Closed bug report

pup Chrome sessions never reaped: 3 orphaned sessions ran 2 days and drained a laptop battery overnight

James · 18d ago ·closed by John Lauer

Summary

Orphaned pup (Puppeteer) Chrome sessions were left running for ~2 days and drained a laptop battery overnight. On 2026-07-23 a diagnostic on a Windows laptop found 6 chrome.exe processes (3 pup browser sessions, each a browser process plus its renderer) launched from C:\Users\<user>\.cache\puppeteer\chrome\win64-146.0.7680.76\... on 2026-07-21 between 18:12 and 18:18, still running when checked two days later.

Impact

  • The 3 browser processes drew ~20% CPU combined (about 6.5% each) while doing nothing visible. All had HWND = 0 (no window, background-only), so there was no on-screen sign they were still alive.
  • On this Modern Standby (S0) laptop, unplugged with the lid closed overnight, that constant CPU kept the system out of deep idle. It drew ~13 W and drained from 99.9% to 2.5% in about 5.5 hours, then force-suspended at critical battery.
  • A powercfg /energy trace flagged "Processor utilization is high" (34.8% average), and the top offenders were exactly the three puppeteer-cache chrome.exe processes.

Root cause

pup opens browsers in the background and never auto-closes them (documented behavior: "Windows never auto-close, clean up when a task ends"). A task that ended without an explicit cleanup left the sessions running indefinitely. Nothing reaped them: not the bridge on shutdown, not a TTL, not an idle timeout.

Requested fix / ideas

  • Reap a pup Chrome session when its owning task or session ends, or after an idle/TTL timeout.
  • On bridge shutdown or restart, kill any Chrome spawned from this bridge's puppeteer cache that it still owns.
  • Add a verb to list stale pup sessions (with age and owner) and close them, so operators can clean up safely without killing chrome.exe by name.
  • Consider a max-session cap, or a warning surfaced when sessions accumulate past a threshold.

Evidence / how to check

  • List only pup Chrome: Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'chrome.exe' -and $_.CommandLine -like '*puppeteer*' }
  • Kill safely: stop only those PIDs (path-filtered on the puppeteer cache), never chrome.exe by name, so the user's real Chrome is untouched.
  • Environment: adom-desktop GUI 1.9.162 on the laptop, CLI 1.9.167 in the container, Windows, hybrid graphics.

Filed from a battery-drain investigation.

3 Replies

John Lauer · 18d ago

James — thank you for this. Fixed and shipped in pup bridge v1.9.76.

This is one of the best bug reports we've gotten: you measured it (~6.5% CPU each, ~13 W, 99.9% to 2.5% in 5.5 hours), you caught the HWND = 0 detail that explains why nothing looked wrong on screen, you ran a powercfg /energy trace, and you gave a path-filtered way to identify pup Chrome without nuking the user's real browser. That's what made the real cause findable.

Your symptom was exactly right. The cause was something else.

You concluded the sessions were never reaped. They weren't — that part is true — but reaping was not why your battery died, and a reaper would not have fixed this.

The actual cause: pup launched every window with all four of Chrome's power-saving mechanisms disabled.

--disable-renderer-backgrounding            keep rendering in a background tab
--disable-backgrounding-occluded-windows    keep rendering when the window is occluded
--disable-background-timer-throttling       keep timers/rAF firing
--disable-features=CalculateNativeWinOcclusion   skip occlusion detection entirely

Those exist for a real reason: pup windows are backgrounded by design, and Chrome throttles an occluded window, so recording one at normal power yields a 1-2 fps video. The flags keep it painting at full rate. But they were applied globally and permanently, so a pup window could never enter a low-power state at all.

The tell was in your own framing, and it's the thing that cracked it: people leave Chrome open all the time and their laptops sleep fine. A normal Chrome window throttles when occluded. Ours structurally could not. Three of them at 6.5% CPU wasn't three abandoned browsers, it was three browsers we had explicitly forbidden from idling.

That's also why reaping alone would have missed it: even a window you were actively using but not touching burned CPU the whole time.

What we shipped

1. The flags are now opt-in. Default is Chrome's own power behaviour. A normal pup window throttles when occluded and costs essentially nothing in the background. That alone ends the class of bug you hit.

2. High-FPS is a leased mode. browser_open_window {highFps:true} gets the old behaviour for background recording, but it's never open-ended: default 20 min, max 120. We picked 20 because it's long enough for a real demo take and short enough that a forgotten window can't cost a battery. Yours ran for two days.

3. You can't be cut off mid-recording. This was the part we were most careful about. While a recording is actually running, the lease cannot lapse — and that's authoritative, not a heuristic: recording runs through pup's own verbs, so the watchdog reads real recorder state rather than guessing from CPU or frame timing.

4. On lapse we downgrade, not close. Killing a window that may hold the user's work would be a worse bug than the one you filed. pup relaunches the same session with the same tabs at normal power. Content survives; only the power profile changes.

5. The AI can negotiate. Every browser_open_window response now explains which power mode it got and why. Normal windows are told high-FPS exists and when to want it; high-FPS windows are told the lease terms, that recording holds it open, and how to extend with browser_highfps_extend {sessionId, minutes}. Starting a recording on a normal-power window logs a loud warning instead of silently producing a 1-2 fps take.

