Repository navigation
fix(webui): a browser frame that never arrives now says so - #28
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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:
provider.tsstdio: ["ignore","ignore","ignore"]), so the browser's account of its own startup reached nobody.provider.tschild.exitCodeand neverchild.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.tsbrowser-service.tscatch, and the next call started the browser again from scratch with no trace that it had already failed.wire.tseventframes and droppederrorframes, 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/unauthorizedcould ever light up.Why macOS
Not a macOS defect. Three things stack up on that runner:
.github/workflows/webui-ci.ymlsetsWEBUI_BROWSER_HEADED=1for macOS), so a second, permanently open browser competes with the one under test.The repository already records this history.
cdp-connection.tssays 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:
isolated-chromium-screencast(3)browser-panel-live-navigationbrowser-use-real-input(2)workspace-browser-tab-acceptance(14)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" anddescribe.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-stderr.logand quoted — flattened and capped — in the failuremessage.
child.signalCodeas well aschild.exitCode,so a browser killed by a signal is reported at once instead of after the
whole 30s budget.
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 errorrather than a process-level uncaught exception.
first target — with the browser's own version.
Measured with a fake browser, before and after:
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 withno trace that it had already failed.
The client stops dropping the refusal.
The frame watcher accepted
eventframes and droppederrorframes, sothe 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 aframe 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:
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
81ab1cecon this machine — the same 40test 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:
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 thedeadline exists to end.