fix(juggler): don't deadlock on a no-op (zero-displacement) mousemove

A mousemove whose destination rounds to the pixel the pointer is already
on generates no eMouseMove in the widget, so the
juggler-mouse-event-hit-renderer notification never fires and the
sendEvents() call awaits an ack that never arrives. Because input dispatch
is serialized on activateAndRun()'s process-global promise chain
(TargetRegistry.js), that one stuck await wedges EVERY later input event
for the life of the page — the page goes permanently unresponsive at 0%
CPU with nothing in flight.

Same activation-chain failure family as #225, but a distinct trigger: not
an out-of-viewport / exact-edge coordinate (those are already guarded),
but a move with no movement at all.

It is reachable as the FIRST mouse action of a session. The pointer starts
at (0,0) (this._lastTrackedPos), so any first move that rounds to the
origin is a no-op:

    page.mouse.move(0, 0)      // exact origin
    page.mouse.move(0.4, 0.4)  // rounds to (0, 0)

A humanized driver whose cursor model initialises to 0,0 and whose first
move is a short hop near the corner hits this every run. It manifests
through Playwright's sync client (the async client dedupes a move to the
current position and never sends it).

Fix: in the mousemove branch, if the rounded destination equals the
rounded current position, record the position and return without
dispatching. A no-op move has nothing to send.

