mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(files): preserve physical read provenance through cleanup
Keep raw physical read diagnostics alongside explicit operation provenance on the same cleanup owner. Preserve provenance after successful close retry for opted-in readers, restore legacy error projections, and prevent borrowed cleanup-shaped failures from authorizing foreign resource retries. Refs #5686
This commit is contained in:
1 parent
52c84f6f58
commit
56679dd2ae
4 files changed
+256
-13
No files matched your search
@@ -74,7 +74,10 @@ export const retryClientConfigurationCleanup = async (
|
||||
if (retried.cleanup !== undefined) observations.push(retried.cleanup);
|
||||
primary = retried.primary;
|
||||
const last = retried.failures.at(-1)!;
|
||||
nested = last.outcome.kind === "failed" ? last.outcome.cause : undefined;
|
||||
nested =
|
||||
last.operationFailure === undefined && last.outcome.kind === "failed"
|
||||
? last.outcome.cause
|
||||
: undefined;
|
||||
} else {
|
||||
const released = await resources.release({
|
||||
kind: "lease",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RegularFileCancelled,
|
||||
regularFileCancellation,
|
||||
withOwnedRegularFile,
|
||||
OwnedRegularFileOperationFailure,
|
||||
type RegularFileOperationOutcome,
|
||||
} from "../filesystem/OwnedRegularFile.js";
|
||||
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||
@@ -29,12 +30,12 @@ export interface RegularFileReadOptions {
|
||||
readonly cancellationFailure?: (reason: unknown) => Error;
|
||||
}
|
||||
|
||||
/** A regular-file failure returned alongside an enabled cancellation factory. */
|
||||
export class RegularFileReadFailure extends Error {
|
||||
readonly _tag = "RegularFileReadFailure";
|
||||
/** Preserves a physical descriptor-read cause until its owner has finalized. */
|
||||
export class RegularFileReadFailure extends OwnedRegularFileOperationFailure {
|
||||
override readonly _tag = "RegularFileReadFailure";
|
||||
|
||||
constructor(readonly operationCause: unknown) {
|
||||
super("Regular file operation failed", { cause: operationCause });
|
||||
constructor(operationCause: unknown) {
|
||||
super(operationCause);
|
||||
this.name = this._tag;
|
||||
}
|
||||
}
|
||||
@@ -65,6 +66,7 @@ export const retryRegularFileCleanup = async <Value>(
|
||||
}> => {
|
||||
const outcome: RegularFileReadOutcome<Value> =
|
||||
failure.outcome.kind === "failed" &&
|
||||
failure.operationFailure === undefined &&
|
||||
failure.outcome.cause instanceof RegularFileOperationCancelled &&
|
||||
!(signal?.aborted === true && failure.outcome.cause === signal.reason)
|
||||
? { kind: "failed", cause: failure.outcome.cause.reason }
|
||||
@@ -106,7 +108,10 @@ export const retryRegularFileCleanupFailures = async (
|
||||
? retried.outcome.cause
|
||||
: current.cleanupCause;
|
||||
const nested =
|
||||
current.outcome.kind === "failed" ? current.outcome.cause : undefined;
|
||||
current.operationFailure === undefined &&
|
||||
current.outcome.kind === "failed"
|
||||
? current.outcome.cause
|
||||
: undefined;
|
||||
if (!(nested instanceof RegularFileCleanupFailure)) break;
|
||||
current = nested;
|
||||
}
|
||||
@@ -177,16 +182,27 @@ export const withRegularFile = async <Value>(
|
||||
const retry = await retryRegularFileCleanup(cause, resources, signal);
|
||||
if (retry.cleanup !== undefined) throw cause;
|
||||
if (retry.outcome.kind === "failed") {
|
||||
if (signal?.aborted === true && retry.outcome.cause === signal.reason)
|
||||
if (
|
||||
cause.operationFailure === undefined &&
|
||||
signal?.aborted === true &&
|
||||
retry.outcome.cause === signal.reason
|
||||
)
|
||||
throw signal.reason;
|
||||
const cancellation =
|
||||
regularFileCancellation(retry.outcome.cause) ??
|
||||
regularFileCancellation(cause);
|
||||
cause.operationFailure === undefined
|
||||
? (regularFileCancellation(retry.outcome.cause) ??
|
||||
regularFileCancellation(cause))
|
||||
: undefined;
|
||||
if (cancellation !== undefined) {
|
||||
if (options.cancellationFailure !== undefined)
|
||||
throw options.cancellationFailure(cancellation.reason);
|
||||
throw cancellation.reason;
|
||||
}
|
||||
if (
|
||||
cause.operationFailure !== undefined &&
|
||||
options.cancellationFailure !== undefined
|
||||
)
|
||||
throw cause.operationFailure;
|
||||
throw retry.outcome.cause;
|
||||
}
|
||||
return retry.outcome.value;
|
||||
@@ -255,8 +271,8 @@ const readRegularFileContent = <Value>(
|
||||
path: string,
|
||||
decode: (bytes: Buffer, state: Stats) => Value,
|
||||
options: RegularFileReadOptions,
|
||||
): Promise<Value> =>
|
||||
withRegularFile(
|
||||
): Promise<Value> => {
|
||||
const operation = withRegularFile(
|
||||
path,
|
||||
async (handle, stats) => {
|
||||
try {
|
||||
@@ -282,3 +298,9 @@ const readRegularFileContent = <Value>(
|
||||
},
|
||||
options,
|
||||
);
|
||||
if (options.cancellationFailure !== undefined) return operation;
|
||||
return operation.catch((cause: unknown) => {
|
||||
if (cause instanceof RegularFileReadFailure) throw cause.operationCause;
|
||||
throw cause;
|
||||
});
|
||||
};
|
||||
@@ -17,6 +17,16 @@ type CompletedRegularFileOutcome<Value> = Extract<
|
||||
{ readonly kind: "completed" }
|
||||
>;
|
||||
|
||||
/** Physical operation origin kept separate from caller-provided error values. */
|
||||
export class OwnedRegularFileOperationFailure extends Error {
|
||||
readonly _tag: string = "OwnedRegularFileOperationFailure";
|
||||
|
||||
constructor(readonly operationCause: unknown) {
|
||||
super("Owned regular-file operation failed", { cause: operationCause });
|
||||
this.name = this._tag;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancellation after confirmed close, retaining the completed physical read. */
|
||||
export class RegularFileCancelled<Value = unknown> extends Error {
|
||||
readonly _tag = "RegularFileCancelled";
|
||||
@@ -54,6 +64,7 @@ export class RegularFileCleanupFailure<Value = unknown> extends Error {
|
||||
kind: "not_requested",
|
||||
},
|
||||
readonly completionCancellation?: { readonly reason: unknown },
|
||||
readonly operationFailure?: OwnedRegularFileOperationFailure,
|
||||
) {
|
||||
super(`Regular file cleanup failed for ${path}`, {
|
||||
cause: new AggregateError(
|
||||
@@ -78,6 +89,13 @@ export interface OwnedFileOperationOptions {
|
||||
export const regularFileCancellation = (
|
||||
cause: unknown,
|
||||
): { readonly reason: unknown } | undefined => {
|
||||
if (
|
||||
cause instanceof RegularFileCleanupFailure &&
|
||||
cause.operationFailure !== undefined
|
||||
)
|
||||
return cause.outcome.kind === "completed"
|
||||
? cause.completionCancellation
|
||||
: undefined;
|
||||
const primary =
|
||||
cause instanceof RegularFileCleanupFailure &&
|
||||
cause.outcome.kind === "failed"
|
||||
@@ -144,6 +162,18 @@ export const withOwnedFileHandle = async <Value>(
|
||||
try {
|
||||
await owner.close();
|
||||
} catch (cleanupCause: unknown) {
|
||||
const operationFailure =
|
||||
outcome.kind === "failed" &&
|
||||
outcome.cause instanceof OwnedRegularFileOperationFailure
|
||||
? outcome.cause
|
||||
: undefined;
|
||||
const retainedOutcome =
|
||||
operationFailure === undefined
|
||||
? outcome
|
||||
: {
|
||||
kind: "failed" as const,
|
||||
cause: operationFailure.operationCause,
|
||||
};
|
||||
const completionCancellation =
|
||||
outcome.kind === "completed" && options.signal?.aborted === true
|
||||
? { reason: options.signal.reason }
|
||||
@@ -153,10 +183,11 @@ export const withOwnedFileHandle = async <Value>(
|
||||
throw new RegularFileCleanupFailure(
|
||||
path,
|
||||
owner,
|
||||
outcome,
|
||||
retainedOutcome,
|
||||
cleanupCause,
|
||||
retainOwner(options.retainFailedClose, owner),
|
||||
completionCancellation,
|
||||
operationFailure,
|
||||
);
|
||||
}
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
|
||||
@@ -5,7 +5,10 @@ import { expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
withRegularFile,
|
||||
readRegularFile,
|
||||
RegularFileReadFailure,
|
||||
retryRegularFileCleanup,
|
||||
retryRegularFileCleanupFailures,
|
||||
} from "../../../src/application/RegularFileRead.js";
|
||||
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { RegularFileOperationCancelled } from "../../../src/filesystem/RegularFile.js";
|
||||
@@ -88,6 +91,190 @@ it.each(
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves a cleanup-shaped physical read rejection without borrowing its owner", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-file-physical-cleanup-shape-",
|
||||
);
|
||||
const input = join(root, "input.txt");
|
||||
const foreignPath = join(root, "foreign.txt");
|
||||
await writeFile(input, "input");
|
||||
await writeFile(foreignPath, "foreign owner bytes");
|
||||
const foreignOwner = new OwnedFileHandle(await open(foreignPath, "r"));
|
||||
const reason = new RegularFileCleanupFailure(
|
||||
foreignPath,
|
||||
foreignOwner,
|
||||
{ kind: "completed", value: "foreign value" },
|
||||
new Error("foreign cleanup failure"),
|
||||
);
|
||||
const probe = await open(input, "r");
|
||||
const inputIdentity = await probe.stat();
|
||||
const prototype = probe.constructor.prototype as {
|
||||
read: (...arguments_: never[]) => Promise<unknown>;
|
||||
};
|
||||
await probe.close();
|
||||
const originalRead = prototype.read;
|
||||
prototype.read = async function (
|
||||
this: typeof probe,
|
||||
...arguments_: never[]
|
||||
): Promise<unknown> {
|
||||
const state = await this.stat();
|
||||
if (state.dev === inputIdentity.dev && state.ino === inputIdentity.ino)
|
||||
throw reason;
|
||||
return originalRead.apply(this, arguments_);
|
||||
};
|
||||
try {
|
||||
await expect(readRegularFile(input)).rejects.toBe(reason);
|
||||
expect(foreignOwner.handle.fd).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
prototype.read = originalRead;
|
||||
await foreignOwner.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries only the owned close when a cleanup-shaped physical read remains pending", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-file-physical-cleanup-retry-",
|
||||
);
|
||||
const input = join(root, "input.txt");
|
||||
const foreignPath = join(root, "foreign.txt");
|
||||
await writeFile(input, "input");
|
||||
await writeFile(foreignPath, "foreign owner bytes");
|
||||
const foreignOwner = new OwnedFileHandle(await open(foreignPath, "r"));
|
||||
const reason = new RegularFileCleanupFailure(
|
||||
foreignPath,
|
||||
foreignOwner,
|
||||
{ kind: "completed", value: "foreign value" },
|
||||
new Error("foreign cleanup failure"),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const probe = await open(input, "r");
|
||||
const inputIdentity = await probe.stat();
|
||||
const fileHandlePrototype = probe.constructor.prototype as {
|
||||
read: (...arguments_: never[]) => Promise<unknown>;
|
||||
};
|
||||
await probe.close();
|
||||
const originalRead = fileHandlePrototype.read;
|
||||
const ownerPrototype = OwnedFileHandle.prototype;
|
||||
const originalClose = ownerPrototype.close;
|
||||
let closeFailures = 0;
|
||||
fileHandlePrototype.read = async function (
|
||||
this: typeof probe,
|
||||
...arguments_: never[]
|
||||
): Promise<unknown> {
|
||||
const state = await this.stat();
|
||||
if (state.dev === inputIdentity.dev && state.ino === inputIdentity.ino) {
|
||||
controller.abort(reason);
|
||||
throw reason;
|
||||
}
|
||||
return originalRead.apply(this, arguments_);
|
||||
};
|
||||
ownerPrototype.close = async function (this: OwnedFileHandle): Promise<void> {
|
||||
const state = await this.handle.stat();
|
||||
if (state.dev === inputIdentity.dev && state.ino === inputIdentity.ino) {
|
||||
closeFailures += 1;
|
||||
if (closeFailures <= 2)
|
||||
throw new Error(`owned close failure ${closeFailures}`);
|
||||
}
|
||||
return originalClose.call(this);
|
||||
};
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const failure = await readRegularFile(input, {
|
||||
resources,
|
||||
signal: controller.signal,
|
||||
}).catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
|
||||
if (!(failure instanceof RegularFileCleanupFailure))
|
||||
throw new Error("Expected the opened input owner to remain retained");
|
||||
expect(failure.outcome).toMatchObject({ kind: "failed", cause: reason });
|
||||
expect(failure.operationFailure).toBeDefined();
|
||||
|
||||
const retried = await retryRegularFileCleanupFailures(failure, resources);
|
||||
expect(retried.primary).toBe(reason);
|
||||
expect(retried.failures).toEqual([failure]);
|
||||
expect(retried.cleanup).toBeUndefined();
|
||||
expect(closeFailures).toBe(3);
|
||||
expect(foreignOwner.handle.fd).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
fileHandlePrototype.read = originalRead;
|
||||
ownerPrototype.close = originalClose;
|
||||
await resources.retryCleanup();
|
||||
await foreignOwner.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains physical read provenance for an opted-in caller after close retry", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-file-physical-cleanup-recovered-",
|
||||
);
|
||||
const input = join(root, "input.txt");
|
||||
const foreignPath = join(root, "foreign.txt");
|
||||
await writeFile(input, "input");
|
||||
await writeFile(foreignPath, "foreign owner bytes");
|
||||
const foreignOwner = new OwnedFileHandle(await open(foreignPath, "r"));
|
||||
const reason = new RegularFileCleanupFailure(
|
||||
foreignPath,
|
||||
foreignOwner,
|
||||
{ kind: "completed", value: "foreign value" },
|
||||
new Error("foreign cleanup failure"),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const probe = await open(input, "r");
|
||||
const inputIdentity = await probe.stat();
|
||||
const fileHandlePrototype = probe.constructor.prototype as {
|
||||
read: (...arguments_: never[]) => Promise<unknown>;
|
||||
};
|
||||
await probe.close();
|
||||
const originalRead = fileHandlePrototype.read;
|
||||
const ownerPrototype = OwnedFileHandle.prototype;
|
||||
const originalClose = ownerPrototype.close;
|
||||
let closeFailures = 0;
|
||||
let cancellationFactoryCalls = 0;
|
||||
fileHandlePrototype.read = async function (
|
||||
this: typeof probe,
|
||||
...arguments_: never[]
|
||||
): Promise<unknown> {
|
||||
const state = await this.stat();
|
||||
if (state.dev === inputIdentity.dev && state.ino === inputIdentity.ino) {
|
||||
controller.abort(reason);
|
||||
throw reason;
|
||||
}
|
||||
return originalRead.apply(this, arguments_);
|
||||
};
|
||||
ownerPrototype.close = async function (this: OwnedFileHandle): Promise<void> {
|
||||
const state = await this.handle.stat();
|
||||
if (state.dev === inputIdentity.dev && state.ino === inputIdentity.ino) {
|
||||
closeFailures += 1;
|
||||
if (closeFailures === 1) throw new Error("transient owned close failure");
|
||||
}
|
||||
return originalClose.call(this);
|
||||
};
|
||||
const resources = new ArtifactResourceScope();
|
||||
try {
|
||||
const failure = await readRegularFile(input, {
|
||||
resources,
|
||||
signal: controller.signal,
|
||||
cancellationFailure: (abortReason) => {
|
||||
cancellationFactoryCalls += 1;
|
||||
return new Error("cancelled", { cause: abortReason });
|
||||
},
|
||||
}).catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(RegularFileReadFailure);
|
||||
if (!(failure instanceof RegularFileReadFailure))
|
||||
throw new Error("Expected the physical read failure marker");
|
||||
expect(failure.operationCause).toBe(reason);
|
||||
expect(cancellationFactoryCalls).toBe(0);
|
||||
expect(closeFailures).toBe(2);
|
||||
expect(resources.hasPending).toBe(false);
|
||||
expect(foreignOwner.handle.fd).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
fileHandlePrototype.read = originalRead;
|
||||
ownerPrototype.close = originalClose;
|
||||
await resources.retryCleanup();
|
||||
await foreignOwner.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves a nested read cancellation with the same signal=%s",
|
||||
async (sameSignal) => {
|
||||
|
||||
Reference in new issue
Block a user