Skip to content

perf(browser): dispatch coordinate pointer input in process instead of one subprocess per event - #20593

Merged
nwparker merged 1 commit into
mainfrom
perf/remote-browser-pointer-input
Sep 14, 2026
Merged

nwparker merged 1 commit into
mainfrom
perf/remote-browser-pointer-input

Conversation

@nwparker

@nwparker nwparker commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​802 $\color{#cf222e}{\Huge{\mathbf{−}}}$​2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​800
Prod 4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​323 $\color{#cf222e}{\Huge{\mathbf{−}}}$​57 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​266

ELI5

Moving the mouse in the remote browser pane launches a whole new process on the host for every wiggle, click and scroll tick. A click is four of those. This makes the runtime deliver mouse events the same way it already delivers taps from the mobile app and keystrokes since #15310: directly, in process. A click drops from ~670ms to ~133ms, and double-click works through the pane for the first time.

Supersedes #15309, which was opened against a 2000-commit-old main and could no longer apply — agent-browser-bridge.ts has since been split into a class chain. This is the same idea rebuilt on current main, following the conventions #15310 established for keyboard input.

What Changed

mouseMove / mouseDown / mouseUp / mouseWheel no longer go through execAgentBrowser (an execFile of the agent-browser helper per event). They dispatch Input.dispatchMouseEvent over the electron debugger — the transport mouseClick has used since it was added for mobile, and that keypress moved to in #15310. Error normalization, the { ensureSession: false } queue option and the lease handling are copied from that PR so both input paths behave identically.

Two new modules:

  • cdp-pointer-input.ts — per-tab pointer state (position, held-buttons mask, click cadence) in a WeakMap keyed by the WebContents, so state dies with the tab.
  • agent-browser-bridge-pointer-commands.ts — the four commands. They live in their own class because folding them into agent-browser-bridge-mouse-commands.ts pushed it to 387 lines, past the 300-line budget. No max-lines disable.

Element-ref commands (click --element, snapshot, …) are untouched; they genuinely need the helper's accessibility snapshot.

clickCount is now tracked (1 → 2 → 3 → 1 for repeated presses within 500ms / 2px) where the helper hardcoded clickCount: 1. Double-click through the remote pane — select a word, open a file from a list — was previously impossible to express at all.

Why

Measured on the same host, same dev build, main vs this branch, through the exact RPCs BrowserPane sends. browser.eval on the same socket and queue is the control: it isolates dispatch cost from the fixed per-RPC floor, so these numbers don't depend on how fast the harness is.

mean ms/op (n=60) main this PR
browser.eval (control) 33.35 33.35
browser.mouseClick (already in process) 33.29 33.33
browser.mouseMove 173.0 33.36
browser.mouseWheel 184.3 33.36
one click (move+down+move+up, serialized) 670.5 133.4

A pointer event now costs exactly what an eval costs — the dispatch itself adds ~0ms above the floor. Both ends serialize input (enqueueRemoteInput on the client, the per-session command queue on the server), so the old per-event cost multiplied into every gesture. That was most of "the remote pane feels laggy".

The test asserts this relatively (mouseMove < evalControl × 2), not against this machine's milliseconds.

On awaiting the dispatch

#15309 argued the CDP ack is frame-bound (~16ms on a watched tab, ~1s on an idle one) and therefore must not be awaited, and paid for that with deferred-failure machinery and an ORCA_BROWSER_INPUT_AWAIT_ACK escape hatch. That premise is wrong: measured, mouseMove awaited costs the same as browser.eval awaited. All of that machinery is gone. This awaits, exactly like #15310.

Zero-Regression Evidence

tests/e2e/browser-pointer-input-dispatch.spec.ts run against main sources and then branch sources, same host, same page:

behavior main this PR
click events fired 2 2
dblclick 0 1, detail: 2
contextmenu on right-click ✅ ✅
drag-selection text "alpha bravo" "alpha bravo" (identical)
#scroller.scrollTop after 6 notches 0 720
window.scrollY 0 0

Everything main did, this does identically. Two things main could not do now work.

Result shapes were checked by running the pinned agent-browser 0.27 binary directly — {moved:true}, {pressed:true}, {released:true}, {scrolled:true,deltaX,deltaY}, byte-identical, including deltaX: 0 when the CLI passes no dx. No wire, schema or RPC surface change, so mixed-version pairs just get a faster server. Checked against docs/reference/remote-wire-compatibility.md: nothing there applies.

Callouts

Deltas from the helper path, all deliberate:

  • Two clicks within 500ms now produce a real dblclick with detail: 2, as a physical mouse would. The old path physically could not emit a sub-500ms pair (4 × ~170ms per click), so nothing existing can have depended on fast pairs staying detail: 1. Newly-fast CLI scripting that double-invokes a control in under 500ms will now look like a double-click to the page, because it is one.
  • Wheel dispatches at the tracked pointer position. The helper dispatched wheel at (0,0) regardless of any preceding move (the CLI never passes x/y), so inner scrollables under the cursor never scrolled. Measured above: on main six notches moved the scroller 0px and the page 0px — nothing scrolled at all. Both panes send mouseMove before every wheel, so interactive scrolling gets strictly better.
  • mouseUp with no button releases the held button. The helper always released left, which left a right-button press stuck forever. A chorded release keeps the still-held button addressable. Both panes always pass the button, so only CLI/RPC callers see this.
  • Non-finite coordinates and deltas are rejected with browser_error naming the bad argument. Verified against the 0.27 binary: mouse move NaN 20 errored there too, but mouse wheel NaN succeeded and silently scrolled 100px. That silent coercion is the one behavior deliberately not preserved.
  • button: 'back' / 'forward' pass through as real X1/X2 presses with the right mask bits, as the helper dispatched them. The element-click path's existing left-coercion is untouched.
  • wc.focus() on mouseDown mirrors what mouseClick already does. Relative to the old down/up path this widens the focus surface the same way every mobile tap already does — flagging for coordination with fix(browser): restore host focus after synthetic mouse presses #10875/fix(browser): stop agent browser input from stealing the user's focus #11732, which this neither fixes nor worsens beyond the established precedent.
  • A failed dispatch rewinds the tracked state. build() mutates state before the event is on the wire; a rejected dispatch changed nothing in the page, so keeping the mutation would leave a phantom held button on every later event. Mutation-tested: deleting the one-line rollback fails exactly three tests.
  • When the automation-visibility feature swaps the pane's guest WebContents, pointer state resets with it — equivalent to today, where that swap restarted the helper and dropped its mouse state. Both panes send a mouseMove before any down/up/wheel in the same serialized queue, which re-establishes position.

Testing

  • pnpm test src/main/browser/ plus the RPC, pane and CLI consumers of these methods: 3063 passed, run 5× to rule out flakes.
  • New agent-browser-bridge-pointer-input.test.ts, 23 tests: CDP payloads for move/down/up/wheel, position tracking through a drag, per-tab state isolation, clickCount escalation and all three reset conditions, back/forward passthrough, chorded release, non-finite rejection, dispatch-failure rollback (position, held button, click cadence), browser_tab_not_found on a page that dies mid-dispatch, debugger attach/detach symmetry, queue cleanup. Three click-cadence tests are pinned to fake timers after one flaked under concurrent load.
  • Full pnpm test: 81100 passed. The 9 failures are real-Claude-CLI, node-pty and fish/zsh shell-integration tests; verified pre-existing by running those same files against main sources and getting the identical failure.
  • pnpm tc, oxlint, pnpm run check:code-quality:changed, pnpm run check:max-lines-ratchet — all clean, no new casting or type-assertion findings.
  • Platforms actually tested: macOS. The dispatch path is the one mouseClick uses on all three platforms and keypress uses since fix(browser): press keys through a US-layout CDP key table instead of a subprocess per keystroke #15310; no platform-conditional code added.
  • I manually tested these changes locally
  • Automated tests added/updated

AI Disclosure

Built with Claude Code. All numbers come from scripted probes against real instances including the main-branch baseline on the same host and build; claims about the helper's current behavior were re-checked by running the pinned agent-browser 0.27 binary directly. Two claims inherited from #15309 did not survive that re-check and are corrected above: the frame-bound-ack rationale for not awaiting, and the assertion that the helper rejects non-finite wheel deltas.

Notes

Security: no new parsed inputs beyond a finiteness check; RPC surface and schemas unchanged. Cross-platform: no platform-conditional code. Remote/SSH: no wire change, server-side speedup only. Performance: the hot path loses a subprocess spawn per event and gains a small per-tab state object.

Agent skill upstream boundary

  • Not applicable.

Checklist

  • This PR is small and focused
  • I explained what changed and why (including ELI5)
  • Before/after evidence attached (measured tables above, from a committed e2e spec)
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered
  • pnpm lint, pnpm typecheck, pnpm test pass locally

@coderabbitai

coderabbitai Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Warning

Review limit reached

Next included review available in 2 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: de206522-b61f-435b-9801-3c1621f6ebc4

📥 Commits

Reviewing files that changed from the base of the PR and between 72cbef945899d825874652e7c2e53e62a0b241f0 and f9f3daf.

📒 Files selected for processing (1)
  • tests/e2e/browser-pointer-input-dispatch.spec.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 2eab8c6d-96a4-424a-994f-811de21dec27

📥 Commits

Reviewing files that changed from the base of the PR and between e84a272 and 72cbef945899d825874652e7c2e53e62a0b241f0.

📒 Files selected for processing (1)
  • tests/e2e/browser-pointer-input-dispatch.spec.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The bridge now sends coordinate-based mouse movement, button, and wheel events through CDP. It tracks pointer state per tab, supports back and forward buttons, preserves chorded-button state, counts multi-clicks, validates finite values, and rolls back failed dispatches. The mouse command bridge uses the shared pointer implementation. Unit and end-to-end tests cover dispatch payloads, errors, latency, and gesture behavior.

Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to 72cbe

Repeated direct clicks cannot produce the tracked multi-click behavior, and mixing direct clicks with held-button gestures can leave gesture state inconsistent. Resolve this before merging.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.81% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 21 functions across 7 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: moving coordinate pointer input dispatch in process instead of using one subprocess per event.
Description check ✅ Passed The description is detailed and covers the change, rationale, testing, performance evidence, compatibility, AI disclosure, and checklist. The required Linked Issue section is missing, and the build co…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)
src/main/browser/agent-browser-bridge-mouse-commands.ts (1)

49-66: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Route mouseClick through the shared pointer state.

mouseClick bypasses CdpPointerState. Two consecutive mouseClick calls always send clickCount: 1, so they cannot generate the new double-click behavior. A mixed sequence such as mouseDown('left'), mouseClick(..., 'right'), and mouseUp() also sends CDP button masks that omit the held left button while the tracked state still records it. CDP defines buttons as the buttons pressed when the event occurs. (chromedevtools.github.io)

Use the shared pointer transition and dispatch path for this click sequence. Preserve the mobile touch-point handling, but update button masks and click counts from the same state as mouseDown and mouseUp.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: d9affb12-ff52-4712-8833-43f8c284b290

📥 Commits

Reviewing files that changed from the base of the PR and between 539d4d1 and c77ecd4f737d5ecf9ff96393faf720621157f470.

📒 Files selected for processing (7)
  • src/main/browser/agent-browser-bridge-command-transport.test.ts
  • src/main/browser/agent-browser-bridge-mouse-commands.ts
  • src/main/browser/agent-browser-bridge-mouse.ts
  • src/main/browser/agent-browser-bridge-pointer-commands.ts
  • src/main/browser/agent-browser-bridge-pointer-input.test.ts
  • src/main/browser/cdp-pointer-input.ts
  • tests/e2e/browser-pointer-input-dispatch.spec.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.

@nwparker
nwparker force-pushed the perf/remote-browser-pointer-input branch 2 times, most recently from e84a272 to 72cbef9 Compare September 14, 2026 08:26
@nwparker

Copy link
Copy Markdown
Contributor Author

Checked this carefully. It's a real gap, but it's pre-existing and not one this PR widens — and closing it here would trade a measured low-risk change for an unmeasured mobile behavior change. Declining, with reasoning:

This PR does not alter mouseClick. The entire diff to it is a rename:

-        const buttons = cdpMouseButtonMask(cdpButton)
+        const buttons = cdpPointerButtonMask(cdpButton)

cdpMouseButtonMask and cdpPointerButtonMask return identical values for left/middle/right (1/2/4) — they were deduplicated into one table because the old three-button version was a strict subset of the five-button one. Byte-identical payloads.

On the mixed-sequence claim. Tracing mouseDown('left') → mouseClick(x, y, 'right') → mouseUp() on main and on this branch:

step main this PR
mouseDown('left') helper dispatches buttons: 1, helper tracks it buttons: 1, tracked in CdpPointerState
mouseClick(…, 'right') buttons: 2 then 0 — omits the held left buttons: 2 then 0 — omits the held left
mouseUp() helper releases from its state releases from CdpPointerState

The inconsistency is identical on both sides. main had the same split — the held-button state lived in the agent-browser helper and mouseClick never consulted it either. This PR moves that state from the helper into the main process; it does not introduce the divergence.

I verified the analogous case empirically against the shipped agent-browser 0.27 binary, because I had the same worry. After mouse move 10 10 → an element click that presses at [360,220] → a bare mouse down, the helper pressed at [10,10] — its element-click path does not move its coordinate cursor either. The page recorded [[360,220],[10,10]]. Same split, same direction.

On clickCount: 1 for consecutive mouseClick calls. Correct, and also pre-existing. Worth stating plainly: mobile taps use mouseClick, so double-tap still does not produce dblclick on mobile. This PR fixes double-click for the desktop remote pane, which sends coordinate mouseDown/mouseUp, and does not claim to fix it for mobile.

Why not fix it here. Routing mouseClick through the shared state would give every repeated mobile tap within 500ms / 2px an escalating clickCount, turning double-tap-to-zoom and fast repeated taps on a control into real dblclicks. That is a user-visible mobile behavior change, on a path this PR has not measured and whose panes I have not exercised. This PR's whole premise is that every behavior delta is measured against the old path; that one would not be. It belongs in its own change with its own mobile evidence.

Happy to open a follow-up issue for it if you'd like.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues. The dispatch/state machine checks out — one test-reliability suggestion inline.

Reviewed changes

  • Coordinate pointer input now dispatched in process. mouseMove/mouseDown/mouseUp/mouseWheel move off execAgentBrowser onto wc.debugger.sendCommand('Input.dispatchMouseEvent', …), via a new AgentBrowserBridgePointerCommands class slotted between InputCommands and MouseCommands; element-ref commands stay on the helper.
  • Per-tab pointer state added in cdp-pointer-input.ts: a WeakMap<WebContents, CdpPointerState> holding position, held-button mask, active button, and click cadence, with a one-line rollback when a dispatch rejects.
  • Button-mask helpers generalized (cdpMouseButtonMask → cdpPointerButtonMask, plus normalizeCdpPointerButton/cdpPointerButtonFromMask) so back/forward pass through as X1/X2 on the coordinate path while the element-click path keeps its left coercion.
  • New tests: agent-browser-bridge-pointer-input.test.ts (23 unit tests over payloads, chorded releases, click cadence, failure rollback, debugger lifecycle) and tests/e2e/browser-pointer-input-dispatch.spec.ts (latency vs a browser.eval control plus click/dblclick/right-click/drag-select/wheel fidelity); command-transport.test.ts swaps its mouseMove helper-spawn assertion for scroll.

I verified the deleted cdpMouseButtonMask has no remaining references, that MouseButton is OptionalPlainString (so back/forward reach the new passthrough) while MouseXY/MouseWheel.dy use z.custom<number> (so the new finiteness guard is load-bearing), and ran the three affected unit files (47 passing). A focused audit of the payload/state machine found no divergence from the CDP CdpMouse convention.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@nwparker
nwparker force-pushed the perf/remote-browser-pointer-input branch from 72cbef9 to f9f3daf Compare September 14, 2026 08:37

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes (delta since the prior pullfrog review at c77ecd4)

  • E2E gesture sequences now run in one renderer round-trip. New driveRpcSequence/clickSteps replace per-RPC clickAt, so harness IPC no longer sits between the two presses; pressGapMs is read from recorded event timestamps and asserted < 500 before the dblclick check, turning a slow host into an explicit precondition failure rather than a misleading missing-dblclick.
  • Latency measurement recalibrated. Thresholds move to 3x/3x/10x (measured in-process 1.5x/1.5x/4x vs helper 5.7x/5.5x/20.5x), REPEATS 60→12 to amortize the renderer's ~16.6ms clock, the unused mouseClick sample is dropped, and the test timeout is raised to 240s.

Production files are unchanged in this commit; the pointer dispatch and state machine are identical to what I reviewed at c77ecd4. The prior e2e timing thread is addressed and resolved.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes (delta since the 72cbef9 review)

  • Double-click verification is now conditional rather than failing on a slow host. When pressGapMs >= DOUBLE_CLICK_INTERVAL_MS, the spec records a pointer-dblclick-unverified annotation and skips the dblclick/detail: 2 assertions instead of failing; the click count is still asserted, and the click-cadence machine itself remains covered deterministically by the fake-timer unit tests.
  • Latency/cadence numbers are surfaced in CI. latencyReport and cadenceReport ride on test.info().annotations and as expect messages, so passing runs no longer depend on stdout that the CI job log drops.

Test-only change; no production files touched since the last review. The new DOUBLE_CLICK_INTERVAL_MS = 500 constant intentionally mirrors MULTI_CLICK_INTERVAL_MS, which is fine to keep local to the spec.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

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