[GH-ISSUE #6032] [Bug] DMG command cancellation records the process wrapper as the caller reason #3679

Closed
opened 2026-10-31 00:43:00 +00:00 by bsakaguchi · 1 comment
Owner

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

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

Cancellation during the DMG reader's active verify command returns ArtifactReaderFailure with reason: "cancelled", but cancellation.reason is an ExecFileOutputCancelledError rather than the object supplied to AbortController.abort(reason). The original object remains at that wrapper's cause. A pre-aborted control preserves the object directly.

This was executed at the production reader/process boundary. Only the executable and arguments were substituted: a real Node child replaced hdiutil, while the system host, stoppable command helper, cancellation, child exit, and reader translation ran normally. No real DMG, hdiutil operation or CLI/MCP transport cancellation was exercised.

Reproduction

With locked dependencies installed, set REA_VERIFY_ROOT to an existing empty disposable directory. Save this test as tests/boundary/filesystem/dmgCancellationReason.test.ts and run:

node node_modules/vitest/vitest.mjs run tests/boundary/filesystem/dmgCancellationReason.test.ts --maxWorkers=1 --no-file-parallelism

The following is the DMG portion of an executed focused test, with unrelated imports and cases removed. It ran twice, on Node 26.12.0 and the repository-pinned Node 24.18.0. In each run, the active-command exact-reason assertion failed and the pre-aborted control passed. The marker synchronizes cancellation after the child has started; the child is confirmed stopped before the failing assertion.

import { access, mkdtemp, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { expect, it, vi } from "vitest";

const state = vi.hoisted(() => ({ marker: "", calls: 0 }));
vi.mock("../../../src/process/ExecFileOutput.js", async (importOriginal) => {
  const actual = await importOriginal<typeof import("../../../src/process/ExecFileOutput.js")>();
  return { ...actual, execFileOutput: (command: string, args: string[], options: any) => {
    if (command !== "/usr/bin/hdiutil" || args[0] !== "verify") throw new Error("Unexpected native command");
    state.calls++;
    return actual.execFileOutput(process.execPath, ["-e", 'require("node:fs").writeFileSync(process.argv[1], String(process.pid)); setInterval(() => {}, 1000)', state.marker], options);
  }};
});
import { NativeDmgArtifactReader } from "../../../src/artifacts/NativeDmgArtifactReader.js";
import { ExecFileOutputCancelledError } from "../../../src/process/ExecFileOutput.js";
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";

const scratch = process.env.REA_VERIFY_ROOT!;
it.each([false, true])("DMG preserves exact reason with pre-aborted=%s", async (preAborted) => {
  const parent = await mkdtemp(join(scratch, "dmg-"));
  state.marker = join(parent, "ready"); state.calls = 0;
  const controller = new AbortController(); const reason = { requested: "stop DMG" };
  if (preAborted) controller.abort(reason);
  const reader = new NativeDmgArtifactReader("fixture.dmg", {});
  const result = (async () => { for await (const _entry of reader.entries(controller.signal)) {} })().catch((cause: unknown) => cause);
  try {
    if (!preAborted) {
      const deadline = Date.now() + 10000;
      for (;;) {
        if (await access(state.marker).then(() => true, () => false)) break;
        if (Date.now() > deadline) throw new Error("Child readiness marker absent");
        await delay(10);
      }
      controller.abort(reason);
    }
    const failure = await result;
    expect(failure).toBeInstanceOf(ArtifactReaderFailure);
    const error = failure as ArtifactReaderFailure;
    let childStopped = true;
    if (!preAborted) {
      const pid = Number(await readFile(state.marker, "utf8"));
      try { process.kill(pid, 0); childStopped = false; } catch {}
      expect(childStopped).toBe(true);
      expect(error.cause).toBeInstanceOf(ExecFileOutputCancelledError);
      expect((error.cause as Error).cause).toBe(reason);
    }
    console.log(JSON.stringify({ case: "dmg", preAborted, commands: state.calls, reason: error.reason,
      exactReason: error.cancellation?.reason === reason,
      wrapper: error.cancellation?.reason instanceof ExecFileOutputCancelledError,
      wrapperRetainsReason: error.cause instanceof Error && error.cause.cause === reason, childStopped }));
    expect(error.cancellation?.reason).toBe(reason);
  } finally {
    controller.abort(reason); await result; await reader.close(); await rm(parent, { recursive: true, force: true });
  }
}, 20000);

Remove the temporary test afterward. Its finally block stops the command, closes the reader and removes its fixture.

Expected behavior

Retain the exact caller reason in ArtifactReaderFailure.cancellation.reason and its cancellation cause, whether cancellation is observed before a command or inside the command helper. The lifecycle contract explicitly requires preserving the caller reason through wrappers. The command error already retains the exact value.

Impact and scope

P2: cancellation and child cleanup succeed, but the typed provenance contract differs by observation stage. Consumers expecting the exact reason receive a process wrapper. The reason remains recoverable by inspecting the wrapper's cause, so no complete provenance loss is claimed. No corruption, leaked child or security boundary crossing was observed.

The executed case covers verify on macOS with a substituted executable. Attach uses the same host translation by source inspection but was not separately run. The info and detach paths do not pass the caller signal, so their active cancellation is not claimed. DMG extraction is unsupported at this revision; the supported public route is artifact inspection. Natural failure frequency and wire-level effects are unknown.

REA version

rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e.

Runtime and platform

macOS 27.0.1 arm64; Node.js v26.12.0 and repository-pinned v24.18.0; locked Vitest 5.0.3. No native analysis engine was needed.

Target and artifact context

Synthetic verification command represented by a real Node child. No image was attached or target code executed.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Selected executed diagnostics:

{"preAborted":false,"commands":1,"reason":"cancelled","exactReason":false,"wrapper":true,"wrapperRetainsReason":true,"childStopped":true}
{"preAborted":true,"commands":0,"reason":"cancelled","exactReason":true,"wrapper":false}

The active assertion expected the supplied object and received AbortError with tag ExecFileOutputCancelledError and cause equal to that object. Both Node versions gave the same result.

Public reachability is source-derived: the inspect-artifact handler supplies its cancellation signal to inspection, and the macOS inventory reader selection constructs this reader. No public transport request was run.

Typed outcome or recovery expected

Keep the cancelled classification and actual cleanup state, with the exact caller object as the typed cancellation reason. Retain physical command output separately.

Analysis or suspected cause

The system-host catch passes the ExecFileOutputCancelledError itself as NativeDmgCommandFailure.cause. The reader-failure constructor then records that wrapper as the cancellation reason. The cancellation accessor correctly consumes that record, so changing the outer consumer cannot repair the producing boundary. The injected-host fallback has a similar wrapper-as-cause branch; that fallback was not separately executed.

Additional context

Fetched upstream main 6d356b84344659085f2b912857b5e27fe0160c9b and compared the relevant reader, command helper and output-tree sources: no differences from the executed baseline. That newer revision was not executed.

Related reports #5935, #5944, #5959, #5960, #5964, #5985, #5987, #5998, #5999 and #6002 concern other producing checkpoints or translators. #5998 uses the same command helper but discards the reason in AppleAssetCatalogAnalysis; it does not change this DMG catch. #4638 covers shared OwnedCommand provenance, and PR #4711 changes Kuna query waits; this reader uses ExecFileOutput. #5979 concerns malformed successful hdiutil inventory output. PR #987 preserves physical command diagnostics; #1194 changes command shutdown; #3290/#4146 concern localized verification. PR #6018 addresses the WABT translator in #6002, not this DMG producer. None repairs this reason selection. Last known good revision and natural hdiutil incidence: NOT_ENOUGH_INFO.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6032 ### 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 Cancellation during the DMG reader's active verify command returns `ArtifactReaderFailure` with `reason: "cancelled"`, but `cancellation.reason` is an `ExecFileOutputCancelledError` rather than the object supplied to `AbortController.abort(reason)`. The original object remains at that wrapper's `cause`. A pre-aborted control preserves the object directly. This was executed at the production reader/process boundary. Only the executable and arguments were substituted: a real Node child replaced hdiutil, while the system host, stoppable command helper, cancellation, child exit, and reader translation ran normally. No real DMG, hdiutil operation or CLI/MCP transport cancellation was exercised. ### Reproduction With locked dependencies installed, set `REA_VERIFY_ROOT` to an existing empty disposable directory. Save this test as `tests/boundary/filesystem/dmgCancellationReason.test.ts` and run: ```sh node node_modules/vitest/vitest.mjs run tests/boundary/filesystem/dmgCancellationReason.test.ts --maxWorkers=1 --no-file-parallelism ``` The following is the DMG portion of an executed focused test, with unrelated imports and cases removed. It ran twice, on Node 26.12.0 and the repository-pinned Node 24.18.0. In each run, the active-command exact-reason assertion failed and the pre-aborted control passed. The marker synchronizes cancellation after the child has started; the child is confirmed stopped before the failing assertion. ```ts import { access, mkdtemp, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { expect, it, vi } from "vitest"; const state = vi.hoisted(() => ({ marker: "", calls: 0 })); vi.mock("../../../src/process/ExecFileOutput.js", async (importOriginal) => { const actual = await importOriginal<typeof import("../../../src/process/ExecFileOutput.js")>(); return { ...actual, execFileOutput: (command: string, args: string[], options: any) => { if (command !== "/usr/bin/hdiutil" || args[0] !== "verify") throw new Error("Unexpected native command"); state.calls++; return actual.execFileOutput(process.execPath, ["-e", 'require("node:fs").writeFileSync(process.argv[1], String(process.pid)); setInterval(() => {}, 1000)', state.marker], options); }}; }); import { NativeDmgArtifactReader } from "../../../src/artifacts/NativeDmgArtifactReader.js"; import { ExecFileOutputCancelledError } from "../../../src/process/ExecFileOutput.js"; import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; const scratch = process.env.REA_VERIFY_ROOT!; it.each([false, true])("DMG preserves exact reason with pre-aborted=%s", async (preAborted) => { const parent = await mkdtemp(join(scratch, "dmg-")); state.marker = join(parent, "ready"); state.calls = 0; const controller = new AbortController(); const reason = { requested: "stop DMG" }; if (preAborted) controller.abort(reason); const reader = new NativeDmgArtifactReader("fixture.dmg", {}); const result = (async () => { for await (const _entry of reader.entries(controller.signal)) {} })().catch((cause: unknown) => cause); try { if (!preAborted) { const deadline = Date.now() + 10000; for (;;) { if (await access(state.marker).then(() => true, () => false)) break; if (Date.now() > deadline) throw new Error("Child readiness marker absent"); await delay(10); } controller.abort(reason); } const failure = await result; expect(failure).toBeInstanceOf(ArtifactReaderFailure); const error = failure as ArtifactReaderFailure; let childStopped = true; if (!preAborted) { const pid = Number(await readFile(state.marker, "utf8")); try { process.kill(pid, 0); childStopped = false; } catch {} expect(childStopped).toBe(true); expect(error.cause).toBeInstanceOf(ExecFileOutputCancelledError); expect((error.cause as Error).cause).toBe(reason); } console.log(JSON.stringify({ case: "dmg", preAborted, commands: state.calls, reason: error.reason, exactReason: error.cancellation?.reason === reason, wrapper: error.cancellation?.reason instanceof ExecFileOutputCancelledError, wrapperRetainsReason: error.cause instanceof Error && error.cause.cause === reason, childStopped })); expect(error.cancellation?.reason).toBe(reason); } finally { controller.abort(reason); await result; await reader.close(); await rm(parent, { recursive: true, force: true }); } }, 20000); ``` Remove the temporary test afterward. Its finally block stops the command, closes the reader and removes its fixture. ### Expected behavior Retain the exact caller reason in `ArtifactReaderFailure.cancellation.reason` and its cancellation cause, whether cancellation is observed before a command or inside the command helper. The [lifecycle contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L101-L110) explicitly requires preserving the caller reason through wrappers. The [command error](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/process/ExecFileOutput.ts#L26-L35) already retains the exact value. ### Impact and scope P2: cancellation and child cleanup succeed, but the typed provenance contract differs by observation stage. Consumers expecting the exact reason receive a process wrapper. The reason remains recoverable by inspecting the wrapper's cause, so no complete provenance loss is claimed. No corruption, leaked child or security boundary crossing was observed. The executed case covers verify on macOS with a substituted executable. Attach uses the same host translation by source inspection but was not separately run. The info and detach paths do not pass the caller signal, so their active cancellation is not claimed. DMG extraction is unsupported at this revision; the supported public route is artifact inspection. Natural failure frequency and wire-level effects are unknown. ### REA version rea-agents 6.7.0; source revision `52415975942a4b9c53a817915b313ccc404f280e`. ### Runtime and platform macOS 27.0.1 arm64; Node.js v26.12.0 and repository-pinned v24.18.0; locked Vitest 5.0.3. No native analysis engine was needed. ### Target and artifact context Synthetic verification command represented by a real Node child. No image was attached or target code executed. ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Selected executed diagnostics: ```json {"preAborted":false,"commands":1,"reason":"cancelled","exactReason":false,"wrapper":true,"wrapperRetainsReason":true,"childStopped":true} {"preAborted":true,"commands":0,"reason":"cancelled","exactReason":true,"wrapper":false} ``` The active assertion expected the supplied object and received `AbortError` with tag `ExecFileOutputCancelledError` and `cause` equal to that object. Both Node versions gave the same result. Public reachability is source-derived: the [inspect-artifact handler](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/cli/artifactCommands.ts#L245-L264) supplies its cancellation signal to inspection, and the [macOS inventory reader selection](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/inventory/reader.ts#L43-L45) constructs this reader. No public transport request was run. ### Typed outcome or recovery expected Keep the cancelled classification and actual cleanup state, with the exact caller object as the typed cancellation reason. Retain physical command output separately. ### Analysis or suspected cause The [system-host catch](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/NativeDmgArtifactReader.ts#L109-L117) passes the `ExecFileOutputCancelledError` itself as `NativeDmgCommandFailure.cause`. The [reader-failure constructor](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/ArtifactReader.ts#L198-L214) then records that wrapper as the cancellation reason. The [cancellation accessor](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/ArtifactFileOperation.ts#L155-L162) correctly consumes that record, so changing the outer consumer cannot repair the producing boundary. The [injected-host fallback](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/NativeDmgArtifactReader.ts#L669-L677) has a similar wrapper-as-cause branch; that fallback was not separately executed. ### Additional context Fetched upstream main `6d356b84344659085f2b912857b5e27fe0160c9b` and compared the relevant reader, command helper and output-tree sources: no differences from the executed baseline. That newer revision was not executed. Related reports [#5935](https://github.com/morluto/rea/issues/5935), [#5944](https://github.com/morluto/rea/issues/5944), [#5959](https://github.com/morluto/rea/issues/5959), [#5960](https://github.com/morluto/rea/issues/5960), [#5964](https://github.com/morluto/rea/issues/5964), [#5985](https://github.com/morluto/rea/issues/5985), [#5987](https://github.com/morluto/rea/issues/5987), [#5998](https://github.com/morluto/rea/issues/5998), [#5999](https://github.com/morluto/rea/issues/5999) and [#6002](https://github.com/morluto/rea/issues/6002) concern other producing checkpoints or translators. [#5998](https://github.com/morluto/rea/issues/5998) uses the same command helper but discards the reason in AppleAssetCatalogAnalysis; it does not change this DMG catch. #4638 covers shared OwnedCommand provenance, and PR #4711 changes Kuna query waits; this reader uses ExecFileOutput. #5979 concerns malformed successful hdiutil inventory output. PR #987 preserves physical command diagnostics; #1194 changes command shutdown; #3290/#4146 concern localized verification. PR [#6018](https://github.com/morluto/rea/pull/6018) addresses the WABT translator in #6002, not this DMG producer. None repairs this reason selection. Last known good revision and natural hdiutil incidence: NOT_ENOUGH_INFO.
Author
Owner

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

Checked on main at d87e73f2db50be72d33a4b3c7199af35518b441c: Both DMG command cancellation translators now retain the wrapper separately and record the underlying caller value in cancellation.reason. The Darwin regression checks active/pre-aborted verify calls, translated cause and child settlement. NativeDmgArtifactReader.ts NativeDmgArtifactReader.ts NativeDmgArtifactReader.ts dmgCancellationReason.test.ts

I ran the cited regression file on this revision; all passed as part of the focused 28-file, 603-test run. Provider-facing checks here use controlled fixtures; this does not claim a run with the real analysis engine. Closing as completed on main.

<!-- gh-comment-id:6380638595 --> @coygeek commented on GitHub (Oct 24, 2026): Checked on main at `d87e73f2db50be72d33a4b3c7199af35518b441c`: Both DMG command cancellation translators now retain the wrapper separately and record the underlying caller value in cancellation.reason. The Darwin regression checks active/pre-aborted verify calls, translated cause and child settlement. [NativeDmgArtifactReader.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/artifacts/NativeDmgArtifactReader.ts#L118) [NativeDmgArtifactReader.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/artifacts/NativeDmgArtifactReader.ts#L89) [NativeDmgArtifactReader.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/artifacts/NativeDmgArtifactReader.ts#L677) [dmgCancellationReason.test.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/tests/process-global/dmgCancellationReason.test.ts#L49) I ran the cited regression file on this revision; all passed as part of the focused 28-file, 603-test run. Provider-facing checks here use controlled fixtures; this does not claim a run with the real analysis engine. Closing as completed on main.
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#3679
No description provided.