mirror of
https://github.com/daijro/camoufox.git
synced 2026-09-08 16:01:00 +00:00
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 commit827b98d31e)
106 lines
4.2 KiB
Python
Executable File
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())
|