← Commit history

Fix the pup cold-spawn flap that read as 'pup is down': /status (ab's healthEndpoint) no longer triggers the ~8s native heavy-init and now answers a warming chip instantly if chrome is not loaded yet, so ab's spawn-ready probe never times out and reaps the bridge; heavy init + warmup + session recovery are deferred off the bind tick so the event loop serves health probes the instant the port binds; mark the bridge spawn persistent so ab's supervisor keeps it warm instead of reaping it to zero instances between calls; and give the hwnd-resolution PowerShell a unique per-call temp filename (unlinked after use) to end the EBUSY spin loop that hammered one shared file and burned CPU

John Lauer ·74d6cb7fb4 ·2mo ago ·parent ee7ed09
4 files changed +54−24
adom-bridge-puppeteer-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "2.0.12",-  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.12/adom-bridge-puppeteer-v2.0.12.zip",-  "sha256": "d8336009842b006397d70c27509f9a082d2805e2afef0f8000e97427c4becca7",-  "size": 639593,+  "version": "2.0.13",+  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.13/adom-bridge-puppeteer-v2.0.13.zip",+  "sha256": "2563c200160be0ffaacb2fdf518aa35add931d68de8a240f8bb692fc8c53bddb",+  "size": 640581,   "verbPrefixes": [     "browser_",     "desktop_recorder_",
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-2.0.12+2.0.13
src/bridge.json+3−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "2.0.12",+  "version": "2.0.13",   "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.",@@ -13,7 +13,8 @@     "port": 0,     "healthEndpoint": "/status",     "stopMethod": "kill",-    "killImageName": "node.exe"+    "killImageName": "node.exe",+    "persistent": true   },   "verbPrefixes": [     "pup_",
src/server.js+46−17
@@ -5530,7 +5530,12 @@ async function resolveSessionHwndByBounds(session, sessionId) {     // the sole-window-of-a-profile check below — none of which touch window geometry. A window none of     // those can resolve is left BARE (no overlay), which is fine; mangling the user's window is not.     // markerW stays null, so the matching loop below is pure read-only geometry (no setWindowBounds).-    const scriptPath = path.join(require('os').tmpdir(), `_adom_hwnd_${process.pid}.ps1`);+    // v2.0.13 (John): UNIQUE temp file per invocation. This used a single `_adom_hwnd_<pid>.ps1`, so+    // two concurrent hwnd resolutions (e.g. the startup brandSweep touching several windows at once)+    // collided on the same path and threw `EBUSY: resource busy or locked`. On a shared-profile window+    // that never resolved (many wiggle-matches), the retry hammered that same file in a tight loop,+    // spamming EBUSY and burning CPU (John's laptop-load report). A per-call filename removes the race.+    const scriptPath = path.join(require('os').tmpdir(), `_adom_hwnd_${process.pid}_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}.ps1`);     // CSS bounds -> physical px via the DPI scale, then match a top-level window of ANY chrome.exe     // in this profile's process tree by position (a small tolerance absorbs frame/DPI rounding).     const ps = [@@ -5561,7 +5566,11 @@ async function resolveSessionHwndByBounds(session, sessionId) {       '[WEnum]::Find() | ForEach-Object { Write-Output $_ }',     ].join('\r\n');     fs.writeFileSync(scriptPath, ps);-    const out = (await runPsHiddenPromise(scriptPath, { timeout: 9000 }) || '').trim();+    // v2.0.13 (John): unique per-call filename (above) means we MUST clean it up or a persistent bridge+    // leaks a tiny .ps1 into tmpdir on every resolution. Unlink in finally regardless of outcome.+    let out = '';+    try { out = (await runPsHiddenPromise(scriptPath, { timeout: 9000 }) || '').trim(); }+    finally { try { fs.unlinkSync(scriptPath); } catch (e) {} }     if (!out) return null;     // Every chrome.exe under this profile counts (renderers have no windows, so PID may be the parent     // or a sibling in the tree — accept any, then disambiguate purely on geometry).@@ -9969,7 +9978,12 @@ const server = http.createServer(async (req, res) => {   // v1.9.334 (#468): everything except the lightweight /health probe needs the deferred native modules   // (puppeteer/sharp/keytar/chrome). Load them on first use; /health stays answerable during the ~8s   // one-time load so AD never mistakes a warming bridge for a wedged one.-  if (pathname !== '/health') ensureHeavyInit();+  // v2.0.13 (John, "everyone keeps saying pup is down"): ALSO exclude /status. pup declares /status as+  // its healthEndpoint (for the rich cold-start chip), so ab's spawn-ready probe hits /status; when+  // /status triggered the ~8s heavy init it blocked the event loop and ab timed out and reaped the+  // bridge ("never bound its port 64230"), the exact cold-spawn flap. /status now answers instantly+  // (it guards `chrome` for the pre-init window) and never gates on the native load.+  if (pathname !== '/health' && pathname !== '/status') ensureHeavyInit();    try {     // GET /health@@ -14114,6 +14128,19 @@ const server = http.createServer(async (req, res) => {      // GET /status     if (pathname === '/status') {+      // v2.0.13 (John): /status no longer triggers ensureHeavyInit, so on a cold spawn `chrome` may+      // not be loaded yet. Answer the chip probe INSTANTLY with a warming state rather than throwing+      // on chrome.readiness() — that is what let ab's spawn-ready probe succeed instead of reaping us.+      if (!chrome || !chrome.readiness) {+        sendJSON(res, {+          ok: true, bridge: 'puppeteer', version: BRIDGE_VERSION,+          led: 'yellow', summary: 'starting',+          tooltip: `Puppeteer bridge v${BRIDGE_VERSION} — warming up (loading runtime, recovering sessions)`,+          warming: true, ready: false,+          sessionCount: sessions.size, activeSession: activeSessionId,+        });+        return;+      }       const list = [];       for (const [id, s] of sessions) {         let pageUrl = null;@@ -14209,16 +14236,22 @@ server.keepAliveTimeout = 30_000; const BIND_HOST = process.env.ADOM_BIND_HOST || '127.0.0.1'; server.listen(PORT, BIND_HOST, async () => {   console.log(`Puppeteer Bridge running on http://${BIND_HOST}:${PORT} (bind=${BIND_HOST}; no firewall prompt)`);-  // v1.9.334 (#468): the PORT IS NOW BOUND (this callback fires after the OS bind), so AD already sees-  // the bridge listening and will not reap it. NOW do the heavy native loading that was deferred out of-  // the synchronous module-load path. It blocks briefly, but the port is up, so a reap can't happen.-  ensureHeavyInit();   console.log('Multi-session support: sessions are tabs sharing Chrome profiles via userDataDir');   console.log('Endpoints: /health, /command, /launch, /navigate, /reload, /screenshot, /screenshot.png, /errors, /eval, /wait, /status, /close');-  // Self-warm: detect browsers + disk the moment the bridge spawns (right after AD-  // installs/updates it), so the FIRST open is instant. Only fetches CfT if the box-  // has literally no Chrome/Edge and no cached CfT — otherwise it's a cheap no-op.-  try { chrome.warmup(); } catch (e) { console.error('warmup failed:', e.message); }+  // v2.0.13 (John, "everyone keeps saying pup is down"): DEFER the heavy native load + warm-up ++  // session recovery OFF the bind tick with setImmediate, so the event loop is free to answer /health+  // and /status (ab's spawn-ready probe) the instant the port binds. Previously ensureHeavyInit() ran+  // synchronously here and blocked the loop for the whole ~8s native load; a probe landing in that+  // window stalled and ab reaped the bridge ("never bound its port 64230") — the cold-spawn flap.+  // Verbs still force ensureHeavyInit() on first use (idempotent), so nothing races ahead of its deps.+  setImmediate(async () => {+    try { ensureHeavyInit(); } catch (e) { console.error('heavy init failed:', e.message); }+    // Self-warm: detect browsers + disk so the FIRST open is instant (cheap no-op if a browser exists).+    try { if (chrome && chrome.warmup) chrome.warmup(); } catch (e) { console.error('warmup failed:', e.message); }+    // Recover sessions from disk (reconnect to Chrome windows that survived a bridge restart).+    // Fire-and-forget: recovery must NEVER gate readiness or block the health probe.+    try { await recoverSessions(); } catch (e) { console.error('Session recovery failed:', e.message); }+  });   // v1.8.73: register the Adom.Pup app identity once per bridge run (HKCU AUMID + Start Menu   // shortcut whose pin launches a managed pup window). Loopback call from the bridge itself —   // trusted transport, no relay gating. Self-gates on AD <1.9.134 (unknown verb).@@ -14243,12 +14276,8 @@ server.listen(PORT, BIND_HOST, async () => {   }, 8000);   setTimeout(() => { ensureWindowOpenedWatch().catch(() => {}); }, 9000); -  // Recover sessions from disk (reconnect to Chrome windows that survived bridge restart)-  try {-    await recoverSessions();-  } catch (e) {-    console.error('Session recovery failed:', e.message);-  }+  // Session recovery now runs in the setImmediate above (fire-and-forget), so it never gates the+  // health probe or blocks the bind tick.    // v1.9.45: sweep orphaned AUMID keys, but LATE. Running it straight after recoverSessions() looked   // right and was wrong: when recovery legitimately ends with zero sessions (or is still settling),