fix(juggler): don't dispatch mouse events on the chrome/content boundary row (#751, #752)

`sendOne()` converts a relative coordinate to an absolute one as
`eventY + boundingBox.top`, so a relative y of 0 dispatches at absolute
y == boundingBox.top exactly -- the content area's first row. The chrome
above the content is a fractional number of CSS pixels tall, and the
widget rounds the coordinate to a whole device row before hit-testing it.
Wherever round(top) < top that rounded row still belongs to chrome, so
the event fires as an exit event rather than eMouseMove, no
juggler-mouse-event-hit-renderer ack is produced, and -- because dispatch
is serialized on activateAndRun()'s process-global chain -- that one
missing ack wedges every later input event in the process forever. Same
deadlock as the far-edge (#225) and no-op-move cases, from the near edge.

The chrome height is a deterministic function of the spoofed OS, so this
is not flaky, it is per-fingerprint. Measured on v152.0.4-beta.30,
headless Linux, `page.mouse.move(31, 0)` from an interior point:

    os        boundingBox.top   rounds to   result
    windows   51.4              51 (above)  hangs, 5/5
    macos     53.1              53 (above)  hangs, 5/5
    linux     56.5              57 (below)  completes, 8/8

Relative x == 0 is unaffected because boundingBox.left is a whole 0.

The far edges were fixed by treating them as out-of-viewport; 0 cannot
be. It is a legitimate in-viewport coordinate, and the out-of-viewport
branch returns silently for mousedown/mouseup -- so widening the bounds
check would convert the hang into a click that reports success and fires
nothing, which is what #752 describes. Snap the dispatched coordinate to
the first whole pixel inside the browser element instead: it stays within
content pixel 0 and lands clear of the boundary.

humanize is what makes this common in the field rather than the cause of
it. Every PageHandler starts at _lastTrackedPos = {x: 0, y: 0}, so a
session's first humanized move always departs from the top-left corner
and the curve rides the y==0 row. On a stock build a first humanized
click hung on 5 of 20 cold pages; all five had dispatched a point at
y==0, and the 15 that completed had dispatched none. With the fix, 0 of
20 hung and all 8 y==0 points dispatched across those runs were acked.

Add tests/patches/near-edge-mouse-deadlock.py, which covers all three
spoofed OSes so it does not depend on which offset a given host's chrome
produces, and both the direct and humanized dispatch paths. It fails on
stock at the first move and passes with the fix. humanize-edge-deadlock,
humanize-mouse-trajectory, noop-mousemove-deadlock and trusted-events all
still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQgHHGRXNp29jr4xQjK7iv
This commit is contained in:
Jake Writer
2026-09-03 20:03:35 -06:00
co-authored by Claude Opus 5
parent e41e4d6c80
commit 014cc6597a
2 changed files with 258 additions and 2 deletions
+25 -2
View File
@@ -527,14 +527,37 @@ export class PageHandler {
await helper.awaitTopic('apz-repaints-flushed');
const watcher = new EventWatcher(this._pageEventSink, types, this._pendingEventWatchers);
// The browser element's origin is not pixel-aligned: the chrome above the
// content is a fractional number of CSS pixels tall, and how many depends
// on the spoofed OS (measured: windows 51.4, macos 53.1, linux 56.5). So a
// relative coordinate of 0 dispatches at absolute y == boundingBox.top
// exactly -- the content area's first, only partly covered row. The widget
// rounds that to a whole device row before hit-testing it, and wherever
// round(top) < top the rounded row still belongs to chrome: the event fires
// as an exit event rather than eMouseMove, no
// juggler-mouse-event-hit-renderer ack is produced, and because dispatch is
// serialized on activateAndRun()'s process-global chain (TargetRegistry.js)
// that one missing ack wedges every later input event in the process
// forever. windows and macos land on the wrong side of that rounding, so
// page.mouse.move(x, 0) deadlocks on them every time; linux never does.
// This is the near edge's version of the x==width / y==height deadlock the
// bounds checks below guard against, but it cannot be fixed the same way:
// unlike the far edges, 0 is a legitimate in-viewport coordinate a caller
// may ask for, and the out-of-viewport branch drops mousedown/mouseup
// silently -- which is what makes a click look like it simply did nothing.
// Snapping to the first whole pixel inside the element stays within content
// pixel 0 while landing clear of the boundary. boundingBox.left is normally
// a whole 0 and so needs no rounding, but the same snap covers it.
const originX = Math.ceil(boundingBox.left);
const originY = Math.ceil(boundingBox.top);
// Dispatch a single synthesized mouse event to the renderer and return a
// promise that resolves once the renderer acks it.
const sendOne = (eventType, eventX, eventY) => {
// This dispatches to the renderer synchronously.
const jugglerEventId = win.windowUtils.jugglerSendMouseEvent(
eventType,
eventX + boundingBox.left,
eventY + boundingBox.top,
Math.max(eventX + boundingBox.left, originX),
Math.max(eventY + boundingBox.top, originY),
button,
clickCount,
modifiers,
+233
View File
@@ -0,0 +1,233 @@
"""
Verify a mouse event on the viewport's top edge does not deadlock (daijro/camoufox#751, #752).
Camoufox dispatches synthesized mouse events inside `activateAndRun()`
(additions/juggler/TargetRegistry.js), which serializes every dispatch on a
*process-global* promise chain. Each dispatch awaits a `hit-renderer` ack from
the content process. If an ack never arrives, the callback never returns, the
global chain never advances, and every later input event in the process hangs
behind it forever. Two triggers were already fixed and are guarded by
humanize-edge-deadlock.py (the far edges, x==width / y==height, #225) and
noop-mousemove-deadlock.py (a zero-displacement move). This is the third: the
*near* edge, y==0.
MECHANISM
`sendOne()` in PageHandler.js dispatches at `eventY + boundingBox.top`, so a
relative y of 0 dispatches at absolute y == `boundingBox.top` exactly -- the
first row of the content area. That row is only partly covered whenever the
chrome above it is a fractional number of CSS pixels tall, and the widget rounds
the coordinate to a whole device row before hit-testing it. When
`round(top) < top` the rounded row still belongs to chrome, the event fires as
an exit event rather than eMouseMove, no ack is produced, and the chain wedges.
That makes it a deterministic property of the chrome height, which Camoufox
varies with the spoofed OS. Measured on this build (`window.mozInnerScreenY`
reports the rounded content origin, so it shows which side of the boundary the
rounding lands on):
os boundingBox.top rounds to page.mouse.move(31, 0)
windows 51.4 51 (above) hangs, 5/5
macos 53.1 53 (above) hangs, 5/5
linux 56.5 57 (below) completes, 8/8
So the default randomized fingerprint reaches it on most launches, and a run
that spoofs Linux never does -- which is why this went unnoticed while the
far-edge guards were in place. Relative x == 0 is unaffected for the same
reason: `boundingBox.left` is a whole 0, so it needs no rounding.
WHY NOT WIDEN THE BOUNDS CHECK
The far edges were fixed by treating them as out-of-viewport. 0 cannot be: it is
a legitimate in-viewport coordinate a caller may ask for, and the out-of-viewport
branch silently drops mousedown/mouseup, so widening the check would turn the
hang into a click that reports success and fires nothing (#752). The fix snaps
the dispatched coordinate to the first whole pixel inside the browser element,
which stays within content pixel 0 while landing clear of the boundary.
COVERAGE
Both dispatch paths reach the same conversion, so both are covered:
* direct dispatch -- `page.mouse.move(x, 0)`, humanize off, and the humanized
move's explicit endpoint. Deterministic on an affected chrome offset.
* humanized trajectory -- how it is actually hit in the field. Every
PageHandler starts at `_lastTrackedPos = {x: 0, y: 0}` (PageHandler.js:86),
so a session's FIRST humanized move always departs from the top-left corner,
and with the +/-80px knot boundary from MouseTrajectories.hpp the curve rides
the y==0 row. On a stock build a first humanized click hung on 5 of 20 cold
pages; all five had dispatched a point at y==0 and the 15 that completed had
dispatched none. Sampled here rather than asserted deterministically, since
whether the curve touches the row is random.
Run against a specific build:
CAMOUFOX_EXECUTABLE_PATH=/path/to/camoufox-bin python3 tests/patches/near-edge-mouse-deadlock.py
What PASS means:
* a move onto the top edge completes on every spoofed OS, and the page
actually observes it -- an event swallowed by chrome leaves no mousemove;
* the browser still responds to input afterwards, proving the global chain
is not poisoned;
* a session's first humanized click completes on repeated cold pages.
Before the fix the first direct move times out; after it, every move completes.
"""
import asyncio
import os
import sys
from camoufox.async_api import AsyncCamoufox
# The chrome height, and so whether the top row rounds into chrome, depends on
# the spoofed OS. Cover all three rather than assuming which one this host's
# chrome puts on the wrong side of the boundary.
SPOOFED_OSES = ["windows", "macos", "linux"]
# Relative y == 0 is the deadlock coordinate. x is varied only to show it is the
# whole row that is poisoned, not one particular pixel.
TOP_EDGE_TARGETS = [(31, 0), (0, 0), (500, 0)]
# Fresh pages for the humanized half: each resets _lastTrackedPos to (0, 0), so
# each is an independent chance for the first trajectory to ride the top edge.
COLD_PAGES = 4
# Close enough to the top that a trajectory from (0, 0) sweeps the y==0 row.
HUMANIZED_TARGET = (660, 186)
INTERIOR = (250, 250)
TIMEOUT_S = 20
EXECUTABLE_PATH = os.environ.get("CAMOUFOX_EXECUTABLE_PATH")
RECORDER = "window.__moves=0;addEventListener('mousemove',()=>window.__moves++)"
# mozInnerScreenY - screenY is the chrome height as the widget rounds it, which
# is the number that decides whether row 0 hit-tests into content or chrome.
CHROME_OFFSET = "window.mozInnerScreenY - window.screenY"
def _launch_kwargs(humanize, spoofed_os):
kwargs = dict(headless=True, os=spoofed_os, humanize=humanize)
if EXECUTABLE_PATH:
kwargs["executable_path"] = EXECUTABLE_PATH
return kwargs
def _deadlock_report(what):
print(
f"\n DEADLOCK: {what} produced no hit-renderer ack. The global activation\n"
" chain is now wedged -- all further input hangs.\n"
" Fix: snap the dispatched coordinate to the first whole pixel inside the\n"
" browser element in PageHandler.js sendOne(), so a relative 0 does not\n"
" land on the fractional chrome/content boundary.\n"
)
async def _direct_moves() -> bool:
"""A plain move onto the top edge must complete and be seen by the page."""
print("\n=== direct moves onto the top edge (humanize off) ===")
for spoofed_os in SPOOFED_OSES:
async with AsyncCamoufox(**_launch_kwargs(False, spoofed_os)) as browser:
page = await browser.new_page()
await page.set_content('<body style="margin:0;height:1200px"></body>')
await page.evaluate(RECORDER)
offset = await page.evaluate(CHROME_OFFSET)
print(f" [{spoofed_os}] content starts at screen row {offset}")
# Start from an interior point so the move under test is a real
# displacement, not a no-op skipped before dispatch.
await asyncio.wait_for(page.mouse.move(*INTERIOR), timeout=TIMEOUT_S)
for x, y in TOP_EDGE_TARGETS:
label = f" [{spoofed_os}] move -> ({x}, {y})"
before = await page.evaluate("window.__moves")
try:
await asyncio.wait_for(page.mouse.move(x, y), timeout=TIMEOUT_S)
except asyncio.TimeoutError:
print(f"{label} FAIL: no ack after {TIMEOUT_S}s")
_deadlock_report(f"a mousemove at ({x}, {y})")
return False
after = await asyncio.wait_for(
page.evaluate("window.__moves"), timeout=TIMEOUT_S
)
if after == before:
print(f"{label} FAIL: dispatched but the page saw no mousemove")
print(
"\n The event was delivered outside the content area. It did not\n"
" hang this time, but it never reached the renderer either.\n"
)
return False
print(f"{label} ok")
await asyncio.wait_for(page.mouse.move(*INTERIOR), timeout=TIMEOUT_S)
# The chain survived: prove input still works rather than trusting the
# absence of a timeout above.
try:
await asyncio.wait_for(page.mouse.move(400, 300), timeout=TIMEOUT_S)
live = await asyncio.wait_for(
page.evaluate("window.__moves"), timeout=TIMEOUT_S
)
except asyncio.TimeoutError:
print(f" [{spoofed_os}] FAIL: unresponsive after the edge moves")
return False
if not live:
print(f" [{spoofed_os}] FAIL: no mousemove observed at all")
return False
return True
async def _humanized() -> bool:
"""The humanize path reaches the same conversion, by endpoint and by curve."""
print("\n=== humanized moves (humanize on) ===")
for spoofed_os in SPOOFED_OSES:
async with AsyncCamoufox(**_launch_kwargs(True, spoofed_os)) as browser:
# A humanized move whose destination IS the top edge: the trajectory's
# explicit endpoint dispatch is unconditional, so this is the
# deterministic half.
page = await browser.new_page()
await page.set_content('<body style="margin:0;height:1200px"></body>')
await page.evaluate(RECORDER)
await asyncio.wait_for(page.mouse.move(*INTERIOR), timeout=TIMEOUT_S)
label = f" [{spoofed_os}] humanized move -> (500, 0)"
try:
await asyncio.wait_for(page.mouse.move(500, 0), timeout=TIMEOUT_S)
except asyncio.TimeoutError:
print(f"{label} FAIL: no ack after {TIMEOUT_S}s")
_deadlock_report("a humanized move ending at y==0")
return False
print(f"{label} ok")
await page.close()
# Cold pages: the trajectory departs (0, 0) and may ride the y==0 row.
for i in range(1, COLD_PAGES + 1):
label = f" [{spoofed_os}] cold page {i}/{COLD_PAGES}: click -> {HUMANIZED_TARGET}"
page = await browser.new_page()
await page.set_content(
'<button id="b" style="position:absolute;left:600px;top:160px;'
'width:120px;height:52px">go</button>'
)
await page.evaluate(
"window.__clicked=false;document.getElementById('b')"
".addEventListener('click',()=>window.__clicked=true)"
)
try:
# click() moves first, so this is the session's first
# trajectory -- generated from the initial (0, 0).
await asyncio.wait_for(page.click("#b"), timeout=TIMEOUT_S)
clicked = await asyncio.wait_for(
page.evaluate("window.__clicked"), timeout=TIMEOUT_S
)
except asyncio.TimeoutError:
print(f"{label} FAIL: no ack after {TIMEOUT_S}s")
_deadlock_report("a humanized trajectory point at y==0")
return False
if not clicked:
print(f"{label} FAIL: click completed but the target never fired")
return False
print(f"{label} ok")
await page.close()
return True
async def main() -> int:
if not await _direct_moves():
return 1
if not await _humanized():
return 1
print("\n PASS: top-edge moves completed and were seen; input still live\n")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))