Skip to content

fix(webui): a browser frame that never arrives now says so - #28

Merged
modacker merged 5 commits into
mainfrom
fix/browser-first-frame-visibility
Nov 10, 2026
Merged

modacker merged 5 commits into
mainfrom
fix/browser-first-frame-visibility

Conversation

@modacker

@modacker modacker commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

What

A browser panel waiting for its first picture looks exactly like a browser panel whose picture never came. On the macOS CI runner that was one red case and no explanation anywhere:

packages/webui/test/integration/agent-transcript.test.ts
  > real agent rendering and recovery: transcript
Error: expect(locator).toBeVisible() failed
Locator: getByTestId('browser-panel-frame')
Timeout: 60000ms
Error: element(s) not found
{"emptyFrameStillShowing":1,"capabilityNotices":[]}

That diagnostic says the panel was still showing its empty placeholder and had no capability notice to give: the panel believed it was waiting. There was not one browser error in the log, because nothing on the path from Chrome to that panel says anything when it fails.

Five places along that path were silent:

where what was silent
provider.ts Chrome's own stderr was discarded (stdio: ["ignore","ignore","ignore"]), so the browser's account of its own startup reached nobody.
provider.ts The startup poll watched child.exitCode and never child.signalCode, so a browser killed by a signal spent the whole 30s budget and then reported "the browser did not publish a DevTools endpoint in time" — the cause was gone.
provider.ts The CDP handshake was not guarded, so a failed handshake leaked the Chrome process; the leaked process kept the profile's single-instance lock and the next start exited at once, which looked like a slow start again.
browser-service.ts The eager start's failure was swallowed by an empty catch, and the next call started the browser again from scratch with no trace that it had already failed.
wire.ts The client's frame watcher accepted event frames and dropped error frames, so the error the server did put on the wire never reached the panel.

Two more made the silence last. The frame stream had no first-frame deadline, so it hung instead of ending. And the capability matrix reads only the authorization switches, so none of absent / not-ready / unknown / unauthorized could ever light up.

Why macOS

Not a macOS defect. Three things stack up on that runner:

  1. It is the only platform that runs the browser suite headed (.github/workflows/webui-ci.yml sets WEBUI_BROWSER_HEADED=1 for macOS), so a second, permanently open browser competes with the one under test.
  2. Real Chrome is about 3.5x slower there. The suite that boots Chrome, opens two pages and waits for their first frames takes 19.2s / 21.5s on macOS against 5.3s on ubuntu.
  3. The same transcript case measures 58-81s in the green macOS runs, and once ran past 60s without a frame. The 60s wait had no headroom.

The repository already records this history. cdp-connection.ts says it plainly: on a loaded runner the browser answers a page open "in about half a minute", and "the old fifteen-second budget turned that slowness into a failure ... the panel never received a frame and the person saw an empty browser instead of a slow one."

Why nobody noticed

Four browser integration suites were skipped in their entirety on macOS — and one of them is the only coverage for a frame that keeps coming:

suite ubuntu macOS
isolated-chromium-screencast (3) ran, green skipped
browser-panel-live-navigation ran, green skipped
browser-use-real-input (2) ran, green skipped
workspace-browser-tab-acceptance (14) ran, green skipped

Each of those files kept its own copy of "where is Chrome", and each copy only knew the Linux paths. macOS keeps Chrome in /Applications, so the copy answered "no browser" and describe.skipIf(!available) dropped the whole suite. Five copies of that table existed in the repository; two of them knew about macOS, and those two did run.

How

The provider stops throwing its own diagnostics away.

  • Chrome's stderr is piped and kept (last 64 KB), written to
    chrome-stderr.log and quoted — flattened and capped — in the failure
    message.
  • The start-up poll watches child.signalCode as well as child.exitCode,
    so a browser killed by a signal is reported at once instead of after the
    whole 30s budget.
  • The CDP handshake is guarded, so a failed handshake no longer leaks the
    Chrome process — the leak used to hold the profile's single-instance lock
    and make the next start exit immediately.
  • child.on("error") is listened for, so a spawn failure is a browser error
    rather than a process-level uncaught exception.
  • A candidate has to be executable, not merely present.
  • The start-up records its marks — resolved path, spawn, DevTools endpoint,
    first target — with the browser's own version.

Measured with a fake browser, before and after:

