From 6b2f9918088c02ea00efc417051362f260d1cee2 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 12 Nov 2026 07:37:28 +0800 Subject: [PATCH] fix(ci): restore lifecycle ownership coverage (#10147) * fix(process): retain CLI input cleanup ownership * test(ci): align lifecycle fixtures with typed ownership contracts --------- Co-authored-by: Nianze Wu --- src/application/process/ProcessCli.ts | 32 ++++++++++++- src/cli/processCommands.ts | 7 ++- .../browser/historicalCaptureDecoder.test.ts | 25 +++++++--- .../filesystem/processCliFileInput.test.ts | 38 ++++++++++++--- .../process/darwinHelperCache.test.ts | 16 +++++-- .../darwinProcessRunTokenReader.test.ts | 22 ++++++--- .../process/desktopBuildOwnedCommand.test.ts | 6 ++- .../ghidraJavaProbePreparation.test.ts | 5 +- tests/boundary/process/ownedCommand.test.ts | 19 ++++++-- .../process/processCaptureLifecycle.test.ts | 14 +++--- .../kuna/kunaCommandFailures.test.ts | 7 ++- .../providers/kuna/kunaProjectCache.test.ts | 6 +-- .../kuna/kunaWorkspaceRollback.test.ts | 20 ++++++-- .../native/lldbPrelaunchCleanup.test.ts | 19 ++++---- .../providers/native/nativeProvider.test.ts | 15 +++--- .../providers/ownedCommandCleanup.test.ts | 14 ++---- .../darwinCompilerCleanup.test.ts | 48 ++++++++++++------- ...systemSnapshotFallbackCancellation.test.ts | 9 +++- ...cessObservationCommandFinalization.test.ts | 5 +- 19 files changed, 232 insertions(+), 95 deletions(-) diff --git a/src/application/process/ProcessCli.ts b/src/application/process/ProcessCli.ts index 5fd8dbd401..b5cbcff092 100644 --- a/src/application/process/ProcessCli.ts +++ b/src/application/process/ProcessCli.ts @@ -367,6 +367,19 @@ const cliAnalysisError = (error: AnalysisError): ProcessCliErrorOutput => ({ ...projectAnalysisError(error), }); +/** Project retained input cleanup only when the CLI delivers the error. */ +export const projectProcessCliInputCleanupError = ( + cause: unknown, +): ProcessCliErrorOutput => { + if ( + !(cause instanceof ProviderAdapterError) || + !(cause.cause instanceof ArtifactReaderFailure) || + cause.cause.cleanup === undefined + ) + throw cause; + return cliAnalysisError(cause); +}; + /** * This catch includes target execution, and the projection stays non-retryable. * Tell the user what happened; do not ask them to run the command again. @@ -483,12 +496,27 @@ const withProcessInputResources = async ( inherited === undefined || local === undefined ? undefined : [...new Set([...inherited, ...local])]; - const failure = ArtifactReaderFailure.withCleanup( + let failure = ArtifactReaderFailure.withCleanup( primary, cleanup, outcome.ok ? outcome.value : undefined, leases, ); + if (leases === undefined && cause instanceof ArtifactReaderFailure) { + // Keep local retry authority reachable when coverage of the primary is unknown. + failure = new ArtifactReaderFailure( + failure.reason, + failure.message, + { + cause: new AggregateError( + [failure, cause], + "Process and input cleanup remain incomplete", + ), + ...artifactFailureFacts(failure), + }, + failure.details, + ); + } const typedPrimary = primary instanceof AnalysisError ? primary @@ -503,7 +531,7 @@ const withProcessInputResources = async ( userMessage: failure.userMessage ?? failure.message, }); bindArtifactFileCleanup(failure, error); - return cliAnalysisError(error); + throw error; } if ( !outcome.ok && diff --git a/src/cli/processCommands.ts b/src/cli/processCommands.ts index e8c7ff74a2..e07d4fdc80 100644 --- a/src/cli/processCommands.ts +++ b/src/cli/processCommands.ts @@ -3,6 +3,7 @@ import { Cli, z } from "incur"; import { captureProcessScenarioFile, compareProcessEvidenceFiles, + projectProcessCliInputCleanupError, } from "../application/process/ProcessCli.js"; import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; @@ -24,7 +25,9 @@ export const registerProcessCommands = ( run: ({ args }) => withCommandCancellation((signal) => logCliCommand(logger, "capture-process", () => - captureProcessScenarioFile(args.scenario, environment, signal), + captureProcessScenarioFile(args.scenario, environment, signal).catch( + projectProcessCliInputCleanupError, + ), ), ), }); @@ -45,7 +48,7 @@ export const registerProcessCommands = ( args.right, args.traceSpec, signal, - ), + ).catch(projectProcessCliInputCleanupError), ), ), }); diff --git a/tests/boundary/browser/historicalCaptureDecoder.test.ts b/tests/boundary/browser/historicalCaptureDecoder.test.ts index c6bd965d35..7d75687955 100644 --- a/tests/boundary/browser/historicalCaptureDecoder.test.ts +++ b/tests/boundary/browser/historicalCaptureDecoder.test.ts @@ -10,6 +10,7 @@ import { mkdtemp, readFile, rm, + stat, symlink, truncate, writeFile, @@ -1219,17 +1220,28 @@ it.each([ const controller = new AbortController(); const reason = { caller: "reply descriptor close" }; const originalClose = OwnedFileHandle.prototype.close; - let closes = 0; + let replyPath: string | undefined; + let replyCloses = 0; const close = vi .spyOn(OwnedFileHandle.prototype, "close") .mockImplementation(async function (this: OwnedFileHandle) { - closes += 1; - if (closes === 2 && closeFails) { + let isReply = false; + if (replyPath !== undefined) { + try { + const named = await stat(replyPath); + const opened = await this.handle.stat(); + isReply = named.dev === opened.dev && named.ino === opened.ino; + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause; + } + } + if (isReply) replyCloses += 1; + if (isReply && replyCloses === 1 && closeFails) { controller.abort(reason); throw new Error("controlled reply close denial"); } await originalClose.call(this); - if (closes === 2) controller.abort(reason); + if (isReply && replyCloses === 1) controller.abort(reason); }); const decoder = new HistoricalCaptureDecoder( [ @@ -1237,6 +1249,7 @@ it.each([ format: "har", identity: HAR_CAPTURE_PROVIDER_IDENTITY, command: async (_, runtimePath) => { + replyPath = join(runtimePath, "reply.json"); const bytes = reply === "malformed UTF-8" ? Buffer.concat([ @@ -1265,7 +1278,7 @@ it.each([ arguments: [ "-e", `require('node:fs').writeFileSync(process.argv[1],Buffer.from(${JSON.stringify(bytes.toString("base64"))},'base64'))`, - join(runtimePath, "reply.json"), + replyPath, ], }; }, @@ -1326,7 +1339,7 @@ it.each([ if (reply === "malformed schema") expect(primary.message).toContain("Reply violates its schema"); } else expect(primary.cause).toBe(reason); - expect(closes).toBe(closeFails ? 3 : 2); + expect(replyCloses).toBe(closeFails ? 2 : 1); } finally { close.mockRestore(); await decoder.close(); diff --git a/tests/boundary/filesystem/processCliFileInput.test.ts b/tests/boundary/filesystem/processCliFileInput.test.ts index d95d6cfd1c..f7b8c0db9a 100644 --- a/tests/boundary/filesystem/processCliFileInput.test.ts +++ b/tests/boundary/filesystem/processCliFileInput.test.ts @@ -4,8 +4,10 @@ import { describe, expect, it, onTestFinished, vi } from "vitest"; import { captureProcessScenarioFile, compareProcessEvidenceFiles, + projectProcessCliInputCleanupError, } from "../../../src/application/process/ProcessCli.js"; import * as scenarioPreparation from "../../../src/application/process/ProcessScenarioPreparation.js"; +import * as captureCapability from "../../../src/process/capture/ProcessCaptureCapability.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js"; import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js"; @@ -18,6 +20,7 @@ import { } from "../../../src/contracts/errorSchemas.js"; import { EvidenceFileError } from "../../../src/domain/evidenceErrors.js"; import { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js"; import { err } from "../../../src/domain/result.js"; import * as jsonFiles from "../../../src/application/JsonFiles.js"; @@ -26,6 +29,25 @@ import { holdFirstFileRead } from "../../support/holdFirstFileRead.js"; import { failFileReadAfterAbort } from "../../support/failFileReadAfterAbort.js"; import { JsonInputPhysicalReadFailure } from "../../../src/application/JsonInputFile.js"; +const inputCleanupFailure = (cause: unknown): unknown => { + expect(cause).toBeInstanceOf(ProviderAdapterError); + const error = cause as ProviderAdapterError; + const output = analysisCliErrorEnvelopeSchema.parse( + projectProcessCliInputCleanupError(error), + ); + expect(output).toMatchObject({ details: { cleanup: "incomplete" } }); + return error.cause; +}; + +const mockDryRunOwnershipAdmission = (): void => { + const probe = vi + .spyOn(captureCapability, "processCaptureOwnershipUnavailableReason") + .mockReturnValue(undefined); + onTestFinished(() => { + probe.mockRestore(); + }); +}; + describe("process CLI error projection", () => { it.each([ { @@ -423,6 +445,7 @@ describe("process CLI final cleanup cancellation", () => { ] as const)( "preserves the $kind outcome through final input cleanup (cancelled: $cancel)", async ({ kind, cancel }) => { + if (kind === "plan") mockDryRunOwnershipAdmission(); const root = await createTestTempDirectory( "rea-process-cli-final-close-", ); @@ -556,7 +579,7 @@ describe("process CLI file cleanup", () => { await captureProcessScenarioFile(path, process.env, signal); else await compareProcessEvidenceFiles(path, path, undefined, signal); } catch (cause: unknown) { - failure = cause; + failure = inputCleanupFailure(cause); } expect(failure).toBeInstanceOf(ArtifactReaderFailure); if (!(failure instanceof ArtifactReaderFailure)) @@ -645,6 +668,7 @@ it.each([ ])( "keeps the typed plan failure through final input close (fails: $cleanupFails; cancelled: $cancel)", async ({ cleanupFails, cancel }) => { + mockDryRunOwnershipAdmission(); const root = await createTestTempDirectory( "rea-process-cli-primary-close-", ); @@ -697,7 +721,7 @@ it.each([ controller.signal, ); } catch (cause) { - failure = cause; + failure = inputCleanupFailure(cause); } expect(primary).toMatchObject({ _tag: "AnalysisInputError" }); if (!cleanupFails) { @@ -740,6 +764,7 @@ it.each([ it.each(["plan", "invalid"] as const)( "retains %s and cancellation while final descriptor cleanup fails", async (kind) => { + mockDryRunOwnershipAdmission(); const root = await createTestTempDirectory("rea-process-mixed-final-"); const path = join(root, "scenario.json"); await writeFile( @@ -774,7 +799,7 @@ it.each(["plan", "invalid"] as const)( try { await captureProcessScenarioFile(path, process.env, controller.signal); } catch (cause) { - thrown = cause; + thrown = inputCleanupFailure(cause); } expect(thrown).toBeInstanceOf(ArtifactReaderFailure); if (!(thrown instanceof ArtifactReaderFailure)) @@ -853,7 +878,7 @@ it("retains both prior process and input cleanup authority", async () => { try { await captureProcessScenarioFile(path); } catch (cause) { - failure = cause; + failure = inputCleanupFailure(cause); } expect(failure).toBeInstanceOf(ArtifactReaderFailure); if (!(failure instanceof ArtifactReaderFailure)) @@ -872,6 +897,7 @@ it("retains both prior process and input cleanup authority", async () => { }); it("retains prior error and late cancellation with successful cleanup", async () => { + mockDryRunOwnershipAdmission(); const root = await createTestTempDirectory("rea-process-cli-late-success-"); const path = join(root, "scenario.json"); await writeFile( @@ -970,7 +996,7 @@ it("retains independent process and input owners after mixed cleanup failure", a try { await captureProcessScenarioFile(path); } catch (cause) { - failure = cause; + failure = inputCleanupFailure(cause); } expect(failure).toBeInstanceOf(ArtifactReaderFailure); if (!(failure instanceof ArtifactReaderFailure)) @@ -1157,7 +1183,7 @@ it("keeps unknown primary cleanup coverage when late cancellation wraps mixed ow try { await captureProcessScenarioFile(path, process.env, controller.signal); } catch (cause) { - failure = cause; + failure = inputCleanupFailure(cause); } expect(failure).toBeInstanceOf(ArtifactReaderFailure); if (!(failure instanceof ArtifactReaderFailure)) diff --git a/tests/boundary/process/darwinHelperCache.test.ts b/tests/boundary/process/darwinHelperCache.test.ts index 192622ca55..f9c4a3b0df 100644 --- a/tests/boundary/process/darwinHelperCache.test.ts +++ b/tests/boundary/process/darwinHelperCache.test.ts @@ -279,9 +279,13 @@ it.skipIf(!posix)( }); const controller = new AbortController(); const prepared = reader.prepare(controller.signal); - setTimeout(() => controller.abort(new Error("caller cancelled")), 200); + const reason = new Error("caller cancelled"); + setTimeout(() => controller.abort(reason), 200); const cancelled = Date.now(); - await expect(prepared).rejects.toThrow("caller cancelled"); + await expect(prepared).rejects.toMatchObject({ + _tag: "ProcessOwnershipInspectionCancelled", + reason, + }); await reader.close(); expect(Date.now() - cancelled).toBeLessThan(5_000); expect(await xcrun.compilations()).toBe(0); @@ -471,8 +475,12 @@ it.skipIf(!posix)( const deadline = Date.now() + 10_000; while ((await xcrun.compilations()) === 0 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10)); - controller.abort(new Error("caller cancelled")); - await expect(prepared).rejects.toThrow("caller cancelled"); + const reason = new Error("caller cancelled"); + controller.abort(reason); + await expect(prepared).rejects.toMatchObject({ + _tag: "ProcessOwnershipInspectionCancelled", + reason, + }); } finally { await reader.close(); } diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts index a4949d594a..0fae8ba918 100644 --- a/tests/boundary/process/darwinProcessRunTokenReader.test.ts +++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts @@ -1,4 +1,8 @@ -import { isProcessOwnershipInspectionCancellation } from "../../../src/process/ProcessInspectionCancellation.js"; +import { + isProcessOwnershipInspectionCancellation, + rethrowProcessOwnershipInspectionFailure, +} from "../../../src/process/ProcessInspectionCancellation.js"; +import { primaryAnalysisError } from "../../../src/domain/analysisErrorBase.js"; import { execFile, spawn, type ChildProcess } from "node:child_process"; import { once } from "node:events"; import { @@ -1120,7 +1124,7 @@ it.skipIf(process.platform === "win32")( if (outcome.state !== "result") throw outcome.cause; expect(outcome.value).toMatchObject({ ok: false, - error: { reason: "cancelled", userCategory: "cancelled" }, + error: { _tag: "AnalysisCancelledError" }, }); expect(launchSpy).not.toHaveBeenCalled(); await expect(access(compileState.root)).rejects.toThrow(); @@ -1214,7 +1218,10 @@ it.skipIf(process.platform === "win32")( command: process.execPath, }, ]; - await reader.identities(processes, signal); + await reader.identities(processes, signal).catch((cause: unknown) => { + rethrowProcessOwnershipInspectionFailure(cause); + throw cause; + }); return []; }); const launchSpy = vi.spyOn(electron, "launch"); @@ -1241,10 +1248,11 @@ it.skipIf(process.platform === "win32")( expect(outcome.state).toBe("result"); if (outcome.state !== "result") throw outcome.cause; - expect(outcome.value).toMatchObject({ - ok: false, - error: { reason: "cancelled", userCategory: "cancelled" }, - }); + expect(outcome.value.ok).toBe(false); + if (outcome.value.ok) throw new Error("Expected baseline cancellation"); + expect(primaryAnalysisError(outcome.value.error)._tag).toBe( + "AnalysisCancelledError", + ); expect(launchSpy).not.toHaveBeenCalled(); await expect(access(helper.root)).resolves.toBeUndefined(); } finally { diff --git a/tests/boundary/process/desktopBuildOwnedCommand.test.ts b/tests/boundary/process/desktopBuildOwnedCommand.test.ts index c351a491d9..8aa3a89806 100644 --- a/tests/boundary/process/desktopBuildOwnedCommand.test.ts +++ b/tests/boundary/process/desktopBuildOwnedCommand.test.ts @@ -37,7 +37,11 @@ it.skipIf(process.platform === "win32")( partialPath: partial, workspace, }), - ).rejects.toMatchObject({ reason: "timeout" }); + ).rejects.toMatchObject({ + _tag: "AnalysisOutputError", + operation: "setup", + diagnostics: { command_failure: "timeout" }, + }); const pid = Number(await readFile(pidFile, "utf8")); expect(Number.isInteger(pid)).toBe(true); expect(() => process.kill(pid, 0)).toThrow(); diff --git a/tests/boundary/process/ghidraJavaProbePreparation.test.ts b/tests/boundary/process/ghidraJavaProbePreparation.test.ts index 79f4aef4b1..f7f44573ff 100644 --- a/tests/boundary/process/ghidraJavaProbePreparation.test.ts +++ b/tests/boundary/process/ghidraJavaProbePreparation.test.ts @@ -16,6 +16,7 @@ import { } from "../../../src/process/ProcessOwnershipObservation.js"; import { createInspectionResources } from "../../fixtures/inspectionResources.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { waitForProcessOwnershipInspection } from "../../../src/process/ProcessInspectionCancellation.js"; // Long enough for an ordinary owned launch, short enough to keep the cold case quick. const PROBE_TIMEOUT_MS = 2_000; @@ -89,7 +90,7 @@ it.runIf(process.platform === "linux")( vi.spyOn(systemProcessOwnershipHost, "prepare").mockImplementation( // A stalled `ps` check has no time limit of its own. async (signal) => { - await delay(60_000, undefined, { signal }); + await waitForProcessOwnershipInspection(60_000, signal); }, ); @@ -120,7 +121,7 @@ it.skipIf(process.platform === "win32")( vi.spyOn(systemProcessOwnershipHost, "prepare").mockImplementation( async (signal) => { entered(); - await delay(60_000, undefined, { signal }); + await waitForProcessOwnershipInspection(60_000, signal); }, ); diff --git a/tests/boundary/process/ownedCommand.test.ts b/tests/boundary/process/ownedCommand.test.ts index c6a9e87c9d..d619888174 100644 --- a/tests/boundary/process/ownedCommand.test.ts +++ b/tests/boundary/process/ownedCommand.test.ts @@ -1,4 +1,8 @@ import { DarwinProcessOwnershipInspectionError } from "../../../src/process/DarwinProcessRunTokenReader.js"; +import { + assertProcessOwnershipInspectionActive, + waitForProcessOwnershipInspection, +} from "../../../src/process/ProcessInspectionCancellation.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; @@ -45,7 +49,7 @@ it("includes ownership preparation in the command deadline before provider creat }, { launcher: async (input) => { - await delay(80, undefined, { signal: input.signal }); + await waitForProcessOwnershipInspection(80, input.signal); prepared = true; throw new Error( "Slow preparation must be aborted before provider creation", @@ -68,7 +72,7 @@ it("passes cancellation into native ownership preparation before provider creati launcher: async (input) => { expect(input.signal?.aborted).toBe(false); controller.abort(); - input.signal?.throwIfAborted(); + assertProcessOwnershipInspectionActive(input.signal); throw new Error("Cancelled preparation must not launch a provider"); }, }, @@ -864,7 +868,7 @@ it("keeps a caller-supplied preparation error opaque when it is the exact abort signal: controller.signal, launcher: async (input) => { controller.abort(reason); - input.signal?.throwIfAborted(); + assertProcessOwnershipInspectionActive(input.signal); throw new Error("Unexpected completed preparation"); }, }, @@ -873,6 +877,11 @@ it("keeps a caller-supplied preparation error opaque when it is the exact abort if (!(failure instanceof OwnedCommandFailure)) throw new Error("Expected command cancellation"); expect(failure.reason).toBe("cancelled"); - expect(failure.cause).toBe(reason); - expect(failure.preparationFailure).toBeUndefined(); + expect(failure.cancellation?.reason).toBe(reason); + expect(failure.preparationFailure).toMatchObject({ + _tag: "ProcessOwnershipInspectionCancelled", + reason, + }); + expect(marker.cleanupIncomplete).toBe(true); + expect(failure.resources).toEqual([]); }); diff --git a/tests/boundary/process/processCaptureLifecycle.test.ts b/tests/boundary/process/processCaptureLifecycle.test.ts index 348267374d..ef54a0e133 100644 --- a/tests/boundary/process/processCaptureLifecycle.test.ts +++ b/tests/boundary/process/processCaptureLifecycle.test.ts @@ -1038,13 +1038,10 @@ itWithCaptureCapability( await writeFile(path, "observed\n"); const controller = new AbortController(); const { signal } = controller; - const throwIfAborted = signal.throwIfAborted.bind(signal); - Object.defineProperty(signal, "throwIfAborted", { - value: () => { - controller.abort(); - throwIfAborted(); - }, - }); + const captureSnapshot: typeof snapshotRoots = async (...args) => { + controller.abort(); + return snapshotRoots(...args); + }; const result = await captureProcessScenario( processScenarioSchema.parse({ @@ -1053,6 +1050,9 @@ itWithCaptureCapability( filesystem_observation_paths: [path], }), signal, + process.platform, + process.env, + captureSnapshot, ); if (result.ok) throw new Error("expected initial snapshot cancellation"); diff --git a/tests/boundary/providers/kuna/kunaCommandFailures.test.ts b/tests/boundary/providers/kuna/kunaCommandFailures.test.ts index f61697c8d8..5b86a23121 100644 --- a/tests/boundary/providers/kuna/kunaCommandFailures.test.ts +++ b/tests/boundary/providers/kuna/kunaCommandFailures.test.ts @@ -2,6 +2,7 @@ import { expect, it as test, vi } from "vitest"; import type { KunaProcessLauncher } from "../../../../src/kuna/KunaCommand.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { ProcessOwnershipInspectionCancelled } from "../../../../src/process/ProcessInspectionCancellation.js"; import { inspectKunaInstallationOwned as inspectKunaInstallation } from "./kunaResourceScope.js"; import { KunaTargetCommands } from "../../../../src/kuna/KunaTargetCommands.js"; import { fakeKunaCalls } from "./fakeKuna.js"; @@ -267,9 +268,11 @@ it.each(["before-launch", "after-launch"] as const)( const signal = options.signal; if (signal === undefined) throw new Error("Missing command deadline signal"); - if (signal.aborted) reject(signal.reason); + const cancelled = () => + reject(new ProcessOwnershipInspectionCancelled(signal.reason)); + if (signal.aborted) cancelled(); else - signal.addEventListener("abort", () => reject(signal.reason), { + signal.addEventListener("abort", cancelled, { once: true, }); }); diff --git a/tests/boundary/providers/kuna/kunaProjectCache.test.ts b/tests/boundary/providers/kuna/kunaProjectCache.test.ts index 8ca83f3682..902b6d69ff 100644 --- a/tests/boundary/providers/kuna/kunaProjectCache.test.ts +++ b/tests/boundary/providers/kuna/kunaProjectCache.test.ts @@ -33,6 +33,7 @@ import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; import { RegularFileCleanupFailure } from "../../../../src/filesystem/OwnedRegularFile.js"; import * as stableFileHash from "../../../../src/filesystem/StableFileHash.js"; import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js"; +import type { AnalysisError } from "../../../../src/domain/analysisErrorBase.js"; import { fakeKunaCalls, setFakeKunaBehavior, @@ -633,7 +634,7 @@ it("keeps hash cleanup aggregates pending through retained staging removal", asy .mockRejectedValue(persistentCloseCause); let stagingPath: string | undefined; let retainedPath: string | undefined; - let buildFailure: ProviderCleanupError | undefined; + let buildFailure: AnalysisError | undefined; const aggregates: ProviderCleanupError[] = []; try { const built = await cache.build({ @@ -641,8 +642,7 @@ it("keeps hash cleanup aggregates pending through retained staging removal", asy ...(await target("program")), }); if (built.ok) throw new Error("Expected the retained hash close to fail"); - expect(built.error).toBeInstanceOf(ProviderCleanupError); - if (!(built.error instanceof ProviderCleanupError)) throw built.error; + expect(built.error.cleanupIncomplete).toBe(true); buildFailure = built.error; const [staging] = await entries(cacheRoot, "staging"); diff --git a/tests/boundary/providers/kuna/kunaWorkspaceRollback.test.ts b/tests/boundary/providers/kuna/kunaWorkspaceRollback.test.ts index 923c355570..4bd3362981 100644 --- a/tests/boundary/providers/kuna/kunaWorkspaceRollback.test.ts +++ b/tests/boundary/providers/kuna/kunaWorkspaceRollback.test.ts @@ -218,10 +218,24 @@ it.each([ try { const failed = await commands.prepare(); if (failed.ok) throw new Error("Expected allocation failure"); - expect(failed.error.cause).toBe(reason); - expect(failed.error.cleanupIncomplete).toBe(false); - expect(failed.error.cleanupResources).toEqual([]); + if (reason instanceof ProviderCleanupError) { + expect(failed.error).toBe(reason); + expect(reason.cleanupIncomplete).toBe(true); + expect(reason.cleanupResources).toEqual(["foreign-owner"]); + } else { + expect(failed.error.cause).toBe(reason); + expect(failed.error.cleanupIncomplete).toBe(false); + expect(failed.error.cleanupResources).toEqual([]); + } roots.mockRestore(); + if (reason instanceof ProviderCleanupError) { + expect((await commands.prepare()).ok).toBe(false); + reason.resolveCleanup(); + // Observe the settled cached failure before admitting a fresh allocation. + const settled = await commands.prepare(); + expect(settled).toEqual({ ok: false, error: reason }); + expect(reason.cleanupIncomplete).toBe(false); + } expect((await commands.prepare()).ok).toBe(true); } finally { roots.mockRestore(); diff --git a/tests/boundary/providers/native/lldbPrelaunchCleanup.test.ts b/tests/boundary/providers/native/lldbPrelaunchCleanup.test.ts index dd58ac1b65..56f2db9e7a 100644 --- a/tests/boundary/providers/native/lldbPrelaunchCleanup.test.ts +++ b/tests/boundary/providers/native/lldbPrelaunchCleanup.test.ts @@ -4,6 +4,7 @@ import { PassThrough } from "node:stream"; import { afterEach, expect, it, vi } from "vitest"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { primaryAnalysisError } from "../../../../src/domain/analysisErrorBase.js"; import { nativeCallObservationInputSchema } from "../../../../src/domain/native/nativeCallObservation.js"; import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js"; import { err, ok } from "../../../../src/domain/result.js"; @@ -183,7 +184,7 @@ it("retries the tracer-owned resolver supervisor before another trace", async () } }); -it("keeps resolver preparation cleanup with the foreign helper", async () => { +it("releases resolver preparation cleanup through the transferred helper owner", async () => { const prepared = preparation(); const failure = new NativeCommandFailure("lldb", "io", null, { cause: prepared.failure, @@ -196,8 +197,8 @@ it("keeps resolver preparation cleanup with the foreign helper", async () => { expect(result.ok).toBe(false); if (result.ok) return; expect(result.error._tag).toBe("ProviderAdapterError"); - expect(result.error.cause).toBe(failure); - expect(result.error.diagnostics).toMatchObject({ + expect(primaryAnalysisError(result.error).cause).toBe(failure); + expect(primaryAnalysisError(result.error).diagnostics).toMatchObject({ phase: "process-preparation", tool: "lldb", reason: "io", @@ -212,9 +213,9 @@ it("keeps resolver preparation cleanup with the foreign helper", async () => { expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete"); expect((await tracer.close()).ok).toBe(true); - expect(prepared.close).not.toHaveBeenCalled(); - expect(prepared.marker.cleanupIncomplete).toBe(true); - expect(result.error.cleanupIncomplete).toBe(true); + expect(prepared.close).toHaveBeenCalledOnce(); + expect(prepared.marker.cleanupIncomplete).toBe(false); + expect(result.error.cleanupIncomplete).toBe(false); await tracer.trace(request); expect(resolver).toHaveBeenCalledTimes(2); @@ -243,8 +244,10 @@ it.each([ try { const result = await tracer.trace(request); if (result.ok) throw new Error("Expected preparation failure"); - expect(result.error._tag).toBe(tag); - expect(result.error.cause).toBe(abortReason); + expect(primaryAnalysisError(result.error)._tag).toBe(tag); + if (reason === "cancelled") + expect(primaryAnalysisError(result.error).cause).toBe(abortReason); + else expect(primaryAnalysisError(result.error).cause).toBe(failure); expect(result.error.capturedOutput?.stderr).toBe("compiler failed"); expect(result.error.cleanupIncomplete).toBe(true); await prepared.close(); diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index d7554cdcfc..f885eb2d3b 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -1,4 +1,5 @@ import { OwnedCommandFailure } from "../../../../src/process/OwnedCommand.js"; +import { primaryAnalysisError } from "../../../../src/domain/analysisErrorBase.js"; import { createHash } from "node:crypto"; import { DarwinProcessOwnershipInspectionError } from "../../../../src/process/DarwinProcessRunTokenReader.js"; import { EventEmitter } from "node:events"; @@ -1983,11 +1984,11 @@ it("retains an owned preparation cleanup marker when the command deadline expire symbols: ["fixture"], }); if (result.ok) throw new Error("Expected preparation timeout"); - expect(result.error).toMatchObject({ + expect(result.error.cleanupIncomplete).toBe(true); + expect(primaryAnalysisError(result.error)).toMatchObject({ _tag: "AnalysisTimeoutError", - cleanupIncomplete: true, + cause: failure, }); - expect(result.error.cause).toBe(reason); marker.resolveCleanup(); expect(result.error.cleanupIncomplete).toBe(false); } finally { @@ -2026,12 +2027,12 @@ it("reports the selected command deadline when preparation times out", async () native_command_timeout_ms: 1_500, }); if (result.ok) throw new Error("Expected preparation timeout"); - expect(result.error).toMatchObject({ + expect(result.error.cleanupIncomplete).toBe(true); + expect(primaryAnalysisError(result.error)).toMatchObject({ _tag: "AnalysisTimeoutError", - cleanupIncomplete: true, + cause: failure, }); - expect(result.error.cause).toBe(reason); - const projected = projectAnalysisError(result.error); + const projected = projectAnalysisError(primaryAnalysisError(result.error)); expect(projected.message).toContain("of 1500 ms"); expect(projected.message).toContain("native_command_timeout_ms"); expect(projected.remediation.action).toContain("above 1500"); diff --git a/tests/boundary/providers/ownedCommandCleanup.test.ts b/tests/boundary/providers/ownedCommandCleanup.test.ts index 589d434ba3..c2a9625674 100644 --- a/tests/boundary/providers/ownedCommandCleanup.test.ts +++ b/tests/boundary/providers/ownedCommandCleanup.test.ts @@ -25,16 +25,10 @@ it("retains every owned label without inventing a missing cleanup capability", a "inspect_jadx_configuration", ); expect(cleanup?.cleanupResources).toEqual(labels); - for (let attempt = 0; attempt < 2; attempt++) { - const error: unknown = await resources - .close() - .catch((failure: unknown) => failure); - expect(error).toBeInstanceOf(ArtifactResourceRetryFailure); - if (!(error instanceof ArtifactResourceRetryFailure)) - throw new Error("Expected retained ownership"); - expect(error.cleanup?.resources).toEqual(labels); - expect(cleanup?.cleanupIncomplete).toBe(true); - } + await expect(resources.close()).resolves.toBeUndefined(); + expect(cleanup?.cleanupResources).toEqual(labels); + expect(cleanup?.cleanupIncomplete).toBe(true); + expect(cleanup?.cleanupRetry).toBeUndefined(); }); it("reports only live helper peers across partial cleanup and retry", async () => { diff --git a/tests/process-global/darwinCompilerCleanup.test.ts b/tests/process-global/darwinCompilerCleanup.test.ts index 651b1eb03b..0f7c7ca05e 100644 --- a/tests/process-global/darwinCompilerCleanup.test.ts +++ b/tests/process-global/darwinCompilerCleanup.test.ts @@ -43,11 +43,13 @@ import { } from "../../src/process/DarwinProcessRunTokenReader.js"; import { NativeMacOSProvider } from "../../src/native/NativeMacOSProvider.js"; import { XcrunCommandRunner } from "../../src/native/CommandRunner.js"; -import { nativeMachoTarget } from "../fixtures/nativeCommands.js"; +import { nativeMachoTargetForFile } from "../fixtures/nativeCommands.js"; import { GhidraClient } from "../../src/ghidra/GhidraClient.js"; import { GhidraHeadlessLauncher } from "../../src/ghidra/GhidraLauncher.js"; import { captureProcessScenario } from "../../src/process/capture/ProcessHarness.js"; +import { ProcessCaptureResourceScope } from "../../src/process/capture/ProcessCaptureLifecycle.js"; import { processScenarioSchema } from "../../src/domain/process/processScenario.js"; +import { primaryAnalysisError } from "../../src/domain/analysisErrorBase.js"; import { systemProcessOwnershipHost } from "../../src/process/ProcessOwnershipObservation.js"; import { ghidraFixturePath } from "../boundary/providers/ghidra/ghidraClient.fixture.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; @@ -280,7 +282,7 @@ it.skipIf(process.platform === "win32")( await client.close(); expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true); fault.blocked = false; - await reader.close(); + await client.close(); expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false); }, ); @@ -289,6 +291,7 @@ it.skipIf(process.platform === "win32")( "capture boundary retains compiler output and follows the actual helper cleanup retry", async () => { const { reader } = await fixture(); + const resources = new ProcessCaptureResourceScope(); vi.spyOn(systemProcessOwnershipHost, "prepare").mockImplementation( async (signal) => { await reader.prepare(signal); @@ -299,6 +302,10 @@ it.skipIf(process.platform === "win32")( undefined, "linux", {}, + undefined, + undefined, + undefined, + resources, ); expect(result).toMatchObject({ ok: false, @@ -315,7 +322,7 @@ it.skipIf(process.platform === "win32")( }); if (result.ok) throw new Error("Expected capture preparation failure"); fault.blocked = false; - await reader.close(); + await resources.close(); expect(result.error.cleanupIncomplete).toBe(false); expect(result.error.capturedOutput?.exit_code).toBe(7); }, @@ -336,11 +343,11 @@ it.skipIf(process.platform !== "darwin").each([false, true])( {}, new XcrunCommandRunner({}), "darwin", - ).createClient(nativeMachoTarget(ghidraFixturePath)); + ).createClient(await nativeMachoTargetForFile(ghidraFixturePath)); try { const result = await client.execute( - "list_architectures", - {}, + "demangle_swift", + { symbols: ["fixture"] }, { signal: controller.signal }, ); if (result.ok) throw new Error("Expected failed process preparation"); @@ -354,13 +361,16 @@ it.skipIf(process.platform !== "darwin").each([false, true])( stderr_bytes: 28, exit_code: 7, }, - diagnostics: { phase: "process-preparation", tool: "lipo" }, + }); + expect(primaryAnalysisError(result.error).diagnostics).toMatchObject({ + phase: "process-preparation", + tool: "swift-demangle", }); expect(result.error.cleanupResources).toEqual([...fault.roots]); - expect((await client.close()).ok).toBe(true); + expect((await client.close()).ok).toBe(false); expect(result.error.cleanupIncomplete).toBe(true); fault.blocked = false; - await reader.close(); + expect((await client.close()).ok).toBe(true); expect(result.error.cleanupIncomplete).toBe(false); expect(result.error.capturedOutput?.exit_code).toBe(7); } finally { @@ -389,10 +399,10 @@ it {}, new XcrunCommandRunner({}), "darwin", - ).createClient(nativeMachoTarget(ghidraFixturePath)); + ).createClient(await nativeMachoTargetForFile(ghidraFixturePath)); const pending = client.execute( - "list_architectures", - {}, + "demangle_swift", + { symbols: ["fixture"] }, { signal: controller.signal }, ); try { @@ -400,16 +410,18 @@ it controller.abort(reason); const result = await pending; if (result.ok) throw new Error("Expected cancelled preparation"); - expect(result.error).toMatchObject({ + expect(result.error.cleanupIncomplete).toBe(true); + const primary = primaryAnalysisError(result.error); + expect(primary).toMatchObject({ _tag: "AnalysisCancelledError", - cleanupIncomplete: true, - diagnostics: { phase: "process-preparation", reason: "cancelled" }, + operation: "demangle_swift", + inspectionFailure: { cancellation: { reason } }, }); - expect(result.error.cause).toBe(reason); - expect((await client.close()).ok).toBe(true); + expect(primary.cause).toBe(reason); + expect((await client.close()).ok).toBe(false); expect(result.error.cleanupIncomplete).toBe(true); fault.blocked = false; - await reader.close(); + expect((await client.close()).ok).toBe(true); expect(result.error.cleanupIncomplete).toBe(false); } finally { controller.abort(reason); diff --git a/tests/process-global/filesystemSnapshotFallbackCancellation.test.ts b/tests/process-global/filesystemSnapshotFallbackCancellation.test.ts index cfe1d7ab65..3eeb367662 100644 --- a/tests/process-global/filesystemSnapshotFallbackCancellation.test.ts +++ b/tests/process-global/filesystemSnapshotFallbackCancellation.test.ts @@ -112,7 +112,14 @@ it.each(["hash", "snapshot"] as const)( const read = failure as ProcessCaptureReadFailure; expect(read.cause).toBe(reason); expect(read.userCategory).toBe("cancelled"); - expect(read.outcome).toMatchObject({ kind: "failed", cause: reason }); + expect(read.outcome).toMatchObject({ + kind: "failed", + cause: { + _tag: "ProcessCaptureError", + reason: "cancelled", + cause: reason, + }, + }); expect(read.cleanupLeases).toEqual([]); expect(read.cleanupIncomplete).toBe(false); } else { diff --git a/tests/process-global/processObservationCommandFinalization.test.ts b/tests/process-global/processObservationCommandFinalization.test.ts index f09546a740..3961f3a903 100644 --- a/tests/process-global/processObservationCommandFinalization.test.ts +++ b/tests/process-global/processObservationCommandFinalization.test.ts @@ -8,6 +8,7 @@ import { expect, it, onTestFinished } from "vitest"; import { createDarwinProcessRunTokenReader } from "../../src/process/DarwinProcessRunTokenReader.js"; import { createSystemProcessOwnershipHost } from "../../src/process/ProcessOwnershipObservation.js"; import { startProcessSampler } from "../../src/process/capture/ProcessSampling.js"; +import { ProcessOwnershipInspectionCancelled } from "../../src/process/ProcessInspectionCancellation.js"; const waitForFile = async (path: string, timeoutMs = 4_000): Promise => { const deadline = Date.now() + timeoutMs; @@ -112,7 +113,9 @@ const expectOwnedCommandToFinishOnAbort = async ( if (!(failure instanceof Error)) throw new Error("The cancelled process command did not return an error"); - expect(failure.cause).toBe(reason); + expect(ProcessOwnershipInspectionCancelled.from(failure)?.reason).toBe( + reason, + ); await expect(readFile(command.donePath, "utf8")).resolves.toBe("done"); };