Files
rea/src/process/ArtifactAnalysisResources.test.ts
T
morluto 147ae9718a fix(process): preserve artifact cancellation through finalization
Consume owner-recorded cancellation reasons, recheck admission after pending cleanup, and keep typed cancellation with completed observations through final workspace cleanup and retry. Existing physical failures remain primary.

Validation: 18 core tests pass; 20 combined boundary controls fail on baseline before consumer migration, and four final-close service controls failed before finalization signal ownership. Focused integration, static/docs and bounded service comparisons pass. No native Linux/Windows or real sigrok/tshark qualification.
2026-10-18 02:37:53 +08:00

670 lines
20 KiB
TypeScript

import { OwnedCommandFailure } from "./OwnedCommand.js";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { expect, it } from "vitest";
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { createEvidence } from "../domain/evidence.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok } from "../domain/result.js";
import { ArtifactAnalysisResources } from "./ArtifactAnalysisResources.js";
import { readLinuxResourceLimits } from "./LinuxResourceLimits.js";
import { ProviderProcessSupervisor } from "./ProviderProcess.js";
it("projects the specific reason and member evidence for retained cleanup", async () => {
const producer = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
exitCode: null,
signalCode: null,
kill: () => true,
});
const owner = new ProviderProcessSupervisor({
process: producer,
ownsProcessLifetime: true,
ownership: {
runId: "rea-tshark-run-1",
leaderPid: 4242,
processGroupId: 4242,
},
cleanup: async () => ({
cleaned: false as const,
reason: "process ownership token could not be read for 1 live process",
failures: [
{
pid: 101,
reason: "environment-unreadable" as const,
diagnostic: "sysctl_errno_22",
},
],
unverified: [
{ pid: 900, diagnostic: "platform_binary_environment_withheld" },
],
}),
});
const resources = new ArtifactAnalysisResources(
"fixture-provider",
"fixture-operation",
readStableArtifact,
readLinuxResourceLimits,
{ message: "busy", details: {} },
);
resources.retain({ path: "runtime-root", close: async () => {} }, owner);
const result = await resources.finish(
err(new ProviderAdapterError("fixture-provider", "fixture-operation")),
() => {
throw new Error("unexpected success observation");
},
);
owner.dispose();
if (result.ok) throw new Error("expected cleanup failure");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
details: {
resources: [
"rea-tshark-run-1",
"runtime-root",
"pid:4242",
"process-group:4242",
],
diagnostics: {
reason: "process ownership token could not be read for 1 live process",
failures: [
{
pid: 101,
reason: "environment-unreadable",
diagnostic: "sysctl_errno_22",
},
],
unverified: [
{ pid: 900, diagnostic: "platform_binary_environment_withheld" },
],
},
},
});
});
it("preserves the primary error and resolves cleanup status after retry", async () => {
let closes = 0;
const resources = new ArtifactAnalysisResources(
"fixture-provider",
"fixture-operation",
readStableArtifact,
readLinuxResourceLimits,
{ message: "busy", details: {} },
);
resources.retain({
path: "runtime-root",
close: async () => {
closes++;
if (closes === 1) throw new Error("temporary close failure");
},
});
const primary = new AnalysisOutputError(
"fixture-operation",
"producer returned malformed output",
{
partialObservation: createEvidence(
undefined,
{
id: "fixture",
name: "Fixture",
version: null,
},
{
operation: "inspect_signal_decoders",
parameters: {},
result: { decoders: [] },
confidence: "observed",
},
),
},
);
const result = await resources.finish(err(primary), () => {
throw new Error("unexpected success observation");
});
if (result.ok) throw new Error("expected cleanup failure");
expect(result.error.cause).toBeInstanceOf(AggregateError);
expect((result.error.cause as AggregateError).errors).toEqual([
primary,
expect.any(ProviderCleanupError),
]);
expect(result.error.partialObservation).toEqual(primary.partialObservation);
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.diagnostics).toMatchObject({
previous_error: { code: "unreadable_output" },
});
await resources.release();
expect(result.error.cleanupIncomplete).toBe(false);
expect(closes).toBe(2);
await resources.close();
});
it("resolves each returned cleanup error with its own retained root", async () => {
let closesA = 0;
let closesB = 0;
const resources = new ArtifactAnalysisResources(
"fixture-provider",
"fixture-operation",
readStableArtifact,
readLinuxResourceLimits,
{ message: "busy", details: {} },
);
resources.retain({
path: "runtime-root-a",
close: async () => {
closesA++;
if (closesA === 1) throw new Error("root A close failure");
},
});
const first = await resources.finish(
err(new AnalysisOutputError("fixture-operation", "primary failure")),
() => {
throw new Error("unexpected success observation");
},
);
if (first.ok) throw new Error("expected root A cleanup failure");
resources.retain({
path: "runtime-root-b",
close: async () => {
closesB++;
if (closesB === 1) throw new Error("root B close failure");
},
});
const partial = createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: null },
{
operation: "fixture-operation",
parameters: {},
result: { state: "partial" },
confidence: "observed",
},
);
const second = await resources.finish(ok("completed"), () => partial);
if (second.ok) throw new Error("expected root B cleanup failure");
expect(second.error.partialObservation).toEqual(partial);
expect(first.error.cleanupIncomplete).toBe(false);
expect(second.error.cleanupIncomplete).toBe(true);
await resources.release();
expect(second.error.cleanupIncomplete).toBe(false);
await resources.close();
});
it("resolves an artifact cleanup error when its scope owner later closes", async () => {
let closes = 0;
const readArtifact: typeof readStableArtifact = async (
_path,
_maximumBytes,
_signal,
_fileSystem,
scope,
) => {
if (scope === undefined) throw new Error("expected scoped artifact read");
if (closes === 0) {
const owner = {
kind: "lease",
resource: "fixture descriptor",
lease: {
release: async () => {
closes++;
if (closes === 1) throw new Error("descriptor remains open");
},
},
} as const;
expect(await scope.release(owner)).toMatchObject({ kind: "failed" });
throw new ArtifactReaderFailure("io", "Descriptor close failed", {
cleanup: {
reason: "descriptor remains open",
resources: ["fixture descriptor"],
},
});
}
return { bytes: Buffer.alloc(0), sha256: "0".repeat(64) };
};
const resources = new ArtifactAnalysisResources(
"fixture-provider",
"fixture-operation",
readArtifact,
readLinuxResourceLimits,
{ message: "busy", details: {} },
);
const first = await resources.run(async () =>
ok(await resources.read("fixture", 1)),
);
if (first.ok) throw new Error("expected descriptor cleanup failure");
expect(first.error.cleanupIncomplete).toBe(true);
const retried = await resources.run(async () =>
ok(await resources.read("fixture", 1)),
);
expect(retried.ok).toBe(true);
expect(first.error.cleanupIncomplete).toBe(false);
expect(closes).toBe(2);
await resources.close();
});
it("keeps a runtime root until its descriptor cleanup owner is released", async () => {
let descriptorCloses = 0;
let rootCloses = 0;
const readArtifact: typeof readStableArtifact = async (
_path,
_maximumBytes,
_signal,
_fileSystem,
scope,
) => {
if (scope === undefined) throw new Error("expected scoped artifact read");
if (descriptorCloses === 0) {
const owner = {
kind: "lease",
resource: "fixture descriptor",
lease: {
release: async () => {
descriptorCloses++;
if (descriptorCloses === 1)
throw new Error("descriptor remains open");
},
},
} as const;
expect(await scope.release(owner)).toMatchObject({ kind: "failed" });
throw new ArtifactReaderFailure("io", "Descriptor close failed", {
cleanup: {
reason: "descriptor remains open",
resources: ["fixture descriptor"],
},
});
}
return { bytes: Buffer.alloc(0), sha256: "0".repeat(64) };
};
const resources = new ArtifactAnalysisResources(
"fixture-provider",
"fixture-operation",
readArtifact,
readLinuxResourceLimits,
{ message: "busy", details: {} },
);
const first = await resources.run(async () =>
ok(await resources.read("fixture", 1)),
);
if (first.ok) throw new Error("expected descriptor cleanup failure");
resources.retain({
path: "runtime-root",
close: async () => {
rootCloses++;
},
});
const outer = await resources.finish(first, () => {
throw new Error("unexpected success observation");
});
if (outer.ok) throw new Error("expected combined cleanup failure");
expect(rootCloses).toBe(0);
expect(outer.error.cleanupResources).toEqual([
"fixture descriptor",
"runtime-root",
]);
expect(outer.error.cause).toBeInstanceOf(AggregateError);
expect((outer.error.cause as AggregateError).errors[0]).toBe(first.error);
expect(first.error.cleanupIncomplete).toBe(true);
const retried = await resources.run(async () =>
ok(await resources.read("fixture", 1)),
);
expect(retried.ok).toBe(true);
expect(first.error.cleanupIncomplete).toBe(false);
await resources.release();
expect(rootCloses).toBe(1);
expect(outer.error.cleanupIncomplete).toBe(false);
await resources.close();
});
it.each([
null,
"opaque reason",
new ProviderCleanupError("foreign", ["foreign-resource"], {
reason: "caller cleanup",
}),
])(
"preserves caller cancellation at resource admission: %s",
async (reason) => {
const resources = new ArtifactAnalysisResources(
"fixture",
"inspect_signal_decoders",
readStableArtifact,
readLinuxResourceLimits,
{ message: "busy", details: {} },
);
let calls = 0;
try {
const result = await resources.run(async () => {
calls++;
return ok("unexpected");
}, AbortSignal.abort(reason));
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancelled admission");
expect(result.error.cause).toBe(reason);
expect(result.error.cleanupResources).toEqual([]);
expect(calls).toBe(0);
if (reason instanceof ProviderCleanupError)
expect(reason.cleanupIncomplete).toBe(true);
} finally {
await resources.close();
}
},
);
it.each([
null,
"opaque reason",
new ProviderCleanupError("foreign", ["foreign-resource"], {
reason: "caller cleanup",
}),
])(
"projects the command owner's exact cancellation independently of physical causes: %s",
async (reason) => {
const resources = new ArtifactAnalysisResources(
"fixture",
"inspect_signal_decoders",
readStableArtifact,
readLinuxResourceLimits,
{ message: "busy", details: {} },
);
const physical = new Error("physical collection interruption");
const failure = new OwnedCommandFailure(
"cancelled",
"cancelled command",
null,
null,
{ cause: physical, cancellation: { reason } },
);
try {
const error = resources.commandFailure(
failure,
{ command_timeout_ms: 100, output_bytes: 1024 },
undefined,
);
expect(error).toMatchObject({ _tag: "AnalysisCancelledError" });
expect(error.cause).toBe(reason);
expect(error.cleanupResources).toEqual([]);
expect(failure.cause).toBe(physical);
if (reason instanceof ProviderCleanupError)
expect(reason.cleanupIncomplete).toBe(true);
} finally {
await resources.close();
}
},
);
it.each([false, true])(
"preserves artifact cancellation and independent failed cleanup (failed close: %s)",
async (failedClose) => {
const reason = { request: "artifact cancellation" };
let mayClose = false;
const readArtifact: typeof readStableArtifact = async (
_path,
_limit,
_signal,
_fs,
scope,
) => {
if (failedClose) {
if (scope === undefined)
throw new Error("Expected retained file scope");
const retained = {
kind: "lease",
resource: "descriptor",
lease: {
release: async () => {
if (!mayClose)
throw new Error("injected descriptor close failure");
},
},
} as const;
expect(await scope.release(retained)).toMatchObject({ kind: "failed" });
}
throw new ArtifactReaderFailure("cancelled", "read cancelled", {
cause: reason,
...(failedClose
? {
cleanup: {
reason: "injected descriptor close failure",
resources: ["descriptor"],
},
}
: {}),
});
};
const resources = new ArtifactAnalysisResources(
"fixture",
"inspect_signal_decoders",
readArtifact,
readLinuxResourceLimits,
{ message: "busy", details: {} },
);
try {
const result = await resources.run(async () =>
ok(await resources.read("fixture", 1)),
);
if (result.ok) throw new Error("Expected cancelled artifact read");
const primary = failedClose ? result.error.cleanupPrimary : result.error;
expect(primary).toMatchObject({ _tag: "AnalysisCancelledError" });
expect(primary?.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(failedClose);
mayClose = true;
await resources.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(primary?.cause).toBe(reason);
} finally {
mayClose = true;
await resources.close();
}
},
);
it("retains a physical command failure when the owner also observed late cancellation", async () => {
const resources = new ArtifactAnalysisResources(
"fixture",
"inspect_signal_decoders",
readStableArtifact,
readLinuxResourceLimits,
{ message: "busy", details: {} },
);
const reason = { request: "late cancellation" };
const failure = new OwnedCommandFailure(
"process",
"physical failure",
null,
null,
{ cancellation: { reason } },
);
try {
const error = resources.commandFailure(
failure,
{ command_timeout_ms: 100, output_bytes: 1024 },
undefined,
);
expect(error).toMatchObject({ _tag: "AnalysisOutputError" });
expect(error.cause).toBe(failure);
} finally {
await resources.close();
}
});
it("preserves typed caller cancellation through failed workspace cleanup and retry", async () => {
let denied = true;
const resources = new ArtifactAnalysisResources(
"fixture",
"inspect_signal_decoders",
readStableArtifact,
readLinuxResourceLimits,
{ message: "busy", details: {} },
);
const reason = { request: "cancelled before workspace cleanup" };
const primary = new AnalysisCancelledError("inspect_signal_decoders", {
cause: reason,
});
resources.retain({
path: "workspace",
close: async () => {
if (denied) throw new Error("workspace close denied");
},
});
try {
const result = await resources.finish(err(primary), () => {
throw new Error("Must not produce success Evidence");
});
if (result.ok) throw new Error("Expected retained workspace");
expect(result.error.cleanupPrimary).toBe(primary);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
await expect(resources.close()).rejects.toMatchObject({
cleanupIncomplete: true,
});
denied = false;
await resources.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
} finally {
denied = false;
await resources.close();
}
});
it("observes cancellation after pending descriptor cleanup before admitting replacement work", async () => {
let closes = 0;
let cleanupStarted: () => void = () => {};
const started = new Promise<void>((resolve) => {
cleanupStarted = resolve;
});
let releaseCleanup: () => void = () => {};
const held = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const readArtifact: typeof readStableArtifact = async (
_path,
_limit,
_signal,
_fs,
scope,
) => {
if (scope === undefined) throw new Error("Expected file scope");
const retained = {
kind: "lease",
resource: "descriptor",
lease: {
release: async () => {
closes++;
if (closes === 1) throw new Error("descriptor close denied");
cleanupStarted();
await held;
},
},
} as const;
await scope.release(retained);
throw new ArtifactReaderFailure("io", "descriptor cleanup failed", {
cleanup: { reason: "descriptor close denied", resources: ["descriptor"] },
});
};
const resources = new ArtifactAnalysisResources(
"fixture",
"inspect_signal_decoders",
readArtifact,
readLinuxResourceLimits,
{ message: "busy", details: {} },
);
let calls = 0;
try {
const first = await resources.run(async () =>
ok(await resources.read("fixture", 1)),
);
expect(first).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
const caller = new AbortController();
const reason = { request: "cancel while old descriptor closes" };
const pending = resources.run(async () => {
calls++;
return ok("Must not run");
}, caller.signal);
await started;
caller.abort(reason);
releaseCleanup();
const result = await pending;
if (result.ok) throw new Error("Expected cancelled replacement");
expect(result.error.cause).toBe(reason);
expect(calls).toBe(0);
if (!first.ok) expect(first.error.cleanupIncomplete).toBe(false);
} finally {
releaseCleanup();
await resources.close();
}
});
it.each([false, true])(
"keeps an existing physical primary across cleanup-time cancellation (failed close: %s)",
async (failsClose) => {
const resources = new ArtifactAnalysisResources(
"fixture",
"inspect_signal_decoders",
readStableArtifact,
readLinuxResourceLimits,
{ message: "busy", details: {} },
);
const caller = new AbortController();
const primary = new AnalysisOutputError(
"inspect_signal_decoders",
"physical producer report failure",
);
let denied = failsClose;
resources.retain({
path: "workspace",
close: async () => {
caller.abort({ request: "late cancellation" });
if (denied) throw new Error("workspace close denied");
},
});
try {
const result = await resources.finish(
err(primary),
() => {
throw new Error("Failed work must not produce success Evidence");
},
"inspect_signal_decoders",
caller.signal,
);
if (result.ok) throw new Error("Expected original physical failure");
expect(failsClose ? result.error.cleanupPrimary : result.error).toBe(
primary,
);
denied = false;
await resources.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(failsClose ? result.error.cleanupPrimary : result.error).toBe(
primary,
);
} finally {
denied = false;
await resources.close();
}
},
);