killed by a signal   30042ms, "did not publish a DevTools endpoint in time"
                  ->    105ms, "killed by signal SIGKILL during startup
                            (exit code none) after 101ms (chrome: ...)
                            Chrome said: FATAL: the browser was killed
                            while starting"

handshake fails      the browser process is still running afterwards
                  ->   the browser process is gone

The service gives the stream a deadline and keeps a record.

The wait for the first picture had no deadline, so a stream that never
produced a frame never ended: the subscription stayed open forever and the
panel held an empty placeholder forever, with nothing to say which it was.

The stream now gives up after 60s and fails with browser_operation_timeout,
which the server already puts on the wire as an error frame. The budget is
the CDP command budget, and the reason is the repository's own record: on a
loaded runner the browser answers a page open "in about half a minute", and
the old fifteen-second budget turned that slowness into a failure. 30s would
sit on the edge of the measured common case — 19-21s for a cold boot on the
macOS runner against 5.3s on ubuntu — and 90s would outlast every other
operation on the path. The clock stops at the first frame: a running stream
is not a stuck stream.

The service also records what it saw of the browser session — eager starts
attempted and failed, every start's duration, the first frame's latency, the
dropped-frame count. The eager start's failure used to be swallowed by an
empty catch, so the next call started the browser again from scratch with
no trace that it had already failed.

The client stops dropping the refusal.

The frame watcher accepted event frames and dropped error frames, so
the error the server did put on the wire never reached the panel. The watch
now reports it the way the other watches in this package report theirs, and
the panel says so: a stream that failed shows a notice that the picture is
not coming, beside the reason the service gave. The notice renders only when
the panel failed, so the default markup is byte for byte what it was — the
test asserts exactly that, by removing the one notice from the failed markup
and comparing the result with the healthy markup.

The macOS suites can run at all.

The four suites that were skipped in their entirety on macOS now read the
provider's own candidate table through a shared helper. On a mac with Chrome
installed the four suites go from 22 skipped to 22 passed, including
keeps delivering frames while the page changes — the case that covers a
frame that keeps coming.

The provider's table is unchanged; what changed is that a candidate has to be
executable, and that the suites ask the table instead of keeping copies of
it. A machine with no browser at all still skips those suites, exactly as it
did before.

The browser diagnostics are written where the CI already collects artifacts:

$WEBUI_BROWSER_OUTPUT_DIR/chrome-startup.json
$WEBUI_BROWSER_OUTPUT_DIR/chrome-stderr.log
$WEBUI_BROWSER_OUTPUT_DIR/service-browser.json

What was not changed

No expectation, timeout or assertion was relaxed, and no case was skipped to
make a run green. The suites that now run on macOS ran with their original
assertions; the frame stream's first-frame budget is a product behaviour
(the service telling the panel that the picture is not coming), not a test
timeout.

