mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
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:
+37
-25
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user