From ff78165978de9dbcf9fa16c2acf74bf85a69dc44 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:09:34 +0800 Subject: [PATCH] fix(kuna): own version-probe processes through output and cleanup Use the shared command supervisor for installation probes and retain exact process cleanup owners through doctor, profile resolution, and session health. Preserve exit status, bounded captured output, cancellation reasons, and live cleanup facts. Block subsequent inspection until retained cleanup is retried. Generalize the existing inspection resource scope and migrate its consumers, including verifier scripts; remove the verifier's duplicate raw version probe. Validation: 116 distinct focused tests passed, including built CLI doctor with inherited output, cancellation/finalization, cleanup retry, and fake-provider verifier workflows. Build, check:fast, and test discovery passed. No real Kuna distribution or analysis target was executed. Synthetic Darwin inspection timing: first-call median 707ms versus 5.79ms; same-process repeat 37.9ms versus 3.08ms. First use includes ownership-helper preparation. Family CPU and actual peak family memory remain unmeasured. Fixes #5557 --- .../lib/kuna-native-reference-verifier.mjs | 6 +- scripts/lib/kuna-string-verifier.mjs | 7 +- scripts/lib/verifier-file-resources.d.mts | 4 +- scripts/verify-real-kuna.mjs | 46 +- src/android/JadxDoctor.ts | 4 +- src/android/JadxJavaProbe.ts | 4 +- src/android/JadxProvider.ts | 4 +- src/application/AnalysisProvider.ts | 6 +- src/application/Doctor.ts | 11 +- .../binary/AnalysisProviderRegistry.ts | 18 +- src/doctorRuntime.ts | 12 +- src/ghidra/GhidraDoctor.ts | 4 +- src/ghidra/GhidraInstallation.ts | 4 +- src/ghidra/GhidraInstallationProperties.ts | 4 +- src/ghidra/GhidraJavaProbe.ts | 4 +- src/ghidra/GhidraProvider.ts | 6 +- src/kuna/KunaCommand.ts | 23 + src/kuna/KunaDoctor.ts | 80 ++- src/kuna/KunaInstallation.ts | 262 +++++++--- src/kuna/KunaProvider.ts | 70 ++- src/kuna/KunaSessionClient.ts | 68 ++- src/providers/AnalysisClientCreation.ts | 6 +- src/providers/DeferredAnalysisClient.ts | 4 +- src/providers/OwnedCommandCleanup.ts | 4 +- .../boundary/cli/kunaDoctorHeldOutput.test.ts | 139 +++++ .../kuna/kunaCommandFailures.test.ts | 2 +- .../kuna/kunaCommandLifetime.test.ts | 11 +- .../providers/kuna/kunaDoctor.test.ts | 34 +- .../providers/kuna/kunaFileCloseOwner.ts | 41 -- .../kuna/kunaHashOwnerPropagation.test.ts | 14 +- .../providers/kuna/kunaInstallation.test.ts | 41 +- .../kuna/kunaInstallationLifetime.test.ts | 6 +- .../providers/kuna/kunaProjectCache.test.ts | 2 +- .../kuna/kunaProjectConcurrency.test.ts | 2 +- .../providers/kuna/kunaResourceScope.ts | 32 ++ .../providers/kuna/kunaRunOwnership.test.ts | 2 +- .../providers/kuna/kunaSessionFixture.ts | 6 +- .../providers/kuna/kunaSetupReadiness.test.ts | 2 +- .../kuna/kunaVersionProbeOwnership.test.ts | 492 ++++++++++++++++++ .../providers/kuna/kunaVersionProbeProcess.ts | 91 ++++ tests/fixtures/inspectionResources.ts | 4 +- 41 files changed, 1319 insertions(+), 263 deletions(-) create mode 100644 tests/boundary/cli/kunaDoctorHeldOutput.test.ts delete mode 100644 tests/boundary/providers/kuna/kunaFileCloseOwner.ts create mode 100644 tests/boundary/providers/kuna/kunaResourceScope.ts create mode 100644 tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts create mode 100644 tests/boundary/providers/kuna/kunaVersionProbeProcess.ts diff --git a/scripts/lib/kuna-native-reference-verifier.mjs b/scripts/lib/kuna-native-reference-verifier.mjs index df48e017c5..fd78668d0c 100644 --- a/scripts/lib/kuna-native-reference-verifier.mjs +++ b/scripts/lib/kuna-native-reference-verifier.mjs @@ -14,14 +14,14 @@ export const nativeKunaIncomingSites = async ({ binary, address, retain, - fileCloseResources, + resourceScope, }) => { const installed = await inspectKunaInstallation( { REA_KUNA_COMMAND: executable, REA_KUNA_CACHE_DIR: cacheRoot, }, - { fileCloseResources }, + { resourceScope }, ); assert.ok(installed.ok, "Native reference oracle could not inspect Kuna"); const input = await realpath(binary); @@ -37,7 +37,7 @@ export const nativeKunaIncomingSites = async ({ outputBytes: KUNA_XREF_OUTPUT_BYTES, signal: new AbortController().signal, retainFailedFileClose: (owner, resource) => - fileCloseResources.retain(owner, resource), + resourceScope.retain(owner, resource), }); if (outcome.kind === "retained") retain(outcome); if (outcome.kind !== "ok") throw outcome.error; diff --git a/scripts/lib/kuna-string-verifier.mjs b/scripts/lib/kuna-string-verifier.mjs index 157cce141a..d85f6ed20c 100644 --- a/scripts/lib/kuna-string-verifier.mjs +++ b/scripts/lib/kuna-string-verifier.mjs @@ -19,14 +19,14 @@ export const nativeKunaStrings = async ({ workspace, binary, retain, - fileCloseResources, + resourceScope, }) => { const installed = await inspectKunaInstallation( { REA_KUNA_COMMAND: executable, REA_KUNA_CACHE_DIR: cacheRoot, }, - { fileCloseResources }, + { resourceScope }, ); assert.ok(installed.ok, "Native string oracle could not inspect Kuna"); const input = await realpath(binary); @@ -51,7 +51,7 @@ export const nativeKunaStrings = async ({ outputBytes: 16 * 1024 * 1024, signal: new AbortController().signal, retainFailedFileClose: (owner, resource) => - fileCloseResources.retain(owner, resource), + resourceScope.retain(owner, resource), }); if (outcome.kind === "retained") retain(outcome); if (outcome.kind !== "ok") throw outcome.error; @@ -87,7 +87,6 @@ export const verifyKunaStrings = async ({ cacheRoot, nativeStrings, expectedStrings = [], - fileCloseResources, }) => { const expected = await nativeStrings(); if (expectedStrings.length > 0) diff --git a/scripts/lib/verifier-file-resources.d.mts b/scripts/lib/verifier-file-resources.d.mts index 82df77d652..4d7ff58ae4 100644 --- a/scripts/lib/verifier-file-resources.d.mts +++ b/scripts/lib/verifier-file-resources.d.mts @@ -1,9 +1,9 @@ import type { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js"; -import type { FileCloseResourceOwner } from "../../src/application/AnalysisProvider.js"; +import type { RetainedResourceScope } from "../../src/application/AnalysisProvider.js"; /** Complete a prerequisite operation, including retained cleanup on failure. */ export function withVerifierResourceOwner( - operation: (owner: FileCloseResourceOwner) => Promise, + operation: (owner: RetainedResourceScope) => Promise, ): Promise; /** Hash one verifier input and transfer failed-close ownership to its scope. */ diff --git a/scripts/verify-real-kuna.mjs b/scripts/verify-real-kuna.mjs index 07a2681780..d801cc067c 100644 --- a/scripts/verify-real-kuna.mjs +++ b/scripts/verify-real-kuna.mjs @@ -25,8 +25,9 @@ import { fileURLToPath } from "node:url"; import { parseArgs, promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { execFileOutput } from "../dist/process/ExecFileOutput.js"; +import { inspectKunaInstallation } from "../dist/kuna/KunaInstallation.js"; import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js"; +import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js"; import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs"; import { verifyKunaReferences } from "./lib/kuna-reference-verifier.mjs"; import { @@ -71,11 +72,15 @@ if (values["compare-ghidra"]) { ); } const run = createVerifierRun(); -const fileCloseScope = new ArtifactResourceScope(); -const fileCloseResources = { +const inspectionResources = new ArtifactResourceScope(); +const resourceScope = { retain: (owner, resource) => - fileCloseScope.retain({ kind: "file-handle", resource, handle: owner }), - run: (operation) => fileCloseScope.run(operation), + inspectionResources.retain({ + kind: "lease", + resource, + lease: AnalysisCleanupLease.forCloseOwner(owner, resource), + }), + run: (operation) => inspectionResources.run(operation), }; const workspace = await mkdtemp(join(tmpdir(), "rea-kuna-lane-")); const cacheRoot = join(workspace, "cache"); @@ -292,7 +297,6 @@ const verifyKuna = async ( call: cold.call, cacheRoot, expectedStrings, - fileCloseResources, nativeStrings: () => nativeKunaStrings({ executable: kuna, @@ -300,7 +304,7 @@ const verifyKuna = async ( workspace, binary: path, retain: (owner) => retainedNative.add(owner), - fileCloseResources, + resourceScope, }), }), }); @@ -324,7 +328,7 @@ const verifyKuna = async ( binary: path, address: targetAddress, retain: (outcome) => retainedNative.add(outcome), - fileCloseResources, + resourceScope, }), }); const pseudocode = await cold.call("procedure_pseudo_code", { @@ -492,15 +496,17 @@ const verifyVariant = async ({ flavor, directory, symbols }, variant) => { const results = []; const failures = []; -let fileResourcesClean = true; +let inspectionResourcesClean = true; try { - kunaVersion = ( - await execFileOutput(kuna, ["--version"], { - timeout: 10_000, - maxBuffer: 64 * 1024, - stopSignal: "SIGTERM", - }) - ).stdout.trim(); + const installation = await inspectKunaInstallation( + { REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: cacheRoot }, + { resourceScope }, + ); + assert.ok( + installation.ok, + installation.ok ? undefined : installation.error.reason, + ); + kunaVersion = `kuna ${installation.value.version}`; let callerPairs = []; if (values.targets !== undefined) { const names = (values.functions ?? "").split(",").filter(Boolean); @@ -566,14 +572,16 @@ for (const retained of [...retainedNative]) { } } try { - await fileCloseScope.close(); + await inspectionResources.close(); } catch (cause) { - fileResourcesClean = false; + inspectionResourcesClean = false; failures.push(cause); } const lifecycle = await completeVerifierRun(run); let cleanupComplete = - pendingServers.size === 0 && retainedNative.size === 0 && fileResourcesClean; + pendingServers.size === 0 && + retainedNative.size === 0 && + inspectionResourcesClean; try { assertVerifierCleanupComplete(lifecycle.process_lineage); } catch (cause) { diff --git a/src/android/JadxDoctor.ts b/src/android/JadxDoctor.ts index bd8e6fb894..bb2102080b 100644 --- a/src/android/JadxDoctor.ts +++ b/src/android/JadxDoctor.ts @@ -1,4 +1,4 @@ -import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import type { DoctorProviderCheck, DoctorProviderInspection, @@ -15,7 +15,7 @@ import { /** Project the validated JADX configuration into the doctor contract. */ export const inspectJadxProvider = async ( environment: Readonly>, - resourceOwner: FileCloseResourceOwner, + resourceOwner: RetainedResourceScope, signal?: AbortSignal, ): Promise => { const configured = environment.REA_JADX_MCP_JAR !== undefined; diff --git a/src/android/JadxJavaProbe.ts b/src/android/JadxJavaProbe.ts index adb52d9b5a..7ebf769219 100644 --- a/src/android/JadxJavaProbe.ts +++ b/src/android/JadxJavaProbe.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import { AnalysisError, type AnalysisCapturedOutput, @@ -27,7 +27,7 @@ const JAVA_MODULE_LISTING_TIMEOUT_MS = 10_000; const JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES = 1024 * 1024; export interface JadxJavaProbeOptions { - readonly resourceOwner: FileCloseResourceOwner; + readonly resourceOwner: RetainedResourceScope; readonly signal?: AbortSignal | undefined; readonly operation?: string; /** The Android operation owns this deadline; the probe also has its own bound. */ diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index d762edfe9d..289c79528e 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -1,6 +1,6 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; -import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js"; import { throwIfJadxInterrupted } from "./JadxInterruption.js"; import { chmod, copyFile } from "node:fs/promises"; @@ -250,7 +250,7 @@ export class JadxProvider implements AndroidAnalysisPort { readonly #inspections = new Set< Promise>> >(); - readonly #prerequisiteOwner: FileCloseResourceOwner = { + readonly #prerequisiteOwner: RetainedResourceScope = { retain: (owner, resource) => this.#prerequisites.retain({ kind: "lease", diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index ea0af2b62d..e41c876c6d 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -215,8 +215,8 @@ export interface AnalysisClientContext { readonly runId: string; } -/** One lifecycle owner for descriptor cleanup and abortable inspection work. */ -export interface FileCloseResourceOwner { +/** One lifecycle owner for retained resources and abortable inspection work. */ +export interface RetainedResourceScope { retain( owner: { close(): Promise; @@ -246,7 +246,7 @@ export interface AnalysisProviderInspectionOptions { } export interface AnalysisProfileResolutionOptions extends AnalysisProviderInspectionOptions { - readonly fileCloseResources?: FileCloseResourceOwner; + readonly resourceScope?: RetainedResourceScope; } interface CapabilityEffects { diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index face3e03d8..1e978d1c9d 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -10,7 +10,7 @@ import { STARTUP_OWNER_CLOSED } from "../process/SharedStartupAttempt.js"; import { constants } from "node:fs"; import { access, readdir, stat } from "node:fs/promises"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; -import type { FileCloseResourceOwner } from "./AnalysisProvider.js"; +import type { RetainedResourceScope } from "./AnalysisProvider.js"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import { join } from "node:path"; @@ -480,7 +480,7 @@ export interface SystemDoctorHostOptions { readonly environment?: NodeJS.ProcessEnv; readonly execFileOutput?: typeof execFileOutput; readonly providerInspections?: ( - fileCloseResources: FileCloseResourceOwner, + resourceScope: RetainedResourceScope, signal: AbortSignal, ) => Promise; readonly providerRegistrationEnvironment?: () => Readonly< @@ -506,7 +506,7 @@ export const systemDoctorHost = ( const hostExecFileOutput = options.execFileOutput ?? execFileOutput; const homeDirectory = homeDirectoryFromEnvironment(environment, platform); const commandEnvironment = { env: environment }; - const fileCloseResources: FileCloseResourceOwner = { + const resourceScope: RetainedResourceScope = { retain: (owner, resource) => resources.retain({ kind: "lease", @@ -620,10 +620,7 @@ export const systemDoctorHost = ( : { providerInspections: () => resources.run(() => - options.providerInspections!( - fileCloseResources, - inspectionSignal, - ), + options.providerInspections!(resourceScope, inspectionSignal), ), }), ...(options.providerRegistrationEnvironment === undefined diff --git a/src/application/binary/AnalysisProviderRegistry.ts b/src/application/binary/AnalysisProviderRegistry.ts index a2ddae0d0b..7935e9d2f7 100644 --- a/src/application/binary/AnalysisProviderRegistry.ts +++ b/src/application/binary/AnalysisProviderRegistry.ts @@ -39,7 +39,7 @@ import { type AnalysisProviderCandidateEvaluation, } from "./AnalysisProviderEvaluation.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; -import type { FileCloseResourceOwner } from "../AnalysisProvider.js"; +import type { RetainedResourceScope } from "../AnalysisProvider.js"; /** Deterministic discovery truth for one configured deep provider. */ export interface AnalysisProviderCandidateStatus { @@ -108,15 +108,15 @@ export class AnalysisProviderRegistry { > >(); readonly #selectionCleanup = new Set(); - readonly #profileFiles = new ArtifactResourceScope(); - readonly #profileFileOwner: FileCloseResourceOwner = { + readonly #profileResources = new ArtifactResourceScope(); + readonly #profileOwner: RetainedResourceScope = { retain: (owner, resource) => - this.#profileFiles.retain({ + this.#profileResources.retain({ kind: "lease", resource, lease: AnalysisCleanupLease.forCloseOwner(owner, resource), }), - run: (operation) => this.#profileFiles.run(operation), + run: (operation) => this.#profileResources.run(operation), }; #closing: Promise> | undefined; @@ -186,7 +186,7 @@ export class AnalysisProviderRegistry { return ( this.#selections.size > 0 || this.#discoveries.size > 0 || - this.#profileFiles.hasPending || + this.#profileResources.hasPending || [...this.#selectionCleanup].some((error) => error.cleanupIncomplete) || this.#providers.some( (provider) => provider.hasPendingOwnedResources?.() === true, @@ -215,7 +215,7 @@ export class AnalysisProviderRegistry { ]); const failures: AnalysisError[] = []; try { - await this.#profileFiles.retryCleanup(); + await this.#profileResources.retryCleanup(); } catch (cause: unknown) { const cleanup = ArtifactReaderFailure.cleanupObservation( cause, @@ -564,7 +564,7 @@ export class AnalysisProviderRegistry { provider, initial, target, - { signal, fileCloseResources: this.#profileFileOwner, scope }, + { signal, resourceScope: this.#profileOwner, scope }, ); if (!evaluation.ok) return evaluation; const evaluated = evaluation.value; @@ -608,7 +608,7 @@ export class AnalysisProviderRegistry { status.targetSupport.status === "supported" ? evaluateAnalysisProviderCandidate(provider, status, target, { signal, - fileCloseResources: this.#profileFileOwner, + resourceScope: this.#profileOwner, scope, }) : ok({ diff --git a/src/doctorRuntime.ts b/src/doctorRuntime.ts index 54c320bd8d..48b368e38c 100644 --- a/src/doctorRuntime.ts +++ b/src/doctorRuntime.ts @@ -49,20 +49,16 @@ export const createSystemDoctorHost = ( // parser-owned error when IDA itself was explicitly selected. return analysisProvider === "ida" ? registration : ok(undefined); }, - providerInspections: async (fileCloseResources, inspectionSignal) => [ + providerInspections: async (resourceScope, inspectionSignal) => [ await inspectSystemGhidraProvider( environment, - fileCloseResources, + resourceScope, inspectionSignal, ), inspectIdaRegistration(environment.REA_IDA_MCP_CONFIG), - await inspectJadxProvider( - environment, - fileCloseResources, - inspectionSignal, - ), + await inspectJadxProvider(environment, resourceScope, inspectionSignal), await inspectKunaProvider(environment, process.platform, { - fileCloseResources, + resourceScope, signal: inspectionSignal, }), ], diff --git a/src/ghidra/GhidraDoctor.ts b/src/ghidra/GhidraDoctor.ts index 55c69d893f..7e5346eaa6 100644 --- a/src/ghidra/GhidraDoctor.ts +++ b/src/ghidra/GhidraDoctor.ts @@ -1,6 +1,6 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js"; -import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import type { DoctorProviderCheck, DoctorProviderInspection, @@ -69,7 +69,7 @@ export const projectGhidraDoctorInspection = ( /** Inspect the process-configured BYO Ghidra installation for `rea doctor`. */ export const inspectSystemGhidraProvider = async ( environment: Readonly, - resourceOwner: FileCloseResourceOwner, + resourceOwner: RetainedResourceScope, signal?: AbortSignal, ): Promise => { try { diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index a56c5adecc..f671d38110 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -1,6 +1,6 @@ import { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; -import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import { probeGhidraJava, type GhidraJavaObservation, @@ -160,7 +160,7 @@ const assessNativeDecompiler = ( /** Caller-owned paths and host coordinates used for one installation probe. */ export interface GhidraInstallationOptions { readonly environment: Readonly; - readonly resourceOwner: FileCloseResourceOwner; + readonly resourceOwner: RetainedResourceScope; readonly signal?: AbortSignal; readonly installDir?: string; readonly javaHome?: string; diff --git a/src/ghidra/GhidraInstallationProperties.ts b/src/ghidra/GhidraInstallationProperties.ts index 7562cd39a0..28c6b5f83c 100644 --- a/src/ghidra/GhidraInstallationProperties.ts +++ b/src/ghidra/GhidraInstallationProperties.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import { ArtifactFileCancelled, ArtifactFileCleanupFailure, @@ -15,7 +15,7 @@ import { ghidraFileFailure } from "./GhidraFileArtifacts.js"; /** Metadata admission budget, not a Java Properties format limit. */ export const MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES = 1024 * 1024; export interface GhidraInstallationFileOptions { - readonly resourceOwner: FileCloseResourceOwner; + readonly resourceOwner: RetainedResourceScope; readonly signal?: AbortSignal; } diff --git a/src/ghidra/GhidraJavaProbe.ts b/src/ghidra/GhidraJavaProbe.ts index 2785d7b1a7..389fe99515 100644 --- a/src/ghidra/GhidraJavaProbe.ts +++ b/src/ghidra/GhidraJavaProbe.ts @@ -6,7 +6,7 @@ import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; -import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; import { OwnedCommandFailure, @@ -110,7 +110,7 @@ const executablePath = (path: string): boolean => { } }; export interface GhidraJavaProbeOptions { - readonly resourceOwner: FileCloseResourceOwner; + readonly resourceOwner: RetainedResourceScope; readonly signal?: AbortSignal; readonly launcher?: NonNullable< Parameters[2] diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 1776eddbcc..89b4d363cd 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -14,7 +14,7 @@ import type { AnalysisClient, AnalysisClientContext, AnalysisProviderInspectionOptions, - FileCloseResourceOwner, + RetainedResourceScope, AnalysisProfileResolutionOptions, AnalysisProfileResolution, AnalysisProviderCandidate, @@ -108,7 +108,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { #inspectionAttempt: | SharedStartupAttempt> | undefined; - readonly #inspectionOwner: FileCloseResourceOwner = { + readonly #inspectionOwner: RetainedResourceScope = { retain: (owner, resource) => this.#inspectionResources.retain({ kind: "lease", @@ -601,7 +601,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { #observeInstallation( signal: AbortSignal, - resourceOwner: FileCloseResourceOwner = this.#inspectionOwner, + resourceOwner: RetainedResourceScope = this.#inspectionOwner, ): Promise { const options = { resourceOwner, diff --git a/src/kuna/KunaCommand.ts b/src/kuna/KunaCommand.ts index 283a9aae7f..b9ff153601 100644 --- a/src/kuna/KunaCommand.ts +++ b/src/kuna/KunaCommand.ts @@ -13,6 +13,8 @@ import { } from "../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { OwnedCommandFailure, runOwnedCommand, @@ -130,6 +132,27 @@ export const kunaFileCleanupError = ( return analysisErrorWithCleanupFailure(primary, cleanup, operation); }; +/** Preserve the exact retryable resources when an inspection admission retries them. */ +export const kunaRetainedResourceCleanupError = ( + cause: unknown, + operation: string, +): AnalysisError | undefined => { + if ( + !(cause instanceof ArtifactReaderFailure) || + cause.cleanupLeases === undefined || + cause.cleanupLeases.length === 0 + ) + return undefined; + const cleanup = new ProviderCleanupError( + KUNA_PROVIDER_ID, + cause.cleanup?.resources ?? [], + { reason: message(cause) }, + { cause, operation }, + ); + bindArtifactFileCleanup(cause, cleanup); + return cleanup; +}; + /** File metadata that, unchanged, lets a verified digest stand without re-hashing. */ const verifiedExecutables = new Map< string, diff --git a/src/kuna/KunaDoctor.ts b/src/kuna/KunaDoctor.ts index 7c6e8b1840..b5fbfe43a6 100644 --- a/src/kuna/KunaDoctor.ts +++ b/src/kuna/KunaDoctor.ts @@ -7,10 +7,12 @@ import type { DoctorProviderInspection, } from "../application/Doctor.js"; import { readKunaConfiguration } from "./KunaConfiguration.js"; -import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { kunaRetainedResourceCleanupError } from "./KunaCommand.js"; import { + createKunaResourceScope, inspectKunaInstallation, kunaInstallationCancellation, kunaInstallationFailureDiagnostics, @@ -39,25 +41,60 @@ export const inspectKunaProvider = async ( environment: Readonly, platform: NodeJS.Platform = process.platform, options: { - readonly fileCloseResources: FileCloseResourceOwner; + readonly resourceScope: RetainedResourceScope; readonly signal?: AbortSignal; }, ): Promise => { + const kunaResources = createKunaResourceScope(options.resourceScope); let installation: Awaited>; try { installation = await inspectKunaInstallation(environment, { platform, - fileCloseResources: options.fileCloseResources, + resourceScope: kunaResources.owner, ...(options.signal === undefined ? {} : { signal: options.signal }), }); } catch (cause: unknown) { + const retainedCleanup = kunaRetainedResourceCleanupError(cause, "doctor"); + if (retainedCleanup !== undefined) + return { + id: KUNA_PROVIDER_ID, + configured: environment.REA_KUNA_COMMAND !== undefined, + available: false, + providerVersion: null, + registrationEnvironment: {}, + checks: [ + { + name: "installation", + ok: false, + code: "cleanup_incomplete", + detail: retainedCleanup.message, + remediation: null, + classification: "config_drift", + details: { + cleanup_incomplete: true, + cleanup_resources: [...retainedCleanup.cleanupResources], + }, + }, + ], + }; if (!(cause instanceof RegularFileCleanupFailure)) { const cancellation = kunaInstallationCancellation(cause); - if (cancellation !== undefined) - throw new AnalysisCancelledError("doctor", { + if (cancellation !== undefined) { + const error = new AnalysisCancelledError("doctor", { cause: cancellation.reason, diagnostics: cancellation.diagnostics, + ...(!kunaResources.hasPending + ? {} + : { + cleanup: { + reason: "Kuna version-probe process cleanup is unconfirmed.", + resources: kunaResources.cleanupResources ?? [], + }, + }), }); + kunaResources.bindRetainedFailure(error); + throw error; + } throw cause; } return { @@ -85,15 +122,34 @@ export const inspectKunaProvider = async ( const configured = environment.REA_KUNA_COMMAND !== undefined && environment.REA_KUNA_COMMAND.length > 0; - if (!installation.ok) + const base = { + id: KUNA_PROVIDER_ID, + configured, + available: false, + providerVersion: null, + registrationEnvironment: {}, + } as const; + if (!installation.ok) { + const check = installationFailure(installation.error, configured); + const cleanupResources = kunaResources.cleanupResources; + if (!kunaResources.hasPending) return { ...base, checks: [check] }; return { - id: KUNA_PROVIDER_ID, - configured, - available: false, - providerVersion: null, - registrationEnvironment: {}, - checks: [installationFailure(installation.error, configured)], + ...base, + checks: [ + { + ...check, + code: "cleanup_incomplete", + details: { + ...installation.error.diagnostics, + cleanup_incomplete: true, + ...(cleanupResources === undefined + ? {} + : { cleanup_resources: [...cleanupResources] }), + }, + }, + ], }; + } const { value } = installation; return { id: KUNA_PROVIDER_ID, diff --git a/src/kuna/KunaInstallation.ts b/src/kuna/KunaInstallation.ts index aedab42f64..f723b1e648 100644 --- a/src/kuna/KunaInstallation.ts +++ b/src/kuna/KunaInstallation.ts @@ -3,18 +3,23 @@ import { RegularFileOperationCancelled, } from "../filesystem/RegularFile.js"; import { constants } from "node:fs"; +import { randomUUID } from "node:crypto"; import { access, realpath, stat } from "node:fs/promises"; import { join } from "node:path"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../domain/result.js"; -import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + AnalysisCleanupLease, + AnalysisCleanupLeaseGroup, +} from "../artifacts/AnalysisCleanupLease.js"; import { hashStableFile, stableFileHashCause, } from "../filesystem/StableFileHash.js"; -import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import { RegularFileCleanupFailure, RegularFileCancelled, @@ -22,10 +27,15 @@ import { withOwnedRegularFile, } from "../filesystem/OwnedRegularFile.js"; import { - execFileOutput, - execFileOutputFailure, - ExecFileOutputCancelledError, -} from "../process/ExecFileOutput.js"; + OwnedCommandFailure, + runOwnedCommand, +} from "../process/OwnedCommand.js"; +import { retainOwnedCommandCleanup } from "../providers/OwnedCommandCleanup.js"; +import type { + OwnedProviderProcessSpawnOptions, + ProviderProcessSnapshot, + SpawnedOwnedProviderProcess, +} from "../process/ProviderProcess.js"; import { readKunaConfiguration, type KunaConfiguration, @@ -44,6 +54,45 @@ const VERSION_PROBE_MAX_OUTPUT_BYTES = 64 * 1024; /** Release CI prints exactly `kuna `; source builds print the Cargo version. */ const VERSION_LINE = /^kuna (\d+\.\d+(?:\.\d+)?)\r?\n?$/u; +const versionProbeOutput = (snapshot: ProviderProcessSnapshot | null) => ({ + stdout: snapshot?.stdout.text ?? "", + stderr: snapshot?.stderr.text ?? "", + exitCode: snapshot?.exitCode ?? null, + signal: snapshot?.signal ?? null, + stdoutBytes: snapshot?.stdout.bytes ?? 0, + stdoutObservedBytes: snapshot?.stdout.observedBytes ?? 0, + stderrBytes: snapshot?.stderr.bytes ?? 0, + stderrObservedBytes: snapshot?.stderr.observedBytes ?? 0, + stdoutInvalidUtf8: snapshot?.stdout.invalidUtf8 === true, + stderrInvalidUtf8: snapshot?.stderr.invalidUtf8 === true, + diagnosticTruncated: snapshot?.diagnosticTruncated === true, + outputIncomplete: snapshot?.outputIncomplete === true, + truncated: + snapshot?.diagnosticTruncated === true || + snapshot?.outputIncomplete === true || + (snapshot !== null && + snapshot !== undefined && + snapshot.stdout.bytes < snapshot.stdout.observedBytes), +}); + +const versionProbeOutputDiagnostics = ( + output: ReturnType, +) => ({ + stdout: output.stdout, + stderr: output.stderr, + exit_code: output.exitCode, + signal: output.signal, + stdout_bytes: output.stdoutBytes, + stdout_observed_bytes: output.stdoutObservedBytes, + stderr_bytes: output.stderrBytes, + stderr_observed_bytes: output.stderrObservedBytes, + ...(output.stdoutInvalidUtf8 ? { stdout_invalid_utf8: true } : {}), + ...(output.stderrInvalidUtf8 ? { stderr_invalid_utf8: true } : {}), + diagnostic_truncated: output.diagnosticTruncated, + output_incomplete: output.outputIncomplete, + output_truncated: output.truncated, +}); + /** One observed Kuna executable, identified by its bytes as well as its version. */ export interface KunaInstallation { readonly configuration: KunaConfiguration; @@ -77,7 +126,11 @@ export interface KunaInstallationOptions { readonly signal?: AbortSignal; /** Probe deadline; tests shorten it. */ readonly probeTimeoutMs?: number; - readonly fileCloseResources: FileCloseResourceOwner; + readonly resourceScope: RetainedResourceScope; + /** Test seam for the owned command launcher; production uses the verified default. */ + readonly processLauncher?: ( + options: OwnedProviderProcessSpawnOptions, + ) => Promise; } /** Cancellation facts normalized by the installation inspection that observed them. */ @@ -113,30 +166,52 @@ export const kunaInstallationCancellation = ( }; }; -/** One installation inspection's file owners and their originating errors. */ -export interface KunaFileCloseScope { - readonly owner: FileCloseResourceOwner; +/** One installation inspection's retained owners and originating errors. */ +export interface KunaResourceScope { + readonly owner: RetainedResourceScope; + readonly cleanupResources: readonly string[] | undefined; + readonly hasPending: boolean; bindFailure(failure: RegularFileCleanupFailure, error: AnalysisError): void; + bindRetainedFailure(error: AnalysisError): void; } -/** Adapt retained descriptor owners into leases and bind errors to exact owners. */ -export const createKunaFileCloseScope = ( - parent: FileCloseResourceOwner, -): KunaFileCloseScope => { +/** Adapt retained resource owners into leases and bind errors to exact owners. */ +export const createKunaResourceScope = ( + parent: RetainedResourceScope, +): KunaResourceScope => { const leases = new Map< - Parameters[0], - AnalysisCleanupLease + Parameters[0], + { readonly lease: AnalysisCleanupLease } >(); + const bindPendingFailure = (error: AnalysisError) => { + const retained = [...leases.values()] + .map(({ lease }) => lease) + .filter((lease) => !lease.released); + if (retained.length > 0) new AnalysisCleanupLeaseGroup(error, retained); + }; return { + get hasPending() { + return [...leases.values()].some(({ lease }) => !lease.released); + }, + get cleanupResources() { + const pending = [...leases.values()] + .map(({ lease }) => lease) + .filter((lease) => !lease.released); + if (pending.some((lease) => lease.cleanupResources === undefined)) + return undefined; + return [ + ...new Set(pending.flatMap((lease) => lease.cleanupResources ?? [])), + ]; + }, owner: { retain: (owner, resource) => { const lease = AnalysisCleanupLease.forCloseOwner(owner, resource); - leases.set(owner, lease); + leases.set(owner, { lease }); parent.retain( { close: () => lease.release(), get cleanupResources() { - return lease.cleanupResources ?? [resource]; + return lease.cleanupResources; }, }, resource, @@ -144,8 +219,10 @@ export const createKunaFileCloseScope = ( }, run: (operation) => parent.run(operation), }, - bindFailure: (failure, error) => - leases.get(failure.owner)?.bindCleanupError(error), + bindFailure: (failure, error) => { + if (leases.has(failure.owner)) bindPendingFailure(error); + }, + bindRetainedFailure: bindPendingFailure, }; }; @@ -211,40 +288,105 @@ export const inspectKunaInstallation = async ( assertRegularFileActive(options.signal); const deadline = options.probeTimeoutMs ?? VERSION_PROBE_TIMEOUT_MS; - let stdout: string; + let snapshot: ProviderProcessSnapshot; try { - ({ stdout } = await execFileOutput(executable, ["--version"], { - env: kunaChildEnvironment(configuration.value), - timeout: deadline, - maxBuffer: VERSION_PROBE_MAX_OUTPUT_BYTES, - // Escalate to SIGKILL so an executable that ignores SIGTERM cannot hang doctor. - stopSignal: "SIGTERM", - ...(options.signal === undefined ? {} : { signal: options.signal }), - })); + snapshot = await options.resourceScope.run(() => + runOwnedCommand( + { + command: executable, + arguments: ["--version"], + runId: `rea-kuna-version-${randomUUID()}`, + // The configured command may be an interpreter-driven shim. + expectedCommand: null, + hostEnvironment: kunaChildEnvironment(configuration.value), + }, + { + timeoutMs: deadline, + diagnosticBytes: VERSION_PROBE_MAX_OUTPUT_BYTES, + }, + { + ...(options.signal === undefined ? {} : { signal: options.signal }), + ...(options.processLauncher === undefined + ? {} + : { launcher: options.processLauncher }), + }, + ), + ); } catch (cause: unknown) { - if (cause instanceof ExecFileOutputCancelledError) - throw new RegularFileOperationCancelled(cause.cause); - const output = execFileOutputFailure(cause); + if (cause instanceof ArtifactReaderFailure) throw cause; + const retainedCleanup = retainOwnedCommandCleanup( + cause, + options.resourceScope, + "kuna", + "kuna:installation", + ); + const cleanupIncomplete = retainedCleanup.length > 0; + if (cause instanceof OwnedCommandFailure) { + const output = versionProbeOutput(cause.snapshot); + if (cause.reason === "cancelled") { + const cancellation = new RegularFileOperationCancelled( + cause.cancellation?.reason, + ); + installationFailureDiagnostics.set(cancellation, { + kuna_version_probe: { + status: "partial", + command, + executable, + deadline_ms: deadline, + ...versionProbeOutputDiagnostics(output), + probe_failure_reason: cause.reason, + stop_requested: cause.snapshot !== null, + cleanup_incomplete: cleanupIncomplete, + }, + }); + throw cancellation; + } + return failure( + "version_unresolved", + `${executable} --version failed: ${output.stderr.trim() || cause.message}. ${KUNA_VERSION_PROBE_REMEDIATION}`, + { + command, + executable, + probe_failure_reason: cause.reason, + stop_requested: cause.snapshot !== null, + cleanup_incomplete: cleanupIncomplete, + deadline_ms: deadline, + ...versionProbeOutputDiagnostics(output), + }, + ); + } return failure( "version_unresolved", - `${executable} --version failed: ${output?.stderr.trim() || message(cause)}. ${KUNA_VERSION_PROBE_REMEDIATION}`, + `${executable} --version failed: ${message(cause)}. ${KUNA_VERSION_PROBE_REMEDIATION}`, { command, executable, - exit_code: typeof output?.code === "number" ? output.code : null, - signal: output?.signal ?? null, - killed: output?.killed ?? false, - output_truncated: output?.outputTruncated ?? false, + exit_code: null, + signal: null, + cleanup_incomplete: cleanupIncomplete, deadline_ms: deadline, }, ); } - const version = VERSION_LINE.exec(stdout)?.[1]; + const probeOutput = versionProbeOutput(snapshot); + if (probeOutput.truncated) + return failure( + "version_unresolved", + `${executable} --version output was incomplete. ${KUNA_VERSION_PROBE_REMEDIATION}`, + { + command, + executable, + deadline_ms: deadline, + output_truncated: true, + stdout: probeOutput.stdout.slice(0, 256), + }, + ); + const version = VERSION_LINE.exec(probeOutput.stdout)?.[1]; if (version === undefined) return failure( "version_unresolved", `${executable} --version printed no recognizable Kuna version. ${KUNA_VERSION_PROBE_REMEDIATION}`, - { command, executable, stdout: stdout.slice(0, 256) }, + { command, executable, stdout: probeOutput.stdout.slice(0, 256) }, ); let fingerprint: { readonly sha256: string; readonly bytes: number }; @@ -255,26 +397,24 @@ export const inspectKunaInstallation = async ( script?: boolean; } = { phase: "fingerprint" }; try { - ({ fingerprint, script } = await options.fileCloseResources.run( - async () => { - const fingerprint = await hashStableFile(executable, { - ...(options.signal === undefined ? {} : { signal: options.signal }), - retainFailedClose: (owner) => - options.fileCloseResources.retain(owner, executable), - }); - observed.completed_hash = fingerprint; - assertRegularFileActive(options.signal); - observed.phase = "shebang"; - const script = await startsWithShebang( - executable, - options.fileCloseResources, - options.signal, - ); - observed.script = script; - assertRegularFileActive(options.signal); - return { fingerprint, script }; - }, - )); + ({ fingerprint, script } = await options.resourceScope.run(async () => { + const fingerprint = await hashStableFile(executable, { + ...(options.signal === undefined ? {} : { signal: options.signal }), + retainFailedClose: (owner) => + options.resourceScope.retain(owner, executable), + }); + observed.completed_hash = fingerprint; + assertRegularFileActive(options.signal); + observed.phase = "shebang"; + const script = await startsWithShebang( + executable, + options.resourceScope, + options.signal, + ); + observed.script = script; + assertRegularFileActive(options.signal); + return { fingerprint, script }; + })); } catch (cause: unknown) { const cancellation = regularFileCancellation(cause); if ( @@ -332,7 +472,7 @@ export const inspectKunaInstallation = async ( const startsWithShebang = ( path: string, - fileCloseResources: FileCloseResourceOwner, + resourceScope: RetainedResourceScope, signal?: AbortSignal, ): Promise => withOwnedRegularFile( @@ -340,7 +480,7 @@ const startsWithShebang = ( { symlinks: "follow", ...(signal === undefined ? {} : { signal }), - retainFailedClose: (owner) => fileCloseResources.retain(owner, path), + retainFailedClose: (owner) => resourceScope.retain(owner, path), }, async (handle) => { const prefix = Buffer.alloc(2); diff --git a/src/kuna/KunaProvider.ts b/src/kuna/KunaProvider.ts index 02f6ca8db5..937b91a63e 100644 --- a/src/kuna/KunaProvider.ts +++ b/src/kuna/KunaProvider.ts @@ -19,6 +19,7 @@ import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { kunaFileCleanupError, + kunaRetainedResourceCleanupError, type KunaProcessLauncher, } from "./KunaCommand.js"; import { @@ -26,7 +27,7 @@ import { type KunaConfiguration, } from "./KunaConfiguration.js"; import { - createKunaFileCloseScope, + createKunaResourceScope, inspectKunaInstallation, kunaInstallationCancellation, } from "./KunaInstallation.js"; @@ -55,6 +56,7 @@ export class KunaProvider implements AnalysisProviderCandidate { readonly #environment: Readonly; readonly #platform: NodeJS.Platform; readonly #launcher: KunaProcessLauncher | undefined; + readonly #probeTimeoutMs: number | undefined; readonly #singleFunctionRuns: number | undefined; #cache: KunaProjectCache | undefined; #runSlots: KunaRunSlots | undefined; @@ -64,6 +66,8 @@ export class KunaProvider implements AnalysisProviderCandidate { options: { readonly platform?: NodeJS.Platform; readonly launcher?: KunaProcessLauncher; + /** Test seam for bounded installation-probe lifecycle cases. */ + readonly probeTimeoutMs?: number; /** Test seam for foreground native runs; production derives it from the host. */ readonly singleFunctionRuns?: number; } = {}, @@ -71,6 +75,7 @@ export class KunaProvider implements AnalysisProviderCandidate { this.#platform = options.platform ?? process.platform; this.#environment = snapshotEnvironment(environment, this.#platform); this.#launcher = options.launcher; + this.#probeTimeoutMs = options.probeTimeoutMs; this.#singleFunctionRuns = options.singleFunctionRuns; } @@ -175,40 +180,73 @@ export class KunaProvider implements AnalysisProviderCandidate { }), ); let installation: Awaited>; - const fileCloseResources = options?.fileCloseResources; - if (fileCloseResources === undefined) + const resourceScope = options?.resourceScope; + if (resourceScope === undefined) return err( kunaUnavailable( "kuna:profile", - "Kuna profile inspection requires a durable file resource owner", + "Kuna profile inspection requires a durable resource scope", ), ); - const fileCloseScope = createKunaFileCloseScope(fileCloseResources); + const kunaResources = createKunaResourceScope(resourceScope); try { installation = await inspectKunaInstallation(this.#environment, { platform: this.#platform, ...(options?.signal === undefined ? {} : { signal: options.signal }), - fileCloseResources: fileCloseScope.owner, + resourceScope: kunaResources.owner, + ...(this.#launcher === undefined + ? {} + : { processLauncher: this.#launcher }), + ...(this.#probeTimeoutMs === undefined + ? {} + : { probeTimeoutMs: this.#probeTimeoutMs }), }); } catch (cause: unknown) { + const retainedCleanup = kunaRetainedResourceCleanupError( + cause, + "kuna:profile", + ); + if (retainedCleanup !== undefined) return err(retainedCleanup); const cancellation = kunaInstallationCancellation(cause); if ( !(cause instanceof RegularFileCleanupFailure) && cancellation !== undefined - ) - return err( - new AnalysisCancelledError("kuna:profile", { - cause: cancellation.reason, - diagnostics: cancellation.diagnostics, - }), - ); + ) { + const error = new AnalysisCancelledError("kuna:profile", { + cause: cancellation.reason, + diagnostics: cancellation.diagnostics, + ...(!kunaResources.hasPending + ? {} + : { + cleanup: { + reason: "Kuna version-probe process cleanup is unconfirmed.", + resources: kunaResources.cleanupResources ?? [], + }, + }), + }); + kunaResources.bindRetainedFailure(error); + return err(error); + } if (!(cause instanceof RegularFileCleanupFailure)) throw cause; const error = kunaFileCleanupError(cause, "kuna:profile"); - fileCloseScope.bindFailure(cause, error); + kunaResources.bindFailure(cause, error); + return err(error); + } + if (!installation.ok) { + const error = kunaUnavailable("open_binary", installation.error.reason, { + diagnostics: installation.error.diagnostics, + ...(!kunaResources.hasPending + ? {} + : { + cleanup: { + reason: "Kuna version-probe process cleanup is unconfirmed.", + resources: kunaResources.cleanupResources ?? [], + }, + }), + }); + kunaResources.bindRetainedFailure(error); return err(error); } - if (!installation.ok) - return err(kunaUnavailable("open_binary", installation.error.reason)); const { value } = installation; return ok({ profile: createAnalysisProfile( diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index a95d4845da..207f7b77c9 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -7,7 +7,7 @@ import { type AnalysisExecution, type AnalysisOperation, type ExecutionOptions, - type FileCloseResourceOwner, + type RetainedResourceScope, } from "../application/AnalysisProvider.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { @@ -38,11 +38,12 @@ import { SharedStartupAttempt } from "../process/SharedStartupAttempt.js"; import { KUNA_INVENTORY_OUTPUT_BYTES, kunaFileCleanupError, + kunaRetainedResourceCleanupError, type KunaProcessLauncher, } from "./KunaCommand.js"; import { kunaFunctionDeadline } from "./KunaConfiguration.js"; import { - createKunaFileCloseScope, + createKunaResourceScope, inspectKunaInstallation, kunaInstallationCancellation, type KunaInstallation, @@ -236,7 +237,8 @@ export class KunaSessionClient implements AnalysisClient { readonly #cache: KunaProjectCache; readonly #environment: Readonly; readonly #runSlots: KunaRunSlots; - readonly #fileCloseResources = new ArtifactResourceScope(); + readonly #launcher: KunaProcessLauncher | undefined; + readonly #resourceScope = new ArtifactResourceScope(); /** Single-function runs in flight, by entry address. */ readonly #singleFunctionRuns = new Map(); /** Completed single-function texts by address, least recently used first. */ @@ -289,6 +291,7 @@ export class KunaSessionClient implements AnalysisClient { this.#cache = dependencies.cache; this.#runSlots = dependencies.runSlots; this.#environment = dependencies.environment; + this.#launcher = dependencies.launcher; this.#commands = new KunaTargetCommands(target, { signal: this.#session.signal, ...(dependencies.launcher === undefined @@ -450,7 +453,7 @@ export class KunaSessionClient implements AnalysisClient { const commands = await this.#commands.close(); if (!commands.ok) failures.push(commands.error); try { - await this.#fileCloseResources.close(); + await this.#resourceScope.close(); } catch (cause: unknown) { const observation = ArtifactReaderFailure.cleanupObservation( cause, @@ -1376,24 +1379,40 @@ export class KunaSessionClient implements AnalysisClient { async #inspectInstallation( signal: AbortSignal, ): Promise> { - const parentFileCloseResources: FileCloseResourceOwner = { + const parentResourceScope: RetainedResourceScope = { retain: (owner, resource) => - this.#fileCloseResources.retain({ + this.#resourceScope.retain({ kind: "lease", resource, lease: AnalysisCleanupLease.forCloseOwner(owner, resource), }), run: (operation: () => Promise) => - this.#fileCloseResources.run(operation), + this.#resourceScope.run(operation), }; - const fileCloseScope = createKunaFileCloseScope(parentFileCloseResources); + const kunaResources = createKunaResourceScope(parentResourceScope); try { const inspected = await inspectKunaInstallation(this.#environment, { - fileCloseResources: fileCloseScope.owner, + resourceScope: kunaResources.owner, signal, + ...(this.#launcher === undefined + ? {} + : { processLauncher: this.#launcher }), }); - if (!inspected.ok) - return err(kunaUnavailable("health", inspected.error.reason)); + if (!inspected.ok) { + const error = kunaUnavailable("health", inspected.error.reason, { + diagnostics: inspected.error.diagnostics, + ...(!kunaResources.hasPending + ? {} + : { + cleanup: { + reason: "Kuna version-probe process cleanup is unconfirmed.", + resources: kunaResources.cleanupResources ?? [], + }, + }), + }); + kunaResources.bindRetainedFailure(error); + return err(error); + } const committed = this.#profile?.parameters["kuna_sha256"]; if (committed !== undefined && committed !== inspected.value.sha256) return err( @@ -1408,21 +1427,32 @@ export class KunaSessionClient implements AnalysisClient { ); return ok(inspected.value); } catch (cause: unknown) { + const retainedCleanup = kunaRetainedResourceCleanupError(cause, "health"); + if (retainedCleanup !== undefined) return err(retainedCleanup); const cancellation = kunaInstallationCancellation(cause); if ( !(cause instanceof RegularFileCleanupFailure) && cancellation !== undefined - ) - return err( - new AnalysisCancelledError("health", { - cause: cancellation.reason, - diagnostics: cancellation.diagnostics, - }), - ); + ) { + const error = new AnalysisCancelledError("health", { + cause: cancellation.reason, + diagnostics: cancellation.diagnostics, + ...(!kunaResources.hasPending + ? {} + : { + cleanup: { + reason: "Kuna version-probe process cleanup is unconfirmed.", + resources: kunaResources.cleanupResources ?? [], + }, + }), + }); + kunaResources.bindRetainedFailure(error); + return err(error); + } if (!(cause instanceof RegularFileCleanupFailure)) return err(failure("health", cause)); const error = kunaFileCleanupError(cause, "health"); - fileCloseScope.bindFailure(cause, error); + kunaResources.bindFailure(cause, error); return err(error); } } diff --git a/src/providers/AnalysisClientCreation.ts b/src/providers/AnalysisClientCreation.ts index 6d09e5f340..023392b097 100644 --- a/src/providers/AnalysisClientCreation.ts +++ b/src/providers/AnalysisClientCreation.ts @@ -8,7 +8,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AnalysisClient, ExecutionOptions, - FileCloseResourceOwner, + RetainedResourceScope, } from "../application/AnalysisProvider.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; @@ -21,7 +21,7 @@ import { closeAnalysisClient } from "./AnalysisClientCleanup.js"; /** Own deferred client creation while callers wait independently and close joins it. */ export class AnalysisClientCreation { readonly #inspectionResources = new ArtifactResourceScope(); - readonly #inspectionOwner: FileCloseResourceOwner = { + readonly #inspectionOwner: RetainedResourceScope = { retain: (owner, resource) => this.#inspectionResources.retain({ kind: "lease", @@ -44,7 +44,7 @@ export class AnalysisClientCreation { private readonly providerId: string, private readonly create: ( signal: AbortSignal, - resourceOwner: FileCloseResourceOwner, + resourceOwner: RetainedResourceScope, ) => Promise, ) {} diff --git a/src/providers/DeferredAnalysisClient.ts b/src/providers/DeferredAnalysisClient.ts index a42f593ab3..40ef8c5805 100644 --- a/src/providers/DeferredAnalysisClient.ts +++ b/src/providers/DeferredAnalysisClient.ts @@ -1,6 +1,6 @@ import type { AnalysisClient, - FileCloseResourceOwner, + RetainedResourceScope, ProviderRuntimeLineageSnapshot, ProviderRequestActivitySnapshot, } from "../application/AnalysisProvider.js"; @@ -29,7 +29,7 @@ export class DeferredAnalysisClient implements AnalysisClient { constructor( loadClient: ( signal: AbortSignal, - resourceOwner: FileCloseResourceOwner, + resourceOwner: RetainedResourceScope, ) => Promise, providerId: string, ) { diff --git a/src/providers/OwnedCommandCleanup.ts b/src/providers/OwnedCommandCleanup.ts index 0da5bc978b..10ce79d1c6 100644 --- a/src/providers/OwnedCommandCleanup.ts +++ b/src/providers/OwnedCommandCleanup.ts @@ -1,6 +1,6 @@ import { jsonValueSchema } from "../domain/jsonValue.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; @@ -9,7 +9,7 @@ import { OwnedCommandFailure } from "../process/OwnedCommand.js"; /** Transfer exact helper and command cleanup capabilities to the caller's scope. */ export const retainOwnedCommandCleanup = ( cause: unknown, - resourceOwner: FileCloseResourceOwner, + resourceOwner: RetainedResourceScope, providerId: string, operation: string, ): readonly AnalysisError[] => { diff --git a/tests/boundary/cli/kunaDoctorHeldOutput.test.ts b/tests/boundary/cli/kunaDoctorHeldOutput.test.ts new file mode 100644 index 0000000000..c404ac8818 --- /dev/null +++ b/tests/boundary/cli/kunaDoctorHeldOutput.test.ts @@ -0,0 +1,139 @@ +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { + cleanupOwnedProcessGroup, + type OwnedProcessGroup, +} from "../../../src/process/ProcessOwnership.js"; +import { observeOwnedProcessGroups } from "../../../src/process/ProcessOwnershipObservation.js"; + +cliTest.skipIf(!["darwin", "linux"].includes(process.platform))( + "doctor returns bounded Kuna version diagnostics when an exited probe's descendant holds its output pipe", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-kuna-doctor-held-output-"); + const home = join(root, "home"); + const executable = join(root, "kuna"); + const marker = join(root, "probe-owner.json"); + await mkdir(home); + await writeFile( + executable, + `#!${process.execPath} +const fs = require("node:fs"); +const { spawn } = require("node:child_process"); +if (process.argv[2] !== "--version") process.exit(7); +const child = spawn(process.execPath, ["-e", ${JSON.stringify( + `process.on("SIGTERM", () => {}); setTimeout(() => process.exit(0), 28000);`, + )}], { stdio: "inherit" }); +fs.writeFileSync(${JSON.stringify(marker)}, JSON.stringify({ + leaderPid: process.pid, + descendantPid: child.pid, + runId: process.env.REA_PROCESS_RUN_ID, +})); +process.stdout.write("kuna 1.833\\n"); +process.stderr.write("probe stderr\\n"); +process.exit(0); +`, + { mode: 0o700 }, + ); + await chmod(executable, 0o700); + + const pending = cli.run({ + arguments: ["doctor", "--provider", "kuna", "--json"], + environment: { + HOME: home, + USERPROFILE: home, + HOPPER_LAUNCHER_PATH: join(root, "missing-hopper"), + GHIDRA_INSTALL_DIR: join(root, "missing-ghidra"), + REA_KUNA_COMMAND: executable, + REA_KUNA_CACHE_DIR: join(root, "kuna-cache"), + REA_LOG_LEVEL: "silent", + }, + // Leave room for the production probe deadline, while still bounding a + // regression that waits for the descendant's finite fallback expiry. + timeoutMs: 20_000, + }); + let ownership: OwnedProcessGroup | undefined; + const startedAt = Date.now(); + try { + let markerContents: string | undefined; + for (let attempt = 0; attempt < 100; attempt++) { + try { + markerContents = await readFile(marker, "utf8"); + break; + } catch (cause: unknown) { + if ( + !( + cause instanceof Error && + "code" in cause && + cause.code === "ENOENT" + ) + ) + throw cause; + } + await delay(50); + } + expect(markerContents, "fake Kuna probe did not start").toBeDefined(); + const observed = z + .strictObject({ + leaderPid: z.number().int().positive(), + descendantPid: z.number().int().positive(), + runId: z.string().min(1), + }) + .parse(JSON.parse(markerContents!)); + ownership = { + leaderPid: observed.leaderPid, + processGroupId: observed.leaderPid, + runId: observed.runId, + }; + + const result = await pending; + expect(Date.now() - startedAt).toBeLessThan(16_000); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + providerInspections: expect.arrayContaining([ + expect.objectContaining({ + id: "kuna", + available: false, + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "installation", + ok: false, + code: "version_unresolved", + details: expect.objectContaining({ + probe_failure_reason: "timeout", + stop_requested: true, + exit_code: 0, + signal: null, + stdout: "kuna 1.833\n", + stderr: "probe stderr\n", + stdout_bytes: 11, + stdout_observed_bytes: 11, + stderr_bytes: 13, + stderr_observed_bytes: 13, + diagnostic_truncated: false, + output_incomplete: false, + output_truncated: false, + deadline_ms: 10_000, + }), + }), + ]), + }), + ]), + }); + const groups = await observeOwnedProcessGroups(ownership.runId, [ + ownership.processGroupId, + ]); + expect(groups.get(ownership.processGroupId)).toEqual({ state: "empty" }); + } finally { + if (ownership !== undefined) { + expect((await cleanupOwnedProcessGroup(ownership)).cleaned).toBe(true); + } + await pending; + } + }, +); diff --git a/tests/boundary/providers/kuna/kunaCommandFailures.test.ts b/tests/boundary/providers/kuna/kunaCommandFailures.test.ts index 523043649f..c480f52a77 100644 --- a/tests/boundary/providers/kuna/kunaCommandFailures.test.ts +++ b/tests/boundary/providers/kuna/kunaCommandFailures.test.ts @@ -1,7 +1,7 @@ import { expect, it as test } from "vitest"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; -import { inspectKunaInstallationOwned as inspectKunaInstallation } from "./kunaFileCloseOwner.js"; +import { inspectKunaInstallationOwned as inspectKunaInstallation } from "./kunaResourceScope.js"; import { KunaTargetCommands } from "../../../../src/kuna/KunaTargetCommands.js"; import { fakeKunaCalls } from "./fakeKuna.js"; import { session } from "./kunaSessionFixture.js"; diff --git a/tests/boundary/providers/kuna/kunaCommandLifetime.test.ts b/tests/boundary/providers/kuna/kunaCommandLifetime.test.ts index 71cd213c41..659d9946cb 100644 --- a/tests/boundary/providers/kuna/kunaCommandLifetime.test.ts +++ b/tests/boundary/providers/kuna/kunaCommandLifetime.test.ts @@ -7,17 +7,18 @@ import { session } from "./kunaSessionFixture.js"; const SCRIPT = String.raw` import assert from "node:assert/strict"; import { ArtifactResourceScope } from "./dist/artifacts/ArtifactResourceScope.js"; +import { AnalysisCleanupLease } from "./dist/artifacts/AnalysisCleanupLease.js"; import { inspectKunaInstallation } from "./dist/kuna/KunaInstallation.js"; import { KunaTargetCommands } from "./dist/kuna/KunaTargetCommands.js"; const fixture = JSON.parse(process.env.REA_TEST_KUNA_FIXTURE); -const fileCloseScope = new ArtifactResourceScope(); +const kunaResources = new ArtifactResourceScope(); const installation = await inspectKunaInstallation( { REA_KUNA_COMMAND: fixture.kuna, REA_KUNA_CACHE_DIR: fixture.cacheRoot }, { - fileCloseResources: { + resourceScope: { retain: (handle, resource) => - fileCloseScope.retain({ kind: "file-handle", handle, resource }), - run: (operation) => fileCloseScope.run(operation), + kunaResources.retain({ kind: "lease", resource, lease: AnalysisCleanupLease.forCloseOwner(handle, resource) }), + run: (operation) => kunaResources.run(operation), }, }, ); @@ -43,7 +44,7 @@ try { } finally { controller.abort(); assert.equal((await commands.close()).ok, true); - await fileCloseScope.close(); + await kunaResources.close(); } `; diff --git a/tests/boundary/providers/kuna/kunaDoctor.test.ts b/tests/boundary/providers/kuna/kunaDoctor.test.ts index 78661aeda8..758b94d570 100644 --- a/tests/boundary/providers/kuna/kunaDoctor.test.ts +++ b/tests/boundary/providers/kuna/kunaDoctor.test.ts @@ -3,10 +3,11 @@ import { join } from "node:path"; import { expect, it as test } from "vitest"; -import { inspectKunaProviderOwned as inspectKunaProvider } from "./kunaFileCloseOwner.js"; +import { inspectKunaProviderOwned as inspectKunaProvider } from "./kunaResourceScope.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { fakeKunaCalls, writeFakeKuna } from "./fakeKuna.js"; import { createSystemDoctorHost } from "../../../../src/doctorRuntime.js"; +import { runDoctorOwned } from "../../../../src/application/Doctor.js"; import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js"; import { STARTUP_OWNER_CLOSED } from "../../../../src/process/SharedStartupAttempt.js"; @@ -252,3 +253,34 @@ it("keeps a healthy composed Kuna inspection when the caller stays active", asyn await host.close!(); } }); + +it("projects the fake Kuna through the completed doctor workflow", async () => { + const root = await createTestTempDirectory("rea-kuna-public-doctor-"); + const kuna = await writeFakeKuna(root); + const host = createSystemDoctorHost( + doctorEnvironment(root, kuna), + new AbortController().signal, + ); + try { + const report = await runDoctorOwned(undefined, host, { + providers: ["kuna"], + skill: false, + clients: [], + }); + expect(report.providerInspections).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "kuna", + available: true, + providerVersion: "1.833", + checks: expect.arrayContaining([ + expect.objectContaining({ name: "installation", ok: true }), + ]), + }), + ]), + ); + expect(await fakeKunaCalls(kuna)).toHaveLength(1); + } finally { + await host.close!(); + } +}); diff --git a/tests/boundary/providers/kuna/kunaFileCloseOwner.ts b/tests/boundary/providers/kuna/kunaFileCloseOwner.ts deleted file mode 100644 index b047e3a9d3..0000000000 --- a/tests/boundary/providers/kuna/kunaFileCloseOwner.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; -import type { FileCloseResourceOwner } from "../../../../src/application/AnalysisProvider.js"; -import type { KunaInstallationOptions } from "../../../../src/kuna/KunaInstallation.js"; -import { inspectKunaInstallation } from "../../../../src/kuna/KunaInstallation.js"; -import { inspectKunaProvider } from "../../../../src/kuna/KunaDoctor.js"; - -export const withKunaFileCloseOwner = async ( - operation: (owner: FileCloseResourceOwner) => Promise, -): Promise => { - const scope = new ArtifactResourceScope(); - const owner: FileCloseResourceOwner = { - retain: (handle, resource) => - scope.retain({ - kind: "lease", - resource, - lease: { release: () => handle.close() }, - }), - run: (work) => scope.run(work), - }; - try { - return await operation(owner); - } finally { - await scope.close(); - } -}; - -export const inspectKunaInstallationOwned = ( - environment: Readonly, - options: Omit = {}, -) => - withKunaFileCloseOwner((fileCloseResources) => - inspectKunaInstallation(environment, { ...options, fileCloseResources }), - ); - -export const inspectKunaProviderOwned = ( - environment: Readonly, - platform?: NodeJS.Platform, -) => - withKunaFileCloseOwner((fileCloseResources) => - inspectKunaProvider(environment, platform, { fileCloseResources }), - ); diff --git a/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts b/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts index 616e51aad5..f8f2599438 100644 --- a/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts +++ b/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts @@ -14,7 +14,7 @@ import { AnalysisError } from "../../../../src/domain/analysisErrorBase.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; -import type { FileCloseResourceOwner } from "../../../../src/application/AnalysisProvider.js"; +import type { RetainedResourceScope } from "../../../../src/application/AnalysisProvider.js"; const injected = vi.hoisted(() => ({ failNextClose: false, @@ -125,7 +125,7 @@ import { composeBinarySession } from "../../../../src/application/binary/BinaryS import { KunaTargetCommands } from "../../../../src/kuna/KunaTargetCommands.js"; import { KunaProjectCache } from "../../../../src/kuna/KunaProjectCache.js"; import { inspectKunaProvider } from "../../../../src/kuna/KunaDoctor.js"; -import { inspectKunaInstallationOwned } from "./kunaFileCloseOwner.js"; +import { inspectKunaInstallationOwned } from "./kunaResourceScope.js"; import { fakeKunaCalls } from "./fakeKuna.js"; import { run, session } from "./kunaSessionFixture.js"; @@ -575,7 +575,7 @@ it.skipIf(process.platform === "win32")( injected.closeCalls = 0; injected.failNextClose = true; const resources = new ArtifactResourceScope(); - const fileCloseResources: FileCloseResourceOwner = { + const resourceScope: RetainedResourceScope = { retain: (owner, resource) => resources.retain({ kind: "lease", @@ -591,7 +591,7 @@ it.skipIf(process.platform === "win32")( REA_KUNA_CACHE_DIR: fixture.cacheRoot, }, process.platform, - { fileCloseResources }, + { resourceScope }, ); expect(inspection.checks[0]).toMatchObject({ ok: false, @@ -678,7 +678,7 @@ it.skipIf(process.platform === "win32").each( const controller = new AbortController(); const reason = { request: "cancel completed Kuna installation read" }; const resources = new ArtifactResourceScope(); - const fileCloseResources: FileCloseResourceOwner = { + const resourceScope: RetainedResourceScope = { retain: (owner, resource) => resources.retain({ kind: "lease", @@ -700,7 +700,7 @@ it.skipIf(process.platform === "win32").each( REA_KUNA_CACHE_DIR: fixture.cacheRoot, }, process.platform, - { fileCloseResources, signal: controller.signal }, + { resourceScope, signal: controller.signal }, ).then( (inspection) => { expect(closeRefusal).toBe(true); @@ -718,7 +718,7 @@ it.skipIf(process.platform === "win32").each( const result = consumer === "profile" ? await fixture.provider.resolveAnalysisProfile(fixture.target, { - fileCloseResources, + resourceScope, signal: controller.signal, }) : await client!.execute( diff --git a/tests/boundary/providers/kuna/kunaInstallation.test.ts b/tests/boundary/providers/kuna/kunaInstallation.test.ts index c4f7b42cb1..535860690f 100644 --- a/tests/boundary/providers/kuna/kunaInstallation.test.ts +++ b/tests/boundary/providers/kuna/kunaInstallation.test.ts @@ -12,9 +12,10 @@ import { expect, it as test } from "vitest"; import { kunaInstallationCancellation } from "../../../../src/kuna/KunaInstallation.js"; import { readKunaConfiguration } from "../../../../src/kuna/KunaConfiguration.js"; -import { inspectKunaInstallationOwned as inspectKunaInstallation } from "./kunaFileCloseOwner.js"; +import { inspectKunaInstallationOwned as inspectKunaInstallation } from "./kunaResourceScope.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { fakeKunaCalls, writeFakeKuna } from "./fakeKuna.js"; +import { makeLauncher, ownedGroupCleanup } from "./kunaVersionProbeProcess.js"; const it = test.skipIf(process.platform === "win32"); @@ -98,7 +99,7 @@ it("reports a failed version probe with its exit status", async () => { error: { code: "version_unresolved", reason: expect.stringContaining("version probe failed"), - diagnostics: { exit_code: 3, killed: false }, + diagnostics: { exit_code: 3, stop_requested: true }, }, }); }); @@ -210,19 +211,19 @@ it("stops a version probe that ignores SIGTERM instead of waiting on it", async const result = await inspectKunaInstallation( { REA_KUNA_COMMAND: kuna }, - // Long enough for Node to start the fake and install its SIGTERM handler. - { probeTimeoutMs: 1500 }, + // Allow owned-process identity preparation, then exercise the stopped child. + { probeTimeoutMs: 4_000 }, ); expect(result).toMatchObject({ ok: false, error: { code: "version_unresolved", - diagnostics: { killed: true, deadline_ms: 1500 }, + diagnostics: { stop_requested: true, deadline_ms: 4_000 }, }, }); - // The deadline plus ExecFileOutput's bounded kill grace, never an open-ended wait. - expect(Date.now() - started).toBeLessThan(15_000); + // The owned process-group stop is bounded and must not wait on inherited output forever. + expect(Date.now() - started).toBeLessThan(10_000); const pid = (await fakeKunaCalls(kuna))[0]?.pid ?? 0; expect(() => process.kill(pid, 0)).toThrow(); }, 20_000); @@ -232,6 +233,24 @@ it("keeps a version deadline ahead of cancellation during its joined stop", asyn const command = join(root, "probe.cjs"); const stopped = join(root, "stopped"); const release = join(root, "release"); + const launcher = makeLauncher(async (_child, pid) => { + process.kill(-pid, "SIGTERM"); + const waitFor = async (path: string) => { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + try { + await readFile(path); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw new Error(`Timed out waiting for ${path}`); + }; + await waitFor(stopped); + await waitFor(release); + return ownedGroupCleanup(pid); + }); await ( await import("node:fs/promises") ).writeFile( @@ -252,7 +271,11 @@ setTimeout(() => process.exit(9), 10000).unref(); REA_KUNA_COMMAND: command, REA_KUNA_CACHE_DIR: join(root, "cache"), }, - { signal: controller.signal, probeTimeoutMs: 1000 }, + { + signal: controller.signal, + probeTimeoutMs: 1_000, + processLauncher: launcher.launcher, + }, ); const outcome = pending.then( (value) => ({ kind: "result" as const, value }), @@ -277,7 +300,7 @@ setTimeout(() => process.exit(9), 10000).unref(); ok: false, error: { code: "version_unresolved", - diagnostics: { deadline_ms: 1000, killed: true }, + diagnostics: { deadline_ms: 1000, stop_requested: true }, }, }, }); diff --git a/tests/boundary/providers/kuna/kunaInstallationLifetime.test.ts b/tests/boundary/providers/kuna/kunaInstallationLifetime.test.ts index 8ae23a75db..af5012f70b 100644 --- a/tests/boundary/providers/kuna/kunaInstallationLifetime.test.ts +++ b/tests/boundary/providers/kuna/kunaInstallationLifetime.test.ts @@ -61,7 +61,7 @@ vi.mock("../../../../src/filesystem/RegularFile.js", async (importOriginal) => { }); import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; -import type { FileCloseResourceOwner } from "../../../../src/application/AnalysisProvider.js"; +import type { RetainedResourceScope } from "../../../../src/application/AnalysisProvider.js"; import { inspectKunaInstallation, kunaInstallationCancellation, @@ -91,7 +91,7 @@ it const root = await createTestTempDirectory("rea-kuna-inspection-lifetime-"); const executable = await writeFakeKuna(root); const scope = new ArtifactResourceScope(); - const fileCloseResources: FileCloseResourceOwner = { + const resourceScope: RetainedResourceScope = { retain: (owner, resource) => scope.retain({ kind: "lease", @@ -103,7 +103,7 @@ it const controller = new AbortController(); const inspection = inspectKunaInstallation( { REA_KUNA_COMMAND: executable }, - { fileCloseResources, signal: controller.signal }, + { resourceScope, signal: controller.signal }, ); const inspectionOutcome = inspection.then( diff --git a/tests/boundary/providers/kuna/kunaProjectCache.test.ts b/tests/boundary/providers/kuna/kunaProjectCache.test.ts index 9fc96fdcb9..4d71d0de46 100644 --- a/tests/boundary/providers/kuna/kunaProjectCache.test.ts +++ b/tests/boundary/providers/kuna/kunaProjectCache.test.ts @@ -14,7 +14,7 @@ import { join } from "node:path"; import { expect, it as test, vi } from "vitest"; -import { inspectKunaInstallationOwned as inspectKunaInstallation } from "./kunaFileCloseOwner.js"; +import { inspectKunaInstallationOwned as inspectKunaInstallation } from "./kunaResourceScope.js"; import { cleanupOwnedProcessGroup } from "../../../../src/process/ProcessOwnership.js"; import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProcess.js"; import type { KunaInstallation } from "../../../../src/kuna/KunaInstallation.js"; diff --git a/tests/boundary/providers/kuna/kunaProjectConcurrency.test.ts b/tests/boundary/providers/kuna/kunaProjectConcurrency.test.ts index 9f0c302e99..248feac0f9 100644 --- a/tests/boundary/providers/kuna/kunaProjectConcurrency.test.ts +++ b/tests/boundary/providers/kuna/kunaProjectConcurrency.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { afterEach, expect, it as test, onTestFinished, vi } from "vitest"; -import { inspectKunaInstallationOwned as inspectKunaInstallation } from "./kunaFileCloseOwner.js"; +import { inspectKunaInstallationOwned as inspectKunaInstallation } from "./kunaResourceScope.js"; import { KunaProjectCache } from "../../../../src/kuna/KunaProjectCache.js"; import type { KunaExportStatus } from "../../../../src/kuna/KunaProjectExport.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; diff --git a/tests/boundary/providers/kuna/kunaResourceScope.ts b/tests/boundary/providers/kuna/kunaResourceScope.ts new file mode 100644 index 0000000000..b151a91760 --- /dev/null +++ b/tests/boundary/providers/kuna/kunaResourceScope.ts @@ -0,0 +1,32 @@ +import { createInspectionResources } from "../../../fixtures/inspectionResources.js"; +import type { RetainedResourceScope } from "../../../../src/application/AnalysisProvider.js"; +import type { KunaInstallationOptions } from "../../../../src/kuna/KunaInstallation.js"; +import { inspectKunaInstallation } from "../../../../src/kuna/KunaInstallation.js"; +import { inspectKunaProvider } from "../../../../src/kuna/KunaDoctor.js"; + +export const withKunaResourceScope = async ( + operation: (owner: RetainedResourceScope) => Promise, +): Promise => { + const scope = createInspectionResources(); + try { + return await operation(scope.owner); + } finally { + await scope.close(); + } +}; + +export const inspectKunaInstallationOwned = ( + environment: Readonly, + options: Omit = {}, +) => + withKunaResourceScope((resourceScope) => + inspectKunaInstallation(environment, { ...options, resourceScope }), + ); + +export const inspectKunaProviderOwned = ( + environment: Readonly, + platform?: NodeJS.Platform, +) => + withKunaResourceScope((resourceScope) => + inspectKunaProvider(environment, platform, { resourceScope }), + ); diff --git a/tests/boundary/providers/kuna/kunaRunOwnership.test.ts b/tests/boundary/providers/kuna/kunaRunOwnership.test.ts index 62d354fc47..177f827e3d 100644 --- a/tests/boundary/providers/kuna/kunaRunOwnership.test.ts +++ b/tests/boundary/providers/kuna/kunaRunOwnership.test.ts @@ -2,7 +2,7 @@ import { setImmediate } from "node:timers/promises"; import { expect, it as test, vi } from "vitest"; -import { inspectKunaInstallationOwned as inspectKunaInstallation } from "./kunaFileCloseOwner.js"; +import { inspectKunaInstallationOwned as inspectKunaInstallation } from "./kunaResourceScope.js"; import { KunaRunSlots } from "../../../../src/kuna/KunaRunSlots.js"; import { KunaTargetCommands } from "../../../../src/kuna/KunaTargetCommands.js"; import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProcess.js"; diff --git a/tests/boundary/providers/kuna/kunaSessionFixture.ts b/tests/boundary/providers/kuna/kunaSessionFixture.ts index 7fa749afa9..9cd64ef4ed 100644 --- a/tests/boundary/providers/kuna/kunaSessionFixture.ts +++ b/tests/boundary/providers/kuna/kunaSessionFixture.ts @@ -9,7 +9,7 @@ import { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; import { cleanupOwnedProcessGroup } from "../../../../src/process/ProcessOwnership.js"; import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProcess.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; -import { withKunaFileCloseOwner } from "./kunaFileCloseOwner.js"; +import { withKunaResourceScope } from "./kunaResourceScope.js"; import { fakeKunaCalls, writeFakeKuna, @@ -54,8 +54,8 @@ export const session = async ( ...(singleFunctionRuns === undefined ? {} : { singleFunctionRuns }), }, ); - const profile = await withKunaFileCloseOwner((fileCloseResources) => - provider.resolveAnalysisProfile(target, { fileCloseResources }), + const profile = await withKunaResourceScope((resourceScope) => + provider.resolveAnalysisProfile(target, { resourceScope }), ); if (!profile.ok || profile.value.profile === null) throw new Error("profile did not resolve"); diff --git a/tests/boundary/providers/kuna/kunaSetupReadiness.test.ts b/tests/boundary/providers/kuna/kunaSetupReadiness.test.ts index 81898a1e77..95ca8eb2e6 100644 --- a/tests/boundary/providers/kuna/kunaSetupReadiness.test.ts +++ b/tests/boundary/providers/kuna/kunaSetupReadiness.test.ts @@ -6,7 +6,7 @@ import { options, } from "../../../../src/application/Setup.fixture.js"; import { runSetup } from "../../../../src/application/Setup.js"; -import { inspectKunaProviderOwned } from "./kunaFileCloseOwner.js"; +import { inspectKunaProviderOwned } from "./kunaResourceScope.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { writeFakeKuna } from "./fakeKuna.js"; diff --git a/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts b/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts new file mode 100644 index 0000000000..6dd5f8128a --- /dev/null +++ b/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts @@ -0,0 +1,492 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmod, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +import { expect, it } from "vitest"; + +import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; +import { + createKunaResourceScope, + inspectKunaInstallation, + kunaInstallationCancellation, +} from "../../../../src/kuna/KunaInstallation.js"; +import { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; +import type { RetainedResourceScope } from "../../../../src/application/AnalysisProvider.js"; +import { ProviderAdapterError } from "../../../../src/domain/providerAdapterError.js"; +import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; +import { RegularFileCleanupFailure } from "../../../../src/filesystem/OwnedRegularFile.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { makeLauncher, ownedGroupCleanup } from "./kunaVersionProbeProcess.js"; + +const writeProbe = async ( + root: string, + modeFile: string, + childStarted: string, + parentExited: string, +) => { + const executable = join(root, "kuna-probe"); + await writeFile( + executable, + `#!${process.execPath} +const fs = require("node:fs"); +const { spawn } = require("node:child_process"); +const mode = fs.readFileSync(${JSON.stringify(modeFile)}, "utf8"); +process.stdout.write("kuna 1.833\\n"); +process.stderr.write("x".repeat(1024)); +if (mode === "hold" || mode === "detached") { + const child = spawn(process.execPath, ["-e", ${JSON.stringify( + `const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(childStarted)},"started");process.on("SIGTERM",()=>{});setInterval(()=>{},1000);`, + )}], { stdio: mode === "hold" ? "inherit" : "ignore" }); + const poll = setInterval(() => { + if (!fs.existsSync(${JSON.stringify(childStarted)})) return; + clearInterval(poll); + fs.writeFileSync(${JSON.stringify(parentExited)}, "exited"); + process.exit(0); + }, 5); +} +`, + { mode: 0o700 }, + ); + await chmod(executable, 0o700); + await writeFile(modeFile, "hold"); + return executable; +}; + +const resourceScopeFor = ( + scope: ArtifactResourceScope, +): RetainedResourceScope => ({ + retain: (owner, resource) => + scope.retain({ + kind: "lease", + resource, + lease: { release: () => owner.close() }, + }), + run: (operation) => scope.run(operation), +}); + +const waitForFile = async (path: string) => { + const deadline = Date.now() + 3_000; + while (Date.now() < deadline) { + try { + await import("node:fs/promises").then(({ access }) => access(path)); + return; + } catch { + await delay(10); + } + } + throw new Error(`Timed out waiting for test marker ${path}`); +}; + +const target = (path: string) => ({ + path, + sha256: createHash("sha256").update("target").digest("hex"), + kind: "executable" as const, + format: "elf" as const, + architecture: "x86_64" as const, + availableArchitectures: ["x86_64" as const], +}); + +it.skipIf(process.platform === "win32")( + "bounds timeout settlement when a test-owned descendant holds stdout and preserves the captured version output", + async () => { + const root = await createTestTempDirectory("rea-kuna-held-version-pipe-"); + const modeFile = join(root, "mode"); + const childStarted = join(root, "child-started"); + const parentExited = join(root, "parent-exited"); + const executable = await writeProbe( + root, + modeFile, + childStarted, + parentExited, + ); + const launcher = makeLauncher(); + const scope = new ArtifactResourceScope(); + const startedAt = Date.now(); + try { + const result = await inspectKunaInstallation( + { REA_KUNA_COMMAND: executable }, + { + resourceScope: resourceScopeFor(scope), + processLauncher: launcher.launcher, + probeTimeoutMs: 3_000, + }, + ); + expect(result).toMatchObject({ + ok: false, + error: { + code: "version_unresolved", + diagnostics: { + exit_code: 0, + signal: null, + stop_requested: true, + stdout: "kuna 1.833\n", + stderr: "x".repeat(1024), + stdout_bytes: 11, + stdout_observed_bytes: 11, + stderr_bytes: 1024, + stderr_observed_bytes: 1024, + diagnostic_truncated: false, + output_incomplete: false, + output_truncated: false, + deadline_ms: 3_000, + }, + }, + }); + await waitForFile(childStarted); + await waitForFile(parentExited); + expect(Date.now() - startedAt).toBeLessThan(7_000); + expect(scope.hasPending).toBe(false); + expect(launcher.groups).toHaveLength(1); + } finally { + await scope.close(); + } + }, +); + +it.skipIf(process.platform === "win32")( + "keeps a complete successful version capture unavailable until cleanup-only ownership is confirmed", + async () => { + const root = await createTestTempDirectory( + "rea-kuna-cleanup-only-version-owner-", + ); + const modeFile = join(root, "mode"); + const childStarted = join(root, "child-started"); + const parentExited = join(root, "parent-exited"); + const executable = await writeProbe( + root, + modeFile, + childStarted, + parentExited, + ); + await writeFile(modeFile, "detached"); + let cleanupAttempts = 0; + let allowCleanup = false; + const launcher = makeLauncher(async (_child, pid) => { + cleanupAttempts += 1; + if (!allowCleanup) + return { cleaned: false, reason: "controlled cleanup-only refusal" }; + return ownedGroupCleanup(pid); + }); + const scope = new ArtifactResourceScope(); + const provider = new KunaProvider( + { REA_KUNA_COMMAND: executable, REA_KUNA_CACHE_DIR: join(root, "cache") }, + { launcher: launcher.launcher }, + ); + const input = target(join(root, "target")); + await writeFile(input.path, "target"); + const options = { resourceScope: resourceScopeFor(scope) }; + try { + const first = await provider.resolveAnalysisProfile(input, options); + await waitForFile(childStarted); + await waitForFile(parentExited); + expect(first.ok).toBe(false); + if (first.ok) + throw new Error("Unconfirmed cleanup cannot admit a profile"); + expect(first.error.diagnostics).toMatchObject({ + exit_code: 0, + signal: null, + probe_failure_reason: "process", + stop_requested: true, + cleanup_incomplete: true, + stdout: "kuna 1.833\n", + stderr: "x".repeat(1024), + stdout_bytes: 11, + stdout_observed_bytes: 11, + stderr_bytes: 1024, + stderr_observed_bytes: 1024, + diagnostic_truncated: false, + output_incomplete: false, + output_truncated: false, + }); + expect(scope.hasPending).toBe(true); + + const blocked = await provider.resolveAnalysisProfile(input, options); + expect(blocked.ok).toBe(false); + expect(launcher.launches).toBe(1); + expect(cleanupAttempts).toBe(2); + + allowCleanup = true; + const retried = await provider.resolveAnalysisProfile(input, options); + expect(retried.ok).toBe(true); + expect(launcher.launches).toBe(2); + expect(scope.hasPending).toBe(false); + expect(first.error.cleanupIncomplete).toBe(false); + } finally { + allowCleanup = true; + await scope.close(); + } + }, +); + +it.skipIf(process.platform === "win32")( + "settles caller abort after direct-child exit with the exact reason and partial streams", + async () => { + const root = await createTestTempDirectory("rea-kuna-abort-version-pipe-"); + const modeFile = join(root, "mode"); + const childStarted = join(root, "child-started"); + const parentExited = join(root, "parent-exited"); + const executable = await writeProbe( + root, + modeFile, + childStarted, + parentExited, + ); + const launcher = makeLauncher(); + const scope = new ArtifactResourceScope(); + const controller = new AbortController(); + const reason = Object.freeze({ + request: "cancel while inherited stdout stays open", + }); + try { + const pending = inspectKunaInstallation( + { REA_KUNA_COMMAND: executable }, + { + resourceScope: resourceScopeFor(scope), + processLauncher: launcher.launcher, + signal: controller.signal, + probeTimeoutMs: 10_000, + }, + ); + await waitForFile(parentExited); + controller.abort(reason); + const cause = await pending.catch((error: unknown) => error); + const cancellation = kunaInstallationCancellation(cause); + expect(cancellation?.reason).toBe(reason); + expect(cancellation?.diagnostics).toMatchObject({ + kuna_version_probe: { + status: "partial", + exit_code: 0, + signal: null, + stdout: "kuna 1.833\n", + stderr: "x".repeat(1024), + stdout_bytes: 11, + stdout_observed_bytes: 11, + stderr_bytes: 1024, + stderr_observed_bytes: 1024, + diagnostic_truncated: false, + output_incomplete: false, + output_truncated: false, + }, + }); + expect(scope.hasPending).toBe(false); + } finally { + controller.abort(reason); + await scope.close(); + } + }, +); + +it.skipIf(process.platform === "win32")( + "retains incomplete probe cleanup, blocks another profile, then retries only that exact process group", + async () => { + const root = await createTestTempDirectory( + "rea-kuna-retained-version-owner-", + ); + const modeFile = join(root, "mode"); + const childStarted = join(root, "child-started"); + const parentExited = join(root, "parent-exited"); + const executable = await writeProbe( + root, + modeFile, + childStarted, + parentExited, + ); + let cleanupAttempts = 0; + let allowCleanup = false; + const launcher = makeLauncher(async (_child, pid) => { + cleanupAttempts += 1; + if (!allowCleanup) + return { cleaned: false, reason: "controlled stop refusal" }; + return ownedGroupCleanup(pid); + }); + const scope = new ArtifactResourceScope(); + const unrelated = spawn( + process.execPath, + ["-e", "setInterval(()=>{},1000)"], + { + detached: true, + stdio: "ignore", + }, + ); + await new Promise((resolve, reject) => { + unrelated.once("spawn", resolve); + unrelated.once("error", reject); + }); + const provider = new KunaProvider( + { REA_KUNA_COMMAND: executable, REA_KUNA_CACHE_DIR: join(root, "cache") }, + { launcher: launcher.launcher, probeTimeoutMs: 2_500 }, + ); + const input = target(join(root, "target")); + await writeFile(input.path, "target"); + const options = { resourceScope: resourceScopeFor(scope) }; + try { + const first = await provider.resolveAnalysisProfile(input, options); + expect(first.ok).toBe(false); + if (first.ok) + throw new Error("Expected the held version probe to time out"); + const primary = first.error; + expect(primary.cleanupIncomplete).toBe(true); + expect(scope.hasPending).toBe(true); + await waitForFile(parentExited); + + const blocked = await provider.resolveAnalysisProfile(input, options); + expect(blocked.ok).toBe(false); + expect(launcher.launches).toBe(1); + expect(cleanupAttempts).toBe(2); + if (blocked.ok) + throw new Error("Expected retained cleanup to block a new probe"); + expect(blocked.error.cleanupIncomplete).toBe(true); + + allowCleanup = true; + await writeFile(modeFile, "success"); + const retried = await provider.resolveAnalysisProfile(input, options); + expect(retried.ok).toBe(true); + expect(launcher.launches).toBe(2); + expect(scope.hasPending).toBe(false); + expect(primary.cleanupIncomplete).toBe(false); + expect(() => process.kill(unrelated.pid!, 0)).not.toThrow(); + } finally { + allowCleanup = true; + await scope.close(); + unrelated.kill("SIGKILL"); + } + }, +); + +it("keeps a file-operation primary linked to every retained file and process owner", async () => { + const scope = new ArtifactResourceScope(); + const resourceScope = resourceScopeFor(scope); + const kunaResources = createKunaResourceScope(resourceScope); + let processCanClose = false; + let processResources = ["initial Kuna process group"]; + const processOwner = { + close: async () => { + processResources = ["remaining Kuna descendant"]; + if (!processCanClose) throw new Error("process stop unconfirmed"); + }, + get cleanupResources() { + return processResources; + }, + }; + const fileHandle = new OwnedFileHandle({ + fd: 17, + close: async () => {}, + }) as unknown as OwnedFileHandle; + kunaResources.owner.retain(fileHandle, "/tmp/kuna-test-source"); + kunaResources.owner.retain(processOwner, "kuna test process"); + const primary = new ProviderAdapterError("kuna", "kuna:profile", { + userMessage: "The admitted Kuna version probe failed.", + cleanup: { + reason: "The source descriptor close is unconfirmed.", + resources: ["/tmp/kuna-test-source"], + }, + }); + const fileFailure = new RegularFileCleanupFailure( + "/tmp/kuna-test-source", + fileHandle, + { kind: "failed", cause: primary }, + new Error("descriptor close refused"), + ); + kunaResources.bindFailure(fileFailure, primary); + expect(primary.cleanupIncomplete).toBe(true); + expect(primary.cleanupResources).toEqual([ + "/tmp/kuna-test-source", + "initial Kuna process group", + ]); + await expect(scope.retryCleanup()).rejects.toMatchObject({ + failedOwners: expect.any(Array), + }); + expect(primary.cleanupIncomplete).toBe(true); + expect(primary.cleanupResources).toEqual(["remaining Kuna descendant"]); + processCanClose = true; + await scope.retryCleanup(); + expect(primary.cleanupIncomplete).toBe(false); + expect(primary.userMessage).toBe("The admitted Kuna version probe failed."); + await scope.close(); +}); + +it("keeps unknown cleanup labels unknown instead of substituting the lookup key", async () => { + const scope = new ArtifactResourceScope(); + const kunaResources = createKunaResourceScope(resourceScopeFor(scope)); + const unknownOwner = { + close: async () => { + throw new Error("stop remains unconfirmed"); + }, + cleanupResources: undefined, + }; + kunaResources.owner.retain(unknownOwner, "display-only lookup key"); + const primary = new ProviderAdapterError("kuna", "kuna:profile", { + userMessage: "Cleanup is still pending.", + cleanup: { reason: "stop remains unconfirmed", resources: [] }, + }); + kunaResources.bindRetainedFailure(primary); + expect(kunaResources.hasPending).toBe(true); + expect(kunaResources.cleanupResources).toBeUndefined(); + await scope.close().catch(() => undefined); +}); + +it.skipIf(process.platform === "win32")( + "keeps caller cancellation primary while cancellation cleanup is held and then retried", + async () => { + const root = await createTestTempDirectory( + "rea-kuna-final-cancel-cleanup-", + ); + const modeFile = join(root, "mode"); + const childStarted = join(root, "child-started"); + const parentExited = join(root, "parent-exited"); + const executable = await writeProbe( + root, + modeFile, + childStarted, + parentExited, + ); + const cleanupGate = (() => { + let release!: () => void; + let entered!: () => void; + const held = new Promise((resolve) => (release = resolve)); + const started = new Promise((resolve) => (entered = resolve)); + return { held, started, release, entered }; + })(); + const launcher = makeLauncher(async (_child, pid) => { + cleanupGate.entered(); + await cleanupGate.held; + return ownedGroupCleanup(pid); + }); + const scope = new ArtifactResourceScope(); + const controller = new AbortController(); + const reason = Object.freeze({ + request: "abort during final process cleanup", + }); + try { + const pending = inspectKunaInstallation( + { REA_KUNA_COMMAND: executable }, + { + resourceScope: resourceScopeFor(scope), + processLauncher: launcher.launcher, + signal: controller.signal, + probeTimeoutMs: 10_000, + }, + ); + await waitForFile(parentExited); + controller.abort(reason); + await cleanupGate.started; + let settled = false; + void pending.then( + () => (settled = true), + () => (settled = true), + ); + await delay(25); + expect(settled).toBe(false); + cleanupGate.release(); + const cause = await pending.catch((error: unknown) => error); + expect(kunaInstallationCancellation(cause)?.reason).toBe(reason); + expect(scope.hasPending).toBe(false); + } finally { + controller.abort(reason); + cleanupGate.release(); + await scope.close(); + } + }, +); diff --git a/tests/boundary/providers/kuna/kunaVersionProbeProcess.ts b/tests/boundary/providers/kuna/kunaVersionProbeProcess.ts new file mode 100644 index 0000000000..fbba6abb3a --- /dev/null +++ b/tests/boundary/providers/kuna/kunaVersionProbeProcess.ts @@ -0,0 +1,91 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { setTimeout as delay } from "node:timers/promises"; + +import type { ProcessCleanupResult } from "../../../../src/process/ProcessOwnership.js"; +import type { + OwnedProviderProcessSpawnOptions, + SpawnedOwnedProviderProcess, +} from "../../../../src/process/ProviderProcess.js"; + +/** Cleanup authority for the exact detached group created by this test launcher. */ +export const ownedGroupCleanup = async ( + pid: number, +): Promise => { + let signaled = false; + try { + process.kill(-pid, "SIGTERM"); + signaled = true; + } catch { + // The test-owned process group may have exited before cleanup began. + } + await delay(50); + try { + process.kill(-pid, "SIGKILL"); + signaled = true; + } catch { + // An empty process group confirms cleanup. + } + const deadline = Date.now() + 1_000; + while (Date.now() < deadline) { + try { + process.kill(-pid, 0); + } catch { + return { cleaned: true, signaled }; + } + await delay(10); + } + return { + cleaned: false, + reason: `test-owned process group ${pid} remains live`, + }; +}; + +export const makeLauncher = ( + cleanup: ( + child: ChildProcess, + pid: number, + ) => Promise = (_child, pid) => ownedGroupCleanup(pid), +) => { + let launches = 0; + const groups: number[] = []; + const launcher = async ( + options: OwnedProviderProcessSpawnOptions, + ): Promise => { + options.signal?.throwIfAborted(); + const child = spawn(options.command, [...options.arguments], { + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + env: { + ...options.hostEnvironment, + ...options.env, + REA_PROCESS_RUN_ID: options.runId, + }, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + await new Promise((resolve, reject) => { + child.once("spawn", resolve); + child.once("error", reject); + }); + const pid = child.pid; + if (pid === undefined) + throw new Error("test launcher did not receive a PID"); + launches += 1; + groups.push(pid); + return { + process: child, + ownership: { + runId: options.runId, + leaderPid: pid, + processGroupId: pid, + }, + cleanup: () => cleanup(child, pid), + }; + }; + return { + launcher, + groups, + get launches() { + return launches; + }, + }; +}; diff --git a/tests/fixtures/inspectionResources.ts b/tests/fixtures/inspectionResources.ts index 952b3e43b4..e00b8fd5e0 100644 --- a/tests/fixtures/inspectionResources.ts +++ b/tests/fixtures/inspectionResources.ts @@ -1,11 +1,11 @@ import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js"; import { AnalysisCleanupLease } from "../../src/artifacts/AnalysisCleanupLease.js"; -import type { FileCloseResourceOwner } from "../../src/application/AnalysisProvider.js"; +import type { RetainedResourceScope } from "../../src/application/AnalysisProvider.js"; /** Test-scoped real inspection leases; every retained owner is closed by the fixture. */ export const createInspectionResources = () => { const scope = new ArtifactResourceScope(); - const owner: FileCloseResourceOwner = { + const owner: RetainedResourceScope = { retain: (resourceOwner, resource) => scope.retain({ kind: "lease",