Verified by warm-rebuilding the beta.28 omni.ja (juggler is JS, no C++
recompile) and A/B/C testing: stock and a pristine no-edit repack both
wedge on `move(0,0)`; only this one-line guard makes it survive, and a
genuine move afterwards still fires. tests/patches/noop-mousemove-deadlock.py
fails on stock and passes with the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jake Writer
2026-07-24 20:15:16 -06:00
co-authored by Claude Opus 4.8
parent 0583c3ec94
commit 16e5a13bb7
2 changed files with 145 additions and 0 deletions
+15
View File
@@ -634,6 +634,21 @@ export class PageHandler {
return;
}
// Skip a zero-displacement mousemove. When the destination rounds to the
// pixel the pointer is already on, the widget generates no eMouseMove, so
// the juggler-mouse-event-hit-renderer notification never fires and the
// sendEvents() call below awaits an ack that will never arrive. Because
// input dispatch is serialized on activateAndRun()'s process-global chain,
// that one stuck await wedges EVERY later input event for the life of the
// page. Reproduces on a stock build as the first action:
// await page.mouse.move(0, 0); // cursor starts at 0,0 -> no-op -> hang
// A no-op move has nothing to dispatch anyway, so return once the tracked
// position is (re)recorded.
if (Math.round(x) === Math.round(this._lastTrackedPos.x) &&
Math.round(y) === Math.round(this._lastTrackedPos.y)) {
this._lastTrackedPos = { x, y };
return;
}
const watcher = new EventWatcher(this._pageEventSink, ['dragstart', 'juggler-drag-finalized'], this._pendingEventWatchers);
await sendEvents(['mousemove']);
// Camoufox: remember where the cursor landed so the next humanized
+130
View File
@@ -0,0 +1,130 @@
"""
Verify a zero-displacement (no-op) mousemove does not deadlock the input chain.
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. (Same activation-chain machinery as daijro/camoufox#225, but
a distinct trigger.)
The trigger here is a mousemove whose destination rounds to the pixel the
pointer is ALREADY on. The widget coalesces it away and dispatches no
`eMouseMove`, so no `juggler-mouse-event-hit-renderer` notification is ever
produced and the awaited ack never arrives.
It bites in practice because the pointer starts at (0,0)
(`PageHandler.js` `this._lastTrackedPos = { x: 0, y: 0 }`), so the FIRST mouse
action of a session deadlocks whenever it targets that pixel:
page.mouse.move(0, 0) # exact origin
page.mouse.move(0.4, 0.4) # rounds to (0, 0)
Any humanized driver whose cursor model initialises to 0,0 and whose first move
is a short hop near the corner reaches this.
Fix (PageHandler.js, the `type === 'mousemove'` branch): if the rounded
destination equals the rounded current position, record the position and return
without dispatching — a no-op move has nothing to send.
HOW THIS TEST WORKS
- Sync API. The bug only manifests through Playwright's *sync* client: the async
client dedupes a move to the current position and never sends it, so the
redundant dispatch never reaches juggler. camoufox's sync_api is a headline
interface, so this path is worth guarding.
- Child process. A hung sync call cannot be timed out in-process, and a wedged
browser also hangs `close()`, so the work runs in a child the parent bounds
with a timeout and kills on hang.
- Several fresh pages per run. Whether (0,0) is a widget no-op depends on the
window's screen offset, which the randomized default fingerprint varies, so a
single page reproduces only ~80% of the time. Each fresh page is an
independent chance (its pointer starts at 0,0); across PAGES_PER_RUN the run
reproduces deterministically on a headless Linux host. On a build WITH the
fix, every page completes quickly and the run passes.
Run against a specific build:
CAMOUFOX_EXECUTABLE_PATH=/path/to/camoufox-bin python3 tests/patches/noop-mousemove-deadlock.py
Before the fix the child hangs on whichever page first reproduces and is killed.
After it, every page completes and a genuine move still fires a mousemove.
"""
import os
import subprocess
import sys
CHILD_ENV = "_NOOP_DEADLOCK_CHILD"
CHILD_TIMEOUT_S = 90
PAGES_PER_RUN = 6
# Both round to the origin, so both are no-op first moves. Alternated across the
# fresh pages below.
NOOP_TARGETS = [(0.0, 0.0), (0.4, 0.4)]
EXECUTABLE_PATH = os.environ.get("CAMOUFOX_EXECUTABLE_PATH")
def _run_child() -> int:
"""Open several fresh pages; each does a no-op first move then a real move."""
from camoufox.sync_api import Camoufox
# Default fingerprint on purpose — see the module docstring on why (0,0) must
# land on the window corner for the stock build to coalesce it into a no-op.
kwargs = dict(headless=True)
if EXECUTABLE_PATH:
kwargs["executable_path"] = EXECUTABLE_PATH
with Camoufox(**kwargs) as browser:
for i in range(PAGES_PER_RUN):
x, y = NOOP_TARGETS[i % len(NOOP_TARGETS)]
print(f" page {i + 1}/{PAGES_PER_RUN}: first move -> ({x}, {y})", flush=True)
page = browser.new_page()
page.goto("about:blank")
# Session's first mouse action, from the initial (0,0): a no-op.
# On a stock build this never returns and the parent kills us.
page.mouse.move(x, y)
# The activation chain must not be poisoned: a real move still fires.
page.evaluate(
"window.__ok=false;"
"addEventListener('mousemove',()=>window.__ok=true,{once:true})"
)
page.mouse.move(400, 300)
if not page.evaluate("window.__ok"):
print(f" page {i + 1}: genuine move produced no mousemove", flush=True)
return 2
page.close()
return 0
def main() -> int:
print("\n=== no-op first mousemove ===")
env = {**os.environ, CHILD_ENV: "1"}
try:
r = subprocess.run(
[sys.executable, os.path.abspath(__file__)],
env=env, timeout=CHILD_TIMEOUT_S,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired as e:
sys.stdout.write((e.stdout or b"").decode(errors="replace"))
print(f"\n FAIL: hung > {CHILD_TIMEOUT_S}s")
print(
"\n DEADLOCK: a no-op mousemove (destination rounds to the current pixel)\n"
" produced no eMouseMove and no hit-renderer ack. The global activation\n"
" chain is wedged -- all further input hangs.\n"
" Fix: skip zero-displacement moves in PageHandler.js (mousemove branch).\n"
)
return 1
sys.stdout.write(r.stdout.decode(errors="replace"))
if r.returncode == 0:
print("\n PASS: every no-op first move completed; input still live; chain not poisoned\n")
return 0
print(f"\n FAIL: child exit {r.returncode}")
return 1
if __name__ == "__main__":
if os.environ.get(CHILD_ENV):
sys.exit(_run_child())
sys.exit(main())