Files
camoufox/scripts/check-input-dispatch.py
T
Jake Writer 4132dd50a6 refactor(juggler): make the input-dispatch deadlock structurally unreachable
Four deadlocks shipped between 2026-04 and 2026-09 -- exact-edge coordinates
(9270618), humanized trajectory points that bypassed the endpoint's guard
(541ffca, #225/#677), a zero-displacement move (16e5a13), and the near edge
(014cc65, #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. No review catches that.

And every miss costs the whole process. activateAndRun() serializes input on
a chain shared by every tab; EventWatcher.ensureEvent() waited forever. One
missing ack wedged every later input event in the process, permanently, at 0%
CPU with no diagnostic. #677 shows why review is not the answer: 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.

Three changes, in order of leverage.

1. Bound the waits. EventWatcher.ensureEventWithin() gives up instead of
   waiting forever; MouseDispatch.sendAcked() uses it, drops the event and
   logs the type, coordinate and browser rect. This alone closes all four
   historical deadlocks, including on a build with no coordinate fix at all.

   The 5s deadline is sized from measurement, not intuition. Over 1000+
   dispatches: idle content thread p50 0ms / p99 1ms / max 12ms; a thread
   burning 8ms per event p50 8ms / max 12ms. But the ack is delivered FROM
   the content main thread, so a page running a 3s synchronous script delayed
   a legitimate ack by 2849ms. Block length is page-controlled and unbounded,
   and silently dropping real input on a slow page is the #752 symptom, so
   the deadline sits above the slowest legitimate ack rather than near the
   typical one.

   Bounding each ack is not enough to bound the work: a humanized curve is
   ~110 points in a single activation-chain slot. sendTrajectoryAcked()
   abandons the rest of a curve after the first undelivered point -- not a
   wall-clock budget, which would false-fire on exactly the slow pages the
   deadline exists to tolerate. 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.

2. One chokepoint. additions/juggler/input/MouseDispatch.js owns the
   relative-to-absolute conversion, the in-viewport predicate and the ack
   wait. PageHandler's three independent bounds checks and its raw
   jugglerSendMouseEvent/sendWheelEvent calls are gone; it now passes
   relative coordinates and never sees a bounding box. Net effect on that
   vendored file is 92 lines removed against 22 added -- a smaller diff
   against upstream juggler, since the logic moved into a file we own.
   Wheel events go through the same conversion, so a wheel at relative y == 0
   no longer scrolls the tab strip.

3. Enforcement. scripts/check-input-dispatch.py fails the build if anything
   outside the chokepoint dispatches synthesized input or does
   browser-relative coordinate arithmetic. It needs no browser build, so
   .github/workflows/lint.yml gates every pull request -- nothing was
   checking PRs before. Two exemptions, both content-process: PageAgent
   (drag events, already content-relative, no ack) and FrameTree (the ack
   producer). docs/input-dispatch.md states the invariant.

   tests/patches/mouse-boundary-sweep.py replaces hand-picked edge targets,
   which are what let each of the four through: humanize-edge-deadlock.py
   probes only the far edges, and humanize-mouse-trajectory.py pins
   os="linux" -- the one fingerprint immune to #751. It sweeps the whole
   viewport ring across every spoofed OS with humanize on and off, asserting
   each point is acked AND observed by the page. It depends on change 1 to
   run at all: without the backstop the first bad coordinate wedges the
   browser and the sweep dies there.
   tests/patches/input-ack-backstop.py covers the bounded wait itself, by
   blocking the content main thread far longer than the deadline -- a
   legitimate late ack, with no test-only hook in production code.

The sweep immediately found a fifth instance, pre-existing and unreported:
boundingBox.height is consistently 0.5 CSS px less than the innerHeight the
page reports, so the page's last row is half covered. With the box at
1920x977.5 +0+56.5, relative y == 977 -- innerHeight - 1, well inside the
viewport as far as the page is concerned -- dispatches at 1033.5, rounds to
1034, and the content ends at 1034. Deterministic, 4/4, and it deadlocks a
stock build. Fixed by clamping to the last whole pixel inside the element,
symmetric with the near-edge snap; both live in the one conversion now.

All seven patch tests pass: mouse-boundary-sweep (150 ring coordinates over
6 scenarios), near-edge-mouse-deadlock, input-ack-backstop,
humanize-edge-deadlock, humanize-mouse-trajectory, noop-mousemove-deadlock,
trusted-events.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQgHHGRXNp29jr4xQjK7iv
(cherry picked from commit 827b98d31e)
2026-09-05 14:26:37 -06:00

106 lines
4.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Enforce that synthesized input is dispatched from exactly one place.
WHY THIS EXISTS
Between 2026-04 and 2026-09, four deadlocks shipped from the same invariant
being broken in four different ways -- exact-edge coordinates (#225), humanized
trajectory points that bypassed the endpoint's guard (#677), a zero-displacement
move, and the top-edge row (#751, #752). Each was fixed by adding one more
coordinate guard at one more call site.
The invariant:
A synthesized input event whose ack we await must reach the content
renderer -- and when it does not, we must stop waiting.
It is unenforceable by review, because a violation looks like ordinary
arithmetic and costs the entire browser process. #677 is the proof: restoring
the humanize trajectory meant writing a bounds check, and the check that got
written was a copy of the pre-#225 form -- reintroducing a fixed deadlock one
day before it was re-fixed. Nobody caught it in review; a grep would have.
So: one module owns the conversion, the bounds predicate and the ack wait, and
this check fails the build if anything else takes that job on. It needs no
browser build and runs in seconds, so it can gate every pull request.
python3 scripts/check-input-dispatch.py
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCAN_ROOT = ROOT / "additions" / "juggler"
CHOKEPOINT = "additions/juggler/input/MouseDispatch.js"
DOC = "docs/input-dispatch.md"
# The two exemptions are both the CONTENT process -- the other end of the wire,
# where the chokepoint's job does not exist:
#
# PageAgent dispatches drag events with coordinates that are already
# content-relative. No browser-element offset, no ack awaited.
# FrameTree is the ack PRODUCER: it observes the
# juggler-mouse-event-hit-renderer notification and emits the
# InputEvent carrying jugglerEventId. It waits for nothing.
#
# Both are narrow and deliberate. Anything in the parent process is covered.
CONTENT_DRAG = "additions/juggler/content/PageAgent.js"
CONTENT_ACK_SOURCE = "additions/juggler/content/FrameTree.js"
# (regex, what the code is doing, files exempt in addition to the chokepoint)
RULES = [
(r"\bjugglerSendMouseEvent\s*\(", "dispatches a synthesized mouse event", {CONTENT_DRAG}),
(r"\bsendWheelEvent\s*\(", "dispatches a synthesized wheel event", set()),
(r"\bjugglerEventId\b", "waits for a renderer ack", {CONTENT_ACK_SOURCE}),
(r"\bboundingBox\s*\.\s*(?:left|top)\b", "does browser-relative coordinate arithmetic", set()),
]
REMEDY = (
f"Route it through MouseDispatch ({CHOKEPOINT}): sendAcked() to dispatch and\n"
f" wait under a deadline, isInViewport() for the bounds predicate,\n"
f" toAbsolute() for the conversion. See {DOC}."
)
def main() -> int:
if not (ROOT / CHOKEPOINT).is_file():
print(f"FAIL: the chokepoint {CHOKEPOINT} is missing.")
print(" If it moved, update CHOKEPOINT in this script and in " + DOC + ".")
return 1
compiled = [(re.compile(p), what, exempt) for p, what, exempt in RULES]
violations = []
for path in sorted(SCAN_ROOT.rglob("*.js")):
rel = path.relative_to(ROOT).as_posix()
if rel == CHOKEPOINT or path.name.endswith(".bak"):
continue
for lineno, line in enumerate(path.read_text(errors="replace").splitlines(), 1):
if line.lstrip().startswith(("//", "*", "/*")):
continue
for pattern, what, exempt in compiled:
if rel in exempt:
continue
if pattern.search(line):
violations.append((rel, lineno, what, line.strip()))
if not violations:
scanned = sum(1 for _ in SCAN_ROOT.rglob("*.js"))
print(f"input-dispatch: ok -- {scanned} files scanned, all synthesized input "
f"goes through {CHOKEPOINT}")
return 0
print("input-dispatch: FAILED\n")
for rel, lineno, what, line in violations:
print(f" {rel}:{lineno} {what} outside the chokepoint")
print(f" {line}")
print(f"\n {REMEDY}")
print(f"\n{len(violations)} violation(s).")
return 1
if __name__ == "__main__":
sys.exit(main())