diff --git a/additions/juggler/protocol/PageHandler.js b/additions/juggler/protocol/PageHandler.js index d40ab65..6b9e208 100644 --- a/additions/juggler/protocol/PageHandler.js +++ b/additions/juggler/protocol/PageHandler.js @@ -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)); diff --git a/tests/patches/humanize-edge-deadlock.py b/tests/patches/humanize-edge-deadlock.py new file mode 100644 index 0000000..ed67e03 --- /dev/null +++ b/tests/patches/humanize-edge-deadlock.py @@ -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'' + ) + + 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()))