[GH-ISSUE #5935] [Bug] JavaScript artifact reader drops the exact caller cancellation reason #3607

Closed
opened 2026-10-31 00:42:20 +00:00 by bsakaguchi · 0 comments
Owner

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

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

An executed application-service test cancelled JavaScript application analysis immediately before the source reader returned a real directory entry. The service returned ArtifactOperationError with reason cancelled. Its ArtifactReaderFailure cause contained cancellation.reason === undefined and cause === undefined, although the signal still held the exact object passed to AbortController.abort(reason).

artifactFileCancellationCause consequently returned undefined. The non-aborted service control succeeded. The cancellation result reported cleanupIncomplete: false, and awaited test resource cleanup completed. This was a controlled service-boundary reproduction with actual local files; no public CLI or MCP request was executed.

Reproduction

Place the test below at tests/boundary/filesystem/javascriptAbortReason.repro.test.ts in the source checkout with locked dependencies and built runtime available. Set REA_VERIFY_ROOT to an empty disposable directory, then run:

node node_modules/vitest/vitest.mjs run tests/boundary/filesystem/javascriptAbortReason.repro.test.ts --maxWorkers=1

One focused run produced one failing cancellation identity assertion and one passing non-aborted control. The spy delegates to the original directory iterator and only synchronizes the abort after inventory and immediately before reconstruction receives its next entry; it does not replace file contents or synthesize a failure.

import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js";
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";
import { artifactFileCancellationCause } from "../../../src/artifacts/ArtifactFileOperation.js";
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";

it.each(["entry", "control"] as const)("keeps abort provenance: %s", async (phase) => {
  const root = await mkdtemp(join(process.env.REA_VERIFY_ROOT!, "input-"));
  await writeFile(join(root, "main.js"), "module.exports = 42;\n");
  const controller = new AbortController();
  const reason = Object.freeze({ requested: "stop", marker: 28 });
  let reading = false;
  let intercepted = false;
  const entries = DirectoryArtifactReader.prototype.entries;
  const spy = vi.spyOn(DirectoryArtifactReader.prototype, "entries").mockImplementation(async function* (signal, selection) {
    for await (const entry of entries.call(this, signal, selection)) {
      if (reading && phase === "entry" && !intercepted) {
        intercepted = true;
        controller.abort(reason);
      }
      yield entry;
    }
  });
  try {
    const result = await analyzeJavaScriptApplication({ input_path: root }, {
      signal: controller.signal,
      progress: { async report(update) { if (update.phase === "read_javascript_artifacts") reading = true; } },
    });
    if (phase === "control") {
      console.log(JSON.stringify({ phase, ok: result.ok }));
      expect(result.ok).toBe(true);
      return;
    }
    expect(intercepted).toBe(true);
    if (result.ok) throw new Error("Expected typed cancellation");
    const failure = result.error.cause;
    expect(failure).toBeInstanceOf(ArtifactReaderFailure);
    if (!(failure instanceof ArtifactReaderFailure)) throw new Error("Missing reader failure");
    console.log(JSON.stringify({ phase, tag: result.error._tag, reason: failure.reason,
      detail: failure.message, cancellationReasonAbsent: failure.cancellation?.reason === undefined,
      readerCauseAbsent: failure.cause === undefined,
      extractedCauseAbsent: artifactFileCancellationCause(failure) === undefined,
      signalRetainsReason: controller.signal.reason === reason,
      cleanupIncomplete: result.error.cleanupIncomplete }));
    expect(failure.reason).toBe("cancelled");
    expect(artifactFileCancellationCause(failure)).toBe(reason);
  } finally {
    spy.mockRestore();
    await rm(root, { recursive: true, force: true });
  }
});

Remove the temporary test and the empty REA_VERIFY_ROOT directory afterward. The test removes its fixture and restores the iterator in finally.

Expected behavior

The typed reader cancellation should retain the exact signal.reason so its cancellation accessor can return that value. The lifecycle contract explicitly requires preserving caller abort reasons through failure wrapping. The lifecycle test guidance requires explicit reason identity assertions.

Impact and scope

P2: the operation still cancels, but loses a required diagnostic/provenance value before downstream consumers can inspect it. A caller that retains its signal can recover signal.reason; the test confirmed that value remained intact. No artifact corruption or incomplete cleanup was observed.

The entry-checkpoint case reproduced once in the focused run. The helper also serves chunk reads and entry-verification failure handling; those additional timings are source-derived and were not executed. Natural race frequency, ASAR inputs, other platforms, and public transport delivery of cancellation diagnostics were not measured.

REA version

rea-agents 6.7.0, source revision 52415975942a4b9c53a817915b313ccc404f280e.

Runtime and platform

Node.js v26.12.0; macOS 27.0.1, Darwin arm64; Vitest 5.0.3. No external reverse-engineering engine was needed.

Target and artifact context

A synthetic local directory containing main.js with module.exports = 42;. Actual inventory and filesystem reading; no target application code executed.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Focused service test diagnostics:

{"phase":"entry","tag":"ArtifactOperationError","reason":"cancelled","detail":"JavaScript artifact reconstruction cancelled","cancellationReasonAbsent":true,"readerCauseAbsent":true,"extractedCauseAbsent":true,"signalRetainsReason":true,"cleanupIncomplete":false}
{"phase":"control","ok":true}
Tests  1 failed | 1 passed (2)
AssertionError: expected undefined to be { requested: 'stop', marker: 28 } // Object.is equality

Source-derived public reachability: the MCP handler forwards the request signal to the JavaScript application service; reconstruction passes it to this reader. The CLI boundary calls the same service with its signal. These routes were inspected, not executed through their transports.

Typed outcome or recovery expected

Keep the cancellation classification and retain the exact caller reason inside ArtifactReaderFailure.cancellation.reason and its cause accessor. Preserve actual cleanup state.

Analysis or suspected cause

The local abort helper supplies no cause or cancellation options. Its entry checkpoint runs immediately after an awaited iterator result. The failure constructor therefore creates a cancellation record with an undefined reason, and the cancellation accessor and canonical checkpoint show both the lost-value extraction and an existing checkpoint that retains signal.reason.

The service error conversion wraps that already incomplete reader failure. This finding concerns loss inside the reader, independently of outer error projection.

Additional context

Open/closed issue and PR searches included cancellation/reason terms and JavaScriptArtifactFiles. PR #5671 fixes reverse/pcap/SQLite admission; #5822 concerns IDA; #5332 concerns EVM wrapping a reader failure whose reason is still intact. Reports #4858, #5388, #4483 and #4598 concern other resource, codec or filesystem consumers. JavaScript reports #1653 and #5393 concern worker cleanup/exit precedence. None covers this reader helper.

Current upstream main 905cbaf2398c905e96a1492b6145ece13e91c3cc was fetched and source-compared: the reader helper, constructor, accessor and service conversion remain unchanged from the tested revision. That newer revision was not executed.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5935 ### 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 An executed application-service test cancelled JavaScript application analysis immediately before the source reader returned a real directory entry. The service returned `ArtifactOperationError` with reason `cancelled`. Its `ArtifactReaderFailure` cause contained `cancellation.reason === undefined` and `cause === undefined`, although the signal still held the exact object passed to `AbortController.abort(reason)`. `artifactFileCancellationCause` consequently returned `undefined`. The non-aborted service control succeeded. The cancellation result reported `cleanupIncomplete: false`, and awaited test resource cleanup completed. This was a controlled service-boundary reproduction with actual local files; no public CLI or MCP request was executed. ### Reproduction Place the test below at `tests/boundary/filesystem/javascriptAbortReason.repro.test.ts` in the source checkout with locked dependencies and built runtime available. Set `REA_VERIFY_ROOT` to an empty disposable directory, then run: ```sh node node_modules/vitest/vitest.mjs run tests/boundary/filesystem/javascriptAbortReason.repro.test.ts --maxWorkers=1 ``` One focused run produced one failing cancellation identity assertion and one passing non-aborted control. The spy delegates to the original directory iterator and only synchronizes the abort after inventory and immediately before reconstruction receives its next entry; it does not replace file contents or synthesize a failure. ```ts import { mkdtemp, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js"; import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; import { artifactFileCancellationCause } from "../../../src/artifacts/ArtifactFileOperation.js"; import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; it.each(["entry", "control"] as const)("keeps abort provenance: %s", async (phase) => { const root = await mkdtemp(join(process.env.REA_VERIFY_ROOT!, "input-")); await writeFile(join(root, "main.js"), "module.exports = 42;\n"); const controller = new AbortController(); const reason = Object.freeze({ requested: "stop", marker: 28 }); let reading = false; let intercepted = false; const entries = DirectoryArtifactReader.prototype.entries; const spy = vi.spyOn(DirectoryArtifactReader.prototype, "entries").mockImplementation(async function* (signal, selection) { for await (const entry of entries.call(this, signal, selection)) { if (reading && phase === "entry" && !intercepted) { intercepted = true; controller.abort(reason); } yield entry; } }); try { const result = await analyzeJavaScriptApplication({ input_path: root }, { signal: controller.signal, progress: { async report(update) { if (update.phase === "read_javascript_artifacts") reading = true; } }, }); if (phase === "control") { console.log(JSON.stringify({ phase, ok: result.ok })); expect(result.ok).toBe(true); return; } expect(intercepted).toBe(true); if (result.ok) throw new Error("Expected typed cancellation"); const failure = result.error.cause; expect(failure).toBeInstanceOf(ArtifactReaderFailure); if (!(failure instanceof ArtifactReaderFailure)) throw new Error("Missing reader failure"); console.log(JSON.stringify({ phase, tag: result.error._tag, reason: failure.reason, detail: failure.message, cancellationReasonAbsent: failure.cancellation?.reason === undefined, readerCauseAbsent: failure.cause === undefined, extractedCauseAbsent: artifactFileCancellationCause(failure) === undefined, signalRetainsReason: controller.signal.reason === reason, cleanupIncomplete: result.error.cleanupIncomplete })); expect(failure.reason).toBe("cancelled"); expect(artifactFileCancellationCause(failure)).toBe(reason); } finally { spy.mockRestore(); await rm(root, { recursive: true, force: true }); } }); ``` Remove the temporary test and the empty `REA_VERIFY_ROOT` directory afterward. The test removes its fixture and restores the iterator in `finally`. ### Expected behavior The typed reader cancellation should retain the exact `signal.reason` so its cancellation accessor can return that value. The [lifecycle contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L101-L110) explicitly requires preserving caller abort reasons through failure wrapping. The [lifecycle test guidance](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/testing.md#L268-L275) requires explicit reason identity assertions. ### Impact and scope P2: the operation still cancels, but loses a required diagnostic/provenance value before downstream consumers can inspect it. A caller that retains its signal can recover `signal.reason`; the test confirmed that value remained intact. No artifact corruption or incomplete cleanup was observed. The entry-checkpoint case reproduced once in the focused run. The helper also serves chunk reads and entry-verification failure handling; those additional timings are source-derived and were not executed. Natural race frequency, ASAR inputs, other platforms, and public transport delivery of cancellation diagnostics were not measured. ### REA version rea-agents 6.7.0, source revision `52415975942a4b9c53a817915b313ccc404f280e`. ### Runtime and platform Node.js v26.12.0; macOS 27.0.1, Darwin arm64; Vitest 5.0.3. No external reverse-engineering engine was needed. ### Target and artifact context A synthetic local directory containing `main.js` with `module.exports = 42;`. Actual inventory and filesystem reading; no target application code executed. ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Focused service test diagnostics: ```json {"phase":"entry","tag":"ArtifactOperationError","reason":"cancelled","detail":"JavaScript artifact reconstruction cancelled","cancellationReasonAbsent":true,"readerCauseAbsent":true,"extractedCauseAbsent":true,"signalRetainsReason":true,"cleanupIncomplete":false} {"phase":"control","ok":true} ``` ```text Tests 1 failed | 1 passed (2) AssertionError: expected undefined to be { requested: 'stop', marker: 28 } // Object.is equality ``` Source-derived public reachability: the [MCP handler](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/server/registerElectronTools.ts#L100-L118) forwards the request signal to the JavaScript application service; [reconstruction](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/javascript/JavaScriptArtifactReconstruction.ts#L124-L130) passes it to this reader. The [CLI boundary](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/cli/javascriptApplicationAnalysis.ts#L11-L27) calls the same service with its signal. These routes were inspected, not executed through their transports. ### Typed outcome or recovery expected Keep the cancellation classification and retain the exact caller reason inside `ArtifactReaderFailure.cancellation.reason` and its cause accessor. Preserve actual cleanup state. ### Analysis or suspected cause The [local abort helper](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/javascript/JavaScriptArtifactFiles.ts#L626-L632) supplies no cause or cancellation options. Its [entry checkpoint](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/javascript/JavaScriptArtifactFiles.ts#L203-L212) runs immediately after an awaited iterator result. The [failure constructor](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/ArtifactReader.ts#L198-L214) therefore creates a cancellation record with an undefined reason, and the [cancellation accessor and canonical checkpoint](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/artifacts/ArtifactFileOperation.ts#L155-L172) show both the lost-value extraction and an existing checkpoint that retains `signal.reason`. The [service error conversion](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/javascript/JavaScriptApplicationService.ts#L177-L192) wraps that already incomplete reader failure. This finding concerns loss inside the reader, independently of outer error projection. ### Additional context Open/closed issue and PR searches included cancellation/reason terms and `JavaScriptArtifactFiles`. PR #5671 fixes reverse/pcap/SQLite admission; #5822 concerns IDA; #5332 concerns EVM wrapping a reader failure whose reason is still intact. Reports #4858, #5388, #4483 and #4598 concern other resource, codec or filesystem consumers. JavaScript reports #1653 and #5393 concern worker cleanup/exit precedence. None covers this reader helper. Current upstream main `905cbaf2398c905e96a1492b6145ece13e91c3cc` was fetched and source-compared: the reader helper, constructor, accessor and service conversion remain unchanged from the tested revision. That newer revision was not executed.
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#3607
No description provided.