fix(cli): require cancellation through command finalization

Adapt the forwarding proposal in #4652 with a required signal before optional resource diagnostics. Migrate all callers and cover actual command registration during final close. Refs #4939.

Co-authored-by: Tapraj-Pathak <165466587+Tapraj-Pathak@users.noreply.github.com>
This commit is contained in:
morlutoandTapraj-Pathak committed 2026-10-18 09:48:11 +08:00
1 parent 2ae4e1608f
commit 279c590ef6
30 files changed
+154 -20

No files matched your search

+1
View File
@@ -29,6 +29,7 @@ export const registerAdbCommands = (
{ close: () => provider.close() },
"adb",
operation,
signal,
),
),
);
+1
View File
@@ -25,6 +25,7 @@ export const registerAndroidCommands = (
{ close: () => provider.close() },
"jadx",
operation,
signal,
),
),
);
+1
View File
@@ -31,6 +31,7 @@ export const registerApktoolCommands = (
{ close: () => provider.close() },
"apktool",
operation,
signal,
),
),
);
+1
View File
@@ -48,6 +48,7 @@ export const registerBinaryDiagnosticsCommands = (
pe,
"rea-pe-resources",
"inspect_pe_resources",
signal,
),
);
}),
-2
View File
@@ -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,
);
}),
-4
View File
@@ -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,
);
}),
+2
View File
@@ -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,
);
}),
),
-1
View File
@@ -123,7 +123,6 @@ export const registerCutterCommands = (
cutter,
"cutter",
"cutter_command",
undefined,
signal,
);
}),
-4
View File
@@ -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,
);
}),
+1
View File
@@ -37,6 +37,7 @@ export const registerEvmCommands = (
service,
"evmole-interface",
"inspect_evm_interface",
signal,
),
),
),
+2 -2
View File
@@ -18,8 +18,8 @@ export const finishAnalysisCommand = async <
service: { close(): Promise<void> },
providerId: string,
canonicalOperation: string,
signal: AbortSignal,
fallbackResource?: string,
signal?: AbortSignal,
): Promise<Value | ReturnType<typeof projectAnalysisError>> => {
let result: Result<Value, AnalysisError>;
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,
-1
View File
@@ -35,7 +35,6 @@ export const registerFirmwareCommands = (
service,
operation === "inspect_firmware_regions" ? "binwalk" : "unblob",
operation,
undefined,
signal,
),
),
-1
View File
@@ -84,7 +84,6 @@ export const registerFlutterCommands = (
{ close: () => provider.close() },
"flutter",
spec.operation,
undefined,
signal,
),
),
+1
View File
@@ -32,6 +32,7 @@ export const registerGoCommands = (cli: CliInstance, logger: Logger): void => {
service,
"go-build-info",
"inspect_go_binary",
signal,
),
);
}),
+1
View File
@@ -70,6 +70,7 @@ export const registerJavaScriptRecoveryCommands = (
service,
"wakaru",
"recover_javascript_sources",
signal,
),
),
),
+1
View File
@@ -26,6 +26,7 @@ export const registerJebCommands = (
{ close: () => provider.close() },
"jeb",
operation,
signal,
),
),
);
+1
View File
@@ -26,6 +26,7 @@ export const registerPcapCommands = (
{ close: () => provider.close() },
"pcap",
operation,
signal,
),
),
);
-1
View File
@@ -32,7 +32,6 @@ export const registerRustCommands = (
service,
"rust-auditable",
"inspect_rust_binary",
undefined,
signal,
),
);
+2
View File
@@ -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,
);
}),
),
+1
View File
@@ -73,6 +73,7 @@ export const registerSqliteCommands = (
service,
"sqlite",
"inspect_sqlite_database",
signal,
),
),
),
+1
View File
@@ -76,6 +76,7 @@ export const registerWasmCommands = (
service,
"wabt-artifact",
"inspect_wasm_artifact",
signal,
),
),
),
+1
View File
@@ -66,6 +66,7 @@ export const registerWebModuleCommands = (
service,
"web-module-trace",
"trace_web_module_imports",
signal,
),
),
),
+1
View File
@@ -59,6 +59,7 @@ export const registerWebNetworkCaptureCommands = (
service,
"web-network-capture",
"inspect_web_network_capture",
signal,
"Web network capture resources",
);
},
-2
View File
@@ -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,
);
}),
+1
View File
@@ -54,6 +54,7 @@ export const registerWebSourceLocationCommands = (
service,
"web-source-location",
"trace_web_source_location",
signal,
),
),
),
@@ -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<string, () => 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: <Value>(
operation: (signal: AbortSignal) => Promise<Value>,
) => 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);
}
},
);
@@ -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,
);
@@ -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),
@@ -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",
@@ -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",