← Commit history

v2.0.321: #72 fixed: pup_input_dispatch no longer wedges a session when one in-page click handler hangs (the unfocused-window clipboard.writeText trap). Trusted mouse input now goes through a dedicated, per-call-timeout CDP session pup owns, instead of puppeteer's page.mouse (whose internal lock, once a dispatch hangs, wedged every later dispatch on the page with no way for pup_reload/pup_rescan to clear it). A hang now times out at 6s, the input CDP session is discarded, and the next dispatch is clean; the error says the channel was reset and to retry. type/key are timeout-guarded; pup_rescan also resets the input channel.

John Lauer ·6f3da99ea7 ·1mo ago ·parent 85f9e93
4 files changed +67−23
adom-bridge-puppeteer-manifest.json+5−5
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "2.0.320",-  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.320/adom-bridge-puppeteer-v2.0.320.zip",-  "sha256": "64b2680ccbb00461b53e910325eb5f128d9d9c3dfa458eec2a6e1c13376d684d",-  "size": 1437739,+  "version": "2.0.321",+  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.321/adom-bridge-puppeteer-v2.0.321.zip",+  "sha256": "155b4cfdf0db43b8cbcce7713e3ca3d7d8c977efd8be2b5deadd50ba539905b9",+  "size": 1439179,   "verbPrefixes": [     "browser_",     "desktop_recorder_",@@ -12,5 +12,5 @@   ],   "healthEndpoint": "/status",   "statusVerb": "browser_readiness",-  "released_at": "2026-08-24T02:20:06.168342Z"+  "released_at": "2026-08-24T02:35:00.442566Z" }
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-2.0.320\ No newline at end of file+2.0.321\ No newline at end of file
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "2.0.320",+  "version": "2.0.321",   "description": "Drives a browser ALREADY on the machine \u2014 installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) \u2014 launched with a FRESH isolated profile, so the common case has NO ~150 MB Chrome-for-Testing download and it never touches the user's real tabs/logins. Binds its local server to LOOPBACK (127.0.0.1) only, so no Windows Firewall 'allow Node through the firewall' prompt ever pops. Chrome for Testing is fetched only as a LAST RESORT if the machine has no Chromium browser at all. SPAWN-VERIFIED fallback: pup tries each detected browser (Chrome \u2192 Edge \u2192 CfT) and actually launches it, falling through to the next if one won't start (and handling browsers like Edge that relaunch onto another PID), so a corrupt/partial install never hard-fails an open. The browser that launches is cached as the default (persisted across restarts) so it isn't re-probed each time. On spawn the bridge SELF-WARMS: detects browsers + disk, and only if the box has no Chrome/Edge and no cached CfT does it background-fetch CfT so the first open is instant. pup_use pins a specific browser (chrome | edge | cft | auto); {browser:'chrome',install:true} downloads & installs REAL Google Chrome (user-scoped, no admin) on an Edge-only box. pup_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the default + free disk. Every failure returns a specific errorCode + _hint (chrome_install_no_disk, chrome_download_network, chrome_install_permission, chrome_stable_not_detected, \u2026). Launches with FULL browser permissions by default for full debug (strictPermissions:true opts out). Headful automation: open/close windows+tabs, navigate, screenshot, eval JS, record. pup is a GENERIC, non-profile browser only \u2014 every session is a fresh isolated Chrome/Edge process, never the user's real signed-in browser; profile defaults to sessionId. To drive the user's REAL Chrome/Edge (their profile, cookies, SSO), use the Adom browser EXTENSION's nbrowser_* verbs \u2014 a separate bridge, not pup's job. macOS (v1.9.74+): full parity with mac-native mechanics \u2014 background-by-default is the app-level hide (windows stay drivable/screenshottable while hidden), raise/lower map to unhide+activate/hide, pup_alert_window posts a native toast whose click focuses the window, pup_use {browser:'chrome',install:true} installs Chrome from the official dmg, and taskbar identity/badging remains Windows-only.",   "homepage": "https://wiki.adom.inc/adom/adom-bridge",   "author": "Adom Inc.",
src/server.js+60−16
@@ -2515,6 +2515,51 @@ async function hidePupCursorEverywhere(page) {   }).catch(() => {}))); } +// v2.0.321 (#72): trusted input via a DEDICATED, TIMEOUT-BOUNDED CDP session, NOT puppeteer's+// page.mouse/keyboard. puppeteer serializes every mouse op behind an internal lock; when one+// Input.dispatchMouseEvent hangs (a click handler that awaits navigator.clipboard.writeText in an+// UNFOCUSED window neither resolves nor rejects), that lock is held forever and EVERY later dispatch+// on the page wedges — the session's input is dead until the window is recreated, because the lock+// lives in puppeteer's memory where pup_reload/pup_rescan cannot reach it. A per-tab CDP session we+// OWN, with a per-call deadline, isolates the failure: a hang times out, we DISCARD that session,+// and the next call makes a fresh one. One bad page handler can no longer poison the whole session.+async function _inputCdp(tab) {+  if (tab._inputCdp && !tab._inputCdpDead) return tab._inputCdp;+  const sess = await tab.page.target().createCDPSession();+  tab._inputCdp = sess; tab._inputCdpDead = false;+  return sess;+}+function killInputCdp(tab) {+  const sess = tab && tab._inputCdp;+  if (tab) { tab._inputCdp = null; tab._inputCdpDead = false; }+  if (sess) { try { const r = sess.detach(); if (r && r.catch) r.catch(() => {}); } catch (e) {} }+}+async function _rawInput(tab, method, params, ms = 6000) {+  const sess = await _inputCdp(tab);+  try {+    return await Promise.race([+      sess.send(method, params),+      new Promise((_, rej) => setTimeout(() => rej(new Error('input_dispatch_timeout')), ms)),+    ]);+  } catch (e) {+    // a hung/failed command leaves this CDP session with a stuck in-flight send; discard it so the+    // NEXT dispatch is clean. THIS is the #72 fix: the wedge can never persist past one call.+    killInputCdp(tab);+    if (String(e && e.message) === 'input_dispatch_timeout') { const err = new Error('input timed out after ' + ms + 'ms — a page handler hung (e.g. clipboard write in an unfocused window). The input channel was reset; retry, and if it persists the page handler is the cause.'); err.wedge = true; throw err; }+    throw e;+  }+}+const _btnMask = (b) => (b === 'right' ? 2 : b === 'middle' ? 4 : 1);+async function rawTrustedClick(tab, x, y, { button = 'left', clickCount = 1, ms = 6000 } = {}) {+  const b = button === 'right' ? 'right' : button === 'middle' ? 'middle' : 'left';+  await _rawInput(tab, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none', buttons: 0 }, ms);+  await _rawInput(tab, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: b, buttons: _btnMask(b), clickCount }, ms);+  await _rawInput(tab, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: b, buttons: 0, clickCount }, ms);+}+async function rawTrustedMove(tab, x, y, { ms = 6000 } = {}) {+  await _rawInput(tab, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none', buttons: 0 }, ms);+}+ async function showPupCursor(page, x, y, { click = true, label = null } = {}) {   try {     // Remove any stale instance ANYWHERE (legacy id, or one left in an iframe) before drawing.@@ -15164,6 +15209,9 @@ const server = http.createServer(async (req, res) => {           //     callers with random tabs in their sessions list.           try {             const adoptOrphans = !!args.adoptOrphans;+            // v2.0.321 (#72 ask): rescan also resets each tab's input CDP session, so a wedged input+            // channel is recovered by pup_rescan (in addition to the automatic discard-on-timeout).+            for (const [, ss] of sessions) { for (const t of (ss.tabs || [])) { try { if (t && t._inputCdp) killInputCdp(t); } catch (e) {} } }             const stats = await rescanAllProfiles({ adoptOrphans });             brandSweep('rescan').catch(() => {}); // v1.8.79: unify icons on re-adopted windows             // Build the post-rescan session list so callers can verify@@ -17572,13 +17620,13 @@ const server = http.createServer(async (req, res) => {                   }                 } -                // page.click does scrollIntoView + bounding-rect resolution + page.mouse.click.-                // Chromium dispatches via Input.dispatchMouseEvent → isTrusted=true.-                await chosenHandle.click({-                  button: args.button || 'left',-                  clickCount: args.clickCount || 1,-                  delay: args.delay || 0,-                });+                // v2.0.321 (#72): scroll into view, resolve the rect, then click via the ISOLATED+                // timeout-bounded CDP path (rawTrustedClick) instead of chosenHandle.click(), which+                // routes through puppeteer's wedge-prone mouse lock. isTrusted=true either way.+                try { await chosenHandle.scrollIntoView(); } catch (e) {}+                let _box = null; try { _box = await chosenHandle.boundingBox(); } catch (e) {}+                if (!_box || _box.width < 1 || _box.height < 1) { sendJSON(res, { success: false, error: 'chosen element has no visible box to click', selector: args.selector }); return; }+                await rawTrustedClick(tab, Math.round(_box.x + _box.width / 2), Math.round(_box.y + _box.height / 2), { button: args.button || 'left', clickCount: args.clickCount || 1 });                 sendJSON(res, { success: true, output: JSON.stringify({                   ok: true,                   sessionId: resolvedId,@@ -17610,11 +17658,7 @@ const server = http.createServer(async (req, res) => {                 sendJSON(res, { success: false, error: 'pup_input_dispatch click: provide either {selector} OR {x, y}' });                 return;               }-              await tab.page.mouse.click(args.x, args.y, {-                button: args.button || 'left',-                clickCount: args.clickCount || 1,-                delay: args.delay || 0,-              });+              await rawTrustedClick(tab, args.x, args.y, { button: args.button || 'left', clickCount: args.clickCount || 1 });               sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'click', via: 'coords', x: args.x, y: args.y }) });               return;             }@@ -17627,7 +17671,7 @@ const server = http.createServer(async (req, res) => {               // only clicks moved it, which undercuts the whole "gliding cursor" idea. Move the               // visible cursor with the real pointer (no click ripple).               if (args.showCursor !== false) { try { await showPupCursor(tab.page, Number(args.x), Number(args.y), { click: false, label: args.intent || 'Moving' }); } catch (e) {} }-              await tab.page.mouse.move(args.x, args.y, { steps: args.steps || 1 });+              await rawTrustedMove(tab, args.x, args.y);               sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'move', x: args.x, y: args.y }) });               return;             }@@ -17644,7 +17688,7 @@ const server = http.createServer(async (req, res) => {                   return;                 }               }-              await tab.page.keyboard.type(args.text, { delay: args.delay || 0 });+              await withTimeout(tab.page.keyboard.type(args.text, { delay: args.delay || 0 }), 15000, 'input type');               sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'type', length: args.text.length }) });               return;             }@@ -17653,13 +17697,13 @@ const server = http.createServer(async (req, res) => {                 sendJSON(res, { success: false, error: 'pup_input_dispatch key requires {key} (Enter|Escape|Tab|ArrowUp|...)' });                 return;               }-              await tab.page.keyboard.press(args.key, { delay: args.delay || 0 });+              await withTimeout(tab.page.keyboard.press(args.key, { delay: args.delay || 0 }), 8000, 'input key');               sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'key', key: args.key }) });               return;             }             sendJSON(res, { success: false, error: `pup_input_dispatch: unknown type "${args.type}". Supported: click | move | type | key` });           } catch (e) {-            sendJSON(res, { success: false, error: `pup_input_dispatch ${t} failed: ${e.message}` });+            sendJSON(res, { success: false, errorCode: e && e.wedge ? 'input_channel_reset' : undefined, error: `pup_input_dispatch ${t} failed: ${e.message}`, _hint: e && e.wedge ? 'The input channel was reset after a hung page handler; the session is NOT wedged — retry the dispatch. If it keeps hanging, the page handler itself (often an unfocused-window clipboard write) is the cause.' : undefined });           }           return;         }