* fix(cleanup): preserve owner recovery instructions Keep cleanup remediation through combined errors and direct or managed one-shot analysis while retaining primary diagnostics. Refs: #4227 * fix(hopper): recover late startup and retained cleanup Authenticate initial and cleanup handshakes through the same identity owner. Expose startup and cleanup activity, retain health after target retirement, and expire abandoned unauthenticated listeners without timing out authenticated work. Refs: #4227 * fix(providers): retain composite lifecycle facts across retries Preserve observed child health with incomplete peer coverage, retry only unresolved clients, and retain all child cleanup recovery actions. Refs: #4233 * fix(process): honor cancellation through capture cleanup Check the request signal after cleanup and before final publication, retaining completed observations under typed cancellation. Refs: #4005 * fix(native): preserve failed LLDB launch ownership and exit proof Publish failed-launch process identity and observed exit separately from object validity. Retire only the exact owned process and retain uncertainty when exit cannot be established. Refs: #4232 * fix(native): honor cancellation after trace finalization Check cancellation after runtime-root cleanup and retain collected trace observations as partial cancellation evidence. Refs: #4009 * fix(cli): preserve analysis results when provider cleanup fails Reuse the existing one-shot finalizer across thirteen command families to retain completed Evidence and primary diagnostics on close failure. Cover success, denied cleanup, and combined primary/cleanup failure through the public CLI. Refs: #3935 * docs(lifecycle): define completion invariants and regression scenarios Document distinct ownership/readiness/exit facts, late cancellation and atomic publication boundaries, partial child cleanup, and diagnostic retention. Link representative regressions and route contributor guidance through AGENTS.md. Refs: #4227 * fix(session): retain run identity through pending cleanup * fix(cli): preserve original cleanup resource diagnostics * fix(native): retain completed facts through finalization failure * fix(providers): report retained cleanup paths at their owners
298 lines
9.9 KiB
TypeScript
298 lines
9.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
runDirectAnalysis,
|
|
runManagedProviderExecution,
|
|
runProviderAnalysis,
|
|
} from "../../src/application/DirectAnalysis.js";
|
|
import type { DirectAnalysisDependencies } from "../../src/application/DirectAnalysisDependencies.js";
|
|
import { parseConfig } from "../../src/config/parseConfig.js";
|
|
import { analysisCliErrorEnvelopeSchema } from "../../src/contracts/errorSchemas.js";
|
|
import type { AnalysisError } from "../../src/domain/analysisErrorBase.js";
|
|
import { AnalysisTimeoutError } from "../../src/domain/analysisErrorCore.js";
|
|
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
|
|
import { parseEvidence } from "../../src/domain/evidence.js";
|
|
import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js";
|
|
import { err, ok } from "../../src/domain/result.js";
|
|
import { isCliOperationFailure } from "../../src/cliLogging.js";
|
|
import { observed } from "../fixtures/analysisExecution.js";
|
|
import {
|
|
createBinarySessionTargets,
|
|
createTestBinarySession,
|
|
} from "../fixtures/binarySession.js";
|
|
|
|
const cleanupError = new ProviderCleanupError(
|
|
"fixture",
|
|
["fixture-provider-process"],
|
|
{ reason: "process exit could not be confirmed" },
|
|
{ remediationAction: "Close the retained fixture process before retrying." },
|
|
);
|
|
|
|
const primaryError = new AnalysisTimeoutError("read_bytes", 100, {
|
|
capturedOutput: {
|
|
stdout: "retained observation",
|
|
stderr: "",
|
|
truncated: false,
|
|
},
|
|
});
|
|
|
|
const factories = (
|
|
options: {
|
|
readonly executionError?: AnalysisError;
|
|
readonly thrownExecution?: Error;
|
|
readonly cleanupError?: AnalysisError;
|
|
readonly thrownCleanup?: Error;
|
|
} = {},
|
|
) => {
|
|
const session = createTestBinarySession(() => ({
|
|
execute: async (operation) => {
|
|
if (operation === "health") return observed(null);
|
|
if (options.thrownExecution !== undefined) throw options.thrownExecution;
|
|
return options.executionError === undefined
|
|
? observed({ operation, observation: "retained result" })
|
|
: err(options.executionError);
|
|
},
|
|
close: async () => {
|
|
if (options.thrownCleanup !== undefined) throw options.thrownCleanup;
|
|
return options.cleanupError === undefined
|
|
? ok(null)
|
|
: err(options.cleanupError);
|
|
},
|
|
}));
|
|
const dependencies: DirectAnalysisDependencies = {
|
|
readConfiguration: () => parseConfig({}),
|
|
createBinarySession: () => session,
|
|
createArtifactBinarySession: () => session,
|
|
createManagedBinarySession: () => session,
|
|
};
|
|
return { dependencies, session };
|
|
};
|
|
|
|
describe("one-shot analysis cleanup outcomes", () => {
|
|
it("retains successful evidence and reports CLI failure when close fails", async () => {
|
|
const [path] = await createBinarySessionTargets();
|
|
const { dependencies, session } = factories({ cleanupError });
|
|
const result = await runDirectAnalysis(
|
|
dependencies,
|
|
path,
|
|
"read_bytes",
|
|
{},
|
|
);
|
|
expect(result).toMatchObject({
|
|
error: "Analysis failed",
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
resources: ["fixture-provider-process"],
|
|
partial_observation: {
|
|
operation: "read_bytes",
|
|
subject: { local_path: path },
|
|
normalized_result: { observation: "retained result" },
|
|
raw_result: null,
|
|
},
|
|
},
|
|
});
|
|
expect(isCliOperationFailure(result)).toBe(true);
|
|
expect(session.activeTarget()).toBeUndefined();
|
|
});
|
|
|
|
it("preserves primary failure diagnostics alongside an unsuccessful close", async () => {
|
|
const [path] = await createBinarySessionTargets();
|
|
const { dependencies } = factories({
|
|
executionError: primaryError,
|
|
cleanupError,
|
|
});
|
|
const primary = projectAnalysisError(primaryError);
|
|
const result = await runDirectAnalysis(
|
|
dependencies,
|
|
path,
|
|
"read_bytes",
|
|
{},
|
|
);
|
|
const callerError = analysisCliErrorEnvelopeSchema.parse(result);
|
|
expect(callerError).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
category: primary.category,
|
|
message: primary.message,
|
|
remediation: {
|
|
action: "Close the retained fixture process before retrying.",
|
|
},
|
|
details: {
|
|
execution_failure: primary.code,
|
|
captured_output: primary.details?.captured_output,
|
|
cleanup_error: projectAnalysisError(cleanupError),
|
|
},
|
|
});
|
|
expect(callerError.details).not.toHaveProperty("primary_error");
|
|
expect(
|
|
JSON.stringify(callerError).match(/retained observation/g),
|
|
).toHaveLength(1);
|
|
expect(isCliOperationFailure(result)).toBe(true);
|
|
});
|
|
|
|
it("reports provider close rejection with the successful observation", async () => {
|
|
const [path] = await createBinarySessionTargets();
|
|
const { dependencies } = factories({
|
|
thrownCleanup: new Error("unexpected close rejection"),
|
|
});
|
|
const result = await runDirectAnalysis(
|
|
dependencies,
|
|
path,
|
|
"read_bytes",
|
|
{},
|
|
);
|
|
expect(result).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
resources: ["provider-client"],
|
|
diagnostics: { reason: "provider client close rejected unexpectedly" },
|
|
partial_observation: {
|
|
normalized_result: { observation: "retained result" },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"retains original cleanup diagnostics when final close fails: %s",
|
|
async (closeFails) => {
|
|
const [path] = await createBinarySessionTargets();
|
|
const executionError = new AnalysisTimeoutError("read_bytes", 100, {
|
|
cleanup: {
|
|
reason: "snapshot removal failed during execution",
|
|
resources: ["primary-snapshot"],
|
|
},
|
|
capturedOutput: primaryError.capturedOutput,
|
|
});
|
|
const primary = projectAnalysisError(executionError);
|
|
const { dependencies } = factories({
|
|
executionError,
|
|
...(closeFails ? { cleanupError } : {}),
|
|
});
|
|
const result = analysisCliErrorEnvelopeSchema.parse(
|
|
await runDirectAnalysis(dependencies, path, "read_bytes", {}),
|
|
);
|
|
expect(result).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
...primary.details,
|
|
...(closeFails
|
|
? { cleanup_error: projectAnalysisError(cleanupError) }
|
|
: {}),
|
|
},
|
|
});
|
|
expect(
|
|
JSON.stringify(result).match(/retained observation/g),
|
|
).toHaveLength(1);
|
|
if (!closeFails)
|
|
expect(result.details).not.toHaveProperty("cleanup_error");
|
|
},
|
|
);
|
|
|
|
it("retains both unexpected execution failure and cleanup failure", async () => {
|
|
const [path] = await createBinarySessionTargets();
|
|
const thrownExecution = new Error("unexpected execution rejection");
|
|
const { dependencies } = factories({ thrownExecution, cleanupError });
|
|
await expect(
|
|
runDirectAnalysis(dependencies, path, "read_bytes", {}),
|
|
).rejects.toMatchObject({
|
|
cause: thrownExecution,
|
|
errors: [thrownExecution, cleanupError],
|
|
});
|
|
});
|
|
|
|
it("keeps successful evidence unchanged after confirmed cleanup", async () => {
|
|
const [path] = await createBinarySessionTargets();
|
|
const { dependencies, session } = factories();
|
|
const result = await runDirectAnalysis(
|
|
dependencies,
|
|
path,
|
|
"read_bytes",
|
|
{},
|
|
);
|
|
expect(parseEvidence(result)).toMatchObject({
|
|
operation: "read_bytes",
|
|
normalized_result: { observation: "retained result" },
|
|
});
|
|
expect(isCliOperationFailure(result)).toBe(false);
|
|
expect(session.activeTarget()).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("managed one-shot cleanup outcomes", () => {
|
|
it("returns a managed cleanup error retaining the complete successful execution", async () => {
|
|
const [path] = await createBinarySessionTargets();
|
|
const { dependencies } = factories({ cleanupError });
|
|
const result = await runManagedProviderExecution(
|
|
dependencies,
|
|
path,
|
|
"inspect_managed_artifact",
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
remediation: {
|
|
action: "Close the retained fixture process before retrying.",
|
|
},
|
|
details: {
|
|
resources: ["fixture-provider-process"],
|
|
diagnostics: {
|
|
cleanup_error: projectAnalysisError(cleanupError),
|
|
partial_observation: {
|
|
result: {
|
|
operation: "inspect_managed_artifact",
|
|
observation: "retained result",
|
|
},
|
|
rawResult: null,
|
|
provider: { id: "fixture" },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("retains the typed managed primary error when close also fails", async () => {
|
|
const [path] = await createBinarySessionTargets();
|
|
const { dependencies } = factories({
|
|
executionError: primaryError,
|
|
cleanupError,
|
|
});
|
|
const result = await runManagedProviderExecution(
|
|
dependencies,
|
|
path,
|
|
"inspect_managed_artifact",
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.error.cause).toBeInstanceOf(AggregateError);
|
|
expect(result.error.cause).toMatchObject({
|
|
cause: primaryError,
|
|
errors: [primaryError, cleanupError],
|
|
});
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
diagnostics: {
|
|
primary_error: projectAnalysisError(primaryError),
|
|
cleanup_error: projectAnalysisError(cleanupError),
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("exposes managed cleanup failure to the CLI error classifier", async () => {
|
|
const [path] = await createBinarySessionTargets();
|
|
const { dependencies } = factories({ cleanupError });
|
|
const result = await runProviderAnalysis(
|
|
dependencies,
|
|
path,
|
|
"inspect_managed_artifact",
|
|
{},
|
|
);
|
|
expect(isCliOperationFailure(result)).toBe(true);
|
|
expect(result).toMatchObject({
|
|
error: "Analysis failed",
|
|
code: "cleanup_incomplete",
|
|
});
|
|
});
|
|
});
|