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"]], + ); + }, +);