- pointerType was "" for every Playwright mouse event: juggler dispatched with MOZ_SOURCE_UNKNOWN. It now passes MOZ_SOURCE_MOUSE (#776). - keyboard.type() never pressed Shift: a shifted character now arrives bracketed by ShiftLeft keydown/keyup (location 1) with shiftKey set. - After the pointer was parked off content, pointerover/enter re-entered with buttons=1 and pressure 0.5; the tracked position is now forgotten on park. - A Windows identity gets contextmenu after mouseup with buttons=0, as Windows does; GTK/macOS keep it on press. - Wheel events are sent as line deltas (DOMMouseScroll.detail 3 per notch instead of the pixel count). - The browser rect is measured after the APZ flush await, so a chrome height change during the wait cannot put a y==0 dispatch one row above content. - ci/run_sundial.py moves and clicks the mouse so input vectors have data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4.7 KiB
Synthesized input dispatch
Every synthesized mouse and wheel event in the parent process goes through
additions/juggler/input/MouseDispatch.js. scripts/check-input-dispatch.py
fails the build if anything else dispatches input or does browser-relative
coordinate arithmetic, and it runs on every pull request.
The invariant
A synthesized input event whose ack we await must reach the content renderer — and when it does not, we must stop waiting.
Why it is worth a module and a lint
Four deadlocks shipped between 2026-04 and 2026-09, all the same failure:
| Date | Commit | Trigger |
|---|---|---|
| 2026-06-04 | 9270618 |
x == width / y == height — the far edges |
| 2026-07-18 | 541ffca |
trajectory points, which bypassed the endpoint's guard (#225, #677) |
| 2026-07-24 | 16e5a13 |
a zero-displacement move |
| 2026-09-03 | 014cc65 |
y == 0 — the near edge (#751, #752) |
Each was fixed by adding one more coordinate guard at one more call site. That does not converge, for two reasons.
The trigger set is not enumerable. Whether relative y == 0 reaches the
renderer is decided by Math.round(boundingBox.top) < boundingBox.top — a
rounding accident in the fractional height of browser chrome, which varies with
the spoofed OS: windows 51.4 → 51 deadlocks, macos 53.1 → 53
deadlocks, linux 56.5 → 57 is fine. No review catches that, and no
hand-written list of coordinates contains it.
Every miss costs the whole process. activateAndRun()
(TargetRegistry.js) serializes input on a promise chain shared by every tab in
the process. It swallows errors to keep the chain running, but it cannot swallow
a callback that never returns. One unbounded await for an ack that will never
arrive wedges every later input event, in every tab, permanently — at 0% CPU,
with nothing in flight and no diagnostic.
#677 is why review is not enough: restoring the humanize trajectory meant
writing a bounds check, and the one written was a copy of the pre-#225 form,
reintroducing a fixed deadlock one day before it was re-fixed.
How it is enforced
One chokepoint. MouseDispatch owns the relative→absolute conversion (with
the boundary snap), the in-viewport predicate, and the ack wait. Callers pass
relative coordinates and never see a bounding box.
A fresh rect. The browser rect is measured after the last await before
the dispatch (apz-repaints-flushed), never before it. The chrome can change
height during that wait — measured 2026-09-14: the nav-bar grew from 40 to
41 px about a second after startup when the (then forced) built-in theme was
applied — and a rect taken before it put a relative y == 0 one row above the
content: the event reached the renderer as an exit event at client y == -1,
produced no ack, and the page saw no mousemove (~1 in 25 runs of
near-edge-mouse-deadlock.py). Both dispatch sites (Page.dispatchMouseEvent,
Page.dispatchWheelEvent) measure synchronously before dispatching.
Bounded waits. sendAcked() waits at most kAckDeadlineMs (5s) and then
drops the event with a warning naming the type, coordinate and browser rect. The
deadline is sized above the slowest legitimate ack, not near the typical one:
acks are p99 1ms on an idle page, but they are delivered from the content main
thread and inherit any block on it — a 3s synchronous script delayed one by
2849ms. sendTrajectoryAcked() abandons the rest of a curve after the first
undelivered point, so the ~90 bounded waits a curve can hold — the 1.5s default
humanize ceiling at 60Hz — cannot add up to an unbounded slot. It also refuses
to dispatch a point on the pixel the previous dispatch left the cursor on: a
zero-displacement move produces no eMouseMove, so it is never acked, and
that is the same deadlock reached from inside a curve rather than from
Page.dispatchMouseEvent.
activateAndRun() carries a 30s backstop for the other unbounded waits
reachable from the same slot (apz-repaints-flushed, TabSwitchDone, the drag
path's waits), none of which has failed yet.
The static check. scripts/check-input-dispatch.py, wired into
.github/workflows/lint.yml. Two exemptions, both content-process:
PageAgent.js (drag events, already content-relative, no ack) and
FrameTree.js (the ack producer).
Boundary coverage. tests/patches/mouse-boundary-sweep.py sweeps the whole
viewport ring across every spoofed OS with humanize on and off, asserting each
point is acked and observed by the page. Hand-picked coordinate lists are what
let each of the four bugs through: humanize-edge-deadlock.py probed only the
far edges, and humanize-mouse-trajectory.py pins os="linux" — the one
fingerprint immune to #751. tests/patches/input-ack-backstop.py covers the
bounded wait itself.