app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
← 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).
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(); });