Files
camoufox/docs/input-dispatch.md
T
Jake WriterandClaude Opus 5.5 d3d9d73c2d docs: one AGENTS.md for every agent, a roadmap, and docs that match the code
- 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>
2026-09-25 15:22:28 -06:00

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.