Files
rea/src/filesystem/OwnedRegularFile.ts
T
morluto e65c3955f9 fix(filesystem): preserve digest failures through cleanup cancellation
Record cancellation at file checkpoints and before owner handoff. Keep physical hash failures, exact reasons, completed facts and failed-close owners distinct through artifact and provider projections and cleanup retries.

Refs #4598. Validated with 566 passing affected tests and 22 platform skips; final Kuna cleanup reason rerun passed 43 tests. No Android device or real JADX/Kuna execution claimed.
2026-10-17 05:47:19 +08:00

183 lines
5.6 KiB
TypeScript

import type { FileHandle } from "node:fs/promises";
import { OwnedFileHandle } from "./OwnedFileHandle.js";
import {
openRegularFile,
RegularFileAdmissionFailure,
assertRegularFileActive,
RegularFileOperationCancelled,
} from "./RegularFile.js";
export type RegularFileOperationOutcome<Value> =
| { readonly kind: "completed"; readonly value: Value }
| { readonly kind: "failed"; readonly cause: unknown };
type CompletedRegularFileOutcome<Value> = Extract<
RegularFileOperationOutcome<Value>,
{ readonly kind: "completed" }
>;
/** Cancellation after confirmed close, retaining the completed physical read. */
export class RegularFileCancelled<Value = unknown> extends Error {
readonly _tag = "RegularFileCancelled";
readonly outcome: CompletedRegularFileOutcome<Value>;
readonly reason: unknown;
constructor(
readonly path: string,
readonly owner: OwnedFileHandle,
completion: {
readonly outcome: CompletedRegularFileOutcome<Value>;
readonly reason: unknown;
},
) {
super(`Regular file operation cancelled after closing ${path}`, {
cause: completion.reason,
});
this.name = this._tag;
this.outcome = completion.outcome;
this.reason = completion.reason;
}
}
/** A failed close transfers its exact descriptor owner and operation outcome. */
export class RegularFileCleanupFailure<Value = unknown> extends Error {
constructor(
readonly path: string,
readonly owner: OwnedFileHandle,
readonly outcome: RegularFileOperationOutcome<Value>,
readonly cleanupCause: unknown,
readonly retention:
| { readonly kind: "not_requested" }
| { readonly kind: "callback_returned" }
| { readonly kind: "callback_threw"; readonly cause: unknown } = {
kind: "not_requested",
},
readonly completionCancellation?: { readonly reason: unknown },
) {
super(`Regular file cleanup failed for ${path}`, {
cause: new AggregateError(
[
...(outcome.kind === "failed" ? [outcome.cause] : []),
cleanupCause,
...(retention.kind === "callback_threw" ? [retention.cause] : []),
],
"File operation and cleanup failures",
),
});
this.name = "RegularFileCleanupFailure";
}
}
export interface OwnedFileOperationOptions {
readonly signal?: AbortSignal;
readonly retainFailedClose?: (owner: OwnedFileHandle) => void;
}
/** Read only cancellation facts recorded by the operation or its final close. */
export const regularFileCancellation = (
cause: unknown,
): { readonly reason: unknown } | undefined => {
const primary =
cause instanceof RegularFileCleanupFailure &&
cause.outcome.kind === "failed"
? cause.outcome.cause
: cause;
if (
primary instanceof RegularFileOperationCancelled ||
primary instanceof RegularFileCancelled
)
return { reason: primary.reason };
return cause instanceof RegularFileCleanupFailure &&
cause.outcome.kind === "completed"
? cause.completionCancellation
: undefined;
};
export interface OwnedRegularFileOptions extends OwnedFileOperationOptions {
readonly symlinks?: "follow" | "reject";
}
/** Run an operation while one admitted descriptor and its cleanup owner stay together. */
export const withOwnedRegularFile = async <Value>(
path: string,
options: OwnedRegularFileOptions,
operation: (handle: FileHandle) => Promise<Value>,
): Promise<Value> => {
const handle = await openRegularFile(path, {
symlinks: options.symlinks ?? "follow",
signal: options.signal,
}).catch((cause: unknown) => {
if (!(cause instanceof RegularFileAdmissionFailure)) throw cause;
const retention = retainOwner(options.retainFailedClose, cause.owner);
throw new RegularFileCleanupFailure(
path,
cause.owner,
{ kind: "failed", cause: cause.cause },
cause.cleanupCause,
retention,
);
});
return withOwnedFileHandle(
path,
new OwnedFileHandle(handle),
options,
operation,
);
};
/** Own an already admitted descriptor through its operation and final close. */
export const withOwnedFileHandle = async <Value>(
path: string,
owner: OwnedFileHandle,
options: OwnedFileOperationOptions,
operation: (handle: FileHandle) => Promise<Value>,
): Promise<Value> => {
const handle = owner.handle;
let outcome: RegularFileOperationOutcome<Value>;
try {
assertRegularFileActive(options.signal);
outcome = { kind: "completed", value: await operation(handle) };
} catch (cause: unknown) {
outcome = { kind: "failed", cause };
}
try {
await owner.close();
} catch (cleanupCause: unknown) {
const completionCancellation =
outcome.kind === "completed" && options.signal?.aborted === true
? { reason: options.signal.reason }
: undefined;
// Retain the physical read outcome even if the caller cancelled during
// close: consumers may need completed observations such as a file digest.
throw new RegularFileCleanupFailure(
path,
owner,
outcome,
cleanupCause,
retainOwner(options.retainFailedClose, owner),
completionCancellation,
);
}
if (outcome.kind === "failed") throw outcome.cause;
if (options.signal?.aborted === true)
throw new RegularFileCancelled(path, owner, {
outcome,
reason: options.signal.reason,
});
return outcome.value;
};
const retainOwner = (
retain: OwnedRegularFileOptions["retainFailedClose"],
owner: OwnedFileHandle,
): RegularFileCleanupFailure["retention"] => {
if (retain === undefined) return { kind: "not_requested" };
try {
retain(owner);
return { kind: "callback_returned" };
} catch (cause: unknown) {
return { kind: "callback_threw", cause };
}
};