fix(juggler): apply exact-edge bounds guard to humanized trajectory points (#225)

Commit 9270618 fixed the exact-edge mousemove deadlock for the requested
endpoint by treating x==width / y==height as out-of-viewport (>= not >),
now at PageHandler.js:589. A mousemove landing exactly on the edge fires as
an exit event rather than eMouseMove, so no hit-renderer ack is produced;
because dispatch is serialized on the process-global activation chain in
TargetRegistry.js activateAndRun(), one missing ack wedges all later input.

The humanize trajectory added afterwards in 15f2912 (#677) introduced a
second, independent bounds check for the intermediate points it generates,
written in the old strict-> form. Those points never pass through the
endpoint guard, so they could still land exactly on the edge and deadlock:

    PageHandler.js:566   currentX >  boundingBox.width   intermediate points
    PageHandler.js:589   x        >= boundingBox.width   requested endpoint

This is reachable rather than theoretical. MouseTrajectories.hpp:65-74 rounds
every point to int, and :83-86 builds the curve's control knots with a +/-80px
boundary around the endpoints, so any target within 80px of an edge produces a
curve that sweeps across the boundary column and lands on it exactly. Points
beyond the edge are skipped safely by the continue; points on it are dispatched
and hang.

Reproduced against the built v152.0.4-beta.25 binary: a humanized move to
(997, 200) in a 1000x700 viewport hung indefinitely (20s timeout) and left the
browser unresponsive to all further input. With the guard aligned, all five
edge-directed moves complete and input stays live.

Add tests/patches/humanize-edge-deadlock.py covering it, and confirm
humanize-mouse-trajectory.py (#677) still passes: 151 intermediate moves
ending exactly on target, humanized click still lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jake Writer
2026-07-18 19:18:35 -06:00
parent 80080757d2
commit 541ffca302
2 changed files with 130 additions and 2 deletions
+5 -2
View File
@@ -562,8 +562,11 @@ export class PageHandler {
for (let i = 2; i < trajectory.length - 2; i += 2) {
const currentX = trajectory[i];
const currentY = trajectory[i + 1];
// Skip movement that is out of bounds
if (currentX < 0 || currentY < 0 || currentX > boundingBox.width || currentY > boundingBox.height)
// Skip movement that is out of bounds. Must match the endpoint guard
// below (>=, not >): a point at exactly x==width or y==height fires as
// an exit event instead of eMouseMove, so the hit-renderer ack never
// arrives and every later input event hangs behind it forever.
if (currentX < 0 || currentY < 0 || currentX >= boundingBox.width || currentY >= boundingBox.height)
continue;
await sendOne('mousemove', currentX, currentY);
await new Promise(resolve => setTimeout(resolve, 10));
+125
View File
@@ -0,0 +1,125 @@
"""
Verify a humanized mousemove toward a viewport edge does not deadlock (daijro/camoufox#225).
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.
A mousemove at exactly x==width or y==height fires as an exit event rather than
eMouseMove, so no ack is ever produced. Commit 9270618 fixed this for the
*requested endpoint* by treating exact-edge coordinates as out-of-viewport
(`>=` rather than `>`, PageHandler.js:589).
The humanize trajectory added later (commit 15f2912, #677) introduced a second,
independent bounds check for the *intermediate* points it generates, written in
the old strict-`>` form -- so those points bypass the endpoint guard entirely
and can still land exactly on the edge:
PageHandler.js:566 currentX > boundingBox.width <-- intermediate points
PageHandler.js:589 x >= boundingBox.width <-- requested endpoint
This is reachable rather than theoretical: MouseTrajectories.hpp:65-74 rounds
every point to `int`, and :83-86 generates the curve's control knots with a
+/-80px boundary around the endpoints -- so a target within 80px of an edge
produces a curve that sweeps across the boundary column and lands on it exactly.
Points *beyond* the edge are skipped safely by the `continue`; points *on* it
are dispatched and hang.
Run against a specific build:
CAMOUFOX_EXECUTABLE_PATH=/path/to/camoufox-bin python3 tests/patches/humanize-edge-deadlock.py
What PASS means:
* repeated humanized moves toward the right and bottom edges all complete
within the timeout (no missing ack, no wedged activation chain);
* the browser still responds to input afterwards, proving the global chain
is not poisoned.
Before the fix this times out on one of the edge moves. After it, all complete.
"""
import asyncio
import os
import sys
from camoufox.async_api import AsyncCamoufox
VIEWPORT = {"width": 1000, "height": 700}
# Targets inside the viewport but within the trajectory's +/-80px knot boundary
# of an edge, so the generated curve sweeps across the boundary column.
EDGE_TARGETS = [
(VIEWPORT["width"] - 1, 350),
(VIEWPORT["width"] - 3, 200),
(500, VIEWPORT["height"] - 1),
(500, VIEWPORT["height"] - 3),
(VIEWPORT["width"] - 2, VIEWPORT["height"] - 2),
]
MOVE_TIMEOUT_S = 20
EXECUTABLE_PATH = os.environ.get("CAMOUFOX_EXECUTABLE_PATH")
def _launch_kwargs():
kwargs = dict(headless=True, os="linux", humanize=True)
if EXECUTABLE_PATH:
kwargs["executable_path"] = EXECUTABLE_PATH
return kwargs
async def main() -> int:
async with AsyncCamoufox(**_launch_kwargs()) as browser:
page = await browser.new_page(no_viewport=True)
await page.set_viewport_size(VIEWPORT)
await page.set_content(
f'<body style="margin:0;width:{VIEWPORT["width"]}px;'
f'height:{VIEWPORT["height"]}px"></body>'
)
print(f"\n=== humanized moves toward viewport edges ({VIEWPORT}) ===")
await page.mouse.move(20, 20)
for i, (x, y) in enumerate(EDGE_TARGETS, 1):
label = f" [{i}/{len(EDGE_TARGETS)}] move -> ({x}, {y})"
try:
await asyncio.wait_for(page.mouse.move(x, y), timeout=MOVE_TIMEOUT_S)
except asyncio.TimeoutError:
print(f"{label} FAIL: no ack after {MOVE_TIMEOUT_S}s")
print(
"\n DEADLOCK: an intermediate trajectory point landed exactly on the\n"
" viewport edge, was dispatched, and never produced a hit-renderer ack.\n"
" The global activation chain is now wedged -- all further input hangs.\n"
" Fix: use >= (not >) in the humanize bounds check, PageHandler.js:566.\n"
)
return 1
print(f"{label} ok")
# The chain survived: prove input still works rather than trusting the
# absence of a timeout above.
try:
await asyncio.wait_for(
page.evaluate(
"window.ok=false;"
"addEventListener('mousemove',()=>window.ok=true,{once:true})"
),
timeout=MOVE_TIMEOUT_S,
)
await asyncio.wait_for(page.mouse.move(400, 300), timeout=MOVE_TIMEOUT_S)
responsive = await asyncio.wait_for(
page.evaluate("window.ok"), timeout=MOVE_TIMEOUT_S
)
except asyncio.TimeoutError:
print("\n FAIL: browser unresponsive after the edge moves")
return 1
if not responsive:
print("\n FAIL: no mousemove observed after the edge moves")
return 1
print("\n PASS: all edge-directed humanized moves completed; input still live\n")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))