On your other asks

Your suggestion of a verb to list stale sessions with age and owner is a good one and still stands on its own merit — we have long-lived sessions in the wild right now (one 22 hours old with no owner). That's hygiene worth having; it just isn't what was eating the battery, so we did the power fix first.

Full write-up including the defaults and the reasoning behind them: RECORDING.md on the pup bridge page.

Please do reopen if you see idle pup Chrome drawing CPU on v1.9.76+ — with the flags gone it should sit at effectively zero when occluded, exactly like any other Chrome window.

John Lauer · 17d ago

Fixed in pup bridge v1.9.89. James, thank you for this report, it was more serious than "a drained battery" and your write-up is what made it findable. A second user hit the same root cause today at 90% CPU, and on one machine (AdomLapper) it starved the desktop compositor badly enough to freeze the mouse and force a reboot.

What was actually wrong (not what I first assumed)

My earlier reply blamed the four Chrome power flags, and making those opt-in was a real fix for a real problem, but it was NOT the cause of your orphaned sessions. The actual bug was in pup's teardown:

browser_close / browser_close_window called browser.close() plus a process-tree kill, then ran a sweep to catch renderer children that get reparented (a crashed or detached renderer survives the tree kill, because it is no longer a child of the process we killed). That sweep had two fatal properties:

  1. Fire-and-forget. The close returned ok immediately, before the sweep finished or failed. So a close that left processes behind still reported success.
  2. It timed out exactly when it was needed. The sweep enumerated the FULL Windows process table and filtered afterward, with an 8 second timeout. When the box is already pegged at 88-98% CPU by the runaway renderers the sweep exists to kill, that enumeration does not finish in 8 seconds. It silently gave up and returned, and the orphans kept spinning at roughly a full core each, indefinitely.

That is why your three sessions ran for two days: nothing was ever going to reap them, and every close politely said ok.

The fix (v1.9.89)

  • The reap is AWAITED and VERIFIED. browser_close does not return until the processes are confirmed gone.
  • Load-tolerant enumeration. The process query now filters to chrome/msedge server-side in WMI, so only browser rows are materialized. It stays fast at 98% CPU instead of timing out.
  • Every close response returns chromeProcsRemaining (should be 0). If anything survives, the response says so with a warning hint instead of a bare ok.
  • Boot + periodic + close-all orphan sweeps. Any pup chrome/renderer whose profile has no live session gets reaped on bridge start and every 3 minutes. This catches orphans from a previous crash, and anything that outlives a close, without needing a close call at all. Live sessions are spared BY PROFILE, so a session's own renderers are never touched, and the user's real browser is never a candidate (the sweep only matches processes under pup's own profile dir).

Verified, with ground truth

Measured on AdomLapper via AD's own process_list (independent of pup's own reporting):

session while open after close
reaptest-a 7 procs (2 renderers) 0
reaptest-b 7 procs (2 renderers) 0
reaptest-c 7 procs (2 renderers) 0

21 processes spawned, 6 of them renderers, zero left behind. Each close also reported chromeProcsRemaining: 0.

How to get it

This is a source-only change (no dependency bump), so it rides AD's normal bridge auto-update with no blocker: you get it automatically on the next poll (within ~4h), or immediately by restarting Adom Desktop. To force it now:

refresh_bridges {"name":"puppeteer"}
restart_bridge  {"name":"puppeteer"}

Then confirm bridge_list shows puppeteer at 1.9.89 or newer. After that, a browser_close returns chromeProcsRemaining: 0, and if you already have orphans spinning right now, the periodic sweep clears them within ~3 minutes of the restart (or immediately via browser_close with no sessionId).

The emergency workaround (if you are pegged right now and cannot update)

Safe because it only matches pup's own binaries and never your personal Chrome:

Get-Process chrome | Where-Object { $_.Path -match 'puppeteer' } | Stop-Process -Force

Note this only matches Chrome-for-Testing paths. As of v1.9.90 pup drives your INSTALLED Chrome by default, so the reliable modern check is the profile dir instead:

Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" |
  Where-Object { $_.CommandLine -like '*\.adom\pup-profiles\*' } |
  ForEach-Object { Stop-Process -Id $_.ProcessId -Force }

Honest caveat

I proved the clean-close path with live process counts. I did not manufacture runaway reparented orphans on a user's laptop to test the worst case, because that is the exact condition that froze the machine. The evidence supports it though: renderer children carry the profile path in their command line (confirmed via process_list), and the reap keys on that same path, so a reparented orphan that keeps its --user-data-dir is matched. The periodic sweep is the continuous backstop between closes.

Keeping this open until a couple of days of real fleet use confirm no orphans reappear. If you see chromeProcsRemaining come back non-zero, that is the signal, please paste it here.

John Lauer · 17d ago

Closing: fixed in v1.9.89 and verified. Root cause was the fire-and-forget teardown reap timing out under the very CPU load it existed to clean up (full details and the ground-truth verification are in the reply above). Two independent users hit this, one severely enough to freeze a desktop, so the fix also added boot + periodic orphan sweeps so an orphan can never linger for two days again. James, thank you for the report — the two-day/battery detail is what made it findable. If you ever see chromeProcsRemaining come back non-zero, please reopen.

Log in to reply.