diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index a19470c43d..9fdca59419 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -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) diff --git a/src/android/AndroidTargetSnapshot.ts b/src/android/AndroidTargetSnapshot.ts index f49f65f9c1..1801768c66 100644 --- a/src/android/AndroidTargetSnapshot.ts +++ b/src/android/AndroidTargetSnapshot.ts @@ -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 => { - 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) diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index 8fb456c64d..074a218bff 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -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; } }; diff --git a/src/apktool/ApktoolProvider.ts b/src/apktool/ApktoolProvider.ts index 2fa61fe5fd..1c33d515af 100644 --- a/src/apktool/ApktoolProvider.ts +++ b/src/apktool/ApktoolProvider.ts @@ -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 diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 1e206801b1..d0d869ba0f 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -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 }), diff --git a/src/application/LinuxHopperPublisher.ts b/src/application/LinuxHopperPublisher.ts index 7fa3085888..8acbefec98 100644 --- a/src/application/LinuxHopperPublisher.ts +++ b/src/application/LinuxHopperPublisher.ts @@ -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.", diff --git a/src/application/ReferenceSourceImportPolicy.ts b/src/application/ReferenceSourceImportPolicy.ts index 547e57cd35..30e683e0cc 100644 --- a/src/application/ReferenceSourceImportPolicy.ts +++ b/src/application/ReferenceSourceImportPolicy.ts @@ -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( diff --git a/src/application/RegularFileRead.ts b/src/application/RegularFileRead.ts index 4d26e2ca5f..ca420a2b7c 100644 --- a/src/application/RegularFileRead.ts +++ b/src/application/RegularFileRead.ts @@ -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 ( readonly outcome: RegularFileReadOutcome; readonly cleanup?: AnalysisCleanupObservation; }> => { + const outcome: RegularFileReadOutcome = + 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 ( 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 ( !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 diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index 43b90e344b..44a73c8049 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -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, ); } } diff --git a/src/application/WebScriptExportService.ts b/src/application/WebScriptExportService.ts index 1fbb419d7c..a792706c24 100644 --- a/src/application/WebScriptExportService.ts +++ b/src/application/WebScriptExportService.ts @@ -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, ); diff --git a/src/artifacts/ArtifactFileOperation.ts b/src/artifacts/ArtifactFileOperation.ts index dbed70192d..a7d49754ee 100644 --- a/src/artifacts/ArtifactFileOperation.ts +++ b/src/artifacts/ArtifactFileOperation.ts @@ -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; constructor( - readonly fileCleanupFailure: RegularFileCleanupFailure, + fileCleanupFailure: RegularFileCleanupFailure, 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 ( path, { symlinks, + ...(signal === undefined ? {} : { signal }), retainFailedClose: (owner) => { cleanupLease = new AnalysisCleanupLease(() => owner.close(), [path]); resources.retain({ @@ -189,23 +206,16 @@ export const withArtifactRegularFile = async ( 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 ( ); } 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; diff --git a/src/artifacts/NativeDmgArtifactReader.ts b/src/artifacts/NativeDmgArtifactReader.ts index d8636f4ae3..3dd27a5fd5 100644 --- a/src/artifacts/NativeDmgArtifactReader.ts +++ b/src/artifacts/NativeDmgArtifactReader.ts @@ -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"; } diff --git a/src/artifacts/RegularFileAdmission.test.ts b/src/artifacts/RegularFileAdmission.test.ts index 97f61fc344..fe1d0fb376 100644 --- a/src/artifacts/RegularFileAdmission.test.ts +++ b/src/artifacts/RegularFileAdmission.test.ts @@ -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); }); diff --git a/src/artifacts/inventory/hashStableRootArtifact.ts b/src/artifacts/inventory/hashStableRootArtifact.ts index 0956771b00..cf57b804a2 100644 --- a/src/artifacts/inventory/hashStableRootArtifact.ts +++ b/src/artifacts/inventory/hashStableRootArtifact.ts @@ -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({ diff --git a/src/cutter/CutterBridgeClient.ts b/src/cutter/CutterBridgeClient.ts index 2074ba6595..dc821745d4 100644 --- a/src/cutter/CutterBridgeClient.ts +++ b/src/cutter/CutterBridgeClient.ts @@ -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; diff --git a/src/electron/ElectronFuseProvider.ts b/src/electron/ElectronFuseProvider.ts index d05baff43d..af3a29bb64 100644 --- a/src/electron/ElectronFuseProvider.ts +++ b/src/electron/ElectronFuseProvider.ts @@ -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 }, diff --git a/src/filesystem/OwnedRegularFile.ts b/src/filesystem/OwnedRegularFile.ts index f79978e021..aad2d1c1f9 100644 --- a/src/filesystem/OwnedRegularFile.ts +++ b/src/filesystem/OwnedRegularFile.ts @@ -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 = | { readonly kind: "completed"; readonly value: Value } @@ -48,6 +53,7 @@ export class RegularFileCleanupFailure 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 ( const handle = owner.handle; let outcome: RegularFileOperationOutcome; 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 ( 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 ( outcome, cleanupCause, retainOwner(options.retainFailedClose, owner), + completionCancellation, ); } if (outcome.kind === "failed") throw outcome.cause; diff --git a/src/filesystem/RegularFile.ts b/src/filesystem/RegularFile.ts index 47fdfa1d85..8b83f936a5 100644 --- a/src/filesystem/RegularFile.ts +++ b/src/filesystem/RegularFile.ts @@ -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 => { - 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) { diff --git a/src/filesystem/StableFileHash.ts b/src/filesystem/StableFileHash.ts index 657020efa9..890de4d9ce 100644 --- a/src/filesystem/StableFileHash.ts +++ b/src/filesystem/StableFileHash.ts @@ -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); + } +}; diff --git a/src/firmware/FirmwareFailures.ts b/src/firmware/FirmwareFailures.ts index 0cf56ddbc6..4c8f0e7956 100644 --- a/src/firmware/FirmwareFailures.ts +++ b/src/firmware/FirmwareFailures.ts @@ -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; diff --git a/src/flutter/FlutterBuildProvider.ts b/src/flutter/FlutterBuildProvider.ts index 85924b5e79..11df7c0137 100644 --- a/src/flutter/FlutterBuildProvider.ts +++ b/src/flutter/FlutterBuildProvider.ts @@ -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 ( diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 4609185997..2b4b62d704 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -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; 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 }, ), ); } diff --git a/src/javascript/recovery/RecoveryFiles.ts b/src/javascript/recovery/RecoveryFiles.ts index 6cea1b81b6..545744f084 100644 --- a/src/javascript/recovery/RecoveryFiles.ts +++ b/src/javascript/recovery/RecoveryFiles.ts @@ -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, diff --git a/src/kuna/KunaCommand.ts b/src/kuna/KunaCommand.ts index b4f98dda60..cdc996322a 100644 --- a/src/kuna/KunaCommand.ts +++ b/src/kuna/KunaCommand.ts @@ -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 { diff --git a/src/kuna/KunaInstallation.ts b/src/kuna/KunaInstallation.ts index 66dc551de8..af5580c1d5 100644 --- a/src/kuna/KunaInstallation.ts +++ b/src/kuna/KunaInstallation.ts @@ -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 }, ); } diff --git a/src/kuna/KunaProjectCache.ts b/src/kuna/KunaProjectCache.ts index 9dc5a17898..f6319d5e44 100644 --- a/src/kuna/KunaProjectCache.ts +++ b/src/kuna/KunaProjectCache.ts @@ -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; } diff --git a/src/kuna/KunaProjectExport.ts b/src/kuna/KunaProjectExport.ts index 5ca1cb1352..273013822a 100644 --- a/src/kuna/KunaProjectExport.ts +++ b/src/kuna/KunaProjectExport.ts @@ -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)}`, diff --git a/src/kuna/KunaProvider.ts b/src/kuna/KunaProvider.ts index 48f4d35c65..a66c5b7a43 100644 --- a/src/kuna/KunaProvider.ts +++ b/src/kuna/KunaProvider.ts @@ -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>; 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); } diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index ae8478459e..0c281cbf39 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -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); } diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 49b1b894a3..8e7c938cf6 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -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) diff --git a/src/pcap/PcapSnapshot.ts b/src/pcap/PcapSnapshot.ts index 2e9082ccdd..416198ccf8 100644 --- a/src/pcap/PcapSnapshot.ts +++ b/src/pcap/PcapSnapshot.ts @@ -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, diff --git a/src/reference/ReferenceSourceReaderFile.ts b/src/reference/ReferenceSourceReaderFile.ts index e8cabdaeb3..edef7a128b 100644 --- a/src/reference/ReferenceSourceReaderFile.ts +++ b/src/reference/ReferenceSourceReaderFile.ts @@ -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", diff --git a/tests/boundary/filesystem/regularFileCancellation.test.ts b/tests/boundary/filesystem/regularFileCancellation.test.ts index 9a36a581e0..456714c03a 100644 --- a/tests/boundary/filesystem/regularFileCancellation.test.ts +++ b/tests/boundary/filesystem/regularFileCancellation.test.ts @@ -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(); + } + }, +); diff --git a/tests/boundary/filesystem/stableFileHashOwnership.test.ts b/tests/boundary/filesystem/stableFileHashOwnership.test.ts index e19faaae91..f6dbad92c6 100644 --- a/tests/boundary/filesystem/stableFileHashOwnership.test.ts +++ b/tests/boundary/filesystem/stableFileHashOwnership.test.ts @@ -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> | 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); + } + }, +); diff --git a/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts b/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts index c033e276f3..5ff68f8ffd 100644 --- a/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts +++ b/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts @@ -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; diff --git a/tests/boundary/providers/kuna/kunaInstallationLifetime.test.ts b/tests/boundary/providers/kuna/kunaInstallationLifetime.test.ts index 764a12a320..2b123ddea7 100644 --- a/tests/boundary/providers/kuna/kunaInstallationLifetime.test.ts +++ b/tests/boundary/providers/kuna/kunaInstallationLifetime.test.ts @@ -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);