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 <wunianze666@gmail.com>
This commit is contained in:
morlutoandNianze Wu authored and GitHub committed 2026-11-11 18:37:28 -05:00
1 parent 4505477903
commit 6b2f991808
19 files changed
+232 -95

No files matched your search

+30 -2
View File
@@ -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 &&
+5 -2
View File
@@ -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),
),
),
});
@@ -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();
@@ -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))
@@ -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();
}
@@ -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 {
@@ -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();
@@ -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);
},
);
+14 -5
View File
@@ -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([]);
});
@@ -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");
@@ -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,
});
});
@@ -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");
@@ -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();
@@ -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();
@@ -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");
@@ -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 () => {
@@ -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);
@@ -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 {
@@ -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<void> => {
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");
};