Files
rea/src/application/javascript/JavaScriptRecoveryService.test.ts
T
087813fb88 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
2026-10-22 04:15:16 +08:00

205 lines
6.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import { err, ok } from "../../domain/result.js";
import type { JavaScriptRecoveryResult } from "../../domain/javascript/javascriptRecovery.js";
import { createAnalysisExecution } from "../AnalysisProvider.js";
import { JavaScriptRecoveryService } from "./JavaScriptRecoveryService.js";
const artifact = { path: "/tmp/bundle.js", sha256: "a".repeat(64), bytes: 20 };
const recoveryResult = (): JavaScriptRecoveryResult => ({
source: {
...artifact,
snapshot_path: "/tmp/work/inputs/bundle.js",
published_copy: artifact,
},
engine: {
id: "fixture-recovery",
version: "1",
executable: artifact,
audited_source_revision: "pinned",
executed_source_revision: null,
},
options: { extraction_mode: "structural", rewrite_level: "standard" },
status: "complete",
detected_formats: [],
reported_safety: "normal",
reported_strategy: "structural",
reported_format: "unknown",
total: 0,
failed: 0,
modules: [],
warnings: [],
report: artifact,
provenance: artifact,
manifest: artifact,
analysis_input: null,
runtime_equivalence: "unknown",
limitations: ["Not executed"],
});
const providerIdentity = {
id: "fixture-recovery",
name: "Fixture recovery",
version: "1",
};
const input = { path: artifact.path, output_directory: "/tmp/output" };
describe("JavaScript recovery workflow", () => {
it("preserves a missing provider reason", async () => {
const failure = new AnalysisCapabilityUnavailableError(
"fixture-recovery",
"recover_javascript_sources",
"fixture missing engine",
);
const result = await new JavaScriptRecoveryService({
recover: async () => err(failure),
}).recover(input);
expect(result).toEqual(err(failure));
});
it.each([
"invalid-result",
"missing-subject",
"subject-mismatch",
"engine-mismatch",
])("rejects %s returned across the port", async (mode) => {
const service = new JavaScriptRecoveryService({
recover: async () =>
ok(
createAnalysisExecution(
mode === "invalid-result" ? {} : recoveryResult(),
mode === "engine-mismatch"
? { ...providerIdentity, id: "another-engine" }
: providerIdentity,
mode === "missing-subject"
? {}
: {
subject: {
path:
mode === "subject-mismatch" ? "/wrong.js" : artifact.path,
sha256: artifact.sha256,
format: "javascript" as const,
},
},
),
),
});
const result = await service.recover(input);
if (result.ok) throw new Error("expected invalid port output");
expect(result.error._tag).toBe("AnalysisOutputError");
});
it.each(["path", "output_directory"])(
"rejects relative %s before acquiring the recovery engine",
async (field) => {
let invoked = false;
const service = new JavaScriptRecoveryService({
recover: async () => {
invoked = true;
return err(
new AnalysisCapabilityUnavailableError(
"fixture",
"recover_javascript_sources",
"engine missing",
),
);
},
});
const result = await service.recover({
...input,
[field]: "relative.js",
});
expect(invoked).toBe(false);
if (result.ok) throw new Error("expected invalid path");
expect(result.error._tag).toBe("AnalysisInputError");
expect(result.error).toMatchObject({
issues: [{ path: [field], reason: "invalid_format" }],
});
},
);
it("rejects malformed and pre-cancelled input before acquiring the port", async () => {
const service = new JavaScriptRecoveryService({
recover: async () => {
throw new Error("port must not execute");
},
});
const invalid = await service.recover({});
if (invalid.ok) throw new Error("expected malformed input");
expect(invalid.error._tag).toBe("AnalysisInputError");
const cancelled = await service.recover(input, {
signal: AbortSignal.abort(),
});
if (cancelled.ok) throw new Error("expected cancellation");
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
});
it("rejects additional_paths repeating path or each other", async () => {
const service = new JavaScriptRecoveryService({
recover: async () => {
throw new Error("port must not execute");
},
});
for (const additional_paths of [
[artifact.path],
["/tmp/dir/../bundle.js", "/tmp/bundle.js"],
["/tmp/shared.js", "/tmp/shared.js"],
]) {
const result = await service.recover({
...input,
additional_paths,
});
if (result.ok) throw new Error("expected duplicate rejection");
expect(result.error._tag).toBe("AnalysisInputError");
expect(result.error).toMatchObject({
issues: [{ path: ["additional_paths"], reason: "invalid_format" }],
});
}
});
it("rejects relative additional_paths before acquiring the port", async () => {
let invoked = false;
const service = new JavaScriptRecoveryService({
recover: async () => {
invoked = true;
return err(
new AnalysisCapabilityUnavailableError(
"fixture",
"recover_javascript_sources",
"engine missing",
),
);
},
});
const result = await service.recover({
...input,
additional_paths: ["relative-chunk.js"],
});
expect(invoked).toBe(false);
if (result.ok) throw new Error("expected invalid path");
expect(result.error).toMatchObject({
issues: [{ path: ["additional_paths"], reason: "invalid_format" }],
});
});
});
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",
});
});