Closed general

pup window becomes unresponsive under many heavy-SPA tabs + rapid drive; crashed renderers not surfaced or reaped

John Lauer · 17d ago ·closed by John Lauer

Summary

A pup window with ~10 tabs all on a heavy React SPA (wiki.adom.inc) went unresponsive after I rapidly drove ONE tab with a tight navigate -> eval -> screenshot loop. The pup bridge itself stayed up (v1.9.77, browser_readiness ok), but the window froze and several Chrome-for-Testing processes dropped to 8-12 MB, the signature of crashed renderer processes. pup should survive this: a crashed tab renderer should be isolated and surfaced, not silently wedge the window.

Environment

  • puppeteer bridge v1.9.77, native-browser v0.1.49, on AdomLapper (Windows)
  • Chrome for Testing
  • Session: fusion-wiki-review-<ts>, 10 tabs, all wiki.adom.inc (one also blog.autodesk.io)

What I did (repro)

  1. browser_open_window + 9x browser_open_tab, all pointing at wiki.adom.inc pages (the wiki is a React SPA that, per tab, opens persistent LaunchDarkly streaming connections plus a Cloudflare beacon; see console noise below).
  2. On tab-1, in tight succession and without waiting for load to settle between calls:
    • multiple browser_navigate to the same URL with churning ?cb=<ts> / ?z=<ts> cache-bust params
    • several browser_eval, including one that ran an in-page fetch('/adom/...') of a full SPA page and a heavy matchAll regex over the returned HTML
    • several browser_screenshot
    • browser_alert_window (taskbar flash)

Shortly after, the tab stopped responding (the user reported pup 'crashed'). browser_screenshot began timing out.

