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.
This commit is contained in:
morluto
2026-10-17 05:47:19 +08:00
parent 0d22474280
commit e65c3955f9
36 changed files with 854 additions and 234 deletions
+37 -25
View File
@@ -56,11 +56,16 @@ import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.
import { err, ok, type Result } from "../domain/result.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { jsonObjectSchema } from "../domain/jsonValue.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import {
hashStableFile,
stableFileHashCause,
} from "../filesystem/StableFileHash.js";
import {
regularFileCancellation,
RegularFileCancelled,
RegularFileCleanupFailure,
} from "../filesystem/OwnedRegularFile.js";
import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { createEvidence } from "../domain/evidence.js";
import {
@@ -182,9 +187,8 @@ const hashAdbFile = async (
});
} catch (cause: unknown) {
if (
cause !== signal?.reason &&
(cause instanceof RegularFileCleanupFailure ||
cause instanceof RegularFileCancelled)
cause instanceof RegularFileCleanupFailure ||
cause instanceof RegularFileCancelled
)
throw new AdbDigestFailure(
cause as
@@ -194,8 +198,8 @@ const hashAdbFile = async (
closeLease?.bindCleanupError(error);
},
);
if (signal?.aborted === true)
throw new AnalysisCancelledError(operation, { cause });
if (cause instanceof RegularFileOperationCancelled)
throw new AnalysisCancelledError(operation, { cause: cause.reason });
throw cause;
}
};
@@ -203,7 +207,6 @@ const hashAdbFile = async (
const adbDigestError = (
cause: unknown,
operation: string,
signal: AbortSignal | undefined,
partialObservation:
| AdbAcquiredFilePartialObservation
| AdbPackagePullPartialObservation,
@@ -238,12 +241,10 @@ const adbDigestError = (
);
const primaryCause =
digestOutcome.kind === "failed" ? digestOutcome.cause : undefined;
if (
signal?.aborted === true ||
primaryCause instanceof AnalysisCancelledError
) {
const cancellation = regularFileCancellation(digestFailure);
if (cancellation !== undefined) {
const error = new AnalysisCancelledError(operation, {
cause: digestFailure,
cause: cancellation.reason,
cleanup: { reason, resources: [resource] },
partialObservation,
});
@@ -258,7 +259,8 @@ const adbDigestError = (
operation,
`The pulled file could not be fully digested or its handle could not be closed: ${digestFailure.path}`,
{
cause: primaryCause ?? digestFailure,
cause:
digestOutcome.kind === "failed" ? primaryCause : digestFailure,
partialObservation,
},
);
@@ -2085,7 +2087,14 @@ export class AdbProvider {
device_path: string | null;
stage: "resolve_paths" | "pull" | "digest";
message: string;
cause: unknown;
}[] = [];
const observedFailures = () =>
failures.map(({ device_path, stage, message }) => ({
device_path,
stage,
message,
}));
const cancelledPackagePull = (
error: AnalysisCancelledError,
localPath: string,
@@ -2102,7 +2111,7 @@ export class AdbProvider {
output_directory: directory.value,
artifacts,
failures: [
...failures,
...observedFailures(),
{
device_path: devicePath,
stage,
@@ -2139,6 +2148,7 @@ export class AdbProvider {
device_path: entry.device_path,
stage: "pull",
message: pulled.error.message,
cause: pulled.error,
});
continue;
}
@@ -2189,7 +2199,7 @@ export class AdbProvider {
},
],
failures: [
...failures,
...observedFailures(),
{
device_path: entry.device_path,
stage: "digest",
@@ -2207,7 +2217,6 @@ export class AdbProvider {
const cleanupError = adbDigestError(
cause,
operation,
signal,
partialObservation,
);
if (cleanupError !== undefined) return err(cleanupError);
@@ -2218,16 +2227,24 @@ export class AdbProvider {
entry.device_path,
"digest",
);
const physicalCause = stableFileHashCause(cause);
failures.push({
device_path: entry.device_path,
stage: "digest",
message: `The pulled file could not be read back after pulling: ${localPath}`,
message: `The pulled file could not be read back after pulling: ${localPath}: ${physicalCause instanceof Error ? physicalCause.message : String(physicalCause)}`,
cause,
});
}
}
if (artifacts.length === 0 && failures.length > 0) {
const primary = new AnalysisProtocolError(
`Every APK pull failed for ${packageName} on ${serial}: ${failures[0]!.message}`,
{
cause: new AggregateError(
failures.map(({ cause }) => cause),
"APK pulls failed",
),
},
);
return failOwnedPackage(primary);
}
@@ -2239,7 +2256,7 @@ export class AdbProvider {
package_name: packageName,
output_directory: directory.value,
artifacts,
failures,
failures: observedFailures(),
coverage: failures.length === 0 ? "complete" : "partial",
},
provider,
@@ -2330,12 +2347,7 @@ export class AdbProvider {
digest_status: "unknown",
},
};
const cleanupError = adbDigestError(
cause,
operation,
signal,
partialObservation,
);
const cleanupError = adbDigestError(cause, operation, partialObservation);
if (cleanupError !== undefined) return err(cleanupError);
if (cause instanceof AnalysisCancelledError) {
cause.retainPartialObservation(partialObservation);
@@ -3145,7 +3157,7 @@ export class AdbProvider {
const cleanupError =
retainedObservation === undefined
? undefined
: adbDigestError(cause, operation, signal, retainedObservation);
: adbDigestError(cause, operation, retainedObservation);
if (cleanupError !== undefined) return err(cleanupError);
if (cause instanceof AnalysisCancelledError) {
if (retainedObservation !== undefined)
+7 -6
View File
@@ -10,6 +10,7 @@ import {
} from "../domain/analysisErrorCore.js";
import {
openRegularFile,
assertRegularFileActive,
sameRegularFileState,
} from "../filesystem/RegularFile.js";
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
@@ -25,7 +26,7 @@ export const snapshotAndroidTarget = async (
const snapshot = join(root, "target.apk");
try {
await copyAndroidSnapshot(path, snapshot, sha256, signal);
signal?.throwIfAborted();
assertRegularFileActive(signal);
await chmod(snapshot, 0o400);
return snapshot;
} catch (cause: unknown) {
@@ -51,7 +52,7 @@ export const snapshotAndroidEngine = async (
const path = join(root, "engine.jar");
try {
const observed = await copyAndroidSnapshot(source, path, sha256, signal);
signal?.throwIfAborted();
assertRegularFileActive(signal);
await chmod(path, 0o400);
return { path, sha256: observed };
} catch (cause: unknown) {
@@ -71,7 +72,7 @@ const copyAndroidSnapshot = async (
expectedSha256: string,
signal?: AbortSignal,
): Promise<string> => {
signal?.throwIfAborted();
assertRegularFileActive(signal);
const source = await openRegularFile(sourcePath, {
symlinks: "follow",
signal,
@@ -84,7 +85,7 @@ const copyAndroidSnapshot = async (
const hash = createHash("sha256");
try {
const initial = await source.stat();
signal?.throwIfAborted();
assertRegularFileActive(signal);
const digest = new Transform({
transform(
chunk: Buffer,
@@ -106,7 +107,7 @@ const copyAndroidSnapshot = async (
});
try {
destination = await open(snapshotPath, "wx", 0o600);
signal?.throwIfAborted();
assertRegularFileActive(signal);
inputStream = source.createReadStream({
start: 0,
end: initial.size,
@@ -114,7 +115,7 @@ const copyAndroidSnapshot = async (
});
outputStream = destination.createWriteStream({ autoClose: false });
await pipeline(inputStream, digest, outputStream, { signal });
signal?.throwIfAborted();
assertRegularFileActive(signal);
await verifySourceState(source, sourcePath, initial, copiedBytes);
const observedSha256 = hash.digest("hex");
if (observedSha256 !== expectedSha256)
+42 -31
View File
@@ -27,10 +27,17 @@ import {
snapshotAndroidEngine,
snapshotAndroidTarget,
} from "./AndroidTargetSnapshot.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import {
hashStableFile,
stableFileHashCause,
StableFileHashFailure,
} from "../filesystem/StableFileHash.js";
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import {
RegularFileCleanupFailure,
regularFileCancellation,
} from "../filesystem/OwnedRegularFile.js";
import {
inspectJadxAvailability,
resolveJadxConfigurationInputs,
@@ -70,35 +77,33 @@ const executionError = (context: {
session: JadxSession | undefined;
}): AnalysisError => {
const { cause, request, timeout, timeoutMs, signal, session } = context;
const cancellation = regularFileCancellation(cause);
const cancellationError =
cancellation === undefined
? undefined
: timeout.aborted && cancellation.reason === timeout.reason
? new AnalysisTimeoutError(request.operation, timeoutMs, {
cause: cancellation.reason,
})
: new AnalysisCancelledError(request.operation, {
cause: cancellation.reason,
});
if (cause instanceof RegularFileCleanupFailure) {
const primaryCause =
cause.outcome.kind === "failed"
? cause.outcome.cause
: signal?.aborted
? signal.reason
: timeout.aborted
? timeout.reason
: undefined;
const primaryOptions =
primaryCause === undefined ? {} : { cause: primaryCause };
? stableFileHashCause(cause.outcome.cause)
: cause;
const primary =
signal?.aborted === true
? new AnalysisCancelledError(request.operation, primaryOptions)
: timeout.aborted
? new AnalysisTimeoutError(
request.operation,
timeoutMs,
primaryOptions,
)
: primaryCause instanceof AnalysisError
? primaryCause
: new ProviderAdapterError("jadx", request.operation, {
cause: primaryCause ?? cause,
userMessage:
primaryCause instanceof Error
? primaryCause.message
: cause.message,
});
cancellationError ??
(primaryCause instanceof AnalysisError
? primaryCause
: new ProviderAdapterError("jadx", request.operation, {
cause: primaryCause,
userMessage:
primaryCause instanceof Error
? primaryCause.message
: String(primaryCause),
}));
const cleanup = new ProviderCleanupError(
"jadx",
[cause.path],
@@ -112,11 +117,17 @@ const executionError = (context: {
);
return analysisErrorWithCleanupFailure(primary, cleanup, request.operation);
}
if (cancellationError !== undefined) return cancellationError;
if (cause instanceof StableFileHashFailure)
return new ProviderAdapterError("jadx", request.operation, {
cause: cause.cause,
userMessage: cause.message,
});
if (cause instanceof AnalysisError) return cause;
if (signal?.aborted === true)
return new AnalysisCancelledError(request.operation, { cause });
if (timeout.aborted)
return new AnalysisTimeoutError(request.operation, timeoutMs);
if (cause instanceof AnalysisError) return cause;
const protocolFailure = session?.transport.failureReason();
if (protocolFailure !== undefined && protocolFailure !== null)
return new AnalysisOutputError(request.operation, protocolFailure, {
@@ -193,9 +204,9 @@ const hashAndroidSource = async (
try {
return (await hashStableFile(path, { signal, retainFailedClose })).sha256;
} catch (cause: unknown) {
if (signal.aborted) throw cause;
if (cause instanceof RegularFileChangedError)
throw androidSourceChanged(operation, path, cause);
const physical = stableFileHashCause(cause);
if (physical instanceof RegularFileChangedError)
throw androidSourceChanged(operation, path, physical);
throw cause;
}
};
+10
View File
@@ -1,3 +1,4 @@
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
import { createHash, randomUUID } from "node:crypto";
import { createWriteStream } from "node:fs";
import {
@@ -846,6 +847,15 @@ export class ApktoolProvider {
);
} catch (cause) {
await removeWorkspace();
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined)
return err(
failureWithCleanup(
new AnalysisCancelledError(operation, {
cause: cancellation.reason,
}),
),
);
return err(
failureWithCleanup(
signal?.aborted === true
+8
View File
@@ -1,3 +1,4 @@
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
import { constants, type Stats } from "node:fs";
import { createHash } from "node:crypto";
import {
@@ -224,6 +225,13 @@ export const parseBinaryTarget = async (
}
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) return err(cause);
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined)
return err(
new AnalysisCancelledError("resolve_binary_target", {
cause: cancellation.reason,
}),
);
if (signal?.aborted === true && cause === signal.reason)
return err(
new AnalysisCancelledError("resolve_binary_target", { cause }),
+29 -8
View File
@@ -10,9 +10,16 @@ import {
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { err, ok, type Result } from "../domain/result.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import {
hashStableFile,
StableFileHashFailure,
} from "../filesystem/StableFileHash.js";
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import {
RegularFileCleanupFailure,
RegularFileCancelled,
regularFileCancellation,
} from "../filesystem/OwnedRegularFile.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
import { verifyDetachedPgpSignature } from "../process/DetachedPgpSignature.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
@@ -705,8 +712,17 @@ export class LinuxHopperPublisher {
},
);
}
if (hashFailure.outcome.kind === "failed")
throw hashFailure.outcome.cause;
if (hashFailure.outcome.kind === "failed") {
if (regularFileCancellation(hashFailure) !== undefined)
throw hashFailure.outcome.cause;
throw new StableFileHashFailure(path, hashFailure.outcome.cause);
}
const cancellation = regularFileCancellation(hashFailure);
if (cancellation !== undefined)
throw new RegularFileCancelled(path, hashFailure.owner, {
outcome: hashFailure.outcome,
reason: cancellation.reason,
});
return hashFailure.outcome.value;
}
}
@@ -1084,21 +1100,26 @@ const releaseFailure = (
signal: AbortSignal | undefined,
fallback: HopperReleaseError["reason"],
): HopperReleaseError => {
if (cause instanceof HopperReleaseError && cause.reason === "cleanup_failed")
return cause;
if (cause instanceof HopperReleaseError) return cause;
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
return new HopperReleaseError(
"cleanup_failed",
"Hopper publisher cleanup remains incomplete.",
{ cause },
);
if (signal?.aborted === true)
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined)
return new HopperReleaseError(
"cancelled",
"Hopper publisher verification was cancelled.",
{ cause: cancellation.reason },
);
if (!(cause instanceof StableFileHashFailure) && signal?.aborted === true)
return new HopperReleaseError(
"cancelled",
"Hopper publisher verification was cancelled.",
{ cause },
);
if (cause instanceof HopperReleaseError) return cause;
return new HopperReleaseError(
fallback,
"Hopper publisher verification could not complete.",
@@ -112,8 +112,8 @@ const buildIgnored = async (
const retry = await retryRegularFileCleanup(cause, resources);
if (retry.cleanup !== undefined) {
const primary =
cause.outcome.kind === "failed"
? cause.outcome.cause
retry.outcome.kind === "failed"
? retry.outcome.cause
: cause.cleanupCause;
return err({
...failure(
+13 -5
View File
@@ -4,6 +4,8 @@ import { lstat, stat, type FileHandle } from "node:fs/promises";
import {
sameRegularFileState,
RegularFileChangedError,
RegularFileOperationCancelled,
assertRegularFileActive,
} from "../filesystem/RegularFile.js";
import {
RegularFileCleanupFailure,
@@ -45,14 +47,19 @@ export const retryRegularFileCleanup = async <Value>(
readonly outcome: RegularFileReadOutcome<Value>;
readonly cleanup?: AnalysisCleanupObservation;
}> => {
const outcome: RegularFileReadOutcome<Value> =
failure.outcome.kind === "failed" &&
failure.outcome.cause instanceof RegularFileOperationCancelled
? { kind: "failed", cause: failure.outcome.cause.reason }
: failure.outcome;
const released = await resources.release({
kind: "file-handle",
handle: failure.owner,
resource: failure.path,
});
if (released.kind === "released") return { outcome: failure.outcome };
if (released.kind === "released") return { outcome };
return {
outcome: failure.outcome,
outcome,
cleanup: {
reason: `${failureMessage(failure.cleanupCause)}; retry: ${failureMessage(released.cause)}`,
resources: [failure.path],
@@ -82,9 +89,9 @@ export const withRegularFile = async <Value>(
async (handle) => {
admitted = handle;
const stats = await handle.stat();
signal?.throwIfAborted();
assertRegularFileActive(signal);
const value = await read(handle, stats);
signal?.throwIfAborted();
assertRegularFileActive(signal);
const [opened, currentPath] = await Promise.all([
handle.stat(),
options.symlinks === "reject" ? lstat(path) : stat(path),
@@ -94,11 +101,12 @@ export const withRegularFile = async <Value>(
!sameRegularFileState(stats, currentPath)
)
throw new RegularFileChangedError(path);
assertRegularFileActive(signal);
return value;
},
);
} catch (cause: unknown) {
if (signal?.aborted === true && cause === signal.reason) throw cause;
if (cause instanceof RegularFileOperationCancelled) throw cause.reason;
if (
cause instanceof RegularFileCancelled &&
cause.owner.handle === admitted
@@ -74,6 +74,8 @@ export class ClientConfigurationCleanupRollbackFailure extends RegularFileCleanu
[failure.cleanupCause, new Error(configurationFailure.failure.detail)],
`Configuration readback cleanup and rollback failed: ${configurationFailure.failure.path}`,
),
failure.retention,
failure.completionCancellation,
);
}
}
+3 -3
View File
@@ -243,11 +243,11 @@ const readCapture = async (
incomplete: retry.cleanup !== undefined,
cause: cause.cleanupCause,
};
if (cause.outcome.kind === "completed")
return ok({ bytes: cause.outcome.value as Buffer, cleanup });
if (retry.outcome.kind === "completed")
return ok({ bytes: retry.outcome.value as Buffer, cleanup });
const primary = captureReadFailure(
path,
cause.outcome.cause,
retry.outcome.cause,
signal,
retry.cleanup === undefined ? { cause: cause.cleanupCause } : undefined,
);
+35 -20
View File
@@ -1,4 +1,5 @@
import type { FileHandle } from "node:fs/promises";
import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import {
withOwnedFileHandle,
@@ -22,11 +23,25 @@ export class ArtifactFileCleanupFailure<
Value = unknown,
> extends ArtifactReaderFailure {
readonly cleanupLeases: readonly AnalysisCleanupLease[] | undefined;
readonly fileCleanupFailure: RegularFileCleanupFailure<Value>;
constructor(
readonly fileCleanupFailure: RegularFileCleanupFailure<Value>,
fileCleanupFailure: RegularFileCleanupFailure<Value>,
readonly cleanupLease: AnalysisCleanupLease,
readonly completionCancellation?: { readonly reason: unknown },
) {
const outcome = fileCleanupFailure.outcome;
if (
outcome.kind === "failed" &&
outcome.cause instanceof RegularFileOperationCancelled
)
fileCleanupFailure = new RegularFileCleanupFailure(
fileCleanupFailure.path,
fileCleanupFailure.owner,
{ kind: "failed", cause: artifactOperationCause(outcome.cause) },
fileCleanupFailure.cleanupCause,
fileCleanupFailure.retention,
fileCleanupFailure.completionCancellation,
);
const completionCancellation = fileCleanupFailure.completionCancellation;
const primary =
fileCleanupFailure.outcome.kind === "failed"
? fileCleanupFailure.outcome.cause
@@ -60,6 +75,7 @@ export class ArtifactFileCleanupFailure<
},
projected.details,
);
this.fileCleanupFailure = fileCleanupFailure;
const inherited =
primary instanceof ArtifactFileCleanupFailure ||
primary instanceof ArtifactResourceRetryFailure
@@ -95,9 +111,9 @@ export const mapArtifactFileFailure = (
{ kind: "failed", cause: primary },
physical.cleanupCause,
physical.retention,
physical.completionCancellation,
),
cause.cleanupLease,
cause.completionCancellation,
);
};
@@ -175,6 +191,7 @@ export const withArtifactRegularFile = async <Value>(
path,
{
symlinks,
...(signal === undefined ? {} : { signal }),
retainFailedClose: (owner) => {
cleanupLease = new AnalysisCleanupLease(() => owner.close(), [path]);
resources.retain({
@@ -189,23 +206,16 @@ export const withArtifactRegularFile = async <Value>(
return await operation(file);
},
);
if (signal?.aborted)
throw new ArtifactFileCancelled({
reason: signal.reason,
outcome: { kind: "completed", value },
});
return value;
} catch (cause: unknown) {
if (
cause instanceof RegularFileCleanupFailure &&
cleanupLease !== undefined
)
throw new ArtifactFileCleanupFailure(
cause,
cleanupLease,
signal?.aborted ? { reason: signal.reason } : undefined,
);
throw cause;
throw new ArtifactFileCleanupFailure(cause, cleanupLease);
if (cause instanceof RegularFileCancelled)
throw new ArtifactFileCancelled(cause);
throw artifactOperationCause(cause);
}
};
@@ -236,13 +246,18 @@ export const withArtifactFileHandle = async <Value>(
);
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure)
throw new ArtifactFileCleanupFailure(
cause,
cleanupLease,
signal?.aborted ? { reason: signal.reason } : undefined,
);
throw new ArtifactFileCleanupFailure(cause, cleanupLease);
if (cause instanceof RegularFileCancelled)
throw new ArtifactFileCancelled(cause);
throw cause;
throw artifactOperationCause(cause);
}
};
const artifactOperationCause = (cause: unknown): unknown =>
cause instanceof RegularFileOperationCancelled
? new ArtifactReaderFailure(
"cancelled",
"Artifact file operation cancelled",
{ cause: cause.reason },
)
: cause;
+8 -5
View File
@@ -1,3 +1,4 @@
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
import { mkdtemp, realpath, rm, type FileHandle } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
@@ -597,10 +598,11 @@ export class NativeDmgArtifactReader implements ArtifactReader {
} catch (cause: unknown) {
if (cause instanceof RegularFileAdmissionFailure) {
this.#verificationProbeHandle = cause.owner;
const cancellation = regularFileCancellation(cause.cause);
const failure =
signal?.aborted === true
? this.#cancelledVerification(signal.reason)
: primaryFailure;
cancellation === undefined
? primaryFailure
: this.#cancelledVerification(cancellation.reason);
throw ArtifactReaderFailure.withCleanup(
failure,
ArtifactReaderFailure.cleanupObservation(
@@ -609,8 +611,9 @@ export class NativeDmgArtifactReader implements ArtifactReader {
),
);
}
if (signal?.aborted === true)
throw this.#cancelledVerification(signal.reason);
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined)
throw this.#cancelledVerification(cancellation.reason);
if (cause instanceof NonRegularFileReadError) return false;
return classifyCommandFailure(cause, undefined).reason === "io";
}
+7 -3
View File
@@ -7,6 +7,7 @@ import {
NonRegularFileReadError,
openRegularFile,
RegularFileAdmissionFailure,
RegularFileOperationCancelled,
sameRegularFileState,
} from "../filesystem/RegularFile.js";
@@ -65,7 +66,10 @@ it.each(["cancellation after open", "non-regular metadata", "stat failure"])(
expect(cause.cause).toBeInstanceOf(NonRegularFileReadError);
else if (failureCase === "stat failure")
expect(cause.cause).toBe(statFailure);
else expect(cause.cause).toBe(cancellation);
else {
expect(cause.cause).toBeInstanceOf(RegularFileOperationCancelled);
expect(cause.cause).toMatchObject({ reason: cancellation });
}
},
);
@@ -107,7 +111,7 @@ it.each([
},
);
it("keeps cancellation that arrives during the non-regular metadata probe", async () => {
it("preserves the non-regular admission failure when cancellation arrives during its metadata probe", async () => {
const controller = new AbortController();
const cancellation = new Error("cancelled during probe");
mockOpen.mockImplementation(async () => {
@@ -120,5 +124,5 @@ it("keeps cancellation that arrives during the non-regular metadata probe", asyn
symlinks: "follow",
signal: controller.signal,
}),
).rejects.toBe(cancellation);
).rejects.toBeInstanceOf(NonRegularFileReadError);
});
@@ -1,3 +1,4 @@
import { regularFileCancellation } from "../../filesystem/OwnedRegularFile.js";
import { lstat } from "node:fs/promises";
import type { Stats } from "node:fs";
import type { FileHandle } from "node:fs/promises";
@@ -31,14 +32,21 @@ export const openRootArtifact = async (
} catch (cause: unknown) {
const primary =
cause instanceof RegularFileAdmissionFailure ? cause.cause : cause;
const cancellation = regularFileCancellation(primary);
const failure =
primary instanceof NonRegularFileReadError
cancellation !== undefined
? new ArtifactReaderFailure(
"format",
`Artifact root is not a regular file: ${path}`,
{ cause: primary },
"cancelled",
"Artifact admission cancelled",
{ cause: cancellation.reason },
)
: primary;
: primary instanceof NonRegularFileReadError
? new ArtifactReaderFailure(
"format",
`Artifact root is not a regular file: ${path}`,
{ cause: primary },
)
: primary;
if (cause instanceof RegularFileAdmissionFailure) {
const resource = `root artifact descriptor for ${path}`;
const cleanup = await resourceScope.release({
+2 -2
View File
@@ -1049,9 +1049,9 @@ const descriptorFailure = (
? descriptorFailure(outcome.cause, operation)
: outcome?.kind === "completed" &&
cause instanceof ArtifactFileCleanupFailure &&
cause.completionCancellation !== undefined
cause.fileCleanupFailure.completionCancellation !== undefined
? new AnalysisCancelledError(operation, {
cause: cause.completionCancellation.reason,
cause: cause.fileCleanupFailure.completionCancellation.reason,
})
: undefined;
if (primary === undefined && outcome?.kind !== "failed") return cleanup;
+8 -1
View File
@@ -1,3 +1,4 @@
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
import type { ElectronFusePort } from "../application/electron/ElectronFusePort.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
@@ -93,7 +94,13 @@ const failureFor = (
signal?: AbortSignal,
): AnalysisError => {
if (cause instanceof AnalysisError) return cause;
if (signal?.aborted) return new AnalysisCancelledError(OPERATION);
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined)
return new AnalysisCancelledError(OPERATION, {
cause: cancellation.reason,
});
if (signal?.aborted)
return new AnalysisCancelledError(OPERATION, { cause: signal.reason });
if (cause instanceof ElectronFusePathFailure)
return new AnalysisInputError(OPERATION, { cause }, [
{ path: ["path"], reason: cause.reason, message: cause.message },
+33 -1
View File
@@ -1,7 +1,12 @@
import type { FileHandle } from "node:fs/promises";
import { OwnedFileHandle } from "./OwnedFileHandle.js";
import { openRegularFile, RegularFileAdmissionFailure } from "./RegularFile.js";
import {
openRegularFile,
RegularFileAdmissionFailure,
assertRegularFileActive,
RegularFileOperationCancelled,
} from "./RegularFile.js";
export type RegularFileOperationOutcome<Value> =
| { readonly kind: "completed"; readonly value: Value }
@@ -48,6 +53,7 @@ export class RegularFileCleanupFailure<Value = unknown> extends Error {
| { 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(
@@ -68,6 +74,26 @@ export interface OwnedFileOperationOptions {
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";
}
@@ -110,6 +136,7 @@ export const withOwnedFileHandle = async <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 };
@@ -117,6 +144,10 @@ export const withOwnedFileHandle = async <Value>(
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(
@@ -125,6 +156,7 @@ export const withOwnedFileHandle = async <Value>(
outcome,
cleanupCause,
retainOwner(options.retainFailedClose, owner),
completionCancellation,
);
}
if (outcome.kind === "failed") throw outcome.cause;
+18 -6
View File
@@ -5,6 +5,20 @@ import { lstat, open, stat, type FileHandle } from "node:fs/promises";
import { OwnedFileHandle } from "./OwnedFileHandle.js";
/** Cancellation observed at a file operation's explicit checkpoint. */
export class RegularFileOperationCancelled extends Error {
readonly _tag = "RegularFileOperationCancelled";
constructor(readonly reason: unknown) {
super("Regular file operation cancelled", { cause: reason });
this.name = this._tag;
}
}
export const assertRegularFileActive = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new RegularFileOperationCancelled(signal.reason);
};
/** Identifies a selected path that cannot be read as one regular file. */
export class NonRegularFileReadError extends Error {
readonly code: "EISDIR" | "ENOTFILE";
@@ -60,7 +74,7 @@ export const openRegularFile = async (
readonly signal?: AbortSignal | undefined;
},
): Promise<FileHandle> => {
options.signal?.throwIfAborted();
assertRegularFileActive(options.signal);
const handle = await open(
path,
constants.O_RDONLY |
@@ -74,26 +88,24 @@ export const openRegularFile = async (
cause,
);
if (nonRegular === undefined) throw cause;
// Cancellation during the metadata probe outranks the reclassification.
options.signal?.throwIfAborted();
throw nonRegular;
});
const owner = new OwnedFileHandle(handle);
try {
options.signal?.throwIfAborted();
assertRegularFileActive(options.signal);
const metadata = await handle.stat();
options.signal?.throwIfAborted();
if (!metadata.isFile())
throw new NonRegularFileReadError(path, metadata.isDirectory());
assertRegularFileActive(options.signal);
// O_NOFOLLOW is unavailable on Windows. Recheck the name only on hosts
// where open could have followed a symlink, while the descriptor is owned.
if (options.symlinks === "reject" && (constants.O_NOFOLLOW ?? 0) === 0) {
const named = await lstat(path);
options.signal?.throwIfAborted();
if (named.isSymbolicLink())
throw new NonRegularFileReadError(path, false);
if (!sameRegularFileState(metadata, named))
throw new RegularFileChangedError(path);
assertRegularFileActive(options.signal);
}
return handle;
} catch (cause: unknown) {
+74 -34
View File
@@ -1,9 +1,15 @@
import { createHash } from "node:crypto";
import { stat } from "node:fs/promises";
import { withOwnedRegularFile } from "./OwnedRegularFile.js";
import {
withOwnedRegularFile,
RegularFileCancelled,
RegularFileCleanupFailure,
} from "./OwnedRegularFile.js";
import {
RegularFileChangedError,
RegularFileOperationCancelled,
assertRegularFileActive,
sameRegularFileState,
} from "./RegularFile.js";
import type { OwnedFileHandle } from "./OwnedFileHandle.js";
@@ -13,42 +19,76 @@ export interface StableFileHashOptions {
readonly retainFailedClose: (owner: OwnedFileHandle) => void;
}
/** Physical hash admission/read failure with no outstanding descriptor cleanup. */
export class StableFileHashFailure extends Error {
readonly _tag = "StableFileHashFailure";
constructor(
readonly path: string,
override readonly cause: unknown,
) {
super(
`Could not hash ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
{ cause },
);
this.name = this._tag;
}
}
/** Read the producing boundary's physical failure without guessing from signal state. */
export const stableFileHashCause = (cause: unknown): unknown =>
cause instanceof StableFileHashFailure ? cause.cause : cause;
/** Hash one regular file from its admitted descriptor and verify its path state. */
export const hashStableFile = async (
path: string,
options: StableFileHashOptions,
): Promise<{ readonly sha256: string; readonly bytes: number }> =>
withOwnedRegularFile(path, options, async (handle) => {
const initial = await handle.stat();
if (!Number.isSafeInteger(initial.size) || initial.size < 0)
throw new RangeError(
`Regular file size cannot be represented safely: ${path}`,
);
const hash = createHash("sha256");
const chunk = Buffer.allocUnsafe(64 * 1024);
let position = 0;
while (position <= initial.size) {
options.signal?.throwIfAborted();
const length = Math.min(chunk.length, initial.size - position + 1);
if (length <= 0) break;
const read = await handle.read(chunk, 0, length, position);
options.signal?.throwIfAborted();
if (read.bytesRead === 0) break;
hash.update(chunk.subarray(0, read.bytesRead));
position += read.bytesRead;
if (position > initial.size) throw new RegularFileChangedError(path);
}
options.signal?.throwIfAborted();
const [opened, currentPath] = await Promise.all([
handle.stat(),
stat(path),
]);
options.signal?.throwIfAborted();
): Promise<{ readonly sha256: string; readonly bytes: number }> => {
try {
return await withOwnedRegularFile(path, options, async (handle) => {
const initial = await handle.stat();
if (!Number.isSafeInteger(initial.size) || initial.size < 0)
throw new RangeError(
`Regular file size cannot be represented safely: ${path}`,
);
const hash = createHash("sha256");
const chunk = Buffer.allocUnsafe(64 * 1024);
let position = 0;
while (position <= initial.size) {
assertRegularFileActive(options.signal);
const length = Math.min(chunk.length, initial.size - position + 1);
if (length <= 0) break;
const read = await handle.read(chunk, 0, length, position);
if (
position + read.bytesRead > initial.size ||
(read.bytesRead === 0 && position < initial.size)
)
throw new RegularFileChangedError(path);
assertRegularFileActive(options.signal);
if (read.bytesRead === 0) break;
hash.update(chunk.subarray(0, read.bytesRead));
position += read.bytesRead;
}
assertRegularFileActive(options.signal);
const [opened, currentPath] = await Promise.all([
handle.stat(),
stat(path),
]);
if (
position !== initial.size ||
!sameRegularFileState(initial, opened) ||
!sameRegularFileState(initial, currentPath)
)
throw new RegularFileChangedError(path);
assertRegularFileActive(options.signal);
return { sha256: hash.digest("hex"), bytes: position };
});
} catch (cause: unknown) {
if (
position !== initial.size ||
!sameRegularFileState(initial, opened) ||
!sameRegularFileState(initial, currentPath)
cause instanceof RegularFileCleanupFailure ||
cause instanceof RegularFileCancelled ||
cause instanceof RegularFileOperationCancelled
)
throw new RegularFileChangedError(path);
return { sha256: hash.digest("hex"), bytes: position };
});
throw cause;
throw new StableFileHashFailure(path, cause);
}
};
+2 -2
View File
@@ -60,10 +60,10 @@ export const firmwareFailure = (
deadline,
treeCleanupLease,
)
: cause.completionCancellation !== undefined
: cause.fileCleanupFailure.completionCancellation !== undefined
? firmwareCancellation(
operation,
cause.completionCancellation.reason,
cause.fileCleanupFailure.completionCancellation.reason,
deadline,
)
: undefined;
+11
View File
@@ -1,3 +1,4 @@
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
import { createHash } from "node:crypto";
import { chmod, mkdtemp, open as openFile, rm, stat } from "node:fs/promises";
import { createReadStream } from "node:fs";
@@ -165,6 +166,11 @@ const mapInputFailure = (
cause: unknown,
): AnalysisError => {
if (cause instanceof AnalysisError) return cause;
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined)
return new AnalysisCancelledError(operation, {
cause: cancellation.reason,
});
if (isCancellation(cause))
return new AnalysisCancelledError(operation, { cause });
if (
@@ -212,6 +218,11 @@ const mapOwnedIoFailure = (
cause: unknown,
): AnalysisError => {
if (cause instanceof AnalysisError) return cause;
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined)
return new AnalysisCancelledError(operation, {
cause: cancellation.reason,
});
if (isCancellation(cause))
return new AnalysisCancelledError(operation, { cause });
if (
+16 -5
View File
@@ -1,3 +1,4 @@
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import type { Stats } from "node:fs";
import type { FileHandle } from "node:fs/promises";
@@ -64,6 +65,7 @@ import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
import {
openRegularFile,
assertRegularFileActive,
RegularFileChangedError,
RegularFileAdmissionFailure,
sameRegularFileState,
@@ -453,13 +455,13 @@ export class GhidraClient {
let result: Result<T, unknown>;
try {
const initial = await handle.stat();
signal?.throwIfAborted();
assertRegularFileActive(signal);
if (!Number.isSafeInteger(initial.size) || initial.size < 0)
throw new RangeError("Snapshot size must be a safe integer");
const value = await read(handle, initial, path);
signal?.throwIfAborted();
assertRegularFileActive(signal);
const after = await handle.stat();
signal?.throwIfAborted();
assertRegularFileActive(signal);
if (!sameRegularFileState(initial, after))
throw new RegularFileChangedError(path);
result = ok(value);
@@ -480,11 +482,20 @@ export class GhidraClient {
} catch (cause: unknown) {
if (cause instanceof RegularFileAdmissionFailure)
this.#snapshotOwners.add(cause.owner);
const primary =
cause instanceof RegularFileAdmissionFailure ? cause.cause : cause;
const cancellation = regularFileCancellation(primary);
return err(
this.#failure(
signal?.aborted === true ? "cancelled" : "protocol",
cancellation !== undefined || signal?.aborted === true
? "cancelled"
: "protocol",
"Ghidra target snapshot could not be read for load-image verification",
cause,
cancellation === undefined ||
cause instanceof RegularFileAdmissionFailure
? cause
: cancellation.reason,
cancellation === undefined ? {} : { cancellation },
),
);
}
+6 -1
View File
@@ -1,3 +1,4 @@
import { regularFileCancellation } from "../../filesystem/OwnedRegularFile.js";
import { createHash } from "node:crypto";
import { lstat, mkdir, opendir, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -37,7 +38,11 @@ const scanRecoveryFile = async (
// A descriptor whose close failed stays owned by its error, even when
// cancellation was also requested.
if (cause instanceof RegularFileAdmissionFailure) throw cause;
checkRecoveryCancellation(signal);
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined)
throw new AnalysisCancelledError(OPERATION, {
cause: cancellation.reason,
});
if (cause instanceof NonRegularFileReadError)
throw new AnalysisOutputError(
OPERATION,
+28 -31
View File
@@ -19,7 +19,10 @@ import {
} from "../process/OwnedCommand.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import {
RegularFileCleanupFailure,
regularFileCancellation,
} from "../filesystem/OwnedRegularFile.js";
import type {
OwnedProviderProcessSpawnOptions,
ProviderProcessSnapshot,
@@ -92,7 +95,6 @@ const message = (cause: unknown): string =>
export const kunaFileCleanupError = (
failure: RegularFileCleanupFailure,
operation: string,
signal?: AbortSignal,
): AnalysisError => {
const cleanup = new ProviderCleanupError(
KUNA_PROVIDER_ID,
@@ -100,33 +102,24 @@ export const kunaFileCleanupError = (
{ reason: message(failure.cleanupCause) },
{ cause: failure, operation },
);
const cancellation = regularFileCancellation(failure);
if (cancellation !== undefined)
return new AnalysisCancelledError(operation, {
cause: cancellation.reason,
cleanup: {
reason: message(failure.cleanupCause),
resources: [failure.path],
},
});
if (failure.outcome.kind === "completed") return cleanup;
const primaryCause = failure.outcome.cause;
const primary =
primaryCause instanceof AnalysisError
? primaryCause
: signal?.aborted === true
? new AnalysisCancelledError(operation, { cause: primaryCause })
: new ProviderAdapterError(KUNA_PROVIDER_ID, operation, {
cause: primaryCause,
userMessage: `Kuna ${operation} failed: ${message(primaryCause)}`,
});
if (primary instanceof AnalysisCancelledError)
return new AnalysisCancelledError(operation, {
cause: new AggregateError(
[primary, cleanup],
"Kuna operation was cancelled and descriptor cleanup failed",
{ cause: primary },
),
cleanup: {
reason: cleanup.message,
resources: [failure.path],
},
...(primary.capturedOutput === undefined
? {}
: { capturedOutput: primary.capturedOutput }),
partialObservation: primary.partialObservation,
});
: new ProviderAdapterError(KUNA_PROVIDER_ID, operation, {
cause: primaryCause,
userMessage: `Kuna ${operation} failed: ${message(primaryCause)}`,
});
return analysisErrorWithCleanupFailure(primary, cleanup, operation);
};
@@ -194,18 +187,22 @@ export const runKunaCommand = async (
if (cause instanceof RegularFileCleanupFailure) {
return {
kind: "failed",
error: kunaFileCleanupError(cause, request.operation, request.signal),
error: kunaFileCleanupError(cause, request.operation),
fileCleanupFailure: cause,
};
}
const cancellation = regularFileCancellation(cause);
return {
kind: "failed",
error: request.signal.aborted
? new AnalysisCancelledError(request.operation, { cause })
: new ProviderAdapterError(KUNA_PROVIDER_ID, request.operation, {
cause,
userMessage: `Cannot verify ${installation.executable}: ${message(cause)}`,
}),
error:
cancellation !== undefined
? new AnalysisCancelledError(request.operation, {
cause: cancellation.reason,
})
: new ProviderAdapterError(KUNA_PROVIDER_ID, request.operation, {
cause,
userMessage: `Cannot verify ${installation.executable}: ${message(cause)}`,
}),
};
}
try {
+15 -7
View File
@@ -1,3 +1,4 @@
import { assertRegularFileActive } from "../filesystem/RegularFile.js";
import { constants } from "node:fs";
import { access, realpath, stat } from "node:fs/promises";
import { join } from "node:path";
@@ -6,10 +7,14 @@ import type { JsonValue } from "../domain/jsonValue.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../domain/result.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import {
hashStableFile,
stableFileHashCause,
} from "../filesystem/StableFileHash.js";
import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js";
import {
RegularFileCleanupFailure,
regularFileCancellation,
withOwnedRegularFile,
} from "../filesystem/OwnedRegularFile.js";
import {
@@ -149,7 +154,7 @@ export const inspectKunaInstallation = async (
{ command },
);
} catch (cause: unknown) {
options.signal?.throwIfAborted();
assertRegularFileActive(options.signal);
return failure(
"executable_missing",
`Cannot execute REA_KUNA_COMMAND ${command}: ${message(cause)}. ${KUNA_COMMAND_REMEDIATION}`,
@@ -169,7 +174,7 @@ export const inspectKunaInstallation = async (
...(options.signal === undefined ? {} : { signal: options.signal }),
}));
} catch (cause: unknown) {
options.signal?.throwIfAborted();
assertRegularFileActive(options.signal);
const output = execFileOutputFailure(cause);
return failure(
"version_unresolved",
@@ -207,16 +212,19 @@ export const inspectKunaInstallation = async (
executable,
options.fileCloseResources,
);
options.signal?.throwIfAborted();
assertRegularFileActive(options.signal);
return { fingerprint, script };
},
));
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
options.signal?.throwIfAborted();
if (
cause instanceof RegularFileCleanupFailure ||
regularFileCancellation(cause) !== undefined
)
throw cause;
return failure(
"executable_missing",
`Cannot fingerprint ${executable}: ${message(cause)}`,
`Cannot fingerprint ${executable}: ${message(stableFileHashCause(cause))}`,
{ command, executable },
);
}
+10 -4
View File
@@ -23,8 +23,14 @@ import {
} from "../domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import {
hashStableFile,
stableFileHashCause,
} from "../filesystem/StableFileHash.js";
import {
RegularFileCleanupFailure,
regularFileCancellation,
} from "../filesystem/OwnedRegularFile.js";
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import type { KunaInstallation } from "./KunaInstallation.js";
import {
@@ -350,8 +356,8 @@ export class KunaProjectCache {
kunaExportFailure(cause, signal),
cause,
);
signal?.throwIfAborted();
if (!isDamaged(cause)) throw cause;
if (regularFileCancellation(cause) !== undefined) throw cause;
if (!isDamaged(stableFileHashCause(cause))) throw cause;
await this.#discard(key);
return undefined;
}
+16 -5
View File
@@ -10,7 +10,10 @@ import {
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import {
hashStableFile,
StableFileHashFailure,
} from "../filesystem/StableFileHash.js";
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
@@ -32,7 +35,10 @@ import {
type KunaProjectFunction,
} from "./KunaProjectFiles.js";
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import {
RegularFileCleanupFailure,
regularFileCancellation,
} from "../filesystem/OwnedRegularFile.js";
/** Operation name every export and cache failure reports. */
export const KUNA_EXPORT_OPERATION = "kuna:decompile-project";
@@ -125,10 +131,15 @@ export const kunaExportFailure = (
signal: AbortSignal | undefined,
): AnalysisError => {
if (cause instanceof RegularFileCleanupFailure)
return kunaFileCleanupError(cause, KUNA_EXPORT_OPERATION, signal);
if (signal?.aborted === true)
return new AnalysisCancelledError(KUNA_EXPORT_OPERATION, { cause });
return kunaFileCleanupError(cause, KUNA_EXPORT_OPERATION);
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined)
return new AnalysisCancelledError(KUNA_EXPORT_OPERATION, {
cause: cancellation.reason,
});
if (cause instanceof AnalysisError) return cause;
if (!(cause instanceof StableFileHashFailure) && signal?.aborted === true)
return new AnalysisCancelledError(KUNA_EXPORT_OPERATION, { cause });
return new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, {
cause,
userMessage: `Kuna project cache failed: ${message(cause)}`,
+20 -7
View File
@@ -15,7 +15,10 @@ import {
} from "../domain/analysisProfile.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { err, ok } from "../domain/result.js";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import {
RegularFileCleanupFailure,
regularFileCancellation,
} from "../filesystem/OwnedRegularFile.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import {
kunaFileCleanupError,
@@ -168,7 +171,11 @@ export class KunaProvider implements AnalysisProviderCandidate {
options?: AnalysisProfileResolutionOptions,
) {
if (options?.signal?.aborted === true)
return err(new AnalysisCancelledError("kuna:profile"));
return err(
new AnalysisCancelledError("kuna:profile", {
cause: options.signal.reason,
}),
);
let installation: Awaited<ReturnType<typeof inspectKunaInstallation>>;
const fileCloseResources = options?.fileCloseResources;
if (fileCloseResources === undefined)
@@ -186,12 +193,18 @@ export class KunaProvider implements AnalysisProviderCandidate {
fileCloseResources: fileCloseScope.owner,
});
} catch (cause: unknown) {
const cancellation = regularFileCancellation(cause);
if (
!(cause instanceof RegularFileCleanupFailure) &&
cancellation !== undefined
)
return err(
new AnalysisCancelledError("kuna:profile", {
cause: cancellation.reason,
}),
);
if (!(cause instanceof RegularFileCleanupFailure)) throw cause;
const error = kunaFileCleanupError(
cause,
"kuna:profile",
options?.signal,
);
const error = kunaFileCleanupError(cause, "kuna:profile");
fileCloseScope.bindFailure(cause, error);
return err(error);
}
+15 -6
View File
@@ -73,7 +73,10 @@ import {
type KunaProjectFileSpan,
} from "./KunaProjectReader.js";
import { kunaUnavailable } from "./KunaUnavailable.js";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import {
RegularFileCleanupFailure,
regularFileCancellation,
} from "../filesystem/OwnedRegularFile.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
@@ -1331,12 +1334,18 @@ export class KunaSessionClient implements AnalysisClient {
);
return ok(inspected.value);
} catch (cause: unknown) {
const cancellation = regularFileCancellation(cause);
if (
!(cause instanceof RegularFileCleanupFailure) &&
cancellation !== undefined
)
return err(
new AnalysisCancelledError("health", {
cause: cancellation.reason,
}),
);
if (!(cause instanceof RegularFileCleanupFailure)) throw cause;
const error = kunaFileCleanupError(
cause,
"health",
this.#session.signal,
);
const error = kunaFileCleanupError(cause, "health");
fileCloseScope.bindFailure(cause, error);
return err(error);
}
+8
View File
@@ -44,6 +44,7 @@ import { BinaryTargetError } from "../domain/configurationErrors.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import type { NativeUiObservationResult } from "../domain/native/nativeUiObservation.js";
import {
@@ -211,6 +212,13 @@ class NativeMacOSClient implements AnalysisClient {
);
} catch (cause) {
if (cause instanceof AnalysisError) return err(cause);
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined)
return err(
new AnalysisCancelledError(operation, {
cause: cancellation.reason,
}),
);
return err(
options?.signal?.aborted
? new AnalysisCancelledError(operation)
+25 -4
View File
@@ -1,3 +1,4 @@
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
import { createHash } from "node:crypto";
import { chmod, open, stat, mkdir } from "node:fs/promises";
import { join } from "node:path";
@@ -72,6 +73,11 @@ export class PcapSnapshot {
try {
return await read();
} catch (cause) {
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined)
throw new AnalysisCancelledError(OPERATION, {
cause: cancellation.reason,
});
if (signal?.aborted)
throw new AnalysisCancelledError(OPERATION, { cause });
if (cause instanceof AnalysisError) throw cause;
@@ -186,9 +192,17 @@ export class PcapSnapshot {
signal?.throwIfAborted();
return { bytes: copied, sha256: hash.digest("hex") };
} catch (cause) {
if (signal?.aborted)
throw new AnalysisCancelledError(OPERATION, { cause });
if (cause instanceof RegularFileAdmissionFailure) this.own(cause.owner);
const primary =
cause instanceof RegularFileAdmissionFailure ? cause.cause : cause;
const cancellation = regularFileCancellation(primary);
if (cancellation !== undefined)
throw new AnalysisCancelledError(OPERATION, {
cause: cancellation.reason,
});
if (cause instanceof AnalysisError) throw cause;
if (signal?.aborted)
throw new AnalysisCancelledError(OPERATION, { cause: signal.reason });
throw new AnalysisResourceConstraintError(
OPERATION,
"file-size",
@@ -242,10 +256,17 @@ export class PcapSnapshot {
);
await this.release(file);
} catch (cause) {
if (signal?.aborted)
throw new AnalysisCancelledError(OPERATION, { cause });
if (cause instanceof RegularFileAdmissionFailure) this.own(cause.owner);
const primary =
cause instanceof RegularFileAdmissionFailure ? cause.cause : cause;
const cancellation = regularFileCancellation(primary);
if (cancellation !== undefined)
throw new AnalysisCancelledError(OPERATION, {
cause: cancellation.reason,
});
if (cause instanceof AnalysisError) throw cause;
if (signal?.aborted)
throw new AnalysisCancelledError(OPERATION, { cause: signal.reason });
throw new AnalysisArtifactChangedError(
OPERATION,
selectedPath,
+2 -1
View File
@@ -1,3 +1,4 @@
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
import type { BigIntStats } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import { dirname } from "node:path";
@@ -278,7 +279,7 @@ const readStableFileEntry = async (
retain(cause.owner);
cause = cause.cause;
}
if (isAborted(signal))
if (regularFileCancellation(cause) !== undefined || isAborted(signal))
return entryFailure(
path,
"file",
@@ -3,7 +3,10 @@ import { open, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { withRegularFile } from "../../../src/application/RegularFileRead.js";
import {
withRegularFile,
retryRegularFileCleanup,
} from "../../../src/application/RegularFileRead.js";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
import {
@@ -231,3 +234,53 @@ it.each(["completed", "failed"] as const)(
expect(retained[0]?.handle.fd).toBe(-1);
},
);
it.each(
[null, "caller stopped", new Error("caller stopped")].flatMap((reason) =>
[false, true].map((retryFails) => ({ reason, retryFails })),
),
)(
"projects the exact checkpoint reason after application cleanup retry fails=$retryFails ($reason)",
async ({ reason, retryFails }) => {
const root = await createTestTempDirectory("rea-file-retry-reason-");
const path = join(root, "input.bin");
await writeFile(path, "file bytes");
const controller = new AbortController();
const resources = new ArtifactResourceScope();
const nativeClose = OwnedFileHandle.prototype.close;
let allowClose = false;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
if (!allowClose) throw new Error("close unavailable");
await nativeClose.call(this);
});
let failure: unknown;
try {
failure = await withRegularFile(
path,
async () => {
controller.abort(reason);
return "completed callback";
},
{ signal: controller.signal },
).catch((cause: unknown) => cause);
if (!(failure instanceof RegularFileCleanupFailure))
throw new Error("Missing checkpoint cleanup owner");
allowClose = !retryFails;
const retried = await retryRegularFileCleanup(failure, resources);
expect(retried.outcome.kind).toBe("failed");
if (retried.outcome.kind !== "failed")
throw new Error("Lost cancellation outcome");
expect(retried.outcome.cause).toBe(reason);
expect(retried.cleanup !== undefined).toBe(retryFails);
expect(resources.hasPending).toBe(retryFails);
} finally {
allowClose = true;
await resources.close();
if (failure instanceof RegularFileCleanupFailure)
await failure.owner.close();
close.mockRestore();
}
},
);
@@ -342,3 +342,263 @@ it("retries pending cleanup before acquiring a publisher temporary root", async
await publisher.close();
}
});
it.each([
{ stage: "close", reason: null },
{ stage: "close", reason: "caller stopped" },
{ stage: "close", reason: new Error("caller stopped") },
{ stage: "retention", reason: "after the failed close" },
] as const)(
"records completion cancellation at $stage before owner handoff",
async ({ stage, reason }) => {
const root = await createTestTempDirectory("rea-hash-close-provenance-");
const path = join(root, "input.bin");
await writeFile(path, "owned bytes");
const controller = new AbortController();
const nativeClose = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementationOnce(async () => {
if (stage === "close") controller.abort(reason);
throw new Error("injected failed close");
});
let retained: OwnedFileHandle | undefined;
try {
const failure: unknown = await hashStableFile(path, {
signal: controller.signal,
retainFailedClose: (owner) => {
retained = owner;
if (stage === "retention") controller.abort(reason);
},
}).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
expect(failure).toMatchObject({
outcome: { kind: "completed", value: { bytes: 11 } },
});
if (stage === "close") {
expect(failure).toMatchObject({ completionCancellation: { reason } });
expect(
Reflect.get(failure as object, "completionCancellation").reason,
).toBe(reason);
} else
expect(failure).not.toHaveProperty(
"completionCancellation",
expect.anything(),
);
} finally {
close.mockRestore();
if (retained !== undefined) await nativeClose.call(retained);
}
},
);
it("tags a physical hash failure without inferring cancellation from a later signal", async () => {
const root = await createTestTempDirectory("rea-hash-physical-provenance-");
const path = join(root, "input.bin");
await writeFile(path, "owned bytes");
const controller = new AbortController();
const primary = Object.assign(new Error("injected digest read failure"), {
code: "EIO",
});
const nativeOpen = regularFile.openRegularFile;
const open = vi
.spyOn(regularFile, "openRegularFile")
.mockImplementation(async (selected, options) => {
const handle = await nativeOpen(selected, options);
vi.spyOn(handle, "read").mockRejectedValueOnce(primary);
return handle;
});
const nativeClose = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementationOnce(async function (this: OwnedFileHandle) {
await nativeClose.call(this);
controller.abort(primary);
});
try {
const failure: unknown = await hashStableFile(path, {
signal: controller.signal,
retainFailedClose: () => {
throw new Error("Close succeeded");
},
}).catch((cause: unknown) => cause);
expect(failure).toMatchObject({
_tag: "StableFileHashFailure",
cause: primary,
});
if (!(failure instanceof Error))
throw new Error("Expected producer-owned physical failure");
expect(failure.cause).toBe(primary);
} finally {
open.mockRestore();
close.mockRestore();
}
});
it.each([false, true])(
"preserves premature EOF with same-read cancellation and failed close=%s",
async (failClose) => {
const root = await createTestTempDirectory("rea-hash-eof-cancel-");
const path = join(root, "input.bin");
await writeFile(path, "admitted extent");
const controller = new AbortController();
const nativeOpen = regularFile.openRegularFile;
let retained: OwnedFileHandle | undefined;
const open = vi
.spyOn(regularFile, "openRegularFile")
.mockImplementation(async (selected, options) => {
const handle = await nativeOpen(selected, options);
vi.spyOn(handle, "read").mockImplementationOnce(async () => {
controller.abort("cancelled at EOF");
return { bytesRead: 0, buffer: Buffer.alloc(0) };
});
return handle;
});
const nativeClose = OwnedFileHandle.prototype.close;
const close = vi.spyOn(OwnedFileHandle.prototype, "close");
if (failClose) close.mockRejectedValueOnce(new Error("close unavailable"));
try {
const failure: unknown = await hashStableFile(path, {
signal: controller.signal,
retainFailedClose: (owner) => {
retained = owner;
},
}).catch((cause: unknown) => cause);
const physical =
failure instanceof RegularFileCleanupFailure
? failure.outcome.kind === "failed"
? failure.outcome.cause
: undefined
: failure instanceof Error
? failure.cause
: undefined;
expect(physical).toBeInstanceOf(regularFile.RegularFileChangedError);
if (failClose) {
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
expect(retained?.handle.fd).toBeGreaterThanOrEqual(0);
} else expect(failure).toMatchObject({ _tag: "StableFileHashFailure" });
} finally {
open.mockRestore();
close.mockRestore();
if (retained !== undefined) await nativeClose.call(retained);
}
},
);
it.each(
[null, "caller stopped", new Error("caller stopped")].flatMap((reason) =>
[false, true].map((failClose) => ({ reason, failClose })),
),
)(
"retains the exact read checkpoint reason with failed close=$failClose ($reason)",
async ({ reason, failClose }) => {
const root = await createTestTempDirectory("rea-hash-read-checkpoint-");
const path = join(root, "input.bin");
await writeFile(path, "admitted bytes");
const controller = new AbortController();
const nativeOpen = regularFile.openRegularFile;
let retained: OwnedFileHandle | undefined;
let handleSeen: Awaited<ReturnType<typeof nativeOpen>> | undefined;
const open = vi
.spyOn(regularFile, "openRegularFile")
.mockImplementation(async (selected, options) => {
const handle = await nativeOpen(selected, options);
handleSeen = handle;
const read = handle.read.bind(handle);
vi.spyOn(handle, "read").mockImplementationOnce(async (...args) => {
const result = await read(...args);
controller.abort(reason);
return result;
});
return handle;
});
const nativeClose = OwnedFileHandle.prototype.close;
const close = vi.spyOn(OwnedFileHandle.prototype, "close");
if (failClose) close.mockRejectedValueOnce(new Error("close unavailable"));
try {
const failure: unknown = await hashStableFile(path, {
signal: controller.signal,
retainFailedClose: (owner) => {
retained = owner;
},
}).catch((cause: unknown) => cause);
const primary =
failure instanceof RegularFileCleanupFailure &&
failure.outcome.kind === "failed"
? failure.outcome.cause
: failure;
expect(primary).toBeInstanceOf(regularFile.RegularFileOperationCancelled);
expect(
(primary as regularFile.RegularFileOperationCancelled).reason,
).toBe(reason);
expect(handleSeen?.fd).toBe(failClose ? retained?.handle.fd : -1);
} finally {
open.mockRestore();
close.mockRestore();
if (retained !== undefined) await nativeClose.call(retained);
}
},
);
it.each(["physical", "completed"] as const)(
"preserves the publisher's %s hash outcome after cancellation and a successful close retry",
async (outcome) => {
const root = await createTestTempDirectory(
"rea-publisher-hash-retry-cancel-",
);
const path = join(root, "launcher");
await writeFile(path, "launcher bytes");
const controller = new AbortController();
const primary = new Error("injected publisher read failure");
const reason = null;
const nativeOpen = regularFile.openRegularFile;
const open = vi
.spyOn(regularFile, "openRegularFile")
.mockImplementation(async (selected, options) => {
const handle = await nativeOpen(selected, options);
if (outcome === "physical")
vi.spyOn(handle, "read").mockRejectedValueOnce(primary);
return handle;
});
const nativeClose = OwnedFileHandle.prototype.close;
let ownerSeen: OwnedFileHandle | undefined;
let attempts = 0;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
ownerSeen = this;
attempts++;
if (attempts === 1) {
controller.abort(reason);
throw new Error("first close unavailable");
}
await nativeClose.call(this);
});
const publisher = new LinuxHopperPublisher(
{ XDG_CACHE_HOME: join(root, "cache") },
async () => undefined,
);
try {
const result = await publisher.inspectLauncher(path, controller.signal);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Injected hash outcome passed");
expect(result.error.reason).toBe(
outcome === "physical" ? "verification_unavailable" : "cancelled",
);
if (outcome === "physical")
expect(result.error.cause).toMatchObject({
_tag: "StableFileHashFailure",
cause: primary,
});
else expect(result.error.cause).toBe(reason);
expect(attempts).toBe(2);
expect(ownerSeen?.handle.fd).toBe(-1);
} finally {
open.mockRestore();
close.mockRestore();
await publisher.close();
if (ownerSeen !== undefined && ownerSeen.handle.fd >= 0)
await nativeClose.call(ownerSeen);
}
},
);
@@ -145,7 +145,7 @@ it
);
it.skipIf(process.platform === "win32")(
"keeps cancellation primary when an executable hash read and close both fail",
"keeps the physical hash read failure primary when cancellation and close failure follow",
async () => {
const fixture = await session("rea-kuna-cancelled-read-close-");
const installed = await inspectKunaInstallationOwned({
@@ -174,9 +174,15 @@ it.skipIf(process.platform === "win32")(
if (result.ok) throw new Error("injected cancelled read passed");
originatingError = result.error;
expect(result.error).toMatchObject({
_tag: "AnalysisCancelledError",
_tag: "ProviderAdapterError",
cleanupIncomplete: true,
cleanupResources: [installed.value.executable],
diagnostics: {
primary_error: {
code: "execution_failure",
message: expect.stringContaining("injected read fault"),
},
},
});
} finally {
injected.failReadStage = undefined;
@@ -1,3 +1,4 @@
import { regularFileCancellation } from "../../../../src/filesystem/OwnedRegularFile.js";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const gates = vi.hoisted(() => {
@@ -128,7 +129,12 @@ it
}
gates.shebangRead.release();
await expect(inspection).rejects.toBe(controller.signal.reason);
const failure: unknown = await inspection.catch(
(cause: unknown) => cause,
);
const cancellation = regularFileCancellation(failure);
expect(cancellation).toBeDefined();
expect(cancellation?.reason).toBe(controller.signal.reason);
await expect(cleanup).resolves.toBeUndefined();
expect(gates.opens).toBe(phase === "hash-close" ? 1 : 2);
expect(scope.hasPending).toBe(false);