← Commit history

Fix window-handle desync + stuck regenerate: time-box the width-wiggle lock so a hung holder cannot deadlock the whole chain (was leaving every window hwnd=None); persist the resolved OS handle to the session file and restore+validate it on respawn so windows stop re-wiggling after every ship; regenerate button preempts a wedged busy flag, per-window timeouts, and always clears the flag

John Lauer ·0b20a31827 ·3mo ago ·parent 7e3b93b
4 files changed +54−13
adom-bridge-puppeteer-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "1.9.299",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.299/adom-bridge-puppeteer-v1.9.299.zip",-  "sha256": "531605fa0e602bdebdf9bb535250ddd2a80402d77049b2bd07279ce72f3e7812",-  "size": 723296,+  "version": "1.9.300",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.300/adom-bridge-puppeteer-v1.9.300.zip",+  "sha256": "21149edbc6027da8684e66493fbc0878813012412ad35858b65f43682070aa73",+  "size": 724572,   "verbPrefixes": [     "browser_",     "desktop_recorder_",
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.299+1.9.300
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "1.9.299",+  "version": "1.9.300",   "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. browser_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. browser_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, browser_alert_window posts a native toast whose click focuses the window, browser_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-desktop",   "author": "Adom Inc.",
src/server.js+48−7
@@ -1111,6 +1111,12 @@ function saveSessionFile(sessionId, profileName, url) {     // and browser_list_windows reported owner:null for every session — silently disabling the     // session_owned_by_another_thread protection that owner is supposed to back.     owner: ((sessions.get(sessionId) || {}).owner) || null,+    // v1.9.300: persist the resolved OS window handle. Chrome windows survive a bridge respawn with+    // the SAME hwnd, so restoring + validating this on recovery lets a window keep its handle WITHOUT+    // re-running the fragile width-wiggle — which is what stormed/deadlocked after every ship and left+    // every window hwnd=None. Validated via hwndBelongsToPup before reuse, so a stale/recycled handle+    // is discarded, not trusted.+    _hwnd: ((sessions.get(sessionId) || {})._hwnd) || null,     createdAt: new Date().toISOString(),     updatedAt: new Date().toISOString(),   };@@ -1125,6 +1131,20 @@ function deleteSessionFile(sessionId) {   try { fs.unlinkSync(sessionFilePath(sessionId)); } catch {} } +// v1.9.300: patch JUST the resolved hwnd into a session's file without rewriting the rest (so a+// full saveSessionFile with url/pid isn't required at every resolution). Called whenever the handle+// is resolved, so a bridge respawn can restore it and skip the wiggle.+function persistSessionHwnd(sessionId, hwnd) {+  try {+    const f = sessionFilePath(sessionId);+    if (!fs.existsSync(f)) return;+    const d = JSON.parse(fs.readFileSync(f, 'utf8'));+    if (d._hwnd === (hwnd || null)) return;+    d._hwnd = hwnd || null; d.updatedAt = new Date().toISOString();+    fs.writeFileSync(f, JSON.stringify(d, null, 2));+  } catch (e) {}+}+ function readSessionFile(sessionId) {   try {     return JSON.parse(fs.readFileSync(sessionFilePath(sessionId), 'utf8'));@@ -1785,6 +1805,11 @@ async function recoverSessions() {       // park below yanked a window the user was actively using to the bottom (John, 2026-07-29).       if (sf.foreground) { session._foreground = true; session._userActiveAt = Date.now(); }       if (sf.owner) session.owner = sf.owner;   // v1.9.197 BUG 4: restore ownership across restarts+      // v1.9.300: restore the persisted OS handle. The Chrome window kept the SAME hwnd across the+      // respawn, so this lets the window skip the width-wiggle entirely; resolveSessionHwndByBounds+      // still validates it via hwndBelongsToPup before any paint, so a stale/recycled handle is+      // discarded. This is the fix for "every window came back hwnd=None after a ship."+      if (sf._hwnd) session._hwnd = Number(sf._hwnd);       // v1.9.202 (John's icon audit): a recovered window came back with NO taskbar badge. Under       // minimalTouch the overlay is applied only on OPEN and NAVIGATE, and the re-stamp sweeps that       // used to cover recovery are gated off — so every bridge restart left surviving windows bare@@ -4880,7 +4905,7 @@ async function resolveSessionHwndByBounds(session, sessionId) {     if (pupSettings().titleTag === 'on') try {       const fw = adPayload(await chrome.adCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000, caller: sessionCaller(session) }));       const b = fw && (fw.best || (fw.data && fw.data.best));-      if (b && b.hwnd) { session._hwnd = Number(b.hwnd); console.log(`[hwnd] "${sessionId}" resolved by TITLE tag -> ${b.hwnd}`); return Number(b.hwnd); }+      if (b && b.hwnd) { session._hwnd = Number(b.hwnd); persistSessionHwnd(sessionId, Number(b.hwnd)); console.log(`[hwnd] "${sessionId}" resolved by TITLE tag -> ${b.hwnd}`); return Number(b.hwnd); }     } catch (e) {}     const page = (session.tabs || [])[0] && (session.tabs || [])[0].page;     if (!page) return null;@@ -4903,7 +4928,16 @@ async function resolveSessionHwndByBounds(session, sessionId) {     // whichever window is momentarily at a marker width — collapsing them onto one hwnd. Take a     // global lock so exactly one wiggle (set width -> enumerate -> restore) runs at a time.     if (!session._foreground) {-      const _prev = _wiggleLock; let _res; _wiggleLock = new Promise(r => { _res = r; }); await _prev; _releaseWiggle = _res;+      // v1.9.300 (John: "you are way unsynchronized... still a disaster" — every window came back+      // hwnd=None and the regenerate sweep was stuck): the OLD lock did `await _prev` BEFORE assigning+      // _releaseWiggle, so if the previous holder ever hung, this call blocked forever at the await,+      // never set its own releaser (the outer catch couldn't free it), and it had ALREADY published an+      // unresolved promise as _wiggleLock — wedging the entire chain. Assign the releaser FIRST, then+      // time-box the wait so a hung holder can never wedge us: if _prev doesn't settle in 8s we proceed+      // anyway (a briefly-overlapping wiggle is far better than a permanent deadlock), and our own+      // releaser always frees the next caller.+      const _prev = _wiggleLock; let _res; _wiggleLock = new Promise(r => { _res = r; }); _releaseWiggle = _res;+      await Promise.race([Promise.resolve(_prev), new Promise(r => setTimeout(r, 8000))]);       try {         markerW = Math.max(400, Math.round(_origW) - 3 - (_hwndWiggle++ % 90));         await cdp.send('Browser.setWindowBounds', { windowId, bounds: { width: markerW } });@@ -4968,6 +5002,7 @@ async function resolveSessionHwndByBounds(session, sessionId) {     try { if (cdp) await cdp.detach(); } catch (e) {}     if (best) {       session._hwnd = best.h;+      persistSessionHwnd(sessionId, best.h);       console.log(`[hwnd] "${sessionId}" resolved OS handle ${best.h} by ${markerW != null ? 'UNIQUE-WIDTH wiggle' : 'window BOUNDS'} — taskbar paint + park can target it`);       return best.h;     }@@ -6222,7 +6257,7 @@ async function parkSessionWindowDirect(page, sessionId) {     // park-failed, and the reclaim janitor kept re-adopting the window as yet another session     // (five of them by now, all fighting over one taskbar button). A window handle is stable and     // title-independent, so hold onto it here where we have already resolved it.-    try { const _s = sessions.get(sessionId); if (_s) { _s._hwnd = Number(parkHwnd); } } catch (e) {}+    try { const _s = sessions.get(sessionId); if (_s) { _s._hwnd = Number(parkHwnd); persistSessionHwnd(sessionId, Number(parkHwnd)); } } catch (e) {}     // Place on-screen full-size (CDP plain bounds — the only move that doesn't raise), then     // RE-BOTTOM (trust nothing) and CONFIRM the z-order. Unconfirmable → back off-screen.     const placed = await placeOnScreen();@@ -9334,7 +9369,13 @@ const server = http.createServer(async (req, res) => {           // dashboard icons update live over SSE as each window repaints; the per-window drift report           // (which windows were wrong until the sweep — i.e. which verb failed to re-trigger the calc,           // John's hypothesis) goes to the bridge log AND is stored for the header to show.-          if (_regenBusy) return done({ ok: true, started: false, alreadyRunning: true, swept: (_lastRegen && _lastRegen.inFlight) || 0 });+          // v1.9.300 (John hit the button and "it didn't work"): a prior sweep left _regenBusy STUCK+          // true (its await hung on the deadlocked wiggle), so every later click returned "already+          // running" and did nothing. PREEMPT a wedged flag: only refuse if a sweep genuinely started+          // in the last 90s. And below, per-window timeouts + a finally guarantee the flag always+          // clears, so the button can never wedge again.+          const _regenAgeMs = (_lastRegen && _lastRegen.startedAt) ? (Date.now() - _lastRegen.startedAt) : Infinity;+          if (_regenBusy && _regenAgeMs < 90000) return done({ ok: true, started: false, alreadyRunning: true, swept: (_lastRegen && _lastRegen.inFlight) || 0 });           const ids = [...sessions.keys()].filter(id => { const s = sessions.get(id); return s && (s.tabs || []).length; });           _regenBusy = true;           _lastRegen = { startedAt: Date.now(), inFlight: ids.length, done: false, swept: 0, painted: 0, changed: 0 };@@ -9350,7 +9391,8 @@ const server = http.createServer(async (req, res) => {               sess._overlayFav = null; sess._faviconRetried = false; sess._noFavLogged = false;               sess._hwnd = null; sess._appOverlay = false; sess._overlayEnforceAt = 0;               let painted = false;-              try { painted = await applyAppOverlay(sess, id); } catch (e) {}+              // Per-window timeout: one wedged window can never stall the whole sweep (or the flag).+              try { painted = await Promise.race([applyAppOverlay(sess, id), new Promise(r => setTimeout(() => r(false), 20000))]); } catch (e) {}               const after = { hwnd: sess._hwnd || null, fav: favKind(sess._overlayFav) };               const changed = String(before.hwnd) !== String(after.hwnd) || before.fav !== after.fav;               results.push({ sessionId: id, painted: !!painted, favKind: after.fav, hwndBefore: before.hwnd, hwndAfter: after.hwnd, favBefore: before.fav, changed });@@ -9361,8 +9403,7 @@ const server = http.createServer(async (req, res) => {             console.log(`[regen] DONE — swept ${results.length} windows, ${painted} painted, ${changed} corrected (hwnd or favicon changed on the sweep)`);             for (const r of results.filter(x => x.changed)) console.log(`[regen]   CORRECTED "${r.sessionId}": hwnd ${r.hwndBefore}->${r.hwndAfter}, fav ${r.favBefore}->${r.favKind}`);             _lastRegen = { at: Date.now(), done: true, swept: results.length, painted, changed, results };-            _regenBusy = false;-          })().catch(() => { _regenBusy = false; });+          })().catch((e) => { try { console.log('[regen] sweep error: ' + e.message); } catch (x) {} }).finally(() => { _regenBusy = false; });           return done({ ok: true, started: true, swept: ids.length });         }         return done({ ok: false, error: 'unknown action' });