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.660 (insiders): per-profile extension lanes: isolated:true and profile:<name> without CDP (pup-bridge#113/#112); throwaway lanes fully cleaned up
8 files changed
+192−126
dev-skills/pup-bridge-dev/SKILL.md+13@@ -1754,3 +1754,16 @@ John's radar for a misbehaving pup. The replacement is mandatory and already wir do not know `reload`, so the very first upgrade to that line still needs one lane cycle. - **Orphan `about:blank` windows** from a lane relaunch are swept after every ext open and on the tick. - **Rule of the day (John)**: pup is the extension lane. CDP opens only on an explicit `lane:"cdp"`.++## The extension lane is PER PROFILE (2.0.659, pup-bridge#113): how it is wired++`extdrive::LaneState` holds everything that used to be a static: lane token, pids, command queue, results, taken,+last poll, removed windows, worker version, current window, the DevTools handle. The default lane (adom-you) is+`default_lane()`; every other profile gets a state in `LANES`. Which lane a call means is a THREAD-LOCAL profile:+`dispatch` clears it per request, `transport::lane_for` sets it from the session's profile, `open_window_ext`+sets it from `_mintedProfile` / `profile`, so the 40-odd `extdrive::run` call sites are unchanged. The+worker side needed nothing: each lane's browser reads its own `extlane-<profile>/pup-extension/port.json` and+polls `/extcmd?lane=<its token>`; `take_command_for` finds the lane by token, `put_result` by command id.+Rules: a thread spawned from a verb (birth watcher, z-watch) does NOT inherit the thread-local, so capture the+lane's pids before spawning. A throwaway lane ends with its last window (`close_window`) and is swept at boot.+The CDP lane is off by default (`cdpLane`); `profile` and `isolated` no longer refer to it.
rust/Cargo.lock+12−12@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-pup-bridge"-version = "2.0.658"+version = "2.0.660" dependencies = [ "pup-browser", "pup-cdp",@@ -655,7 +655,7 @@ dependencies = [ [[package]] name = "pup-browser"-version = "2.0.658"+version = "2.0.660" dependencies = [ "pup-core", "pup-host",@@ -667,7 +667,7 @@ dependencies = [ [[package]] name = "pup-cdp"-version = "2.0.658"+version = "2.0.660" dependencies = [ "serde_json", "thiserror",@@ -677,7 +677,7 @@ dependencies = [ [[package]] name = "pup-core"-version = "2.0.658"+version = "2.0.660" dependencies = [ "serde", "serde_json",@@ -687,7 +687,7 @@ dependencies = [ [[package]] name = "pup-dashboard"-version = "2.0.658"+version = "2.0.660" dependencies = [ "pup-cdp", "pup-core",@@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "pup-host"-version = "2.0.658"+version = "2.0.660" dependencies = [ "serde", "serde_json",@@ -709,14 +709,14 @@ dependencies = [ [[package]] name = "pup-host-mac"-version = "2.0.658"+version = "2.0.660" dependencies = [ "pup-host", ] [[package]] name = "pup-host-win"-version = "2.0.658"+version = "2.0.660" dependencies = [ "pup-core", "pup-host",@@ -726,7 +726,7 @@ dependencies = [ [[package]] name = "pup-identity"-version = "2.0.658"+version = "2.0.660" dependencies = [ "fontdue", "image",@@ -736,7 +736,7 @@ dependencies = [ [[package]] name = "pup-session"-version = "2.0.658"+version = "2.0.660" dependencies = [ "pup-core", "serde",@@ -746,7 +746,7 @@ dependencies = [ [[package]] name = "pup-vault"-version = "2.0.658"+version = "2.0.660" dependencies = [ "pup-core", "pup-host",@@ -757,7 +757,7 @@ dependencies = [ [[package]] name = "pup-verbs"-version = "2.0.658"+version = "2.0.660" 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.658"+version = "2.0.660" edition = "2021" license = "MIT"
rust/browser/src/lib.rs+2−1@@ -379,7 +379,8 @@ pub fn stage_extensions(host: &dyn Host, port: u16, profile: &str, toolbar_on: b /// answered /extcmd, and pup ended up driving a DIFFERENT thread's window (measured 2026-09-13). pub fn stage_extensions_lane(host: &dyn Host, port: u16, profile: &str, toolbar_on: bool, lane: Option<&str>, identity_shim: Option<String>) -> Vec<PathBuf> { if !toolbar_on { return Vec::new(); }- let root = if lane.is_some() { host.state_dir().join("extlane") } else { host.state_dir() };+ // 2.0.659: one staging folder per lane profile, so each lane browser reads its OWN port.json (lane token)+ let root = if lane.is_some() { host.state_dir().join(if profile == "adom-you" { "extlane".to_string() } else { format!("extlane-{profile}") }) } else { host.state_dir() }; let _ = fs::create_dir_all(&root); // Remove any staged extension folder that is no longer one of ours (a retired placeholder like // pup-lane-extension left a second browser-window icon in the toolbar until it was deleted by hand,
rust/verbs/src/extdrive.rs+109−84@@ -24,51 +24,87 @@ use crate::Ctx; /// and a verb) overwrote each other, one of them timed out and the annotate watcher died on its first /// read. Commands queue by id, results are keyed by id, the worker drains the queue on every poll, and /// everything the worker had taken is re-queued when a fresh hello says it restarted.-static PENDING: Mutex<std::collections::VecDeque<Value>> = Mutex::new(std::collections::VecDeque::new());-static RESULTS: Mutex<Option<std::collections::HashMap<u64, Value>>> = Mutex::new(None);-/// Taken by the worker, not yet answered: id -> (command, taken_at_ms).-static TAKEN: Mutex<Option<std::collections::HashMap<u64, (Value, u64)>>> = Mutex::new(None);+/// 2.0.659 (pup-bridge#113): the lane is PER PROFILE. The default lane (adom-you, the one every user runs)+/// keeps exactly the state it always had; every other profile (a throwaway iso-<id> for isolated:true, or a+/// named profile) gets its own browser, its own staged extension with its own lane token, its own queue.+/// Which lane a call means comes from a thread-local set by `enter_profile` (the dispatcher resets it per+/// request; `transport::lane_for` sets it from the session's profile), so every existing call site is unchanged.+pub struct LaneState {+ pub profile: String,+ pub token: Mutex<String>,+ pub pids: Mutex<Vec<u32>>,+ pub pending: Mutex<std::collections::VecDeque<Value>>,+ pub results: Mutex<Option<std::collections::HashMap<u64, Value>>>,+ pub taken: Mutex<Option<std::collections::HashMap<u64, (Value, u64)>>>,+ pub last_poll: Mutex<u64>,+ pub removed: Mutex<Vec<i64>>,+ pub worker: Mutex<String>,+ pub cur: Mutex<(i64, i64)>,+ pub handle: std::sync::Mutex<Option<std::sync::Arc<crate::BrowserHandle>>>,+}+impl LaneState {+ pub fn new(profile: &str) -> LaneState { LaneState { profile: profile.to_string(), token: Mutex::new(String::new()), pids: Mutex::new(Vec::new()), pending: Mutex::new(std::collections::VecDeque::new()), results: Mutex::new(None), taken: Mutex::new(None), last_poll: Mutex::new(0), removed: Mutex::new(Vec::new()), worker: Mutex::new(String::new()), cur: Mutex::new((0, 0)), handle: std::sync::Mutex::new(None) } }+}+static DEFAULT_LANE: std::sync::OnceLock<std::sync::Arc<LaneState>> = std::sync::OnceLock::new();+static LANES: Mutex<Vec<std::sync::Arc<LaneState>>> = Mutex::new(Vec::new());+thread_local! { static CUR_PROFILE: std::cell::RefCell<String> = std::cell::RefCell::new(String::new()); }+pub fn default_lane() -> std::sync::Arc<LaneState> { DEFAULT_LANE.get_or_init(|| std::sync::Arc::new(LaneState::new("adom-you"))).clone() }+pub fn st() -> std::sync::Arc<LaneState> {+ let p = CUR_PROFILE.with(|c| c.borrow().clone());+ if p.is_empty() || p == "adom-you" { return default_lane(); }+ lane_for_profile(&p).unwrap_or_else(default_lane)+}+pub fn cur_profile() -> String { let p = CUR_PROFILE.with(|c| c.borrow().clone()); if p.is_empty() { "adom-you".into() } else { p } }+pub fn enter_profile(profile: &str) {+ if profile.is_empty() || profile == "adom-you" { CUR_PROFILE.with(|c| c.borrow_mut().clear()); return; }+ if lane_for_profile(profile).is_none() { LANES.lock().unwrap().push(std::sync::Arc::new(LaneState::new(profile))); }+ CUR_PROFILE.with(|c| *c.borrow_mut() = profile.to_string());+}+pub fn leave_profile() { CUR_PROFILE.with(|c| c.borrow_mut().clear()); }+pub fn lane_for_profile(profile: &str) -> Option<std::sync::Arc<LaneState>> {+ if profile.is_empty() || profile == "adom-you" { return Some(default_lane()); }+ LANES.lock().unwrap().iter().find(|l| l.profile == profile).cloned()+}+pub fn lane_for_token(token: &str) -> Option<std::sync::Arc<LaneState>> {+ if token.is_empty() { return None; }+ if *default_lane().token.lock().unwrap() == token { return Some(default_lane()); }+ LANES.lock().unwrap().iter().find(|l| *l.token.lock().unwrap() == token).cloned()+}+pub fn drop_lane_for_profile(profile: &str) { LANES.lock().unwrap().retain(|l| l.profile != profile); }+pub fn secondary_lanes() -> Vec<std::sync::Arc<LaneState>> { LANES.lock().unwrap().clone() } 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()) }+pub fn take_removed() -> Vec<i64> { std::mem::take(&mut *st().removed.lock().unwrap()) } /// GET /extcmd — the extension asks for work. Returns `{}` when there is nothing to do. 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()-}--pub fn take_command() -> Value {- let first = *LAST_POLL.lock().unwrap() == 0;- *LAST_POLL.lock().unwrap() = pup_core::now_ms();- // 2.0.495: log the FIRST poll so "the extension never called home" is answerable from the log instead of- // guessed at (ConfRoomROG: the channel stayed dead and there was no way to tell reach from load).- if first { pup_core::log("[extdrive] the pup extension reached /extcmd — the control channel is live".to_string()); }- let cmd = PENDING.lock().unwrap().pop_front().unwrap_or(json!({}));- if let Some(id) = cmd["id"].as_u64() { TAKEN.lock().unwrap().get_or_insert_with(Default::default).insert(id, (cmd.clone(), pup_core::now_ms())); }+ // 2.0.502: only a browser carrying a lane token pup issued may take work. 2.0.659: the token names WHICH+ // lane (default adom-you, or a per-profile lane), so several browsers can be driven at once.+ let Some(l) = lane_for_token(lane) else { return json!({}) };+ if !removed.is_empty() { l.removed.lock().unwrap().extend_from_slice(removed); }+ take_command_on(&l)+}++pub fn take_command() -> Value { take_command_on(&st()) }+fn take_command_on(l: &LaneState) -> Value {+ let first = *l.last_poll.lock().unwrap() == 0;+ *l.last_poll.lock().unwrap() = pup_core::now_ms();+ if first { pup_core::log(format!("[extdrive] the pup extension reached /extcmd (lane profile {}) — the control channel is live", l.profile)); }+ let cmd = l.pending.lock().unwrap().pop_front().unwrap_or(json!({}));+ if let Some(id) = cmd["id"].as_u64() { l.taken.lock().unwrap().get_or_insert_with(Default::default).insert(id, (cmd.clone(), pup_core::now_ms())); } cmd } /// Called on a matched hello: if the previous worker instance took a command and never answered, hand it /// to this one. Returns how many were re-queued.-fn requeue_taken(reason: &str) -> usize {+fn requeue_taken_on(l: &LaneState, reason: &str) -> usize { let now = pup_core::now_ms();- let mut taken = TAKEN.lock().unwrap();+ let mut taken = l.taken.lock().unwrap(); let Some(m) = taken.as_mut() else { return 0 }; let mut ids: Vec<u64> = m.iter().filter(|(_, (_, at))| now.saturating_sub(*at) >= 1500).map(|(id, _)| *id).collect(); ids.sort(); let mut n = 0;- let mut pending = PENDING.lock().unwrap();- let results = RESULTS.lock().unwrap();+ let mut pending = l.pending.lock().unwrap();+ let results = l.results.lock().unwrap(); for id in ids.into_iter().rev() { // oldest ends up at the front if results.as_ref().map(|r| r.contains_key(&id)).unwrap_or(false) { m.remove(&id); continue; } if let Some((cmd, _)) = m.remove(&id) {@@ -78,11 +114,13 @@ fn requeue_taken(reason: &str) -> usize { } n }+#[allow(dead_code)]+fn requeue_taken(reason: &str) -> usize { requeue_taken_on(&st(), reason) } /// POST /exthello — an extension announces itself, whatever lane it holds. Throttled to one line per /// distinct (lane, match) per 30s so a 1.2s poll loop cannot flood the log. /// The worker version the running lane browser reported in its last hello ("2-0-541" from /worker.2-0-541.js).-pub static LANE_WORKER: Mutex<String> = Mutex::new(String::new());+// LANE_WORKER lives in LaneState.worker since 2.0.659 /// 2.0.569: after a bridge_install the running extension browser keeps the OLD worker until it relaunches, /// which only happens when no thread has an ext window open (measured: a 2.0.565 bridge driving a 2.0.562 /// worker for an hour). On each health tick, if the lane's worker is behind, re-stage the extension folder@@ -94,7 +132,7 @@ pub fn reload_stale_worker(ctx: &Ctx) { let Some((have, want)) = lane_worker_stale() else { return }; if ASKED.lock().unwrap().as_deref() == Some(want.as_str()) { return; } *ASKED.lock().unwrap() = Some(want.clone());- let lane = crate::EXTDRIVE_LANE.lock().unwrap().clone();+ let lane = st().token.lock().unwrap().clone(); if lane.is_empty() { return; } let shim = crate::lane_shim(ctx); let staged = pup_browser::stage_extensions_lane(&*ctx.host, ctx.port, "adom-you", true, Some(&lane), shim);@@ -121,10 +159,10 @@ pub fn reload_stale_worker(ctx: &Ctx) { } /// 2.0.647: a lane that ends takes its worker version with it; a fresh browser reports its own at hello.-pub fn reset_worker_version() { LANE_WORKER.lock().unwrap().clear(); }+pub fn reset_worker_version() { st().worker.lock().unwrap().clear(); } pub fn lane_worker_stale() -> Option<(String, String)> {- let w = LANE_WORKER.lock().unwrap().clone();+ let w = st().worker.lock().unwrap().clone(); if w.is_empty() { return None; } let want = pup_core::version().replace('.', "-"); if w == want { None } else { Some((w, want)) }@@ -133,14 +171,13 @@ pub fn lane_worker_stale() -> Option<(String, String)> { pub fn hello(v: Value) { static LAST: Mutex<Option<std::collections::HashMap<String, u64>>> = Mutex::new(None); let lane = v["lane"].as_str().unwrap_or("").to_string();- if !lane.is_empty() {- if let Some(w) = v["worker"].as_str().and_then(|p| p.rsplit('/').next()).and_then(|f| f.strip_prefix("worker.")).and_then(|f| f.strip_suffix(".js")) {- *LANE_WORKER.lock().unwrap() = w.to_string();- }+ let target = lane_for_token(&lane);+ if let (Some(l), Some(w)) = (target.as_ref(), v["worker"].as_str().and_then(|p| p.rsplit('/').next()).and_then(|f| f.strip_prefix("worker.")).and_then(|f| f.strip_suffix(".js"))) {+ *l.worker.lock().unwrap() = w.to_string(); }- let want = crate::EXTDRIVE_LANE.lock().unwrap().clone();- let matched = !lane.is_empty() && lane == want;- if matched { requeue_taken("the lane worker restarted (fresh hello)"); }+ let want = default_lane().token.lock().unwrap().clone();+ let matched = target.is_some();+ if let Some(l) = target.as_ref() { requeue_taken_on(l, "the lane worker restarted (fresh hello)"); } let key = format!("{lane}|{matched}"); { let mut g = LAST.lock().unwrap();@@ -149,45 +186,33 @@ pub fn hello(v: Value) { if let Some(t) = m.get(&key) { if now.saturating_sub(*t) < 30_000 { return; } } m.insert(key, now); }- if !matched && !lane.is_empty() && *LAST_POLL.lock().unwrap() == 0 {- // 2.0.513: ADOPT. Nothing has ever been served since this pup launched, and a browser on our own- // profile is asking for work with a lane token. That is an extension browser we started and then- // lost track of (a pup restart, or a close whose kill missed the main process and took lane.json- // with it), and it is unkillable because pup no longer knows its pids. Fighting it just spawns a- // second Chrome that forwards to it and exits. Adopting costs nothing and cannot hijack a working- // lane, because a working lane has served at least one poll.- *crate::EXTDRIVE_LANE.lock().unwrap() = lane.clone();+ let dl = default_lane();+ if !matched && !lane.is_empty() && *dl.last_poll.lock().unwrap() == 0 {+ *dl.token.lock().unwrap() = lane.clone(); pup_core::log(format!("[extdrive] adopted the running extension browser (lane {lane}); pup had lost track of it")); return; }- // 2.0.599: record the token-less hello whether or not pup holds a token. It used to require pup to hold- // NONE, so a pup whose token belonged to a killed browser refused this live one on every hello and never- // recovered (ConfRoomROG, 2026-09-16: "carrying lane \"\" (pup wants ext-1789600049390) NOT served", for- // 20 minutes, until a pup restart). adopt_tokenless still refuses while pup's own browser is alive.- if !matched && lane.is_empty() && *LAST_POLL.lock().unwrap() == 0 {+ if !matched && lane.is_empty() && *dl.last_poll.lock().unwrap() == 0 { *TOKENLESS_HELLO_AT.lock().unwrap() = pup_core::now_ms();- pup_core::log(format!("[extdrive] a pup extension is polling with no lane token{}: the next health tick adopts it if pup's own browser is gone", if want.is_empty() { String::new() } else { format!(" while pup wants \"{want}\"") }));+ pup_core::log(format!("[extdrive] a pup extension is polling with no lane token{}: the next health tick adopts it if pup's own browser is gone", if want.is_empty() { String::new() } else { format!(" while pup wants {want}") })); return; }- if matched {- pup_core::log(format!("[extdrive] hello from the lane browser (lane {lane}) — this one may take work"));- } else {- pup_core::log(format!("[extdrive] hello from a pup extension carrying lane {:?} (pup wants {:?}), port.json read={} pkg={} — NOT served",- lane, want, v["read"], v["pkg"]));- }+ if let Some(l) = target { pup_core::log(format!("[extdrive] hello from the lane browser (lane {lane}, profile {}) — this one may take work", l.profile)); }+ else { pup_core::log(format!("[extdrive] hello from a pup extension carrying lane {:?} (pup wants {:?}), port.json read={} pkg={} — NOT served", lane, want, v["read"], v["pkg"])); } } -/// POST /extresult — the extension reports what happened. pub fn put_result(v: Value) { let Some(id) = v["id"].as_u64() else { return };- if let Some(m) = TAKEN.lock().unwrap().as_mut() { m.remove(&id); }- RESULTS.lock().unwrap().get_or_insert_with(Default::default).insert(id, v);+ let mut lanes = vec![default_lane()]; lanes.extend(secondary_lanes());+ let owner = lanes.iter().find(|l| l.taken.lock().unwrap().as_ref().map(|m| m.contains_key(&id)).unwrap_or(false)).cloned().unwrap_or_else(|| st());+ if let Some(m) = owner.taken.lock().unwrap().as_mut() { m.remove(&id); }+ owner.results.lock().unwrap().get_or_insert_with(Default::default).insert(id, v); } /// A killed lane must read as dead at once. `alive()` is heartbeat-based, and a browser killed 3 s after its /// last poll still counted as alive for 7 more seconds: the next open took the WARM path and waited 60 s on a /// dead channel until ab timed out (2026-09-13, the first cold open of the 20-site proof).-pub(crate) fn mark_dead() { *LAST_POLL.lock().unwrap() = 0; }+pub(crate) fn mark_dead() { *st().last_poll.lock().unwrap() = 0; } /// The lane as pup_status reports it. There are no lane verbs: a lane window is a pup window. pub fn lane_health(ctx: &Ctx) -> Value {@@ -198,14 +223,14 @@ pub fn lane_health(ctx: &Ctx) -> Value { let live_windows = { let p = crate::extdrive_pids(); if p.is_empty() { 0 } else { ctx.host.windows_for_pids(&p, Some("Chrome_WidgetWin_1")).into_iter().filter(|w| w.visible && w.rect.2 - w.rect.0 > 200).count() } }; json!({ "connected": alive(ctx), "lastPollMsAgo": if ago > 86_400_000 { Value::Null } else { json!(ago) }, "processes": crate::extdrive_pids().len(), "profile": "adom-you", "debugPort": false,- "workerVersion": LANE_WORKER.lock().unwrap().replace('-', "."), "stale": stale.is_some(),+ "workerVersion": st().worker.lock().unwrap().replace('-', "."), "stale": stale.is_some(), "_staleHint": stale.map(|(w, want)| format!("the lane browser still runs extension {} while pup is {}; it cycles by itself the moment no extension-lane window is open, or pup_close_window {{all:true}} from every thread cycles it now", w.replace('-', "."), want.replace('-', "."))), "sessions": ctx.sessions.all().iter().filter(|s| crate::transport::is_ext(s)).count(), "windows": live_windows }) } -pub fn connected_ms_ago() -> u64 { pup_core::now_ms().saturating_sub(*LAST_POLL.lock().unwrap()) }-pub fn last_poll_ms() -> u64 { *LAST_POLL.lock().unwrap() }+pub fn connected_ms_ago() -> u64 { pup_core::now_ms().saturating_sub(*st().last_poll.lock().unwrap()) }+pub fn last_poll_ms() -> u64 { *st().last_poll.lock().unwrap() } /// 2.0.580: a pup extension polled with NO lane token while pup holds none either (both sides empty after a /// restart whose lane.json did not survive). Refusing it forever left an orphan browser (2026-09-14 20:57). /// The health tick adopts it: mint a token, stamp it into port.json (the worker re-reads it every poll),@@ -222,7 +247,7 @@ pub fn adopt_tokenless(ctx: &Ctx) { if last_poll_ms() != 0 { return; } let mine = crate::extdrive_pids(); if !mine.is_empty() && mine.iter().any(|p| ctx.host.alive(*p)) { return; }- let had = crate::EXTDRIVE_LANE.lock().unwrap().clone();+ let had = st().token.lock().unwrap().clone(); let lane = format!("ext-{}", pup_core::now_ms()); let staged = pup_browser::stage_extensions_lane(&*ctx.host, ctx.port, "adom-you", true, Some(&lane), crate::lane_shim(ctx)); if staged.is_empty() { return; }@@ -231,7 +256,7 @@ pub fn adopt_tokenless(ctx: &Ctx) { let cdp: Vec<u32> = ctx.browsers.lock().unwrap().values().map(|b| b.launched.pid).filter(|p| *p > 0).collect(); let pids: Vec<u32> = { let mut v: Vec<u32> = ctx.host.frame_windows_all(Some("Chrome_WidgetWin_1")).into_iter().filter(|w| w.title.ends_with("Google Chrome for Testing") && !cdp.contains(&w.pid)).map(|w| w.pid).collect(); v.sort(); v.dedup(); v }; if pids.is_empty() { return; }- *crate::EXTDRIVE_LANE.lock().unwrap() = lane.clone();+ *st().token.lock().unwrap() = lane.clone(); crate::set_extdrive_pids(pids.clone()); crate::persist_lane(ctx); *TOKENLESS_HELLO_AT.lock().unwrap() = 0;@@ -249,18 +274,18 @@ fn run_inner(cmd: Value, wait: Duration) -> Result<Value, Value> { let id = { let mut s = SEQ.lock().unwrap(); *s += 1; *s }; let mut c = cmd; c["id"] = json!(id);- PENDING.lock().unwrap().push_back(c);+ st().pending.lock().unwrap().push_back(c); let t0 = Instant::now(); while t0.elapsed() < wait {- let hit = RESULTS.lock().unwrap().as_mut().and_then(|m| m.remove(&id));+ let hit = st().results.lock().unwrap().as_mut().and_then(|m| m.remove(&id)); if let Some(r) = hit { if r["ok"] == json!(true) { return Ok(r); } return Err(err("ext_command_failed", r["error"].as_str().unwrap_or("the extension could not run it").to_string(), None)); } std::thread::sleep(Duration::from_millis(60)); }- PENDING.lock().unwrap().retain(|c| c["id"].as_u64() != Some(id));- if let Some(m) = TAKEN.lock().unwrap().as_mut() { m.remove(&id); }+ st().pending.lock().unwrap().retain(|c| c["id"].as_u64() != Some(id));+ if let Some(m) = st().taken.lock().unwrap().as_mut() { m.remove(&id); } Err(pup_core::merge( err("ext_no_answer", format!("the pup extension did not answer within {}s", wait.as_secs()), Some("Is an extension-driven window open? pup_open_window starts one. The extension only answers while its browser is running; a normal CDP window does not serve this channel.")),@@ -279,14 +304,14 @@ static IN_FLIGHT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsiz pub(crate) fn alive(_ctx: &Ctx) -> bool { connected_ms_ago() < 20_000 || (IN_FLIGHT.load(std::sync::atomic::Ordering::Relaxed) > 0) } /// The window/tab pup is driving, so navigate/eval do not wander to another window.-static CUR: Mutex<(i64, i64)> = Mutex::new((0, 0)); // (windowId, tabId)+// CUR lives in LaneState.cur since 2.0.659 fn pin(r: &Value) { let w = r["windowId"].as_i64().unwrap_or(0); let t = r["tabId"].as_i64().unwrap_or(0);- if w > 0 || t > 0 { *CUR.lock().unwrap() = (w, t); }+ if w > 0 || t > 0 { *st().cur.lock().unwrap() = (w, t); } } fn aim(c: &mut Value) {- let (w, t) = *CUR.lock().unwrap();+ let (w, t) = *st().cur.lock().unwrap(); if t > 0 { c["tabId"] = json!(t); } if w > 0 { c["windowId"] = json!(w); } }@@ -380,7 +405,7 @@ pub fn close(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value { return match run(json!({ "action": "close", "tabId": t }), Duration::from_secs(20)) { Ok(_) => ok(json!({ "closed": true })), Err(e) => e }; } let via_ext = run(json!({ "action": "closeAll" }), Duration::from_secs(20)).is_ok();- *CUR.lock().unwrap() = (0, 0);+ *st().cur.lock().unwrap() = (0, 0); let n = crate::kill_extdrive(ctx); ok(json!({ "closed": true, "viaExtension": via_ext, "processesEnded": n, "_hint": "The extension-driven browser is closed. pup_open_window starts a fresh one." })) }@@ -392,7 +417,7 @@ pub fn status(ctx: &Ctx, _caller: &Caller, _args: &Value) -> Value { ok(json!({ "browserRunning": a, "extensionConnected": a && ago < 8000,- "lastPollMsAgo": if *LAST_POLL.lock().unwrap() == 0 { Value::Null } else { json!(ago) },+ "lastPollMsAgo": if *st().last_poll.lock().unwrap() == 0 { Value::Null } else { json!(ago) }, "profile": "adom-you", "_hint": "Extension-driven mode carries NO CDP debug port. Measured 2026-09-12: st.com, mouser and arrow load here and fail over CDP on the same box. analog.com stays denied (that one is profile trust, and denies the real extension on a cold profile too).", "_next": ["pup_close_window"],@@ -443,7 +468,7 @@ fn tab_inner(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value { let mut c = json!({ "action": "newtab", "url": url }); aim(&mut c); match run(c, Duration::from_secs(70)) {- Ok(r) => { let w = r["windowId"].as_i64().unwrap_or(0); if w > 0 { CUR.lock().unwrap().0 = w; }+ Ok(r) => { let w = r["windowId"].as_i64().unwrap_or(0); if w > 0 { st().cur.lock().unwrap().0 = w; } ok(json!({ "opened": true, "url": r["url"], "title": r["title"], "tabId": r["tabId"], "windowId": r["windowId"], "_hint": "Tab opened in the extension-driven window (no CDP debug port). pup_eval {tabId} reads it." })) } Err(e) => e,@@ -669,7 +694,7 @@ pub fn fit_now() { pub fn apply_consumer_brands(ctx: &Ctx) { // 6 s, not 10: on a cold start this runs before the first page and every second here is on the open's clock. static DONE_LANE: Mutex<String> = Mutex::new(String::new());- let lane = crate::EXTDRIVE_LANE.lock().unwrap().clone();+ let lane = st().token.lock().unwrap().clone(); if lane.is_empty() || *DONE_LANE.lock().unwrap() == lane { return; } let on = ctx.settings.get("consumerBrands", "on") != "off"; let mut cmd = json!({ "action": "setBrands", "on": on });@@ -712,9 +737,9 @@ pub fn dress_window(ctx: &Ctx, caller: &Caller) { /// 2.0.578: a browser handle for the extension lane whose Cdp forwards every command to the extension's /// DevTools driver (nbe's cdp.js, via the worker's `cdp` action). Verbs written against BrowserHandle /// (click, hover, type, press_key, scroll, fill, element/full-res screenshots) run unchanged on this lane.-static EXT_HANDLE: std::sync::Mutex<Option<std::sync::Arc<crate::BrowserHandle>>> = std::sync::Mutex::new(None);+// EXT_HANDLE lives in LaneState.handle since 2.0.659 pub fn ext_handle() -> std::sync::Arc<crate::BrowserHandle> {- let mut g = EXT_HANDLE.lock().unwrap();+ let lane_st = st(); let mut g = lane_st.handle.lock().unwrap(); if let Some(h) = g.as_ref() { return h.clone(); } let via: pup_cdp::Via = Box::new(|session: Option<&str>, method: &str, params: Value, timeout: Duration| { let tab = session.and_then(|s| s.strip_prefix("ext:")).and_then(|x| x.parse::<i64>().ok());
rust/verbs/src/lib.rs+39−27@@ -83,24 +83,24 @@ pub(crate) static BIRTH_LOCK: Mutex<()> = Mutex::new(()); pub(crate) fn taken_hwnds(ctx: &Ctx, except: &str) -> std::collections::HashSet<i64> { ctx.sessions.all().iter().filter(|s| s.id != except).filter_map(|s| s.hwnd).collect() }-static EXTDRIVE_PIDS: Mutex<Vec<u32>> = Mutex::new(Vec::new());+// EXTDRIVE_PIDS lives in extdrive::LaneState.pids since 2.0.659 (per profile) /// The token only the extension-driven browser carries (see stage_extensions_lane).-pub static EXTDRIVE_LANE: Mutex<String> = Mutex::new(String::new());-pub fn extdrive_pids() -> Vec<u32> { EXTDRIVE_PIDS.lock().unwrap().clone() }-pub(crate) fn set_extdrive_pids(p: Vec<u32>) { *EXTDRIVE_PIDS.lock().unwrap() = p; }+// EXTDRIVE_LANE lives in extdrive::LaneState.token since 2.0.659 (per profile)+pub fn extdrive_pids() -> Vec<u32> { extdrive::st().pids.lock().unwrap().clone() }+pub(crate) fn set_extdrive_pids(p: Vec<u32>) { *extdrive::st().pids.lock().unwrap() = p; } /// Write lane.json (token + pids) so the next pup can re-attach (restore_extdrive). pub(crate) fn persist_lane(ctx: &Ctx) { let p = lane_file(ctx); if let Some(d) = p.parent() { let _ = std::fs::create_dir_all(d); }- let _ = std::fs::write(&p, json!({ "lane": EXTDRIVE_LANE.lock().unwrap().clone(), "pids": EXTDRIVE_PIDS.lock().unwrap().clone() }).to_string());+ let _ = std::fs::write(&p, json!({ "lane": extdrive::st().token.lock().unwrap().clone(), "pids": extdrive::st().pids.lock().unwrap().clone() }).to_string()); }-pub fn extdrive_browser_alive(ctx: &Ctx) -> bool { EXTDRIVE_PIDS.lock().unwrap().iter().any(|p| ctx.host.alive(*p)) }+pub fn extdrive_browser_alive(ctx: &Ctx) -> bool { extdrive::st().pids.lock().unwrap().iter().any(|p| ctx.host.alive(*p)) } pub fn kill_extdrive(ctx: &Ctx) -> u32 {- let pids: Vec<u32> = EXTDRIVE_PIDS.lock().unwrap().clone();+ let pids: Vec<u32> = extdrive::st().pids.lock().unwrap().clone(); let mut n = 0; for p in pids { if ctx.host.alive(p) { ctx.host.kill(p); n += 1; } }- EXTDRIVE_PIDS.lock().unwrap().clear();- EXTDRIVE_LANE.lock().unwrap().clear();+ extdrive::st().pids.lock().unwrap().clear();+ extdrive::st().token.lock().unwrap().clear(); extdrive::mark_dead(); extdrive::reset_worker_version(); // 2.0.647: the next hello is the truth, not the last lane's let _ = std::fs::remove_file(lane_file(ctx));@@ -115,11 +115,11 @@ pub fn kill_extdrive(ctx: &Ctx) -> u32 { /// Chrome that forwarded to the running one and exited at once ("1 process(es)" in the log), and /// pup_close_window killed nothing because it knew no pids. Measured on AdomLapper 2026-09-13: pup said /// browserRunning:false while two of its own Chrome for Testing windows sat on John's screen.-fn lane_file(ctx: &Ctx) -> std::path::PathBuf { ctx.host.state_dir().join("extlane").join("lane.json") }+fn lane_file(ctx: &Ctx) -> std::path::PathBuf { let p = extdrive::cur_profile(); ctx.host.state_dir().join(if p == "adom-you" { "extlane".to_string() } else { format!("extlane-{p}") }).join("lane.json") } fn save_lane(ctx: &Ctx) {- let lane = EXTDRIVE_LANE.lock().unwrap().clone();- let pids = EXTDRIVE_PIDS.lock().unwrap().clone();+ let lane = extdrive::st().token.lock().unwrap().clone();+ let pids = extdrive::st().pids.lock().unwrap().clone(); let p = lane_file(ctx); if let Some(d) = p.parent() { let _ = std::fs::create_dir_all(d); } let _ = std::fs::write(&p, json!({ "lane": lane, "pids": pids }).to_string());@@ -137,8 +137,8 @@ pub fn restore_extdrive(ctx: &Ctx) { let _ = std::fs::remove_file(&p); // nothing survived; do not hold a token no browser carries return; }- *EXTDRIVE_LANE.lock().unwrap() = lane.clone();- *EXTDRIVE_PIDS.lock().unwrap() = alive.clone();+ *extdrive::st().token.lock().unwrap() = lane.clone();+ *extdrive::st().pids.lock().unwrap() = alive.clone(); pup_core::log(format!("[extdrive] re-attached to the extension browser that outlived this pup ({} process(es), lane {lane})", alive.len())); } /// The consumer-identity JS shim staged into the lane extension (None when consumerBrands is off).@@ -197,7 +197,8 @@ pub fn launch_extdrive(ctx: &Ctx, url: &str) -> Result<u32, Value> { // cannot coexist with a CDP browser on the same directory. The resolution is that the lane TAKES the // profile: any CDP browser holding adom-you is ended first, and CDP re-launches on it are refused // while the lane owns it (see ext_lane_owns_profile).- let profile = "adom-you";+ let profile_owned = extdrive::cur_profile(); // 2.0.659: the lane this thread entered (adom-you unless isolated/profile)+ let profile = profile_owned.as_str(); let dir = pup_browser::profiles_dir(&*ctx.host).join(profile); // 2.0.513: publish the token AFTER kill_extdrive below, never before. 2.0.510 taught kill_extdrive to // clear EXTDRIVE_LANE (right, so a closed lane holds no token), but this line ran FIRST and the kill@@ -266,7 +267,7 @@ pub fn launch_extdrive(ctx: &Ctx, url: &str) -> Result<u32, Value> { pup_core::log(format!("[lane] ended {} orphaned browser process(es) holding profile {} (no live lane owned them); launching fresh", orphans.len(), dir.display())); } for f in ["SingletonLock", "SingletonCookie", "SingletonSocket", "lockfile"] { let _ = std::fs::remove_file(dir.join(f)); }- *EXTDRIVE_LANE.lock().unwrap() = lane.clone(); // the kill is done; this token is now the live one+ *extdrive::st().token.lock().unwrap() = lane.clone(); // the kill is done; this token is now the live one let before: Vec<u32> = ctx.host.pids_for_image(&image); let stock = ctx.settings.get("laneStockFlags", "off") == "on"; if stock { pup_core::log("[lane] laneStockFlags is ON: launching with only the firewall guards, everything else stock".to_string()); }@@ -292,7 +293,7 @@ pub fn launch_extdrive(ctx: &Ctx, url: &str) -> Result<u32, Value> { } 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;+ *extdrive::st().pids.lock().unwrap() = owned; save_lane(ctx); Ok(pid) }@@ -538,6 +539,7 @@ pub const PHASE1_VERBS: &[&str] = &["pup_describe", "pup_readiness", "pup_prewar pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { let t0 = Instant::now();+ extdrive::leave_profile(); // 2.0.659: every request starts on the default lane; lane_for / open enter another let mut r = match verb { "pup_describe" => describe(ctx), "pup_readiness" => readiness(ctx),@@ -833,7 +835,7 @@ fn close_orphan_blank_targets(ctx: &Ctx, cdp: &Cdp, profile: &str) { /// Does the extension lane currently hold this profile directory? True only while a lane browser is /// actually alive, so a dead lane never blocks a CDP open. pub fn ext_lane_owns_profile(ctx: &Ctx, profile: &str) -> bool {- profile == "adom-you" && !EXTDRIVE_LANE.lock().unwrap().is_empty() && extdrive_browser_alive(ctx)+ profile == "adom-you" && !extdrive::st().token.lock().unwrap().is_empty() && extdrive_browser_alive(ctx) } pub fn browser_for(ctx: &Ctx, profile: &str) -> Option<Arc<BrowserHandle>> { ctx.browsers.lock().unwrap().get(profile).cloned().filter(|b| b.cdp.is_alive()) }@@ -1185,9 +1187,8 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { return err("cdp_lane_disabled", "the CDP lane is off on this box; every pup window runs on the extension lane", Some("Use the extension lane (omit lane). Trusted input, HTTP auth, fetch, screenshots and recording all work there. If a feature genuinely needs CDP, file it on adom/pup-bridge as an extension-lane gap; the user can enable the lane for one box with pup_configure {cdpLane:\"on\"}.")); } if lane != "cdp" {- let needs_cdp = if profile != "adom-you" { Some(format!("profile:\"{profile}\" (a separate profile runs on the CDP lane; the extension lane is the shared adom-you browser)")) }- else if isolated { Some("isolated:true (a throwaway jar is a CDP-lane feature; see pup-bridge#112 for what it does today)".to_string()) }- else if high_fps { Some("highFps:true (anti-throttle flags are a CDP-lane feature)".to_string()) }+ // 2.0.659 (pup-bridge#112/#113): profile and isolated run on the EXTENSION lane now (a lane per profile)+ let needs_cdp = if high_fps { Some("highFps:true (anti-throttle flags are a CDP-lane feature)".to_string()) } else if insecure { Some("webSecurity:false (a CDP-lane feature)".to_string()) } else { None }; if let Some(why) = needs_cdp {@@ -1196,7 +1197,8 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { Some("The extension lane is pup's default: it is the lane every vendor site loads on, with no --remote-debugging-port for edges to detect. Pass lane:\"cdp\" ONLY if you specifically need what CDP alone can do (iframes, trusted input events, element/full-res screenshots, a separate profile, highFps, webSecurity:false); read the pup-lanes skill first. Note that lane:\"cdp\" on adom-you takes the profile away from the extension lane and closes its windows.")); } let owner = args.get("owner").and_then(|o| o.as_str()).map(|o| o.to_string()).or_else(|| if caller.thread.is_empty() { None } else { Some(caller.thread.clone()) });- return open_window_ext(ctx, caller, args, &sid, owner.as_deref(), &url);+ let mut a2 = args.clone(); a2["_mintedProfile"] = json!(profile);+ return open_window_ext(ctx, caller, &a2, &sid, owner.as_deref(), &url); } // lane:cdp on adom-you while the extension lane holds it: hand the profile back. The two lanes cannot // share one --user-data-dir (Chrome runs one process per profile), so this is symmetric with the ext@@ -1577,16 +1579,17 @@ type BirthRx = std::sync::mpsc::Receiver<(i64, bool, bool, u128, u128, Option<u1 fn birth_watch(ctx: &Ctx, caller: &Caller, sid: &str, plan_name: &str, url: &str, before: &std::collections::HashSet<i64>, taken: &std::collections::HashSet<i64>, ext: bool) -> (Option<(String, String, std::path::PathBuf)>, bool, Option<BirthRx>) { let plan = identity::plan(ctx, sid, plan_name, url); let early_ok = plan.as_ref().map(|(a, d, i)| identity::preregister(ctx, caller, a, d, i)).unwrap_or(false); // used by the watcher thread below+ let lane_pids_now: Vec<u32> = if ext { crate::extdrive_pids() } else { Vec::new() }; // 2.0.659: THIS lane's pids, read on the verb thread let watcher = ctx.arc().map(|a| { let (tx, rx) = std::sync::mpsc::channel::<(i64, bool, bool, u128, u128, Option<u128>)>();- let before2 = before.clone(); let taken2 = taken.clone(); let plan2 = plan.clone(); let sid2 = sid.to_string(); let t_w = Instant::now();+ let before2 = before.clone(); let taken2 = taken.clone(); let plan2 = plan.clone(); let sid2 = sid.to_string(); let t_w = Instant::now(); let lane_pids = lane_pids_now.clone(); std::thread::Builder::new().name("birth-watch".into()).spawn(move || { let mut found: Option<pup_host::HostWindow> = None; let mut ambiguous = 0usize; while t_w.elapsed() < Duration::from_secs(4) { // Class Chrome_WidgetWin_1 is ALSO Edge's: John's own Edge window is one, so an unfiltered // diff can stamp pup's tile onto his browser. Filter to the lane's own Chrome pids.- let pids = if ext { crate::extdrive_pids() } else { Vec::new() };+ let pids = if ext { if lane_pids.is_empty() { crate::extdrive_pids() } else { lane_pids.clone() } } else { Vec::new() }; let cands: Vec<pup_host::HostWindow> = a.host.frame_windows_all(Some("Chrome_WidgetWin_1")).into_iter() .filter(|w| !before2.contains(&w.hwnd) && !taken2.contains(&w.hwnd)) .filter(|w| pids.is_empty() || pids.contains(&w.pid))@@ -1665,7 +1668,11 @@ fn spawn_zwatch(ctx: &Ctx, sid: &str, before: std::collections::HashSet<i64>) { }).ok(); } pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owner: Option<&str>, url: &str) -> Value {- let t_verb = Instant::now(); // 2.0.648 (pup-bridge#90): the early-return budget starts here+ let t_verb = Instant::now();+ // 2.0.659 (pup-bridge#113): the lane is per profile. isolated:true minted an iso-<id> profile (passed as+ // _mintedProfile); a named profile is honoured too. Everything below talks to THAT profile's browser and queue.+ let profile_arg = args.get("_mintedProfile").and_then(|p| p.as_str()).or_else(|| args.get("profile").and_then(|p| p.as_str())).unwrap_or("adom-you").to_string();+ extdrive::enter_profile(&profile_arg); // 2.0.648 (pup-bridge#90): the early-return budget starts here if let Some(s) = ctx.sessions.get(sid) { if transport::is_ext(&s) { if url != "about:blank" { return tabs::open_tab(ctx, caller, &json!({ "window": sid, "url": url })); }@@ -1769,7 +1776,7 @@ pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owne } } };- let mut s = Session::new(sid, "adom-you", owner, Some(&caller.container), 0, None);+ let mut s = Session::new(sid, &profile_arg, owner, Some(&caller.container), 0, None); s.transport = "ext".into(); s.window_id = chrome_win; s.owner_from_caller = s.owner.as_deref() == Some(caller.thread.as_str()) && !caller.thread.is_empty() && args.get("owner").is_none(); let t = transport::ext_tab(&mut s, chrome_tab, &got_url, &got_title); s.tabs.push(t.clone()); s.active_tab = 0;@@ -2007,8 +2014,12 @@ pub fn close_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { } ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id); closed.push(s.id.clone()); // again: a late save loses // last ext window: let the lane browser go, the same rule the CDP browsers follow below- if transport::is_ext(&s) && !ctx.sessions.all().iter().any(|x| transport::is_ext(x)) {+ if transport::is_ext(&s) && !ctx.sessions.all().iter().any(|x| transport::is_ext(x) && x.profile == s.profile) {+ // 2.0.659: the LAST window of this profile's lane ends that lane's browser (the default lane's rule unchanged)+ extdrive::enter_profile(&s.profile); let n = kill_extdrive(ctx); if n > 0 { chrome_killed += 1; }+ if s.profile != "adom-you" { extdrive::drop_lane_for_profile(&s.profile); }+ extdrive::leave_profile(); } // last window on that profile: let the browser go (Node: killProfileBrowserIfUnused) if ctx.sessions.for_profile(&s.profile).is_empty() {@@ -2016,6 +2027,7 @@ pub fn close_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { // 2.0.648 (pup-bridge#112): a throwaway profile dies with its last window; the browser is ended // just below, so remove the directory after it has let go (a few tries, then leave it for prewarm). let dir = pup_browser::profiles_dir(&*ctx.host).join(&s.profile); let sid = s.id.clone();+ let staging = ctx.host.state_dir().join(format!("extlane-{}", s.profile)); let _ = std::fs::remove_dir_all(&staging); // 2.0.660: the lane's staged extension goes with it std::thread::spawn(move || { for _ in 0..10 { std::thread::sleep(Duration::from_millis(1500)); if std::fs::remove_dir_all(&dir).is_ok() || !dir.exists() { pup_core::log(format!("[profile] \"{sid}\": throwaway profile {} removed", dir.display())); return; } } pup_core::log(format!("[profile] \"{sid}\": throwaway profile {} still held; left on disk", dir.display())); }); } if let Some(b) = ctx.browsers.lock().unwrap().remove(&s.profile) {
rust/verbs/src/recovery.rs+15−1@@ -255,6 +255,20 @@ pub fn adopt_window_tabs(ctx: &Ctx, h: &Arc<BrowserHandle>) -> usize { } pub fn recover_at_boot(ctx: &Ctx) {+ // 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()));@@ -478,7 +492,7 @@ pub fn health_tick(ctx: &Ctx) { if crate::extdrive_browser_alive(ctx) && !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::LANE_WORKER.lock().unwrap() = String::new();+ crate::extdrive::reset_worker_version(); } } browser_liveness(ctx);
rust/verbs/src/transport.rs+1@@ -18,6 +18,7 @@ pub fn is_ext(s: &Session) -> bool { s.transport == "ext" } pub fn lane_for(ctx: &Ctx, s: &Session) -> Result<Lane, Value> { if is_ext(s) {+ crate::extdrive::enter_profile(&s.profile); // 2.0.659: this thread now talks to the session's own lane if crate::extdrive::alive(ctx) { Ok(Lane::Ext) } else { Err(err("browser_gone", "the extension-driven browser behind this window is not running", Some("pup_close_window then pup_open_window."))) } } else { browser_for(ctx, &s.profile).map(Lane::Cdp).ok_or_else(|| err("browser_gone", "the browser behind this window is not connected", Some("pup_close_window then pup_open_window; Phase 2 reconnects automatically.")))