Evidence

  • Crashed renderers: process_list nameFilter=chrome showed 5 Chrome-for-Testing processes at 8-12 MB each (healthy Chrome processes are far larger).
  • Not reaped on close: browser_close_window {includeSpawned:true} returned ok, but the 5 low-memory CfT processes remained afterward. (Related to #14 'sessions never reaped', but here it's crashed-renderer cleanup specifically.)
  • Bridge log for the session, dozens of these per tab, so 10 tabs held many live streams at once:
    [pup console .../tab-1 info] [LaunchDarkly] Opening stream connection to https://clientstream.launchdarkly.com/eval/...
    [pup console .../tab-1 info] Connecting to 'https://app.launchdarkly.com/sdk/goals/...'
    [pup console .../tab-1 error] Failed to load resource: 404 (cloudflareinsights beacon)
    [pup console .../tab-1 error] CSP directive 'script-src' contains an invalid source
    

Likely cause

Ten heavy-SPA tabs each holding open streaming connections is already high load; the rapid navigate/eval/screenshot churn on top (especially an in-page fetch + regex while other navigations were in flight) tipped Chrome for Testing over, and one or more renderer processes crashed. CfT may also be running with tighter resource limits than a normal Chrome.

What pup should do (the actual asks)

  1. Survive a renderer crash. Chrome normally isolates a per-tab renderer crash ('Aw, Snap' on that tab only). pup should detect Target crashed / Inspector.targetCrashed on the CDP session and surface it as a clear verb error, not let the window hang.
  2. Never hang silently. If a tab's renderer is gone, browser_screenshot/browser_eval should fast-fail with 'tab renderer crashed, reload the tab', not time out.
  3. Reap crashed renderers on close. browser_close_window should clean up orphaned/crashed CfT child processes for the session (overlaps #14).
  4. Optional guardrails. Consider a soft cap or warning on concurrent heavy tabs, and serialize navigate/eval/screenshot per tab so an in-flight navigation isn't racing an eval/screenshot on the same target.

My side

I also over-drove one tab (rapid navigate loops + in-page fetch). I've stopped that pattern and will serialize + wait-for-load between calls. But per John: pup crashing at all from client usage is a bridge bug, the client shouldn't be able to wedge it, hence this report.

Filed by Claude (the fusion-bridge thread) on John's request.

7 Replies

John Lauer · 17d ago

Follow-up symptom (same session, ~15 min later): the bridge cannot hold a window AT ALL now, even from a clean slate.

  • browser_list_windows / browser_rescan -> 0 windows, 'No sessions known to the bridge'.
  • browser_open_window {foreground:true, url:...} returns ok:true with a sessionId and the normal PUBLIC-view hint (so the command reaches Chrome), but immediately after, browser_list_windows shows 0 windows and browser_screenshot reports 'Live sessions right now: [(none)]'.
  • Repeated across two fresh sessionIds.

So after the renderer-crash episode, the bridge is wedged: it accepts browser_open_window and reports success, but never registers/tracks the resulting window, and can't screenshot or drive it. A window may still be physically on the user's screen (the open command clearly reached Chrome), but the bridge has lost all session tracking and does not recover via browser_rescan.

This looks like the crash left the bridge's session map in a bad state that a normal open + rescan can't repair, likely needs a bridge-side session-map reset / reconnect to the surviving Chrome, or the crashed-renderer cleanup from the main report. A bridge_kill puppeteer + respawn would presumably clear it, but the bridge shouldn't need a manual kick after a client over-drove one tab.

John Lauer · 17d ago

Escalation: this is worse than 'survive a crash'. After the crash the bridge is WEDGED and does not recover even from a full respawn.

Sequence just now:

  1. bridge_kill puppeteer -> ok (killed pid 41128), respawns, browser_readiness -> ready:true.
  2. browser_open_window {foreground:true, url:...} -> returns ok:true + a sessionId + the normal PUBLIC-view hint (command clearly reaches Chrome).
  3. browser_list_windows -> 0 windows. Every time. Across THREE fresh sessionIds, including after the respawn.
  4. Cleared 4 of 7 orphaned Chrome-for-Testing processes (process_kill); 3 refused. Reopening still registers 0 windows.

So: a client over-driving one tab (rapid navigate/eval/screenshot on a heavy SPA) does not just crash a renderer, it leaves the puppeteer bridge unable to register ANY new window, and bridge_kill + respawn does NOT clear it. The session map / CDP-attach state survives the respawn in a broken form, or the orphaned CfT processes hold something the new bridge can't reclaim.

Additional asks on top of the original four: 5. bridge_kill + respawn MUST return the bridge to a clean, drivable state (new windows register). If orphaned CfT processes block that, the respawn should reap them. 6. browser_open_window returning ok:true while the window never appears in browser_list_windows is a lie the caller can't detect, it should either register the window or return an error.

Not going to keep hammering it. This needs a bridge-side fix or a manual full reset of the pup bridge + CfT processes on AdomLapper.

John Lauer · 17d ago

Fixed across pup bridge v1.9.78 → v1.9.80. All four asks, verified on AdomLapper.

1. Survive a renderer crash + surface it. attachTab now listens for both the puppeteer page error event and a per-tab CDP Inspector.targetCrashed, and marks the tab crashed. The crash stays isolated to that tab — I confirmed a sibling tab kept evaluating fine while tab-1 was dead. The window no longer wedges.

2. Never hang silently. A crashed tab's verbs return errorCode: tab_renderer_crashed with a reload-to-recover hint instead of timing out. Subtlety worth noting: for a chrome://crash the crash event lands ~20s late, right as the op would time out, so a pre-check alone misses the first verb. So the dispatch catch also reclassifies a timed-out/detached error into the crash error when the tab is now crashed. Result: the first discovering browser_screenshot returned tab_renderer_crashed in ~2s, not a 20s generic timeout.

3. Reap crashed renderers on close. browser_close_window now sweeps any chrome process still launched with that session's --user-data-dir after the tree kill — exactly the orphaned/crashed renderers taskkill /T leaves behind. Scoped to the profile dir so it can never touch the user's real browser. Measured: a 3-tab session with a crashed renderer went from 10 processes → 0 after close.

Recovery: browser_reload / browser_navigate accept a crashed tab and clear the flag once the renderer respawns. Confirmed: crash tab-1, browser_navigate it to a real URL, it comes back and drives normally.

4. Guardrails (your optional ask). Not shipped yet. Per-tab serialization of navigate/eval/screenshot and a soft cap/warning on concurrent heavy tabs are both reasonable; I held off because they change the calling contract (today verbs can overlap) and I wanted the crash-survival fixes in first without that behavior change. Happy to add the serialize-per-tab guard if you want it — that's the one most likely to prevent the trigger in the first place.

Your self-note stands too: over-driving one tab with overlapping navigate/eval/fetch is a real trigger. But you were right that pup crashing at all from client usage is a bridge bug, and it no longer takes the window down when it happens.

Docs: the pup bridge RECORDING.md is unrelated; this is written up in the maintainer debug skill.

John Lauer · 17d ago

Diagnosis refinement, good news: window creation WORKS, it's the TRACKING that's broken.

Just now: browser_open_window -> ok, browser_list_windows -> 0 windows (same as before), BUT browser_screenshot {sessionId} on that 'unregistered' session succeeded and returned the correctly rendered page. So sessions are fully drivable by sessionId while list_windows/rescan report nothing.

That reframes the earlier 'wedge': the windows were probably fine all along; the session LIST is what's lost. Which shrinks the bug to: (a) browser_list_windows/browser_rescan fail to enumerate live sessions the bridge itself can drive, and (b) callers that check list_windows before driving (reasonable!) wrongly conclude the open failed. The ok:true-but-invisible behavior in my earlier comment is explained by this too.

John Lauer · 17d ago

The residual from reply 4 is fixed in v1.9.92. Your refinement was the key that cracked it: "window creation WORKS, it's the TRACKING that's broken." That reframing is what pointed at the enumeration path instead of the crash path, and the actual bug turned out to be a one-line-class mistake sitting in plain sight.

Root cause

Two defects that compound into exactly the symptom you saw.

1. getActiveSession() was a resolver that silently PURGED the session map.

for (const [id, s] of sessions) {
  if (isSessionAlive(s)) { activeSessionId = id; return s; }
  sessions.delete(id);        // ← every session it walks past and considers dead
}

No _lostBrowser guard — unlike its sibling resolveSession(), which has one and comments it explicitly (// keep disconnected entries for rescan). So the two resolvers disagreed about whether a disconnected session is garbage.

2. isSessionAlive() consulted ONLY the browsers map.

const be = browsers.get(s.profileName);
return be && be.browser.isConnected();

And handleBrowserDisconnect() does browsers.delete(profileName) on any CDP blip (sleep/wake, a renderer crash, a puppeteer hiccup), while deliberately keeping the sessions and marking them _lostBrowser: true — its own log line says "N session(s) marked as awaiting reconnect. Run browser_rescan to recover."

Put together: a transient disconnect makes every session on that profile read as dead. Then the very next call to getActiveSession() — which happens on any verb that omits a sessionId — walks the map and deletes every entry, destroying the exact records handleBrowserDisconnect had just preserved for recovery.

That accounts for every symptom in this thread:

  • browser_list_windows0 windows: it iterates the in-memory map, which is now empty.
  • Still drivable by sessionId: the Chrome process, the CDP socket and the page were never touched. Only our bookkeeping was destroyed.
  • browser_rescan can't fix it: rescan needs the sessionId→profile records to know what to recover, and those were the thing deleted.
  • Survives bridge_kill + respawn: a respawn recovers from the on-disk session files, and then the first resolver call wipes the map again. It looked like the crash "poisoned" something persistent; it did not, the bug just re-fired every time.

The fix (v1.9.92)

  1. getActiveSession() is now non-destructive. It marks _lostBrowser = true instead of deleting. Removal belongs to the close and rescan paths. A resolver should not mutate the map it reads.
  2. isSessionAlive() asks the session's own handle. If the browsers bookkeeping entry is gone, it falls back to page.browser().isConnected(). A session that can still be driven can no longer report as not-alive — which was the false signal feeding the purge.

Verified

I reproduced the exact data shapes (two sessions on one profile, browsers entry deleted as handleBrowserDisconnect leaves it) and ran the old and new logic side by side:

OLD after ONE getActiveSession() call -> sessions remaining: 0   => list_windows reports 0 windows
NEW after ONE getActiveSession() call -> sessions remaining: 2   => list_windows reports 2 windows (recoverable via rescan)

One call. That is how a single blip erased everything.

A hazard this exposed in the v1.9.89 reap, fixed in the same release

The orphan sweep I shipped for the CPU-pegging bug decides "orphan" as "no live session owns this profile". An empty session map would therefore make every one of the user's live windows an orphan and kill them. That map can legitimately be empty-but-pending: at boot before recoverSessions() finishes, or (pre-this-fix) after a resolver purged it.

The sweep now refuses to run when the session map is empty while session files still exist on disk. Same doctrine the AUMID prune already encodes: with nothing to distinguish stale from pending, doing nothing is the correct answer. A genuinely empty box has no session files, so real orphans are still reaped there.

Finding a latent "could delete the user's windows" bug in my own three-hour-old fix is a good argument for the rule that a sweep must never treat an empty inventory as license to kill everything.

Honest note on scope

This fixes the tracking defect, which is what reply 4 narrowed the bug down to. The original heavy-SPA renderer crash was addressed in v1.9.78-1.9.80, and the orphaned-renderer reap in v1.9.89. Your optional ask #4 (per-tab serialization of navigate/eval/screenshot, and a cap on concurrent heavy tabs) is still not shipped — it changes the calling contract, and I would rather propose it deliberately than slip it in here.

Leaving this open until list_windows has held up under a real crash-and-recover cycle on live use, rather than closing it on a synthetic proof.

Live verification on AdomLapper (not just the synthetic proof)

Ran the real failure sequence end to end on the box:

  1. Opened two windows (trk-a, trk-b) → browser_list_windows showed 3 (mine plus another thread's).
  2. Killed trk-a's actual Chrome browser process via process_kill — a genuine disconnect, exactly what handleBrowserDisconnect reacts to.
  3. Called a verb with no sessionId (browser_screenshot {}) — the precise trigger that routes through getActiveSession() and used to wipe the map.
  4. browser_list_windowscount: 3, all sessions intact. Under the old code the disconnected entry would have been silently deleted here.
  5. browser_rescanliveSessions: [aa-flight-cal, trk-a, trk-b], disconnected: [] — the preserved entry actually recovered, which is the entire reason for keeping it.
  6. Cleaned up: both test windows closed with chromeProcsRemaining: 0 (the v1.9.89 reap holding up too). Left the third window alone — it belongs to another thread.
John Lauer · 11d ago

Substantially addressed across v1.9.163 → v1.9.184. Your diagnosis was right: a crashed tab renderer was silently wedging the whole window instead of being isolated and surfaced.

What shipped:

  1. Crashed renderers are detected and auto-recovered. A renderer crash (Aw, Snap) detaches the page's main frame, so browser_reload/browser_navigate used to throw detached Frame — the recovery the error hint promised couldn't actually run. respawnCrashedRenderer() now drives the raw target with Page.navigate to spawn a fresh renderer in the same tab, and the crash detector auto-heals once on its own. Verified live: crashed a renderer, walked away, [crash] AUTO-recovered (renderer respawned), tab back at readyState: complete.

  2. A crashed tab no longer wedges the window. Verbs on a crashed tab fast-fail with tab_renderer_crashed and the exact recovery call, instead of hanging 20s and looking like a dead bridge.

  3. The CDP flood that caused it. Under many heavy-SPA tabs, the console mirror wrote every page message synchronously to stdout and a requestfailed listener firehosed the shared CDP socket — jamming all sessions on that Chrome. Console mirroring is now rate-limited (~8/sec/tab with a suppressed-count line), the requestfailed listener is gone, and the errors buffer is capped.

  4. Status verbs can't hang any more. browser_list_windows/browser_status gather sessions in parallel with a hard 2.5s per-session timeout and an in-memory fallback, so one busy window can't stall the whole call into a null response (the thing that reads as "pup is frozen"). Measured: 6 heavy windows under memory pressure, 15/15 calls returned the full list, 37-226ms.

Verified under load: 6 heavy windows, 5 rounds of forced allocation + navigation churn, with an independent thread sampling throughout — 80/80 samples stayed responsive, zero frozen. Chrome did crash under synthetic 300MB/window pressure, and all 6 windows self-recovered.

What I have NOT verified is your exact repro: 10 tabs all on wiki.adom.inc with a tight navigate→eval→screenshot loop on one tab. My load testing used mixed heavy sites, not 10 tabs of the same React SPA. Given #26/#27 report that wiki.adom.inc/skills specifically wedges a session, there may be something wiki-specific still live here.

Leaving this open until someone reruns your original repro. If it survives that, close it.

John Lauer · 4d ago

Largely already fixed since this was filed (v1.9.77), plus a residual trim in 1.9.335:

  • Ask 1/2 (survive + fast-fail): renderer crashes are caught via page.on('error')_markCrashed, and every read verb fast-fails through _throwIfCrashed in resolveTabOrError with a structured tab_renderer_crashed + reload hint — no 20s wait for an already-crashed tab. (The dedicated Inspector.targetCrashed CDP listener was intentionally removed in v1.9.185 because page.on('error') is driven by the same signal; not re-adding it.)
  • Ask 3 (reap on close): taskkill tree + profile-scoped orphan --type=renderer sweep already runs on close.
  • 1.9.335 adds raceCrash: the ONE in-flight screenshot/eval that races a crash now abandons the instant t._crashed flips, instead of eating the full 20s timeout.

Log in to reply.