From 0ec4826633482be4ac3bbc194cc677f50db72a87 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:18:05 +0800 Subject: [PATCH] fix(evm): preserve command owner cancellation reasons Copy the exact opaque reason already recorded by OwnedCommand rather than discarding it at EVM failure conversion. Keep physical, timeout and incomplete cleanup outcomes distinct. Validation: direct compiled probe and eight source controls; four reason controls failed before the fix. Twenty-one overlapping service/adapter checks, check:fast and docs:check passed. No real Linux x64 worker cancellation or public delivery exercised. Refs #4686. --- src/evm/EvmoleFailures.test.ts | 71 ++++++++++++++++++++++++++++++++++ src/evm/EvmoleFailures.ts | 1 + 2 files changed, 72 insertions(+) create mode 100644 src/evm/EvmoleFailures.test.ts diff --git a/src/evm/EvmoleFailures.test.ts b/src/evm/EvmoleFailures.test.ts new file mode 100644 index 0000000000..0a1f44fb2b --- /dev/null +++ b/src/evm/EvmoleFailures.test.ts @@ -0,0 +1,71 @@ +import { expect, it } from "vitest"; +import { evmInterfaceFailure } from "./EvmoleFailures.js"; +import { OwnedCommandFailure } from "../process/OwnedCommand.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js"; + +const snapshot: ProviderProcessSnapshot = { + stdout: { text: "selected output", bytes: 15, observedBytes: 15 }, + stderr: { text: "warning", bytes: 7, observedBytes: 7 }, + exitCode: 0, + signal: null, +}; + +it.each([ + undefined, + null, + "caller stopped", + { requested: "stop" }, + new AnalysisCancelledError("foreign", { + cleanup: { reason: "foreign owner", resources: ["foreign-resource"] }, + }), +])( + "retains the exact command cancellation reason as opaque data: %j", + (reason) => { + const source = new OwnedCommandFailure( + "cancelled", + "cancelled", + snapshot, + null, + { + cancellation: { reason }, + }, + ); + const result = evmInterfaceFailure(source, "worker", "/selected.hex"); + expect(result).toBeInstanceOf(AnalysisCancelledError); + expect(result.cause).toBe(reason); + expect(result.capturedOutput).toEqual({ + stdout: "selected output", + stderr: "warning", + truncated: false, + }); + expect(result.cleanupIncomplete).toBe(false); + }, +); + +it.each([ + { failure: "process" as const, cleanup: null, tag: "ProviderAdapterError" }, + { failure: "timeout" as const, cleanup: null, tag: "AnalysisTimeoutError" }, + { + failure: "cancelled" as const, + cleanup: "close refused", + tag: "ProviderAdapterError", + }, +])("keeps $tag ahead of recorded cancellation", ({ failure, cleanup, tag }) => { + const source = new OwnedCommandFailure( + failure, + "physical outcome", + snapshot, + cleanup, + { + cancellation: { reason: "later interruption" }, + }, + ["exact-owner"], + ); + const result = evmInterfaceFailure(source, "worker", "/selected.hex"); + expect(result._tag).toBe(tag); + if (cleanup !== null) { + expect(result.cleanupIncomplete).toBe(true); + expect(result.cleanupResources).toEqual(["exact-owner"]); + } +}); diff --git a/src/evm/EvmoleFailures.ts b/src/evm/EvmoleFailures.ts index da1bae8d85..683a828f1a 100644 --- a/src/evm/EvmoleFailures.ts +++ b/src/evm/EvmoleFailures.ts @@ -68,6 +68,7 @@ export const evmInterfaceFailure = ( ); if (cause.reason === "cancelled") return new AnalysisCancelledError("inspect_evm_interface", { + cause: cause.cancellation?.reason, ...outputOptions, ...cleanupOptions, });