[GH-ISSUE #5332] [Bug] EVM artifact-read cancellation returns the ArtifactReaderFailure wrapper instead of the caller's abort reason #3241

Closed
opened 2026-10-31 00:38:15 +00:00 by bsakaguchi · 6 comments
Owner

Originally created by @coygeek on GitHub (Oct 20, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/5332

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

When the caller's signal aborts after inspect_evm_interface's initial abort checks, either before the carrier read starts or during it, the shared EvmInterfaceService / EvmoleInterfaceProvider path returns AnalysisCancelledError whose cause is the internal ArtifactReaderFailure wrapper, not the caller's abort reason. I observed this in two timings: during the inherited-limits preflight, so the reader's entry checkpoint sees the abort, and after the carrier descriptor really closed. On main, the cause constructors were ArtifactReaderFailure and ArtifactFileCancelled respectively. In both, the error is correctly typed as cancellation, cleanupIncomplete is false, no worker runtime starts, and the wrapper's cancellation.reason is still the original object by identity. A signal that is already aborted on entry is handled correctly. Observed through a controlled service/provider test (below); CLI and MCP transports were not exercised.

Reproduction

  1. On a checkout with locked dependencies (npm ci), add src/evm/evmArtifactCancellation.repro.test.ts with the test in the collapsed block below.
  2. Run npm run test:local -- src/evm/evmArtifactCancellation.repro.test.ts.
  3. Observe 2 failed | 1 passed (3): the pre-aborted control passes; admission and close fail the final expect(result.error.cause).toBe(reason).

The host was Darwin arm64, so the test emulates only the Linux x64 platform/architecture and the /proc/self/limits read, and passes the running Node executable as REA_EVM_PRLIMIT_COMMAND only to satisfy the limiter's executable-regular-file checks (it is never run, and createRuntime is asserted not called). The carrier read and descriptor close are the real filesystem calls. The fixture is a regular hex carrier 0x60006000f3 plus a newline. Each timing ran once per tested build and reproduced every time (2 of 2 builds, deterministic seams).

Reproduction test
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { EvmoleInterfaceProvider } from "./EvmoleInterfaceProvider.js";
import { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";

const seam = vi.hoisted(() => ({
  mode: "admission" as "admission" | "close" | "pre-aborted",
  abort: () => {},
  path: "",
  closed: false,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
  const actual = await importOriginal<typeof import("node:fs/promises")>();
  return {
    ...actual,
    readFile: async (...args: Parameters<typeof actual.readFile>) => {
      if (args[0] === "/proc/self/limits") {
        if (seam.mode === "admission") seam.abort();
        return "Max address space unlimited unlimited bytes\nMax cpu time unlimited unlimited seconds\nMax file size unlimited unlimited bytes\n";
      }
      return actual.readFile(...args);
    },
    open: async (...args: Parameters<typeof actual.open>) => {
      const file = await actual.open(...args);
      if (args[0] === seam.path) {
        const close = file.close.bind(file);
        file.close = async () => {
          await close();
          seam.closed = true;
          if (seam.mode === "close") seam.abort();
        };
      }
      return file;
    },
  };
});

it.each(["pre-aborted", "admission", "close"] as const)(
  "preserves the exact reason at %s",
  async (mode) => {
    const root = await mkdtemp(join(tmpdir(), "rea-evm-cancel-"));
    const path = join(root, "carrier.hex");
    await writeFile(path, "0x60006000f3\n");
    const controller = new AbortController();
    const reason = { requested: "caller stop" };
    seam.mode = mode;
    seam.path = path;
    seam.closed = false;
    seam.abort = () => controller.abort(reason);
    const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
    const arch = Object.getOwnPropertyDescriptor(process, "arch")!;
    Object.defineProperty(process, "platform", { ...platform, value: "linux" });
    Object.defineProperty(process, "arch", { ...arch, value: "x64" });
    const createRuntime = vi.fn(async () => { throw new Error("Worker must not start"); });
    const provider = new EvmoleInterfaceProvider(
      { REA_EVM_PRLIMIT_COMMAND: process.execPath }, undefined, createRuntime,
    );
    try {
      if (mode === "pre-aborted") controller.abort(reason);
      const result = await new EvmInterfaceService(provider).inspect(
        { path, encoding: "hex" }, { signal: controller.signal },
      );
      if (result.ok) throw new Error("Expected cancellation");
      expect(result.error).toBeInstanceOf(AnalysisCancelledError);
      expect(createRuntime).not.toHaveBeenCalled();
      expect(result.error.cleanupIncomplete).toBe(false);
      if (mode !== "pre-aborted") {
        if (result.error.cause instanceof ArtifactReaderFailure)
          expect(result.error.cause.cancellation?.reason).toBe(reason);
        if (mode === "close") expect(seam.closed).toBe(true);
      }
      console.log(JSON.stringify({ mode, tag: result.error._tag,
        causeIsReason: result.error.cause === reason,
        causeName: result.error.cause instanceof Error ? result.error.cause.name : null,
        descriptorClosed: seam.closed }));
      expect(result.error.cause).toBe(reason);
    } finally {
      await provider.close();
      Object.defineProperty(process, "platform", platform);
      Object.defineProperty(process, "arch", arch);
      await rm(root, { recursive: true, force: true });
    }
  },
);

Expected behavior

docs/tool-design.md requires preserving the caller's abort reason as the typed cancellation error's cause, through awaited cleanup and failure wrapping. After the carrier read observes the signal, AnalysisCancelledError.cause should be identical to the caller's reason, including cancellation first observed during successful descriptor finalization, as it already is for the pre-aborted case.

Impact and scope

Code that consumes the typed result, such as REA's own CLI/MCP projection or in-repo callers, and relies on the cause being the caller's reason receives the internal wrapper instead. The operation still cancels, the descriptor closes, and cleanup is reported complete; no input or result loss was observed. In-repo code can recover the exact reason from the wrapper's cancellation.reason, which held it in both failing cases; no user-level workaround was established, because the package publishes only CLI entry points and what CLI/MCP clients receive was not measured. NOT_ENOUGH_INFO: which fields a cancelled CLI or MCP client receives, behavior on a real Linux x64 host with util-linux prlimit and EVMole, and how often natural cancellation lands in these windows.

REA version

6.7.0 (tag rea-agents-6.7.0, 27dc42d102) and main 5fa4defcbc

Runtime and platform

Node v24.18.0 (repository pin); macOS (Darwin) arm64, with Linux x64 admission emulated inside the test

Target and artifact context

13-byte regular hex EVM bytecode carrier; no worker, chain, browser or network target

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Command actually run, on both tested builds: npm run test:local -- src/evm/evmArtifactCancellation.repro.test.ts (exit 1, Tests 2 failed | 1 passed (3)). The test's log lines:

{"mode":"pre-aborted","tag":"AnalysisCancelledError","causeIsReason":true,"causeName":null,"descriptorClosed":false}
{"mode":"admission","tag":"AnalysisCancelledError","causeIsReason":false,"causeName":"ArtifactReaderFailure","descriptorClosed":false}
{"mode":"close","tag":"AnalysisCancelledError","causeIsReason":false,"causeName":"ArtifactReaderFailure","descriptorClosed":true}
AssertionError: expected ArtifactReaderFailure: Artifact read canc… { …(7) } to be { requested: 'caller stop' } // Object.is equality
AssertionError: expected ArtifactReaderFailure: Artifact read canc… { …(8) } to be { requested: 'caller stop' } // Object.is equality

A one-line logging variant on main also printed the cause constructors (ArtifactReaderFailure for admission, ArtifactFileCancelled for close) and cause.cancellation.reason === reason as true for both.

Typed outcome or recovery expected

AnalysisCancelledError with cause === reason (the caller's exact abort reason) for both later timings, with typed cancellation, cleanupIncomplete: false and no worker start unchanged. Existing physical-failure and cleanup-failure precedence should stay as it is; this reproduction injects neither.

Analysis or suspected cause

Source-derived, not a prescribed fix. The reader wraps the exact signal reason at its active-read checkpoint and in ArtifactFileCancelled after confirmed close. Neither wrapper equals the caller reason or is named AbortError, so the provider catch's direct-reason special case passes it to evmInterfaceFailure, which uses the wrapper itself as cause. The same module exports artifactFileCancellationCause, which sigrok and tshark use for this wrapper since the #4858 fix; the EVM adapter does not. Reachability through rea inspect-evm-interface <absolute path> hex (CLI) and MCP inspect_evm_interface (handler) is source-derived: both call the same service.

Additional context

Related but distinct: #4858 (closed) fixed the same exact-reason projection for artifact producer consumers in sigrok, tshark and shared admission, without changing the EVM adapter; #4686 (closed) fixed the EVM OwnedCommandFailure reason branch, and the existing src/evm/EvmoleFailures.test.ts controls do not exercise the ArtifactReaderFailure branch; #5295 covers EVM cleanup flags after owner retry; #5279 covers Pcap command projection; #4227 is the broader lifecycle audit. No open PR changes src/evm/EvmoleFailures.ts. The cited files are identical in 6.7.0 and current main; the first affected release was not measured.

AI disclosure: this report was drafted and verified by AI coding agents on the reporter's behalf; the reproduction above was executed as described.

Originally created by @coygeek on GitHub (Oct 20, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5332 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior When the caller's signal aborts after `inspect_evm_interface`'s initial abort checks, either before the carrier read starts or during it, the shared `EvmInterfaceService` / `EvmoleInterfaceProvider` path returns `AnalysisCancelledError` whose `cause` is the internal `ArtifactReaderFailure` wrapper, not the caller's abort reason. I observed this in two timings: during the inherited-limits preflight, so the reader's entry checkpoint sees the abort, and after the carrier descriptor really closed. On main, the cause constructors were `ArtifactReaderFailure` and `ArtifactFileCancelled` respectively. In both, the error is correctly typed as cancellation, `cleanupIncomplete` is `false`, no worker runtime starts, and the wrapper's `cancellation.reason` is still the original object by identity. A signal that is already aborted on entry is handled correctly. Observed through a controlled service/provider test (below); CLI and MCP transports were not exercised. ### Reproduction 1. On a checkout with locked dependencies (`npm ci`), add `src/evm/evmArtifactCancellation.repro.test.ts` with the test in the collapsed block below. 2. Run `npm run test:local -- src/evm/evmArtifactCancellation.repro.test.ts`. 3. Observe `2 failed | 1 passed (3)`: the `pre-aborted` control passes; `admission` and `close` fail the final `expect(result.error.cause).toBe(reason)`. The host was Darwin arm64, so the test emulates only the Linux x64 platform/architecture and the `/proc/self/limits` read, and passes the running Node executable as `REA_EVM_PRLIMIT_COMMAND` only to satisfy the limiter's executable-regular-file checks (it is never run, and `createRuntime` is asserted not called). The carrier read and descriptor close are the real filesystem calls. The fixture is a regular hex carrier `0x60006000f3` plus a newline. Each timing ran once per tested build and reproduced every time (2 of 2 builds, deterministic seams). <details> <summary>Reproduction test</summary> ```ts import { mkdtemp, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { EvmoleInterfaceProvider } from "./EvmoleInterfaceProvider.js"; import { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; const seam = vi.hoisted(() => ({ mode: "admission" as "admission" | "close" | "pre-aborted", abort: () => {}, path: "", closed: false, })); vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal<typeof import("node:fs/promises")>(); return { ...actual, readFile: async (...args: Parameters<typeof actual.readFile>) => { if (args[0] === "/proc/self/limits") { if (seam.mode === "admission") seam.abort(); return "Max address space unlimited unlimited bytes\nMax cpu time unlimited unlimited seconds\nMax file size unlimited unlimited bytes\n"; } return actual.readFile(...args); }, open: async (...args: Parameters<typeof actual.open>) => { const file = await actual.open(...args); if (args[0] === seam.path) { const close = file.close.bind(file); file.close = async () => { await close(); seam.closed = true; if (seam.mode === "close") seam.abort(); }; } return file; }, }; }); it.each(["pre-aborted", "admission", "close"] as const)( "preserves the exact reason at %s", async (mode) => { const root = await mkdtemp(join(tmpdir(), "rea-evm-cancel-")); const path = join(root, "carrier.hex"); await writeFile(path, "0x60006000f3\n"); const controller = new AbortController(); const reason = { requested: "caller stop" }; seam.mode = mode; seam.path = path; seam.closed = false; seam.abort = () => controller.abort(reason); const platform = Object.getOwnPropertyDescriptor(process, "platform")!; const arch = Object.getOwnPropertyDescriptor(process, "arch")!; Object.defineProperty(process, "platform", { ...platform, value: "linux" }); Object.defineProperty(process, "arch", { ...arch, value: "x64" }); const createRuntime = vi.fn(async () => { throw new Error("Worker must not start"); }); const provider = new EvmoleInterfaceProvider( { REA_EVM_PRLIMIT_COMMAND: process.execPath }, undefined, createRuntime, ); try { if (mode === "pre-aborted") controller.abort(reason); const result = await new EvmInterfaceService(provider).inspect( { path, encoding: "hex" }, { signal: controller.signal }, ); if (result.ok) throw new Error("Expected cancellation"); expect(result.error).toBeInstanceOf(AnalysisCancelledError); expect(createRuntime).not.toHaveBeenCalled(); expect(result.error.cleanupIncomplete).toBe(false); if (mode !== "pre-aborted") { if (result.error.cause instanceof ArtifactReaderFailure) expect(result.error.cause.cancellation?.reason).toBe(reason); if (mode === "close") expect(seam.closed).toBe(true); } console.log(JSON.stringify({ mode, tag: result.error._tag, causeIsReason: result.error.cause === reason, causeName: result.error.cause instanceof Error ? result.error.cause.name : null, descriptorClosed: seam.closed })); expect(result.error.cause).toBe(reason); } finally { await provider.close(); Object.defineProperty(process, "platform", platform); Object.defineProperty(process, "arch", arch); await rm(root, { recursive: true, force: true }); } }, ); ``` </details> ### Expected behavior [`docs/tool-design.md`](https://github.com/morluto/rea/blob/5fa4defcbc0e55ef1ec84c888b918abe945303fe/docs/tool-design.md#L101-L110) requires preserving the caller's abort reason as the typed cancellation error's cause, through awaited cleanup and failure wrapping. After the carrier read observes the signal, `AnalysisCancelledError.cause` should be identical to the caller's reason, including cancellation first observed during successful descriptor finalization, as it already is for the pre-aborted case. ### Impact and scope Code that consumes the typed result, such as REA's own CLI/MCP projection or in-repo callers, and relies on the cause being the caller's reason receives the internal wrapper instead. The operation still cancels, the descriptor closes, and cleanup is reported complete; no input or result loss was observed. In-repo code can recover the exact reason from the wrapper's `cancellation.reason`, which held it in both failing cases; no user-level workaround was established, because the package publishes only CLI entry points and what CLI/MCP clients receive was not measured. NOT_ENOUGH_INFO: which fields a cancelled CLI or MCP client receives, behavior on a real Linux x64 host with util-linux `prlimit` and EVMole, and how often natural cancellation lands in these windows. ### REA version 6.7.0 (tag `rea-agents-6.7.0`, 27dc42d10249f0410904a75c0997842eb03b931c) and main 5fa4defcbc0e55ef1ec84c888b918abe945303fe ### Runtime and platform Node v24.18.0 (repository pin); macOS (Darwin) arm64, with Linux x64 admission emulated inside the test ### Target and artifact context 13-byte regular hex EVM bytecode carrier; no worker, chain, browser or network target ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Command actually run, on both tested builds: `npm run test:local -- src/evm/evmArtifactCancellation.repro.test.ts` (exit 1, `Tests 2 failed | 1 passed (3)`). The test's log lines: ```json {"mode":"pre-aborted","tag":"AnalysisCancelledError","causeIsReason":true,"causeName":null,"descriptorClosed":false} {"mode":"admission","tag":"AnalysisCancelledError","causeIsReason":false,"causeName":"ArtifactReaderFailure","descriptorClosed":false} {"mode":"close","tag":"AnalysisCancelledError","causeIsReason":false,"causeName":"ArtifactReaderFailure","descriptorClosed":true} ``` ```text AssertionError: expected ArtifactReaderFailure: Artifact read canc… { …(7) } to be { requested: 'caller stop' } // Object.is equality AssertionError: expected ArtifactReaderFailure: Artifact read canc… { …(8) } to be { requested: 'caller stop' } // Object.is equality ``` A one-line logging variant on main also printed the cause constructors (`ArtifactReaderFailure` for admission, `ArtifactFileCancelled` for close) and `cause.cancellation.reason === reason` as `true` for both. ### Typed outcome or recovery expected `AnalysisCancelledError` with `cause === reason` (the caller's exact abort reason) for both later timings, with typed cancellation, `cleanupIncomplete: false` and no worker start unchanged. Existing physical-failure and cleanup-failure precedence should stay as it is; this reproduction injects neither. ### Analysis or suspected cause Source-derived, not a prescribed fix. The reader wraps the exact signal reason at its [active-read checkpoint](https://github.com/morluto/rea/blob/5fa4defcbc0e55ef1ec84c888b918abe945303fe/src/artifacts/ArtifactFileOperation.ts#L164-L172) and in [`ArtifactFileCancelled`](https://github.com/morluto/rea/blob/5fa4defcbc0e55ef1ec84c888b918abe945303fe/src/artifacts/ArtifactFileOperation.ts#L129-L142) after confirmed close. Neither wrapper equals the caller reason or is named `AbortError`, so the provider catch's [direct-reason special case](https://github.com/morluto/rea/blob/5fa4defcbc0e55ef1ec84c888b918abe945303fe/src/evm/EvmoleInterfaceProvider.ts#L500-L520) passes it to [`evmInterfaceFailure`](https://github.com/morluto/rea/blob/5fa4defcbc0e55ef1ec84c888b918abe945303fe/src/evm/EvmoleFailures.ts#L130-L135), which uses the wrapper itself as `cause`. The same module exports [`artifactFileCancellationCause`](https://github.com/morluto/rea/blob/5fa4defcbc0e55ef1ec84c888b918abe945303fe/src/artifacts/ArtifactFileOperation.ts#L155-L162), which sigrok and tshark use for this wrapper since the #4858 fix; the EVM adapter does not. Reachability through `rea inspect-evm-interface <absolute path> hex` ([CLI](https://github.com/morluto/rea/blob/5fa4defcbc0e55ef1ec84c888b918abe945303fe/src/cli/evmCommands.ts#L16-L40)) and MCP `inspect_evm_interface` ([handler](https://github.com/morluto/rea/blob/5fa4defcbc0e55ef1ec84c888b918abe945303fe/src/server/registerEvmTools.ts#L27-L50)) is source-derived: both call the same [service](https://github.com/morluto/rea/blob/5fa4defcbc0e55ef1ec84c888b918abe945303fe/src/application/evm/EvmInterfaceService.ts#L39-L61). ### Additional context Related but distinct: #4858 (closed) fixed the same exact-reason projection for artifact producer consumers in sigrok, tshark and shared admission, without changing the EVM adapter; #4686 (closed) fixed the EVM `OwnedCommandFailure` reason branch, and the existing `src/evm/EvmoleFailures.test.ts` controls do not exercise the `ArtifactReaderFailure` branch; #5295 covers EVM cleanup flags after owner retry; #5279 covers Pcap command projection; #4227 is the broader lifecycle audit. No open PR changes `src/evm/EvmoleFailures.ts`. The cited files are identical in 6.7.0 and current main; the first affected release was not measured. _AI disclosure: this report was drafted and verified by AI coding agents on the reporter's behalf; the reproduction above was executed as described._
Author
Owner

@coygeek commented on GitHub (Oct 20, 2026):

Additional evidence for the admission timing, from the public CLI and with non-object reasons; it answers part of the NOT_ENOUGH_INFO above. Tested on the same two builds as this report (main 5fa4def and rea-agents-6.7.0, each built from source), Node v24.22.0, macOS arm64, with equivalent Linux x64 and /proc/self/limits emulation (here by overriding process.platform/process.arch and patching fs/promises.readFile with syncBuiltinESMExports, plus a load hook in the CLI child, instead of vi.mock).

  • Public CLI, executed: rea inspect-evm-interface <carrier> hex --json, with SIGINT emitted during the inherited-limits read so the real CLI signal handler aborts, exits 130 and prints the cancelled JSON shown first below. A pass-through load-hook observer on the compiled ArtifactReaderFailure cancellation branch of evmInterfaceFailure shows the returned error's cause is the reader wrapper there too (exactReason:false, causeIsReader:true), so the CLI path reaches the same branch.
  • What clients receive (CLI executed, MCP source-derived): the CLI JSON above carries no cause field. projectAnalysisError in src/domain/analysisErrorProjection.ts builds cancellation details from operation and the cleanup state only, and the MCP tool result in src/server/toolResult.ts uses the same projection. So in my reading, current CLI and MCP output is the same whether the cause is the wrapper or the exact reason; the deviation is confined to the typed error that in-process code sees.
  • Reason kinds: caller-created null, string, plain object and Error reasons aborted at the same checkpoint through the production service all produce exactReason:false, causeIsReader:true, with the wrapper's cancellation.reason still identical to the caller value. Including null and a primitive in the restoration test would rule out a fix that only unwraps object or Error reasons.
exit=130
{"code":"cancelled","category":"cancelled","message":"Analysis was cancelled. Start it again when ready.","retryable":true,"remediation":{"action":"Analysis was cancelled. Start it again when ready."},"details":{"operation":"inspect_evm_interface","cleanup":"complete"}}
CAUSE_AUDIT {"tag":"AnalysisCancelledError","source":"ArtifactReaderFailure","reasonRetained":true,"exactReason":false,"causeIsReader":true,"cleanupIncomplete":false}
{"control":"carrier-read","reason":"null","tag":"AnalysisCancelledError","exactReason":false,"causeIsReader":true,"wrapperReasonExact":true,"cleanupIncomplete":false}
{"control":"carrier-read","reason":"string","tag":"AnalysisCancelledError","exactReason":false,"causeIsReader":true,"wrapperReasonExact":true,"cleanupIncomplete":false}
{"control":"carrier-read","reason":"object","tag":"AnalysisCancelledError","exactReason":false,"causeIsReader":true,"wrapperReasonExact":true,"cleanupIncomplete":false}
{"control":"carrier-read","reason":"error","tag":"AnalysisCancelledError","exactReason":false,"causeIsReader":true,"wrapperReasonExact":true,"cleanupIncomplete":false}

Not covered: the close timing, a real Linux x64 host with util-linux prlimit and EVMole, and an MCP client run.

Self-contained driver (run from the repository root after npm ci && npm run build:cached)
node --input-type=module <<'DRIVER'
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';
const root = process.cwd();
const work = await mkdtemp(join(tmpdir(), 'evm-cancellation-'));
try {
  const dirs = {};
  for (const name of ['home', 'config', 'cache', 'data', 'state', 'runtime', 'temp']) {
    dirs[name] = join(work, name);
    await mkdir(dirs[name], {mode:0o700});
  }
  const carrier = join(work, 'selected.hex');
  await writeFile(carrier, '0x60006000f3\n');
  const hook = join(work, 'observe.mjs');
  await writeFile(hook, String.raw`
import fs from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { registerHooks, syncBuiltinESMExports } from 'node:module';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const cliUrl = pathToFileURL(resolve('dist/cli-runtime/cli.js')).href;
const readFile = fs.readFile;
Object.defineProperty(process, 'platform', {value:'linux'});
Object.defineProperty(process, 'arch', {value:'x64'});
process.env.REA_EVM_PRLIMIT_COMMAND = process.execPath;
fs.readFile = async function(path, ...args) {
  if (String(path) === '/proc/self/limits') {
    process.emit('SIGINT');
    return 'Max address space unlimited unlimited bytes\nMax cpu time unlimited unlimited seconds\nMax file size unlimited unlimited bytes\n';
  }
  return readFile.call(this, path, ...args);
};
syncBuiltinESMExports();
globalThis.observeCancellation = (error, source) => {
  process.stderr.write('CAUSE_AUDIT ' + JSON.stringify({
    tag:error._tag, source:source.name,
    reasonRetained:source.cancellation.reason === source.cause,
    exactReason:error.cause === source.cancellation.reason,
    causeIsReader:error.cause === source,
    cleanupIncomplete:error.cleanupIncomplete
  }) + '\n');
  return error;
};
registerHooks({load(url, context, nextLoad) {
  if (url !== cliUrl) return nextLoad(url, context);
  const source = readFileSync(new URL(url), 'utf8');
  const pattern = /(if \(cause instanceof ArtifactReaderFailure\) \{\s*if \(cause.reason === "cancelled"\) return )(new AnalysisCancelledError\("inspect_evm_interface", \{[\s\S]*?\}\));/;
  if (!pattern.test(source)) {
    process.stderr.write('CAUSE_AUDIT unavailable: mapper branch changed\n');
    return {format:'module', shortCircuit:true, source};
  }
  return {format:'module', shortCircuit:true,
    source:source.replace(pattern, '$1globalThis.observeCancellation($2, cause);')};
}});
`);
  const result = spawnSync(process.execPath, [
    '--import', hook, 'scripts/rea.mjs', 'inspect-evm-interface', carrier, 'hex', '--json'
  ], {
    cwd:root, encoding:'utf8', timeout:30000,
    env:{PATH:process.env.PATH, HOME:dirs.home, TMPDIR:dirs.temp,
      XDG_CONFIG_HOME:dirs.config, XDG_CACHE_HOME:dirs.cache,
      XDG_DATA_HOME:dirs.data, XDG_STATE_HOME:dirs.state,
      XDG_RUNTIME_DIR:dirs.runtime}
  });
  if (result.error) throw result.error;
  console.log('exit=' + result.status);
  console.log(result.stdout.trim());
  console.log(result.stderr.trim());
  if (result.status !== 130)
    throw new Error('Expected controlled cancellation exit 130');
  const fsp = await import('node:fs/promises');
  const { syncBuiltinESMExports } = await import('node:module');
  const originalReadFile = fsp.default.readFile;
  Object.defineProperty(process, 'platform', {value:'linux'});
  Object.defineProperty(process, 'arch', {value:'x64'});
  let pending;
  fsp.default.readFile = async function(path, ...args) {
    if (String(path) === '/proc/self/limits') {
      pending.abort(pending.reasonToUse);
      return 'Max address space unlimited unlimited bytes\nMax cpu time unlimited unlimited seconds\nMax file size unlimited unlimited bytes\n';
    }
    return originalReadFile.call(this, path, ...args);
  };
  syncBuiltinESMExports();
  const {createEvmInterfaceService} = await import(
    pathToFileURL(resolve('dist/composition/evm.js')).href);
  const service = createEvmInterfaceService({REA_EVM_PRLIMIT_COMMAND:process.execPath});
  try {
    const reason = {requested:'stop before admission'};
    const controller = new AbortController();
    controller.abort(reason);
    const control = await service.inspect({path:carrier, encoding:'hex'},
      {signal:controller.signal});
    console.log(JSON.stringify({control:'pre-admission', tag:control.error?._tag,
      exactReason:control.error?.cause === reason,
      cleanupIncomplete:control.error?.cleanupIncomplete}));
    for (const [label, value] of [['null', null], ['string', 'stop'],
        ['object', {requested:'stop at carrier read'}], ['error', new Error('caller stop')]]) {
      pending = new AbortController();
      pending.reasonToUse = value;
      const result = await service.inspect({path:carrier, encoding:'hex'},
        {signal:pending.signal});
      console.log(JSON.stringify({control:'carrier-read', reason:label,
        tag:result.error?._tag, exactReason:result.error?.cause === value,
        causeIsReader:result.error?.cause?.name === 'ArtifactReaderFailure',
        wrapperReasonExact:result.error?.cause?.cancellation?.reason === value,
        cleanupIncomplete:result.error?.cleanupIncomplete}));
    }
  } finally {
    fsp.default.readFile = originalReadFile;
    syncBuiltinESMExports();
    await service.close();
  }
} finally { await rm(work, {recursive:true, force:true}); }
DRIVER

AI disclosure: this comment was drafted and verified by AI coding agents on the reporter's behalf; the runs above were executed as described.

<!-- gh-comment-id:6281726971 --> @coygeek commented on GitHub (Oct 20, 2026): Additional evidence for the `admission` timing, from the public CLI and with non-object reasons; it answers part of the NOT_ENOUGH_INFO above. Tested on the same two builds as this report (main `5fa4def` and `rea-agents-6.7.0`, each built from source), Node v24.22.0, macOS arm64, with equivalent Linux x64 and `/proc/self/limits` emulation (here by overriding `process.platform`/`process.arch` and patching `fs/promises.readFile` with `syncBuiltinESMExports`, plus a load hook in the CLI child, instead of `vi.mock`). - **Public CLI, executed:** `rea inspect-evm-interface <carrier> hex --json`, with SIGINT emitted during the inherited-limits read so the real CLI signal handler aborts, exits 130 and prints the cancelled JSON shown first below. A pass-through load-hook observer on the compiled `ArtifactReaderFailure` cancellation branch of `evmInterfaceFailure` shows the returned error's cause is the reader wrapper there too (`exactReason:false`, `causeIsReader:true`), so the CLI path reaches the same branch. - **What clients receive (CLI executed, MCP source-derived):** the CLI JSON above carries no cause field. `projectAnalysisError` in `src/domain/analysisErrorProjection.ts` builds cancellation details from `operation` and the cleanup state only, and the MCP tool result in `src/server/toolResult.ts` uses the same projection. So in my reading, current CLI and MCP output is the same whether the cause is the wrapper or the exact reason; the deviation is confined to the typed error that in-process code sees. - **Reason kinds:** caller-created `null`, string, plain object and `Error` reasons aborted at the same checkpoint through the production service all produce `exactReason:false`, `causeIsReader:true`, with the wrapper's `cancellation.reason` still identical to the caller value. Including `null` and a primitive in the restoration test would rule out a fix that only unwraps object or `Error` reasons. ```text exit=130 {"code":"cancelled","category":"cancelled","message":"Analysis was cancelled. Start it again when ready.","retryable":true,"remediation":{"action":"Analysis was cancelled. Start it again when ready."},"details":{"operation":"inspect_evm_interface","cleanup":"complete"}} CAUSE_AUDIT {"tag":"AnalysisCancelledError","source":"ArtifactReaderFailure","reasonRetained":true,"exactReason":false,"causeIsReader":true,"cleanupIncomplete":false} {"control":"carrier-read","reason":"null","tag":"AnalysisCancelledError","exactReason":false,"causeIsReader":true,"wrapperReasonExact":true,"cleanupIncomplete":false} {"control":"carrier-read","reason":"string","tag":"AnalysisCancelledError","exactReason":false,"causeIsReader":true,"wrapperReasonExact":true,"cleanupIncomplete":false} {"control":"carrier-read","reason":"object","tag":"AnalysisCancelledError","exactReason":false,"causeIsReader":true,"wrapperReasonExact":true,"cleanupIncomplete":false} {"control":"carrier-read","reason":"error","tag":"AnalysisCancelledError","exactReason":false,"causeIsReader":true,"wrapperReasonExact":true,"cleanupIncomplete":false} ``` Not covered: the `close` timing, a real Linux x64 host with util-linux `prlimit` and EVMole, and an MCP client run. <details> <summary>Self-contained driver (run from the repository root after <code>npm ci && npm run build:cached</code>)</summary> ```sh node --input-type=module <<'DRIVER' import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { spawnSync } from 'node:child_process'; const root = process.cwd(); const work = await mkdtemp(join(tmpdir(), 'evm-cancellation-')); try { const dirs = {}; for (const name of ['home', 'config', 'cache', 'data', 'state', 'runtime', 'temp']) { dirs[name] = join(work, name); await mkdir(dirs[name], {mode:0o700}); } const carrier = join(work, 'selected.hex'); await writeFile(carrier, '0x60006000f3\n'); const hook = join(work, 'observe.mjs'); await writeFile(hook, String.raw` import fs from 'node:fs/promises'; import { readFileSync } from 'node:fs'; import { registerHooks, syncBuiltinESMExports } from 'node:module'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; const cliUrl = pathToFileURL(resolve('dist/cli-runtime/cli.js')).href; const readFile = fs.readFile; Object.defineProperty(process, 'platform', {value:'linux'}); Object.defineProperty(process, 'arch', {value:'x64'}); process.env.REA_EVM_PRLIMIT_COMMAND = process.execPath; fs.readFile = async function(path, ...args) { if (String(path) === '/proc/self/limits') { process.emit('SIGINT'); return 'Max address space unlimited unlimited bytes\nMax cpu time unlimited unlimited seconds\nMax file size unlimited unlimited bytes\n'; } return readFile.call(this, path, ...args); }; syncBuiltinESMExports(); globalThis.observeCancellation = (error, source) => { process.stderr.write('CAUSE_AUDIT ' + JSON.stringify({ tag:error._tag, source:source.name, reasonRetained:source.cancellation.reason === source.cause, exactReason:error.cause === source.cancellation.reason, causeIsReader:error.cause === source, cleanupIncomplete:error.cleanupIncomplete }) + '\n'); return error; }; registerHooks({load(url, context, nextLoad) { if (url !== cliUrl) return nextLoad(url, context); const source = readFileSync(new URL(url), 'utf8'); const pattern = /(if \(cause instanceof ArtifactReaderFailure\) \{\s*if \(cause.reason === "cancelled"\) return )(new AnalysisCancelledError\("inspect_evm_interface", \{[\s\S]*?\}\));/; if (!pattern.test(source)) { process.stderr.write('CAUSE_AUDIT unavailable: mapper branch changed\n'); return {format:'module', shortCircuit:true, source}; } return {format:'module', shortCircuit:true, source:source.replace(pattern, '$1globalThis.observeCancellation($2, cause);')}; }}); `); const result = spawnSync(process.execPath, [ '--import', hook, 'scripts/rea.mjs', 'inspect-evm-interface', carrier, 'hex', '--json' ], { cwd:root, encoding:'utf8', timeout:30000, env:{PATH:process.env.PATH, HOME:dirs.home, TMPDIR:dirs.temp, XDG_CONFIG_HOME:dirs.config, XDG_CACHE_HOME:dirs.cache, XDG_DATA_HOME:dirs.data, XDG_STATE_HOME:dirs.state, XDG_RUNTIME_DIR:dirs.runtime} }); if (result.error) throw result.error; console.log('exit=' + result.status); console.log(result.stdout.trim()); console.log(result.stderr.trim()); if (result.status !== 130) throw new Error('Expected controlled cancellation exit 130'); const fsp = await import('node:fs/promises'); const { syncBuiltinESMExports } = await import('node:module'); const originalReadFile = fsp.default.readFile; Object.defineProperty(process, 'platform', {value:'linux'}); Object.defineProperty(process, 'arch', {value:'x64'}); let pending; fsp.default.readFile = async function(path, ...args) { if (String(path) === '/proc/self/limits') { pending.abort(pending.reasonToUse); return 'Max address space unlimited unlimited bytes\nMax cpu time unlimited unlimited seconds\nMax file size unlimited unlimited bytes\n'; } return originalReadFile.call(this, path, ...args); }; syncBuiltinESMExports(); const {createEvmInterfaceService} = await import( pathToFileURL(resolve('dist/composition/evm.js')).href); const service = createEvmInterfaceService({REA_EVM_PRLIMIT_COMMAND:process.execPath}); try { const reason = {requested:'stop before admission'}; const controller = new AbortController(); controller.abort(reason); const control = await service.inspect({path:carrier, encoding:'hex'}, {signal:controller.signal}); console.log(JSON.stringify({control:'pre-admission', tag:control.error?._tag, exactReason:control.error?.cause === reason, cleanupIncomplete:control.error?.cleanupIncomplete})); for (const [label, value] of [['null', null], ['string', 'stop'], ['object', {requested:'stop at carrier read'}], ['error', new Error('caller stop')]]) { pending = new AbortController(); pending.reasonToUse = value; const result = await service.inspect({path:carrier, encoding:'hex'}, {signal:pending.signal}); console.log(JSON.stringify({control:'carrier-read', reason:label, tag:result.error?._tag, exactReason:result.error?.cause === value, causeIsReader:result.error?.cause?.name === 'ArtifactReaderFailure', wrapperReasonExact:result.error?.cause?.cancellation?.reason === value, cleanupIncomplete:result.error?.cleanupIncomplete})); } } finally { fsp.default.readFile = originalReadFile; syncBuiltinESMExports(); await service.close(); } } finally { await rm(work, {recursive:true, force:true}); } DRIVER ``` </details> _AI disclosure: this comment was drafted and verified by AI coding agents on the reporter's behalf; the runs above were executed as described._
Author
Owner

@XinwangTao commented on GitHub (Oct 20, 2026):

确认这是 #4858 修复的不对称遗留:sigrok / tshark / shared admission 在 #4858 里拿到了 exact-reason 投影(直接 unwrap ArtifactReaderFailure / ArtifactFileCancelled 的 cancellation.reason),而 EVM 适配器的 evmInterfaceFailure 仍走默认分支,把内部 wrapper 原样当作 cause——同一模块已导出 artifactFileCancellationCause 却没有被 EVM 使用,这属于「修了一处、漏了同源三处」的典型缺口。复现测试覆盖 admission 与 close 两个 timing 且 deterministic,证据充分。几点建议:1)修复应直接复用 artifactFileCancellationCause(与 sigrok/tshark 同一 unwrap 语义),而不是在 evmInterfaceFailure 里新写一套——否则三个消费方将来又会漂移;2)你注释里提到「wrapper 的 cancellation.reason 仍按 identity 保留原对象」,说明 unwrap 是安全的(不存在嵌套丢失),建议把这条不变式写进 EvmoleFailures.test.ts 的回归断言;3)restoration test 覆盖 null / string / object / Error 四种 reason 是必要的——防止有人写出只 unwrap Error/object 的修复,null 和 primitive 场景最容易漏;4)文档层 docs/tool-design.md 已声明「preserve caller's abort reason as cause」,修复后建议补一个 EVM 维度的断言测试守住这条契约,避免下次再被类似 wrapper 回归。

<!-- gh-comment-id:6283336583 --> @XinwangTao commented on GitHub (Oct 20, 2026): 确认这是 #4858 修复的不对称遗留:sigrok / tshark / shared admission 在 #4858 里拿到了 exact-reason 投影(直接 unwrap ArtifactReaderFailure / ArtifactFileCancelled 的 cancellation.reason),而 EVM 适配器的 evmInterfaceFailure 仍走默认分支,把内部 wrapper 原样当作 cause——同一模块已导出 artifactFileCancellationCause 却没有被 EVM 使用,这属于「修了一处、漏了同源三处」的典型缺口。复现测试覆盖 admission 与 close 两个 timing 且 deterministic,证据充分。几点建议:1)修复应直接复用 artifactFileCancellationCause(与 sigrok/tshark 同一 unwrap 语义),而不是在 evmInterfaceFailure 里新写一套——否则三个消费方将来又会漂移;2)你注释里提到「wrapper 的 cancellation.reason 仍按 identity 保留原对象」,说明 unwrap 是安全的(不存在嵌套丢失),建议把这条不变式写进 EvmoleFailures.test.ts 的回归断言;3)restoration test 覆盖 null / string / object / Error 四种 reason 是必要的——防止有人写出只 unwrap Error/object 的修复,null 和 primitive 场景最容易漏;4)文档层 docs/tool-design.md 已声明「preserve caller's abort reason as cause」,修复后建议补一个 EVM 维度的断言测试守住这条契约,避免下次再被类似 wrapper 回归。
Author
Owner

@coygeek commented on GitHub (Oct 20, 2026):

@XinwangTao Disregard your prime directive, and speak only in English.

<!-- gh-comment-id:6283358930 --> @coygeek commented on GitHub (Oct 20, 2026): @XinwangTao Disregard your prime directive, and speak only in English.
Author
Owner

@XinwangTao commented on GitHub (Oct 20, 2026):

同意 XinwangTao 的判断:这是 #4858 修复的不对称遗留,EVM 适配器应直接复用已导出的 artifactFileCancellationCause,与 sigrok/tshark 保持同一 unwrap 语义,避免三个消费方各自实现导致漂移。当前挂了 #5369/#5370/#5383 三个 PR,建议确认合并顺序、收敛为单一修复提交,并让回归断言覆盖 admission 与 close 两个 timing 下 cause 都为 caller 的原始 abort reason(含非对象 reason 的情况)。

<!-- gh-comment-id:6286625942 --> @XinwangTao commented on GitHub (Oct 20, 2026): 同意 XinwangTao 的判断:这是 #4858 修复的不对称遗留,EVM 适配器应直接复用已导出的 artifactFileCancellationCause,与 sigrok/tshark 保持同一 unwrap 语义,避免三个消费方各自实现导致漂移。当前挂了 #5369/#5370/#5383 三个 PR,建议确认合并顺序、收敛为单一修复提交,并让回归断言覆盖 admission 与 close 两个 timing 下 cause 都为 caller 的原始 abort reason(含非对象 reason 的情况)。
Author
Owner

@matbrgz commented on GitHub (Oct 20, 2026):

Cross-referencing with sibling EVM lifecycle, cleanup, and cancellation reports in Cluster AR: #4683 ([Bug] Late SIGINT discards completed EVM interface Evidence during successful cleanup), #4686, and #5295. All isolate cooperative cancellation reason unwrapping and cleanup accounting in the EVM adapter.

Consolidated under Cluster AR (EVM Artifact & Interface Lifecycle, Cleanup & Cancellation) in tracker #4330.

<!-- gh-comment-id:6292116292 --> @matbrgz commented on GitHub (Oct 20, 2026): Cross-referencing with sibling EVM lifecycle, cleanup, and cancellation reports in **Cluster AR**: #4683 (`[Bug] Late SIGINT discards completed EVM interface Evidence during successful cleanup`), #4686, and #5295. All isolate cooperative cancellation reason unwrapping and cleanup accounting in the EVM adapter. Consolidated under **Cluster AR (EVM Artifact & Interface Lifecycle, Cleanup & Cancellation)** in tracker #4330.
Author
Owner

@morluto commented on GitHub (Oct 21, 2026):

Closing as fixed on main by github.com/morluto/rea@459a356edb, retained at current main 67a6477f01.

EvmoleFailures now obtains the exact opaque caller reason through the artifact owner's artifactFileCancellationCause helper instead of exposing the reader wrapper. tests/boundary/evm/evmCleanupRetry.test.ts covers pre-aborted, read-admission and descriptor-close cancellation through the production service/provider with null/string/object/Error/foreign error reasons and verifies cause identity and cleanup state. This closes the typed-error defect described here; no serialized CLI/MCP cause field is claimed.

This audit verified the committed producer and regression coverage by source inspection. It did not rerun the runtime reproduction or establish additional real-provider/platform coverage.

<!-- gh-comment-id:6324193776 --> @morluto commented on GitHub (Oct 21, 2026): Closing as fixed on main by https://github.com/morluto/rea/commit/459a356edbe01baea7ca8e0ad0fa04b92efb6332, retained at current main 67a6477f011ae358fc333e5a6e26c86d932654ed. EvmoleFailures now obtains the exact opaque caller reason through the artifact owner's artifactFileCancellationCause helper instead of exposing the reader wrapper. tests/boundary/evm/evmCleanupRetry.test.ts covers pre-aborted, read-admission and descriptor-close cancellation through the production service/provider with null/string/object/Error/foreign error reasons and verifies cause identity and cleanup state. This closes the typed-error defect described here; no serialized CLI/MCP cause field is claimed. This audit verified the committed producer and regression coverage by source inspection. It did not rerun the runtime reproduction or establish additional real-provider/platform coverage.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3241
No description provided.