[GH-ISSUE #5089] [Bug] observe_web_execution drops the caller's abort cause when cancelling an armed window #3125

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

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

Before submitting

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

Area

Process capture or runtime observation

Observed behavior

Reproduced at the shared application/provider boundary with a mocked CDP producer served over loopback HTTP/WebSocket. Cancelling WebRuntimeService.observe from the armed progress callback returns AnalysisCancelledError, but its cause is undefined although the caller supplied an Error to AbortController.abort(reason). The same signal still retains that exact reason. No real browser was launched.

Reproduction

In a source checkout of the revision below with its locked dependencies installed, save this test as tests/boundary/browser/callerCancellationReason.test.ts:

import { expect, it } from "vitest";
import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js";
import { WebRuntimeService } from "../../../src/application/WebRuntimeService.js";
import { startRuntimeBrowser } from "../../fixtures/webRuntime.js";

it("retains the caller's cancellation cause through the public observation service", async () => {
  const browser = await startRuntimeBrowser();
  const provider = new CdpWebRuntimeProvider();
  const service = new WebRuntimeService(provider);
  try {
    const input = { cdp_endpoint: browser.endpoint, target_id: "allowed-page", observation_ms: 10 };
    const control = await service.observe(input);
    expect(control.ok).toBe(true);
    const controller = new AbortController();
    const reason = new Error("synthetic caller cancellation");
    const result = await service.observe(input, {
      signal: controller.signal,
      progress: { report: async (update) => {
        if (update.completed === 1) controller.abort(reason);
      } },
    });
    expect(result.ok).toBe(false);
    if (result.ok) throw new Error("Expected cancellation");
    expect(result.error._tag).toBe("AnalysisCancelledError");
    const stopCount = browser.commands.filter(c => c.method === "Profiler.stopPreciseCoverage").length;
    console.log(JSON.stringify({control_ok: control.ok, error_tag: result.error._tag,
      cause_is_reason: result.error.cause === reason, cause_is_undefined: result.error.cause === undefined,
      signal_reason_is_reason: controller.signal.reason === reason, stop_precise_coverage_count: stopCount}));
    expect(stopCount).toBe(2);
    expect(result.error.cause).toBe(reason);
  } finally {
    await provider.close();
    await browser.close();
  }
});

Run with an isolated home/config directory:

WORK="$(mktemp -d)"
mkdir -p "$WORK/home" "$WORK/config" "$WORK/cache"
HOME="$WORK/home" XDG_CONFIG_HOME="$WORK/config" XDG_CACHE_HOME="$WORK/cache" node node_modules/vitest/vitest.mjs run tests/boundary/browser/callerCancellationReason.test.ts --maxWorkers=1 --no-file-parallelism

Executed once: the non-cancelled control succeeds; the cancelled call returns the correct cancellation tag; both calls send Profiler.stopPreciseCoverage. The final assertion requiring the exact abort cause fails. The test closes its fixture and provider in finally.

Expected behavior

Preserve the caller's exact signal.reason as the typed cancellation error's cause. This is an explicit lifecycle requirement. The same provider already preserves it for cancellation detected after a successful capture, in its finalization branch.

Impact and scope

Callers lose cancellation provenance from the returned error when aborting an armed execution observation. The observed defect affects diagnostics and correlation with the caller's cancellation reason; cancellation classification still works and no incorrect successful observation was returned. A caller retaining the original signal can inspect its reason separately, as the probe confirms. One cancellation and one successful control were exercised. Real-browser frequency and external CLI/MCP cause transport are NOT_ENOUGH_INFO; they were not tested.

REA version

Source SHA 3537e58c1ab29309f06eabf50bc52adba9817a0c, package version 6.7.0. Relevant application and execution-provider source is identical to tag rea-agents-6.7.0 by git diff; the published stable runtime was not executed.

Runtime and platform

Node v26.12.0; macOS 27.0.1, Darwin arm64; locked Vitest 5.0.3. Node 24 was not exercised.

Target and artifact context

Repository-owned synthetic CDP fixture on loopback; no real browser, provider application, user artifact, or external service.

Interface

Other

Shared TypeScript application/provider workflow (WebRuntimeService.observe).

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Observed probe output:

{"control_ok":true,"error_tag":"AnalysisCancelledError","cause_is_reason":false,"cause_is_undefined":true,"signal_reason_is_reason":true,"stop_precise_coverage_count":2}

Vitest: one test failed on expect(result.error.cause).toBe(reason), with undefined received instead of Error: synthetic caller cancellation. All earlier control/classification/cleanup-command assertions passed. Sending the stop command to a mocked producer does not establish cleanup on a real browser.

The armed-window abort handler constructs the cancellation error without a cause. The provider catch and finalization preserve that existing typed failure. The shared application service returns it to its caller.

Typed outcome or recovery expected

AnalysisCancelledError for observe_web_execution with error.cause === controller.signal.reason, preserving the original value's identity.

Analysis or suspected cause

Source-derived explanation consistent with the executed probe: the window's abort handler omits the cause when constructing the error, and the provider only adds signal.reason when converting a successful result into cancellation. An already failed capture passes through unchanged after cleanup.

Additional context

This report concerns lost abort-cause provenance. No caller-facing failure was established from the internal window's unset reason: active also checks endedAt, cancellation rejects the wait, and capture finally removes collection subscriptions.

Related #4261 concerns preserving completed observations on late cancellation, and #2539 concerns an Inspector timer after disconnect. Neither covers abort-cause loss during an armed execution observation. The supplied complete open/closed issue snapshot was searched for the exact classes, operation, window state, and cancellation/cause terms; no matching report was found.

Originally created by @coygeek on GitHub (Oct 18, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5089 ## 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 Process capture or runtime observation ## Observed behavior Reproduced at the shared application/provider boundary with a mocked CDP producer served over loopback HTTP/WebSocket. Cancelling `WebRuntimeService.observe` from the armed progress callback returns `AnalysisCancelledError`, but its `cause` is `undefined` although the caller supplied an Error to `AbortController.abort(reason)`. The same signal still retains that exact reason. No real browser was launched. ## Reproduction In a source checkout of the revision below with its locked dependencies installed, save this test as `tests/boundary/browser/callerCancellationReason.test.ts`: ```ts import { expect, it } from "vitest"; import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js"; import { WebRuntimeService } from "../../../src/application/WebRuntimeService.js"; import { startRuntimeBrowser } from "../../fixtures/webRuntime.js"; it("retains the caller's cancellation cause through the public observation service", async () => { const browser = await startRuntimeBrowser(); const provider = new CdpWebRuntimeProvider(); const service = new WebRuntimeService(provider); try { const input = { cdp_endpoint: browser.endpoint, target_id: "allowed-page", observation_ms: 10 }; const control = await service.observe(input); expect(control.ok).toBe(true); const controller = new AbortController(); const reason = new Error("synthetic caller cancellation"); const result = await service.observe(input, { signal: controller.signal, progress: { report: async (update) => { if (update.completed === 1) controller.abort(reason); } }, }); expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected cancellation"); expect(result.error._tag).toBe("AnalysisCancelledError"); const stopCount = browser.commands.filter(c => c.method === "Profiler.stopPreciseCoverage").length; console.log(JSON.stringify({control_ok: control.ok, error_tag: result.error._tag, cause_is_reason: result.error.cause === reason, cause_is_undefined: result.error.cause === undefined, signal_reason_is_reason: controller.signal.reason === reason, stop_precise_coverage_count: stopCount})); expect(stopCount).toBe(2); expect(result.error.cause).toBe(reason); } finally { await provider.close(); await browser.close(); } }); ``` Run with an isolated home/config directory: ```sh WORK="$(mktemp -d)" mkdir -p "$WORK/home" "$WORK/config" "$WORK/cache" HOME="$WORK/home" XDG_CONFIG_HOME="$WORK/config" XDG_CACHE_HOME="$WORK/cache" node node_modules/vitest/vitest.mjs run tests/boundary/browser/callerCancellationReason.test.ts --maxWorkers=1 --no-file-parallelism ``` Executed once: the non-cancelled control succeeds; the cancelled call returns the correct cancellation tag; both calls send `Profiler.stopPreciseCoverage`. The final assertion requiring the exact abort cause fails. The test closes its fixture and provider in `finally`. ## Expected behavior Preserve the caller's exact `signal.reason` as the typed cancellation error's `cause`. This is an explicit [lifecycle requirement](https://github.com/morluto/rea/blob/3537e58c1ab29309f06eabf50bc52adba9817a0c/docs/tool-design.md#L101-L110). The same provider already preserves it for cancellation detected after a successful capture, in [its finalization branch](https://github.com/morluto/rea/blob/3537e58c1ab29309f06eabf50bc52adba9817a0c/src/browser/execution/CdpWebRuntimeProvider.ts#L128-L135). ## Impact and scope Callers lose cancellation provenance from the returned error when aborting an armed execution observation. The observed defect affects diagnostics and correlation with the caller's cancellation reason; cancellation classification still works and no incorrect successful observation was returned. A caller retaining the original signal can inspect its `reason` separately, as the probe confirms. One cancellation and one successful control were exercised. Real-browser frequency and external CLI/MCP cause transport are NOT_ENOUGH_INFO; they were not tested. ## REA version Source SHA `3537e58c1ab29309f06eabf50bc52adba9817a0c`, package version 6.7.0. Relevant application and execution-provider source is identical to tag `rea-agents-6.7.0` by `git diff`; the published stable runtime was not executed. ## Runtime and platform Node v26.12.0; macOS 27.0.1, Darwin arm64; locked Vitest 5.0.3. Node 24 was not exercised. ## Target and artifact context Repository-owned synthetic CDP fixture on loopback; no real browser, provider application, user artifact, or external service. ## Interface Other Shared TypeScript application/provider workflow (`WebRuntimeService.observe`). ## Earliest failure stage Cleanup, cancellation, or lifecycle ## Relevant output or evidence Observed probe output: ```json {"control_ok":true,"error_tag":"AnalysisCancelledError","cause_is_reason":false,"cause_is_undefined":true,"signal_reason_is_reason":true,"stop_precise_coverage_count":2} ``` Vitest: one test failed on `expect(result.error.cause).toBe(reason)`, with `undefined` received instead of `Error: synthetic caller cancellation`. All earlier control/classification/cleanup-command assertions passed. Sending the stop command to a mocked producer does not establish cleanup on a real browser. The [armed-window abort handler](https://github.com/morluto/rea/blob/3537e58c1ab29309f06eabf50bc52adba9817a0c/src/browser/execution/CdpExecutionWindow.ts#L65-L81) constructs the cancellation error without a cause. The [provider catch and finalization](https://github.com/morluto/rea/blob/3537e58c1ab29309f06eabf50bc52adba9817a0c/src/browser/execution/CdpWebRuntimeProvider.ts#L95-L135) preserve that existing typed failure. The [shared application service](https://github.com/morluto/rea/blob/3537e58c1ab29309f06eabf50bc52adba9817a0c/src/application/WebRuntimeService.ts#L40-L68) returns it to its caller. ## Typed outcome or recovery expected `AnalysisCancelledError` for `observe_web_execution` with `error.cause === controller.signal.reason`, preserving the original value's identity. ## Analysis or suspected cause Source-derived explanation consistent with the executed probe: the window's abort handler omits the cause when constructing the error, and the provider only adds `signal.reason` when converting a successful result into cancellation. An already failed capture passes through unchanged after cleanup. ## Additional context This report concerns lost abort-cause provenance. No caller-facing failure was established from the internal window's unset `reason`: `active` also checks `endedAt`, cancellation rejects the wait, and [capture finally removes collection subscriptions](https://github.com/morluto/rea/blob/3537e58c1ab29309f06eabf50bc52adba9817a0c/src/browser/execution/CdpExecutionCapture.ts#L158-L160). Related [#4261](https://github.com/morluto/rea/issues/4261) concerns preserving completed observations on late cancellation, and [#2539](https://github.com/morluto/rea/issues/2539) concerns an Inspector timer after disconnect. Neither covers abort-cause loss during an armed execution observation. The supplied complete open/closed issue snapshot was searched for the exact classes, operation, window state, and cancellation/cause terms; no matching report was found.
Author
Owner

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

Fixed on main in 7f4e7d5577, with related runtime and historical consumers covered by 87ab054ea3 and 2e5171e391.

The producing execution window now records abort through its existing terminal failure owner and retains the exact signal reason. Discovery, handshake, commands, waits, application admission, and historical finalization preserve that reason too. First physical failures stay primary; caller/deadline arbitration uses explicit provenance rather than a late signal check. The regression drives WebRuntimeService.observe through the actual HTTP/WebSocket transport, aborts at armed progress, checks reason identity and Profiler shutdown, then completes a healthy follow-up. Error-valued, typed-error-valued, opaque-object, and default abort reasons are covered at the applicable boundaries.

Validation on pinned Node 24.18.0: 696 passed, 5 skipped across 67 selected files; full npm run check, npm run docs:check, and test discovery passed (1,632 files discovered exactly once). Controlled filesystem/final-close seams and loopback HTTP/WebSocket peers establish the described boundaries; no real Chrome/Electron target, natural OS close failure, Windows, or Bun qualification is claimed.

<!-- gh-comment-id:6260829543 --> @morluto commented on GitHub (Oct 19, 2026): Fixed on main in 7f4e7d5577adf2f4ea6edf869cbb35a43469b8d3, with related runtime and historical consumers covered by 87ab054ea384b3fcbae2998b1f94685edcd5f624 and 2e5171e391b83f8a467d0861fd452c1f78621fd8. The producing execution window now records abort through its existing terminal failure owner and retains the exact signal reason. Discovery, handshake, commands, waits, application admission, and historical finalization preserve that reason too. First physical failures stay primary; caller/deadline arbitration uses explicit provenance rather than a late signal check. The regression drives `WebRuntimeService.observe` through the actual HTTP/WebSocket transport, aborts at armed progress, checks reason identity and Profiler shutdown, then completes a healthy follow-up. Error-valued, typed-error-valued, opaque-object, and default abort reasons are covered at the applicable boundaries. Validation on pinned Node 24.18.0: 696 passed, 5 skipped across 67 selected files; full `npm run check`, `npm run docs:check`, and test discovery passed (1,632 files discovered exactly once). Controlled filesystem/final-close seams and loopback HTTP/WebSocket peers establish the described boundaries; no real Chrome/Electron target, natural OS close failure, Windows, or Bun qualification is claimed.
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#3125
No description provided.