diff --git a/src/cli/adbCommands.ts b/src/cli/adbCommands.ts index 6f5d5d9eb0..b2759b7b5f 100644 --- a/src/cli/adbCommands.ts +++ b/src/cli/adbCommands.ts @@ -29,6 +29,7 @@ export const registerAdbCommands = ( { close: () => provider.close() }, "adb", operation, + signal, ), ), ); diff --git a/src/cli/androidCommands.ts b/src/cli/androidCommands.ts index 99cbee0b46..6f0c3152a9 100644 --- a/src/cli/androidCommands.ts +++ b/src/cli/androidCommands.ts @@ -25,6 +25,7 @@ export const registerAndroidCommands = ( { close: () => provider.close() }, "jadx", operation, + signal, ), ), ); diff --git a/src/cli/apktoolCommands.ts b/src/cli/apktoolCommands.ts index d870d918df..fb7e4c7c52 100644 --- a/src/cli/apktoolCommands.ts +++ b/src/cli/apktoolCommands.ts @@ -31,6 +31,7 @@ export const registerApktoolCommands = ( { close: () => provider.close() }, "apktool", operation, + signal, ), ), ); diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 502e51145f..32a2bf34c4 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -48,6 +48,7 @@ export const registerBinaryDiagnosticsCommands = ( pe, "rea-pe-resources", "inspect_pe_resources", + signal, ), ); }), diff --git a/src/cli/browserAdvancedCommands.ts b/src/cli/browserAdvancedCommands.ts index d73c53f6f4..ac1a98e162 100644 --- a/src/cli/browserAdvancedCommands.ts +++ b/src/cli/browserAdvancedCommands.ts @@ -88,7 +88,6 @@ const registerWebMcp = ( }, context.provider.identity().id, "discover_webmcp_tools", - undefined, signal, ); }), @@ -200,7 +199,6 @@ const registerScreenshot = ( }, context.provider.identity().id, "capture_web_screenshot", - undefined, signal, ); }), diff --git a/src/cli/browserCommands.ts b/src/cli/browserCommands.ts index e1bc055821..cd4cde56e4 100644 --- a/src/cli/browserCommands.ts +++ b/src/cli/browserCommands.ts @@ -75,7 +75,6 @@ const registerTargetList = ( }, context.provider.identity().id, "list_browser_targets", - undefined, signal, ); }), @@ -132,7 +131,6 @@ const registerPageInspection = ( }, context.provider.identity().id, "inspect_web_page", - undefined, signal, ); }), @@ -191,7 +189,6 @@ const registerBundleAnalysis = ( }, context.provider.identity().id, "analyze_web_bundle", - undefined, signal, ); }), @@ -243,7 +240,6 @@ const registerObservationSession = ( }, context.provider.identity().id, "observe_web_session", - undefined, signal, ); }), diff --git a/src/cli/captureCommands.ts b/src/cli/captureCommands.ts index 241edb89b6..66a4f27259 100644 --- a/src/cli/captureCommands.ts +++ b/src/cli/captureCommands.ts @@ -60,6 +60,7 @@ export const registerCaptureCommands = ( service, "tshark-capture", "inspect_packet_fields", + signal, ), ), ), @@ -112,6 +113,7 @@ export const registerCaptureCommands = ( service, "tshark-capture", "inspect_packet_capture", + signal, ); }), ), diff --git a/src/cli/cutterCommands.ts b/src/cli/cutterCommands.ts index 551c1acef3..b500308847 100644 --- a/src/cli/cutterCommands.ts +++ b/src/cli/cutterCommands.ts @@ -123,7 +123,6 @@ export const registerCutterCommands = ( cutter, "cutter", "cutter_command", - undefined, signal, ); }), diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index 02f399d21a..8cc6550ba8 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -64,7 +64,6 @@ const registerElectronFuseCommand = ( service, service.provider.identity.id, "inspect_electron_fuses", - undefined, signal, ); }), @@ -134,7 +133,6 @@ const registerElectronActiveCommand = ( }, provider.identity().id, "capture_electron_scenario", - undefined, signal, ); return "code" in result @@ -217,7 +215,6 @@ const registerElectronTargetList = ( }, "rea-cdp-electron", "list_electron_targets", - undefined, signal, ); }), @@ -259,7 +256,6 @@ const registerElectronPageInspection = ( }, "rea-cdp-electron", "inspect_electron_page", - undefined, signal, ); }), diff --git a/src/cli/evmCommands.ts b/src/cli/evmCommands.ts index b2872eea5d..a9cbe82cfb 100644 --- a/src/cli/evmCommands.ts +++ b/src/cli/evmCommands.ts @@ -37,6 +37,7 @@ export const registerEvmCommands = ( service, "evmole-interface", "inspect_evm_interface", + signal, ), ), ), diff --git a/src/cli/finishAnalysisCommand.ts b/src/cli/finishAnalysisCommand.ts index a279464399..7e4823dfca 100644 --- a/src/cli/finishAnalysisCommand.ts +++ b/src/cli/finishAnalysisCommand.ts @@ -18,8 +18,8 @@ export const finishAnalysisCommand = async < service: { close(): Promise }, providerId: string, canonicalOperation: string, + signal: AbortSignal, fallbackResource?: string, - signal?: AbortSignal, ): Promise> => { let result: Result; try { @@ -69,7 +69,7 @@ export const finishAnalysisCommand = async < ), ); } - if (result.ok && signal?.aborted === true) + if (result.ok && signal.aborted) return projectAnalysisError( new AnalysisCancelledError(canonicalOperation, { cause: signal.reason, diff --git a/src/cli/firmwareCommands.ts b/src/cli/firmwareCommands.ts index 89d3e6774b..a29cee323b 100644 --- a/src/cli/firmwareCommands.ts +++ b/src/cli/firmwareCommands.ts @@ -35,7 +35,6 @@ export const registerFirmwareCommands = ( service, operation === "inspect_firmware_regions" ? "binwalk" : "unblob", operation, - undefined, signal, ), ), diff --git a/src/cli/flutterCommands.ts b/src/cli/flutterCommands.ts index fd1f0e1967..07aa2c8cdb 100644 --- a/src/cli/flutterCommands.ts +++ b/src/cli/flutterCommands.ts @@ -84,7 +84,6 @@ export const registerFlutterCommands = ( { close: () => provider.close() }, "flutter", spec.operation, - undefined, signal, ), ), diff --git a/src/cli/goCommands.ts b/src/cli/goCommands.ts index 44807c4fbc..3aebbf461c 100644 --- a/src/cli/goCommands.ts +++ b/src/cli/goCommands.ts @@ -32,6 +32,7 @@ export const registerGoCommands = (cli: CliInstance, logger: Logger): void => { service, "go-build-info", "inspect_go_binary", + signal, ), ); }), diff --git a/src/cli/javascriptRecoveryCommands.ts b/src/cli/javascriptRecoveryCommands.ts index 0920ccb13c..dfa10ed604 100644 --- a/src/cli/javascriptRecoveryCommands.ts +++ b/src/cli/javascriptRecoveryCommands.ts @@ -70,6 +70,7 @@ export const registerJavaScriptRecoveryCommands = ( service, "wakaru", "recover_javascript_sources", + signal, ), ), ), diff --git a/src/cli/jebCommands.ts b/src/cli/jebCommands.ts index 01ba546074..18009c2f71 100644 --- a/src/cli/jebCommands.ts +++ b/src/cli/jebCommands.ts @@ -26,6 +26,7 @@ export const registerJebCommands = ( { close: () => provider.close() }, "jeb", operation, + signal, ), ), ); diff --git a/src/cli/pcapCommands.ts b/src/cli/pcapCommands.ts index e59adc691d..a2e1fc447f 100644 --- a/src/cli/pcapCommands.ts +++ b/src/cli/pcapCommands.ts @@ -26,6 +26,7 @@ export const registerPcapCommands = ( { close: () => provider.close() }, "pcap", operation, + signal, ), ), ); diff --git a/src/cli/rustCommands.ts b/src/cli/rustCommands.ts index f98218eb87..119892afb7 100644 --- a/src/cli/rustCommands.ts +++ b/src/cli/rustCommands.ts @@ -32,7 +32,6 @@ export const registerRustCommands = ( service, "rust-auditable", "inspect_rust_binary", - undefined, signal, ), ); diff --git a/src/cli/signalCommands.ts b/src/cli/signalCommands.ts index 497cd53e76..2d48b7bd9d 100644 --- a/src/cli/signalCommands.ts +++ b/src/cli/signalCommands.ts @@ -62,6 +62,7 @@ export const registerSignalCommands = ( service, "sigrok-recording", "inspect_signal_decoders", + signal, ), ), ), @@ -114,6 +115,7 @@ export const registerSignalCommands = ( service, "sigrok-recording", "decode_signal_recording", + signal, ); }), ), diff --git a/src/cli/sqliteCommands.ts b/src/cli/sqliteCommands.ts index 582fce271f..355559e422 100644 --- a/src/cli/sqliteCommands.ts +++ b/src/cli/sqliteCommands.ts @@ -73,6 +73,7 @@ export const registerSqliteCommands = ( service, "sqlite", "inspect_sqlite_database", + signal, ), ), ), diff --git a/src/cli/wasmCommands.ts b/src/cli/wasmCommands.ts index 681442b9d9..1a3217c6ac 100644 --- a/src/cli/wasmCommands.ts +++ b/src/cli/wasmCommands.ts @@ -76,6 +76,7 @@ export const registerWasmCommands = ( service, "wabt-artifact", "inspect_wasm_artifact", + signal, ), ), ), diff --git a/src/cli/webModuleCommands.ts b/src/cli/webModuleCommands.ts index a7795aa95d..fc1ab27036 100644 --- a/src/cli/webModuleCommands.ts +++ b/src/cli/webModuleCommands.ts @@ -66,6 +66,7 @@ export const registerWebModuleCommands = ( service, "web-module-trace", "trace_web_module_imports", + signal, ), ), ), diff --git a/src/cli/webNetworkCaptureCommands.ts b/src/cli/webNetworkCaptureCommands.ts index 755992e125..81a7ec4c52 100644 --- a/src/cli/webNetworkCaptureCommands.ts +++ b/src/cli/webNetworkCaptureCommands.ts @@ -59,6 +59,7 @@ export const registerWebNetworkCaptureCommands = ( service, "web-network-capture", "inspect_web_network_capture", + signal, "Web network capture resources", ); }, diff --git a/src/cli/webRuntimeCommands.ts b/src/cli/webRuntimeCommands.ts index f970078b50..c978709405 100644 --- a/src/cli/webRuntimeCommands.ts +++ b/src/cli/webRuntimeCommands.ts @@ -59,7 +59,6 @@ export const registerWebRuntimeCommands = ( }, service.provider.identity().id, "observe_web_execution", - undefined, signal, ); }), @@ -100,7 +99,6 @@ export const registerWebRuntimeCommands = ( }, service.provider.identity().id, "inspect_web_event_listeners", - undefined, signal, ); }), diff --git a/src/cli/webSourceLocationCommands.ts b/src/cli/webSourceLocationCommands.ts index 961081c2d2..fb1a91cefd 100644 --- a/src/cli/webSourceLocationCommands.ts +++ b/src/cli/webSourceLocationCommands.ts @@ -54,6 +54,7 @@ export const registerWebSourceLocationCommands = ( service, "web-source-location", "trace_web_source_location", + signal, ), ), ), diff --git a/tests/boundary/cli/commandFinalizationCancellation.test.ts b/tests/boundary/cli/commandFinalizationCancellation.test.ts new file mode 100644 index 0000000000..88454632df --- /dev/null +++ b/tests/boundary/cli/commandFinalizationCancellation.test.ts @@ -0,0 +1,130 @@ +import { Cli } from "incur"; +import { afterEach, expect, it, vi } from "vitest"; + +import { WasmArtifactService } from "../../../src/application/wasm/WasmArtifactService.js"; +import { registerWasmCommands } from "../../../src/cli/wasmCommands.js"; +import { wasmArtifactModule } from "../../../src/composition/wasm.js"; +import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { err, ok } from "../../../src/domain/result.js"; +import { silentLogger } from "../../../src/logger.js"; +import { + wasmArtifactExecution, + wasmArtifactFixture, +} from "../../fixtures/wasm/artifact.js"; + +const host = vi.hoisted(() => ({ + listeners: new Map void>(), + exitCode: undefined as number | undefined, + on(signal: string, listener: () => void) { + this.listeners.set(signal, listener); + }, + off(signal: string, listener: () => void) { + if (this.listeners.get(signal) === listener) this.listeners.delete(signal); + }, +})); + +vi.mock("../../../src/cli/commandCancellation.js", async (importOriginal) => { + const original = + await importOriginal< + typeof import("../../../src/cli/commandCancellation.js") + >(); + return { + ...original, + withCommandCancellation: ( + operation: (signal: AbortSignal) => Promise, + ) => original.withCommandCancellation(operation, host), + }; +}); + +afterEach(() => { + vi.restoreAllMocks(); + host.listeners.clear(); + host.exitCode = undefined; +}); + +it.each([ + "control", + "interrupt", + "primary-failure", + "cleanup-failure", + "interrupt-cleanup-failure", +] as const)( + "finishes the registered WASM command through final close: %s", + async (scenario) => { + const value = wasmArtifactFixture(); + const primary = new AnalysisOutputError( + "inspect_wasm_artifact", + "primary reply failure", + ); + const cleanup = new ProviderCleanupError( + "wabt-artifact", + ["exact-owned-root"], + { reason: "close refused" }, + { operation: "inspect_wasm_artifact" }, + ); + let closed = false; + const service = new WasmArtifactService({ + inspect: async () => + scenario === "primary-failure" + ? err(primary) + : ok(wasmArtifactExecution(value)), + close: async () => { + await Promise.resolve(); + if (scenario !== "control" && scenario !== "cleanup-failure") + host.listeners.get("SIGINT")?.(); + closed = true; + if ( + scenario === "cleanup-failure" || + scenario === "interrupt-cleanup-failure" + ) + throw cleanup; + }, + }); + vi.spyOn(wasmArtifactModule, "create").mockReturnValue(service); + const cli = Cli.create("finalization-test", { sync: false }); + registerWasmCommands(cli, silentLogger, {}); + let output = ""; + const previousExitCode = process.exitCode; + try { + await cli.serve( + ["inspect-wasm-artifact", value.artifact.path, "--json"], + { + stdout: (part) => { + output += part; + }, + exit: () => {}, + }, + ); + } finally { + process.exitCode = previousExitCode; + } + const result = JSON.parse(output); + expect(closed).toBe(true); + expect(host.listeners.size).toBe(0); + if (scenario === "control") { + expect(result.normalized_result).toEqual(value); + expect(host.exitCode).toBeUndefined(); + } else if (scenario === "primary-failure") { + expect(result).toMatchObject({ + code: "unreadable_output", + details: { reason: "primary reply failure" }, + }); + expect(host.exitCode).toBe(130); + } else { + expect(result).toMatchObject({ + code: scenario === "interrupt" ? "cancelled" : "cleanup_incomplete", + details: { + partial_observation: { + operation: "inspect_wasm_artifact", + normalized_result: value, + }, + ...(scenario === "interrupt" + ? {} + : { resources: ["exact-owned-root"] }), + }, + }); + if (scenario !== "cleanup-failure") expect(host.exitCode).toBe(130); + } + }, +); diff --git a/tests/boundary/cli/finishAnalysisCommand.test.ts b/tests/boundary/cli/finishAnalysisCommand.test.ts index 627a3a8f03..fa24544f9b 100644 --- a/tests/boundary/cli/finishAnalysisCommand.test.ts +++ b/tests/boundary/cli/finishAnalysisCommand.test.ts @@ -21,6 +21,7 @@ it("preserves producer-owned cleanup failure when service close does not resolve { close: async () => {} }, "fixture-provider", "inspect_web_page", + new AbortController().signal, ); expect(result).toEqual(expected); @@ -39,7 +40,6 @@ it("returns completed partial Evidence when cancellation arrives during close", }, "fixture-provider", "capture_browser_scenario", - undefined, controller.signal, ); @@ -61,7 +61,6 @@ it("preserves the primary operation error when cancellation arrives during close }, "fixture-provider", "inspect_web_page", - undefined, controller.signal, ); diff --git a/tests/boundary/cli/wasmLateCancellationCli.test.ts b/tests/boundary/cli/wasmLateCancellationCli.test.ts index ba47e1e650..c782870de4 100644 --- a/tests/boundary/cli/wasmLateCancellationCli.test.ts +++ b/tests/boundary/cli/wasmLateCancellationCli.test.ts @@ -30,6 +30,7 @@ it("delivers cancellation with completed WASM Evidence through the CLI boundary" service, "wabt-artifact", "inspect_wasm_artifact", + signal, ), { on: events.on.bind(events), diff --git a/tests/boundary/filesystem/flutterBuildProvider.test.ts b/tests/boundary/filesystem/flutterBuildProvider.test.ts index e9b61cac47..289f159ba3 100644 --- a/tests/boundary/filesystem/flutterBuildProvider.test.ts +++ b/tests/boundary/filesystem/flutterBuildProvider.test.ts @@ -684,6 +684,7 @@ it("retains failed snapshot cleanup and retries it on close", async () => { { close: () => provider.close() }, "flutter", "identify_flutter_build", + new AbortController().signal, ); expect(result).toMatchObject({ code: "cleanup_incomplete", diff --git a/tests/boundary/filesystem/webArtifactReadOwnership.test.ts b/tests/boundary/filesystem/webArtifactReadOwnership.test.ts index f679ff75e2..e378d7a38c 100644 --- a/tests/boundary/filesystem/webArtifactReadOwnership.test.ts +++ b/tests/boundary/filesystem/webArtifactReadOwnership.test.ts @@ -107,6 +107,7 @@ it("retains cancelled source read ownership until service close retries it", asy service, "web-module-artifacts", "trace_web_module_imports", + new AbortController().signal, ); expect(finalized).toMatchObject({ code: "cleanup_incomplete",