← Commit history

v2.0.398 (insiders): Chrome for Testing unpack could hang forever in puppeteer's in-process extract-zip (arav-rog: 3 files then silence for 7 min, the origin of the half-populated folders). pup now downloads only and unpacks in a supervised child process (OS tar first, extract-zip worker fallback) with a stall watchdog, verifies the exe, renames the .partial folder into place atomically; readiness shows a real unpack phase.

John Lauer ·e26557d0f5 ·1mo ago ·parent e9a2915
6 files changed +134−9
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "pup-bridge",-  "version": "2.0.397",+  "version": "2.0.398",   "type": "app",   "description": "The pup SKILL PACK \u2014 the AI skills that teach a cloud/container AI how to drive the Adom Desktop Puppeteer (pup) bridge (browser_* verbs): the everyday `pup` skill plus focused sub-skills for windows/sessions/tabs, screenshots+recording, browsers+Chrome install, and driving the Adom wiki. Installs into ~/.claude/skills + ~/.codex/skills. DOCS ONLY \u2014 this is NOT the bridge runtime. The bridge itself (the Node program that drives the browser) runs on the DESKTOP: Adom Desktop bundles it and auto-installs/updates it, and it's published separately on the Releases tab as adom-bridge-puppeteer-v<ver>.zip. The pkg version is kept in lockstep with the Release version so the page's max version row always carries BOTH a pkg tarball (skills) and a runtime zip (bridge) \u2014 a skills-only or runtime-only bump on the top row breaks constraint installs. TL;DR: pkg install = the skills for your AI; the bridge = the Release zip, which Adom Desktop manages for you.",   "keywords": [
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-2.0.397\ No newline at end of file+2.0.398\ 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.397",+  "version": "2.0.398",   "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/chrome.js+114−5
@@ -321,6 +321,77 @@ function pickSummary() {  // Programmatic install of the pinned CfT build. Resolves to { ok, executablePath, buildId } // or { ok:false, errorCode, error }. Deduped via _installInFlight.+// v2.0.398 — SUPERVISED UNPACK. Child process (killable), stall watchdog on bytes written,+// OS tar first on Windows/macOS (bsdtar reads zip natively, fast, no JS streams), the+// extract-zip worker (src/unpack.js) as the fallback and the Linux path. Each method that+// fails or stalls is cleaned up before the next is tried; the caller verifies the executable.+const UNPACK_STALL_MS = 90000;+const UNPACK_MAX_MS = 15 * 60000;+function dirBytes(dir, depth = 0) {+  let total = 0;+  try {+    for (const e of fs.readdirSync(dir, { withFileTypes: true })) {+      const p = path.join(dir, e.name);+      if (e.isDirectory()) { if (depth < 6) total += dirBytes(p, depth + 1); }+      else if (e.isFile()) { try { total += fs.statSync(p).size; } catch {} }+    }+  } catch {}+  return total;+}+function runUnpackMethod(m, dest, onBytes) {+  return new Promise((resolve, reject) => {+    const { spawn } = require('child_process');+    let child;+    try { child = spawn(m.cmd, m.args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, cwd: __dirname }); }+    catch (e) { return reject(e); }+    let stderr = '', done = false;+    const finish = (fn, v) => { if (done) return; done = true; clearInterval(timer); fn(v); };+    child.stderr.on('data', (d) => { stderr += String(d); if (stderr.length > 4000) stderr = stderr.slice(-4000); });+    child.stdout.on('data', () => {});+    child.on('error', (e) => finish(reject, e));+    let lastBytes = -1, lastGrowth = Date.now(); const started = Date.now();+    const timer = setInterval(() => {+      const b = dirBytes(dest);+      if (onBytes) { try { onBytes(b); } catch {} }+      if (b !== lastBytes) { lastBytes = b; lastGrowth = Date.now(); }+      if (Date.now() - lastGrowth > UNPACK_STALL_MS) {+        try { child.kill('SIGKILL'); } catch {}+        finish(reject, new Error(`unpack via ${m.name} STALLED: no bytes written for ${Math.round(UNPACK_STALL_MS / 1000)}s (${b} bytes so far); killed it`));+      } else if (Date.now() - started > UNPACK_MAX_MS) {+        try { child.kill('SIGKILL'); } catch {}+        finish(reject, new Error(`unpack via ${m.name} exceeded ${Math.round(UNPACK_MAX_MS / 60000)} min; killed it`));+      }+    }, 5000);+    child.on('exit', (code, sig) => {+      const b = dirBytes(dest); if (onBytes) { try { onBytes(b); } catch {} }+      if (code === 0) finish(resolve, { bytes: b });+      else finish(reject, new Error(`${m.name} exited ${code}${sig ? ' (' + sig + ')' : ''}: ${stderr.trim().slice(-300)}`));+    });+  });+}+async function unpackSupervised(archive, dest, { onBytes, onMethod } = {}) {+  const methods = [];+  const tar = { name: 'tar', cmd: 'tar', args: ['-xf', archive, '-C', dest] };+  const worker = { name: 'extract-zip', cmd: process.execPath, args: [path.join(__dirname, 'unpack.js'), archive, dest] };+  if (process.platform === 'linux') methods.push(worker, tar); else methods.push(tar, worker);+  let lastErr = null;+  for (const m of methods) {+    try { fs.rmSync(dest, { recursive: true, force: true }); } catch {}+    fs.mkdirSync(dest, { recursive: true });+    if (onMethod) { try { onMethod(m.name); } catch {} }+    const t0 = Date.now();+    try {+      const r = await runUnpackMethod(m, dest, onBytes);+      return { method: m.name, bytes: r.bytes, ms: Date.now() - t0 };+    } catch (e) {+      lastErr = e;+      console.log(`[chrome] ${e.message} — ${methods.indexOf(m) < methods.length - 1 ? 'trying the next unpack method' : 'no unpack method left'}`);+    }+  }+  try { fs.rmSync(dest, { recursive: true, force: true }); } catch {}+  throw lastErr || new Error('no unpack method available');+}+ function installChrome({ onProgress } = {}) {   if (_installInFlight) return _installInFlight; @@ -395,10 +466,18 @@ function installChrome({ onProgress } = {}) {       } catch (e) { console.log(`[chrome] half-populated-cache check skipped: ${e.message}`); }       console.log(`[chrome] installing Chrome for Testing ${buildId} (${platform}) into ${cacheDir()} (disk free: ${freeMb == null ? 'unknown' : freeMb + ' MB'}) ...`); -      const installed = await install({+      // v2.0.398: DOWNLOAD ONLY here. puppeteer's own unpack (extract-zip, in-process) hung forever+      // on arav-rog at the third file of a valid, fully downloaded archive, with nothing to kill+      // and nothing reported: that is how the half-populated folders that break first runs are+      // born. pup unpacks in a SUPERVISED CHILD PROCESS (OS tar first, extract-zip worker as+      // fallback) into a .partial folder with a stall watchdog, verifies the executable, then+      // renames the folder into place atomically. A failure leaves nothing behind but a clear+      // lastError, and the next attempt starts clean.+      const archiveRaw = await install({         browser: Browser.CHROME,         buildId,         cacheDir: cacheDir(),+        unpack: false,         downloadProgressCallback: (downloaded, total) => {           state.bytesDownloaded = downloaded;           state.bytesTotal = total;@@ -406,8 +485,30 @@ function installChrome({ onProgress } = {}) {           if (onProgress) { try { onProgress(state.progressPct, downloaded, total); } catch {} }         },       });--      const ep = (installed && installed.executablePath) || detectChrome().executablePath;+      const archive = typeof archiveRaw === 'string' ? archiveRaw : (archiveRaw && (archiveRaw.path || archiveRaw.executablePath)) || null;+      if (!archive || !fileExists(archive)) {+        state.errorCode = 'chrome_download_failed';+        throw new Error(`the Chrome for Testing archive is not on disk after the download (${archive || 'no path returned'})`);+      }+      const { computeExecutablePath: _cep } = require('@puppeteer/browsers');+      const expectedExe = _cep({ browser: Browser.CHROME, buildId, cacheDir: cacheDir() });+      let versionedDir = path.dirname(expectedExe);+      while (versionedDir && !path.basename(versionedDir).includes(buildId) && path.dirname(versionedDir) !== versionedDir) versionedDir = path.dirname(versionedDir);+      const partial = versionedDir + '.partial';+      state.phase = 'unpacking'; state.unpackStartedAt = Date.now(); state.unpackBytes = 0; state.unpackMethod = null;+      console.log(`[chrome] downloaded ${archive} (${state.bytesTotal} bytes) — unpacking into ${partial}`);+      const un = await unpackSupervised(archive, partial, { onBytes: (b) => { state.unpackBytes = b; }, onMethod: (m) => { state.unpackMethod = m; } });+      const exeInPartial = path.join(partial, path.relative(versionedDir, expectedExe));+      if (!fileExists(exeInPartial)) {+        state.errorCode = 'chrome_unpack_incomplete';+        try { fs.rmSync(partial, { recursive: true, force: true }); } catch {}+        throw new Error(`unpack via ${un.method} finished but ${exeInPartial} is missing`);+      }+      try { if (fs.existsSync(versionedDir)) fs.rmSync(versionedDir, { recursive: true, force: true }); } catch {}+      fs.renameSync(partial, versionedDir);+      try { fs.unlinkSync(archive); } catch {}+      console.log(`[chrome] unpacked via ${un.method} in ${Math.round((Date.now() - state.unpackStartedAt) / 1000)}s (${state.unpackBytes} bytes) -> ${versionedDir}`);+      const ep = expectedExe;       if (!fileExists(ep)) {         state.phase = 'failed';         state.errorCode = 'chrome_install_incomplete';@@ -430,6 +531,7 @@ function installChrome({ onProgress } = {}) {         if (/ENOSPC|no space left|not enough space/i.test(msg)) state.errorCode = 'chrome_install_no_disk';         else if (/EACCES|EPERM|permission/i.test(msg))          state.errorCode = 'chrome_install_permission';         else if (/ENOTFOUND|ETIMEDOUT|ECONNRESET|network|getaddrinfo|proxy|tunneling/i.test(msg)) state.errorCode = 'chrome_download_network';+        else if (/unpack|STALLED|exited \d/i.test(msg))          state.errorCode = 'chrome_unpack_failed';         else state.errorCode = 'chrome_download_failed';       }       state.error = state.errorCode === 'chrome_install_no_disk'@@ -486,7 +588,7 @@ async function ensureChromeReady({ background = true, onProgress } = {}) { function readiness() {   const b = resolveBrowser();   const det = detectChrome();-  const installing = !!_installInFlight || state.phase === 'installing';+  const installing = !!_installInFlight || state.phase === 'installing' || state.phase === 'unpacking';   // v1.9.94 (issue #22 Finding 4) — `ready` means "a drivable browser EXISTS", nothing more. The old   // `&& !installing` conflated "a background CfT download is in flight" with "nothing is drivable", so   // an in-flight (or STALLED) last-resort download reported the whole subsystem not-ready even on a box@@ -513,6 +615,11 @@ function readiness() {     installing,     installPhase: state.phase,     installProgressPct: state.progressPct,+    // v2.0.398: pup owns the unpack (supervised child process); bytes written so far and the+    // ratio to the archive (a CfT zip unpacks to ~2.3x its size) give a real percent.+    unpackBytes: state.unpackBytes || 0,+    unpackElapsedSec: (state.phase === 'unpacking' && state.unpackStartedAt) ? Math.round((Date.now() - state.unpackStartedAt) / 1000) : null,+    unpackMethod: state.unpackMethod || null,     bytesDownloaded: state.bytesDownloaded,     bytesTotal: state.bytesTotal,     // v2.0.383 (#59 measured progress): elapsed + ETA from the actual byte rate, never a constant.@@ -1047,6 +1154,7 @@ function pruneBrokenCacheDirs({ keepBuildId = null } = {}) {       let st = null; try { st = fs.statSync(dir); } catch (e) { continue; }       if (!st.isDirectory()) continue;       if (keepBuildId && name.includes(keepBuildId)) continue;   // the install path handles its own build+      if (name.endsWith('.partial')) { try { if (Date.now() - fs.statSync(dir).mtimeMs < 30 * 60000) continue; } catch (e) {} }   // a live unpack in progress       if (state.installing && state.buildId && name.includes(state.buildId)) continue;   // being written right now       if (hasCftExecutable(dir)) continue;       try {@@ -1096,7 +1204,7 @@ function warmup() {       console.log(`[warmup] no native browser + CfT missing + low disk (${free}MB) — NOT auto-fetching; readiness reports lowDisk and opens will surface the disk hint`);       return;     }-    console.log('[warmup] no installed Chromium on this box — background-fetching Chrome for Testing as the last-resort browser');+    if (!hasNative) console.log('[warmup] no installed Chromium on this box — background-fetching Chrome for Testing as the last-resort browser');     ensureChromeReady({ background: true });   } catch (e) { console.error(`[warmup] ${e && e.message}`); } }@@ -1107,5 +1215,6 @@ module.exports = {   setDefaultBrowser, readDefault, clearDefault, markVerified, pickSummary,   installChromeStable, chromeStableStatus, warmup, setPreferCft, expireTemporaryDefault,   pruneBrokenCacheDirs, hasCftExecutable,+  unpackSupervised, dirBytes,   // v2.0.398: exported for tests/check_unpack.js   abCommand, // v1.8.40: reused by server.js to call AD verbs (e.g. desktop_taskbar) via the direct-API };
src/server.js+7−1
@@ -970,7 +970,13 @@ function activeProgress() {   }   try {     const rd = chrome && chrome.readiness ? chrome.readiness() : null;-    if (rd && rd.installing) rows.push({ id: 'cft-download', verb: 'pup_prewarm', phase: 'download', stepLabel: 'Downloading Chrome for Testing', percent: rd.installProgressPct || 0,+    if (rd && rd.installing && rd.installPhase === 'unpacking') {+      // v2.0.398: the unpack is its own phase (it used to sit at 'download 100%' for minutes).+      const est = rd.bytesTotal ? Math.round(rd.bytesTotal * 2.3) : 0;+      const pct = est ? Math.min(99, Math.round((rd.unpackBytes || 0) / est * 100)) : null;+      rows.push({ id: 'cft-unpack', verb: 'pup_prewarm', phase: 'unpack', stepLabel: `Unpacking Chrome for Testing${rd.unpackMethod ? ' (' + rd.unpackMethod + ')' : ''}`, percent: pct,+        bytes: rd.unpackBytes || 0, elapsedSec: rd.unpackElapsedSec, estimatedSec: null, etaSec: null, confidence: 'estimated', startKind: 'cold' });+    } else if (rd && rd.installing) rows.push({ id: 'cft-download', verb: 'pup_prewarm', phase: 'download', stepLabel: 'Downloading Chrome for Testing', percent: rd.installProgressPct || 0,       elapsedSec: rd.downloadElapsedSec, estimatedSec: (rd.downloadElapsedSec != null && rd.downloadEtaSec != null) ? rd.downloadElapsedSec + rd.downloadEtaSec : null, etaSec: rd.downloadEtaSec, confidence: rd.downloadEtaSec != null ? 'measured' : 'estimated', startKind: 'cold' });   } catch (e) {}   for (const row of consent.rows(now)) rows.push(row);
src/unpack.jsadded+10
@@ -0,0 +1,10 @@+// pup-bridge unpack worker (v2.0.398): `node unpack.js <archive.zip> <destDir>`.+// Runs OUT of the bridge process so a hung unzip can be killed by the watchdog in chrome.js+// (measured on arav-rog 2026-09-05: puppeteer's in-process extract-zip stalled forever at the+// third file of a fully downloaded, valid Chrome-for-Testing archive, with the bridge idle).+// Windows and macOS use the OS tar first; this worker is the fallback / Linux path.+const path = require('path');+const [, , archive, dest] = process.argv;+if (!archive || !dest) { console.error('usage: node unpack.js <archive.zip> <destDir>'); process.exit(64); }+require('extract-zip')(archive, { dir: path.resolve(dest) })+  .then(() => process.exit(0), (e) => { console.error(e && e.stack || String(e)); process.exit(2); });