From 131fd5ddcd3dc7124be118c7a3e56b8e8df38baf Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:46:25 +0800 Subject: [PATCH] fix(filesystem): retain stable hash cleanup owners across callers Preserve completed digests and primary failures when descriptor close fails. Carry the exact handle owner through provider, readiness, cache, doctor and verifier boundaries, and retry cleanup without rehashing or retrying a raw fd. Close candidate-owned resources even when no target client was opened. Keep verifier workspaces when provider shutdown remains incomplete. Refs #4266 --- scripts/verify-flutter.mjs | 55 ++++-- scripts/verify-pcap.mjs | 55 ++++-- src/adb/AdbProvider.ts | 136 +++++++++++--- src/android/JadxProvider.ts | 114 ++++++++--- src/application/AnalysisProvider.ts | 2 + src/application/Doctor.ts | 24 ++- src/application/LinuxHopper.ts | 8 +- src/application/LinuxHopperPublisher.ts | 59 +++++- .../binary/AnalysisProviderRegistry.ts | 23 +++ src/application/binary/BinarySession.ts | 15 +- .../binary/SessionProviderRouter.ts | 5 + src/doctorRuntime.ts | 4 +- src/filesystem/RegularFile.ts | 25 ++- src/filesystem/StableFileHash.ts | 57 +++++- src/kuna/KunaCommand.ts | 45 ++++- src/kuna/KunaDoctor.ts | 59 +++++- src/kuna/KunaInstallation.ts | 64 ++++++- src/kuna/KunaProjectCache.ts | 91 ++++++++- src/kuna/KunaProjectExport.ts | 44 ++++- src/kuna/KunaProvider.ts | 129 ++++++++++++- src/kuna/KunaSessionClient.ts | 38 +++- src/kuna/KunaTargetCommands.ts | 54 +++++- .../stableFileHashOwnership.test.ts | 177 ++++++++++++++++++ .../kuna/kunaHashOwnerPropagation.test.ts | 169 +++++++++++++++++ 24 files changed, 1313 insertions(+), 139 deletions(-) create mode 100644 tests/boundary/filesystem/stableFileHashOwnership.test.ts create mode 100644 tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts diff --git a/scripts/verify-flutter.mjs b/scripts/verify-flutter.mjs index 76b5a1fdaf..52213e71f8 100644 --- a/scripts/verify-flutter.mjs +++ b/scripts/verify-flutter.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readdir, readFile, rmdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -8,7 +8,9 @@ import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { toolContract } from "../dist/contracts/toolContracts.js"; import { hashStableFile } from "../dist/filesystem/StableFileHash.js"; +import { OwnedRegularFileCleanupFailure } from "../dist/filesystem/RegularFile.js"; import { mcpTextValue } from "./lib/mcp-verifier-results.mjs"; +import { closeVerifierResources } from "./lib/verifier-cleanup.mjs"; const options = parseArguments(process.argv.slice(2)); if (options.help) { @@ -39,6 +41,17 @@ Object.assign(environment, { TEMP: root, }); const client = new Client({ name: "flutter-verifier", version: "1" }); +let primaryError; +const hashOwners = new Map(); +const hashOwned = async (...args) => { + try { + return await hashStableFile(...args); + } catch (cause) { + if (cause instanceof OwnedRegularFileCleanupFailure) + hashOwners.set(cause.owner, cause.path); + throw cause; + } +}; const transport = new StdioClientTransport({ command: process.execPath, args: [entrypoint, "mcp"], @@ -49,7 +62,7 @@ const execute = promisify(execFile); try { await client.connect(transport); const apk = resolve(options.apk); - const identity = await hashStableFile(apk); + const identity = await hashOwned(apk); const identified = await run("identify_flutter_build", { path: apk }); assert.deepEqual(identified.target, { path: apk, ...identity }); assert.equal(identified.flutter_detected, true); @@ -176,32 +189,46 @@ try { const negative = await run("identify_flutter_build", { path: plain }); assert.deepEqual(negative.target, { path: plain, - ...(await hashStableFile(plain)), + ...(await hashOwned(plain)), }); assert.equal(negative.flutter_detected, false); assert.equal(negative.coverage, "complete"); } assert.deepEqual( - await hashStableFile(apk), + await hashOwned(apk), identity, "Verification must preserve its input", ); +} catch (cause) { + primaryError = cause; + throw cause; } finally { - // Server shutdown retries retained provider cleanup and must settle before success. - try { - try { - await client.close(); - } finally { - await transport.close(); - } + const cleanup = []; + for (const [owner, path] of hashOwners) { + cleanup.push(async () => { + try { + await owner.close(); + hashOwners.delete(owner); + } catch (cause) { + throw new Error(`Could not close hash file handle for ${path}`, { + cause, + }); + } + }); + } + cleanup.push(async () => { + await closeVerifierResources([ + () => client.close(), + () => transport.close(), + ]); assert.deepEqual( await readdir(root), [], `Provider snapshots remain under ${root}`, ); - } finally { - await rm(root, { recursive: true, force: true }); - } + await rmdir(root); + }); + await closeVerifierResources(cleanup, primaryError); } process.stdout.write( "all asserted Flutter CLI/MCP checks passed; unverified facets are reported above\n", diff --git a/scripts/verify-pcap.mjs b/scripts/verify-pcap.mjs index 3adf14531a..91d454ac16 100644 --- a/scripts/verify-pcap.mjs +++ b/scripts/verify-pcap.mjs @@ -8,7 +8,9 @@ import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { toolContract } from "../dist/contracts/toolContracts.js"; import { hashStableFile } from "../dist/filesystem/StableFileHash.js"; +import { OwnedRegularFileCleanupFailure } from "../dist/filesystem/RegularFile.js"; import { mcpTextValue } from "./lib/mcp-verifier-results.mjs"; +import { closeVerifierResources } from "./lib/verifier-cleanup.mjs"; const args = process.argv.slice(2); if (args.includes("--help") || args.includes("-h")) { @@ -34,12 +36,23 @@ Object.assign(environment, { const entrypoint = resolve("scripts/rea.mjs"); const execute = promisify(execFile); const client = new Client({ name: "pcap-real-verifier", version: "1" }); +let primaryError; const transport = new StdioClientTransport({ command: process.execPath, args: [entrypoint, "mcp"], env: environment, stderr: "pipe", }); +const hashOwners = new Map(); +const hashOwned = async (...args) => { + try { + return await hashStableFile(...args); + } catch (cause) { + if (cause instanceof OwnedRegularFileCleanupFailure) + hashOwners.set(cause.owner, cause.path); + throw cause; + } +}; transport.stderr?.resume(); try { await client.connect(transport); @@ -48,7 +61,7 @@ try { if (path === null) console.log("[unverified] Capture inspection: no --pcap was supplied"); else { - const identity = await hashStableFile(path); + const identity = await hashOwned(path); const result = await run("inspect_pcap_capture", { path }); assert.deepEqual(result.target, { path, ...identity }); assert(result.packet_count >= 0); @@ -75,7 +88,7 @@ try { ); assert.equal(result.coverage, partial ? "partial" : "complete"); assert.deepEqual( - await hashStableFile(path), + await hashOwned(path), identity, "Input capture must remain unchanged", ); @@ -83,18 +96,36 @@ try { `[ok] ${result.packet_count} packets; ${result.endpoint_count} addresses; coverage=${result.coverage}`, ); } +} catch (cause) { + primaryError = cause; + throw cause; } finally { - try { - await client.close(); - } finally { - await transport.close(); + const cleanup = []; + for (const [owner, path] of hashOwners) { + cleanup.push(async () => { + try { + await owner.close(); + hashOwners.delete(owner); + } catch (cause) { + throw new Error(`Could not close hash file handle for ${path}`, { + cause, + }); + } + }); } - assert.deepEqual( - await readdir(root), - [], - `Owned capture resources remain under ${root}`, - ); - await rmdir(root); + cleanup.push(async () => { + await closeVerifierResources([ + () => client.close(), + () => transport.close(), + ]); + assert.deepEqual( + await readdir(root), + [], + `Owned capture resources remain under ${root}`, + ); + await rmdir(root); + }); + await closeVerifierResources(cleanup, primaryError); } console.log("All asserted pcap CLI/MCP checks passed"); diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index fc6ce5282e..8c81573c43 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -42,8 +42,15 @@ import { import { createEvidence } from "../domain/evidence.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { err, ok, type Result } from "../domain/result.js"; -import { hashStableFile } from "../filesystem/StableFileHash.js"; +import { + hashStableFile, + StableFileHashCleanupFailure, + type StableFileHashResult, +} from "../filesystem/StableFileHash.js"; +import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { OwnedRegularFileCleanupFailure } from "../filesystem/RegularFile.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js"; @@ -121,20 +128,6 @@ const BUGREPORT_MAX_OUTPUT_BYTES = 1024 * 1024; const STDERR_EXCERPT_BYTES = 512; const UNPARSED_LINES_REPORTED = 10; -const hashAdbFile = async ( - path: string, - operation: string, - signal: AbortSignal | undefined, -) => { - try { - return await hashStableFile(path, signal); - } catch (cause: unknown) { - if (signal?.aborted === true) - throw new AnalysisCancelledError(operation, { cause }); - throw cause; - } -}; - /** adb joins shell operands into a remote POSIX command; quote at that boundary. */ const quoteShellOperand = (value: string): string => /^[A-Za-z0-9_./:=@+,-]+$/u.test(value) @@ -388,6 +381,12 @@ interface PendingAdbDirectoryCleanup { readonly remove: () => Promise; } +interface PendingAdbFileCleanup { + readonly operation: string; + readonly path: string; + readonly owner: OwnedFileHandle; +} + interface OwnedDirectoryIdentity { readonly dev: number; readonly ino: number; @@ -562,6 +561,7 @@ const reservePackageDestinations = async ( export class AdbProvider { private readonly environment: Readonly>; readonly #pendingDirectoryCleanup = new Set(); + readonly #pendingFileCleanup = new Set(); readonly #activeOperations = new Set>(); #closing = false; #closePromise: Promise | undefined; @@ -575,6 +575,53 @@ export class AdbProvider { return resolveAdbBinary(this.environment); } + private async hashAdbFile( + path: string, + operation: string, + signal: AbortSignal | undefined, + ) { + try { + return await hashStableFile(path, signal); + } catch (cause: unknown) { + if (cause instanceof OwnedRegularFileCleanupFailure) + this.#pendingFileCleanup.add({ operation, path, owner: cause.owner }); + if (cause instanceof OwnedRegularFileCleanupFailure) { + const primary = signal?.aborted + ? new AnalysisCancelledError(operation, { cause: cause.cause }) + : new ProviderAdapterError(ADB_PROVIDER_IDENTITY.id, operation, { + cause: cause.cause, + userMessage: + cause.cause instanceof Error + ? cause.cause.message + : String(cause.cause), + diagnostics: (cause instanceof StableFileHashCleanupFailure && + cause.completedHash !== undefined + ? { completed_hash: cause.completedHash } + : {}), + }); + const cleanup = new ProviderCleanupError( + ADB_PROVIDER_IDENTITY.id, + [path], + { + reason: + cause.cleanupCause instanceof Error + ? cause.cleanupCause.message + : String(cause.cleanupCause), + ...(cause instanceof StableFileHashCleanupFailure && + cause.completedHash !== undefined + ? { completed_hash: cause.completedHash } + : {}), + }, + { operation, cause: cause.cleanupCause }, + ); + throw analysisErrorWithCleanupFailure(primary, cleanup, operation); + } + if (signal?.aborted === true) + throw new AnalysisCancelledError(operation, { cause }); + throw cause; + } + } + /** Probe the selected adb binary; never contacts devices or the server. */ async inspectAvailability( signal?: AbortSignal, @@ -598,7 +645,7 @@ export class AdbProvider { } } - /** Retry removal of owned ADB directories retained after failed cleanup. */ + /** Retry file and directory resources retained after failed cleanup. */ close(): Promise { this.#closing = true; if (this.#closePromise !== undefined) return this.#closePromise; @@ -615,7 +662,7 @@ export class AdbProvider { #retryDirectoryCleanup(): Promise { if (this.#directoryCleanupPromise !== undefined) return this.#directoryCleanupPromise; - const cleanup = this.#performDirectoryCleanup().finally(() => { + const cleanup = this.#performResourceCleanup().finally(() => { if (this.#directoryCleanupPromise === cleanup) this.#directoryCleanupPromise = undefined; }); @@ -623,8 +670,23 @@ export class AdbProvider { return cleanup; } - async #performDirectoryCleanup(): Promise { + async #performResourceCleanup(): Promise { const failures: AnalysisError[] = []; + for (const owner of this.#pendingFileCleanup) { + try { + await owner.owner.close(); + this.#pendingFileCleanup.delete(owner); + } catch (cause: unknown) { + failures.push( + new ProviderCleanupError( + ADB_PROVIDER_IDENTITY.id, + [owner.path], + { reason: cause instanceof Error ? cause.message : String(cause) }, + { operation: owner.operation, cause }, + ), + ); + } + } for (const owner of this.#pendingDirectoryCleanup) { try { await owner.remove(); @@ -651,12 +713,18 @@ export class AdbProvider { if (failures.length > 1) throw new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, - [...this.#pendingDirectoryCleanup].map(({ path }) => path), + [ + ...[...this.#pendingFileCleanup].map(({ path }) => path), + ...[...this.#pendingDirectoryCleanup].map(({ path }) => path), + ], { - reason: "Failed ADB directories remain after cleanup retry", - operations: [...this.#pendingDirectoryCleanup].map( - ({ operation }) => operation, - ), + reason: "Failed ADB resources remain after cleanup retry", + operations: [ + ...[...this.#pendingFileCleanup].map(({ operation }) => operation), + ...[...this.#pendingDirectoryCleanup].map( + ({ operation }) => operation, + ), + ], failures: failures.map(({ message, cleanupResources }) => ({ message, resources: [...cleanupResources], @@ -679,7 +747,7 @@ export class AdbProvider { signal: AbortSignal | undefined, consume: ( path: string, - digest: Awaited>, + digest: StableFileHashResult, ) => Promise>, ): Promise> { let root: PrivateRuntimeRoot | undefined; @@ -700,7 +768,7 @@ export class AdbProvider { }; const snapshotPath = join(root.path, basename(sourcePath)); await copyFile(sourcePath, snapshotPath, fsConstants.COPYFILE_EXCL); - const digest = await hashAdbFile(snapshotPath, operation, signal); + const digest = await this.hashAdbFile(snapshotPath, operation, signal); signal?.throwIfAborted(); result = await consume(snapshotPath, digest); } catch (cause: unknown) { @@ -809,7 +877,10 @@ export class AdbProvider { }); this.#activeOperations.add(active); try { - if (this.#pendingDirectoryCleanup.size > 0) { + if ( + this.#pendingDirectoryCleanup.size > 0 || + this.#pendingFileCleanup.size > 0 + ) { try { await this.#retryDirectoryCleanup(); } catch (cause: unknown) { @@ -818,7 +889,12 @@ export class AdbProvider { ? cause : new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, - [...this.#pendingDirectoryCleanup].map(({ path }) => path), + [ + ...[...this.#pendingFileCleanup].map(({ path }) => path), + ...[...this.#pendingDirectoryCleanup].map( + ({ path }) => path, + ), + ], { reason: cause instanceof Error ? cause.message : String(cause), @@ -1698,7 +1774,7 @@ export class AdbProvider { continue; } try { - const digest = await hashAdbFile(localPath, operation, signal); + const digest = await this.hashAdbFile(localPath, operation, signal); artifacts.push({ device_path: entry.device_path, file_name: entry.file_name, @@ -1792,7 +1868,7 @@ export class AdbProvider { ); if (!pulled.ok) return pulled; try { - const digest = await hashAdbFile(localPath, operation, signal); + const digest = await this.hashAdbFile(localPath, operation, signal); return ok( createAnalysisExecution( { @@ -2531,7 +2607,7 @@ export class AdbProvider { ...retainedObservation, result: { ...retainedObservation.result, local_path: publishedPath }, }; - const digest = await hashAdbFile(publishedPath, operation, signal); + const digest = await this.hashAdbFile(publishedPath, operation, signal); const report = { client, serial: input.serial, diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index 3980ce318e..3865dca1ff 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -14,6 +14,7 @@ import { } from "../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AndroidRequest } from "../domain/android/androidAnalysis.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; @@ -26,8 +27,15 @@ import { snapshotAndroidEngine, snapshotAndroidTarget, } from "./AndroidTargetSnapshot.js"; -import { hashStableFile } from "../filesystem/StableFileHash.js"; -import { RegularFileChangedError } from "../filesystem/RegularFile.js"; +import { + hashStableFile, + StableFileHashCleanupFailure, +} from "../filesystem/StableFileHash.js"; +import { + OwnedRegularFileCleanupFailure, + RegularFileChangedError, +} from "../filesystem/RegularFile.js"; +import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { inspectJadxAvailability, resolveJadxConfigurationInputs, @@ -66,8 +74,38 @@ const executionError = (context: { session: JadxSession | undefined; }): AnalysisError => { const { cause, request, timeout, signal, session } = context; + if (cause instanceof OwnedRegularFileCleanupFailure) { + const primary = + signal?.aborted === true + ? new AnalysisCancelledError(request.operation, { cause: cause.cause }) + : timeout.aborted + ? new AnalysisTimeoutError(request.operation, OPERATION_TIMEOUT_MS) + : new ProviderAdapterError("jadx", request.operation, { + cause: cause.cause, + userMessage: + cause.cause instanceof Error + ? cause.cause.message + : String(cause.cause), + }); + const cleanup = new ProviderCleanupError( + "jadx", + [cause.path], + { + reason: + cause.cleanupCause instanceof Error + ? cause.cleanupCause.message + : String(cause.cleanupCause), + ...(cause instanceof StableFileHashCleanupFailure && + cause.completedHash !== undefined + ? { completed_hash: cause.completedHash } + : {}), + }, + { operation: request.operation, cause: cause.cleanupCause }, + ); + return analysisErrorWithCleanupFailure(primary, cleanup, request.operation); + } if (signal?.aborted === true) - return new AnalysisCancelledError(request.operation); + return new AnalysisCancelledError(request.operation, { cause }); if (timeout.aborted) return new AnalysisTimeoutError(request.operation, OPERATION_TIMEOUT_MS); if (cause instanceof AnalysisError) return cause; @@ -156,57 +194,69 @@ const hashAndroidSource = async ( interface PendingCleanup { session: JadxSession | undefined; root: JadxWorkspaceRoot | undefined; + readonly fileHandles: Map; readonly previousError: AnalysisError | undefined; } const cleanup = async ( resources: PendingCleanup, ): Promise> => { - const { session, root, previousError } = resources; + const { session, root, previousError, fileHandles } = resources; const diagnostics = { previous_error: previousError === undefined ? null : projectAnalysisError(previousError), }; + const failures: { readonly resource: string; readonly cause: unknown }[] = []; + for (const [owner, path] of fileHandles) { + try { + await owner.close(); + fileHandles.delete(owner); + } catch (cause: unknown) { + failures.push({ resource: path, cause }); + } + } try { await session?.close(); resources.session = undefined; } catch (cause) { - // Retain an uncertain workspace until the caller can resolve process ownership. + failures.push({ resource: session?.transport.runId ?? "unknown", cause }); + } + if (resources.session === undefined && failures.length === 0) { + try { + await root?.close(); + resources.root = undefined; + } catch (cause: unknown) { + failures.push({ resource: root?.path ?? "unknown", cause }); + } + } + if (failures.length > 0) { + const cleanupCause = new AggregateError( + failures.map(({ cause }) => cause), + "JADX file, session, or workspace cleanup failed", + ); const error = new ProviderCleanupError( "jadx", - [ - session?.transport.runId ?? "unknown", - ...(root === undefined ? [] : [root.path]), - ], + failures.map(({ resource }) => resource), { ...diagnostics, - reason: cause instanceof Error ? cause.message : String(cause), - provider_cleanup: + reason: failures + .map(({ cause }) => + cause instanceof Error ? cause.message : String(cause), + ) + .join("; "), + provider_cleanup: failures.map(({ cause }) => cause instanceof ProviderCleanupError ? (cause.diagnostics ?? null) : null, + ), }, - { cause: previousError ?? cause }, - ); - if (previousError?.partialObservation !== undefined) - error.retainPartialObservation(previousError.partialObservation); - return err(error); - } - try { - await root?.close(); - resources.root = undefined; - } catch (cause) { - const error = new ProviderCleanupError( - "jadx", - [root?.path ?? "unknown"], { - ...diagnostics, - reason: cause instanceof Error ? cause.message : String(cause), + cause: previousError ?? cleanupCause, + ...(previousError?.partialObservation === undefined + ? {} + : { partialObservation: previousError.partialObservation }), }, - { cause: previousError ?? cause }, ); - if (previousError?.partialObservation !== undefined) - error.retainPartialObservation(previousError.partialObservation); return err(error); } return ok(undefined); @@ -299,6 +349,7 @@ export class JadxProvider implements AndroidAnalysisPort { this.#pendingCleanup = { session: this.#retained.session, root: this.#retained.root, + fileHandles: new Map(), previousError: undefined, }; this.#retained = undefined; @@ -471,6 +522,9 @@ export class JadxProvider implements AndroidAnalysisPort { this.#retained = retained; return outcome; } catch (cause) { + const fileHandles = new Map(); + if (cause instanceof OwnedRegularFileCleanupFailure) + fileHandles.set(cause.owner, cause.path); const previousError = executionError({ cause, request, @@ -485,7 +539,7 @@ export class JadxProvider implements AndroidAnalysisPort { root ??= this.#retained?.root; session ??= this.#retained?.session; this.#retained = undefined; - this.#pendingCleanup = { session, root, previousError }; + this.#pendingCleanup = { session, root, fileHandles, previousError }; const retired = await this.#retire(); return retired.ok ? err(previousError) : retired; } diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index d1d41800ce..20f80720c1 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -290,4 +290,6 @@ export interface AnalysisProviderCandidate extends AnalysisProvider { inspectAvailability(): ProviderAvailability; inspectReadinessSnapshot?(): AnalysisProviderReadinessSnapshot; inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport; + /** Release candidate-owned resources that may exist without a client. */ + closeOwnedResources?(): Promise>; } diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 7918582d03..7b4d31f57d 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -1,6 +1,7 @@ import { constants } from "node:fs"; import { access, readdir, stat } from "node:fs/promises"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import { join } from "node:path"; @@ -433,9 +434,12 @@ export interface SystemDoctorHostOptions { readonly architecture?: NodeJS.Architecture; readonly environment?: NodeJS.ProcessEnv; readonly execFileOutput?: typeof execFileOutput; - readonly providerInspections?: () => Promise< - readonly DoctorProviderInspection[] - >; + readonly providerInspections?: ( + adoptFileHandle: ( + resource: string, + handle: OwnedFileHandle, + ) => Promise, + ) => Promise; readonly providerRegistrationEnvironment?: () => Readonly< Record >; @@ -549,7 +553,19 @@ export const systemDoctorHost = ( manualHopperPaths: () => manualHopperPaths(homeDirectory), ...(options.providerInspections === undefined ? {} - : { providerInspections: options.providerInspections }), + : { + providerInspections: () => + options.providerInspections!( + async (resource, handle) => + ( + await resources.release({ + kind: "file-handle", + resource, + handle, + }) + ).kind === "released", + ), + }), ...(options.providerRegistrationEnvironment === undefined ? {} : { diff --git a/src/application/LinuxHopper.ts b/src/application/LinuxHopper.ts index 342fa20028..5130a29bbd 100644 --- a/src/application/LinuxHopper.ts +++ b/src/application/LinuxHopper.ts @@ -5,7 +5,6 @@ import { join } from "node:path"; import { execFileBoundedOutput } from "../process/ExecFileBoundedOutput.js"; import { execFileOutput } from "../process/ExecFileOutput.js"; import { downloadHopperPackage } from "./HopperDownload.js"; -import { hashStableFile } from "../filesystem/StableFileHash.js"; import { err, type Result } from "../domain/result.js"; import { HOPPER_RELEASES_URL, @@ -432,10 +431,15 @@ export const systemLinuxHopperInstallHost = ( !linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`) ) return "runtime_dependencies"; - return (await hashStableFile(path)).sha256 === expectedSha256 + return (await publisher.hashLauncher(path)).sha256 === expectedSha256 ? "ready" : "unsupported_hopper_build"; } catch (cause: unknown) { + if ( + cause instanceof HopperReleaseError && + cause.reason === "cleanup_failed" + ) + throw cause; // best-effort cleanup: optional launcher probing; unreadable means missing. void cause; return "missing"; diff --git a/src/application/LinuxHopperPublisher.ts b/src/application/LinuxHopperPublisher.ts index 79f09729e3..f267d0d623 100644 --- a/src/application/LinuxHopperPublisher.ts +++ b/src/application/LinuxHopperPublisher.ts @@ -11,6 +11,11 @@ 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 { + StableFileHashCleanupFailure, + type StableFileHashResult, +} from "../filesystem/StableFileHash.js"; +import { OwnedRegularFileCleanupFailure } from "../filesystem/RegularFile.js"; import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; import { verifyDetachedPgpSignature } from "../process/DetachedPgpSignature.js"; import { execFileOutput } from "../process/ExecFileOutput.js"; @@ -124,6 +129,46 @@ export class LinuxHopperPublisher { readonly packageFamily: () => Promise, ) {} + /** Hash a selected launcher while retaining any descriptor cleanup owner. */ + hashLauncher(path: string): Promise { + return this.#hashStableFile(path); + } + + async #hashStableFile( + path: string, + signal?: AbortSignal, + ): Promise { + try { + return await hashStableFile(path, signal); + } catch (cause: unknown) { + if (!(cause instanceof OwnedRegularFileCleanupFailure)) throw cause; + const cleanup = await this.#resources.release({ + kind: "file-handle", + resource: path, + handle: cause.owner, + }); + if (cleanup.kind === "failed") { + pendingCleanup.add(this.#resources); + throw new HopperReleaseError( + "cleanup_failed", + `Hopper publisher retained file descriptor cleanup for ${path}.`, + { + cause: new AggregateError( + [cause, cleanup.cause], + "Stable file hash and descriptor cleanup failed", + { cause }, + ), + }, + ); + } + if (cause instanceof StableFileHashCleanupFailure) { + if (cause.hasPrimaryCause) throw cause.cause; + if (cause.completedHash !== undefined) return cause.completedHash; + } + throw cause; + } + } + get cacheRoot(): string { const location = linuxHopperReleaseCacheDirectory(this.environment); if (!location.ok) throw location.error; @@ -293,8 +338,8 @@ export class LinuxHopperPublisher { await this.#withTemporary(async (stage) => { await copyFile(archive, join(stage, "archive")); if ( - (await hashStableFile(join(stage, "archive"), signal)).sha256 !== - verified.packageSha256 + (await this.#hashStableFile(join(stage, "archive"), signal)) + .sha256 !== verified.packageSha256 ) throw new HopperReleaseError( "integrity", @@ -435,10 +480,12 @@ export class LinuxHopperPublisher { signal?: AbortSignal, ): Promise> { try { - const observed = await hashStableFile(path, signal); + const observed = await this.#hashStableFile(path, signal); const cached = await this.#cachedPackage(observed.sha256, signal); if (cached.ok) { - if ((await hashStableFile(path, signal)).sha256 !== observed.sha256) + if ( + (await this.#hashStableFile(path, signal)).sha256 !== observed.sha256 + ) return err( new HopperReleaseError( "integrity", @@ -490,7 +537,7 @@ export class LinuxHopperPublisher { if (!verified.ok) throw verified.error; return verified.value; }); - if ((await hashStableFile(path, signal)).sha256 !== observed.sha256) + if ((await this.#hashStableFile(path, signal)).sha256 !== observed.sha256) return err( new HopperReleaseError( "integrity", @@ -683,7 +730,7 @@ export class LinuxHopperPublisher { commandOptions, ); return ( - await hashStableFile( + await this.#hashStableFile( join(extracted, "opt", "hopper", "bin", "Hopper"), signal, ) diff --git a/src/application/binary/AnalysisProviderRegistry.ts b/src/application/binary/AnalysisProviderRegistry.ts index c6a1739a31..e0bf6e962c 100644 --- a/src/application/binary/AnalysisProviderRegistry.ts +++ b/src/application/binary/AnalysisProviderRegistry.ts @@ -7,6 +7,7 @@ import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js" import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { ProviderSelectionError, type ProviderSelectionFailureReason, @@ -135,6 +136,28 @@ export class AnalysisProviderRegistry { ); } + /** Close candidate-owned resources even when no target client was opened. */ + async closeOwnedResources(): Promise> { + const failures: AnalysisError[] = []; + for (const provider of this.#providers) { + if (provider.closeOwnedResources === undefined) continue; + const result = await provider.closeOwnedResources(); + if (!result.ok) failures.push(result.error); + } + if (failures.length === 0) return ok(null); + if (failures.length === 1) return err(failures[0]!); + return err( + new ProviderCleanupError( + "rea", + failures.flatMap((failure) => failure.cleanupResources), + { + reason: failures.map((failure) => failure.message).join("; "), + }, + { cause: new AggregateError(failures) }, + ), + ); + } + /** List the unique deep-operation declarations without probing candidates. */ declaredOperations(): readonly string[] { return [...this.#declaredOperations]; diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 2494e511d4..0eb51ab348 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -499,15 +499,26 @@ export class BinarySession if (clearRecords) this.clearSessionRecords(); this.#clearRuntimeAvailability(); const fileResources = await this.closeWriteResources(); + const candidates = await this.#providerRouter.closeOwnedResources(); + let lifecycle: Result; if (!closed.ok && !fileResources.ok) - return err( + lifecycle = err( analysisErrorWithCleanupFailure( closed.error, fileResources.error, "close_binary", ), ); - return closed.ok ? fileResources : closed; + else lifecycle = closed.ok ? fileResources : closed; + if (!lifecycle.ok && !candidates.ok) + return err( + analysisErrorWithCleanupFailure( + lifecycle.error, + candidates.error, + "close_binary", + ), + ); + return lifecycle.ok ? candidates : lifecycle; } async #retireClient( diff --git a/src/application/binary/SessionProviderRouter.ts b/src/application/binary/SessionProviderRouter.ts index cf56535370..ebb5385197 100644 --- a/src/application/binary/SessionProviderRouter.ts +++ b/src/application/binary/SessionProviderRouter.ts @@ -70,6 +70,11 @@ export class SessionProviderRouter { return this.route; } + /** Close provider owners not represented by the currently bound client. */ + closeOwnedResources() { + return this.registry.closeOwnedResources(); + } + /** Resolve target routes before any provider client is created. */ async resolve( target: BinaryTarget, diff --git a/src/doctorRuntime.ts b/src/doctorRuntime.ts index e85238db4f..0384937f0b 100644 --- a/src/doctorRuntime.ts +++ b/src/doctorRuntime.ts @@ -42,11 +42,11 @@ export const createSystemDoctorHost = ( // parser-owned error when IDA itself was explicitly selected. return analysisProvider === "ida" ? registration : ok(undefined); }, - providerInspections: async () => [ + providerInspections: async (adoptFileHandle) => [ await inspectSystemGhidraProvider(environment), inspectIdaRegistration(environment.REA_IDA_MCP_CONFIG), await inspectJadxProvider(environment), - await inspectKunaProvider(environment), + await inspectKunaProvider(environment, process.platform, adoptFileHandle), ], linuxDemoRuntimeCheck: () => inspectLinuxPrivateDisplay(environment), }); diff --git a/src/filesystem/RegularFile.ts b/src/filesystem/RegularFile.ts index 47fdfa1d85..9b416f7924 100644 --- a/src/filesystem/RegularFile.ts +++ b/src/filesystem/RegularFile.ts @@ -19,21 +19,38 @@ export class NonRegularFileReadError extends Error { } } -/** Admission failed and the opened descriptor could not be confirmed closed. */ -export class RegularFileAdmissionFailure extends Error { +/** One regular-file operation failed while its opened descriptor could not be confirmed closed. */ +export class OwnedRegularFileCleanupFailure extends Error { override readonly cause: unknown; constructor( readonly path: string, - admissionCause: unknown, + cause: unknown, readonly cleanupCause: unknown, readonly owner: OwnedFileHandle, + message: string, + ) { + super(message, { cause }); + this.cause = cause; + } +} + +/** Admission failed and the opened descriptor could not be confirmed closed. */ +export class RegularFileAdmissionFailure extends OwnedRegularFileCleanupFailure { + constructor( + path: string, + admissionCause: unknown, + cleanupCause: unknown, + owner: OwnedFileHandle, ) { super( + path, + admissionCause, + cleanupCause, + owner, `Selected input failed admission and its descriptor close failed: ${path}`, ); this.name = "RegularFileAdmissionFailure"; - this.cause = admissionCause; } } diff --git a/src/filesystem/StableFileHash.ts b/src/filesystem/StableFileHash.ts index 11c9193613..066d44e03d 100644 --- a/src/filesystem/StableFileHash.ts +++ b/src/filesystem/StableFileHash.ts @@ -2,20 +2,52 @@ import { createHash } from "node:crypto"; import { stat } from "node:fs/promises"; import { + OwnedRegularFileCleanupFailure, openRegularFile, RegularFileChangedError, sameRegularFileState, } from "./RegularFile.js"; +import { OwnedFileHandle } from "./OwnedFileHandle.js"; + +export type StableFileHashResult = { + readonly sha256: string; + readonly bytes: number; +}; + +/** Hashing finished or failed, but the owned descriptor close was rejected. */ +export class StableFileHashCleanupFailure extends OwnedRegularFileCleanupFailure { + constructor( + path: string, + primaryCause: unknown, + readonly hasPrimaryCause: boolean, + cleanupCause: unknown, + owner: OwnedFileHandle, + readonly completedHash: StableFileHashResult | undefined, + ) { + super( + path, + hasPrimaryCause ? primaryCause : cleanupCause, + cleanupCause, + owner, + `Stable file hashing could not confirm descriptor close: ${path}`, + ); + this.name = "StableFileHashCleanupFailure"; + } +} /** Hash one regular file from its admitted descriptor and verify its path state. */ export const hashStableFile = async ( path: string, signal?: AbortSignal, -): Promise<{ readonly sha256: string; readonly bytes: number }> => { +): Promise => { const handle = await openRegularFile(path, { symlinks: "follow", signal, }); + const owner = new OwnedFileHandle(handle); + let primaryCause: unknown; + let hasPrimaryCause = false; + let completedHash: StableFileHashResult | undefined; try { const initial = await handle.stat(); if (!Number.isSafeInteger(initial.size) || initial.size < 0) @@ -48,8 +80,25 @@ export const hashStableFile = async ( !sameRegularFileState(initial, currentPath) ) throw new RegularFileChangedError(path); - return { sha256: hash.digest("hex"), bytes: position }; - } finally { - await handle.close(); + completedHash = { sha256: hash.digest("hex"), bytes: position }; + } catch (cause: unknown) { + primaryCause = cause; + hasPrimaryCause = true; } + try { + await owner.close(); + } catch (cleanupCause: unknown) { + throw new StableFileHashCleanupFailure( + path, + primaryCause, + hasPrimaryCause, + cleanupCause, + owner, + completedHash, + ); + } + if (hasPrimaryCause) throw primaryCause; + if (completedHash === undefined) + throw new Error("Stable file hashing completed without a digest"); + return completedHash; }; diff --git a/src/kuna/KunaCommand.ts b/src/kuna/KunaCommand.ts index 669a6709e8..0061059992 100644 --- a/src/kuna/KunaCommand.ts +++ b/src/kuna/KunaCommand.ts @@ -16,7 +16,12 @@ import { OwnedCommandFailure, runOwnedCommand, } from "../process/OwnedCommand.js"; -import { hashStableFile } from "../filesystem/StableFileHash.js"; +import { + hashStableFile, + StableFileHashCleanupFailure, +} from "../filesystem/StableFileHash.js"; +import { OwnedRegularFileCleanupFailure } from "../filesystem/RegularFile.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import type { OwnedProviderProcessSpawnOptions, ProviderProcessSnapshot, @@ -63,7 +68,11 @@ export interface KunaCommandRequest { */ export type KunaCommandOutcome = | { readonly kind: "ok"; readonly snapshot: ProviderProcessSnapshot } - | { readonly kind: "failed"; readonly error: AnalysisError } + | { + readonly kind: "failed"; + readonly error: AnalysisError; + readonly fileCleanup?: OwnedRegularFileCleanupFailure; + } | { readonly kind: "retained"; readonly error: ProviderCleanupError; @@ -124,6 +133,38 @@ export const runKunaCommand = async ( }), }; } catch (cause: unknown) { + if (cause instanceof OwnedRegularFileCleanupFailure) { + const primary = request.signal.aborted + ? new AnalysisCancelledError(request.operation, { cause: cause.cause }) + : new ProviderAdapterError(KUNA_PROVIDER_ID, request.operation, { + cause: cause.cause, + userMessage: `Cannot verify ${installation.executable}: ${message(cause.cause)}`, + }); + const cleanup = new ProviderCleanupError( + KUNA_PROVIDER_ID, + [cause.path], + { + reason: + cause.cleanupCause instanceof Error + ? cause.cleanupCause.message + : String(cause.cleanupCause), + ...(cause instanceof StableFileHashCleanupFailure && + cause.completedHash !== undefined + ? { completed_hash: cause.completedHash } + : {}), + }, + { operation: request.operation, cause: cause.cleanupCause }, + ); + return { + kind: "failed", + error: analysisErrorWithCleanupFailure( + primary, + cleanup, + request.operation, + ), + fileCleanup: cause, + }; + } return { kind: "failed", error: request.signal.aborted diff --git a/src/kuna/KunaDoctor.ts b/src/kuna/KunaDoctor.ts index e8166b2163..049f083e16 100644 --- a/src/kuna/KunaDoctor.ts +++ b/src/kuna/KunaDoctor.ts @@ -6,6 +6,7 @@ import type { DoctorProviderCheck, DoctorProviderInspection, } from "../application/Doctor.js"; +import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { readKunaConfiguration } from "./KunaConfiguration.js"; import { inspectKunaInstallation, @@ -29,10 +30,34 @@ const REGISTRATION_SETTINGS = [ "REA_KUNA_JOBS", ] as const; +/** Readiness failed and doctor has no lifecycle owner for the hash descriptor. */ +export class KunaDoctorHashCleanupFailure extends Error { + constructor( + readonly readinessFailure: KunaReadinessFailure, + readonly owner: OwnedFileHandle, + readonly cleanupCause: unknown, + ) { + super( + `${readinessFailure.reason}; the executable hash descriptor could not be closed`, + { + cause: + readinessFailure.code === "hash_cleanup_failed" + ? readinessFailure.hashCleanupFailure + : cleanupCause, + }, + ); + this.name = "KunaDoctorHashCleanupFailure"; + } +} + /** Project Kuna readiness into the doctor contract without writing anything. */ export const inspectKunaProvider = async ( environment: Readonly, platform: NodeJS.Platform = process.platform, + adoptFileHandle?: ( + resource: string, + handle: OwnedFileHandle, + ) => Promise, ): Promise => { const installation = await inspectKunaInstallation(environment, { platform, @@ -40,15 +65,39 @@ export const inspectKunaProvider = async ( const configured = environment.REA_KUNA_COMMAND !== undefined && environment.REA_KUNA_COMMAND.length > 0; - if (!installation.ok) + if (!installation.ok) { + let cleanupDetail: string | undefined; + const hashFailure = + installation.error.code === "hash_cleanup_failed" + ? installation.error.hashCleanupFailure + : undefined; + if (hashFailure !== undefined) { + if (adoptFileHandle === undefined) { + throw new KunaDoctorHashCleanupFailure( + installation.error, + hashFailure.owner, + hashFailure.cleanupCause, + ); + } else { + const closed = await adoptFileHandle( + hashFailure.path, + hashFailure.owner, + ); + if (!closed) + cleanupDetail = "Executable hash cleanup is still pending."; + } + } return { id: KUNA_PROVIDER_ID, configured, available: false, providerVersion: null, registrationEnvironment: {}, - checks: [installationFailure(installation.error, configured)], + checks: [ + installationFailure(installation.error, configured, cleanupDetail), + ], }; + } const { value } = installation; return { id: KUNA_PROVIDER_ID, @@ -88,11 +137,15 @@ export const inspectKunaProvider = async ( const installationFailure = ( failure: KunaReadinessFailure, configured: boolean, + cleanupDetail?: string, ): DoctorProviderCheck => ({ name: "installation", ok: false, code: failure.code, - detail: failure.reason, + detail: + cleanupDetail === undefined + ? failure.reason + : `${failure.reason} ${cleanupDetail}`, remediation: failure.code === "unsupported_host" ? KUNA_UNSUPPORTED_HOST_REMEDIATION diff --git a/src/kuna/KunaInstallation.ts b/src/kuna/KunaInstallation.ts index 5ba0c58b18..9d62dc8e2d 100644 --- a/src/kuna/KunaInstallation.ts +++ b/src/kuna/KunaInstallation.ts @@ -4,7 +4,11 @@ import { join } from "node:path"; import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; -import { hashStableFile } from "../filesystem/StableFileHash.js"; +import { + hashStableFile, + StableFileHashCleanupFailure, +} from "../filesystem/StableFileHash.js"; +import { OwnedRegularFileCleanupFailure } from "../filesystem/RegularFile.js"; import { execFileOutput, execFileOutputFailure, @@ -46,7 +50,7 @@ export interface KunaInstallation { } /** Why the selected Kuna installation cannot be used. */ -export interface KunaReadinessFailure { +interface KunaReadinessFailureBase { readonly code: | KunaConfigurationFailure["code"] | "executable_missing" @@ -55,6 +59,19 @@ export interface KunaReadinessFailure { readonly diagnostics: Readonly>; } +/** Hash close failure with one owner and any already-completed installation facts. */ +export interface KunaInstallationHashCleanupFailure { + readonly code: "hash_cleanup_failed"; + readonly reason: string; + readonly diagnostics: Readonly>; + readonly hashCleanupFailure: OwnedRegularFileCleanupFailure; + readonly completedInstallation?: KunaInstallation; +} + +export type KunaReadinessFailure = + | KunaReadinessFailureBase + | KunaInstallationHashCleanupFailure; + /** * Environment for every Kuna child. Kuna unpacks its built-in SLEIGH tree into * the per-user cache under HOME (macOS) or XDG_CACHE_HOME (Linux), so both @@ -72,10 +89,11 @@ export const kunaChildEnvironment = ( }; const failure = ( - code: KunaReadinessFailure["code"], + code: KunaReadinessFailureBase["code"], reason: string, diagnostics: Readonly>, -): Result => err({ code, reason, diagnostics }); +): Result => + err({ code, reason, diagnostics }); const message = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); @@ -156,11 +174,38 @@ export const inspectKunaInstallation = async ( ); let fingerprint: { readonly sha256: string; readonly bytes: number }; - let script: boolean; + let script: boolean | undefined; try { - fingerprint = await hashStableFile(executable, options.signal); script = await startsWithShebang(executable); + fingerprint = await hashStableFile(executable, options.signal); } catch (cause: unknown) { + if (cause instanceof OwnedRegularFileCleanupFailure) + return err({ + code: "hash_cleanup_failed", + reason: `Cannot fingerprint ${executable}: ${message(cause)}`, + diagnostics: { + command, + executable, + version, + ...(typeof script === "boolean" ? { script } : {}), + }, + hashCleanupFailure: cause, + ...(cause instanceof StableFileHashCleanupFailure && + !cause.hasPrimaryCause && + cause.completedHash !== undefined && + script !== undefined + ? { + completedInstallation: { + configuration: configuration.value, + executable, + version, + verified: version === KUNA_VERIFIED_VERSION, + ...cause.completedHash, + script, + }, + } + : {}), + }); options.signal?.throwIfAborted(); return failure( "executable_missing", @@ -169,6 +214,13 @@ export const inspectKunaInstallation = async ( ); } + if (script === undefined) + return failure( + "executable_missing", + `Cannot inspect Kuna executable ${executable}.`, + { command, executable }, + ); + return ok({ configuration: configuration.value, executable, diff --git a/src/kuna/KunaProjectCache.ts b/src/kuna/KunaProjectCache.ts index e137243423..2fc892a7f8 100644 --- a/src/kuna/KunaProjectCache.ts +++ b/src/kuna/KunaProjectCache.ts @@ -23,6 +23,8 @@ import { import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; import { hashStableFile } from "../filesystem/StableFileHash.js"; +import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { OwnedRegularFileCleanupFailure } from "../filesystem/RegularFile.js"; import type { KunaInstallation } from "./KunaInstallation.js"; import { exportKunaProject, @@ -224,6 +226,15 @@ export class KunaProjectCache { string, { readonly key: string | null; readonly error: ProviderCleanupError } >(); + /** Hash descriptors remain owned here until the exact handle closes. */ + readonly #retainedHashHandles = new Map< + OwnedFileHandle, + { + readonly key: string; + readonly path: string; + readonly error: ProviderCleanupError; + } + >(); constructor( root: string, @@ -260,6 +271,8 @@ export class KunaProjectCache { key: string, signal?: AbortSignal, ): Promise { + const pendingHashCleanup = await this.#retryHashHandles(key); + if (pendingHashCleanup !== undefined) throw pendingHashCleanup; const verified = this.#verified.get(key); if (verified !== undefined) { // Another process may have evicted it since this one verified it. @@ -288,6 +301,15 @@ export class KunaProjectCache { try { observed = await hashStableFile(join(directory, file.path), signal); } catch (cause: unknown) { + if (cause instanceof OwnedRegularFileCleanupFailure) { + this.#retainHashHandle( + key, + cause.path, + cause.owner, + cause.cleanupCause, + ); + throw cause; + } signal?.throwIfAborted(); if (!isDamaged(cause)) throw cause; await this.#discard(key); @@ -373,11 +395,15 @@ export class KunaProjectCache { ); await this.#retryProcesses((retained) => retained.key === key); await this.#retryFiles(key); - return cleanupResult( - [...this.#retainedProcesses, ...this.#retainedFiles.values()] + await this.#retryHashHandles(key); + return cleanupResult([ + ...[...this.#retainedProcesses, ...this.#retainedFiles.values()] .filter((retained) => retained.key === key) .map(({ error }) => error), - ); + ...[...this.#retainedHashHandles.values()] + .filter((retained) => retained.key === key) + .map(({ error }) => error), + ]); } /** Wait for builds, retry safe file removal, and report unresolved cleanup. */ @@ -388,9 +414,11 @@ export class KunaProjectCache { while (this.#maintenance !== undefined) await this.#maintenance; await this.#retryProcesses(() => true); await this.#retryFiles(); + await this.#retryHashHandles(); return cleanupResult([ ...this.#retainedProcesses.map(({ error }) => error), ...[...this.#retainedFiles.values()].map(({ error }) => error), + ...[...this.#retainedHashHandles.values()].map(({ error }) => error), ]); } @@ -416,6 +444,49 @@ export class KunaProjectCache { await this.#removeRetired(path).catch(() => undefined); } + async #retryHashHandles( + key?: string, + ): Promise { + for (const [owner, retained] of [...this.#retainedHashHandles]) { + if (key !== undefined && retained.key !== key) continue; + try { + await owner.close(); + this.#retainedHashHandles.delete(owner); + } catch { + // Preserve the same owner and its original cleanup failure for callers. + } + } + const remaining = [...this.#retainedHashHandles.values()].filter( + (retained) => key === undefined || retained.key === key, + ); + if (remaining.length === 0) return undefined; + const result = cleanupResult(remaining.map(({ error }) => error)); + return result.ok ? undefined : result.error; + } + + #retainHashHandle( + key: string, + path: string, + owner: OwnedFileHandle, + cause: unknown, + ): void { + this.#retainedHashHandles.set(owner, { + key, + path, + error: new ProviderCleanupError( + KUNA_PROVIDER_ID, + [path], + { reason: cause instanceof Error ? cause.message : String(cause) }, + { operation: KUNA_EXPORT_OPERATION, cause }, + ), + }); + } + + #adoptHashFailure(key: string, cause: unknown): void { + if (cause instanceof OwnedRegularFileCleanupFailure) + this.#retainHashHandle(key, cause.path, cause.owner, cause.cleanupCause); + } + /** * Move proven-stopped staging into trash and remove it. A failed removal * stays owned as a file cleanup that close retries. @@ -494,6 +565,7 @@ export class KunaProjectCache { await this.#sweep(signal); staging = await mkdtemp(join(this.#root, "staging", "build-")); } catch (cause: unknown) { + this.#adoptHashFailure(key, cause); return err(kunaExportFailure(cause, signal)); } const built = await this.#exportAndPublish( @@ -502,10 +574,13 @@ export class KunaProjectCache { request, signal, onStatus, - ).catch((cause: unknown): StagedResult => ({ - result: err(kunaExportFailure(cause, signal)), - retainStaging: false, - })); + ).catch((cause: unknown): StagedResult => { + this.#adoptHashFailure(key, cause); + return { + result: err(kunaExportFailure(cause, signal)), + retainStaging: false, + }; + }); if (built.retainStaging) return built.result; // Only a proven-stopped build may enter trash. A failed write, crash or // lost cleanup identity therefore never makes live staging reclaimable. @@ -543,6 +618,8 @@ export class KunaProjectCache { }); return { result: err(outcome.error), retainStaging: true }; } + if (outcome.fileCleanup !== undefined) + this.#adoptHashFailure(key, outcome.fileCleanup); return { result: outcome.result.ok ? await this.#publish( diff --git a/src/kuna/KunaProjectExport.ts b/src/kuna/KunaProjectExport.ts index 25b25606d9..9a2d28b270 100644 --- a/src/kuna/KunaProjectExport.ts +++ b/src/kuna/KunaProjectExport.ts @@ -8,9 +8,12 @@ import { AnalysisCancelledError, } from "../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; -import type { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { err, ok, type Result } from "../domain/result.js"; import { hashStableFile } from "../filesystem/StableFileHash.js"; +import { StableFileHashCleanupFailure } from "../filesystem/StableFileHash.js"; +import { OwnedRegularFileCleanupFailure } from "../filesystem/RegularFile.js"; import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js"; import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; import { @@ -83,6 +86,7 @@ export type KunaExportOutcome = | { readonly kind: "done"; readonly result: Result; + readonly fileCleanup?: OwnedRegularFileCleanupFailure; } | { readonly kind: "retained"; @@ -118,6 +122,36 @@ export const kunaExportFailure = ( cause: unknown, signal: AbortSignal, ): AnalysisError => { + if (cause instanceof OwnedRegularFileCleanupFailure) { + const primary = signal.aborted + ? new AnalysisCancelledError(KUNA_EXPORT_OPERATION, { + cause: cause.cause, + }) + : new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, { + cause: cause.cause, + userMessage: `Kuna project cache failed: ${message(cause.cause)}`, + }); + const cleanup = new ProviderCleanupError( + KUNA_PROVIDER_ID, + [cause.path], + { + reason: + cause.cleanupCause instanceof Error + ? cause.cleanupCause.message + : String(cause.cleanupCause), + ...(cause instanceof StableFileHashCleanupFailure && + cause.completedHash !== undefined + ? { completed_hash: cause.completedHash } + : {}), + }, + { operation: KUNA_EXPORT_OPERATION, cause: cause.cleanupCause }, + ); + return analysisErrorWithCleanupFailure( + primary, + cleanup, + KUNA_EXPORT_OPERATION, + ); + } if (signal.aborted) return new AnalysisCancelledError(KUNA_EXPORT_OPERATION, { cause }); if (cause instanceof AnalysisError) return cause; @@ -206,7 +240,13 @@ export const exportKunaProject = async ( error: outcome.error, owner: outcome.owner, } as const) - : ({ kind: "done", result: err(outcome.error) } as const), + : ({ + kind: "done", + result: err(outcome.error), + ...(outcome.fileCleanup === undefined + ? {} + : { fileCleanup: outcome.fileCleanup }), + } as const), }; }; diff --git a/src/kuna/KunaProvider.ts b/src/kuna/KunaProvider.ts index c2a103431a..c54a852c65 100644 --- a/src/kuna/KunaProvider.ts +++ b/src/kuna/KunaProvider.ts @@ -9,19 +9,27 @@ import type { ProviderTargetSupport, } from "../application/AnalysisProvider.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { createAnalysisProfile, type AnalysisProfileCommitment, } from "../domain/analysisProfile.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { err, ok } from "../domain/result.js"; +import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { StableFileHashCleanupFailure } from "../filesystem/StableFileHash.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import type { KunaProcessLauncher } from "./KunaCommand.js"; import { readKunaConfiguration, type KunaConfiguration, } from "./KunaConfiguration.js"; -import { inspectKunaInstallation } from "./KunaInstallation.js"; +import { + inspectKunaInstallation, + type KunaInstallation, +} from "./KunaInstallation.js"; import { KunaProjectCache } from "./KunaProjectCache.js"; import { KUNA_PROVIDER_IDENTITY, @@ -45,6 +53,7 @@ const VERIFIED_ARCHITECTURES: ReadonlySet = new Set([ */ export class KunaProvider implements AnalysisProviderCandidate { readonly #environment: Readonly; + readonly #readinessFileOwners = new Map(); readonly #platform: NodeJS.Platform; readonly #launcher: KunaProcessLauncher | undefined; readonly #singleFunctionRuns: number | undefined; @@ -162,13 +171,83 @@ export class KunaProvider implements AnalysisProviderCandidate { ) { if (options?.signal?.aborted === true) return err(new AnalysisCancelledError("kuna:profile")); + const pending = await this.#closeReadinessOwners(); + if (!pending.ok) return pending; const installation = await inspectKunaInstallation(this.#environment, { platform: this.#platform, ...(options?.signal === undefined ? {} : { signal: options.signal }), }); - if (!installation.ok) - return err(kunaUnavailable("open_binary", installation.error.reason)); - const { value } = installation; + let value: KunaInstallation; + if (!installation.ok) { + const readiness = installation.error; + const hashFailure = + readiness.code === "hash_cleanup_failed" + ? readiness.hashCleanupFailure + : undefined; + if (hashFailure !== undefined) { + this.#readinessFileOwners.set(hashFailure.owner, hashFailure.path); + try { + await hashFailure.owner.close(); + this.#readinessFileOwners.delete(hashFailure.owner); + } catch (cleanupCause: unknown) { + const cleanup = new ProviderCleanupError( + KUNA_PROVIDER_ID, + [hashFailure.path], + { + reason: + cleanupCause instanceof Error + ? cleanupCause.message + : String(cleanupCause), + ...(hashFailure instanceof StableFileHashCleanupFailure && + hashFailure.completedHash !== undefined + ? { completed_hash: hashFailure.completedHash } + : {}), + }, + { operation: "open_binary", cause: cleanupCause }, + ); + const primary = options?.signal?.aborted + ? new AnalysisCancelledError("kuna:profile", { + cause: hashFailure.cause, + }) + : hashFailure instanceof StableFileHashCleanupFailure && + hashFailure.hasPrimaryCause + ? new ProviderAdapterError(KUNA_PROVIDER_ID, "open_binary", { + cause: hashFailure.cause, + userMessage: readiness.reason, + }) + : kunaUnavailable("open_binary", readiness.reason); + return err( + analysisErrorWithCleanupFailure(primary, cleanup, "open_binary"), + ); + } + if ( + hashFailure instanceof StableFileHashCleanupFailure && + hashFailure.hasPrimaryCause + ) { + if (options?.signal?.aborted) + return err( + new AnalysisCancelledError("kuna:profile", { + cause: hashFailure.cause, + }), + ); + return err( + new ProviderAdapterError(KUNA_PROVIDER_ID, "open_binary", { + cause: hashFailure.cause, + userMessage: readiness.reason, + }), + ); + } + const completedInstallation = + readiness.code === "hash_cleanup_failed" + ? readiness.completedInstallation + : undefined; + if (completedInstallation === undefined) + return err(kunaUnavailable("open_binary", installation.error.reason)); + value = completedInstallation; + } else { + return err(kunaUnavailable("open_binary", installation.error.reason)); + } + } else value = installation.value; return ok({ profile: createAnalysisProfile( { ...KUNA_PROVIDER_IDENTITY, version: value.version }, @@ -198,6 +277,48 @@ export class KunaProvider implements AnalysisProviderCandidate { }); } + /** Release candidate resources even when no target client was opened. */ + async closeOwnedResources() { + const readiness = await this.#closeReadinessOwners(); + const cache = await this.#cache?.close(); + if (readiness.ok && (cache === undefined || cache.ok)) return ok(null); + if (!readiness.ok && (cache === undefined || cache.ok)) return readiness; + if (readiness.ok && cache !== undefined && !cache.ok) return cache; + const failures = [ + ...(readiness.ok ? [] : [readiness.error]), + ...(cache === undefined || cache.ok ? [] : [cache.error]), + ]; + return err( + new ProviderCleanupError( + KUNA_PROVIDER_ID, + failures.flatMap((failure) => failure.cleanupResources), + { reason: failures.map((failure) => failure.message).join("; ") }, + { cause: new AggregateError(failures) }, + ), + ); + } + + async #closeReadinessOwners() { + const failures: { readonly path: string; readonly cause: unknown }[] = []; + for (const [owner, path] of this.#readinessFileOwners) { + try { + await owner.close(); + this.#readinessFileOwners.delete(owner); + } catch (cause: unknown) { + failures.push({ path, cause }); + } + } + if (failures.length === 0) return ok(null); + return err( + new ProviderCleanupError( + KUNA_PROVIDER_ID, + failures.map(({ path }) => path), + { reason: "Kuna executable hash descriptors remain open" }, + { operation: "open_binary", cause: new AggregateError(failures) }, + ), + ); + } + createClient( target: BinaryTarget, profile?: AnalysisProfileCommitment, diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index add5794952..e11a62bce1 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -30,6 +30,8 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { RelationshipCoverage } from "../domain/relationshipCoverage.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { StableFileHashCleanupFailure } from "../filesystem/StableFileHash.js"; import { err, ok, type Result } from "../domain/result.js"; import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js"; import { @@ -1271,8 +1273,42 @@ export class KunaSessionClient implements AnalysisClient { > { this.#installation ??= inspectKunaInstallation(this.#environment).then( (inspected): Result => { - if (!inspected.ok) + if (!inspected.ok) { + const hashFailure = + inspected.error.code === "hash_cleanup_failed" + ? inspected.error.hashCleanupFailure + : undefined; + if (hashFailure !== undefined) { + this.#commands.retainHashCleanup(hashFailure); + const primary = this.#session.signal.aborted + ? new AnalysisCancelledError("health", { + cause: hashFailure.cause, + }) + : new ProviderAdapterError(KUNA_PROVIDER_ID, "health", { + cause: hashFailure.cause, + userMessage: inspected.error.reason, + }); + const cleanup = new ProviderCleanupError( + KUNA_PROVIDER_ID, + [hashFailure.path], + { + reason: + hashFailure.cleanupCause instanceof Error + ? hashFailure.cleanupCause.message + : String(hashFailure.cleanupCause), + ...(hashFailure instanceof StableFileHashCleanupFailure && + hashFailure.completedHash !== undefined + ? { completed_hash: hashFailure.completedHash } + : {}), + }, + { operation: "health", cause: hashFailure.cleanupCause }, + ); + return err( + analysisErrorWithCleanupFailure(primary, cleanup, "health"), + ); + } return err(kunaUnavailable("health", inspected.error.reason)); + } const committed = this.#profile?.parameters["kuna_sha256"]; if (committed !== undefined && committed !== inspected.value.sha256) return err( diff --git a/src/kuna/KunaTargetCommands.ts b/src/kuna/KunaTargetCommands.ts index 357ca9bfc2..dd6b9d8076 100644 --- a/src/kuna/KunaTargetCommands.ts +++ b/src/kuna/KunaTargetCommands.ts @@ -10,6 +10,8 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; +import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { OwnedRegularFileCleanupFailure } from "../filesystem/RegularFile.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js"; import type { @@ -55,6 +57,7 @@ export class KunaTargetCommands { readonly releaseRun: KunaRunRelease | undefined; }[] = []; readonly #roots: PrivateRuntimeRoot[] = []; + readonly #fileHandles = new Map(); #workspace: Promise> | undefined; #closing = false; @@ -78,6 +81,11 @@ export class KunaTargetCommands { return this.#own(this.#run(request)); } + /** Retain a hash owner whose readiness error has been projected by a session. */ + retainHashCleanup(failure: OwnedRegularFileCleanupFailure): void { + this.#fileHandles.set(failure.owner, failure.path); + } + #own(work: Promise): Promise { this.#active.add(work); const release = () => this.#active.delete(work); @@ -90,9 +98,12 @@ export class KunaTargetCommands { if (this.#closing || this.lifetime.signal.aborted) return Promise.resolve(err(new AnalysisCancelledError("kuna:workspace"))); return this.#own( - this.#ensureWorkspace().then((workspace) => - workspace.ok ? ok(null) : workspace, - ), + (async () => { + const pending = await this.#retryFileHandles(); + if (pending !== undefined) return err(pending); + const workspace = await this.#ensureWorkspace(); + return workspace.ok ? ok(null) : workspace; + })(), ); } @@ -102,6 +113,8 @@ export class KunaTargetCommands { const { releaseRun, ...options } = request; let retained = false; try { + const pending = await this.#retryFileHandles(); + if (pending !== undefined) return err(pending); const workspace = await this.#ensureWorkspace(); if (!workspace.ok) return workspace; const outcome = await runKunaCommand({ @@ -120,6 +133,11 @@ export class KunaTargetCommands { retained = true; return err(outcome.error); } + if (outcome.kind === "failed" && outcome.fileCleanup !== undefined) + this.#fileHandles.set( + outcome.fileCleanup.owner, + outcome.fileCleanup.path, + ); return outcome.kind === "ok" ? ok({ ...outcome.snapshot, input: workspace.value.input }) : err(outcome.error); @@ -140,8 +158,10 @@ export class KunaTargetCommands { } } const failures = this.#retained.map(({ error }) => error); + const fileFailure = await this.#retryFileHandles(); + if (fileFailure !== undefined) failures.push(fileFailure); const workspace = await this.#workspace; - if (this.#retained.length === 0) { + if (this.#retained.length === 0 && fileFailure === undefined) { if (workspace?.ok === true) { this.#roots.push(workspace.value.root); this.#workspace = undefined; @@ -180,6 +200,32 @@ export class KunaTargetCommands { ); } + async #retryFileHandles(): Promise { + const failures: ProviderCleanupError[] = []; + for (const [owner, path] of this.#fileHandles) { + try { + await owner.close(); + this.#fileHandles.delete(owner); + } catch (cause: unknown) { + failures.push( + new ProviderCleanupError( + KUNA_PROVIDER_ID, + [path], + { reason: cause instanceof Error ? cause.message : String(cause) }, + { cause }, + ), + ); + } + } + return failures.length === 0 + ? undefined + : new ProviderCleanupError( + KUNA_PROVIDER_ID, + failures.flatMap((failure) => failure.cleanupResources), + { reason: failures.map((failure) => failure.message).join("; ") }, + ); + } + async #ensureWorkspace(): Promise> { this.#workspace ??= (async () => { const root = await PrivateRuntimeRoot.create({ prefix: "rea-kuna-" }); diff --git a/tests/boundary/filesystem/stableFileHashOwnership.test.ts b/tests/boundary/filesystem/stableFileHashOwnership.test.ts new file mode 100644 index 0000000000..792096b301 --- /dev/null +++ b/tests/boundary/filesystem/stableFileHashOwnership.test.ts @@ -0,0 +1,177 @@ +import { + mkdtemp, + open, + rm, + writeFile, + type FileHandle, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, expect, it, vi } from "vitest"; + +import { openRegularFile } from "../../../src/filesystem/RegularFile.js"; +import { hashStableFile } from "../../../src/filesystem/StableFileHash.js"; + +vi.mock("../../../src/filesystem/RegularFile.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../src/filesystem/RegularFile.js") + >()), + openRegularFile: vi.fn(), +})); + +interface TestOwner { + readonly handle: FileHandle; + close(): Promise; +} + +interface OwnedHashFailure extends Error { + readonly cause?: unknown; + readonly cleanupCause?: unknown; + readonly owner: TestOwner; +} + +const roots: string[] = []; +const rawHandles: FileHandle[] = []; + +afterEach(async () => { + vi.mocked(openRegularFile).mockReset(); + for (const handle of rawHandles.splice(0)) { + if (handle.fd >= 0) await handle.close(); + } + for (const root of roots.splice(0)) + await rm(root, { recursive: true, force: true }); +}); + +const makeFile = async (options: { + readonly closeFailure?: Error; + readonly invalidateFdOnCloseFailure?: boolean; + readonly readFailure?: Error; + readonly abortAfterRead?: AbortController; + readonly abortReason?: unknown; +}) => { + const root = await mkdtemp(join(tmpdir(), "rea-stable-hash-")); + roots.push(root); + const path = join(root, "input.bin"); + await writeFile(path, "stable hash input"); + const raw = await open(path, "r"); + rawHandles.push(raw); + + let failedClose = false; + let invalidated = false; + const close = vi.fn(async () => { + if (options.closeFailure !== undefined && !failedClose) { + failedClose = true; + invalidated = options.invalidateFdOnCloseFailure === true; + throw options.closeFailure; + } + await raw.close(); + }); + const handle = { + get fd() { + return invalidated ? -1 : raw.fd; + }, + stat: () => raw.stat(), + read: async ( + buffer: Buffer, + offset: number, + length: number, + position: number | null, + ) => { + if (options.readFailure !== undefined) throw options.readFailure; + const result = await raw.read(buffer, offset, length, position); + options.abortAfterRead?.abort(options.abortReason); + return result; + }, + close, + } as unknown as FileHandle; + + vi.mocked(openRegularFile).mockResolvedValueOnce(handle); + return { path, handle, close }; +}; + +const rejectHash = async ( + path: string, + signal?: AbortSignal, +): Promise => { + const result = await hashStableFile(path, signal).then( + () => undefined, + (cause: unknown) => cause, + ); + expect(result).toBeInstanceOf(Error); + expect(result).toHaveProperty("owner"); + expect(result).toHaveProperty("cleanupCause"); + return result as OwnedHashFailure; +}; + +it("hashes and closes normally when close succeeds", async () => { + const { path, close } = await makeFile({}); + + const result = await hashStableFile(path); + + expect(result.bytes).toBe(Buffer.byteLength("stable hash input")); + expect(result.sha256).toMatch(/^[a-f0-9]{64}$/u); + expect(close).toHaveBeenCalledTimes(1); +}); + +it("retains a completed hash's live file owner after close fails and retries it", async () => { + const closeFailure = new Error("injected close failure"); + const { path, handle, close } = await makeFile({ closeFailure }); + + const failure = await rejectHash(path); + + expect(failure.cleanupCause).toBe(closeFailure); + expect(failure.owner.handle).toBe(handle); + expect(failure).toHaveProperty( + "completedHash.bytes", + Buffer.byteLength("stable hash input"), + ); + await expect(failure.owner.close()).resolves.toBeUndefined(); + expect(close).toHaveBeenCalledTimes(2); +}); + +it("preserves a read failure as primary when closing the owned file also fails", async () => { + const readFailure = new Error("injected read failure"); + const closeFailure = new Error("injected close failure"); + const { path, handle } = await makeFile({ closeFailure, readFailure }); + + const failure = await rejectHash(path); + + expect(failure.cause).toBe(readFailure); + expect(failure.cleanupCause).toBe(closeFailure); + expect(failure.owner.handle).toBe(handle); +}); + +it("preserves cancellation as primary when closing the owned file also fails", async () => { + const cancellation = new AbortController(); + const cancelCause = new Error("injected cancellation"); + const closeFailure = new Error("injected close failure"); + const { path, handle } = await makeFile({ + closeFailure, + abortAfterRead: cancellation, + abortReason: cancelCause, + }); + + const failure = await rejectHash(path, cancellation.signal); + + expect(failure.cause).toBe(cancelCause); + expect(failure.cleanupCause).toBe(closeFailure); + expect(failure.owner.handle).toBe(handle); +}); + +it("keeps an invalidated descriptor's close failure sticky without retrying raw close", async () => { + const closeFailure = new Error( + "injected close failure after fd invalidation", + ); + const { path, handle, close } = await makeFile({ + closeFailure, + invalidateFdOnCloseFailure: true, + }); + + const failure = await rejectHash(path); + + expect(failure.cleanupCause).toBe(closeFailure); + expect(failure.owner.handle).toBe(handle); + await expect(failure.owner.close()).rejects.toBe(closeFailure); + expect(close).toHaveBeenCalledTimes(1); +}); diff --git a/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts b/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts new file mode 100644 index 0000000000..6edb3c77f2 --- /dev/null +++ b/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts @@ -0,0 +1,169 @@ +import { afterEach, expect, it, vi } from "vitest"; + +const injected = vi.hoisted(() => ({ + failNextClose: false, + holdCloseFailures: false, + releaseCloseFailures: false, + closeCalls: 0, + handles: [] as import("node:fs/promises").FileHandle[], +})); + +afterEach(async () => { + injected.failNextClose = false; + injected.holdCloseFailures = false; + injected.releaseCloseFailures = false; + injected.closeCalls = 0; + for (const handle of injected.handles.splice(0)) + if (handle.fd >= 0) await handle.close(); +}); + +vi.mock("../../../../src/filesystem/RegularFile.js", async (importOriginal) => { + const original = + await importOriginal< + typeof import("../../../../src/filesystem/RegularFile.js") + >(); + return { + ...original, + openRegularFile: vi.fn( + async (...args: Parameters) => { + const handle = await original.openRegularFile(...args); + injected.handles.push(handle); + if (!injected.failNextClose && !injected.holdCloseFailures) + return handle; + injected.failNextClose = false; + return { + get fd() { + return handle.fd; + }, + stat: (...statArgs: Parameters) => + handle.stat(...statArgs), + read: (...readArgs: Parameters) => + handle.read(...readArgs), + close: async () => { + injected.closeCalls += 1; + if ( + injected.closeCalls === 1 || + (injected.holdCloseFailures && !injected.releaseCloseFailures) + ) + throw new Error("injected close fault"); + await handle.close(); + }, + } as unknown as typeof handle; + }, + ), + }; +}); + +import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js"; +import { KunaTargetCommands } from "../../../../src/kuna/KunaTargetCommands.js"; +import { inspectKunaProvider } from "../../../../src/kuna/KunaDoctor.js"; +import { inspectKunaInstallation } from "../../../../src/kuna/KunaInstallation.js"; +import { fakeKunaCalls } from "./fakeKuna.js"; +import { session } from "./kunaSessionFixture.js"; + +it.skipIf(process.platform === "win32")( + "retains and retries the exact Kuna executable hash owner without running Kuna", + async () => { + const fixture = await session("rea-kuna-hash-owner-"); + const installed = await inspectKunaInstallation({ + REA_KUNA_COMMAND: fixture.kuna, + REA_KUNA_CACHE_DIR: fixture.cacheRoot, + }); + if (!installed.ok) throw new Error(installed.error.reason); + const controller = new AbortController(); + const commands = new KunaTargetCommands(fixture.target, { + signal: controller.signal, + }); + injected.closeCalls = 0; + injected.failNextClose = true; + try { + const result = await commands.run({ + installation: installed.value, + operation: "list_procedures", + arguments: (input) => ["functions", input, "--json"], + timeoutMs: 10_000, + outputBytes: 1024 * 1024, + signal: controller.signal, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Kuna command unexpectedly ran"); + expect(result.error).toMatchObject({ + cleanupIncomplete: true, + cleanupResources: [installed.value.executable], + }); + expect(await commands.close()).toEqual({ ok: true, value: null }); + expect(injected.closeCalls).toBe(2); + expect( + (await fakeKunaCalls(fixture.kuna)).map(({ argv }) => argv), + ).toEqual([["--version"], ["--version"]]); + } finally { + controller.abort(); + const closed = await commands.close(); + if (!closed.ok) throw closed.error; + } + }, +); + +it.skipIf(process.platform === "win32")( + "drains a Kuna candidate's retained hash owner when the binary session closes without an active client", + async () => { + const fixture = await session("rea-kuna-candidate-hash-owner-"); + const binary = composeBinarySession( + new AnalysisProviderRegistry([fixture.provider]), + ); + injected.closeCalls = 0; + injected.holdCloseFailures = true; + injected.releaseCloseFailures = false; + + const resolved = await fixture.provider.resolveAnalysisProfile( + fixture.target, + ); + expect(resolved.ok).toBe(false); + if (resolved.ok) throw new Error("Kuna readiness unexpectedly succeeded"); + expect(resolved.error).toMatchObject({ + cleanupIncomplete: true, + cleanupResources: [fixture.kuna], + }); + expect(injected.closeCalls).toBe(2); + + injected.releaseCloseFailures = true; + expect(await binary.close()).toEqual({ ok: true, value: null }); + expect(injected.closeCalls).toBe(3); + expect((await fakeKunaCalls(fixture.kuna)).map(({ argv }) => argv)).toEqual( + [["--version"], ["--version"]], + ); + }, +); + +it.skipIf(process.platform === "win32")( + "returns the exact Kuna doctor owner when no lifecycle sink was supplied", + async () => { + const fixture = await session("rea-kuna-doctor-hash-owner-"); + injected.closeCalls = 0; + injected.failNextClose = true; + let failure: unknown; + try { + await inspectKunaProvider({ + REA_KUNA_COMMAND: fixture.kuna, + REA_KUNA_CACHE_DIR: fixture.cacheRoot, + }); + } catch (cause: unknown) { + failure = cause; + } + expect(failure).toMatchObject({ + name: "KunaDoctorHashCleanupFailure", + readinessFailure: { code: "hash_cleanup_failed" }, + cleanupCause: expect.any(Error), + owner: { handle: { fd: expect.any(Number) } }, + }); + const owned = failure as { + readonly owner: { close(): Promise }; + }; + await owned.owner.close(); + expect(injected.closeCalls).toBe(2); + expect((await fakeKunaCalls(fixture.kuna)).map(({ argv }) => argv)).toEqual( + [["--version"], ["--version"]], + ); + }, +);