mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
087813fb88
* fix(cancellation): preserve caller abort reasons through late finalization Caller aborts that arrived during final provider, cleanup, or protocol handling were dropped or replaced by I/O, cleanup, or success results. Each affected path now samples the caller signal after its owner has settled and returns AnalysisCancelledError carrying the exact signal.reason, keeping any completed partial Evidence or cleanup state. - Web script export: keep the exact reason for an interrupted output write, and honor a cancellation fact recorded before a retried capture close succeeds. - Native value tracing and Hopper regex search: check the signal after the final provider reply or regex worker settles. - Apple asset catalog: stop assetutil with SIGTERM so cancellation settles only after the helper exits. - CDP observation: end an armed window on a fatal protocol failure. - ADB bugreport: prefer cancellation over the staging-removal output error when the caller aborted. - JavaScript recovery: check the signal after provider success. - export-web-scripts CLI: run under command cancellation and forward the signal into the export workflow. - Objdump and Rizin CLI: report cancellation after cleanup completes. - Binary session: keep cancellation primary when previous-client retirement fails after abort. Fixes morluto/rea#6042 Fixes morluto/rea#6043 Fixes morluto/rea#6048 Fixes morluto/rea#6049 Fixes morluto/rea#6057 Fixes morluto/rea#6060 Fixes morluto/rea#6078 Fixes morluto/rea#6080 Fixes morluto/rea#6108 Fixes morluto/rea#6111 Fixes morluto/rea#6115 * fix(cancellation): keep caller reasons through export rollback and commit - Rollback failure after a caller abort now returns AnalysisCancelledError with the exact signal.reason, attaching the rollback failure as cleanup and retaining any published result as partial observation. - A capture close retry that succeeds after abort returns cancellation instead of the stale cleanup diagnostic. - An abort that lands during the final output commit returns cancellation carrying the completed manifest as partial observation. - Apple assetutil stop test: abort and await the run in finally so a timed-out wait does not leave the child running, and skip on Windows where the JavaScript SIGTERM handler cannot run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b247e9-c793-4b8f-979a-807c27112cf5 * fix(cancellation): bind export cancellations to cleanup and caller reasons - A rollback-failure cancellation is bound to the retained cleanup lease, so a later successful close clears cleanup_incomplete instead of reporting a residual directory that no longer exists. - mapExportFailure wraps the caller's own AnalysisCancelledError reason under export_web_scripts rather than returning it as the export's error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b247e9-c793-4b8f-979a-807c27112cf5 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Copilot-Session: 35b247e9-c793-4b8f-979a-807c27112cf5
377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { expect, it, onTestFinished } from "vitest";
|
|
|
|
import type { AnalysisClient } from "../../../src/application/AnalysisProvider.js";
|
|
import { HopperStartError } from "../../../src/domain/hopperErrors.js";
|
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
|
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
|
import { err, ok as resultOk } from "../../../src/domain/result.js";
|
|
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
|
import {
|
|
createBinarySessionTargets,
|
|
createDeferred,
|
|
createTestBinarySession,
|
|
} from "../../fixtures/binarySession.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
const retryFixture = async (
|
|
execute: AnalysisClient["execute"] = () => Promise.resolve(ok(null)),
|
|
onClose?: () => void,
|
|
) => {
|
|
const parent = await createTestTempDirectory("rea-session-cleanup-retry-");
|
|
const runtime = join(parent, "owned-runtime");
|
|
const document = join(parent, "retained-document");
|
|
await mkdir(runtime);
|
|
await writeFile(join(runtime, "observation"), "owned observation");
|
|
await writeFile(document, "caller-retained document");
|
|
const targets = await createBinarySessionTargets();
|
|
let allowed = false;
|
|
let created = 0;
|
|
const runIds: string[] = [];
|
|
const cleanupError = new ProviderCleanupError("fixture", [runtime], {
|
|
reason: "owned runtime removal denied",
|
|
leftover_paths: [runtime],
|
|
});
|
|
const client: AnalysisClient = {
|
|
execute,
|
|
operationHealthSnapshot: () => ({
|
|
state: "unreachable",
|
|
stage: "connection",
|
|
retryAction: "restart_provider",
|
|
exitCode: null,
|
|
requests: [],
|
|
}),
|
|
close: async (options) => {
|
|
onClose?.();
|
|
if (!allowed) return err(cleanupError);
|
|
await rm(runtime, { recursive: true, force: true });
|
|
if (options?.retainDocument !== true) await rm(document, { force: true });
|
|
return resultOk(null);
|
|
},
|
|
};
|
|
const session = createTestBinarySession((_target, _profile, context) => {
|
|
if (context === undefined) throw new Error("missing analysis run context");
|
|
runIds.push(context.runId);
|
|
created += 1;
|
|
return created === 1
|
|
? {
|
|
...client,
|
|
runtimeLineageSnapshots: () => [
|
|
{
|
|
provider: {
|
|
id: "fixture",
|
|
name: "Fixture analysis provider",
|
|
version: "1",
|
|
},
|
|
observation: {
|
|
status: "unavailable" as const,
|
|
observedAt: "2026-10-16T00:00:00Z",
|
|
runId: context.runId,
|
|
launcherPid: 123,
|
|
processGroupId: 123,
|
|
reason: "fixture lineage unavailable",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
: {
|
|
execute: () => Promise.resolve(ok(null)),
|
|
close: () => Promise.resolve(resultOk(null)),
|
|
};
|
|
});
|
|
onTestFinished(async () => {
|
|
allowed = true;
|
|
await session.close();
|
|
});
|
|
return {
|
|
session,
|
|
targets,
|
|
runtime,
|
|
document,
|
|
cleanupError,
|
|
runIds,
|
|
allowCleanup: () => {
|
|
allowed = true;
|
|
},
|
|
created: () => created,
|
|
};
|
|
};
|
|
|
|
it.each(["close", "switch", "open after close"] as const)(
|
|
"retries owned cleanup before completing %s",
|
|
async (transition) => {
|
|
const fixture = await retryFixture();
|
|
const {
|
|
session,
|
|
targets: [first, second],
|
|
runtime,
|
|
cleanupError,
|
|
} = fixture;
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
const attempt = () =>
|
|
transition === "close" ? session.close() : session.open(second);
|
|
const initial =
|
|
transition === "switch" ? await attempt() : await session.close();
|
|
expect(initial).toEqual(err(cleanupError));
|
|
expect(session.activeTarget()).toBeUndefined();
|
|
expect(await session.status()).toMatchObject({
|
|
open: false,
|
|
cleanup_pending: true,
|
|
analysis_run: {
|
|
run_id: fixture.runIds[0],
|
|
process_lineage: {
|
|
status: "snapshots",
|
|
snapshots: [
|
|
{ observation: { reason: "fixture lineage unavailable" } },
|
|
],
|
|
},
|
|
},
|
|
provider_operation_health: { state: "unreachable", stage: "connection" },
|
|
});
|
|
expect(await session.execute("address_name", {})).toMatchObject({
|
|
ok: false,
|
|
});
|
|
await access(runtime);
|
|
|
|
expect(await attempt()).toEqual(err(cleanupError));
|
|
expect(await session.status()).toMatchObject({
|
|
analysis_run: { run_id: fixture.runIds[0] },
|
|
});
|
|
expect(fixture.created()).toBe(1);
|
|
await access(runtime);
|
|
|
|
fixture.allowCleanup();
|
|
expect((await attempt()).ok).toBe(true);
|
|
expect(await session.status()).toMatchObject({ cleanup_pending: false });
|
|
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(fixture.created()).toBe(transition === "close" ? 1 : 2);
|
|
expect(await session.status()).toMatchObject({
|
|
analysis_run:
|
|
transition === "close"
|
|
? null
|
|
: {
|
|
run_id: fixture.runIds[1],
|
|
process_lineage: { status: "not_observed" },
|
|
},
|
|
});
|
|
expect(new Set(fixture.runIds).size).toBe(fixture.created());
|
|
},
|
|
);
|
|
|
|
it("keeps a failed candidate for cleanup and preserves its startup failure", async () => {
|
|
const primary = new HopperStartError();
|
|
const fixture = await retryFixture(() => Promise.resolve(err(primary)));
|
|
const result = await fixture.session.open(fixture.targets[0]);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
cleanupIncomplete: true,
|
|
cleanupResources: [fixture.runtime],
|
|
diagnostics: {
|
|
primary_error: projectAnalysisError(primary),
|
|
cleanup_error: projectAnalysisError(fixture.cleanupError),
|
|
},
|
|
},
|
|
});
|
|
expect(fixture.session.activeTarget()).toBeUndefined();
|
|
expect(await fixture.session.status()).toMatchObject({
|
|
open: false,
|
|
cleanup_pending: true,
|
|
analysis_run: {
|
|
run_id: fixture.runIds[0],
|
|
process_lineage: { status: "snapshots" },
|
|
},
|
|
});
|
|
fixture.allowCleanup();
|
|
expect((await fixture.session.close()).ok).toBe(true);
|
|
await expect(access(fixture.runtime)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
});
|
|
|
|
it("retains cancelled startup cleanup and the cancellation reason", async () => {
|
|
const controller = new AbortController();
|
|
const fixture = await retryFixture(() => {
|
|
controller.abort();
|
|
return Promise.resolve(ok(null));
|
|
});
|
|
expect(
|
|
await fixture.session.open(fixture.targets[0], {
|
|
signal: controller.signal,
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
cleanupIncomplete: true,
|
|
cleanupResources: [fixture.runtime],
|
|
diagnostics: { primary_error: { code: "cancelled" } },
|
|
},
|
|
});
|
|
expect(await fixture.session.status()).toMatchObject({
|
|
open: false,
|
|
cleanup_pending: true,
|
|
analysis_run: {
|
|
run_id: fixture.runIds[0],
|
|
process_lineage: { status: "snapshots" },
|
|
},
|
|
});
|
|
fixture.allowCleanup();
|
|
expect((await fixture.session.close()).ok).toBe(true);
|
|
await expect(access(fixture.runtime)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
});
|
|
|
|
it("keeps cancellation as the primary when previous-client retirement fails after abort", async () => {
|
|
const controller = new AbortController();
|
|
const reason = { phase: "binary-retirement" };
|
|
const fixture = await retryFixture(undefined, () => controller.abort(reason));
|
|
expect((await fixture.session.open(fixture.targets[0])).ok).toBe(true);
|
|
const result = await fixture.session.open(fixture.targets[1], {
|
|
signal: controller.signal,
|
|
});
|
|
if (result.ok) throw new Error("Expected cancelled retirement");
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(result.error.cleanupPrimary).toMatchObject({
|
|
_tag: "AnalysisCancelledError",
|
|
});
|
|
expect(result.error.cleanupPrimary?.cause).toBe(reason);
|
|
expect(await fixture.session.status()).toMatchObject({
|
|
open: false,
|
|
cleanup_pending: true,
|
|
});
|
|
fixture.allowCleanup();
|
|
expect((await fixture.session.close()).ok).toBe(true);
|
|
});
|
|
|
|
it("preserves caller-selected document retention when retrying cleanup", async () => {
|
|
const fixture = await retryFixture();
|
|
expect((await fixture.session.open(fixture.targets[0])).ok).toBe(true);
|
|
expect(
|
|
await fixture.session.close({ retainProviderDocuments: true }),
|
|
).toEqual(err(fixture.cleanupError));
|
|
fixture.allowCleanup();
|
|
expect((await fixture.session.close()).ok).toBe(true);
|
|
await expect(access(fixture.runtime)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
expect(await readFile(fixture.document, "utf8")).toBe(
|
|
"caller-retained document",
|
|
);
|
|
});
|
|
|
|
it("honors an explicit retention choice on a cleanup retry", async () => {
|
|
const fixture = await retryFixture();
|
|
expect((await fixture.session.open(fixture.targets[0])).ok).toBe(true);
|
|
expect(await fixture.session.close()).toEqual(err(fixture.cleanupError));
|
|
fixture.allowCleanup();
|
|
expect(
|
|
(await fixture.session.close({ retainProviderDocuments: true })).ok,
|
|
).toBe(true);
|
|
await expect(access(fixture.runtime)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
expect(await readFile(fixture.document, "utf8")).toBe(
|
|
"caller-retained document",
|
|
);
|
|
});
|
|
|
|
it("keeps failed restoration resources available for a close retry", async () => {
|
|
const parent = await createTestTempDirectory("rea-restore-cleanup-retry-");
|
|
const runtime = join(parent, "restore-runtime");
|
|
await mkdir(runtime);
|
|
await writeFile(join(runtime, "observation"), "restore observation");
|
|
const [first, second] = await createBinarySessionTargets();
|
|
let created = 0;
|
|
let allowed = false;
|
|
const runIds: string[] = [];
|
|
const session = createTestBinarySession((_target, _profile, context) => {
|
|
if (context === undefined) throw new Error("missing analysis run context");
|
|
runIds.push(context.runId);
|
|
const index = ++created;
|
|
return {
|
|
execute: () =>
|
|
Promise.resolve(index === 1 ? ok(null) : err(new HopperStartError())),
|
|
close: async () => {
|
|
if (index !== 3) return resultOk(null);
|
|
if (!allowed)
|
|
throw new ProviderCleanupError("fixture", [runtime], {
|
|
reason: "restoration cleanup denied",
|
|
});
|
|
await rm(runtime, { recursive: true, force: true });
|
|
return resultOk(null);
|
|
},
|
|
};
|
|
});
|
|
onTestFinished(async () => {
|
|
allowed = true;
|
|
await session.close();
|
|
});
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect(await session.open(second)).toMatchObject({
|
|
ok: false,
|
|
error: { cleanupIncomplete: true, cleanupResources: [runtime] },
|
|
});
|
|
expect(session.activeTarget()).toBeUndefined();
|
|
expect(await session.status()).toMatchObject({
|
|
open: false,
|
|
cleanup_pending: true,
|
|
analysis_run: { run_id: runIds[2] },
|
|
});
|
|
expect(new Set(runIds).size).toBe(3);
|
|
allowed = true;
|
|
expect((await session.close()).ok).toBe(true);
|
|
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it("does not start a replacement cancelled while pending cleanup completes", async () => {
|
|
const parent = await createTestTempDirectory("rea-cancel-cleanup-retry-");
|
|
const runtime = join(parent, "owned-runtime");
|
|
await mkdir(runtime);
|
|
await writeFile(join(runtime, "observation"), "owned observation");
|
|
const [first, second] = await createBinarySessionTargets();
|
|
const entered = createDeferred<void>();
|
|
const release = createDeferred<void>();
|
|
let allowed = false;
|
|
let created = 0;
|
|
const session = createTestBinarySession(() => {
|
|
const index = ++created;
|
|
return {
|
|
execute: () => Promise.resolve(ok(null)),
|
|
close: async () => {
|
|
if (index !== 1) return resultOk(null);
|
|
if (!allowed)
|
|
return err(
|
|
new ProviderCleanupError("fixture", [runtime], {
|
|
reason: "cleanup denied",
|
|
}),
|
|
);
|
|
entered.resolve();
|
|
await release.promise;
|
|
await rm(runtime, { recursive: true, force: true });
|
|
return resultOk(null);
|
|
},
|
|
};
|
|
});
|
|
onTestFinished(async () => {
|
|
allowed = true;
|
|
release.resolve();
|
|
await session.close();
|
|
});
|
|
expect((await session.open(first)).ok).toBe(true);
|
|
expect((await session.close()).ok).toBe(false);
|
|
allowed = true;
|
|
const controller = new AbortController();
|
|
const opening = session.open(second, { signal: controller.signal });
|
|
await entered.promise;
|
|
controller.abort();
|
|
release.resolve();
|
|
expect(await opening).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError" },
|
|
});
|
|
expect(created).toBe(1);
|
|
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|