← Commit history

v2.0.595 (insiders): The user's close is final: a pup window closed by hand is forgotten at once and never restored; restore only on a proven browser crash (nonzero exit code) or a pup restart. The orphan about:blank sweep no longer closes a lane's last window or a window being born. rust/ ships with every release (pup-bridge#117).

John Lauer ·dde7ed546d ·1mo ago ·parent f77b1dd
10 files changed +213−52
dev-skills/pup-user-foreground/SKILL.md+36
@@ -115,6 +115,42 @@ loaded gun pointed at the user's click.    HIDE one. A hidden pup window is therefore always pup's own mess, and re-showing it can only    ever give the user back something pup took. +## The user's close is FINAL (2.0.595, John 2026-09-15)++"pup windows are supposed to be fleeting, meaning a user uses them for some ai its doing, but then the+user closes the window and doesn't care about them anymore ... on a normal day to day basis you should+NEVER bring back those windows unless you have a super important reason to."++What happened: John closed seven pup windows by hand. The lane browser exited with its last window+(as Chrome does), the sessions stayed on disk, and the next cold open tripped a chain (the health+tick's orphan sweep closed the cold window while it was still about:blank, the lane died, the "silent+lane" path fired 190 s later) that relaunched all seven. Rude, and nothing in the doctrine forbade it,+because recovery was written for the crash case and then applied to silence.++The rules now:++1. **A session whose window the user closed is forgotten at once**: removed from memory AND its file+   deleted, with a `_cleanupReport` so the owning thread learns why its next verb says+   `session_not_found`. Two ground-truth signals, in `forget_user_closed`: the worker reports every+   `chrome.windows.onRemoved` on its next poll (`&removed=` on `/extcmd`), and a dead hwnd while the+   lane is alive with no Chrome window behind it. A dead hwnd whose Chrome window still exists is a+   stale handle, not a close: unbind it for rebind.+2. **A gone lane is a lane the user closed, unless it provably crashed.** `lane_gone_pass` restores+   only when a pid pup launched exited with a NONZERO code (`Host::watch_process` at launch keeps a+   handle; `Host::exit_code` reads it after the process is gone; pup's own `kill` reads as 0). Exit+   code 0 (the last window closed), a hung process silent 10 min with no window on glass, or an+   unwatched lane after a pup restart: all forget, none restore. The CDP path in `health_tick` has the+   same gate. Silence is never proof of a crash.+3. **The one restore a user expects is the pup restart** (`recover_at_boot`): what was live when pup+   died comes back. Since closed windows have no file any more, that restores only live windows.+4. **Never close a lane's last window on inference.** `close_orphan_blank_windows` skips while a birth+   holds `BIRTH_LOCK` and never touches the only window: closing it exits Chrome and takes the worker.+   The cold open inserts its session BEFORE the deferred navigate so its about:blank window is owned.++Proof (winvm, `proof-user-close.sh`): close one of two ext windows by WM_CLOSE, the session is gone+within one tick and stays gone for 4 min; close the last one, the lane ends with exit code 0 and every+session is forgotten; kill the browser process from outside (nonzero exit), the windows come back.+ ## The birth grace may override FLAGS, never INPUT (2.0.584, John 2026-09-15)  R6's birth grace (8 s, no granted raise) skipped BOTH user signals inside the shared primitive, on the
rust/Cargo.lock+12−12
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-pup-bridge"-version = "2.0.594"+version = "2.0.595" dependencies = [  "pup-browser",  "pup-cdp",@@ -655,7 +655,7 @@ dependencies = [  [[package]] name = "pup-browser"-version = "2.0.594"+version = "2.0.595" dependencies = [  "pup-core",  "pup-host",@@ -667,7 +667,7 @@ dependencies = [  [[package]] name = "pup-cdp"-version = "2.0.594"+version = "2.0.595" dependencies = [  "serde_json",  "thiserror",@@ -677,7 +677,7 @@ dependencies = [  [[package]] name = "pup-core"-version = "2.0.594"+version = "2.0.595" dependencies = [  "serde",  "serde_json",@@ -687,7 +687,7 @@ dependencies = [  [[package]] name = "pup-dashboard"-version = "2.0.594"+version = "2.0.595" dependencies = [  "pup-cdp",  "pup-core",@@ -700,7 +700,7 @@ dependencies = [  [[package]] name = "pup-host"-version = "2.0.594"+version = "2.0.595" dependencies = [  "serde",  "serde_json",@@ -709,14 +709,14 @@ dependencies = [  [[package]] name = "pup-host-mac"-version = "2.0.594"+version = "2.0.595" dependencies = [  "pup-host", ]  [[package]] name = "pup-host-win"-version = "2.0.594"+version = "2.0.595" dependencies = [  "pup-core",  "pup-host",@@ -725,7 +725,7 @@ dependencies = [  [[package]] name = "pup-identity"-version = "2.0.594"+version = "2.0.595" dependencies = [  "fontdue",  "image",@@ -735,7 +735,7 @@ dependencies = [  [[package]] name = "pup-session"-version = "2.0.594"+version = "2.0.595" dependencies = [  "pup-core",  "serde",@@ -745,7 +745,7 @@ dependencies = [  [[package]] name = "pup-vault"-version = "2.0.594"+version = "2.0.595" dependencies = [  "pup-core",  "pup-host",@@ -756,7 +756,7 @@ dependencies = [  [[package]] name = "pup-verbs"-version = "2.0.594"+version = "2.0.595" dependencies = [  "base64",  "png 0.17.16",
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "host", "host-win", "host-mac", "cdp", "browser", "session",  # One version for the whole bridge, in lockstep with src/BRIDGE_VERSION (tools/ship.sh bumps both). [workspace.package]-version = "2.0.594"+version = "2.0.595" edition = "2021" license = "MIT" 
rust/bridge/src/main.rs+4−3
@@ -134,9 +134,10 @@ fn main() {                 // pup-bridge#109: the extension-driven control channel. The pup extension polls for one                 // command and posts the result; this is how pup drives a browser that carries NO CDP port.                 (Method::Get, "/extcmd") => {-                    let lane = url.split('?').nth(1).unwrap_or("").split('&')-                        .find_map(|kv| kv.strip_prefix("lane=")).unwrap_or("").to_string();-                    let _ = req.respond(json_response(200, &pup_verbs::extdrive::take_command_for(&lane)));+                    let q = url.split('?').nth(1).unwrap_or("");+                    let lane = q.split('&').find_map(|kv| kv.strip_prefix("lane=")).unwrap_or("").to_string();+                    let removed: Vec<i64> = q.split('&').find_map(|kv| kv.strip_prefix("removed=")).unwrap_or("").split(',').filter_map(|x| x.parse().ok()).collect();+                    let _ = req.respond(json_response(200, &pup_verbs::extdrive::take_command_for(&lane, &removed)));                 }                 (Method::Post, "/exthello") => {                     let mut raw = String::new();
rust/host-win/src/lib.rs+26
@@ -151,7 +151,32 @@ mod imp {         fn dpi_scale(&self, hwnd: i64) -> f64 { let d = unsafe { GetDpiForWindow(hwnd as HWND) }; if d == 0 { 1.0 } else { d as f64 / 96.0 } }     } +    // 2.0.595: handles on the browsers pup launched, so their exit code outlives them (GetExitCodeProcess on a+    // handle opened while the process ran). Pids pup killed itself are remembered as deliberate ends.+    static WATCHED: std::sync::Mutex<Vec<(u32, isize)>> = std::sync::Mutex::new(Vec::new());+    static KILLED: std::sync::Mutex<Vec<u32>> = std::sync::Mutex::new(Vec::new());+     impl HostProcess for WinHost {+        fn watch_process(&self, pid: u32) {+            if pid == 0 { return; }+            unsafe {+                let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);+                if h.is_null() { return; }+                let mut w = WATCHED.lock().unwrap();+                if let Some(i) = w.iter().position(|(p, _)| *p == pid) { let (_, old) = w.remove(i); windows_sys::Win32::Foundation::CloseHandle(old as windows_sys::Win32::Foundation::HANDLE); }+                w.push((pid, h as isize));+                KILLED.lock().unwrap().retain(|p| *p != pid);+            }+        }+        fn exit_code(&self, pid: u32) -> Option<u32> {+            if KILLED.lock().unwrap().contains(&pid) { return Some(0); }+            let h = WATCHED.lock().unwrap().iter().find(|(p, _)| *p == pid).map(|(_, h)| *h)?;+            unsafe {+                let mut code: u32 = 0;+                if GetExitCodeProcess(h as windows_sys::Win32::Foundation::HANDLE, &mut code) == 0 { return None; }+                if code == STILL_ACTIVE as u32 { None } else { Some(code) }+            }+        }         // Node: isProcessAlive         fn alive(&self, pid: u32) -> bool {             unsafe {@@ -278,6 +303,7 @@ mod imp {                 if h.is_null() { return false; }                 let ok = TerminateProcess(h, 1) != 0;                 windows_sys::Win32::Foundation::CloseHandle(h);+                if ok { KILLED.lock().unwrap().push(pid); }                 ok             }         }
rust/host/src/lib.rs+6
@@ -75,6 +75,12 @@ pub trait HostProcess: Send + Sync {     /// Spawn a browser DETACHED from this bridge (it must survive a bridge restart). Returns the pid.     fn spawn_detached(&self, exe: &Path, args: &[String], cwd: Option<&Path>) -> std::io::Result<u32>;     fn kill(&self, pid: u32) -> bool;+    /// 2.0.595: keep a handle on a browser pup launched so its EXIT CODE is readable after it is gone. A+    /// browser whose last window the user closed exits 0; a crash does not. Recovery restores only a crash.+    fn watch_process(&self, pid: u32) { let _ = pid; }+    /// The exit code of a watched process that has exited: None while it runs, or if it was never watched.+    /// A process pup itself killed reads as 0 (a deliberate end, not a crash).+    fn exit_code(&self, pid: u32) -> Option<u32> { let _ = pid; None }     /// Run a script through the platform shell and return stdout (Windows: powershell -Command; empty     /// on platforms without one). For the credential-import scan and the real-Chrome installer.     fn run_shell_script(&self, _script: &str, _timeout: std::time::Duration) -> String { String::new() }
rust/verbs/src/extdrive.rs+10−1
@@ -31,14 +31,19 @@ static TAKEN: Mutex<Option<std::collections::HashMap<u64, (Value, u64)>>> = Mute static SEQ: Mutex<u64> = Mutex::new(0); /// Last time the extension asked for work — how we know an extension-driven browser is alive. static LAST_POLL: Mutex<u64> = Mutex::new(0);+/// 2.0.595: Chrome window ids the worker saw removed (chrome.windows.onRemoved), reported on its next poll.+/// A session whose window is in here was closed by the USER (pup's own close removes the session first).+static REMOVED: Mutex<Vec<i64>> = Mutex::new(Vec::new());+pub fn take_removed() -> Vec<i64> { std::mem::take(&mut *REMOVED.lock().unwrap()) }  /// GET /extcmd — the extension asks for work. Returns `{}` when there is nothing to do.-pub fn take_command_for(lane: &str) -> Value {+pub fn take_command_for(lane: &str, removed: &[i64]) -> Value {     // 2.0.502: only the extension-driven browser (the one carrying this launch's lane token) may take work.     // Every pup browser loads the same extension, so without this a NORMAL window answered and pup drove     // another thread's window.     let want = crate::EXTDRIVE_LANE.lock().unwrap().clone();     if want.is_empty() || lane != want { return json!({}); }+    if !removed.is_empty() { REMOVED.lock().unwrap().extend_from_slice(removed); }     take_command() } @@ -506,6 +511,9 @@ pub fn rebind_unbound(ctx: &Ctx) -> usize { /// created for a session that has not been recorded yet. pub(crate) fn close_orphan_blank_windows(ctx: &Ctx, keep: Option<i64>) -> usize {     if !alive(ctx) { return 0; }+    // 2.0.595: never while a window is being born (a cold open's window is about:blank until its deferred+    // navigate), and never the lane's LAST window: closing it exits Chrome and takes the worker with it.+    if crate::BIRTH_LOCK.try_lock().is_err() { return 0; }     let Ok(r) = run(json!({ "action": "tabs" }), Duration::from_secs(10)) else { return 0 };     let owned: std::collections::HashSet<i64> = ctx.sessions.all().iter().filter_map(|s| s.window_id).collect();     let mut wins: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();@@ -513,6 +521,7 @@ pub(crate) fn close_orphan_blank_windows(ctx: &Ctx, keep: Option<i64>) -> usize         if let Some(w) = t["windowId"].as_i64() { wins.entry(w).or_default().push(t["url"].as_str().unwrap_or("").to_string()); }     }     let mut n = 0;+    if wins.len() <= 1 { return 0; }     for (w, urls) in wins {         if Some(w) == keep || owned.contains(&w) || urls.is_empty() { continue; }         if urls.iter().all(|u| u.is_empty() || u == "about:blank") {
rust/verbs/src/lib.rs+21−2
@@ -75,7 +75,7 @@ static CONSUMER_UA: Mutex<Option<(String, Value)>> = Mutex::new(None); /// SAME new frame, and both claimed it: three sessions on one hwnd, each stamping its tile over the last /// (John, 2026-09-14: "you fucking stick them in nsf's pup windows taskbar icon and vice versa"). Held /// across [snapshot -> create window -> resolve hwnd] ONLY; navigation happens after it is released.-static BIRTH_LOCK: Mutex<()> = Mutex::new(());+pub(crate) static BIRTH_LOCK: Mutex<()> = Mutex::new(()); /// Every hwnd another session already owns. A handle is NEVER handed to a second session: that is what /// collapses distinct threads onto one taskbar button. (readopt_ext already did this; birth and the late /// rebind did not.)@@ -234,6 +234,7 @@ pub fn launch_extdrive(ctx: &Ctx, url: &str) -> Result<u32, Value> {                 if !fresh.is_empty() { owned = fresh; break; }             }             pup_core::log(format!("[extdrive] {} up on profile \"{profile}\" with NO debug port ({} process(es))", cand.kind, owned.len()));+            for p in &owned { ctx.host.watch_process(*p); }   // 2.0.595: the exit code decides restore vs forget             *EXTDRIVE_PIDS.lock().unwrap() = owned;             save_lane(ctx);             Ok(pid)@@ -801,6 +802,7 @@ fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) -             if ctx.settings.get("prePermissions", "off") == "on" { grant_default_permissions(&h.cdp); }             close_orphan_blank_targets(ctx, &h.cdp, profile);             events::start_pump(profile.into(), h.cdp.clone(), ctx.sessions.clone(), ctx.tabstate.clone());+            ctx.host.watch_process(h.launched.pid);             ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());             return Ok(h);         }@@ -831,6 +833,7 @@ fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) -                     if ctx.settings.get("prePermissions", "off") == "on" { grant_default_permissions(&h.cdp); }                     close_orphan_blank_targets(ctx, &h.cdp, profile);                     events::start_pump(profile.into(), h.cdp.clone(), ctx.sessions.clone(), ctx.tabstate.clone());+                    ctx.host.watch_process(h.launched.pid);                     ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());                     return Ok(h);                 }@@ -1537,7 +1540,23 @@ pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owne     drop(birth_guard);   // hwnd resolved (or refused): the next thread's birth may start     // the deferred cold navigate, now that no other birth is blocked behind it     if cold && url != "about:blank" {-        let nav = extdrive::run(json!({ "action": "navigate", "url": url, "tabId": chrome_tab, "waitMs": 25000 }), Duration::from_secs(30)).unwrap_or(Value::Null);+        // 2.0.595: the session OWNS its window from here. It used to be inserted only after this navigate, so for+        // up to 25 s the lane's only window was an unowned about:blank, and the health tick's orphan sweep closed+        // it: Chrome exited with its last window, the worker went silent, and 190 s later the "dead lane" path+        // relaunched every session on disk, including six windows John had closed by hand (AdomLapper 21:41,+        // 2026-09-15). That was also the "worker stalls right after a cold launch" mystery.+        ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);+        let nav = match extdrive::run(json!({ "action": "navigate", "url": url, "tabId": chrome_tab, "waitMs": 25000 }), Duration::from_secs(30)) {+            Ok(v) => v,+            Err(e) => {+                if s.hwnd.map(|h| !ctx.host.is_alive(h)).unwrap_or(false) && !extdrive::alive(ctx) {+                    ctx.sessions.remove(sid); ctx.sessions.delete_file(sid);+                    pup_core::log(format!("[window] \"{sid}\": the lane browser died during the cold open (its window is gone and the worker is silent); no session"));+                    return pup_core::merge(e, json!({ "errorCode": "ext_lane_died_during_open", "_hint": "The lane browser exited while this window was being opened. Open again; pup did not keep a dead session." }));+                }+                Value::Null+            }+        };         if let Some(t0) = s.tabs.get_mut(0) {             if let Some(u) = nav["url"].as_str() { if !u.is_empty() { t0.url = u.to_string(); } }             if let Some(ti) = nav["title"].as_str() { if !ti.is_empty() { t0.title = ti.to_string(); } }
rust/verbs/src/recovery.rs+87−32
@@ -28,6 +28,7 @@ fn reconnect_profile_hint(ctx: &Ctx, profile: &str, hint: Option<u16>) -> Option     // learn the browser pid from the browser itself (SystemInfo.getProcessInfo) so a later close can reap it     let pid = cdp.send(None, "SystemInfo.getProcessInfo", json!({}), Duration::from_secs(5)).ok().and_then(|v| v["processInfo"].as_array().and_then(|a| a.iter().find(|p| p["type"] == json!("browser")).and_then(|p| p["id"].as_u64()))).unwrap_or(0) as u32;     let h = Arc::new(BrowserHandle { cdp, launched: Launched { pid, cdp_port: port, candidate: cand, profile_dir, reused: true } });+    ctx.host.watch_process(pid);     events::start_pump(profile.into(), h.cdp.clone(), ctx.sessions.clone(), ctx.tabstate.clone());     ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());     pup_core::log(format!("Reconnected to Chrome on port {port} for profile \"{profile}\""));@@ -380,47 +381,91 @@ fn idle_browser_reap(ctx: &Ctx) {     } } -/// 2.0.580: the lane died overnight (worker silent 14 h, no window on glass) while pup kept listing its-/// session and calling the lane healthy, because the remembered pids still "looked" alive. The extension-/// lane has no CDP socket to notice a death on, so the worker's heartbeat IS its liveness. Silent 3 min-/// with no lane window on glass, or 10 min regardless: end what is left, and restore every extension-/// session from its file (the same path that heals a crash), tabs included.-fn silent_lane_restore(ctx: &Ctx) {+/// 2.0.595: forget ONE session whose window the user closed. Nothing is restored: "pup windows are fleeting,+/// a user uses them for some AI it's doing, then closes the window and doesn't care about them anymore"+/// (John, 2026-09-15, after a cold open brought back six windows he had closed by hand).+fn forget_session(ctx: &Ctx, s: &Session, why: &str) {+    ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id);+    cancel_restore(&s.id);+    pup_core::log(format!("[lane] \"{}\": {why}; forgotten, nothing restored", s.id));+    crate::push_cleanup_report(format!("the user closed the \"{}\" pup window ({why}). pup forgot it and will not bring it back; open it again with pup_open_window if you still need it.", s.id));+}++/// The user's close is final. Two signals, both ground truth: the worker reports every chrome.windows.onRemoved+/// on its next poll, and a session whose OS window is dead while its lane is alive has no window to come back+/// to. A dead hwnd whose Chrome window still exists is a stale handle, not a close: it is unbound for rebind.+fn forget_user_closed(ctx: &Ctx) {+    let removed = crate::extdrive::take_removed();+    let ext: Vec<Session> = ctx.sessions.all().into_iter().filter(|s| crate::transport::is_ext(s)).collect();+    if ext.is_empty() { return; }+    for s in &ext {+        if let Some(w) = s.window_id { if removed.contains(&w) { forget_session(ctx, s, "its window was closed by the user (reported by the extension)"); } }+    }+    if !crate::extdrive::alive(ctx) || crate::extdrive::connected_ms_ago() > 8000 { return; }+    let dead: Vec<Session> = ctx.sessions.all().into_iter().filter(|s| crate::transport::is_ext(s) && s.hwnd.map(|h| !ctx.host.is_alive(h)).unwrap_or(false)).collect();+    if dead.is_empty() { return; }+    let Ok(r) = crate::extdrive::run(json!({ "action": "tabs" }), Duration::from_secs(10)) else { return };+    let live: Vec<i64> = r["tabs"].as_array().cloned().unwrap_or_default().iter().filter_map(|t| t["windowId"].as_i64()).collect();+    for s in dead {+        match s.window_id {+            Some(w) if live.contains(&w) => { let mut u = s.clone(); u.hwnd = None; ctx.sessions.insert(u.clone()); ctx.sessions.save_file(&u); pup_core::log(format!("[lane] \"{}\": its OS handle is dead but Chrome window {w} lives; unbound for rebind", s.id)); }+            _ => forget_session(ctx, &s, "its OS window is gone while the lane is alive (closed by the user)"),+        }+    }+}++/// What ended the lane browser, from the exit code of the pids pup launched (host.watch_process at launch).+/// Some(code) with code != 0 is a crash; 0 is the user closing the last window (or pup's own kill); None when+/// still running or never watched (after a pup restart), which is NOT proof of a crash.+fn lane_crash_code(ctx: &Ctx) -> Option<u32> {+    crate::extdrive_pids().iter().filter_map(|p| ctx.host.exit_code(*p)).find(|c| *c != 0)+}++/// 2.0.580 restored every extension session from disk whenever the lane went quiet with no window on glass,+/// on the theory that a silent lane is a dead lane. 2.0.595: a lane that is gone is, by default, a lane whose+/// windows the USER closed (Chrome exits with its last window, exit code 0). Restore ONLY a proven crash+/// (nonzero exit code, sleepRecovery on); everything else is forgotten. A pup restart still restores what+/// was live when it died (recover_at_boot), which is the one restore a user expects.+fn lane_gone_pass(ctx: &Ctx) {     static LAST: std::sync::Mutex<u64> = std::sync::Mutex::new(0);     let ext: Vec<Session> = ctx.sessions.all().into_iter().filter(|s| crate::transport::is_ext(s)).collect();-    if ext.is_empty() || crate::extdrive::last_poll_ms() == 0 { return; }-    let ago = crate::extdrive::connected_ms_ago();-    if ago < 180_000 { return; }-    // 2.0.588 (John, 2026-09-15: "i just foregrounded the kicad-bridge pup window to look at it and you stole it-    // away from me"): this fired on INFERENCE (a quiet worker plus a window count that read zero with unknown-    // pids) and relaunched a window the user was looking at. Ground truth first: a session whose OS window is-    // alive on glass is NOT a dead lane, whatever the worker is doing. Never destroy on inference when the-    // truth is one IsWindow away (pup-user-foreground, the window-destruction incident).+    if ext.is_empty() { return; }+    let pids = crate::extdrive_pids();+    let process_alive = !pids.is_empty() && pids.iter().any(|p| ctx.host.alive(*p));     let alive_sessions = ext.iter().filter(|s| s.hwnd.map(|h| ctx.host.is_alive(h)).unwrap_or(false)).count();-    if alive_sessions > 0 {-        static SAID: std::sync::Mutex<u64> = std::sync::Mutex::new(0);-        let mut g = SAID.lock().unwrap();-        if pup_core::now_ms().saturating_sub(*g) > 300_000 { *g = pup_core::now_ms(); pup_core::log(format!("[health] the extension worker has been silent {} s but {alive_sessions} session window(s) are alive on glass: leaving them alone (a quiet worker is not a dead lane)", ago / 1000)); }+    // a window on glass is a live lane whatever the worker or the pids say (2.0.588, the kicad window John raised)+    if alive_sessions > 0 { return; }+    let ago = if crate::extdrive::last_poll_ms() == 0 { u64::MAX } else { crate::extdrive::connected_ms_ago() };+    let gone = if !pids.is_empty() && !process_alive { true }   // the browser process ended: known now, not in 3 min+        else if ago == u64::MAX { false }+        else { ago >= 180_000 && !process_alive || ago >= 600_000 };+    if !gone { return; }+    { let mut g = LAST.lock().unwrap(); if pup_core::now_ms().saturating_sub(*g) < 60_000 { return; } *g = pup_core::now_ms(); }+    let crash = lane_crash_code(ctx);+    let restore = crash.is_some() && ctx.settings.get("sleepRecovery", "on") == "on";+    if restore {+        pup_core::log(format!("[health] the lane browser CRASHED (exit code 0x{:x}); restoring {} session(s) from disk", crash.unwrap_or(0), ext.len()));+        let ids: Vec<String> = ext.iter().map(|s| s.id.clone()).collect();+        for s in &ext { ctx.sessions.save_file(s); ctx.sessions.remove(&s.id); }+        crate::kill_extdrive(ctx);+        for f in ctx.sessions.read_files() {+            let id = f["sessionId"].as_str().unwrap_or("");+            if ids.iter().any(|x| x == id) { relaunch_from_file(ctx, &f); }+        }         return;     }-    // an EMPTY pid list is "no filter" to the enumerator (it would count the user's own Chrome and Edge, measured: 7)-    let pids = crate::extdrive_pids();-    let wins = if pids.is_empty() { 0 } else { ctx.host.windows_for_pids(&pids, Some("Chrome_WidgetWin_1")).into_iter().filter(|w| w.visible && w.rect.2 - w.rect.0 > 200).count() };-    if wins > 0 && ago < 600_000 { return; }-    { let mut g = LAST.lock().unwrap(); if pup_core::now_ms().saturating_sub(*g) < 300_000 { return; } *g = pup_core::now_ms(); }-    pup_core::log(format!("[health] the extension lane has been silent for {} s with {wins} lane window(s) on glass: it is gone. Ending what is left and restoring {} session(s) from disk", ago / 1000, ext.len()));-    let ids: Vec<String> = ext.iter().map(|s| s.id.clone()).collect();-    for s in &ext { ctx.sessions.save_file(s); ctx.sessions.remove(&s.id); }+    let why = if !pids.is_empty() && !process_alive { "the lane browser exited cleanly (exit code 0: its last window was closed by the user)".to_string() }+        else if process_alive { format!("the lane browser has been silent {} s with no window on glass (a hung process, not a crash)", ago / 1000) }+        else { format!("the lane has been silent {} s with no window on glass and no process to ask", ago / 1000) };+    pup_core::log(format!("[health] {why}: forgetting {} session(s), nothing restored (pup windows are fleeting)", ext.len()));+    for s in &ext { forget_session(ctx, s, &why); }     crate::kill_extdrive(ctx);-    for f in ctx.sessions.read_files() {-        let id = f["sessionId"].as_str().unwrap_or("");-        if ids.iter().any(|x| x == id) { relaunch_from_file(ctx, &f); }-    } }  pub fn health_tick(ctx: &Ctx) {     crate::extdrive::adopt_tokenless(ctx);   // 2.0.580: a token-less extension browser after a restart-    silent_lane_restore(ctx);                // 2.0.580: a lane whose worker went quiet is a dead lane+    forget_user_closed(ctx);                 // 2.0.595: the user's close is final+    lane_gone_pass(ctx);                     // 2.0.595: a gone lane is forgotten unless it provably crashed     crate::extdrive::rebind_unbound(ctx);   // ext sessions whose birth outran the hwnd wait: bind + stamp now     crate::extdrive::reload_stale_worker(ctx);   // a bridge_install must not wait for every thread's window to close     crate::extdrive::close_orphan_blank_windows(ctx, None);   // and any about:blank window no session owns@@ -456,8 +501,18 @@ pub fn health_tick(ctx: &Ctx) {                 // (Node: the crash auto-recover, "pup auto-recovered your window after a Chrome crash").                 // A live-but-silent Chrome (asleep, hung, restarting) is left alone for the next tick.                 if pid == 0 || !ctx.host.alive(pid) {+                    // 2.0.595: Chrome exits with its last window (code 0) when the USER closes it; only a nonzero+                    // exit code is a crash worth restoring. Never watched (pid 0, or a pup restart) is not proof.+                    let code = if pid == 0 { None } else { ctx.host.exit_code(pid) };+                    let crashed = matches!(code, Some(c) if c != 0) && ctx.settings.get("sleepRecovery", "on") == "on";+                    if !crashed {+                        let gone: Vec<Session> = ctx.sessions.for_profile(&p);+                        pup_core::log(format!("[health] Chrome for profile \"{p}\" (pid {pid}) is gone with exit code {code:?}: not a crash. Forgetting its {} window(s), nothing restored (pup windows are fleeting)", gone.len()));+                        for s in &gone { forget_session(ctx, s, "its browser exited cleanly (the user closed its last window)"); }+                        continue;+                    }                     let files: Vec<Value> = ctx.sessions.for_profile(&p).iter().map(|s| s.file_json()).collect();-                    pup_core::log(format!("[health] Chrome for profile \"{p}\" (pid {pid}) is gone — relaunching its {} window(s) from their files", files.len()));+                    pup_core::log(format!("[health] Chrome for profile \"{p}\" (pid {pid}) CRASHED (exit code 0x{:x}) — relaunching its {} window(s) from their files", code.unwrap_or(0), files.len()));                     // 2.0.469: drop and reserve EVERY session first, then relaunch. Dropping each only at its turn left                     // the second window listed with its three dead tabs for the ~30 s the first window's relaunch took                     // (CRR crash drill: "restored 2x3" but 3 of 6 live); a listed window must be a live one.
src/extension/worker.js+10−1
@@ -207,7 +207,11 @@ async function pupExtPollOnce() {   }   let cmd = null;   try {-    const r = await fetch(`http://127.0.0.1:${found.port}/extcmd?lane=${encodeURIComponent(_pkgLane)}`, { signal: AbortSignal.timeout(2500) });+    const removed = pupRemovedWindows.splice(0, pupRemovedWindows.length);+    const rq = removed.length ? `&removed=${removed.join(',')}` : '';+    let r;+    try { r = await fetch(`http://127.0.0.1:${found.port}/extcmd?lane=${encodeURIComponent(_pkgLane)}${rq}`, { signal: AbortSignal.timeout(2500) }); }+    catch (e) { pupRemovedWindows.unshift(...removed); throw e; }     cmd = await r.json();   } catch (e) { return false; }   if (!cmd || !cmd.id) return false;@@ -481,6 +485,11 @@ async function pupExtHello() {       signal: AbortSignal.timeout(1500) });   } catch (e) {} }+// 2.0.595: windows the USER closed. Reported on the next poll so pup forgets their sessions instead of+// restoring them later ("pup windows are fleeting", John 2026-09-15). pup's own close removes the session+// before the window goes, so a report for it is a no-op there.+const pupRemovedWindows = [];+try { chrome.windows.onRemoved.addListener((id) => { pupRemovedWindows.push(id); }); } catch (e) {} chrome.alarms.create('pup-hello', { periodInMinutes: 0.5 }); chrome.alarms.onAlarm.addListener((a) => { if (a.name === 'pup-hello') pupExtHello(); });