← Commit history

v2.0.590 (insiders): extension worker: the packaged port.json read had no deadline and hung on every fresh worker instance after a reload in place (hellos continued, /extcmd never hit; measured on winvm), the 'worker stalls after a cold launch or update' bug. Now a 2 s deadline with the last good lane and port remembered in extension storage, and every poll step bounded. Plus 2.0.589: per-command deadlines, an unsticking drain guard, the hello on its own alarm, and a birth grace that demotes only a no-input-since-birth self-raise on the CDP lane.

John Lauer ·1b89e1ff69 ·1mo ago ·parent 63c159e
2 files changed +51−12
src/extension/manifest.json+1−1
@@ -1,7 +1,7 @@ {   "manifest_version": 3,   "name": "Adom Pup",-  "version": "0.4.2",+  "version": "0.4.4",   "description": "Toolbar for Adom Pup browser windows: annotate, snip, view toggle, dashboard, owner info. Thin trigger surface - the pup bridge is the engine.",   "action": {     "default_popup": "popup.html",
src/extension/worker.js+50−11
@@ -16,20 +16,26 @@ let _pkgPort = null; let _pkgLane = '';   // only the extension-DRIVE browser gets a lane token in port.json let _pkgRead = false;   // ONLY a successful read may be memoized async function packagedPort() {-  // 2.0.511: the memo used to be `if (_pkgPort !== null)`, and the catch set _pkgPort = 0. So a single-  // failed first read (it races service-worker startup, and sweepBadges can trigger it before the drive-  // loop does) poisoned the memo for the worker's ENTIRE life: 0 is !== null, so it never retried, and-  // _pkgLane stayed '' — which pupExtPoll treats as "not the drive browser" and returns. The extension-  // loaded, the window was fine, and the control channel was silent forever (AdomLapper, 2026-09-13:-  // lastPollMsAgo never set while the lane window sat there correctly sized and stamped).+  // 2.0.590: this read hung forever on a fresh worker instance after a reload in place (measured on winvm:+  // hellos every 30 s with the right lane, /extcmd never hit again, the drain stuck in this fetch). The+  // memo below meant a healthy worker never re-read the file, so the hang only ever bit a NEW instance,+  // which is why it looked like "the worker stalls after a cold launch or an update". A deadline, and the+  // last good lane and port remembered in storage so a stuck read falls back instead of reporting an empty+  // lane (which pupExtPoll treats as "not the drive browser").   if (_pkgRead) return _pkgPort;   try {-    const r = await fetch(chrome.runtime.getURL('port.json'), { cache: 'no-store' });+    const r = await fetch(chrome.runtime.getURL('port.json'), { cache: 'no-store', signal: AbortSignal.timeout(2000) });     const j = await r.json();     _pkgPort = (j && j.port) || 0;     _pkgLane = (j && j.lane) || '';     _pkgRead = true;-  } catch (e) { /* leave the memo UNSET so the next tick retries */ }+    try { chrome.storage.local.set({ pupPkgPort: _pkgPort, pupPkgLane: _pkgLane }); } catch (e) {}+  } catch (e) {+    try {+      const st = await new Promise((res) => chrome.storage.local.get(['pupPkgPort', 'pupPkgLane'], res));+      if (st && st.pupPkgLane) { _pkgPort = st.pupPkgPort || 0; _pkgLane = st.pupPkgLane; }+    } catch (e2) {}+  }   return _pkgPort === null ? 0 : _pkgPort; } @@ -207,7 +213,14 @@ async function pupExtPollOnce() {   if (!cmd || !cmd.id) return false;      let out = { id: cmd.id, ok: false, error: `unsupported action ${cmd.action}` };+  // every command races a deadline: a hung Chrome API answers as an error and names itself, instead of+  // freezing the lane (2.0.589)+  const deadlineMs = Math.min(Number(cmd.timeoutMs) || 0, 45000) || (cmd.action === 'navigate' || cmd.action === 'open' || cmd.action === 'newtab' ? 35000 : 20000);+  let deadlineTimer = null;+  const deadline = new Promise((_, rej) => { deadlineTimer = setTimeout(() => rej(new Error(`${cmd.action} did not settle within ${deadlineMs} ms (a Chrome API hung)`)), deadlineMs); });   try {+   out = await Promise.race([deadline, (async () => {+    let out = { id: cmd.id, ok: false, error: `unsupported action ${cmd.action}` };     if (cmd.action === 'open') {       const w = await chrome.windows.create({ url: cmd.url || 'about:blank', focused: false });       const tab = w.tabs && w.tabs[0];@@ -427,7 +440,10 @@ async function pupExtPollOnce() {     } else if (cmd.action === 'ping') {       out = { id: cmd.id, ok: true, pong: true, ua: navigator.userAgent };     }+    return out;+   })()]);   } catch (e) { out = { id: cmd.id, ok: false, error: String(e && e.message ? e.message : e) }; }+  finally { if (deadlineTimer) clearTimeout(deadlineTimer); }   try {     await fetch(`http://127.0.0.1:${found.port}/extresult`, {       method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(out),@@ -438,12 +454,35 @@ async function pupExtPollOnce() {  // 2.0.570: the bridge queues commands; drain the queue on every poll (up to 12 per tick) instead of // serving one command per 1.2 s, so a watcher polling every 500 ms and a verb do not starve each other.+// 2.0.589: a Chrome API promise that never settles (measured twice: a debugger capture in 2.0.574, and on+// 2026-09-15 a `fit` right after a cold launch) froze this drain, and because the hello lived inside it, the+// bridge saw total silence and declared the lane dead. Three rules: every command has a deadline, the busy+// guard cannot stick (a drain older than 60 s is abandoned), and the hello has its own alarm below.+let pupExtBusySince = 0; async function pupExtPoll() {-  if (pupExtBusy) return;-  pupExtBusy = true;-  try { for (let k = 0; k < 12; k++) { if (!(await pupExtPollOnce())) break; } }+  if (pupExtBusy && Date.now() - pupExtBusySince < 60000) return;+  if (pupExtBusy) { console.warn('[pup] a drain has been stuck for 60 s; starting a fresh one'); }+  pupExtBusy = true; pupExtBusySince = Date.now();+  try {+    for (let k = 0; k < 12; k++) {+      const step = await Promise.race([pupExtPollOnce(), new Promise((res) => setTimeout(() => res('deadline'), 50000))]);+      if (step === 'deadline') { console.warn('[pup] a poll step did not settle within 50 s; abandoning it'); break; }+      if (!step) break;+    }+  }   finally { pupExtBusy = false; } }+async function pupExtHello() {+  try {+    const found = await findPort();+    if (!found) return;+    await fetch(`http://127.0.0.1:${found.port}/exthello`, { method: 'POST', headers: { 'Content-Type': 'application/json' },+      body: JSON.stringify({ lane: _pkgLane, pkg: _pkgPort, read: _pkgRead, worker: (self.location && self.location.pathname) || 'worker.js', tries: pupExtDiag.tries, standalone: true }),+      signal: AbortSignal.timeout(1500) });+  } catch (e) {}+}+chrome.alarms.create('pup-hello', { periodInMinutes: 0.5 });+chrome.alarms.onAlarm.addListener((a) => { if (a.name === 'pup-hello') pupExtHello(); });  // A tight poll keeps the MV3 worker alive while pup is driving; the alarm revives it if it ever // does get torn down between commands.