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",