← Commit history

v2.0.256: Never let a refcount authorize killing a shared Chrome. #434 established the rule (decide from ground truth, not the cached refCount) but converted only ONE of four call sites; the other three still ran 'if (--refCount <= 0) kill'. That counter is provably wrong right after a restart: recovery re-creates the browser entry with refCount 0 and does not count the sessions it just restored. Measured on AdomLapper today: three recovered windows (fusion-dashboard, kicad-maint, and the ab web-demo window John was actively using) sat on the shared adom-you Chrome at refCount 0, the next path decremented 0 to 0 and force-killed the process hosting all three, and pup then relaunched and reconciled them away as closed. All four sites now go through one primitive, killProfileBrowserIfUnused, which refuses while ANY other session is bound to that profile or any verb is in flight; the refcount is telemetry only.

John Lauer ·aa4f0a3a8e ·2mo ago ·parent 42e866b
4 files changed +45−47
adom-bridge-puppeteer-manifest.json+5−5
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "2.0.255",-  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.255/adom-bridge-puppeteer-v2.0.255.zip",-  "sha256": "95eb9f61a1cb565d7b7ff8d3ac2cb7dbc7c7d1e8a86c0bd6989b5eaf82287fff",-  "size": 952730,+  "version": "2.0.256",+  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.256/adom-bridge-puppeteer-v2.0.256.zip",+  "sha256": "315928c415b45c0d6a0b32420c8aa0964173c0f80e1a1ca0616c8c70fac4975a",+  "size": 952824,   "verbPrefixes": [     "browser_",     "desktop_recorder_",@@ -12,5 +12,5 @@   ],   "healthEndpoint": "/status",   "statusVerb": "browser_readiness",-  "released_at": "2026-08-20T11:00:14.163938Z"+  "released_at": "2026-08-20T11:05:49.142324Z" }
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-2.0.255\ No newline at end of file+2.0.256\ 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.255",+  "version": "2.0.256",   "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+38−40
@@ -278,6 +278,38 @@ try {  // Multi-session state: sessions are tabs within shared Chrome browsers // One Chrome process per profile (userDataDir), multiple tabs per Chrome.+// v2.0.256 (John lost THREE windows to this): the ONE primitive allowed to kill a profile's Chrome.+// #434 established the rule — decide from GROUND TRUTH, never from the cached refCount — but only+// converted ONE of the four call sites. The other three still ran `if (--refCount <= 0) kill`, and+// that counter is provably wrong right after a RESTART: recovery re-creates the browser entry with+// `refCount: 0` and does not count the sessions it just restored. So on AdomLapper, three recovered+// windows (fusion-dashboard, kicad-maint, and the ab web-demo window John was using) sat on the+// shared adom-you Chrome with refCount 0, and the next path to run decremented 0 -> 0 and+// force-killed the process hosting all three. pup then relaunched them and reconciled them away as+// "closed", so all three of the user's windows vanished.+// A refcount is telemetry. The session map is truth. Every kill goes through here.+async function killProfileBrowserIfUnused(profileName, why) {+  const be = browsers.get(profileName);+  if (!be) return { killed: 0, remaining: 0 };+  be.refCount = Math.max(0, be.refCount - 1);   // kept honest for telemetry only — NEVER authorizes a kill+  // Any OTHER session still bound to this profile? _lostBrowser entries count on purpose: rescan may+  // re-adopt that still-open window, so a disconnected record must still veto the kill.+  let others = 0;+  for (const s of sessions.values()) { if (s.profileName === profileName) others++; }+  let busy = 0;+  for (const [sid, st] of _busyState) {+    if (st && st.busy) { const bs = sessions.get(sid); if (bs && bs.profileName === profileName) busy++; }+  }+  if (others || busy) {+    console.log(`Keeping browser for profile "${profileName}" — ${others} other session(s)${busy ? ` and ${busy} verb(s) in flight` : ''} still using it (refCount=${be.refCount}); NOT killing (${why})`);+    return { killed: 0, remaining: others };+  }+  console.log(`No more sessions using profile "${profileName}" — killing browser (${why})`);+  let r = { killed: 0, remaining: 0 };+  try { r = await killBrowserProcess(be.browser, profileName); } catch (e) {}+  browsers.delete(profileName);+  return r;+} // browsers:  profileName → { browser, refCount } // sessions:  sessionId → Session (see createSession below) //@@ -14449,35 +14481,10 @@ const server = http.createServer(async (req, res) => {           clearSessionBusy(sessionId);                       // v1.9.57: drop any pending progress timer           unregisterPupAumid(_closingAppId).catch(() => {}); // v1.9.2/#207 cleanup (appId captured pre-delete) -          // #434: decide the kill from GROUND TRUTH, not the cached refCount. The old code killed the-          // profile's Chrome whenever `be.refCount` hit 0. That counter DRIFTS: a session record that-          // was transiently lost / being restored (_lostBrowser), or one whose increment was missed,-          // leaves refCount at 0 while ANOTHER session is still open on the same SHARED profile and-          // actively receiving verbs. Result: closing session A (e.g. ralph-reap) force-killed the-          // shared "adom-you" Chrome out from under live session B (cowboy-order). So never let the-          // refcount alone authorize a destructive kill: count the live session map (this session was-          // already deleted above) for ANY entry on this profile, and refuse while a verb is in flight.+          // #434 / v2.0.256: the ground-truth decision now lives in ONE primitive+          // (killProfileBrowserIfUnused) so no call site can reintroduce the refCount kill.           let _reap = { killed: 0, remaining: 0 };-          const be = browsers.get(profileName);-          if (be) {-            be.refCount = Math.max(0, be.refCount - 1); // keep the counter honest for telemetry only-            // Any OTHER session still bound to this profile? Includes _lostBrowser entries on purpose —-            // rescan may re-adopt the still-open window, so a disconnected record must still veto the kill.-            let _otherSessions = 0;-            for (const _s of sessions.values()) { if (_s.profileName === profileName) _otherSessions++; }-            // Any verb in flight on this profile right now?-            let _busyOnProfile = 0;-            for (const [_sid, _st] of _busyState) {-              if (_st && _st.busy) { const _bs = sessions.get(_sid); if (_bs && _bs.profileName === profileName) _busyOnProfile++; }-            }-            if (_otherSessions === 0 && _busyOnProfile === 0) {-              console.log(`No more sessions using profile "${profileName}" — killing browser`);-              _reap = await killBrowserProcess(be.browser, profileName);-              browsers.delete(profileName);-            } else {-              console.log(`Keeping browser for profile "${profileName}" — ${_otherSessions} other session(s)${_busyOnProfile ? ` and ${_busyOnProfile} verb(s) in flight` : ''} still using it (refCount=${be.refCount}); NOT killing`);-            }-          }+          _reap = await killProfileBrowserIfUnused(profileName, 'session closed (all tabs)');            if (activeSessionId === sessionId) {             activeSessionId = sessions.size > 0 ? sessions.keys().next().value : null;@@ -14717,8 +14724,7 @@ const server = http.createServer(async (req, res) => {               sessions.delete(resolvedId);               deleteSessionFile(resolvedId);               unregisterPupAumid(_closingAppId).catch(() => {});-              const be = browsers.get(profileName);-              if (be) { be.refCount = Math.max(0, be.refCount - 1); if (be.refCount <= 0) { try { await killBrowserProcess(be.browser, profileName); } catch (e) {} browsers.delete(profileName); } }+              await killProfileBrowserIfUnused(profileName, 'window closed');               windowClosed = true;               setTimeout(() => { try { pruneStaleAumids(); } catch (e) {} }, 3000);             } catch (e) {}@@ -15095,11 +15101,7 @@ const server = http.createServer(async (req, res) => {               sessions.delete(sessionId);               deleteSessionFile(sessionId);               unregisterPupAumid(_closingAppId).catch(() => {});-              const beOld = browsers.get(oldProfile);-              if (beOld) {-                beOld.refCount = Math.max(0, beOld.refCount - 1);-                if (beOld.refCount <= 0) { try { await killBrowserProcess(beOld.browser, oldProfile); } catch (e) {} browsers.delete(oldProfile); }-              }+              await killProfileBrowserIfUnused(oldProfile, 'profile steer: old window retired');               // RENAME temp -> original id: map, files, hwnd claim. The periodic brand sweep +               // title injector key off the map id, so titles/badges restamp to the original id.               sessions.delete(tempId);@@ -15164,11 +15166,7 @@ const server = http.createServer(async (req, res) => {             sessions.delete(sessionId);             deleteSessionFile(sessionId);             unregisterPupAumid(_closingAppId).catch(() => {});-            const be = browsers.get(oldProfile);-            if (be) {-              be.refCount = Math.max(0, be.refCount - 1);-              if (be.refCount <= 0) { try { await killBrowserProcess(be.browser, oldProfile); } catch (e) {} browsers.delete(oldProfile); }-            }+            await killProfileBrowserIfUnused(oldProfile, 'profile switch');           }           // Relaunch the SAME sessionId under the new profile at the same URL.           let s2 = null;