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.
master
John Lauer
v2.0.739 (insiders): Never a bare Chrome taskbar button during recovery (pup-bridge#143): an OS-level health-tick safet…
f222eda
9d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954
//! Recovery: reconnect the windows a previous bridge left running (session files + a live
//! DevTools port), the 15 s health loop, and pup_rescan. ONE implementation (Node had six paths).
//! A slow Chrome is never killed here (2.0.392 lesson): a probe miss leaves the process and the
//! session file alone for the next tick.
use crate::{events, BrowserHandle, Ctx};
use pup_browser::{Candidate, Launched};
use pup_cdp::Cdp;
use pup_core::ok;
use pup_host::Host;
use pup_session::{Session, Tab};
use serde_json::{json, Value};
use std::sync::Arc;
use std::time::Duration;
/// Connect to a profile's live browser (by its DevToolsActivePort) and register the handle.
fn reconnect_profile(ctx: &Ctx, profile: &str) -> Option<Arc<BrowserHandle>> { reconnect_profile_hint(ctx, profile, None) }
/// `hint`: the cdpPort a session file recorded. The Node build's Chrome answers on it while its
/// user-data-dir carries no DevToolsActivePort file (measured on winvm at the 2.0.401 install: Chrome
/// alive on 9420, no port file), so the file's port is the second place to look.
fn reconnect_profile_hint(ctx: &Ctx, profile: &str, hint: Option<u16>) -> Option<Arc<BrowserHandle>> {
if let Some(b) = ctx.browsers.lock().unwrap().get(profile) { if b.cdp.is_alive() { return Some(b.clone()); } }
let profile_dir = pup_browser::profiles_dir(&*ctx.host).join(profile);
let port = pup_browser::existing_port(&profile_dir).or_else(|| hint.filter(|p| *p > 0 && Cdp::connect_port(*p, Duration::from_secs(3)).map(|c| { let ok = c.version().is_ok(); c.close(); ok }).unwrap_or(false)))?;
let cdp = Cdp::connect_port(port, Duration::from_secs(8)).ok()?;
let product = cdp.version().ok().and_then(|v| v["product"].as_str().map(|s| s.to_string())).unwrap_or_default();
let cand = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft()).into_iter().find(|c| (product.contains("Edg") && c.kind == "edge") || (!product.contains("Edg") && c.kind != "edge")).unwrap_or(Candidate { kind: "chrome".into(), source: "reconnect".into(), executable_path: Default::default(), build_id: None });
// 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}\""));
Some(h)
}
/// Node: sleepRecovery / the crash auto-recover. A session whose Chrome is gone comes back at its
/// last URL, in the background, signed in (same profile), at most twice per session id; the caller
/// hears about it through `_cleanupReport` on its next verb.
static RELAUNCHES: std::sync::Mutex<Vec<(String, u32)>> = std::sync::Mutex::new(Vec::new());
/// 2.0.468: session ids a relaunch is bringing back RIGHT NOW. Between "dropped from memory" and "open_window
/// inserted the new session" the id has a file and no session, which is exactly what the health tick's
/// file re-attach looks for: on arav-rog it re-attached "drill-b" to the tab "drill-a"'s relaunch had just
/// opened (same URL), and the restore thread then relaunched drill-b on top of it (4 tabs, one shared).
static RESTORING: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
static IN_FLIGHT: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
pub fn restoring(id: &str) -> bool { RESTORING.lock().unwrap().iter().any(|s| s == id) }
/// Reserve ids a caller is about to relaunch one by one (pup_restart_browser): the health tick leaves their
/// files alone until each relaunch has run. Released by `release`, or by the relaunch itself.
pub fn reserve(ids: &[String]) { let mut m = RESTORING.lock().unwrap(); for id in ids { if !m.contains(id) { m.push(id.clone()); } } }
pub fn release(id: &str) { RESTORING.lock().unwrap().retain(|s| s != id); }
struct RelaunchGuard(String);
static RELAUNCHED_AT: std::sync::Mutex<Vec<(String, u64)>> = std::sync::Mutex::new(Vec::new());
/// 2.0.473: a window is "settling" for a grace period after a relaunch. During it the event pump must not adopt a
/// tab into the window as "opened outside pup": a killed Chrome auto-restores the old session's page as the new
/// window comes up, and pup has already reopened that page itself, so the restore lands as a phantom extra tab
/// (triple-restart drill on ConfRoomROG: "back with 4 of 3", 0 live).
pub fn note_relaunched(id: &str) { let mut m = RELAUNCHED_AT.lock().unwrap(); let now = pup_core::now_ms(); m.retain(|(s, t)| s != id && now.saturating_sub(*t) < 120_000); m.push((id.to_string(), now)); }
pub fn recently_relaunched(id: &str, within_ms: u64) -> bool { let now = pup_core::now_ms(); RELAUNCHED_AT.lock().unwrap().iter().any(|(s, t)| s == id && now.saturating_sub(*t) < within_ms) }
static CANCELLED_RESTORE: std::sync::Mutex<Vec<(String, u64)>> = std::sync::Mutex::new(Vec::new());
/// 2.0.682: windows whose browser pup KNOWS is gone for this pass (pup ended it for an upgrade or restart, it crashed,
/// or it was already dead when this pup booted). Only these are relaunched when their window is missing; a missing
/// window that is not marked here was closed and is forgotten. 2.0.681 decided by "is a browser running NOW", and the
/// first relaunch of an upgrade restarts the browser, so every later window of that upgrade was dropped as "closed".
static LOST: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
pub fn mark_lost(ids: &[String]) { let mut m = LOST.lock().unwrap(); for id in ids { if !m.contains(id) { m.push(id.clone()); } } }
fn is_lost(id: &str) -> bool { LOST.lock().unwrap().iter().any(|s| s == id) }
/// 2.0.476: a bulk close CANCELS any in-flight restore of these windows instead of waiting for it. The restore
/// thread checks this after it brings a window back and, if the window was cancelled, closes it again and drops
/// the session — so "Close all" is instant and no window finishes relaunching behind the close and lingers
/// (close-during-restore cycle). The mark expires in 45 s, and a deliberate reopen of the id clears it at once.
pub fn cancel_restore(id: &str) { let mut m = CANCELLED_RESTORE.lock().unwrap(); let now = pup_core::now_ms(); m.retain(|(s, t)| s != id && now.saturating_sub(*t) < 300_000); m.push((id.to_string(), now)); }
pub fn restore_cancelled(id: &str) -> bool { let now = pup_core::now_ms(); CANCELLED_RESTORE.lock().unwrap().iter().any(|(s, t)| s == id && now.saturating_sub(*t) < 300_000) } // 2.0.682: 45 s -> 5 min; an upgrade queue took 70 s
pub fn clear_cancel(id: &str) { CANCELLED_RESTORE.lock().unwrap().retain(|(s, _)| s != id); }
impl Drop for RelaunchGuard { fn drop(&mut self) { note_relaunched(&self.0); IN_FLIGHT.lock().unwrap().retain(|s| *s != self.0); LOST.lock().unwrap().retain(|s| *s != self.0); release(&self.0); } }
/// 2.0.533: an EXT session is re-adopted, never relaunched, while its Chrome window is still in the lane
/// browser. The liveness check here is CDP (reconnect_profile), which an ext window never satisfies, so a
/// pup restart declared both ext sessions' "Chrome is gone" and relaunched them as NEW windows next to the
/// originals (AdomLapper, 2026-09-13: the 17-tab proof window orphaned, a duplicate opened beside it). The
/// session file carries windowId; the live `tabs` listing says whether that window still exists and which
/// tabs it holds, so the session is rebuilt from what Chrome actually has.
/// Runs the body on the lane of the file's OWN profile. 2.0.670: recovery threads sit on the adom-you lane, so a
/// svc-test window was looked for among adom-you's tabs, never found, and stayed forgotten (CRR, 2026-09-21).
struct ProfileScope;
impl ProfileScope { fn enter(profile: &str) -> ProfileScope { crate::extdrive::enter_profile(profile); ProfileScope } }
impl Drop for ProfileScope { fn drop(&mut self) { crate::extdrive::leave_profile(); } }
/// 2.0.717: profiles whose browser pup restarted on purpose in the last 2 min. Chrome can bring such windows back
/// itself (its own session restore); pup then RE-ADOPTS them instead of relaunching, and the relaunch path was the only
/// one that paused their autoplaying media. A plain pup restart (the browser kept running) is not in this set, so a
/// video someone is watching is never paused by it.
static RESTARTED: std::sync::Mutex<Vec<(String, u64)>> = std::sync::Mutex::new(Vec::new());
pub fn mark_browser_restarted(profile: &str) { let now = pup_core::now_ms(); let mut m = RESTARTED.lock().unwrap(); m.retain(|(p, t)| p != profile && now.saturating_sub(*t) < 120_000); m.push((profile.to_string(), now)); }
fn browser_restarted_recently(profile: &str) -> bool { let now = pup_core::now_ms(); RESTARTED.lock().unwrap().iter().any(|(p, t)| p == profile && now.saturating_sub(*t) < 120_000) }
/// Pause autoplaying media in a restored window's tabs, in the background (see quietMedia in worker.js).
fn quiet_restored(id: &str, profile: &str, wid: i64) {
let (id2, prof2) = (id.to_string(), profile.to_string());
std::thread::spawn(move || {
crate::extdrive::enter_profile(&prof2);
match crate::extdrive::run(json!({ "action": "quietMedia", "windowId": wid, "ms": 30000, "timeoutMs": 20000 }), Duration::from_secs(24)) {
Ok(q) => pup_core::log(format!("[recovery] \"{id2}\": restored tabs keep their media paused for 30 s ({} tab(s) guarded, {} paused at once)", q["guarded"], q["paused"])),
Err(e) => pup_core::log(format!("[recovery] \"{id2}\": could not quiet the restored tabs: {}", e["error"].as_str().unwrap_or("?"))),
}
});
}
fn readopt_ext(ctx: &Ctx, f: &Value) -> bool {
if f["transport"].as_str() != Some("ext") { return false; }
let _scope = ProfileScope::enter(f["profile"].as_str().unwrap_or("adom-you"));
let id = f["sessionId"].as_str().unwrap_or("").to_string();
let Some(wid) = f["windowId"].as_i64() else { return false };
if !crate::extdrive::alive(ctx) {
// 2.0.536: at boot restore_extdrive has already re-attached the lane's live pids, but the extension's
// first poll lands ~1-2 s later. Declaring the window gone in that gap KILLED the live browser and
// rebuilt the 20-tab window from its file, twice (AdomLapper 20:34, 2026-09-13). Wait for the heartbeat.
let t0 = std::time::Instant::now();
while !crate::extdrive::alive(ctx) && !crate::extdrive_pids().is_empty() && t0.elapsed() < Duration::from_secs(8) { std::thread::sleep(Duration::from_millis(250)); }
if !crate::extdrive::alive(ctx) { return false; }
}
let Ok(r) = crate::extdrive::run(json!({ "action": "tabs" }), Duration::from_secs(20)) else { return false };
let live: Vec<Value> = r["tabs"].as_array().cloned().unwrap_or_default().into_iter().filter(|t| t["windowId"].as_i64() == Some(wid)).collect();
if live.is_empty() { return false; }
let mut s = Session::new(&id, f["profile"].as_str().unwrap_or("adom-you"), f["owner"].as_str(), None, 0, None);
s.transport = "ext".into(); s.window_id = Some(wid);
// 2.0.694: keep the tab ids the file saved (matched by the browser's own tab id). Renumbering from tab-1 on
// every bridge restart sent an AI's eval to a different tab under the id it already held (2026-10-04).
let saved: std::collections::HashMap<String, String> = f["tabs"].as_array().cloned().unwrap_or_default().iter()
.filter_map(|t| Some((t["browserTabId"].as_str()?.to_string(), t["tabId"].as_str()?.to_string()))).collect();
let top = saved.values().filter_map(|v| v.strip_prefix("tab-")?.parse::<u32>().ok()).max().unwrap_or(0);
s.next_tab_seq = s.next_tab_seq.max(top + 1);
for t in &live {
let mut tab = crate::transport::ext_tab(&mut s, t["tabId"].as_i64().unwrap_or(0), t["url"].as_str().unwrap_or(""), t["title"].as_str().unwrap_or(""));
// 2.0.724: a tab still on the birth page keeps the address the file recorded, so the rescue can send it on
if let Some(saved_url) = f["tabs"].as_array().and_then(|a| a.iter().find(|x| x["browserTabId"].as_str() == Some(tab.target_id.as_str()))).and_then(|x| x["url"].as_str()) { let mut u = saved_url.to_string(); crate::extdrive::keep_url(&mut u, &tab.url); tab.url = u; }
if let Some(keep) = saved.get(&tab.target_id) { if !s.tabs.iter().any(|x| &x.tab_id == keep) { tab.tab_id = keep.clone(); } }
s.tabs.push(tab);
}
if let Some(i) = live.iter().position(|t| t["active"] == json!(true)) { s.active_tab = i; }
s.hwnd = f["_hwnd"].as_i64().filter(|&h| h > 0 && ctx.host.is_alive(h));
if s.hwnd.is_none() {
let taken: Vec<i64> = ctx.sessions.all().iter().filter_map(|x| x.hwnd).collect();
s.hwnd = ctx.host.windows_for_pids(&crate::extdrive_pids(), Some("Chrome_WidgetWin_1")).iter().find(|w| w.visible && w.rect.2 - w.rect.0 > 200 && !taken.contains(&w.hwnd)).map(|w| w.hwnd);
}
s.app_id = f["appId"].as_str().map(|x| x.to_string());
ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);
// 2.0.690: a window re-adopted across an upgrade keeps the tile of the build that drew it. When that tile comes
// from an older drawing generation, stamp the window once so it wears the current art (2.0.689 redrew tiles
// smoother and John's re-adopted windows kept the grainy ones).
if s.app_id.as_deref().map(|a| a.contains(".thread.") && !a.contains(&format!(".{}", pup_identity::TILE_GEN))).unwrap_or(false) {
if let Some(a) = ctx.arc() { crate::identity::schedule(a, s.id.clone(), crate::identity::internal_caller(), true); pup_core::log(format!("[recovery] \"{id}\": its tile is from an older drawing; re-stamping it")); }
}
pup_core::log(format!("[recovery] \"{id}\": re-adopted its extension-lane window (Chrome window {wid}, {} tab(s), hwnd {:?}); nothing relaunched", s.tabs.len(), s.hwnd));
if browser_restarted_recently(&s.profile) { quiet_restored(&id, &s.profile, wid); }
crate::extdrive::rebind_unbound(ctx); // stamp the re-adopted window NOW; the health tick is only a backstop
true
}
pub fn relaunch_from_file(ctx: &Ctx, f: &Value) {
let id = f["sessionId"].as_str().unwrap_or("").to_string();
forget_user_closed(ctx); // 2.0.682: a close reported during the previous relaunch cancels this one before it opens
{ let mut m = IN_FLIGHT.lock().unwrap(); if m.contains(&id) { pup_core::log(format!("[recovery] \"{id}\": a relaunch is already in flight; not starting a second")); return; } m.push(id.clone()); }
reserve(std::slice::from_ref(&id));
let _guard = RelaunchGuard(id.clone());
let url = f["url"].as_str().unwrap_or("about:blank").to_string();
let profile = f["profile"].as_str().unwrap_or("adom-you").to_string();
let owner = f["owner"].as_str().unwrap_or("").to_string();
// 2.0.669: a window still on glass is NOT a failed relaunch. The "alive, will re-adopt" path below used to
// run after this counter, so three quiet 15 s ticks read as "failed to come back twice" and the file of a
// LIVE window was deleted: after the 2.0.668 upgrade on AdomLapper (2026-09-20 23:57) three windows stayed on
// screen with no session, no badge and no owner, and the adom/insiders thread opened a duplicate the next
// morning. Re-adopt first, then decline while the window lives, and count only real relaunches.
if restore_cancelled(&id) { pup_core::log(format!("[recovery] \"{id}\": its restore was cancelled by a close; not relaunching")); ctx.sessions.delete_file(&id); return; }
if readopt_ext(ctx, f) { RELAUNCHES.lock().unwrap().retain(|(s, _)| *s != id); return; }
if let Some(h) = f["_hwnd"].as_i64().filter(|&h| h > 0) {
if ctx.host.is_alive(h) {
// 2.0.670: alive is not enough; it must be alive in ITS profile's browser. A window left inside another
// profile's browser (adopted as the wrong lane before 2.0.670) would otherwise be "kept" forever, never
// re-adopted, running in the wrong cookie jar. That one is closed and relaunched in its own profile.
let prof = f["profile"].as_str().unwrap_or("adom-you").to_string();
let by = crate::pup_browser_pids_by_profile(ctx);
let owner_prof = by.iter().find(|(_, pids)| !ctx.host.windows_for_pids(pids, None).iter().all(|w| w.hwnd != h)).map(|(p, _)| p.clone());
match owner_prof {
Some(op) if op != prof => {
pup_core::log(format!("[recovery] \"{id}\": its window {h} is alive but inside the {op} profile's browser, not {prof}; closing it there and relaunching it in {prof}"));
let _ = ctx.ab.call("desktop_close_window", json!({ "hwnd": h, "reason": format!("pup: the \"{id}\" window was running in the wrong profile ({op}); reopening it in {prof}") }), &crate::identity::internal_caller(), Duration::from_secs(10));
}
_ => { pup_core::log(format!("[recovery] \"{id}\": its window {h} is alive on glass; NOT relaunching (worker quiet, will re-adopt on the next tick; its file is kept)")); return; }
}
}
}
// 2.0.681 (John, 2026-09-23: "i fucking closed those windows on purpose cuz i don't fucking need them anymore").
// A window is only RESTORED when its browser is GONE (a crash, a sleep, an upgrade). If the lane browser is alive
// and simply does not have this window, the window was CLOSED — by the user, or by the page — and pup must forget
// it. Before, any missing window was treated as a crash to repair, so windows the user closed while pup was
// restarting (an install landed in that exact second on his laptop) came back at the next boot, twice.
if f["transport"].as_str() == Some("ext") && !is_lost(&id) {
let _scope = ProfileScope::enter(&profile);
if !crate::extdrive_pids().is_empty() {
pup_core::log(format!("[recovery] \"{id}\": its browser is running and does not have this window, so it was CLOSED, not lost; forgetting it (never restored)"));
ctx.sessions.delete_file(&id);
RELAUNCHES.lock().unwrap().retain(|(s, _)| *s != id);
return;
}
}
let attempts = { let mut m = RELAUNCHES.lock().unwrap(); match m.iter_mut().find(|(s, _)| *s == id) { Some(e) => { e.1 += 1; e.1 } None => { m.push((id.clone(), 1)); 1 } } };
if attempts > 2 { pup_core::log(format!("[recovery] \"{id}\" failed to come back twice — giving up on it (the page itself may be crashing Chrome); removing its file")); ctx.sessions.delete_file(&id); crate::push_cleanup_report(format!("pup could NOT auto-recover the \"{id}\" window: it crashed again on relaunch ({url}). Open it again yourself with pup_open_window if you still need it.")); return; }
// (2.0.588: a window still on glass is never relaunched; that check now runs before the attempt counter.)
pup_core::log(format!("[recovery] \"{id}\": its Chrome is gone (profile {profile}) — relaunching at {url} in the background (attempt {attempts})"));
let caller = pup_core::Caller { thread: owner.clone(), reason: "auto-recovery: the window's Chrome was gone".into(), ..Default::default() };
let open_args = json!({ "window": id, "url": url, "profile": profile, "takeover": true, "lane": f["transport"].as_str().map(|x| x.to_string()).unwrap_or_else(|| ctx.settings.get("defaultLane", "ext")), "owner": if owner.is_empty() { Value::Null } else { json!(owner) } });
let mut r = crate::open_window(ctx, &caller, &open_args);
// 2.0.580: a cold extension lane answers `starting:true` (no session yet) when its worker is not up within
// the open's budget. Treating that as done left an orphan window and "0 session(s) active" (2026-09-14
// 20:57). Wait for the worker, then open once more.
if r["starting"] == json!(true) {
let t0 = std::time::Instant::now();
while t0.elapsed() < Duration::from_secs(30) && !crate::extdrive::alive(ctx) { std::thread::sleep(Duration::from_millis(500)); }
pup_core::log(format!("[recovery] \"{id}\": the lane was still starting; opening again now that the worker {}", if crate::extdrive::alive(ctx) { "is up" } else { "is STILL silent (best effort)" }));
r = crate::open_window(ctx, &caller, &open_args);
}
// 2.0.476: a close that landed WHILE this relaunch was opening the window cancels it; undo the window we just
// brought back so it does not linger behind the close.
if r["ok"] == json!(true) && restore_cancelled(&id) {
pup_core::log(format!("[recovery] \"{id}\": a close cancelled its restore mid-flight; closing the window it had just reopened"));
let c = pup_core::Caller { thread: owner.clone(), reason: "undo a cancelled restore".into(), ..Default::default() };
let _ = crate::close_window(ctx, &c, &json!({ "window": id, "takeover": true }));
ctx.sessions.delete_file(&id);
return;
}
// 2.0.461: reopen the window's OTHER tabs too, in their saved order.
if r["ok"] == json!(true) {
// 2.0.478: dedup the reopened tabs by base URL (and drop the active url) so a file that accumulated a
// duplicate never reopens the same page twice.
// 2.0.675: exact URLs (only a trailing slash ignored). Stripping the query collapsed different pages on one
// path (two YouTube videos, two search results, example.org/?a2 and ?a3): a recovered window came back with
// 2 of its 4 tabs on CRR, 2026-09-22.
let base = |u: &str| u.trim_end_matches('/').to_string();
let mut seen: Vec<String> = vec![base(&url)];
let rest: Vec<String> = f["tabs"].as_array().map(|a| a.iter().filter_map(|t| t["url"].as_str()).map(|u| u.to_string()).filter(|u| {
let b = base(u);
if u.is_empty() || u == "about:blank" || seen.contains(&b) { false } else { seen.push(b); true }
}).collect()).unwrap_or_default();
if !rest.is_empty() {
// 2.0.537: reopen ONLY into the window this relaunch itself just made. If a caller opened this id
// meanwhile (its session is newer than the relaunch's own window), the saved list would land on top
// of the caller's tabs: that produced ti.com twice and 33 tabs for 20 sites (John, 2026-09-13).
let mine = r["hwnd"].as_i64().or_else(|| ctx.sessions.get(&id).and_then(|x| x.hwnd));
let cur = ctx.sessions.get(&id);
let Some(cur) = cur else {
// 2.0.580: no session at all is a FAILED relaunch, not another caller's window. Keep the file so the
// next tick tries again instead of dropping the user's tabs on the floor.
pup_core::log(format!("[recovery] \"{id}\": the relaunch registered no session (open answered ok={} error={:?}); keeping its file for the next attempt", r["ok"], r["error"].as_str()));
return;
};
// Chrome's own windowId is exact; the hwnd can legitimately change between the open and this check
// (a late rebind), which is what mis-fired this guard on the extension lane.
let still_mine = match (cur.window_id, r["windowId"].as_i64()) { (Some(a), Some(b)) => a == b, _ => cur.hwnd == mine || mine.is_none() };
if !still_mine {
pup_core::log(format!("[recovery] \"{id}\": another caller opened this window while the relaunch ran; not reopening the saved {} tab(s) on top of theirs", rest.len()));
} else {
pup_core::log(format!("[recovery] \"{id}\": reopening its other {} tab(s)", rest.len()));
for u in &rest { let _ = crate::tabs::open_tab(ctx, &caller, &json!({ "window": id, "url": u, "takeover": true })); }
}
}
}
// 2.0.698: a restored window reloads every page at once, and every video in them autoplayed together ("i get like
// 10 videos playing all at the same time", John 2026-10-04). Pause media that starts on its own in the restored
// tabs for 30 s; anything the user starts by clicking plays normally.
if r["ok"] == json!(true) {
if let Some(wid) = ctx.sessions.get(&id).filter(|s| crate::transport::is_ext(s)).and_then(|s| s.window_id) {
quiet_restored(&id, &profile, wid); // 2.0.715: in the background; the next window's restore never waits on it
}
}
// 2.0.471: a relaunch that WORKED resets the counter. It counted every relaunch in the bridge's life, so a window
// brought back twice (a wake, a crash) was refused the third time as "failed to come back twice" (winvm drill,
// 2026-09-11: "drill-b" back with 0 of 3 after pup_restart_browser). Only consecutive failures count now.
if r["ok"] == json!(true) { RELAUNCHES.lock().unwrap().retain(|(s, _)| *s != id); }
if r["ok"] == json!(true) { crate::push_cleanup_report(format!("pup auto-recovered your \"{id}\" window after its Chrome was gone (a bridge update, a Chrome crash, or a sleep): it is back at {url}, in the background, on the same profile. Verify with pup_screenshot.")); }
if r["ok"] == json!(true) { ctx.sessions.with_mut(&id, |x| x.disruption_notice = Some(format!("This window was auto-recovered after its Chrome was gone; it is back at {url} in the background. If something you set on the old page (an emulated viewport, an in-page draft) is missing, that is why."))); }
else { pup_core::log(format!("[recovery] \"{id}\" relaunch failed: {}", r["error"].as_str().unwrap_or("?"))); }
}
fn page_targets(cdp: &Cdp) -> Vec<Value> { cdp.targets().unwrap_or_default().into_iter().filter(|t| t["type"] == json!("page")).collect() }
/// Rebuild one session from its file against a connected browser. Match by the recorded
/// targetId first (exact), then by URL prefix (the Node rule), never adopting about:blank.
fn recover_one(ctx: &Ctx, file: &Value, h: &BrowserHandle, taken: &mut Vec<String>) -> Option<Session> {
let id = file["sessionId"].as_str()?.to_string();
let profile = file["profile"].as_str().unwrap_or("adom-you").to_string();
let targets = page_targets(&h.cdp);
let want_tid = file["targetId"].as_str().map(|s| s.to_string());
let want_url = file["url"].as_str().unwrap_or("").split('?').next().unwrap_or("").to_string();
let pick = targets.iter().find(|t| want_tid.as_deref().is_some_and(|w| t["targetId"] == json!(w)))
.or_else(|| targets.iter().find(|t| { let u = t["url"].as_str().unwrap_or(""); !want_url.is_empty() && u != "about:blank" && u.starts_with(&want_url) && !taken.contains(&t["targetId"].as_str().unwrap_or("").to_string()) }))?;
// 2.0.454: a target another session already re-attached is not picked twice; an "adopted-*" leftover whose
// page now belongs to its window's session is finished with, and its file goes.
if let Some(t) = pick["targetId"].as_str() { if taken.contains(&t.to_string()) {
if id.starts_with("adopted-") { pup_core::log(format!("Session \"{id}\": its page already belongs to a window session; dropping the leftover file")); ctx.sessions.delete_file(&id); }
else { pup_core::log(format!("Session \"{id}\": its target {t} was already re-attached by another session; leaving the file")); }
return None;
} }
let Some(tid) = pick["targetId"].as_str().map(|t| t.to_string()) else { pup_core::log(format!("Session \"{id}\": no live page target matches its file (wanted {:?} or url {:?}); leaving the file", want_tid, want_url)); return None; };
taken.push(tid.clone());
let sid = match h.cdp.attach(&tid) { Ok(x) => x, Err(e) => { pup_core::log(format!("Session \"{id}\": could not attach its target {tid}: {e}")); return None; } };
// 2.0.456: Page.enable on a helper thread (a discarded tab answers only when its renderer comes back;
// inline it cost the first recovery 40 s on AdomLapper).
{ let (c2, s2) = (h.cdp.clone(), sid.clone()); std::thread::Builder::new().name("recover-setup".into()).spawn(move || { let _ = c2.page_enable(&s2); }).ok(); }
let mut s = Session::new(&id, &profile, file["owner"].as_str(), None, h.launched.cdp_port, file["pid"].as_u64().map(|p| p as u32));
s.created_iso = file["createdAt"].as_str().unwrap_or(&s.created_iso).to_string();
s.foreground = file["foreground"].as_bool().unwrap_or(false);
s.app_id = file["appId"].as_str().map(|a| a.to_string());
s.window_id = h.cdp.window_for_target(&tid).ok().map(|(w, _)| w).filter(|w| *w > 0).or_else(|| file["windowId"].as_i64());
let tab_id = s.next_tab_id();
s.tabs.push(Tab { tab_id, target_id: tid, cdp_session: sid, url: pick["url"].as_str().unwrap_or("").into(), title: pick["title"].as_str().unwrap_or("").into(), error_count: 0, fav: None });
// the OS window: the persisted handle if it is still alive, else the one carrying our tag
let tag = s.tag_string();
let hwnd = file["_hwnd"].as_i64().filter(|&h| h > 0 && ctx.host.is_alive(h))
.or_else(|| ctx.host.windows_for_pids(&[], Some("Chrome_WidgetWin_1")).into_iter().find(|w| ctx.host.has_tag(w.hwnd, &tag)).map(|w| w.hwnd));
if let Some(hh) = hwnd { s.hwnd = Some(hh); ctx.host.tag(hh, &tag); if !s.foreground { ctx.host.send_to_bottom_no_activate(hh); } }
// A recovered tab is a fresh CDP session: give it what attach_tab gives a new one (the login
// capture + floaty bindings, the owner chip, the floaty) or the window silently loses them
// (measured 2026-09-05: no owner chip on a window recovered across the 2.0.407 update).
if let Some(t) = s.tabs.last() {
for name in ["__pupCredCapture", "__pupFloatyPush"] { let _ = h.cdp.send(Some(&t.cdp_session), "Runtime.addBinding", json!({ "name": name }), Duration::from_secs(5)); }
let capture = crate::vault::capture_login_script();
let _ = h.cdp.send(Some(&t.cdp_session), "Page.addScriptToEvaluateOnNewDocument", json!({ "source": capture }), Duration::from_secs(5));
let _ = h.cdp.evaluate(&t.cdp_session, &capture, Duration::from_secs(5));
crate::inject_page_scripts(ctx, &s, &h.cdp, &t.cdp_session);
}
Some(s)
}
/// Boot: every session file names a browser we may still own.
/// 2.0.456: after sessions re-attach (boot, reconnect), every page target in a recovered window that no
/// session tracks joins that window's session, keyed by the CDP Browser window id. Event-driven (runs at
/// the recovery moment, never on a timer). Skips about:blank and browser-internal pages.
pub fn adopt_window_tabs(ctx: &Ctx, h: &Arc<BrowserHandle>) -> usize {
let mut adopted = 0;
let tracked: Vec<String> = ctx.sessions.all().iter().flat_map(|s| s.tabs.iter().map(|t| t.target_id.clone())).collect();
for t in page_targets(&h.cdp) {
let tid = t["targetId"].as_str().unwrap_or("").to_string(); let url = t["url"].as_str().unwrap_or("").to_string();
if tid.is_empty() || tracked.contains(&tid) || url == "about:blank" || url.starts_with("chrome://") || url.starts_with("devtools://") || url.starts_with("chrome-extension://") { continue; }
let Ok((wid, _)) = h.cdp.window_for_target(&tid) else { continue };
let Some(owner) = ctx.sessions.all().into_iter().find(|s| s.window_id == Some(wid) && s.hwnd.is_some()) else { continue };
let mut s = owner.clone();
if crate::attach_tab(&mut s, &h.cdp, &tid).is_ok() { adopted += 1; pup_core::log(format!("[recover] \"{}\": adopted the window's untracked tab {}", s.id, &url[..url.len().min(80)])); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); }
}
adopted
}
pub fn recover_at_boot(ctx: &Ctx) {
// 2.0.718: the owner chip went default-OFF in 2.0.713 (John: it covers the page's own controls), but a desktop where
// the Settings dialog had ever saved its values kept the old default stored as "on" (ConfRoomROG, measured). Before
// 2.0.713 "on" was the default, so a stored "on" is almost never a choice: turn it off once. Anyone who turns the
// chip on after this keeps it (the marker means the migration never runs again).
if ctx.settings.get("ownerChipMigrated", "") != "713" {
if ctx.settings.get("ownerChip", "off") == "on" { let _ = ctx.settings.set("ownerChip", json!("off")); pup_core::log("[settings] ownerChip was stored as the old default \"on\"; turned off once (2.0.713 made it default-off)"); }
let _ = ctx.settings.set("ownerChipMigrated", json!("713"));
}
// 2.0.659 (pup-bridge#113): throwaway lanes do not survive a pup restart. End any browser still on an
// iso-* profile and remove the profile; their windows were fleeting by definition.
if let Ok(rd) = std::fs::read_dir(pup_browser::profiles_dir(&*ctx.host)) {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if !name.starts_with("iso-") { continue; }
let pids = crate::orphan_browser_pids(ctx, &e.path());
for p in &pids { ctx.host.kill(*p); }
std::thread::sleep(Duration::from_millis(if pids.is_empty() { 0 } else { 800 }));
let removed = std::fs::remove_dir_all(e.path()).is_ok();
pup_core::log(format!("[profile] boot: throwaway profile {name}: ended {} browser process(es), removed={removed}", pids.len()));
for id in ctx.sessions.for_profile(&name).iter().map(|s| s.id.clone()).collect::<Vec<_>>() { ctx.sessions.remove(&id); ctx.sessions.delete_file(&id); }
}
}
let files = ctx.sessions.read_files();
if files.is_empty() { pup_core::log("Recovering 0 session(s) from disk..."); return; }
pup_core::log(format!("Recovering {} session(s) from disk...", files.len()));
let mut taken: Vec<String> = Vec::new();
// 2.0.454: named window sessions first; hwnd-less "adopted-*" leftovers last, so they can never claim a
// window's targets ahead of the session that owns the window.
let mut files = files; files.sort_by_key(|f| f["sessionId"].as_str().unwrap_or("").starts_with("adopted-"));
// 2.0.682: decide ONCE, before anything relaunches, which profiles' browsers were already gone at boot. Their
// windows are lost (restore them); a missing window whose browser is still running was closed while pup was down.
{
let mut dead_profiles: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
let mut lost = Vec::new();
for f in files.iter().filter(|f| f["transport"].as_str() == Some("ext")) {
let prof = f["profile"].as_str().unwrap_or("adom-you").to_string();
let dead = *dead_profiles.entry(prof.clone()).or_insert_with(|| { let _s = ProfileScope::enter(&prof); crate::extdrive_pids().is_empty() });
if dead { if let Some(i) = f["sessionId"].as_str() { lost.push(i.to_string()); } }
}
mark_lost(&lost);
}
for f in &files {
let id = f["sessionId"].as_str().unwrap_or("").to_string();
if id.is_empty() { continue; }
let pid = f["pid"].as_u64().unwrap_or(0) as u32;
let profile = f["profile"].as_str().unwrap_or("adom-you").to_string();
if readopt_ext(ctx, f) { continue; }
if f["transport"].as_str() == Some("ext") { relaunch_from_file(ctx, f); continue; }
let hint = f["cdpPort"].as_u64().map(|p| p as u16);
let Some(h) = reconnect_profile_hint(ctx, &profile, hint) else {
if pid == 0 || !ctx.host.alive(pid) { relaunch_from_file(ctx, f); } else { pup_core::log(format!("Session \"{id}\": profile \"{profile}\" has no live DevTools port but PID {pid} is alive (Chrome asleep or starting) — leaving the file for the health loop")); }
continue
};
match recover_one(ctx, f, &h, &mut taken) {
Some(s) => { pup_core::log(format!("Recovered session \"{id}\" (profile: {profile}, URL: {}, hwnd: {:?})", s.active().map(|t| t.url.clone()).unwrap_or_default(), s.hwnd)); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); }
None => { pup_core::log(format!("Session \"{id}\" — no matching page in Chrome, removing")); ctx.sessions.delete_file(&id); }
}
}
let handles: Vec<Arc<BrowserHandle>> = ctx.browsers.lock().unwrap().values().cloned().collect();
let adopted: usize = handles.iter().map(|h| adopt_window_tabs(ctx, h)).sum();
crate::focus_emulate_all(ctx); // 2.0.458: re-apply the focus-emulation policy to the recovered sessions
pup_core::log(format!("Recovery complete. {} session(s) active{}.", ctx.sessions.count(), if adopted > 0 { format!(", {adopted} untracked tab(s) adopted into their windows") } else { String::new() }));
}
/// One health tick: dead browser connections are reconnected (never killed), sessions whose
/// target vanished are dropped, stale files are swept.
/// 2.0.453. (a) A session whose last tab target was destroyed keeps its OS window for 15 s: if Chrome
/// recreated a page in that Browser window (sleep, Memory Saver, renderer restart) it is re-attached;
/// once the OS window is gone, or nothing replaced the tab, the session is removed. (b) A hwnd-less
/// session whose tab lives in another session's window (an "adopted-*" leftover) merges into it.
fn tabless_and_orphan_pass(ctx: &Ctx) {
let now = pup_core::now_ms();
for s in ctx.sessions.all() {
// 2.0.536: both halves of this pass reason in CDP terms (window_for_target, browser_for). An ext session's
// liveness is the lane and its Chrome window id, judged by readopt_ext; this pass removed a live ext
// session 17 s after recovery rebuilt it, which the file pass then relaunched again.
if crate::transport::is_ext(&s) { continue; }
if s.tabs.is_empty() && s.tabless_since_ms > 0 {
let Some(h) = crate::browser_for(ctx, &s.profile) else { continue };
let taken: Vec<String> = ctx.sessions.all().iter().flat_map(|x| x.tabs.iter().map(|t| t.target_id.clone())).collect();
let replacement = page_targets(&h.cdp).into_iter().map(|t| t["targetId"].as_str().unwrap_or("").to_string())
.filter(|tid| !tid.is_empty() && !taken.contains(tid))
.find(|tid| h.cdp.window_for_target(tid).ok().map(|(w, _)| Some(w) == s.window_id).unwrap_or(false));
if let Some(tid) = replacement {
let mut ns = s.clone();
if crate::attach_tab(&mut ns, &h.cdp, &tid).is_ok() { ns.tabless_since_ms = 0; pup_core::log(format!("[health] \"{}\": Chrome replaced its tab; re-attached the new page target", ns.id)); ctx.sessions.insert(ns.clone()); ctx.sessions.save_file(&ns); if let Some(a) = ctx.arc() { crate::identity::schedule(a, ns.id.clone(), crate::identity::internal_caller(), false); } continue; }
}
let window_alive = s.hwnd.map(|h| ctx.host.is_alive(h)).unwrap_or(false);
if !window_alive || now.saturating_sub(s.tabless_since_ms) > 15_000 {
pup_core::log(format!("[health] session \"{}\" dead (its last tab was closed outside pup and {}) — removing", s.id, if window_alive { "nothing replaced it in 15 s" } else { "its window is gone" }));
ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id);
}
continue;
}
if s.hwnd.is_none() && !s.tabs.is_empty() && s.id.starts_with("adopted-") {
let Some(h) = crate::browser_for(ctx, &s.profile) else { continue };
let Ok((wid, _)) = h.cdp.window_for_target(&s.tabs[0].target_id) else { continue };
if let Some(owner) = ctx.sessions.all().into_iter().find(|x| x.id != s.id && x.window_id == Some(wid) && x.hwnd.is_some()) {
let mut o = owner.clone();
for t in &s.tabs { let mut t2 = t.clone(); t2.tab_id = o.next_tab_id(); o.tabs.push(t2); }
pup_core::log(format!("[health] merged the hwnd-less session \"{}\" into \"{}\" (same Browser window)", s.id, o.id));
ctx.sessions.insert(o.clone()); ctx.sessions.save_file(&o); ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id);
}
}
}
}
/// A Chrome whose BROWSER process stops answering is invisible to a bridge that only ever times out: on
/// 2026-09-10 John's Chrome browser main thread spun at 98 % of a core for hours, so its taskbar tab strip and
/// his touchpad wheel stopped working, pup's calls timed out one by one, and pup said nothing. Every health
/// tick now pings the browser endpoint itself (Browser.getVersion, no page involved). Three misses in a row
/// (about 45 s) is not a slow page, it is a browser that is not coming back: pup says so in the log, toasts the
/// user with what to do, and carries it in pup_status / pup_readiness until it answers again.
fn browser_liveness(ctx: &Ctx) {
let handles: Vec<(String, Arc<BrowserHandle>)> = ctx.browsers.lock().unwrap().iter().map(|(p, h)| (p.clone(), h.clone())).collect();
for (profile, h) in handles {
let alive = h.cdp.send(None, "Browser.getVersion", json!({}), Duration::from_secs(5)).is_ok();
let mut m = ctx.browser_stall.lock().unwrap();
let misses = m.entry(profile.clone()).or_insert(0u32);
if alive { if *misses >= 3 { pup_core::log(format!("[health] the browser for profile \"{profile}\" is answering again")); } *misses = 0; continue; }
*misses += 1;
let n = *misses;
drop(m);
if n == 3 {
let tabs: usize = ctx.sessions.for_profile(&profile).iter().map(|s| s.tabs.len()).sum();
pup_core::log(format!("[health] the browser for profile \"{profile}\" has not answered 3 checks in a row (~45 s). Its browser process is wedged, not busy: your windows will not scroll with the wheel and their tab strips will not respond, though dragging a scrollbar still works. pup cannot fix this from inside; that Chrome has to be restarted. {tabs} tab(s) are saved in the session files and come back with it."));
let _ = ctx.ab.call("notify_user", json!({
"title": "pup: this browser has stopped responding",
"body": format!("The Chrome behind your pup windows (profile {profile}) is wedged: the wheel and the tab strip will not respond, though the scrollbar still drags. Restarting it fixes it, and your tabs are saved. Ask your AI thread to run pup_restart_browser."),
"level": "warning", "focus": false }), &crate::identity::internal_caller(), Duration::from_secs(8));
}
}
}
/// A browser with no pup windows is holding the user's RAM for nothing. Measured on John's laptop,
/// 2026-09-11, with zero tabs open: 657 MB to 1.2 GB across 8 processes, most of it a GPU process still
/// sitting on allocations from 3D pages that had been closed. pup has always had pup_quit_idle_browsers and
/// never called it, so an empty Chrome lived until the machine rebooted. Now: a browser that has had no
/// windows for `idleBrowserQuitMin` minutes (default 10, 0 disables) is closed. The grace means a thread that
/// closes one window and opens another never pays a cold start, and nothing on disk is lost: the profile,
/// its cookies and its logins are files, so the next open comes back signed in.
fn idle_browser_reap(ctx: &Ctx) {
let mins: u64 = ctx.settings.get("idleBrowserQuitMin", "10").parse().unwrap_or(10);
if mins == 0 { return; }
let now = pup_core::now_ms();
let profiles: Vec<String> = ctx.browsers.lock().unwrap().keys().cloned().collect();
for p in profiles {
if !ctx.sessions.for_profile(&p).is_empty() { ctx.browser_idle_since.lock().unwrap().remove(&p); continue; }
let since = *ctx.browser_idle_since.lock().unwrap().entry(p.clone()).or_insert(now);
if now.saturating_sub(since) < mins * 60_000 { continue; }
let Some(b) = ctx.browsers.lock().unwrap().remove(&p) else { continue };
let mb = ctx.host.process_tree_rss_mb(b.launched.pid).unwrap_or(0);
let _ = b.cdp.send(None, "Browser.close", json!({}), Duration::from_secs(5));
b.cdp.close();
if b.launched.pid > 0 { std::thread::sleep(Duration::from_millis(800)); if ctx.host.alive(b.launched.pid) { ctx.host.kill(b.launched.pid); } }
ctx.browser_idle_since.lock().unwrap().remove(&p);
pup_core::log(format!("[idle] closed the browser for profile \"{p}\": no pup windows for {mins} min, and it was holding {mb} MB. The next open launches a fresh one, still signed in."));
}
}
/// 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);
// 2.0.682 (John, 2026-09-23): closing a thread's taskbar icon while an upgrade was relaunching its windows one by
// one brought the NEXT window of that thread back 13-28 s later, twice. A user close now cancels every queued
// restore of the same thread, so the icon they closed stays closed.
if let Some(owner) = s.owner.as_deref() {
for f in ctx.sessions.read_files() {
let Some(sid) = f["sessionId"].as_str() else { continue };
if sid == s.id || f["owner"].as_str() != Some(owner) || ctx.sessions.exists(sid) || !restoring(sid) { continue; }
cancel_restore(sid); ctx.sessions.delete_file(sid);
pup_core::log(format!("[lane] \"{sid}\": the user closed \"{}\" of the same thread ({owner}) during a restore; its queued restore is cancelled", 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) {
// 2.0.707: once per browser. Each profile's browser reports its own closes on its own lane, and this only ever read
// the default lane, so a closed ISOLATED window was never forgotten: its session stayed, sweep_idle_isolated (rightly)
// kept its browser, and the windowless browser sat in the system tray (John, 2026-10-11).
let mut profiles = vec!["adom-you".to_string()];
for l in crate::extdrive::secondary_lanes() { if !profiles.contains(&l.profile) { profiles.push(l.profile.clone()); } }
for p in profiles { let _scope = ProfileScope::enter(&p); forget_user_closed_on(ctx, &p); }
}
fn forget_user_closed_on(ctx: &Ctx, profile: &str) {
let removed = crate::extdrive::take_removed();
let ext: Vec<Session> = ctx.sessions.all().into_iter().filter(|s| crate::transport::is_ext(s) && s.profile == profile).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.profile == profile && 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> {
// pup's own kill counts as "not the user": restore, do not forget (2.0.600).
if crate::extdrive_pids().iter().any(|p| ctx.host.ended_by_pup(*p)) { return Some(1); }
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() { 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();
// 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);
mark_lost(&ids);
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;
}
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);
}
/// 2.0.706: end isolated (iso-*) browsers whose windows are gone, and delete their throwaway profiles. This only
/// happened at boot, so every isolated window a thread opened and closed left its browser running for the rest of
/// the session, each one a Chrome for Testing icon in the system tray (John's laptop, 2026-10-07: three icons,
/// two of them windowless isolated browsers from 15:13 and 17:52). A profile no session uses on two health ticks in
/// a row goes; never while a window is being born.
pub fn sweep_idle_isolated(ctx: &Ctx) -> usize {
static IDLE: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
let Ok(_birth) = crate::BIRTH_LOCK.try_lock() else { return 0 };
let Ok(rd) = std::fs::read_dir(pup_browser::profiles_dir(&*ctx.host)) else { return 0 };
let mut idle = IDLE.lock().unwrap();
let prev = idle.take().unwrap_or_default();
let mut now_idle = std::collections::HashSet::new();
let mut n = 0;
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if !name.starts_with("iso-") || !ctx.sessions.for_profile(&name).is_empty() { continue; }
if !prev.contains(&name) { now_idle.insert(name); continue; } // first sighting: wait one more tick
let pids = crate::orphan_browser_pids(ctx, &e.path());
for p in &pids { ctx.host.kill(*p); }
std::thread::sleep(Duration::from_millis(if pids.is_empty() { 0 } else { 800 }));
let removed = std::fs::remove_dir_all(e.path()).is_ok();
crate::extdrive::drop_lane_for_profile(&name);
pup_core::log(format!("[profile] isolated profile {name} has no window: ended {} browser process(es), removed={removed}", pids.len()));
n += 1;
}
*idle = Some(now_idle);
n
}
/// 2.0.728 (John, 2026-10-20: a wiki 3D viewer failed in pup with "Cannot read properties of null (reading 'alpha')"
/// while it worked in his own Chrome). After its GPU process crashes a few times, Chrome stops using the GPU for the
/// rest of that browser session, and current Chrome has no software WebGL fallback, so every 3D page breaks silently
/// until the browser restarts. Probe WebGL in a pup window every 5 min (every minute once a probe fails); after two
/// failures in a row, restart pup's browser when no AI has used a pup window for a minute (at most every 30 min), so
/// every window comes back with 3D working, and tell the user.
/// The Chrome for Testing build the adom-you lane browser was launched from (set in launch_extdrive).
pub static LANE_BUILD: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// 2.0.734 (John, 2026-10-25: "it sure seems like u should just be upgrading silently and always"). pup pinned one
/// Chrome for Testing build, which has no updater of its own, so once it aged Chrome showed "New Chrome available"
/// in pup windows. Now: a few minutes after start and every 6 h, check Google's Stable channel; download and unpack
/// a newer build in the background (detect_cft then prefers it). When the main lane still runs an older build, wait
/// until no AI has used pup for 10 minutes and no pup window is the one the user is looking at, then restart pup's
/// browser once; every window comes back at its page on the new build.
pub fn cft_auto_update(ctx: &Ctx) {
static STATE: std::sync::Mutex<(u64, bool, Option<String>)> = std::sync::Mutex::new((0, false, None)); // last check, check running, restarted for build
static BOOT: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
let now = pup_core::now_ms();
let boot = *BOOT.get_or_init(|| now);
if ctx.settings.get("browserAutoUpdate", "on") == "off" { return; }
let (last, running, restarted_for) = STATE.lock().unwrap().clone();
// 1. check + stage
if !running && now.saturating_sub(boot) > 3 * 60_000 && (last == 0 || now.saturating_sub(last) > 6 * 3600_000) {
STATE.lock().unwrap().0 = now; STATE.lock().unwrap().1 = true;
let host = ctx.host.clone();
std::thread::Builder::new().name("cft-update".into()).spawn(move || {
let current = pup_browser::detect_cft(&*host).and_then(|c| c.build_id).unwrap_or_else(|| pup_browser::CFT_BUILD_ID.to_string());
match pup_browser::latest_stable_build() {
Some(latest) if pup_browser::build_newer(&latest, ¤t) => {
pup_core::log(format!("[chrome-update] Stable is {latest}, pup has {current}: downloading it in the background"));
let st = std::sync::Arc::new(std::sync::Mutex::new(pup_browser::InstallState::default()));
match pup_browser::install_cft_build(&*host, &st, &latest) {
Ok(_) => pup_core::log(format!("[chrome-update] {latest} is ready; pup's browser moves to it at its next start")),
Err((code, e)) => pup_core::log(format!("[chrome-update] could not install {latest} ({code}): {e}; pup keeps {current}")),
}
}
Some(latest) => pup_core::log(format!("[chrome-update] up to date ({current}; Stable is {latest})")),
None => pup_core::log("[chrome-update] could not read the Chrome for Testing Stable version; will try again later"),
}
STATE.lock().unwrap().1 = false;
}).ok();
return;
}
// 2. switch the running lane to a newer staged build at a quiet moment
let Some(newest) = pup_browser::detect_cft(&*ctx.host).and_then(|c| c.build_id) else { return };
let Some(lane) = LANE_BUILD.lock().unwrap().clone() else { return };
if !pup_browser::build_newer(&newest, &lane) || restarted_for.as_deref() == Some(newest.as_str()) { return; }
let sessions = ctx.sessions.for_profile("adom-you");
if sessions.is_empty() { return; } // nothing open: the next open launches the new build anyway
let busy = sessions.iter().map(|s| s.last_agent_update_ms.max(s.user_active_at_ms)).max().unwrap_or(0);
if now.saturating_sub(busy) < 10 * 60_000 { return; }
let fg = ctx.host.foreground();
if fg != 0 && sessions.iter().any(|s| s.hwnd == Some(fg)) { return; } // the user is looking at a pup window
STATE.lock().unwrap().2 = Some(newest.clone());
let Some(a) = ctx.arc() else { return };
pup_core::log(format!("[chrome-update] moving pup's browser from {lane} to {newest} while it is quiet; every window comes back at its page"));
std::thread::Builder::new().name("cft-switch".into()).spawn(move || {
let ctx2: &Ctx = &a;
let _ = crate::extras::restart_browser(ctx2, &crate::identity::internal_caller(), &json!({ "profile": "adom-you", "reason": format!("move pup's browser to Chrome {newest}") }));
}).ok();
}
pub fn webgl_watch(ctx: &Ctx) {
static STATE: std::sync::Mutex<(u64, u32, u64, u64)> = std::sync::Mutex::new((0, 0, 0, 0)); // last probe, failures, last restart, last wait log
let now = pup_core::now_ms();
let (last, fails, last_restart, _) = *STATE.lock().unwrap();
let every = if fails > 0 { 60_000 } else { 300_000 };
if now.saturating_sub(last) < every { return; }
STATE.lock().unwrap().0 = now;
let sessions: Vec<Session> = ctx.sessions.for_profile("adom-you").into_iter().filter(|s| crate::transport::is_ext(s)).collect();
let probe = sessions.iter().find_map(|s| {
let t = s.active().filter(|t| t.url.starts_with("http") || t.url.starts_with("file:"))?.clone();
let lane = crate::transport::lane_for(ctx, s).ok()?;
let expr = "(()=>{try{const c=document.createElement('canvas');const g=c.getContext('webgl2')||c.getContext('webgl');if(!g)return false;const l=g.getExtension('WEBGL_lose_context');if(l)l.loseContext();return true}catch(e){return null}})()";
let r = crate::transport::cdp_raw(&lane, &t, "Runtime.evaluate", json!({ "expression": expr, "returnByValue": true }), 10).ok()?;
r["result"]["value"].as_bool().map(|v| (v, s.id.clone()))
});
let Some((has_webgl, sid)) = probe else { return };
if has_webgl { if fails > 0 { pup_core::log(format!("[webgl] back in \"{sid}\"")); } STATE.lock().unwrap().1 = 0; return; }
let fails = { let mut g = STATE.lock().unwrap(); g.1 += 1; g.1 };
pup_core::log(format!("[webgl] OFF in \"{sid}\" (probe {fails}): the browser has stopped giving pages 3D graphics, most likely after GPU-process crashes"));
if fails < 2 { return; }
if now.saturating_sub(last_restart) < 30 * 60_000 { return; }
let busy_ms = sessions.iter().map(|s| s.last_agent_update_ms).max().unwrap_or(0);
if now.saturating_sub(busy_ms) < 60_000 {
let mut g = STATE.lock().unwrap();
if now.saturating_sub(g.3) > 300_000 { g.3 = now; pup_core::log("[webgl] waiting for a quiet minute (an AI used a pup window in the last 60 s) before restarting the browser"); }
return;
}
{ let mut g = STATE.lock().unwrap(); g.1 = 0; g.2 = now; }
let Some(a) = ctx.arc() else { return };
std::thread::Builder::new().name("webgl-restart".into()).spawn(move || {
let ctx2: &Ctx = &a;
let caller = crate::identity::internal_caller();
pup_core::log("[webgl] restarting pup's browser so 3D pages work again; every window comes back at its page");
let r = crate::extras::restart_browser(ctx2, &caller, &json!({ "profile": "adom-you", "reason": "WebGL was off after GPU crashes; a fresh browser restores 3D" }));
let mut ok = r["ok"] != json!(false) && r["success"] != json!(false);
// The restore after a restart can stall on a busy box (ConfRoomROG, 2026-10-20: 3 of 8 windows back, the rest
// left on disk while the extension went quiet; a second restart brought all of them). Check after 3 minutes and
// restart once more if any saved window is still waiting.
std::thread::sleep(Duration::from_secs(180));
let live: std::collections::HashSet<String> = ctx2.sessions.all().iter().map(|s| s.id.clone()).collect();
let waiting: Vec<String> = ctx2.sessions.read_files().iter().filter(|f| f["profile"].as_str().unwrap_or("adom-you") == "adom-you").filter_map(|f| f["sessionId"].as_str().map(|s| s.to_string())).filter(|id| !live.contains(id)).collect();
if !waiting.is_empty() {
pup_core::log(format!("[webgl] {} window(s) still waiting after the restart ({}); restarting once more to bring them back", waiting.len(), waiting.join(", ")));
let r2 = crate::extras::restart_browser(ctx2, &caller, &json!({ "profile": "adom-you", "reason": "finish restoring windows after the WebGL restart" }));
ok = r2["ok"] != json!(false) && r2["success"] != json!(false);
}
let _ = ctx2.ab.call("notify_user", json!({ "title": "pup restarted its browser", "body": if ok { "3D graphics (WebGL) had stopped working in pup's browser, so pages like the wiki's 3D viewer were failing. pup restarted it; your pup windows are back at the same pages." } else { "3D graphics (WebGL) stopped working in pup's browser and the automatic restart failed. Ask your AI to run pup_restart_browser." }, "focus": false }), &caller, Duration::from_secs(8));
}).ok();
}
pub fn health_tick(ctx: &Ctx) {
sweep_idle_isolated(ctx); // 2.0.706: an isolated window's browser ends with its window
crate::extdrive::adopt_tokenless(ctx); // 2.0.580: a token-less extension browser after a restart
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
cft_auto_update(ctx); // 2.0.734: keep pup's browser on the current Chrome Stable, silently
webgl_watch(ctx); // 2.0.728: Chrome turned WebGL off after GPU crashes; restart when quiet
crate::extdrive::rescue_birth_tabs(ctx); // 2.0.724: a window stuck on its birth page (PUPBIRTH-... on the taskbar)
crate::extdrive::adopt_stray_windows(ctx); // 2.0.694: windows Chrome made outside pup (a dragged-out tab, a page's popup)
crate::extdrive::rebind_unbound(ctx); // ext sessions whose birth outran the hwnd wait: bind + stamp now
crate::identity::stamp_stray_cft_windows(ctx); // 2.0.739 (#143): no pup window wears a bare Chrome button while recovery catches up
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
// 2.0.542: a lane browser that outlived a pup update keeps running the OLD extension until it cycles
// (measured: worker.2-0-539 still serving under pup 2.0.541, so a brand rewrite shipped in .540 never
// ran). When no extension-lane window is open, end it; the next open stages the current extension.
if let Some((w, want)) = crate::extdrive::lane_worker_stale() {
// 2.0.692: "idle" means no extension-lane window in memory AND none saved on disk waiting to be re-adopted.
// Right after a restart both of John's windows were still waiting, the in-memory list was empty, and this
// ended the browser with them on it; one did not come back (AdomLapper, 2026-10-03 13:02).
let saved_ext = ctx.sessions.read_files().iter().any(|f| f["transport"].as_str() == Some("ext"));
if crate::extdrive_browser_alive(ctx) && !saved_ext && !ctx.sessions.all().iter().any(|s| crate::transport::is_ext(s)) {
pup_core::log(format!("[lane] cycling the idle lane browser: it runs extension {} while pup is {}", w.replace('-', "."), want.replace('-', ".")));
crate::kill_extdrive(ctx);
crate::extdrive::reset_worker_version();
}
}
browser_liveness(ctx);
idle_browser_reap(ctx);
tabless_and_orphan_pass(ctx);
let profiles: Vec<(String, u32)> = ctx.browsers.lock().unwrap().iter().filter(|(_, b)| !b.cdp.is_alive()).map(|(p, b)| (p.clone(), b.launched.pid)).collect();
for (p, pid) in profiles {
pup_core::log(format!("[health] browser for profile \"{p}\" disconnected — attempting reconnect"));
ctx.browsers.lock().unwrap().remove(&p);
match reconnect_profile(ctx, &p) {
Some(h) => {
let mut taken = Vec::new();
for s in ctx.sessions.for_profile(&p) {
let f = s.file_json();
match recover_one(ctx, &f, &h, &mut taken) { Some(ns) => { ctx.sessions.insert(ns.clone()); ctx.sessions.save_file(&ns); if let Some(a) = ctx.arc() { crate::identity::schedule(a, ns.id.clone(), crate::identity::internal_caller(), true); } } None => { pup_core::log(format!("[health] session \"{}\" not found after reconnect — removing", s.id)); ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id); } }
}
let n = adopt_window_tabs(ctx, &h); if n > 0 { pup_core::log(format!("[health] {n} untracked tab(s) adopted into their windows after the reconnect")); }
crate::focus_emulate_all(ctx);
}
None => {
// Chrome is GONE (its pid is dead): every window on that profile comes back from its file
// (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) };
// 2.0.600: PUP's OWN kill is not a user close. pup ends browsers for its own reasons (a
// profile handover, a lane relaunch, a restart), and reading that as "the user closed the
// last window" deleted live windows: the ab-smoke window died this way three runs running
// (ConfRoomROG, 2026-09-16, "is gone with exit code Some(0): not a crash. Forgetting").
// Only a browser that ended on ITS OWN with code 0 means the user closed its last window.
let ours = pid != 0 && ctx.host.ended_by_pup(pid);
let crashed = (ours || 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}) {} — relaunching its {} window(s) from their files", if ours { "was ended by pup itself (a handover or relaunch), not by the user".to_string() } else { format!("CRASHED (exit code 0x{:x})", 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.
let ids: Vec<String> = files.iter().filter_map(|f| f["sessionId"].as_str().map(|s| s.to_string())).collect();
reserve(&ids); mark_lost(&ids);
for id in &ids { ctx.sessions.remove(id); }
for f in files { relaunch_from_file(ctx, &f); }
} else {
pup_core::log(format!("[health] could not reconnect profile \"{p}\" but its Chrome (pid {pid}) is alive — its sessions wait for the next tick (Chrome may be asleep or restarting)"));
}
}
}
}
for s in ctx.sessions.all() {
if let Some(h) = s.hwnd { if !ctx.host.is_alive(h) { ctx.sessions.with_mut(&s.id, |x| x.hwnd = None); } }
}
// No-fault fg watch (Node: the fgWatch interval): if a BACKGROUND window fronts itself within 20s of
// opening and notMineCaption is on, caption that pup did NOT raise it — then let it stay up.
if ctx.settings.get("notMineCaption", "off") == "on" {
let now = pup_core::now_ms(); let fg = ctx.host.foreground();
for s in ctx.sessions.all() {
if s.fg_watch_until_ms > now && !s.foreground && s.hwnd == Some(fg) && fg != 0 {
ctx.sessions.with_mut(&s.id, |x| { x.fg_watch_until_ms = 0; x.foreground = true; x.user_active_at_ms = pup_core::now_ms(); });
let _ = ctx.ab.call("desktop_caption", json!({ "id": "pup-notmine", "text": format!("Adom did NOT raise this \"{}\" window — the page or Chrome brought it forward.", s.id), "duration": 3000, "expiresInMs": 3000, "position": "bottom", "size": "small" }), &crate::identity::internal_caller(), Duration::from_secs(5));
}
}
}
for f in ctx.sessions.read_files() {
let id = f["sessionId"].as_str().unwrap_or("").to_string(); let pid = f["pid"].as_u64().unwrap_or(0) as u32;
if id.is_empty() || ctx.sessions.exists(&id) || restoring(&id) { continue; }
// 2.0.536: an ext file is re-adopted from the live lane first; it never enters the CDP reconnect, and it
// is relaunched only when the lane really is dead (pid is 0 on ext sessions by design, which used to
// read as "gone" every tick and tore the live window down).
if f["transport"].as_str() == Some("ext") {
if readopt_ext(ctx, &f) { continue; }
let dead = { let _scope = ProfileScope::enter(f["profile"].as_str().unwrap_or("adom-you")); !crate::extdrive::alive(ctx) && crate::extdrive_pids().is_empty() };
if dead { if let Some(i) = f["sessionId"].as_str() { mark_lost(&[i.to_string()]); } relaunch_from_file(ctx, &f); }
continue;
}
let profile = f["profile"].as_str().unwrap_or("adom-you").to_string();
if let Some(h) = reconnect_profile_hint(ctx, &profile, f["cdpPort"].as_u64().map(|p| p as u16)) {
// 2.0.468: a tab a LIVE session already tracks is never re-attached from a file (the URL-prefix rule
// matched another window's tab at the same page on arav-rog).
let mut taken: Vec<String> = ctx.sessions.all().iter().flat_map(|s| s.tabs.iter().map(|t| t.target_id.clone())).collect();
match recover_one(ctx, &f, &h, &mut taken) { Some(ns) => { pup_core::log(format!("[health] re-attached session \"{id}\" to its live Chrome")); ctx.sessions.insert(ns.clone()); ctx.sessions.save_file(&ns); if let Some(a) = ctx.arc() { crate::identity::schedule(a, ns.id.clone(), crate::identity::internal_caller(), true); } } None => relaunch_from_file(ctx, &f) }
let n = adopt_window_tabs(ctx, &h); if n > 0 { pup_core::log(format!("[health] {n} untracked tab(s) adopted into their windows after the re-attach")); }
crate::focus_emulate_all(ctx);
} else if pid == 0 || !ctx.host.alive(pid) { relaunch_from_file(ctx, &f); }
}
crate::focus_emulate_all(ctx); // 2.0.459: converge the focus-emulation policy (no CDP calls when it already matches)
}
/// 2.0.470: the event pump kicks the health loop the moment a browser connection closes, so a crashed Chrome's
/// windows come back within a second or two instead of up to 15 s later (arav-rog crash drill: pup_list_windows
/// listed the dead windows as normal for the rest of the tick).
static HEALTH_KICK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn kick_health() { HEALTH_KICK.store(true, std::sync::atomic::Ordering::SeqCst); }
/// 2.0.472: how many relaunches are still running. pup_restart_browser waits for this to reach zero before it
/// saves + kills again, so a second restart (or the wedge watchdog firing while a restore is mid-flight) can no
/// longer tear a window down while its tabs are still being reopened into it (ConfRoomROG/arav-rog triple-restart
/// drill: round 2 killed tri-b's window mid-restore — "HWND is not a valid window", "back with 6 of 3").
pub fn relaunches_in_flight() -> usize { IN_FLIGHT.lock().unwrap().len() }
/// 2.0.475: drain on the RESERVATION set, not IN_FLIGHT. A restart/crash batch reserves every window id up front
/// and releases each only as its relaunch runs, so RESTORING stays non-empty for the WHOLE batch; IN_FLIGHT goes
/// momentarily empty BETWEEN two sequential relaunches, and a close that drained on it fired in that gap and
/// missed the second window (close-during-restore cycle: a "cyc-b" lingered, then poisoned the next restart).
pub fn restores_pending() -> usize { RESTORING.lock().unwrap().len() }
pub fn wait_for_relaunches_to_drain(max: Duration) {
let t0 = std::time::Instant::now();
while (restores_pending() > 0 || relaunches_in_flight() > 0) && t0.elapsed() < max { std::thread::sleep(Duration::from_millis(100)); }
}
pub fn start_health_loop(ctx: Arc<Ctx>) {
std::thread::Builder::new().name("health".into()).spawn(move || loop {
let slept = std::time::Instant::now();
while slept.elapsed() < Duration::from_secs(15) { if HEALTH_KICK.swap(false, std::sync::atomic::Ordering::SeqCst) { pup_core::log("[health] kicked: a browser connection closed; running the tick now".to_string()); break; } std::thread::sleep(Duration::from_millis(500)); }
let t0 = std::time::Instant::now(); health_tick(&ctx); pup_core::loops::tick("health+user-activity", 15_000, t0.elapsed().as_millis() as u64);
}).ok();
}
/// pup_rescan: reconnect dropped connections and rebuild sessions from files; adoptOrphans:true
/// also adopts pages no session owns (as sessions named after their target).
pub fn rescan(ctx: &Ctx, args: &Value) -> Value {
let before = ctx.sessions.count();
health_tick(ctx);
let mut reattached = 0; let mut adopted = 0; let mut reconnected = Vec::new(); let mut unreachable = Vec::new();
let mut taken: Vec<String> = ctx.sessions.all().iter().flat_map(|s| s.tabs.iter().map(|t| t.target_id.clone())).collect();
for f in ctx.sessions.read_files() {
let id = f["sessionId"].as_str().unwrap_or("").to_string();
if id.is_empty() || ctx.sessions.exists(&id) { continue; }
let profile = f["profile"].as_str().unwrap_or("adom-you").to_string();
match reconnect_profile(ctx, &profile) {
Some(h) => { if !reconnected.contains(&profile) { reconnected.push(profile.clone()); } if let Some(s) = recover_one(ctx, &f, &h, &mut taken) { ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); reattached += 1; } }
None => { if !unreachable.contains(&profile) { unreachable.push(profile.clone()); } }
}
}
if args.get("adoptOrphans").and_then(|b| b.as_bool()) == Some(true) {
let handles: Vec<(String, Arc<BrowserHandle>)> = ctx.browsers.lock().unwrap().iter().map(|(p, h)| (p.clone(), h.clone())).collect();
for (profile, h) in handles {
for t in page_targets(&h.cdp) {
let tid = t["targetId"].as_str().unwrap_or("").to_string(); let url = t["url"].as_str().unwrap_or("");
if taken.contains(&tid) || url == "about:blank" || url.starts_with("chrome://") || url.starts_with("devtools://") { continue; }
// 2.0.453: a page in a window pup already tracks joins THAT session as a tab (keyed by the CDP
// Browser window id), instead of becoming a hwnd-less standalone "adopted-*" session.
if let Ok((wid, _)) = h.cdp.window_for_target(&tid) {
if let Some(owner) = ctx.sessions.all().into_iter().find(|s| s.window_id == Some(wid) && s.hwnd.is_some()) {
let mut s = owner.clone();
if crate::attach_tab(&mut s, &h.cdp, &tid).is_ok() { s.tabless_since_ms = 0; taken.push(tid.clone()); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); adopted += 1; pup_core::log(format!("[rescan] \"{}\": adopted the orphan tab {} into its own window", s.id, &url[..url.len().min(80)])); continue; }
}
}
if let Ok(sid) = h.cdp.attach(&tid) {
let _ = h.cdp.page_enable(&sid);
let id = format!("adopted-{}", &tid[..tid.len().min(8)].to_lowercase());
let mut s = Session::new(&id, &profile, None, None, h.launched.cdp_port, None);
let tab_id = s.next_tab_id();
s.tabs.push(Tab { tab_id, target_id: tid.clone(), cdp_session: sid, url: url.into(), title: t["title"].as_str().unwrap_or("").into(), error_count: 0, fav: None });
taken.push(tid); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); adopted += 1;
}
}
}
}
let live: Vec<String> = ctx.sessions.all().iter().filter(|s| crate::browser_for(ctx, &s.profile).is_some()).map(|s| s.id.clone()).collect();
let rebound = crate::extdrive::rebind_unbound(ctx);
let disconnected: Vec<String> = ctx.sessions.all().iter().filter(|s| if crate::transport::is_ext(s) { !crate::extdrive_browser_alive(ctx) } else { crate::browser_for(ctx, &s.profile).is_none() }).map(|s| s.id.clone()).collect();
let hint = if disconnected.is_empty() { format!("All known sessions are live: [{}].", live.join(", ")) } else { format!("{} session(s) still disconnected: [{}] — their Chrome is gone; pup_open_window recreates them.", disconnected.len(), disconnected.join(", ")) };
ok(json!({ "rescanned": reconnected.len() + unreachable.len(), "liveSessions": live, "disconnectedSessions": disconnected, "before": before, "reattached": reattached, "orphansAdopted": adopted, "profilesReconnected": reconnected.len(), "extWindowsRebound": rebound, "profilesUnreachable": unreachable.len(), "_hint": hint }))
}
#[allow(dead_code)]
fn _host(_: &dyn Host) {}