Verification

  • The webui unit and integration gate: 302 files / 4523 tests passed, 0
    failed (the branch's base was 4485 — the new tests are 38).

  • The Playwright browser suite on this machine: 437 passed, 40 failed,
    8 skipped
    . The 40 failures are the visual baselines, and they fail
    identically on the base commit 81ab1cec on this machine — the same 40
    test names: the local Chromium draws a 0-wide scrollbar where the recorded
    baseline has 6px, so the screenshot is 6px wider. The three-platform CI run
    is the authority, not this machine.

  • The four macOS-skipped suites, before and after, on this machine:

    before   Test Files  4 skipped (4)
             Tests       22 skipped (22)
    after    Test Files  4 passed (4)
             Tests       22 passed (22)
    
  • Mutation probes: removing the new provider's signal handling, its handshake
    guard, the client's error-frame delivery or the panel's failure notice
    each turns the corresponding case red — the new assertions are not vacuous.

  • The first-frame deadline: with the throw removed from a copy, the stream
    never settles (stream settled: false), which is the behaviour the
    deadline exists to end.

probe added 5 commits November 10, 2026 23:53
…table

Five copies of "where is Chrome" lived in the browser integration suites, and
three of them only knew the Linux paths. On macOS the real browser lives in
/Applications, so those copies answered "no browser" and
describe.skipIf(!available) dropped the whole suite.

The suites that ran on ubuntu and were skipped in their entirety on macOS:

  isolated-chromium-screencast       3 cases
  browser-panel-live-navigation
  browser-use-real-input              2 cases
  workspace-browser-tab-acceptance   14 cases

isolated-chromium-screencast is the only coverage for a frame that keeps
coming — the case that would have caught a browser panel waiting forever for
a picture that was never coming.

The suites now read the provider's own candidate table
(defaultChromeCandidates()) through a shared helper, and the helper asks for
an executable rather than for a file that happens to exist. No expectation,
timeout or assertion changed: on a mac with Chrome installed the four suites
go from 22 skipped to 22 passed.
The isolated Chromium's start-up was silent about everything that could go
wrong, so a browser that never came up was indistinguishable from a browser
that was slow:

- Chrome's own stderr was discarded (stdio: ["ignore","ignore","ignore"]),
  so the browser's account of its own start-up reached nobody.
- The start-up poll watched child.exitCode and never child.signalCode, so a
  browser killed by a signal spent the whole 30s budget and then reported
  "the browser did not publish a DevTools endpoint in time" — the cause was
  gone by then.
- The CDP handshake was not guarded, so a failed handshake leaked the Chrome
  process; the leaked process kept the profile's single-instance lock and the
  next start exited at once, which looked like a slow start again.
- A spawn failure was not listened for, so it surfaced as a process-level
  uncaught exception rather than as a browser error.

Measured with a fake browser, before and after:

  killed by a signal   30042ms, "did not publish a DevTools endpoint in time"
                    ->    105ms, "killed by signal SIGKILL during startup
                              (exit code none) after 101ms (chrome: ...)
                              Chrome said: FATAL: the browser was killed
                              while starting"

  handshake fails      the browser process is still running afterwards
                    ->   the browser process is gone

The provider now keeps the browser's stderr, records the marks of a start-up
(resolved path, spawn, DevTools endpoint, first target) together with the
browser's own version, and asks for an executable rather than for a file that
exists. The start-up diagnostics are written beside the other browser
artifacts, so a CI run can carry them:

  $WEBUI_BROWSER_OUTPUT_DIR/chrome-startup.json
  $WEBUI_BROWSER_OUTPUT_DIR/chrome-stderr.log
A frame stream that never produced a frame never ended either: the wait for
the first picture had no deadline, so the service held the subscription open
forever and the panel held an empty placeholder forever. Nothing said which
of the two it was.

The stream now gives up on its first frame after 60s and fails with
browser_operation_timeout, which the server already puts on the wire as an
error frame. The budget is the CDP command budget, and the reason is the
repository's own record: on a loaded runner the browser answers a page open
"in about half a minute", and the old fifteen-second budget turned that
slowness into a failure. 30s would sit on the edge of the measured common
case — 19-21s for a cold boot on the macOS runner, 5.3s on ubuntu — and 90s
would outlast every other operation on the path. The clock stops at the first
frame: a running stream is not a stuck stream.

The service also records what it saw of the browser session: how many eager
starts were attempted and how many failed, how long every start took, how
long the first frame took, and how many frames were dropped. The eager
start's failure used to be swallowed by an empty catch, so the next call
started the browser again from scratch with no trace that it had already
failed.

The record is written beside the other browser artifacts:

  $WEBUI_BROWSER_OUTPUT_DIR/service-browser.json
The client's browser frame watcher accepted event frames and dropped error
frames, so the refusal the server does put on the wire never reached the
panel. The panel's empty placeholder then stood for both "the picture is
still coming" and "the picture is not coming", and the person looking at it
could not tell which.

The watch now reports the refusal the way the other watches in this package
report theirs, and the panel says so: a stream that failed shows a notice
that the picture is not coming, beside the reason the service gave. The
notice renders only when the panel failed, so the default markup is byte for
byte what it was.
Two "baseline probe" blocks were left in this file from the work that
compared the healthy markup with the failed one: one printed the markup
to the console, the other wrote it to a hard-coded /tmp path.

The second one is what failed the Windows runner. On Windows a /tmp path
resolves to D:\tmp on the current drive, the write ended in ENOENT, and
the file was reported as failed.

The comparison the probes were written for is already a test in this
file: "says so when the frame stream failed, and says nothing extra"
removes the one notice from the failed markup and compares what is left
with the healthy markup. The probes were scaffolding for writing that
case; they have no place in the suite.
@modacker
modacker merged commit 67e6be4 into main Nov 10, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant