- AGENTS.md holds the engineering rules for any coding agent, plus the
repo map, build, patch and test commands that CLAUDE.md used to carry.
CLAUDE.md now only imports it, so there is one set of rules.
ci/tribal-rules.yml is the record of settled decisions it points to.
- ROADMAP.md lists planned work, each item linked to its issue.
- README:
- fpgen and the coherence check replace BrowserForge;
- the patch workflow uses the make targets instead of the removed
developer UI;
- letter-spacing noise is described as off by default, as it is.
- docs/:
- beta-testing-ff146.md removed;
- patch-upgrading-guide rewritten around the make targets;
- per-context-patches without the canvas patch that no longer exists,
and with measured preset counts;
- playwright-maintenance without the JSM wrapper that does not exist;
- smaller fixes in MEDIA-DEVICES, input-dispatch and FONTS.
- ci/README: every job, and the real shard, skiplist and entry-point lists.
- pythonlib, tester and patch-dependency READMEs corrected against the code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4.8 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
the static job of .github/workflows/tests.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.