mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 09:52:27 +00:00
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:
1 parent
4505477903
commit
6b2f991808
19 files changed
+232
-95
No files matched your search
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user