fix(cancellation): preserve caller abort reasons through late finalization (#6143)

* 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
This commit is contained in:
authored and GitHub committed 2026-10-22 04:15:16 +08:00
1 parent 4e28166626
commit 087813fb88
21 files changed
+611 -61

No files matched your search

+6 -4
View File
@@ -3216,10 +3216,12 @@ export class AdbProvider {
await removeOwnedOutputDirectory(stagingDirectory, stagingIdentity);
} catch (cause) {
const primary = collectedResult.ok
? new AnalysisOutputError(
operation,
"The archive was published, but its staging directory could not be removed",
)
? signal?.aborted === true
? new AnalysisCancelledError(operation, { cause: signal.reason })
: new AnalysisOutputError(
operation,
"The archive was published, but its staging directory could not be removed",
)
: collectedResult.error;
if (retainedObservation !== undefined)
primary.retainPartialObservation(retainedObservation);
+32 -2
View File
@@ -38,6 +38,7 @@ import {
NonRegularFileReadError,
RegularFileChangedError,
} from "../filesystem/RegularFile.js";
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
const OPERATION = "export_web_scripts";
@@ -101,6 +102,13 @@ export const exportWebScriptsValidated = async (
resources,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
if (options.signal?.aborted === true)
return err(
new AnalysisCancelledError(OPERATION, {
cause: options.signal.reason,
partialObservation: { kind: "web-script-export", result },
}),
);
return ok(
createEvidence(
{ path: input.capture_path, sha256, format: "file" },
@@ -170,6 +178,8 @@ const mapExportFailure = (
signal: AbortSignal | undefined,
): AnalysisError => {
if (cause instanceof WebScriptExportError) return cause;
if (cause instanceof AnalysisCancelledError && cause !== signal?.reason)
return cause;
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
return analysisErrorWithCleanupFailure(
new WebScriptExportError(
@@ -187,7 +197,12 @@ const mapExportFailure = (
OPERATION,
);
if (signal?.aborted === true)
return new AnalysisCancelledError(OPERATION, { cause });
return new AnalysisCancelledError(OPERATION, {
cause:
cause instanceof ArtifactReaderFailure && cause.cause === signal.reason
? signal.reason
: cause,
});
if (cause instanceof ArtifactReaderFailure)
return new WebScriptExportError(
cause.reason,
@@ -245,8 +260,23 @@ const readCapture = async (
incomplete: retry.cleanup !== undefined,
cause: cause.cleanupCause,
};
if (retry.outcome.kind === "completed")
if (retry.outcome.kind === "completed") {
const cancellation =
signal?.aborted === true
? { reason: signal.reason }
: regularFileCancellation(cause);
if (cancellation !== undefined) {
const primary = new AnalysisCancelledError(OPERATION, {
cause: cancellation.reason,
});
return err(
retry.cleanup === undefined
? primary
: incompleteCaptureCleanup(primary, path, cleanup),
);
}
return ok({ bytes: retry.outcome.value as Buffer, cleanup });
}
const primary = captureReadFailure(
path,
retry.outcome.cause,
+10
View File
@@ -589,6 +589,16 @@ export class BinarySession
// Preserve Evidence and Unknown history for export and recovery.
this.clearSnapshot();
this.#clearRuntimeAvailability();
if (isAborted(options.signal))
return err(
analysisErrorWithCleanupFailure(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
closed.error,
"open_binary",
),
);
return closed;
}
}
@@ -176,3 +176,29 @@ describe("JavaScript recovery workflow", () => {
});
});
});
it("returns cancellation with the validated Evidence when the caller aborts after provider success", async () => {
const controller = new AbortController();
const reason = { request: "stop recovery" };
const service = new JavaScriptRecoveryService({
recover: async () => {
controller.abort(reason);
return ok(
createAnalysisExecution(recoveryResult(), providerIdentity, {
subject: {
path: artifact.path,
sha256: artifact.sha256,
format: "javascript" as const,
},
}),
);
},
});
const result = await service.recover(input, { signal: controller.signal });
if (result.ok) throw new Error("expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toMatchObject({
operation: "recover_javascript_sources",
});
});
@@ -13,8 +13,9 @@ import {
} from "../../domain/javascript/javascriptRecovery.js";
import { jsonObjectSchema, jsonValueSchema } from "../../domain/jsonValue.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../../domain/result.js";
import { err, type Result } from "../../domain/result.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
import type { JavaScriptRecoveryPort } from "./JavaScriptRecoveryPort.js";
const OPERATION = "recover_javascript_sources";
@@ -107,8 +108,10 @@ export class JavaScriptRecoveryService {
"Recovery source or engine identity disagrees with its Evidence binding",
),
);
return ok(
createEvidence(execution.value.subject, execution.value.provider, {
const evidence = createEvidence(
execution.value.subject,
execution.value.provider,
{
operation: OPERATION,
parameters: jsonObjectSchema.parse(parsed.data),
result: jsonValueSchema.parse(result.data),
@@ -117,7 +120,8 @@ export class JavaScriptRecoveryService {
limitations: execution.value.limitations,
confidence: "derived",
authority: "shipped-artifact",
}),
},
);
return completeProviderEvidence(evidence, options?.signal);
}
}
@@ -698,6 +698,25 @@ describe("bounded native value dependency composition", () => {
error: { _tag: "AnalysisOutputError" },
});
});
it("returns typed cancellation when the caller aborts with the final provider reply", async () => {
const controller = new AbortController();
const reason = { request: "stop trace" };
const result = await traceNativeValues(
{
execute: async (operation, parameters, options) => {
const value = await analysis.execute(operation, parameters, options);
if (parameters.procedure === "0x2000") controller.abort(reason);
return value;
},
},
{ procedure: "0x1000" },
controller.signal,
);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
});
});
describe("native value trace selector origin", () => {
@@ -446,5 +446,11 @@ export const traceNativeValues = async (
"Graph output exceeds 33554432 bytes (32 MiB). This release has no runtime override. Lower max_nodes, max_edges, or the page limit.",
),
);
if (signal?.aborted)
return err(
new AnalysisCancelledError("trace_native_values", {
cause: signal.reason,
}),
);
return ok(result);
};
@@ -1,10 +1,11 @@
import { createHash } from "node:crypto";
import { mkdir, symlink, writeFile } from "node:fs/promises";
import { access, mkdir, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import * as processOutput from "../../process/ExecFileOutput.js";
import { encodeNibArchiveFixture } from "./NibArchive.fixture.js";
import type { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
@@ -330,4 +331,50 @@ describe("Apple asset catalog and Interface Builder concurrency", () => {
finishCatalog("[]");
await expect(analysis).rejects.toThrow("interface builder failed");
});
it.skipIf(process.platform === "win32")(
"settles a cancelled assetutil run only after its process exits",
async () => {
const bundle = await catalogBundle();
const work = await createTestTempDirectory("rea-assetutil-stop-");
const ready = join(work, "ready");
const exited = join(work, "exited");
const script = `const fs=require("node:fs");process.on("SIGTERM",()=>{setTimeout(()=>{fs.writeFileSync(process.argv[2],"exited");process.exit(0)},500)});fs.writeFileSync(process.argv[1],"ready");setInterval(()=>{},1000);`;
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
const realExecFileOutput = processOutput.execFileOutput;
Object.defineProperty(process, "platform", {
...platform,
value: "darwin",
});
const spy = vi
.spyOn(processOutput, "execFileOutput")
.mockImplementation((_command, _args, options) =>
realExecFileOutput(
process.execPath,
["-e", script, ready, exited],
options,
),
);
const controller = new AbortController();
let settled: Promise<unknown> | undefined;
try {
settled = analyzeAppleAssetCatalogs({
environment: {},
bundlePath: bundle,
targetSha256: "a".repeat(64),
signal: controller.signal,
analyzeInterfaceBuilder: async () => emptyGraph,
}).catch((cause: unknown) => cause);
await vi.waitFor(() => expect(access(ready)).resolves.toBeUndefined());
controller.abort("stop asset inspection");
expect(await settled).toMatchObject({ reason: "cancelled" });
await expect(access(exited)).resolves.toBeUndefined();
} finally {
controller.abort("stop asset inspection");
await settled;
spy.mockRestore();
Object.defineProperty(process, "platform", platform);
}
},
);
});
@@ -274,6 +274,7 @@ const runAssetUtil = async (
env: environment,
timeout: 60_000,
maxBuffer: MAX_CATALOG_BYTES,
stopSignal: "SIGTERM",
...(signal === undefined ? {} : { signal }),
})
).stdout;
+8
View File
@@ -123,6 +123,7 @@ export const observeCdpSession = async (
let endReason = await waitForWindow(
context.input.observation_ms,
capture,
context.connection,
context.signal,
);
if (endReason === "window_elapsed")
@@ -593,18 +594,21 @@ const performanceTimestamp = (value: unknown): number => {
const waitForWindow = async (
durationMs: number,
capture: TimelineCapture,
connection: CdpConnection,
signal?: AbortSignal,
): Promise<EndReason> =>
await new Promise<EndReason>((resolve, reject) => {
const timerController = new AbortController();
let settled = false;
let removeEnd = (): void => {};
let removeProtocolFailure = (): void => {};
const settle = (complete: () => void): void => {
if (settled) return;
settled = true;
timerController.abort();
signal?.removeEventListener("abort", onAbort);
removeEnd();
removeProtocolFailure();
complete();
};
const finish = (reason: EndReason): void => settle(() => resolve(reason));
@@ -619,6 +623,10 @@ const waitForWindow = async (
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted === true) onAbort();
if (!settled) removeEnd = capture.onEnd(finish);
if (!settled)
removeProtocolFailure = connection.onProtocolFailure((error) =>
settle(() => reject(error)),
);
if (settled) return;
void delayWithCancellation(
durationMs,
+22 -5
View File
@@ -8,6 +8,7 @@ import { DESTINATION_CASE_COLLISION_PREFIX } from "../../artifacts/ArtifactPaths
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
import { SafeOutputTreeCreationFailure } from "../../artifacts/SafeOutputTreeCreationFailure.js";
import { PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION } from "../../domain/browserNetworkEvidence.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import {
webScriptExportManifestSchema,
webScriptExportResultSchema,
@@ -30,6 +31,8 @@ const LIMITATIONS = [
"Scenario response bytes preserve the output of Playwright's Response.body() API; any source-body provenance caveat from the capture is retained in this manifest. Debugger sources are the exposed text encoded as UTF-8. Redacted bytes are exported exactly as retained, and may no longer parse as JavaScript.",
];
const OPERATION = "export_web_scripts";
interface PublishWebScriptsOptions {
readonly resources: ArtifactResourceScope;
readonly signal?: AbortSignal;
@@ -128,19 +131,33 @@ export const publishWebScripts = async (
const diagnostic = tree.published
? `Publication completed, but descriptor cleanup failed (${message(cleanupCause)}). The published output remains available.`
: `Publication failed (${message(cause)}); rollback failed (${message(cleanupCause)}). Remove the residual output directory before retrying.`;
const partialObservation =
tree.published && result !== undefined
? ({ kind: "web-script-export", result } as const)
: undefined;
if (options.signal?.aborted === true) {
const cancelled = new AnalysisCancelledError(OPERATION, {
cause: options.signal.reason,
cleanup: { reason: diagnostic, resources: [tree.outputRoot] },
...(partialObservation === undefined
? {}
: { partialObservation }),
});
cleanup.lease.bindCleanupError(cancelled);
throw cancelled;
}
const error = new WebScriptExportError(
cause instanceof ArtifactReaderFailure ? cause.reason : "io",
tree.outputRoot,
diagnostic,
{ residualPaths: [tree.outputRoot] },
);
if (tree.published && result !== undefined)
error.retainPartialObservation({
kind: "web-script-export",
result,
});
if (partialObservation !== undefined)
error.retainPartialObservation(partialObservation);
cleanup.lease.bindCleanupError(error);
throw error;
}
if (tree.published && result !== undefined) return result;
}
throw cause;
+22 -5
View File
@@ -4,6 +4,7 @@ import type { Logger } from "pino";
import { createReverseEngineeringService } from "../composition/reverseEngineering.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
@@ -33,6 +34,7 @@ import {
const executeWithCleanup = async (
service: ReverseEngineeringService,
signal: AbortSignal,
execute: () => Promise<Result<Evidence, AnalysisError>>,
): Promise<Result<Evidence, AnalysisError>> => {
let result: Result<Evidence, AnalysisError>;
@@ -66,11 +68,26 @@ const executeWithCleanup = async (
}
if (!result.ok && hadIncompleteCleanup && !result.error.cleanupIncomplete) {
const evidence = cleanupOnlyCompleteEvidence(result.error);
if (evidence !== undefined) return { ok: true, value: evidence };
if (evidence !== undefined)
return cancelledAfterCleanup({ ok: true, value: evidence }, signal);
}
return result;
return cancelledAfterCleanup(result, signal);
};
/** Late cancellation observed during finalization keeps the completed Evidence as partial data. */
const cancelledAfterCleanup = (
result: Result<Evidence, AnalysisError>,
signal: AbortSignal,
): Result<Evidence, AnalysisError> =>
result.ok && signal.aborted
? err(
new AnalysisCancelledError(result.value.operation, {
cause: signal.reason,
partialObservation: result.value,
}),
)
: result;
const cleanupOnlyCompleteEvidence = (
error: AnalysisError,
): Evidence | undefined => {
@@ -152,7 +169,7 @@ export const registerReverseEngineeringCommands = (
};
const inputError = cliEvidenceDeliveryInputError(delivery);
if (inputError !== undefined) return projectAnalysisError(inputError);
const result = await executeWithCleanup(service, () =>
const result = await executeWithCleanup(service, signal, () =>
service.inspectWithObjdump(
{
...args,
@@ -196,7 +213,7 @@ export const registerReverseEngineeringCommands = (
const inputError = cliEvidenceDeliveryInputError(delivery);
if (inputError !== undefined)
return projectAnalysisError(inputError);
const result = await executeWithCleanup(service, () =>
const result = await executeWithCleanup(service, signal, () =>
service.inspectWithObjdump(
{
operation: "architectures",
@@ -229,7 +246,7 @@ export const registerReverseEngineeringCommands = (
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.executeRizinCommand, async () => {
const result = await executeWithCleanup(service, () =>
const result = await executeWithCleanup(service, signal, () =>
service.executeRizinCommand(
{
...args,
+42 -38
View File
@@ -9,6 +9,7 @@ import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { Logger } from "pino";
import { withCommandCancellation } from "./commandCancellation.js";
/** Register the local captured-script export CLI workflow. */
export const registerWebScriptCommands = (
@@ -29,46 +30,49 @@ export const registerWebScriptCommands = (
),
}),
run: ({ args }) =>
logCliCommand(logger, CLI_COMMANDS.exportWebScripts, async () => {
const resources = new ArtifactResourceScope();
const result = await exportWebScripts(
{
capture_path: args.capturePath,
output_directory: args.outputDirectory,
},
resources,
);
try {
await resources.close();
} catch (cause: unknown) {
const cleanup = ArtifactReaderFailure.cleanupObservation(
cause,
"Web script export resources",
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.exportWebScripts, async () => {
const resources = new ArtifactResourceScope();
const result = await exportWebScripts(
{
capture_path: args.capturePath,
output_directory: args.outputDirectory,
},
resources,
{ signal },
);
const failure = new ProviderCleanupError(
"web-script-export",
cleanup.resources,
{ reason: cleanup.reason },
{ cause, operation: "export_web_scripts" },
);
const error = result.ok
? failure
: analysisErrorWithCleanupFailure(
result.error,
failure,
"export_web_scripts",
);
return {
error: "Script export failed",
...projectAnalysisError(error),
};
}
return result.ok
? result.value
: {
try {
await resources.close();
} catch (cause: unknown) {
const cleanup = ArtifactReaderFailure.cleanupObservation(
cause,
"Web script export resources",
);
const failure = new ProviderCleanupError(
"web-script-export",
cleanup.resources,
{ reason: cleanup.reason },
{ cause, operation: "export_web_scripts" },
);
const error = result.ok
? failure
: analysisErrorWithCleanupFailure(
result.error,
failure,
"export_web_scripts",
);
return {
error: "Script export failed",
...projectAnalysisError(result.error),
...projectAnalysisError(error),
};
}),
}
return result.ok
? result.value
: {
error: "Script export failed",
...projectAnalysisError(result.error),
};
}),
),
});
};
+42 -1
View File
@@ -1,4 +1,4 @@
import { expect, it } from "vitest";
import { expect, it, vi } from "vitest";
import { HopperRegexSearch } from "./HopperRegexSearch.js";
import { err, ok } from "../domain/result.js";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
@@ -9,6 +9,24 @@ import {
} from "../domain/hopperErrors.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
const regexMatch = vi.hoisted(() => ({
afterSettle: undefined as (() => void) | undefined,
}));
vi.mock("../process/RegexMatcher.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../process/RegexMatcher.js")>();
return {
...actual,
matchRegexValues: async (
request: Parameters<typeof actual.matchRegexValues>[0],
) => {
const result = await actual.matchRegexValues(request);
regexMatch.afterSettle?.();
return result;
},
};
});
it.each(
(["search_strings", "search_procedures"] as const).flatMap((operation) =>
(["remote", "capability", "timeout", "process"] as const).map((family) => ({
@@ -196,3 +214,26 @@ it("preserves an inventory failure when cancellation arrives after the physical
await search.close();
}
});
it("returns cancellation when the caller aborts after the regex worker settles", async () => {
const controller = new AbortController();
const reason = { request: "stop search" };
regexMatch.afterSettle = () => controller.abort(reason);
const search = new HopperRegexSearch({
callTool: async () =>
ok([{ address: "0x1000", value: "fixture" }] as never),
});
try {
const result = await search.execute(
"search_strings",
{ pattern: "fixture", mode: "regex", case_sensitive: false },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
} finally {
regexMatch.afterSettle = undefined;
await search.close();
}
});
+4
View File
@@ -136,6 +136,10 @@ export class HopperRegexSearch {
}),
});
if (!matched.ok) return matched;
if (options.signal?.aborted)
return err(
new AnalysisCancelledError(operation, { cause: options.signal.reason }),
);
const selected = new Set(matched.value);
return ok(parsed.data.filter((_, index) => selected.has(index)));
}
@@ -374,3 +374,24 @@ it("represents absent timeline timestamps as unknown and preserves an observed z
]),
);
});
it("ends an armed observation at a fatal protocol failure without waiting out its window", async () => {
const browser = await startFakeCdpBrowser({
malformedEventOnMethod: "Page.setLifecycleEventsEnabled",
});
trackBrowser(browser);
const started = performance.now();
const result = await new CdpBrowserProvider().observeSession(
observeWebSessionInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 10_000,
}),
);
expect(performance.now() - started).toBeLessThan(5_000);
expect(result).toMatchObject({
ok: false,
error: { _tag: "BrowserObservationError", reason: "protocol_error" },
});
}, 15_000);
@@ -369,6 +369,20 @@ it("does not project cancellation cleanup as incomplete after a successful retry
});
});
it("returns cancellation with the completed observation when the caller interrupts service close", async () => {
const service = {
inspectWithObjdump: vi.fn(async () => ok(observation("complete"))),
close: vi.fn(async () => {
process.emit("SIGINT");
}),
};
const result = await runObjdump(service);
expect(result).toMatchObject({ code: "cancelled" });
expect(process.exitCode).toBe(130);
});
it("parses the Rizin inferior TTY as an option and passes it to the manager", async () => {
const commands = new Map<
string,
@@ -1,11 +1,56 @@
import { access } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import pino from "pino";
import { expect, it, vi } from "vitest";
import { registerWebScriptCommands } from "../../../src/cli/webScriptCommands.js";
import * as webScriptExport from "../../../src/application/WebScriptExportService.js";
import { CLI_COMMANDS } from "../../../src/cliCommandNames.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { err } from "../../../src/domain/result.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
it("forwards an interrupt into the export workflow and reports cancellation", async () => {
const commands = new Map<string, { run(input: unknown): Promise<unknown> }>();
const cli = {
command: (
name: string,
definition: { run(input: unknown): Promise<unknown> },
) => {
commands.set(name, definition);
return cli;
},
} as unknown as Parameters<typeof registerWebScriptCommands>[0];
registerWebScriptCommands(cli, pino({ enabled: false }));
const exported = vi
.spyOn(webScriptExport, "exportWebScripts")
.mockImplementation(async (_input, _resources, options) => {
process.emit("SIGINT");
return err(
new AnalysisCancelledError("export_web_scripts", {
cause: options?.signal?.reason,
}),
);
});
const previousExitCode = process.exitCode;
try {
const output = await commands.get(CLI_COMMANDS.exportWebScripts)?.run({
args: {
capturePath: "/unused/capture.json",
outputDirectory: "/unused/out",
},
});
expect(exported.mock.calls[0]?.[2]?.signal?.aborted).toBe(true);
expect(output).toMatchObject({ code: "cancelled" });
expect(process.exitCode).toBe(130);
} finally {
exported.mockRestore();
process.exitCode = previousExitCode;
}
});
cliTest.skipIf(process.platform === "win32")(
"exits with a capture_path invalid_request for a named pipe before publishing output",
async ({ cli, processes }) => {
@@ -25,6 +25,7 @@ import {
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
import { exportWebScripts } from "../../../src/application/WebScriptExportService.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { publishWebScripts } from "../../../src/browser/assets/PublishWebScripts.js";
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
@@ -468,6 +469,194 @@ describe("captured script publication failures and cleanup", () => {
expect(await readdir(root)).toEqual(["capture.json"]);
});
it("keeps the exact caller reason when cancellation interrupts the first output write", async () => {
const { input, resources } = await setup(
scriptScenarioFixture([
{
url: "https://fixture.test/first.js",
bytes: Buffer.from("export const a = 1;"),
},
{
url: "https://fixture.test/second.js",
bytes: Buffer.from("export const b = 2;"),
},
]),
);
const controller = new AbortController();
const reason = { request: "stop export" };
const create = SafeOutputTree.create.bind(SafeOutputTree);
const createSpy = vi
.spyOn(SafeOutputTree, "create")
.mockImplementationOnce(async (...args) => {
const tree = await create(...args);
const write = tree.write.bind(tree);
vi.spyOn(tree, "write").mockImplementationOnce(async (...writeArgs) => {
controller.abort(reason);
return write(...writeArgs);
});
return tree;
});
try {
const result = await exportWebScripts(input, resources, {
signal: controller.signal,
});
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
expect(resources.hasPending).toBe(false);
} finally {
createSpy.mockRestore();
}
});
});
describe("captured script cancellation finalization", () => {
it("keeps the caller reason when rollback also fails after an interrupted write", async () => {
const { input, resources } = await setup(
scriptScenarioFixture([
{
url: "https://fixture.test/first.js",
bytes: Buffer.from("export const a = 1;"),
},
]),
);
const controller = new AbortController();
const reason = { request: "stop export" };
const create = SafeOutputTree.create.bind(SafeOutputTree);
const createSpy = vi
.spyOn(SafeOutputTree, "create")
.mockImplementationOnce(async (...args) => {
const tree = await create(...args);
vi.spyOn(tree, "write").mockImplementationOnce(async () => {
controller.abort(reason);
throw new Error("injected write failure");
});
vi.spyOn(tree, "rollback").mockRejectedValueOnce(
new Error("injected rollback failure"),
);
return tree;
});
try {
const result = await exportWebScripts(input, resources, {
signal: controller.signal,
});
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(true);
} finally {
createSpy.mockRestore();
await resources.close();
}
});
it("wraps a caller-owned cancellation reason under the export operation", async () => {
const { input, resources } = await setup(
scriptScenarioFixture([
{
url: "https://fixture.test/first.js",
bytes: Buffer.from("export const a = 1;"),
},
]),
);
const controller = new AbortController();
const reason = new AnalysisCancelledError("upstream_workflow", {
cause: { request: "stop upstream" },
});
const create = SafeOutputTree.create.bind(SafeOutputTree);
const createSpy = vi
.spyOn(SafeOutputTree, "create")
.mockImplementationOnce(async (...args) => {
const tree = await create(...args);
const write = tree.write.bind(tree);
vi.spyOn(tree, "write").mockImplementationOnce(async (...writeArgs) => {
controller.abort(reason);
return write(...writeArgs);
});
return tree;
});
try {
const result = await exportWebScripts(input, resources, {
signal: controller.signal,
});
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).not.toBe(reason);
if (!(result.error instanceof AnalysisCancelledError))
throw new Error("Expected AnalysisCancelledError");
expect(result.error.operation).toBe("export_web_scripts");
expect(result.error.cause).toBe(reason);
} finally {
createSpy.mockRestore();
await resources.close();
}
});
});
describe("captured script final commit cancellation", () => {
it("reports cancellation with the completed output when abort lands during final commit", async () => {
const { input, resources } = await setup();
const controller = new AbortController();
const reason = { request: "stop export" };
const create = SafeOutputTree.create.bind(SafeOutputTree);
const createSpy = vi
.spyOn(SafeOutputTree, "create")
.mockImplementationOnce(async (...args) => {
const tree = await create(...args);
const commit = tree.commit.bind(tree);
vi.spyOn(tree, "commit").mockImplementation(async (...commitArgs) => {
await commit(...commitArgs);
controller.abort(reason);
});
return tree;
});
try {
const result = await exportWebScripts(input, resources, {
signal: controller.signal,
});
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toMatchObject({
kind: "web-script-export",
result: {
output_directory: input.output_directory,
manifest: { path: join(input.output_directory, "manifest.json") },
},
});
} finally {
createSpy.mockRestore();
}
});
it("returns cancellation with the caller reason when a retried capture close recovers", async () => {
const { input, resources } = await setup();
const controller = new AbortController();
const reason = { request: "stop export" };
let closeCalls = 0;
openMock.mockImplementation(async (...args) => {
const handle = await actualFs.open(...args);
if (String(args[0]) !== input.capture_path) return handle;
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
closeCalls += 1;
if (closeCalls === 1) {
controller.abort(reason);
throw new Error("injected capture close failure");
}
await close();
});
return handle;
});
const result = await exportWebScripts(input, resources, {
signal: controller.signal,
});
if (result.ok) throw new Error("Expected cancellation");
expect(closeCalls).toBe(2);
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
expect(resources.hasPending).toBe(false);
});
it("identifies a missing capture file and rejected host path syntax", async () => {
const { input, resources } = await setup();
const missing = await exportWebScripts(
@@ -382,3 +382,24 @@ test("preserves the primary protocol failure when staging cleanup also fails", a
await f.provider.close();
expect(await readdir(f.root)).toEqual([]);
});
test("reports caller cancellation when staging removal fails after the archive is published", async () => {
const controller = new AbortController();
const reason = { request: "stop bugreport" };
const f = await fixture();
vi.mocked(rm).mockImplementationOnce(async () => {
controller.abort(reason);
throw new Error("cleanup denied");
});
const result = await f.provider.execute(f.request, {
signal: controller.signal,
});
if (result.ok) throw new Error("expected cancellation");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupPrimary).toMatchObject({
_tag: "AnalysisCancelledError",
});
expect(result.error.cleanupPrimary?.cause).toBe(reason);
await f.provider.close();
expect(projectAnalysisError(result.error).code).toBe("cancelled");
});
@@ -17,6 +17,7 @@ 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");
@@ -42,6 +43,7 @@ const retryFixture = async (
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 });
@@ -221,6 +223,28 @@ it("retains cancelled startup cleanup and the cancellation reason", async () =>
});
});
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);