Share the Chrome launcher and retarget CdpClient onto deno-cri - #14835
Merged
Conversation
Collaborator
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
cwickham
force-pushed
the
refactor/shared-chrome-launcher
branch
from
August 31, 2026 22:50
5d608be to
142f0f9
Compare
5 tasks
cwickham
marked this pull request as ready for review
September 3, 2026 19:49
Member
Author
cderv
self-requested a review
September 4, 2026 09:16
cderv
approved these changes
Sep 4, 2026
cderv
left a comment
Member
There was a problem hiding this comment.
LGTM.
About the test this is a good idea to maybe add some more. I'll do that either in this PR before merge or after.
I'll take over to merge both.
Thanks a lot for the work on this !!
cderv
added a commit
that referenced
this pull request
Sep 7, 2026
Pins the exact Chrome command-line flags criClient builds in src/core/cri/cri.ts before PR #14835's shared-launcher refactor lands, so that refactor can be verified as behavior-preserving rather than trusted by inspection. The test spawns a generated fake Chrome executable instead of mocking Deno.Command, so it observes the same argv contract any real Chrome launch depends on, independent of which module ends up building it — surviving the refactor without needing to change.
cderv
force-pushed
the
refactor/shared-chrome-launcher
branch
from
September 7, 2026 15:13
198447b to
1dc1e50
Compare
cderv
force-pushed
the
refactor/shared-chrome-launcher
branch
2 times, most recently
from
September 9, 2026 09:25
7162e41 to
17ad6c4
Compare
A bare repo path is no use to a reader of the changelog, so it is now a GitHub URL. The help text loses the pointer rather than carrying a long URL in a terminal: the quarto-web page that replaces dev-docs will get one, following the 'For details, see:' shape quarto run uses. Reported by cderv in review of #14815.
Two subsystems launch Chrome over CDP — criClient (mermaid) and the axe scanner — and their launch halves had drifted into near-duplicates of each other: the same headless-mode escape hatch, the same flag set, the same "Chrome never exits on its own" kill, the same wait-for-the-CDP-port poll. Two copies of "how quarto starts Chrome" is one copy too many; the cri.ts comment warning readers to sync flag changes by hand was the interim fix. launchChrome() in src/core/cri/launch.ts is now the single launcher. It owns the flags, QUARTO_CHROMIUM_HEADLESS_MODE, the optional throwaway profile dir, stderr draining, exit cleanup, and the wait loop; callers pass in only what they genuinely disagree about (--renderer-process-limit=1 for mermaid, --hide-scrollbars and an isolated profile for the scanner). No caller uses it yet — the two switches follow. registerForExitCleanup() now installs the handler that actually kills the registry. It never did: only execProcess() installed it, so a command that spawns a browser and never shells out could register a process and still orphan it on Ctrl-C. The axe scanner is exactly that command, and it used onCleanup() directly to work around this.
criClient keeps its mermaid-shaped facade (navigate / querySelector / screenshot) and its deno-cri connection; only the spawn half moves out to launchChrome(). --renderer-process-limit=1 is passed in, since one diagram is rendered at a time. Three things change as a side effect of using the shared code path, all in the failure direction only: - stdout is no longer piped. Nothing ever read it, and an unread pipe is a way for Chrome to block on a full buffer. - stderr is drained to the debug log as it arrives, instead of being read once after a failed wait. The old path could hang: it awaited `cmd.status` for a Chrome that was running happily but had not opened the port, and asserted that a single read had drained the whole pipe. - the wait for the CDP endpoint goes from 3s to 15s (the shared default). This only lengthens how long a genuinely broken launch takes to report; a healthy Chrome still returns as soon as the endpoint answers. Verified: a mermaid-format: png render produces a byte-identical PNG before and after (sha256 9ee2aef2...).
launchScanBrowser drops its own copy of the launch half — flags, headless mode, temp profile, stderr drain, kill-on-exit, wait loop — and calls launchChrome() instead. What stays here is what is genuinely scanner-specific: --hide-scrollbars, an isolated profile, and connecting the CDP client. Behaviour is unchanged, with one deliberate substitution: exit cleanup now goes through registerForExitCleanup() rather than onCleanup() directly, so the kill handler is unregistered once the browser has been closed cleanly instead of staying on the cleanup list for the life of the process. Tests: all 147 axe unit tests and all 8 tests/smoke/axe/ smoke tests pass.
CdpClient kept its own WebSocket internals — message framing, id counter, pending map, listener registry — which is the third copy of that machinery in the tree after deno-cri and whatever Chrome does on the other end. The typed interface stays exactly as it was (send<T>, cancellable once, close); only what sits under it changes, to the same deno-cri client cri.ts already uses. deno-cri does not do the one thing fail-closed cells depend on: it notices a dropped socket but leaves the commands that were in flight unsettled forever. So this tracks in-flight sends itself and rejects them on close or disconnect — a crashed tab fails its own cell inside --timeout rather than hanging the scan. The unit tests that stub this client cover exactly that behaviour and are unchanged. Target discovery goes with it: deno-cri picks the page target (and creates one if the browser has none), so scan.ts's own /json/list polling is gone, and the launcher's wait for the CDP endpoint is the only wait left. Connecting retries 5x100ms, the interval cri.ts measured its way to. One behaviour is not carried over: the old client logged and ignored a frame that did not parse as JSON, where deno-cri parses inside the socket's onmessage handler and an unparseable frame therefore exits the process. Chrome does not send such frames, and guarding it would mean patching vendored code for a case never observed, so it is left alone. Tests: 147 axe unit tests and 8 tests/smoke/axe/ smoke tests pass.
The scan-stage section still described two launchers and a hand-rolled WebSocket, with the shared launcher as follow-up work and the deno-cri retarget as a "plausible future". Both are done, so rewrite the section around the three layers the PR discussion settled on — launcher, transport, task logic — and say which are shared and which never will be. Both the section and launch.ts's own header say "the one place quarto's CDP drivers start headless Chrome", not "the one place quarto starts headless Chrome": src/core/puppeteer.ts launches through puppeteer instead (withHeadlessBrowser, reached through withPuppeteerBrowserAndPage and inPuppeteer). Nothing outside that file enters it today, but a maintainer chasing browser-launch behaviour should not be told it doesn't exist.
The unit tests stub the CDP client, so they cover what scanCell does with a rejected send but not whether a real client rejects at all. That half now matters more than it did: rejecting in-flight commands used to fall out of owning the WebSocket, and is now CdpClient's own contribution on top of deno-cri, which notices a dropped socket and leaves those commands unsettled forever. Three cases against a real browser, each with a command the browser can never answer in flight: closing the client, sending after close, and the connection dropping from the far end (Browser.close, as the portable stand-in for a tab or process dying). Every wait has a deadline and a blown deadline reports as `hung`, so a transport that never settles fails the assertion instead of passing it. Checked by mutation: reverting abandonPending to deno-cri's own behaviour fails the test with `hung: nothing settled within 15000ms`. Runs in ~0.7s.
criClient's open() and axe scan's CdpClient.connect() each retried connecting to the CDP socket with the same 5-tries-at-100ms loop and the same empirically-tuned comment block, duplicating the exact thing the shared launcher was meant to unify. Extract connectCdp() beside launchChrome() and have both callers use it. tests/unit/chrome-launch.test.ts pins the retry-exhaustion behavior so a future change to the retry budget or its error message is visible.
waitForCdpEndpoint only checked for HTTP 200 on /json/list, which Chrome's DevTools server can answer before any page target exists. That's fine for criClient (deno-cri's own default target creates one on connect), but the axe scanner connects with a bare function target that skips that creation path, so its 500ms connect-retry was the only thing standing between "endpoint up" and "a target actually exists" - shrinking what used to be a 15s wait down to effectively nothing on a loaded box. launchChrome now takes an awaitPageTarget option; when set, the readiness poll requires a target with webSocketDebuggerUrl in the /json/list response, not just a 200. Along the way, the readiness wait's own timeout tracking is fixed to use wall-clock deltas instead of counting fixed-size iterations (an iteration count doesn't bound real elapsed time if a single attempt runs long), each fetch attempt is capped so one slow or refused connection can't consume the whole budget on its own, and the wait is raced against the launched process exiting, so a Chrome that's already dead is reported immediately rather than only once the timeout expires. tests/unit/chrome-launch.test.ts gains coverage for launchChrome's rejection path against a Chrome that never becomes ready - a fixed-endpoint executable is a real subprocess, not a mock, per this repo's testing convention.
Review turned up that the previous commit's awaitPageTarget rationale was wrong: neither axe nor criClient ever passes deno-cri a target, so both hit the exact same defaultTarget page-creation race the option was written to address — the "axe connects with a bare function target, criClient is fine" distinction doesn't exist in the code. Wire awaitPageTarget into criClient's launchChrome call too, and fix the comment to describe the actual mechanism instead of the invented one. Also fixes two smaller issues found in the same review: hasPageTarget accepted any target with a webSocketDebuggerUrl regardless of type, so a non-page target (a service worker, say) could satisfy readiness early — now requires type === "page", matching deno-cri's own defaultTarget filter. And each readiness probe was capped at a fixed 1s regardless of how much of the launch timeout was left, so a probe starting late could overrun the caller's requested timeout by close to a full second; probeTimeoutMs now bounds each attempt by whichever of the two is smaller. tests/unit/chrome-launch.test.ts covers hasPageTarget's type filter, probeTimeoutMs's bound, connectCdp's attempt count on both exhaustion and recovery, and launchChrome's awaitPageTarget option end to end against a fake CDP endpoint that withholds and then serves a real page target. chrome-launch-flags.test.ts's fake endpoint is updated to serve a real page target, since criClient now requires one.
…cceed Review turned up that the previous commit's awaitPageTarget: true on criClient's launchChrome call was a real regression: verified against the actual chrome-headless-shell binary that with no URL to open, it launches with zero CDP targets at all, and nothing else creates one before launchChrome's readiness wait gives up -- deno-cri's own target creation only runs later, at connect time. Every mermaid render through chrome-headless-shell would have hung for the full launch timeout and then failed. Passing url: "about:blank" (same as the axe scanner already does) makes Chrome create a page target as part of its own startup, which open()'s Page.navigate() replaces immediately afterward. Also fixes a second, smaller issue in the same review: Chrome's own exit was only checked between readiness probes, so a probe already in flight against a genuinely unresponsive port could hold up "Chrome exited" for close to a full probe timeout after the exit actually happened. waitForCdpEndpoint now aborts the in-flight probe as soon as the process exits instead of waiting for it to time out on its own. tests/unit/chrome-launch-flags.test.ts adds a fake Chrome whose /json/list mirrors the real binary's URL-gated target creation, driving criClient through it end to end. tests/unit/chrome-launch.test.ts adds a test where a silent Chrome exits quickly under a much longer launch timeout, and asserts the exit is reported near-immediately rather than after an in-flight probe's own timeout.
Roborev flagged the exit-report timing assertion as flaky: elapsed time was measured from before the fake Chrome subprocess was even spawned, so subprocess startup on a loaded Windows host swamped the narrow abort-in-flight-probe window the test meant to verify (observed 1489ms against a 700ms budget). The child now records its own exit timestamp synchronously right before exiting, and the test measures detection latency against that instead of wall time from before the spawn.
…e scaffolding CdpClient.connect's unavailable-port test duplicated the deterministic connectCdp coverage while costing ~12s per run -- traced to Deno's fetch taking ~2.3s per refused connect against a closed "localhost" port (measured directly; not an internal deno-cri retry). Real integration coverage of CdpClient against the vendored deno-cri library already exists via tests/smoke/axe/axe-transport-failclosed.test.ts, which drives a real browser through connect/close/dropped-socket. Dropped the no-dangling-colon-on-empty-stderr case (a cosmetic message branch already exercised structurally by the non-empty-stderr test) and the direct probeTimeoutMs unit test, making probeTimeoutMs private again now that nothing outside launch.ts needs to call it directly. In chrome-launch-flags.test.ts, replaced the second URL-gated fake-browser fixture and its dedicated test with a one-line argv assertion on the existing launch-arguments test, since criClient always passes the same url: "about:blank" regardless of fixture behavior. Extracted the repeated "write a fake Chrome .cmd/.sh wrapper executable" boilerplate (three near-identical copies across the two files) into a shared writeFakeExecutable helper in the new chrome-launch-fixtures.ts.
cderv
force-pushed
the
refactor/shared-chrome-launcher
branch
from
September 9, 2026 10:12
17ad6c4 to
ed17bb3
Compare
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.
Stacked on #14815 (
feat/axe-scan-command).Description
This is the follow-up promised in
#14815 (comment),
where @cderv asked why the axe scanner does not reuse
cri.ts.The short answer from that thread: a Chrome driver has three layers, and the
answer differs at each one.
port, kill on exit.
command, awaits the result, subscribes to events, and closes.
recovery. For mermaid that is the
cri.tsfacade. For the scanner it isscanCell.Layers 1 and 2 were duplicated. This PR removes both copies. Layer 3 stays
separate, because mermaid's commands and the scanner's commands are different
jobs. After this PR, no launcher code and no protocol code exists twice among
quarto's CDP drivers. (
src/core/puppeteer.tsis a third, puppeteer-basedlaunch path —
withHeadlessBrowser, reached throughwithPuppeteerBrowserAndPageandinPuppeteer. Nothing outside that fileenters it, so I left it alone and only made sure the new comments do not
claim it away.)
No user-visible change, so no changelog entry.
Part 1: one launcher
launchChrome()in the newsrc/core/cri/launch.tsstarts headless Chromefor both callers. It owns the flag set, the
QUARTO_CHROMIUM_HEADLESS_MODEescape hatch, the optional throwaway
--user-data-dir, stderr draining, exitcleanup, and the wait for the CDP endpoint.
The two old launchers differed in six ways. Three are now unified and three
are passed in:
--renderer-process-limit=1--hide-scrollbars--user-data-dir"null"for bothregisterForExitCleanup, the scanner usedonCleanupregisterForExitCleanupfor bothThe three unifications each fix something in the failure path:
cmd.statusfor a Chrome thatwas running happily but had not opened the port, and it asserted that one
read had drained the whole stderr pipe.
onCleanuphandlers cannot be unregistered, so a mermaid-heavy render grewthe cleanup list by one dead closure per file.
I left the throwaway profile dir as the scanner's own, and it is the one
decision I am least sure of. Giving mermaid an isolated profile is arguably
correct for the same reason it is correct here — a Chrome the user already
has running can otherwise short-circuit the launch. But mermaid renders sit
in the hot path of ordinary user renders, a fresh profile costs Chrome's
first-run work every time, and this PR is meant to remove duplication rather
than change mermaid. Happy to unify it if you disagree.
One more change came out of this.
registerForExitCleanup()never installedthe handler that kills its own registry — only
execProcess()did. A commandthat spawns a browser and never shells out could therefore register a process
and still orphan it on Ctrl-C.
quarto call axeis exactly that command,which is why it used
onCleanupdirectly.registerForExitCleanup()nowinstalls the handler itself.
One timing changed, in the failure direction only. cri's wait for the CDP
endpoint goes from 3s to the shared 15s, which only lengthens how long a
genuinely broken launch takes to report. A healthy Chrome still returns as
soon as the endpoint answers.
Part 2: one transport
CdpClientinscan.tskept its own WebSocket internals: message framing, anid counter, a pending map, a listener registry. That was the third copy of
that machinery in the tree, after
deno-criand whatever Chrome runs on theother end. Those internals are now the vendored
deno-cri(
src/core/cri/deno-cri/), the same clientcri.tsconnects with.The typed interface is unchanged, because the unit tests stub it and three
behaviours are what fail-closed cells depend on (all three now also covered
against a real browser — see Verification):
send<T>resolves with the command result, or rejects.once(method)returns{ event, cancel }, so a cell that timed out canstop caring about a load event that belongs to it.
deno-cridoes not do the third one. It notices a dropped socket and emitsdisconnect, then leaves every in-flight command unsettled forever. SoCdpClienttracks in-flight sends itself and rejects them on close or ondisconnect. A crashed tab fails its own cell inside
--timeoutinstead ofhanging the scan.
Target discovery went with it:
deno-cripicks the page target, and createsone if the browser has none (#4653), so
scan.ts's own/json/listpollingis gone. The launcher's wait for the endpoint is the only wait left.
Connecting retries five times at 100ms, the interval
cri.tsmeasured its wayto.
No vendored code is touched. One behaviour is not carried over: the old
client logged and ignored a frame that did not parse as JSON, where
deno-criparses inside the socket's
onmessagehandler, so an unparseable frame exitsthe process. Chrome does not send such frames, and guarding it would mean
patching
deno-cri/chrome.jsfor a case never observed, so it is left alone.Known cost: connecting through
deno-crifetches/json/protocoltobuild its domain shorthands. That is roughly 1 MB of JSON, once per scan, on a
run that already takes minutes.
cri.tshas paid it per render for years.Explicitly out of scope
cri.tsexporting a typed transport as a core surface. That is the cleanerend state, and it waits on the scanner's needs settling — concurrent tabs in
particular.
deno-crisources.Verification
147 axe unit tests and 9
tests/smoke/axe/smoke tests pass, atevery commit that touches them.
3 mermaid smoke tests pass (
mermaid-svg-docx,mermaid-gfm-svg,mermaid-multi-diagram).A
mermaid-format: pngrender produces a byte-identical PNG before andafter (sha256
9ee2aef2…).A full quarto-web scan: 416 pages found, 373 scanned, 1504 cells,
before and after. Identical finding set (the same 300 signature ids), the
same
{total: 300, new: 300, baselined: 0}, the same 12 not-ok cells, andall 1504 cell statuses identical. Timing is unchanged: 639s of cell time
before, 643s after, median cell 355ms in both.
What is not byte-identical is which cells six occurrences were attributed
to, on OJS tables, a leaflet map and a headroom navbar — all client-side
rendering. I ran the unchanged code a second time as a control, and those
two runs of the same code differ from each other in 21 attributions. So
the after-run sits inside the existing run-to-run noise, and this is the
client-side render race
llm-docs/axe-scan-architecture.mdalreadydocuments.
Fail-closed against a real browser is now a test rather than a manual
check:
tests/smoke/axe/axe-transport-failclosed.test.ts. With a commandthe browser can never answer in flight, closing the client, sending after
close, and the connection dropping from the far end all reject with
CDP connection closed. Every wait has a deadline that reports ashung,so a transport that never settles fails rather than passes. Mutation-checked:
reverting
abandonPendingto deno-cri's own behaviour fails it withhung: nothing settled within 15000ms. Runs in ~0.7s.deno check src/quarto.tsanddeno lintare clean on every changed file.Checklist
I have (if applicable):
review thread on Add
quarto call axe, a hidden experimental site accessibility scanner #14815 (linked above)axe-transport-failclosed.test.ts, covering the halfa stubbed client cannot: that a real
CdpClientrejects at all when theconnection goes
llm-docs/axe-scan-architecture.mdis updated: its scan-stage sectiondescribed two launchers and a hand-rolled WebSocket, with both of these
changes listed as future work.
AI-assisted PR