From a5a88d0d2d7ab20a904a6e0a142275b63a014682 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 1 Nov 2026 11:29:32 -0500 Subject: [PATCH 01/26] =?UTF-8?q?docs(specs):=20webview=20recycle=20gate?= =?UTF-8?q?=20=E2=80=94=20recycle=20when=20unfocused=2030=20s,=20an=20'alr?= =?UTF-8?q?eady=20exists'=20rebuild=20counts=20as=20success,=20a=20per-che?= =?UTF-8?q?ck=20sweep=20kills=20leftover=20renderers=20(THI-449)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../2026-11-01-webview-recycle-gate-design.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md diff --git a/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md b/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md new file mode 100644 index 00000000..90a22385 --- /dev/null +++ b/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md @@ -0,0 +1,84 @@ +# Webview recycle gate + +The Tauri shell recycles the leaking WKWebView when you leave periscope, not only when you leave the machine, and +kills every displaced WebContent instead of leaking one when a rebuild stumbles. THI-449. + +## What the logs showed (`~/.config/periscope/shell.log`, 2026-10-26 → 11-01) + +- **The gate is too strict for a working day.** A recycle needs the renderer over `PERISCOPE_RECYCLE_GB` (1 GB) + **and** no input to any app for `PERISCOPE_RECYCLE_IDLE_S` (300 s). Recycles do run when you step away (8 on + 10-28, 11 on 10-30/31), but while you work the footprint grows unchecked: 730 one-minute checks over threshold and + blocked on idle in 36 hours, peaking at 7.7 GB. The leak comes from input inside the webview (~2.5 graphics regions + per click, `docs/tauri-shell.md`), so leaving periscope for another app is the natural moment to recycle. +- **A stumbling rebuild leaks the old renderer.** On 10-28 20:51 the rebuild's first three attempts timed out (5 s + each; the main thread was busy) but one of them did build the window: the page registered its notification + categories at 20:51:48. Attempts 4–10 then failed with "a webview with label `main` already exists", the loop gave + up, and the reaper — which only runs on the success path — never killed WebContent 41485. It is still alive at + 1.1 GB three days later. A second leftover, 37895 (7 MB, started the moment a reap ran on 10-26), is WebKit's + pre-warmed spare and harmless. + +## Decisions + +**D1 — Recycle when the periscope window has been unfocused for 30 s, or after the existing system idle.** Over +threshold AND (`unfocused_for ≥ PERISCOPE_RECYCLE_UNFOCUSED_S`, default 30 s, OR system input idle ≥ +`PERISCOPE_RECYCLE_IDLE_S`, default 300 s). The idle branch stays for periscope left frontmost while you're away. +Switching to the terminal or editor for 30 s while over 1 GB costs a ~2 s blank and page reload you are not looking +at; if periscope is visible on another screen you may glimpse it. Rejected: recycling only when the window is +hidden or occluded (periscope often stays visible on a second screen, so it would rarely fire), and a hard ceiling +that recycles under you while focused (an interruption the unfocused gate makes unnecessary). + +**D2 — One focus signal, owned by a small `focus.rs`.** Today `notifications.rs` keeps a bare `FOCUSED` boolean set +from `on_window_event`. It moves to `focus.rs`, which records the state and when it last changed: +`set_focused(bool)` (called from the same hook, `Focused(f)` → `f`, `Destroyed` → false), `is_focused()` (the +banners' quiet-while-focused check keeps using it), and `unfocused_for() -> Option` (None while focused). +The app starts unfocused-since-launch until the first `Focused(true)`. A recycle destroys the window, which counts as +unfocused, so the clock runs through the rebuild. + +**D3 — "Already exists" means an earlier attempt built the window.** In the rebuild loop, an attempt that fails +with "a webview with label `main` already exists" is the success of an attempt that timed out: the loop logs +"webview rebuilt (an earlier attempt landed)", clears `RECYCLING`, and returns, like a normal success. The timed-out +attempt's closure already restored the window state and zoom, because it ran to completion on the main thread. + +**D4 — Every check sweeps leftover WebContents; the after-recycle reaper goes.** Once per `CHECK_INTERVAL_S`, after +the live WebContent pid is refreshed, every other WebContent process the app is responsible for with a footprint of +at least 256 MB is SIGKILLed and logged. Found by listing all pids (`proc_listallpids`), keeping those whose +executable path (`proc_pidpath`) contains `WebKit.WebContent` and whose responsible pid +(`responsibility_get_pid_responsible_for_pid`, private libSystem — the same call periscope's server uses to find the +webview, `periscope/resources.py`) is this process. Never runs while the live pid is unknown (0), so the live +renderer is never a candidate. The 256 MB floor leaves WebKit's pre-warmed spare (~7 MB) alone; every displaced +renderer seen in the log was ≥ 700 MB. This one path replaces `reap_old_webcontent`'s 10 s after-recycle kill: +it covers a normal recycle (within one check, ≤ 60 s), the give-up path, and anything else that leaves a renderer +parked. The first check after the new `.app` starts kills today's 41485. + +**D5 — Policy is pure and unit-tested; the OS calls stay thin.** `should_recycle(footprint, threshold, +unfocused_for, idle, unfocused_gate, idle_gate) -> bool`, `rebuilt_by_earlier_attempt(err: &str) -> bool` and +`sweep_targets(candidates: &[(pid, footprint)], live_pid) -> Vec` hold every decision and get `cargo test` +cases next to `notifications.rs`'s and `zoom.rs`'s. The monitor loop gathers inputs and acts on their answers. + +## Changes + +- `src-tauri/src/focus.rs` (new): the focus state and its clock (D2). +- `src-tauri/src/notifications.rs`: drops `FOCUSED` / `set_focused`, reads `focus::is_focused()`. +- `src-tauri/src/main.rs`: `on_window_event` calls `focus::set_focused`; `mod focus`. +- `src-tauri/src/recycle.rs`: the unfocused gate and its env knob (D1), the "already exists" success (D3), the sweep + replacing `reap_old_webcontent` (D4), the pure policy functions and their tests (D5). The "waiting" log line names + both timers: `over threshold (N MB) but focused/unfocused Xs, input Ys ago — waiting`. +- `docs/tauri-shell.md`: the recycling paragraph describes the new gate, its knobs and the sweep. + +## Testing + +- `cargo test` in `src-tauri/`: `should_recycle` (under threshold never; over threshold with unfocused ≥ / < gate; + focused with idle ≥ / < gate), `rebuilt_by_earlier_attempt` (the exact error text; other errors), `sweep_targets` + (skips the live pid, skips < 256 MB, skips everything when live is 0, keeps ≥ 256 MB others). +- Manual, on the rebuilt `.app` (replacing the running app needs the user's go-ahead): lower the gates with + `launchctl setenv PERISCOPE_RECYCLE_UNFOCUSED_S 10` (GUI apps don't inherit shell env), switch away, and watch + `shell.log` for the recycle; confirm the first check kills 41485 if it is still alive; confirm the popover's + "webview" line (THI-448) stays near or under 1 GB through a working day. + +## Assumptions + +- `Focused(false)` fires when another app becomes frontmost and when periscope is minimized or hidden (tao's + `windowDidResignKey`). Verify in the manual test; if minimizing doesn't fire it, the idle branch still covers it. +- `proc_listallpids` and `responsibility_get_pid_responsible_for_pid` behave from the shell as they do from Python + (verified there, macOS 27). +- A displaced renderer is always ≥ 256 MB by the time a check sees it; a smaller one is left until it grows or exits. From 61e66863d9444ccd3c4608f4bc53f88db4f5b21d Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 1 Nov 2026 14:36:24 -0500 Subject: [PATCH 02/26] docs(plans): webview recycle gate implementation plan, 6 tasks (THI-449) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../plans/2026-11-01-webview-recycle-gate.md | 417 ++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-01-webview-recycle-gate.md diff --git a/docs/superpowers/plans/2026-11-01-webview-recycle-gate.md b/docs/superpowers/plans/2026-11-01-webview-recycle-gate.md new file mode 100644 index 00000000..a7bc5ee2 --- /dev/null +++ b/docs/superpowers/plans/2026-11-01-webview-recycle-gate.md @@ -0,0 +1,417 @@ +# Webview Recycle Gate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The Tauri shell recycles the leaking webview once periscope has been unfocused for 30 s (not only after 5 min of no input anywhere), treats an "already exists" rebuild as success, and sweeps leftover WebContent processes every check (THI-449). + +**Architecture:** A new `focus.rs` owns the window's focus state and how long it has been unfocused; `notifications.rs` and `recycle.rs` both read it. `recycle.rs` gets three pure, unit-tested policy functions (`should_recycle`, `rebuilt_by_earlier_attempt`, `sweep_targets`) and thin OS glue around them; the 10 s after-recycle reaper is replaced by a per-check sweep. + +**Tech Stack:** Rust (Tauri 2, objc2, libc), macOS libproc / libSystem. + +**Spec:** `docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md`. Read it, and `docs/tauri-shell.md`'s recycling paragraph, first. + +**Worktree:** `/Users/thibaultdody/dev/worktrees/periscope/thi-449-webview-recycle`, branch `thibaultdody/thi-449-webview-recycle-never-fires-while-the-user-is-active-so-the`. Rust commands run in `src-tauri/`. + +**Never** build into, copy over, quit or relaunch the running app (`/Users/thibaultdody/github/tomplex/periscope/src-tauri/target/release/bundle/macos/Periscope.app`) — that is the user's live app; Task 6 asks first. + +**Commits:** one per task step marked Commit; `area: what (THI-449)` single line, blank line, your model's `Co-Authored-By:` line. Commit only the task's paths (`git commit -m … -- `). + +--- + +## File structure + +| File | Change | Responsibility | +|---|---|---| +| `src-tauri/src/focus.rs` | create | focus state + time since it last changed | +| `src-tauri/src/notifications.rs` | modify | drop its own `FOCUSED`; read `focus::is_focused()` | +| `src-tauri/src/main.rs` | modify | `mod focus`; window events call `focus::set_focused` | +| `src-tauri/src/recycle.rs` | modify | unfocused gate, "already exists" success, sweep replacing the reaper, policy fns + tests | +| `docs/tauri-shell.md` | modify | recycling paragraph | + +--- + +### Task 1: `focus.rs` — one focus signal with a clock + +**Files:** Create `src-tauri/src/focus.rs`; modify `src-tauri/src/notifications.rs:35,74-82,411`, `src-tauri/src/main.rs:7-10,136-148`. + +- [ ] **Step 1: Write the module with its test** + +Create `src-tauri/src/focus.rs`: + +```rust +// Whether the dashboard window has focus, and since when — kept by main.rs's +// window events. Two readers on threads of their own: notification banners +// (quiet while focused; will_present runs on a thread UNUserNotificationCenter +// chooses) and the recycle monitor (recycle once unfocused long enough). +// Atomics answer both without a round trip to the main thread. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +static FOCUSED: AtomicBool = AtomicBool::new(false); +// ms since START of the last change; 0 = unfocused since launch. +static CHANGED_AT_MS: AtomicU64 = AtomicU64::new(0); +static START: OnceLock = OnceLock::new(); + +fn now_ms() -> u64 { + START.get_or_init(Instant::now).elapsed().as_millis() as u64 +} + +/// Only a real change moves the clock: a recycle's Destroyed after an earlier +/// Focused(false) must not restart the unfocused time. +pub fn set_focused(focused: bool) { + if FOCUSED.swap(focused, Ordering::Relaxed) != focused { + CHANGED_AT_MS.store(now_ms(), Ordering::Relaxed); + } +} + +pub fn is_focused() -> bool { + FOCUSED.load(Ordering::Relaxed) +} + +/// How long the window has been unfocused; None while it has focus. +pub fn unfocused_for() -> Option { + if is_focused() { + return None; + } + Some(Duration::from_millis(now_ms().saturating_sub(CHANGED_AT_MS.load(Ordering::Relaxed)))) +} + +#[cfg(test)] +mod tests { + use super::*; + + // One test: the state is process-global, and cargo runs tests in parallel. + #[test] + fn tracks_focus_and_how_long_it_has_been_lost() { + set_focused(true); + assert!(is_focused()); + assert_eq!(unfocused_for(), None); + + set_focused(false); + assert!(!is_focused()); + std::thread::sleep(Duration::from_millis(30)); + let first = unfocused_for().expect("unfocused"); + assert!(first >= Duration::from_millis(30)); + + // A repeated false (a recycle's Destroyed) keeps the clock running. + set_focused(false); + assert!(unfocused_for().expect("unfocused") >= first); + + set_focused(true); + assert_eq!(unfocused_for(), None); + } +} +``` + +- [ ] **Step 2: Rewire the callers** + +`src-tauri/src/main.rs`: add `mod focus;` to the `mod` list (alphabetical, before `mod notifications;`), and in `on_window_event` replace the two `notifications::set_focused(…)` calls with `focus::set_focused(…)` (same arguments: `*focused` and `false`). Keep the comment above `on_window_event`, but make its first clause say the atomic lives in `focus.rs`. + +`src-tauri/src/notifications.rs`: delete the `FOCUSED` static, its doc comment and `pub fn set_focused` (lines ~74–82); remove `AtomicBool` from the `use std::sync::atomic::{…}` import; in `will_present` replace `FOCUSED.load(Ordering::Relaxed)` with `crate::focus::is_focused()`. + +- [ ] **Step 3: Build and test** + +Run (in `src-tauri/`): `cargo test --quiet 2>&1 | tail -5` +Expected: all tests pass, including `focus::tests::tracks_focus_and_how_long_it_has_been_lost`; no `unused import` warnings (`cargo build 2>&1 | grep -c warning` → 0 new warnings vs. before). + +- [ ] **Step 4: Commit** + +```bash +git commit -m "shell: focus.rs owns the window's focus and how long it has been lost; notifications reads it (THI-449)" -- src-tauri/src/focus.rs src-tauri/src/notifications.rs src-tauri/src/main.rs +``` + +--- + +### Task 2: the unfocused gate + +**Files:** Modify `src-tauri/src/recycle.rs` (header comment, env knobs, `start_monitor`, new `should_recycle` + tests). + +- [ ] **Step 1: Write the failing tests** + +Append to `src-tauri/src/recycle.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + const GB: u64 = 1024 * 1024 * 1024; + + fn recycles(footprint: u64, unfocused_s: Option, idle_s: f64) -> bool { + should_recycle(footprint, GB, unfocused_s.map(Duration::from_secs), idle_s, 30.0, 300.0) + } + + #[test] + fn under_threshold_never_recycles() { + assert!(!recycles(GB - 1, Some(3600), 3600.0)); + } + + #[test] + fn unfocused_long_enough_recycles() { + assert!(recycles(2 * GB, Some(30), 0.0)); + assert!(!recycles(2 * GB, Some(29), 0.0)); + } + + #[test] + fn focused_waits_for_system_idle() { + assert!(!recycles(2 * GB, None, 299.0)); + assert!(recycles(2 * GB, None, 300.0)); + } +} +``` + +Run: `cargo test --quiet recycle 2>&1 | tail -5` — Expected: FAIL to compile, `cannot find function should_recycle`. + +- [ ] **Step 2: Implement** + +In `recycle.rs`, next to `idle_gate_s()`: + +```rust +fn unfocused_gate_s() -> f64 { + std::env::var("PERISCOPE_RECYCLE_UNFOCUSED_S") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(30.0) +} + +/// Over the threshold, and either periscope has been left for another app +/// (the leak grows from input inside it, so leaving is the moment) or the +/// whole machine has gone idle (periscope left frontmost while you're away). +fn should_recycle( + footprint: u64, + threshold: u64, + unfocused_for: Option, + idle_s: f64, + unfocused_gate_s: f64, + idle_gate_s: f64, +) -> bool { + footprint >= threshold + && (unfocused_for.is_some_and(|d| d.as_secs_f64() >= unfocused_gate_s) || idle_s >= idle_gate_s) +} +``` + +In `start_monitor`: read `let unfocused_needed = unfocused_gate_s();` next to `idle_needed`, include it in the "monitor started" log (`… idle gate {idle_needed}s, unfocused gate {unfocused_needed}s`), and replace the block from `if fp < threshold {` through the "waiting for idle" `continue;` with: + +```rust + if fp < threshold { + continue; + } + let unfocused = crate::focus::unfocused_for(); + let idle = seconds_since_last_input(); + if !should_recycle(fp, threshold, unfocused, idle, unfocused_needed, idle_needed) { + let focus = match unfocused { + Some(d) => format!("unfocused {}s", d.as_secs()), + None => "focused".to_string(), + }; + shell_log(&format!( + "[recycle] over threshold ({} MB) but {focus}, input {}s ago — waiting", + fp / (1024 * 1024), + idle as u64 + )); + continue; + } +``` + +Update the file's header comment "Policy:" paragraph: over `PERISCOPE_RECYCLE_GB` (default 1.0) AND (periscope unfocused for `PERISCOPE_RECYCLE_UNFOCUSED_S`, default 30 s, OR system input idle for `PERISCOPE_RECYCLE_IDLE_S`, default 300 s). Keep it to the paragraph's current length. + +- [ ] **Step 3: Test** + +Run: `cargo test --quiet 2>&1 | tail -5` — Expected: all pass. + +- [ ] **Step 4: Commit** + +```bash +git commit -m "shell: recycle once periscope has been unfocused 30 s, not only after 5 min of no input anywhere (THI-449)" -- src-tauri/src/recycle.rs +``` + +--- + +### Task 3: an "already exists" rebuild counts as rebuilt + +**Files:** Modify `src-tauri/src/recycle.rs` (`recycle_now`'s rebuild loop, new `rebuilt_by_earlier_attempt` + test). + +- [ ] **Step 1: Write the failing test** — add inside `mod tests`: + +```rust + #[test] + fn already_exists_means_an_earlier_attempt_landed() { + // Tauri's text, as logged on 2026-10-28 20:51. + assert!(rebuilt_by_earlier_attempt("a webview with label `main` already exists")); + assert!(!rebuilt_by_earlier_attempt("window creation failed")); + } +``` + +Run: `cargo test --quiet already_exists 2>&1 | tail -3` — Expected: FAIL to compile. + +- [ ] **Step 2: Implement** + +```rust +/// A rebuild attempt that timed out still runs on the main thread and builds +/// the window; the next attempt then fails with this. Seen 2026-10-28 20:51: +/// three timeouts, the page loaded, seven "already exists", give-up — and the +/// old renderer was never killed. +fn rebuilt_by_earlier_attempt(err: &str) -> bool { + err.contains("already exists") +} +``` + +In the rebuild loop's `match rx.recv_timeout(…)`, add an arm before the generic `Ok(Err(e))` one: + +```rust + Ok(Err(e)) if rebuilt_by_earlier_attempt(&e) => { + shell_log("[recycle] webview rebuilt (an earlier attempt landed)"); + RECYCLING.store(false, Ordering::SeqCst); + return; + } +``` + +(Task 4 removes the `reap_old_webcontent(old_pid)` call from the plain-success arm; until then leave it.) + +- [ ] **Step 3: Test** — `cargo test --quiet 2>&1 | tail -5` — Expected: all pass. + +- [ ] **Step 4: Commit** + +```bash +git commit -m "shell: a rebuild that finds the window already built counts as rebuilt, not a give-up that leaks the old renderer (THI-449)" -- src-tauri/src/recycle.rs +``` + +--- + +### Task 4: sweep leftover WebContents every check + +**Files:** Modify `src-tauri/src/recycle.rs` (replace `reap_old_webcontent`; new `sweep_targets`, `app_webcontents`, `sweep`; call from `start_monitor`). + +- [ ] **Step 1: Write the failing test** — add inside `mod tests`: + +```rust + const MB: u64 = 1024 * 1024; + + #[test] + fn sweep_takes_big_renderers_other_than_the_live_one() { + let candidates = [(10, 2048 * MB), (11, 7 * MB), (12, 300 * MB), (13, 255 * MB)]; + assert_eq!(sweep_targets(&candidates, 10), vec![12]); + } + + #[test] + fn sweep_does_nothing_while_the_live_renderer_is_unknown() { + assert!(sweep_targets(&[(12, 4096 * MB)], 0).is_empty()); + } +``` + +Run: `cargo test --quiet sweep 2>&1 | tail -3` — Expected: FAIL to compile. + +- [ ] **Step 2: Implement** + +Replace the whole `reap_old_webcontent` function (and its comment) with: + +```rust +// WebKit parks a displaced WebContent in its process cache at full leaked +// size instead of exiting it (observed alive at 480MB 90s after its window +// was destroyed). Every check kills each WebContent this app is responsible +// for, other than the live one, at or above SWEEP_FLOOR. The floor leaves +// WebKit's pre-warmed spare (~7 MB) alone; every displaced renderer in the +// log was ≥ 700 MB. One path covers a normal recycle, a rebuild that gave +// up, and anything else that leaves one behind. +const SWEEP_FLOOR: u64 = 256 * 1024 * 1024; + +fn sweep_targets(candidates: &[(i32, u64)], live_pid: i32) -> Vec { + if live_pid <= 0 { + return Vec::new(); // the live renderer could be among them + } + candidates + .iter() + .filter(|(pid, footprint)| *pid != live_pid && *footprint >= SWEEP_FLOOR) + .map(|(pid, _)| *pid) + .collect() +} + +extern "C" { + fn proc_listallpids(buffer: *mut std::ffi::c_void, buffersize: i32) -> i32; + // Private libSystem: the process macOS holds responsible — for a WebKit + // helper, the app hosting the webview (periscope/resources.py uses it too). + fn responsibility_get_pid_responsible_for_pid(pid: i32) -> i32; +} + +/// (pid, footprint) of every WebContent process this app is responsible for. +fn app_webcontents() -> Vec<(i32, u64)> { + let me = std::process::id() as i32; + let mut pids = vec![0i32; 8192]; + let n = unsafe { + proc_listallpids(pids.as_mut_ptr() as *mut std::ffi::c_void, (pids.len() * 4) as i32) + }; + pids.truncate(n.max(0) as usize); + pids.into_iter() + .filter(|&pid| pid > 0 && unsafe { responsibility_get_pid_responsible_for_pid(pid) } == me) + .filter(|&pid| { + let mut buf = [0u8; 4096]; + let len = unsafe { + proc_pidpath(pid, buf.as_mut_ptr() as *mut std::ffi::c_void, buf.len() as u32) + }; + len > 0 && String::from_utf8_lossy(&buf[..len as usize]).contains("WebKit.WebContent") + }) + .filter_map(|pid| phys_footprint(pid).map(|fp| (pid, fp))) + .collect() +} + +fn sweep(live_pid: i32) { + for pid in sweep_targets(&app_webcontents(), live_pid) { + let fp = phys_footprint(pid).unwrap_or(0) / (1024 * 1024); + unsafe { libc::kill(pid, libc::SIGKILL) }; + shell_log(&format!("[recycle] killed leftover WebContent {pid} ({fp} MB)")); + } +} +``` + +Remove the `reap_old_webcontent(old_pid);` call in `recycle_now`'s success arm. `old_pid` is still used for the "destroying webview" log line — keep it. + +In `start_monitor`, right after `let pid = WEBCONTENT_PID.load(Ordering::Relaxed);`, add: + +```rust + // Mid-recycle the stored pid is the renderer being torn down. + if !RECYCLING.load(Ordering::SeqCst) { + sweep(pid); + } +``` + +- [ ] **Step 3: Test** — `cargo test --quiet 2>&1 | tail -5` and `cargo build 2>&1 | grep -E '^(warning|error)' | sort | uniq -c` — Expected: all pass; no new warnings (in particular no "unused" for `proc_pidpath` or `old_pid`). + +- [ ] **Step 4: Commit** + +```bash +git commit -m "shell: every check kills leftover WebContent renderers the app owns, replacing the after-recycle reaper (THI-449)" -- src-tauri/src/recycle.rs +``` + +--- + +### Task 5: docs + +**Files:** Modify `docs/tauri-shell.md` (the **Webview recycling** paragraph). + +- [ ] **Step 1: Rewrite the gate and reaper sentences** so the paragraph says: the shell recycles when the renderer's phys_footprint tops `PERISCOPE_RECYCLE_GB` (default 1.0) AND either periscope has been unfocused for `PERISCOPE_RECYCLE_UNFOCUSED_S` (default 30 s, tracked in `focus.rs`) or system input has been idle `PERISCOPE_RECYCLE_IDLE_S` (default 300 s); a rebuild attempt that fails with "already exists" counts as rebuilt (a timed-out earlier attempt built it); and every check SIGKILLs any WebContent the app is responsible for, other than the live one, at ≥ 256 MB (WebKit parks displaced renderers at full size; the floor spares its ~7 MB pre-warmed one). Keep the two existing invariants (later-tick rebuild + ExitRequested veto) and the `launchctl setenv` testing note; add `PERISCOPE_RECYCLE_UNFOCUSED_S` to it. + +- [ ] **Step 2: Commit** + +```bash +git commit -m "docs: tauri-shell describes the unfocused recycle gate and the leftover-renderer sweep (THI-449)" -- docs/tauri-shell.md +``` + +--- + +### Task 6: build and verify on the real app (needs the user's go-ahead) + +- [ ] **Step 1: Release build in the worktree** (its own `target/`; the running app is untouched): + +Run (in `src-tauri/`): `cargo tauri build 2>&1 | tail -5` +Expected: a bundle at `src-tauri/target/release/bundle/macos/Periscope.app` inside the **worktree**. + +- [ ] **Step 2: Ask the user** before swapping apps: quitting the running Periscope.app and opening the worktree's bundle. The single-instance plugin focuses an already-running app instead of starting a second one, so the old one must quit first. + +- [ ] **Step 3: After the go-ahead** — quit the running app (`osascript -e 'quit app "Periscope"'`), `open` the worktree bundle, then verify from `~/.config/periscope/shell.log`: + - `monitor started (threshold 1024 MB, idle gate 300s, unfocused gate 30s)`; + - within ~60 s: `killed leftover WebContent 41485 (… MB)` if it is still alive (`ps -p 41485`); + - while over 1 GB, switching to another app for > 30 s produces `destroying webview … webview rebuilt`; while focused it logs `… but focused, input …s ago — waiting`. + - Check whether minimizing the window produces a recycle (spec assumption: tao sends `Focused(false)`); record the answer in the PR. + +- [ ] **Step 4: Hand off** — report results; ask before pushing and opening the PR (title `THI-449: …`). After merge, the user's long-lived app is rebuilt from the prod checkout (`cargo tauri build` there), which is the same swap and needs the same go-ahead. From c17467dff85d577882d4053e93e6f1c7e747b24d Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 1 Nov 2026 16:53:43 -0500 Subject: [PATCH 03/26] =?UTF-8?q?docs:=20recycle-gate=20spec=20and=20plan?= =?UTF-8?q?=20after=20adversarial=20review=20=E2=80=94=2030=20min=20minimu?= =?UTF-8?q?m=20age=20and=202=20GB=20threshold,=20one=20rebuild=20channel,?= =?UTF-8?q?=20no=20focus=20theft,=20sweep=20only=20with=20a=20fresh=20pid?= =?UTF-8?q?=20(THI-449)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../plans/2026-11-01-webview-recycle-gate.md | 425 ++++++++++-------- .../2026-11-01-webview-recycle-gate-design.md | 165 ++++--- 2 files changed, 346 insertions(+), 244 deletions(-) diff --git a/docs/superpowers/plans/2026-11-01-webview-recycle-gate.md b/docs/superpowers/plans/2026-11-01-webview-recycle-gate.md index a7bc5ee2..77fcac34 100644 --- a/docs/superpowers/plans/2026-11-01-webview-recycle-gate.md +++ b/docs/superpowers/plans/2026-11-01-webview-recycle-gate.md @@ -2,19 +2,19 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** The Tauri shell recycles the leaking webview once periscope has been unfocused for 30 s (not only after 5 min of no input anywhere), treats an "already exists" rebuild as success, and sweeps leftover WebContent processes every check (THI-449). +**Goal:** The Tauri shell recycles the leaking webview once periscope has been unfocused for 30 s (or the machine is idle), never within 30 min of the last rebuild and only above 2 GB, without pulling focus; rebuild attempts share one result channel; a per-check sweep kills leftover renderers (THI-449). -**Architecture:** A new `focus.rs` owns the window's focus state and how long it has been unfocused; `notifications.rs` and `recycle.rs` both read it. `recycle.rs` gets three pure, unit-tested policy functions (`should_recycle`, `rebuilt_by_earlier_attempt`, `sweep_targets`) and thin OS glue around them; the 10 s after-recycle reaper is replaced by a per-check sweep. +**Architecture:** A new `focus.rs` owns the window's focus state (one mutex: Unknown / Focused / UnfocusedSince). `recycle.rs` gets a pure `should_recycle(Inputs)` and `sweep_targets`, a rebuild that shares one channel across attempts and restores everything but visibility, a pid read that answers on a channel, and a sweep replacing the 10 s reaper. -**Tech Stack:** Rust (Tauri 2, objc2, libc), macOS libproc / libSystem. +**Tech Stack:** Rust (Tauri 2, tao, tauri-plugin-window-state 2.4, objc2, libc), macOS libproc / libSystem. -**Spec:** `docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md`. Read it, and `docs/tauri-shell.md`'s recycling paragraph, first. +**Spec:** `docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md`. Read it, and `docs/tauri-shell.md`'s recycling paragraph (two hard-won invariants: rebuild on a LATER tick than destroy; the ExitRequested veto while `RECYCLING`), first. -**Worktree:** `/Users/thibaultdody/dev/worktrees/periscope/thi-449-webview-recycle`, branch `thibaultdody/thi-449-webview-recycle-never-fires-while-the-user-is-active-so-the`. Rust commands run in `src-tauri/`. +**Worktree:** `/Users/thibaultdody/dev/worktrees/periscope/thi-449-webview-recycle`, branch `thibaultdody/thi-449-webview-recycle-never-fires-while-the-user-is-active-so-the`. Rust commands run in `src-tauri/` (`cargo test --quiet`: 30 tests at the start, ~40 s cold). -**Never** build into, copy over, quit or relaunch the running app (`/Users/thibaultdody/github/tomplex/periscope/src-tauri/target/release/bundle/macos/Periscope.app`) — that is the user's live app; Task 6 asks first. +**Never** build into, copy over, quit, relaunch or signal the running app (`/Users/thibaultdody/github/tomplex/periscope/src-tauri/target/release/bundle/macos/Periscope.app`) or its WebContent processes — Task 6 asks first. -**Commits:** one per task step marked Commit; `area: what (THI-449)` single line, blank line, your model's `Co-Authored-By:` line. Commit only the task's paths (`git commit -m … -- `). +**Commits:** `area: what (THI-449)` single line, blank line, your model's `Co-Authored-By:` line; commit only the task's paths (`git commit -m … -- `). --- @@ -22,69 +22,76 @@ | File | Change | Responsibility | |---|---|---| -| `src-tauri/src/focus.rs` | create | focus state + time since it last changed | -| `src-tauri/src/notifications.rs` | modify | drop its own `FOCUSED`; read `focus::is_focused()` | +| `src-tauri/src/focus.rs` | create | focus state + how long it has been lost | +| `src-tauri/src/notifications.rs` | modify | drop `FOCUSED`; read `focus::is_focused()` | | `src-tauri/src/main.rs` | modify | `mod focus`; window events call `focus::set_focused` | -| `src-tauri/src/recycle.rs` | modify | unfocused gate, "already exists" success, sweep replacing the reaper, policy fns + tests | -| `docs/tauri-shell.md` | modify | recycling paragraph | +| `src-tauri/src/recycle.rs` | modify | gate + knobs, renderer age, shared-channel rebuild, focus-preserving restore, answered pid read, sweep, tests | +| `docs/tauri-shell.md`, `static/src/notify/persist.js` | modify | recycling paragraph; header comment | --- -### Task 1: `focus.rs` — one focus signal with a clock +### Task 1: `focus.rs` -**Files:** Create `src-tauri/src/focus.rs`; modify `src-tauri/src/notifications.rs:35,74-82,411`, `src-tauri/src/main.rs:7-10,136-148`. +**Files:** Create `src-tauri/src/focus.rs`; modify `src-tauri/src/notifications.rs` (~35, ~74–82, ~411), `src-tauri/src/main.rs` (mod list ~7–10, `on_window_event` ~136–148). -- [ ] **Step 1: Write the module with its test** - -Create `src-tauri/src/focus.rs`: +- [ ] **Step 1: Create the module with its test** ```rust -// Whether the dashboard window has focus, and since when — kept by main.rs's -// window events. Two readers on threads of their own: notification banners +// Whether the dashboard window has focus, and since when it hasn't — kept by +// main.rs's window events. Read off the main thread by notification banners // (quiet while focused; will_present runs on a thread UNUserNotificationCenter -// chooses) and the recycle monitor (recycle once unfocused long enough). -// Atomics answer both without a round trip to the main thread. +// chooses) and by the recycle monitor (recycle once periscope is left). One +// mutex, so a reader never pairs "unfocused" with a stale time. -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::OnceLock; +use std::sync::Mutex; use std::time::{Duration, Instant}; -static FOCUSED: AtomicBool = AtomicBool::new(false); -// ms since START of the last change; 0 = unfocused since launch. -static CHANGED_AT_MS: AtomicU64 = AtomicU64::new(0); -static START: OnceLock = OnceLock::new(); +#[derive(Clone, Copy, PartialEq, Debug)] +enum Focus { + Unknown, // launch, before the first window event + Focused, + UnfocusedSince(Instant), +} + +static FOCUS: Mutex = Mutex::new(Focus::Unknown); -fn now_ms() -> u64 { - START.get_or_init(Instant::now).elapsed().as_millis() as u64 +fn state() -> Focus { + *FOCUS.lock().unwrap_or_else(|e| e.into_inner()) } /// Only a real change moves the clock: a recycle's Destroyed after an earlier -/// Focused(false) must not restart the unfocused time. +/// Focused(false) keeps the unfocused time running. pub fn set_focused(focused: bool) { - if FOCUSED.swap(focused, Ordering::Relaxed) != focused { - CHANGED_AT_MS.store(now_ms(), Ordering::Relaxed); - } + let mut f = FOCUS.lock().unwrap_or_else(|e| e.into_inner()); + *f = match (*f, focused) { + (_, true) => Focus::Focused, + (Focus::UnfocusedSince(t), false) => Focus::UnfocusedSince(t), + (_, false) => Focus::UnfocusedSince(Instant::now()), + }; } pub fn is_focused() -> bool { - FOCUSED.load(Ordering::Relaxed) + state() == Focus::Focused } -/// How long the window has been unfocused; None while it has focus. +/// How long periscope has been unfocused; None while focused or not yet known. pub fn unfocused_for() -> Option { - if is_focused() { - return None; + match state() { + Focus::UnfocusedSince(t) => Some(t.elapsed()), + _ => None, } - Some(Duration::from_millis(now_ms().saturating_sub(CHANGED_AT_MS.load(Ordering::Relaxed)))) } #[cfg(test)] mod tests { use super::*; - // One test: the state is process-global, and cargo runs tests in parallel. + // One test: the state is process-global and cargo runs tests in parallel. #[test] fn tracks_focus_and_how_long_it_has_been_lost() { + assert_eq!(unfocused_for(), None); // Unknown at start + assert!(!is_focused()); + set_focused(true); assert!(is_focused()); assert_eq!(unfocused_for(), None); @@ -95,8 +102,7 @@ mod tests { let first = unfocused_for().expect("unfocused"); assert!(first >= Duration::from_millis(30)); - // A repeated false (a recycle's Destroyed) keeps the clock running. - set_focused(false); + set_focused(false); // a recycle's Destroyed assert!(unfocused_for().expect("unfocused") >= first); set_focused(true); @@ -106,31 +112,20 @@ mod tests { ``` - [ ] **Step 2: Rewire the callers** + - `main.rs`: add `mod focus;` before `mod notifications;`. In `on_window_event` replace `notifications::set_focused(*focused)` and `notifications::set_focused(false)` with `focus::set_focused(…)`. In the comment above it, say the atomic now lives in `focus.rs` (keep the Destroyed explanation). + - `notifications.rs`: delete the `FOCUSED` static with its doc comment and `pub fn set_focused`; drop `AtomicBool` from the `use std::sync::atomic::{…}` line (`AtomicUsize`, `Ordering` stay for `SEQ`); in `will_present` use `crate::focus::is_focused()`. -`src-tauri/src/main.rs`: add `mod focus;` to the `mod` list (alphabetical, before `mod notifications;`), and in `on_window_event` replace the two `notifications::set_focused(…)` calls with `focus::set_focused(…)` (same arguments: `*focused` and `false`). Keep the comment above `on_window_event`, but make its first clause say the atomic lives in `focus.rs`. - -`src-tauri/src/notifications.rs`: delete the `FOCUSED` static, its doc comment and `pub fn set_focused` (lines ~74–82); remove `AtomicBool` from the `use std::sync::atomic::{…}` import; in `will_present` replace `FOCUSED.load(Ordering::Relaxed)` with `crate::focus::is_focused()`. +- [ ] **Step 3: Test** — `cargo test --quiet 2>&1 | tail -4` → 31 passed; `cargo build 2>&1 | grep -c '^warning'` → same count as before the change. -- [ ] **Step 3: Build and test** - -Run (in `src-tauri/`): `cargo test --quiet 2>&1 | tail -5` -Expected: all tests pass, including `focus::tests::tracks_focus_and_how_long_it_has_been_lost`; no `unused import` warnings (`cargo build 2>&1 | grep -c warning` → 0 new warnings vs. before). - -- [ ] **Step 4: Commit** - -```bash -git commit -m "shell: focus.rs owns the window's focus and how long it has been lost; notifications reads it (THI-449)" -- src-tauri/src/focus.rs src-tauri/src/notifications.rs src-tauri/src/main.rs -``` +- [ ] **Step 4: Commit** — `git commit -m "shell: focus.rs owns the window's focus and how long it has been lost; notifications reads it (THI-449)" -- src-tauri/src/focus.rs src-tauri/src/notifications.rs src-tauri/src/main.rs` --- -### Task 2: the unfocused gate - -**Files:** Modify `src-tauri/src/recycle.rs` (header comment, env knobs, `start_monitor`, new `should_recycle` + tests). +### Task 2: the gate — unfocused, minimum age, 2 GB -- [ ] **Step 1: Write the failing tests** +**Files:** Modify `src-tauri/src/recycle.rs` (header comment, knobs, a renderer-born clock, `start_monitor`, `should_recycle` + tests). -Append to `src-tauri/src/recycle.rs`: +- [ ] **Step 1: Failing tests** — append to `recycle.rs`: ```rust #[cfg(test)] @@ -138,152 +133,199 @@ mod tests { use super::*; const GB: u64 = 1024 * 1024 * 1024; - - fn recycles(footprint: u64, unfocused_s: Option, idle_s: f64) -> bool { - should_recycle(footprint, GB, unfocused_s.map(Duration::from_secs), idle_s, 30.0, 300.0) + const MIN: u64 = 60; + + fn inputs(footprint: u64, age_s: u64, unfocused_s: Option, idle_s: u64) -> Inputs { + Inputs { + footprint, + threshold: 2 * GB, + age: Duration::from_secs(age_s), + min_age: Duration::from_secs(30 * MIN), + unfocused_for: unfocused_s.map(Duration::from_secs), + unfocused_gate: Duration::from_secs(30), + idle: Duration::from_secs(idle_s), + idle_gate: Duration::from_secs(300), + } } #[test] fn under_threshold_never_recycles() { - assert!(!recycles(GB - 1, Some(3600), 3600.0)); + assert!(!should_recycle(&inputs(2 * GB - 1, 3600, Some(3600), 3600))); + } + + #[test] + fn a_young_renderer_never_recycles() { + // A fresh dashboard measures 1.1–1.6 GB at once; without this the gate loops. + assert!(!should_recycle(&inputs(3 * GB, 30 * MIN - 1, Some(3600), 3600))); } #[test] fn unfocused_long_enough_recycles() { - assert!(recycles(2 * GB, Some(30), 0.0)); - assert!(!recycles(2 * GB, Some(29), 0.0)); + assert!(should_recycle(&inputs(3 * GB, 30 * MIN, Some(30), 0))); + assert!(!should_recycle(&inputs(3 * GB, 30 * MIN, Some(29), 0))); } #[test] - fn focused_waits_for_system_idle() { - assert!(!recycles(2 * GB, None, 299.0)); - assert!(recycles(2 * GB, None, 300.0)); + fn focused_or_unknown_waits_for_system_idle() { + assert!(!should_recycle(&inputs(3 * GB, 30 * MIN, None, 299))); + assert!(should_recycle(&inputs(3 * GB, 30 * MIN, None, 300))); } } ``` -Run: `cargo test --quiet recycle 2>&1 | tail -5` — Expected: FAIL to compile, `cannot find function should_recycle`. +`cargo test --quiet recycle 2>&1 | tail -3` → fails to compile (`Inputs`, `should_recycle`). - [ ] **Step 2: Implement** -In `recycle.rs`, next to `idle_gate_s()`: +Knobs (replace `threshold_bytes`'s default and add two): ```rust -fn unfocused_gate_s() -> f64 { - std::env::var("PERISCOPE_RECYCLE_UNFOCUSED_S") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(30.0) +fn env_f64(name: &str, default: f64) -> f64 { + std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default) } -/// Over the threshold, and either periscope has been left for another app -/// (the leak grows from input inside it, so leaving is the moment) or the -/// whole machine has gone idle (periscope left frontmost while you're away). -fn should_recycle( +// A fresh dashboard already measures 1.1–1.6 GB; leaked renderers reach 3–7.7. +fn threshold_bytes() -> u64 { + (env_f64("PERISCOPE_RECYCLE_GB", 2.0) * 1024.0 * 1024.0 * 1024.0) as u64 +} +``` + +and make `idle_gate_s` use `env_f64("PERISCOPE_RECYCLE_IDLE_S", 300.0)`; add `PERISCOPE_RECYCLE_UNFOCUSED_S` (30.0) and `PERISCOPE_RECYCLE_MIN_AGE_S` (1800.0) the same way. (One helper replaces the two copies of the parse.) + +Renderer age: + +```rust +// When the current renderer was built (the app's start, then each rebuild): +// D1's minimum age, so a fresh renderer that is already big can't loop. +static RENDERER_BORN: Mutex> = Mutex::new(None); + +fn mark_renderer_born() { + *RENDERER_BORN.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now()); +} + +fn renderer_age() -> Duration { + RENDERER_BORN.lock().unwrap_or_else(|e| e.into_inner()).map_or(Duration::ZERO, |t| t.elapsed()) +} +``` + +Policy: + +```rust +struct Inputs { footprint: u64, threshold: u64, + age: Duration, + min_age: Duration, unfocused_for: Option, - idle_s: f64, - unfocused_gate_s: f64, - idle_gate_s: f64, -) -> bool { - footprint >= threshold - && (unfocused_for.is_some_and(|d| d.as_secs_f64() >= unfocused_gate_s) || idle_s >= idle_gate_s) + unfocused_gate: Duration, + idle: Duration, + idle_gate: Duration, +} + +/// Big enough, old enough, and either periscope has been left for another app +/// (the leak grows from input inside it) or the machine has gone idle. +fn should_recycle(i: &Inputs) -> bool { + i.footprint >= i.threshold + && i.age >= i.min_age + && (i.unfocused_for.is_some_and(|d| d >= i.unfocused_gate) || i.idle >= i.idle_gate) } ``` -In `start_monitor`: read `let unfocused_needed = unfocused_gate_s();` next to `idle_needed`, include it in the "monitor started" log (`… idle gate {idle_needed}s, unfocused gate {unfocused_needed}s`), and replace the block from `if fp < threshold {` through the "waiting for idle" `continue;` with: +Imports: add `use std::sync::Mutex;` and `use std::time::Instant;` (keep `Duration`). + +`start_monitor`: call `mark_renderer_born()` once before the loop; read the four knobs into `Duration`s (`Duration::from_secs_f64`) and log them in "monitor started (threshold … MB, min age …s, unfocused gate …s, idle gate …s)". Replace the block from `if fp < threshold {` through the "waiting for idle" `continue;` with: ```rust if fp < threshold { continue; } - let unfocused = crate::focus::unfocused_for(); - let idle = seconds_since_last_input(); - if !should_recycle(fp, threshold, unfocused, idle, unfocused_needed, idle_needed) { - let focus = match unfocused { + let inputs = Inputs { + footprint: fp, + threshold, + age: renderer_age(), + min_age, + unfocused_for: crate::focus::unfocused_for(), + unfocused_gate, + idle: Duration::from_secs_f64(seconds_since_last_input().max(0.0)), + idle_gate, + }; + if !should_recycle(&inputs) { + let focus = match inputs.unfocused_for { Some(d) => format!("unfocused {}s", d.as_secs()), None => "focused".to_string(), }; shell_log(&format!( - "[recycle] over threshold ({} MB) but {focus}, input {}s ago — waiting", + "[recycle] over threshold ({} MB), renderer {}s old, {focus}, input {}s ago — waiting", fp / (1024 * 1024), - idle as u64 + inputs.age.as_secs(), + inputs.idle.as_secs() )); continue; } ``` -Update the file's header comment "Policy:" paragraph: over `PERISCOPE_RECYCLE_GB` (default 1.0) AND (periscope unfocused for `PERISCOPE_RECYCLE_UNFOCUSED_S`, default 30 s, OR system input idle for `PERISCOPE_RECYCLE_IDLE_S`, default 300 s). Keep it to the paragraph's current length. +Rewrite the header comment's "Policy:" paragraph to D1 (threshold 2 GB, min age 1800 s, unfocused 30 s OR idle 300 s, each with its env var), same length as now. -- [ ] **Step 3: Test** - -Run: `cargo test --quiet 2>&1 | tail -5` — Expected: all pass. - -- [ ] **Step 4: Commit** - -```bash -git commit -m "shell: recycle once periscope has been unfocused 30 s, not only after 5 min of no input anywhere (THI-449)" -- src-tauri/src/recycle.rs -``` +- [ ] **Step 3: Test** — `cargo test --quiet 2>&1 | tail -4` → all pass (35). +- [ ] **Step 4: Commit** — `git commit -m "shell: recycle once periscope has been unfocused 30 s, never within 30 min of a rebuild, above 2 GB (THI-449)" -- src-tauri/src/recycle.rs` --- -### Task 3: an "already exists" rebuild counts as rebuilt - -**Files:** Modify `src-tauri/src/recycle.rs` (`recycle_now`'s rebuild loop, new `rebuilt_by_earlier_attempt` + test). +### Task 3: the rebuild — one channel, no focus theft -- [ ] **Step 1: Write the failing test** — add inside `mod tests`: - -```rust - #[test] - fn already_exists_means_an_earlier_attempt_landed() { - // Tauri's text, as logged on 2026-10-28 20:51. - assert!(rebuilt_by_earlier_attempt("a webview with label `main` already exists")); - assert!(!rebuilt_by_earlier_attempt("window creation failed")); - } -``` +**Files:** Modify `src-tauri/src/recycle.rs` (`recycle_now`). -Run: `cargo test --quiet already_exists 2>&1 | tail -3` — Expected: FAIL to compile. +No unit test: this is main-thread/OS orchestration; Task 6 verifies it on the real app. -- [ ] **Step 2: Implement** +- [ ] **Step 1: Implement** + - At the top of `recycle_now` (after the `RECYCLING.swap` guard), capture `let was_focused = crate::focus::is_focused();` — before `destroy()`, which sets focus to false. + - In the spawned thread, create ONE channel before the loop, with this comment: ```rust -/// A rebuild attempt that timed out still runs on the main thread and builds -/// the window; the next attempt then fails with this. Seen 2026-10-28 20:51: -/// three timeouts, the page loaded, seven "already exists", give-up — and the -/// old renderer was never killed. -fn rebuilt_by_earlier_attempt(err: &str) -> bool { - err.contains("already exists") -} + // One channel for every attempt. The main thread runs the closures in + // order, so an attempt that timed out but later built the window sends + // its Ok ahead of the "already exists" errors of the attempts queued + // behind it — 10-28 20:51 gave up on exactly that and leaked a 1.1 GB + // renderer. An "already exists" that comes first is the OLD window still + // registered (22 of 440 recycles), and the loop retries as before. + let (tx, rx) = std::sync::mpsc::channel::>(); ``` -In the rebuild loop's `match rx.recv_timeout(…)`, add an arm before the generic `Ok(Err(e))` one: + Inside the loop drop the per-attempt `let (tx, rx) = …`, and clone into the closure: `let tx = tx.clone();`. + - In the builder chain add `.focused(false)` (keep `.title(…)` and `.disable_drag_drop_handler()` with its comment). + - Replace the `Ok(window) => { … }` body with: ```rust - Ok(Err(e)) if rebuilt_by_earlier_attempt(&e) => { - shell_log("[recycle] webview rebuilt (an earlier attempt landed)"); - RECYCLING.store(false, Ordering::SeqCst); - return; - } + Ok(window) => { + // Everything but visibility: the plugin's VISIBLE restore + // calls set_focus, i.e. activateIgnoringOtherApps — it would + // pull you out of the app you left periscope for. + let _ = window.restore_state(StateFlags::all() - StateFlags::VISIBLE); + let _ = window.show(); + if was_focused { + let _ = window.set_focus(); + } + // A fresh WKWebView starts at pageZoom 1.0. + crate::zoom::apply(&window); + WEBCONTENT_PID.store(0, Ordering::Relaxed); // re-learned next tick + mark_renderer_born(); + Ok(()) + } ``` -(Task 4 removes the `reap_old_webcontent(old_pid)` call from the plain-success arm; until then leave it.) - -- [ ] **Step 3: Test** — `cargo test --quiet 2>&1 | tail -5` — Expected: all pass. + `was_focused` must be moved into the spawned thread and copied into each closure (it's `bool`, `Copy`). + - If `StateFlags::all() - StateFlags::VISIBLE` doesn't compile with the plugin's bitflags version, use `StateFlags::all().difference(StateFlags::VISIBLE)`. -- [ ] **Step 4: Commit** - -```bash -git commit -m "shell: a rebuild that finds the window already built counts as rebuilt, not a give-up that leaks the old renderer (THI-449)" -- src-tauri/src/recycle.rs -``` +- [ ] **Step 2: Build** — `cargo build 2>&1 | grep -E '^(warning|error)'` → no new lines; `cargo test --quiet 2>&1 | tail -3` → all pass. +- [ ] **Step 3: Commit** — `git commit -m "shell: rebuild attempts share one result channel, and a rebuild only takes focus when periscope had it (THI-449)" -- src-tauri/src/recycle.rs` --- -### Task 4: sweep leftover WebContents every check +### Task 4: answered pid read + the sweep -**Files:** Modify `src-tauri/src/recycle.rs` (replace `reap_old_webcontent`; new `sweep_targets`, `app_webcontents`, `sweep`; call from `start_monitor`). +**Files:** Modify `src-tauri/src/recycle.rs` (replace `refresh_webcontent_pid` and `reap_old_webcontent`; `start_monitor`). -- [ ] **Step 1: Write the failing test** — add inside `mod tests`: +- [ ] **Step 1: Failing tests** — add inside `mod tests`: ```rust const MB: u64 = 1024 * 1024; @@ -300,20 +342,51 @@ git commit -m "shell: a rebuild that finds the window already built counts as re } ``` -Run: `cargo test --quiet sweep 2>&1 | tail -3` — Expected: FAIL to compile. +`cargo test --quiet sweep 2>&1 | tail -3` → fails to compile. -- [ ] **Step 2: Implement** +- [ ] **Step 2: Replace `refresh_webcontent_pid`** with a read that answers: -Replace the whole `reap_old_webcontent` function (and its comment) with: +```rust +/// The live WebContent pid, read off the WKWebView on the main thread. 0 with +/// no `main` window or an OS without the selector; None when the main thread +/// didn't answer in time — a stale pid must never reach the sweep (wry reloads +/// the page itself when its WebContent dies, so the live pid can change +/// without a recycle). +fn read_webcontent_pid(app: &AppHandle) -> Option { + let Some(window) = app.get_webview_window("main") else { + return Some(0); + }; + let (tx, rx) = std::sync::mpsc::channel::(); + window + .with_webview(move |webview| { + let wk = webview.inner() as *mut AnyObject; + let mut pid = 0; + if !wk.is_null() { + unsafe { + let wk = &*wk; + let responds: bool = msg_send![wk, respondsToSelector: sel!(_webProcessIdentifier)]; + if responds { + pid = msg_send![wk, _webProcessIdentifier]; + } + } + } + let _ = tx.send(pid); + }) + .ok()?; + rx.recv_timeout(Duration::from_secs(2)).ok() +} +``` + +- [ ] **Step 3: Replace `reap_old_webcontent`** (function and comment) with the sweep: ```rust // WebKit parks a displaced WebContent in its process cache at full leaked // size instead of exiting it (observed alive at 480MB 90s after its window // was destroyed). Every check kills each WebContent this app is responsible -// for, other than the live one, at or above SWEEP_FLOOR. The floor leaves -// WebKit's pre-warmed spare (~7 MB) alone; every displaced renderer in the -// log was ≥ 700 MB. One path covers a normal recycle, a rebuild that gave -// up, and anything else that leaves one behind. +// for, other than the live one, at or above SWEEP_FLOOR — which spares +// WebKit's ~7 MB pre-warmed spare (the smallest displaced renderer the old +// reaper ever killed was 522 MB). One path covers a normal recycle, a rebuild +// that gave up, and anything else that leaves one behind. const SWEEP_FLOOR: u64 = 256 * 1024 * 1024; fn sweep_targets(candidates: &[(i32, u64)], live_pid: i32) -> Vec { @@ -338,6 +411,7 @@ extern "C" { fn app_webcontents() -> Vec<(i32, u64)> { let me = std::process::id() as i32; let mut pids = vec![0i32; 8192]; + // buffersize is in bytes; the return value is a count of pids. let n = unsafe { proc_listallpids(pids.as_mut_ptr() as *mut std::ffi::c_void, (pids.len() * 4) as i32) }; @@ -364,54 +438,49 @@ fn sweep(live_pid: i32) { } ``` -Remove the `reap_old_webcontent(old_pid);` call in `recycle_now`'s success arm. `old_pid` is still used for the "destroying webview" log line — keep it. +Remove the `reap_old_webcontent(old_pid);` call from `recycle_now`'s success arm (`old_pid` stays for the "destroying" log line). -In `start_monitor`, right after `let pid = WEBCONTENT_PID.load(Ordering::Relaxed);`, add: +- [ ] **Step 4: Wire into `start_monitor`** — replace the refresh + 200 ms sleep + `WEBCONTENT_PID.load` with: ```rust - // Mid-recycle the stored pid is the renderer being torn down. + let Some(pid) = read_webcontent_pid(&handle) else { + continue; // main thread busy: no fresh pid, so no sweep and no recycle this tick + }; + WEBCONTENT_PID.store(pid, Ordering::Relaxed); if !RECYCLING.load(Ordering::SeqCst) { sweep(pid); } ``` -- [ ] **Step 3: Test** — `cargo test --quiet 2>&1 | tail -5` and `cargo build 2>&1 | grep -E '^(warning|error)' | sort | uniq -c` — Expected: all pass; no new warnings (in particular no "unused" for `proc_pidpath` or `old_pid`). +(the existing `let Some(fp) = phys_footprint(pid) else { continue; };` follows unchanged). -- [ ] **Step 4: Commit** - -```bash -git commit -m "shell: every check kills leftover WebContent renderers the app owns, replacing the after-recycle reaper (THI-449)" -- src-tauri/src/recycle.rs -``` +- [ ] **Step 5: Test** — `cargo test --quiet 2>&1 | tail -4` → all pass (37); `cargo build 2>&1 | grep -E '^(warning|error)'` → nothing new (no unused `run_on_main_thread` handle clones left behind). +- [ ] **Step 6: Commit** — `git commit -m "shell: every check sweeps leftover renderers the app owns, but only with a freshly read live pid; the after-recycle reaper goes (THI-449)" -- src-tauri/src/recycle.rs` --- ### Task 5: docs -**Files:** Modify `docs/tauri-shell.md` (the **Webview recycling** paragraph). - -- [ ] **Step 1: Rewrite the gate and reaper sentences** so the paragraph says: the shell recycles when the renderer's phys_footprint tops `PERISCOPE_RECYCLE_GB` (default 1.0) AND either periscope has been unfocused for `PERISCOPE_RECYCLE_UNFOCUSED_S` (default 30 s, tracked in `focus.rs`) or system input has been idle `PERISCOPE_RECYCLE_IDLE_S` (default 300 s); a rebuild attempt that fails with "already exists" counts as rebuilt (a timed-out earlier attempt built it); and every check SIGKILLs any WebContent the app is responsible for, other than the live one, at ≥ 256 MB (WebKit parks displaced renderers at full size; the floor spares its ~7 MB pre-warmed one). Keep the two existing invariants (later-tick rebuild + ExitRequested veto) and the `launchctl setenv` testing note; add `PERISCOPE_RECYCLE_UNFOCUSED_S` to it. +**Files:** `docs/tauri-shell.md` (Webview recycling paragraph), `static/src/notify/persist.js` (header comment). -- [ ] **Step 2: Commit** - -```bash -git commit -m "docs: tauri-shell describes the unfocused recycle gate and the leftover-renderer sweep (THI-449)" -- docs/tauri-shell.md -``` +- [ ] **Step 1:** In `docs/tauri-shell.md`, rewrite the gate and reaper sentences to D1/D4/D5: recycles when the renderer's phys_footprint tops `PERISCOPE_RECYCLE_GB` (default 2.0), it has lived `PERISCOPE_RECYCLE_MIN_AGE_S` (default 1800 s) since the last rebuild, and either periscope has been unfocused `PERISCOPE_RECYCLE_UNFOCUSED_S` (default 30 s, `focus.rs`) or input has been idle `PERISCOPE_RECYCLE_IDLE_S` (300 s); the rebuild restores everything but visibility and takes focus only if periscope had it; rebuild attempts share one result channel; every check, with a freshly read live pid, SIGKILLs any other app-owned WebContent ≥ 256 MB. Keep both invariants and the `launchctl setenv` note (listing all four knobs). +- [ ] **Step 2:** In `static/src/notify/persist.js`'s header, change "recycle.rs rebuilds the webview once you've been idle a while" to "…once you've left periscope or been idle a while" (match the file's exact wording; JS comment only, so no `npm run build`). +- [ ] **Step 3: Commit** — `git commit -m "docs: tauri-shell and persist.js describe the new recycle gate, rebuild and sweep (THI-449)" -- docs/tauri-shell.md static/src/notify/persist.js` --- ### Task 6: build and verify on the real app (needs the user's go-ahead) -- [ ] **Step 1: Release build in the worktree** (its own `target/`; the running app is untouched): - -Run (in `src-tauri/`): `cargo tauri build 2>&1 | tail -5` -Expected: a bundle at `src-tauri/target/release/bundle/macos/Periscope.app` inside the **worktree**. - -- [ ] **Step 2: Ask the user** before swapping apps: quitting the running Periscope.app and opening the worktree's bundle. The single-instance plugin focuses an already-running app instead of starting a second one, so the old one must quit first. - -- [ ] **Step 3: After the go-ahead** — quit the running app (`osascript -e 'quit app "Periscope"'`), `open` the worktree bundle, then verify from `~/.config/periscope/shell.log`: - - `monitor started (threshold 1024 MB, idle gate 300s, unfocused gate 30s)`; - - within ~60 s: `killed leftover WebContent 41485 (… MB)` if it is still alive (`ps -p 41485`); - - while over 1 GB, switching to another app for > 30 s produces `destroying webview … webview rebuilt`; while focused it logs `… but focused, input …s ago — waiting`. - - Check whether minimizing the window produces a recycle (spec assumption: tao sends `Focused(false)`); record the answer in the PR. - -- [ ] **Step 4: Hand off** — report results; ask before pushing and opening the PR (title `THI-449: …`). After merge, the user's long-lived app is rebuilt from the prod checkout (`cargo tauri build` there), which is the same swap and needs the same go-ahead. +- [ ] **Step 1:** `cargo tauri build 2>&1 | tail -5` in the worktree's `src-tauri/` → bundle at the **worktree's** `src-tauri/target/release/bundle/macos/Periscope.app`. The running app is untouched. +- [ ] **Step 2: Ask the user** before swapping apps (quit the running Periscope.app, open the worktree bundle; single-instance means the old one must quit first). Quitting also ends today's leftover 41485. +- [ ] **Step 3: After the go-ahead:** + - `launchctl setenv PERISCOPE_RECYCLE_GB 0.5; launchctl setenv PERISCOPE_RECYCLE_MIN_AGE_S 60; launchctl setenv PERISCOPE_RECYCLE_UNFOCUSED_S 10`; + - `osascript -e 'quit app "Periscope"'`, then `open` the worktree bundle; + - in `~/.config/periscope/shell.log` see "monitor started (threshold 512 MB, min age 60s, unfocused gate 10s, idle gate 300s)"; + - after ≥ 60 s, switch to the terminal for > 10 s: "destroying webview … webview rebuilt", and **the terminal stays frontmost**; + - no second recycle within 60 s of the first; + - View → Recycle Webview while periscope is frontmost leaves it frontmost; + - minimize periscope for > 10 s: does it recycle? (record the answer); + - every "killed leftover WebContent" pid differs from the live pid read just before (compare against `_webProcessIdentifier` via the next "destroying webview (WebContent pid …)" line). + - Then `launchctl unsetenv` the three knobs and relaunch so the defaults apply. +- [ ] **Step 4:** Report results; ask before pushing and opening the PR (`THI-449: …`). After merge, rebuilding the long-lived app from the prod checkout is the same swap and needs the same go-ahead. diff --git a/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md b/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md index 90a22385..fc174f83 100644 --- a/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md +++ b/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md @@ -1,84 +1,117 @@ # Webview recycle gate -The Tauri shell recycles the leaking WKWebView when you leave periscope, not only when you leave the machine, and -kills every displaced WebContent instead of leaking one when a rebuild stumbles. THI-449. +The Tauri shell recycles the leaking webview when you leave periscope (not only when you leave the machine), never +in a loop and never by pulling focus, and kills displaced renderers instead of leaking one when a rebuild stumbles. +THI-449. -## What the logs showed (`~/.config/periscope/shell.log`, 2026-10-26 → 11-01) +## What the logs showed (`~/.config/periscope/shell.log`, 2026-08-07 → 11-01, 440 recycles) - **The gate is too strict for a working day.** A recycle needs the renderer over `PERISCOPE_RECYCLE_GB` (1 GB) - **and** no input to any app for `PERISCOPE_RECYCLE_IDLE_S` (300 s). Recycles do run when you step away (8 on - 10-28, 11 on 10-30/31), but while you work the footprint grows unchecked: 730 one-minute checks over threshold and - blocked on idle in 36 hours, peaking at 7.7 GB. The leak comes from input inside the webview (~2.5 graphics regions - per click, `docs/tauri-shell.md`), so leaving periscope for another app is the natural moment to recycle. -- **A stumbling rebuild leaks the old renderer.** On 10-28 20:51 the rebuild's first three attempts timed out (5 s - each; the main thread was busy) but one of them did build the window: the page registered its notification - categories at 20:51:48. Attempts 4–10 then failed with "a webview with label `main` already exists", the loop gave - up, and the reaper — which only runs on the success path — never killed WebContent 41485. It is still alive at - 1.1 GB three days later. A second leftover, 37895 (7 MB, started the moment a reap ran on 10-26), is WebKit's - pre-warmed spare and harmless. + **and** no input to any app for `PERISCOPE_RECYCLE_IDLE_S` (300 s). Recycles do run when you step away, but while + you work the footprint grows unchecked: 730 one-minute checks over threshold and blocked on idle in 36 hours + (10-29 → 10-30), peaking at 7.7 GB. The leak comes from input inside the webview (~2.5 graphics regions per click, + `docs/tauri-shell.md`), so leaving periscope for another app is the natural moment to recycle. +- **1 GB can't tell a leaked renderer from a fresh one.** A freshly reloaded dashboard measures 1.1–1.6 GB within a + minute (e.g. 10-28 16:12 at 1409 MB, 10-15 13:56 at 1121 MB). So the idle gate already looped: 62 recycles came + less than 90 s after the previous one, 20 of them on 10-09 12:07–12:27, each new renderer 1.4–1.6 GB at destroy. +- **A stumbling rebuild leaks the old renderer.** On 10-28 20:51 the first three rebuild attempts timed out (5 s + each, main thread busy), but one of them built the window: the page registered its notification categories at + 20:51:48. Attempts 4–10 then failed with "a webview with label `main` already exists", the loop gave up, and the + reaper, which only runs on the success path, never killed WebContent 41485 (still alive at 1.1 GB three days later). + The same error also means the opposite: in 22 recycles attempt 1 failed with it 250 ms after `destroy()`, because + the OLD window was still registered, and attempt 2 then succeeded. +- **A rebuild activates the app.** `recycle_now` restores the window with `restore_state(StateFlags::all())`, which + in tauri-plugin-window-state 2.4.1 calls `show()` then `set_focus()`; tao's `set_focus` runs + `activateIgnoringOtherApps: YES`. Harmless while you were away; under a gate that fires while you work in another + app, it would pull focus out from under you. ## Decisions -**D1 — Recycle when the periscope window has been unfocused for 30 s, or after the existing system idle.** Over -threshold AND (`unfocused_for ≥ PERISCOPE_RECYCLE_UNFOCUSED_S`, default 30 s, OR system input idle ≥ -`PERISCOPE_RECYCLE_IDLE_S`, default 300 s). The idle branch stays for periscope left frontmost while you're away. -Switching to the terminal or editor for 30 s while over 1 GB costs a ~2 s blank and page reload you are not looking -at; if periscope is visible on another screen you may glimpse it. Rejected: recycling only when the window is -hidden or occluded (periscope often stays visible on a second screen, so it would rarely fire), and a hard ceiling -that recycles under you while focused (an interruption the unfocused gate makes unnecessary). - -**D2 — One focus signal, owned by a small `focus.rs`.** Today `notifications.rs` keeps a bare `FOCUSED` boolean set -from `on_window_event`. It moves to `focus.rs`, which records the state and when it last changed: -`set_focused(bool)` (called from the same hook, `Focused(f)` → `f`, `Destroyed` → false), `is_focused()` (the -banners' quiet-while-focused check keeps using it), and `unfocused_for() -> Option` (None while focused). -The app starts unfocused-since-launch until the first `Focused(true)`. A recycle destroys the window, which counts as -unfocused, so the clock runs through the rebuild. - -**D3 — "Already exists" means an earlier attempt built the window.** In the rebuild loop, an attempt that fails -with "a webview with label `main` already exists" is the success of an attempt that timed out: the loop logs -"webview rebuilt (an earlier attempt landed)", clears `RECYCLING`, and returns, like a normal success. The timed-out -attempt's closure already restored the window state and zoom, because it ran to completion on the main thread. - -**D4 — Every check sweeps leftover WebContents; the after-recycle reaper goes.** Once per `CHECK_INTERVAL_S`, after -the live WebContent pid is refreshed, every other WebContent process the app is responsible for with a footprint of -at least 256 MB is SIGKILLed and logged. Found by listing all pids (`proc_listallpids`), keeping those whose -executable path (`proc_pidpath`) contains `WebKit.WebContent` and whose responsible pid -(`responsibility_get_pid_responsible_for_pid`, private libSystem — the same call periscope's server uses to find the -webview, `periscope/resources.py`) is this process. Never runs while the live pid is unknown (0), so the live -renderer is never a candidate. The 256 MB floor leaves WebKit's pre-warmed spare (~7 MB) alone; every displaced -renderer seen in the log was ≥ 700 MB. This one path replaces `reap_old_webcontent`'s 10 s after-recycle kill: -it covers a normal recycle (within one check, ≤ 60 s), the give-up path, and anything else that leaves a renderer -parked. The first check after the new `.app` starts kills today's 41485. - -**D5 — Policy is pure and unit-tested; the OS calls stay thin.** `should_recycle(footprint, threshold, -unfocused_for, idle, unfocused_gate, idle_gate) -> bool`, `rebuilt_by_earlier_attempt(err: &str) -> bool` and -`sweep_targets(candidates: &[(pid, footprint)], live_pid) -> Vec` hold every decision and get `cargo test` -cases next to `notifications.rs`'s and `zoom.rs`'s. The monitor loop gathers inputs and acts on their answers. +**D1 — Recycle when periscope has been unfocused for 30 s, or after the existing system idle, but never within 30 +minutes of the last rebuild; the threshold rises to 2 GB.** A recycle needs all of: +- footprint ≥ `PERISCOPE_RECYCLE_GB`, default raised from 1.0 to **2.0** (above every fresh renderer in the log, + well below the leaked peaks); +- the renderer has lived ≥ `PERISCOPE_RECYCLE_MIN_AGE_S`, default 1800 s, since the last rebuild (or the app's + start), so no gate can loop; +- either periscope has been unfocused ≥ `PERISCOPE_RECYCLE_UNFOCUSED_S` (default 30 s), or system input has been + idle ≥ `PERISCOPE_RECYCLE_IDLE_S` (default 300 s; covers periscope left frontmost while you're away). + +The manual View → Recycle Webview item stays ungated. Rejected: recycling only when hidden or occluded (periscope +often stays visible on a second screen, so it would rarely fire), and a hard ceiling that recycles under you while +focused (the unfocused gate makes that interruption unnecessary). + +**D2 — One focus signal, owned by `focus.rs`.** Today `notifications.rs` keeps a bare `FOCUSED` boolean. It moves +to `focus.rs`, which holds one state behind a mutex — `Unknown` (from launch until the first focus event), +`Focused`, or `UnfocusedSince(Instant)` — so a reader never sees "unfocused" paired with a stale time. `set_focused` +is called from `main.rs`'s `on_window_event` (`Focused(f)` → f, `Destroyed` → false); only a real change moves the +clock, so a recycle's `Destroyed` after an earlier `Focused(false)` keeps it running. `is_focused()` serves the +banners' quiet-while-focused check; `unfocused_for() -> Option` is None while focused or Unknown. + +**D3 — The rebuild loop shares one result channel across attempts.** Every attempt's closure sends its result on +the same channel instead of a per-attempt one. The main thread runs closures in order, so when an attempt times out +but later builds the window, its `Ok` reaches the channel before the "already exists" errors of attempts queued +behind it; the loop takes that `Ok` as the success it is (clearing `RECYCLING`). An "already exists" that arrives +first still means the old window is registered, so the loop keeps retrying as today. No error text is matched. + +**D4 — A rebuild never activates the app unless periscope had focus.** `recycle_now` notes `focus::is_focused()` +before `destroy()`. The rebuild builds the window `.focused(false)`, restores every saved state except visibility +(`StateFlags::all() - StateFlags::VISIBLE`), shows it, and calls `set_focus()` only when periscope had focus — so the +manual menu recycle keeps the window focused, and an unfocused-gate recycle leaves you in your terminal. It also +records the rebuild time for D1's minimum age. + +**D5 — Every check sweeps leftover renderers, but only with a fresh live pid; the after-recycle reaper goes.** Once +per `CHECK_INTERVAL_S`, the live WebContent pid is read on the main thread and the answer comes back on a channel +with a timeout; the sweep runs only when this tick's read answered with a pid (and no recycle is in flight), so a +stale pid after an unannounced renderer restart (wry reloads the page itself when its WebContent dies) can never be +mistaken for a leftover. With no `main` window the pid is reset to 0. The sweep SIGKILLs every other WebContent the +app is responsible for at or above 256 MB: candidates come from `proc_listallpids`, kept when their executable path +(`proc_pidpath`) contains `WebKit.WebContent` and their responsible pid +(`responsibility_get_pid_responsible_for_pid`, private libSystem, the call `periscope/resources.py` also uses) is +this process. The floor spares WebKit's ~7 MB pre-warmed spare; the smallest displaced renderer the reaper ever +killed was 522 MB. This one path replaces `reap_old_webcontent`'s 10 s kill: it covers a normal recycle (within one +check, ≤ 60 s), a rebuild that gave up, and anything else that leaves one parked. Renderers left by an older run of +the app die with it, so quitting the current app clears today's 41485. + +**D6 — Policy is pure and unit-tested; the OS calls stay thin.** `should_recycle(Inputs) -> bool` and +`sweep_targets(candidates, live_pid) -> Vec` hold the decisions and get `cargo test` cases next to +`notifications.rs`'s and `zoom.rs`'s; `focus.rs`'s state transitions get one test. ## Changes -- `src-tauri/src/focus.rs` (new): the focus state and its clock (D2). +- `src-tauri/src/focus.rs` (new): the focus state (D2). - `src-tauri/src/notifications.rs`: drops `FOCUSED` / `set_focused`, reads `focus::is_focused()`. -- `src-tauri/src/main.rs`: `on_window_event` calls `focus::set_focused`; `mod focus`. -- `src-tauri/src/recycle.rs`: the unfocused gate and its env knob (D1), the "already exists" success (D3), the sweep - replacing `reap_old_webcontent` (D4), the pure policy functions and their tests (D5). The "waiting" log line names - both timers: `over threshold (N MB) but focused/unfocused Xs, input Ys ago — waiting`. -- `docs/tauri-shell.md`: the recycling paragraph describes the new gate, its knobs and the sweep. +- `src-tauri/src/main.rs`: `mod focus`; `on_window_event` calls `focus::set_focused`. +- `src-tauri/src/recycle.rs`: the gate and its knobs (D1), the shared channel (D3), the focus-preserving rebuild and + the rebuild timestamp (D4), the answered pid refresh and the sweep replacing `reap_old_webcontent` (D5), the pure + policy and its tests (D6). The "waiting" log line names what is holding the recycle back. +- `docs/tauri-shell.md`: the recycling paragraph describes the new gate, knobs, rebuild and sweep. +- `static/src/notify/persist.js`: its header's "recycle.rs rebuilds the webview once you've been idle a while" + becomes "…once you've left periscope or been idle a while". ## Testing -- `cargo test` in `src-tauri/`: `should_recycle` (under threshold never; over threshold with unfocused ≥ / < gate; - focused with idle ≥ / < gate), `rebuilt_by_earlier_attempt` (the exact error text; other errors), `sweep_targets` - (skips the live pid, skips < 256 MB, skips everything when live is 0, keeps ≥ 256 MB others). -- Manual, on the rebuilt `.app` (replacing the running app needs the user's go-ahead): lower the gates with - `launchctl setenv PERISCOPE_RECYCLE_UNFOCUSED_S 10` (GUI apps don't inherit shell env), switch away, and watch - `shell.log` for the recycle; confirm the first check kills 41485 if it is still alive; confirm the popover's - "webview" line (THI-448) stays near or under 1 GB through a working day. +- `cargo test` in `src-tauri/`: + - `should_recycle`: under threshold never; too young never; unfocused ≥ / < gate; focused or unknown with idle + ≥ / < gate. + - `sweep_targets`: skips the live pid, skips < 256 MB, takes nothing when the live pid is unknown (0), keeps + ≥ 256 MB others. + - `focus`: Unknown → Focused → UnfocusedSince (clock runs, a repeated false keeps it) → Focused. +- Manual, on the rebuilt `.app` (replacing the running app needs the user's go-ahead), with + `launchctl setenv PERISCOPE_RECYCLE_GB 0.5`, `…_MIN_AGE_S 60` and `…_UNFOCUSED_S 10` (GUI apps don't inherit shell + env; unset afterwards): + - switching to another app for 10 s recycles, and you **stay** in that app (D4); + - no second recycle within the minimum age; + - the manual menu recycle leaves periscope focused; + - whether minimizing produces a recycle (assumption below); `shell.log` shows the sweep never killing the live pid. +- After merge: the popover's "webview" line (THI-448) shows whether the renderer stays near or under 2 GB through a + working day. ## Assumptions -- `Focused(false)` fires when another app becomes frontmost and when periscope is minimized or hidden (tao's - `windowDidResignKey`). Verify in the manual test; if minimizing doesn't fire it, the idle branch still covers it. -- `proc_listallpids` and `responsibility_get_pid_responsible_for_pid` behave from the shell as they do from Python - (verified there, macOS 27). -- A displaced renderer is always ≥ 256 MB by the time a check sees it; a smaller one is left until it grows or exits. +- `Focused(false)` fires on app switch and ⌘H (tao's `windowDidResignKey`; verified in source). Whether it fires on + minimize is untested; if not, the idle gate still covers a minimized periscope. +- A window built `.focused(false)` and shown with `show()` (`makeKeyAndOrderFront`) while the app is inactive does + not activate the app or cover the frontmost app's window. Verify in the manual test. +- `proc_listallpids` takes a byte size and returns a count; it and `responsibility_get_pid_responsible_for_pid` link + from Rust without a `#[link]` attribute (both verified in a scratch binary). +- A displaced renderer is ≥ 256 MB by the time a check sees it; a smaller one is left until it grows or exits. From 62bce3f41588bd83b347c102f6ec175be1e46ff7 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 1 Nov 2026 18:17:22 -0500 Subject: [PATCH 04/26] shell: focus.rs owns the window's focus and how long it has been lost; notifications reads it (THI-449) Co-Authored-By: Claude Sonnet 5 --- src-tauri/src/focus.rs | 72 ++++++++++++++++++++++++++++++++++ src-tauri/src/main.rs | 11 +++--- src-tauri/src/notifications.rs | 14 +------ 3 files changed, 79 insertions(+), 18 deletions(-) create mode 100644 src-tauri/src/focus.rs diff --git a/src-tauri/src/focus.rs b/src-tauri/src/focus.rs new file mode 100644 index 00000000..fac0480e --- /dev/null +++ b/src-tauri/src/focus.rs @@ -0,0 +1,72 @@ +// Whether the dashboard window has focus, and since when it hasn't — kept by +// main.rs's window events. Read off the main thread by notification banners +// (quiet while focused; will_present runs on a thread UNUserNotificationCenter +// chooses) and by the recycle monitor (recycle once periscope is left). One +// mutex, so a reader never pairs "unfocused" with a stale time. + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Focus { + Unknown, // launch, before the first window event + Focused, + UnfocusedSince(Instant), +} + +static FOCUS: Mutex = Mutex::new(Focus::Unknown); + +fn state() -> Focus { + *FOCUS.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Only a real change moves the clock: a recycle's Destroyed after an earlier +/// Focused(false) keeps the unfocused time running. +pub fn set_focused(focused: bool) { + let mut f = FOCUS.lock().unwrap_or_else(|e| e.into_inner()); + *f = match (*f, focused) { + (_, true) => Focus::Focused, + (Focus::UnfocusedSince(t), false) => Focus::UnfocusedSince(t), + (_, false) => Focus::UnfocusedSince(Instant::now()), + }; +} + +pub fn is_focused() -> bool { + state() == Focus::Focused +} + +/// How long periscope has been unfocused; None while focused or not yet known. +pub fn unfocused_for() -> Option { + match state() { + Focus::UnfocusedSince(t) => Some(t.elapsed()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // One test: the state is process-global and cargo runs tests in parallel. + #[test] + fn tracks_focus_and_how_long_it_has_been_lost() { + assert_eq!(unfocused_for(), None); // Unknown at start + assert!(!is_focused()); + + set_focused(true); + assert!(is_focused()); + assert_eq!(unfocused_for(), None); + + set_focused(false); + assert!(!is_focused()); + std::thread::sleep(Duration::from_millis(30)); + let first = unfocused_for().expect("unfocused"); + assert!(first >= Duration::from_millis(30)); + + set_focused(false); // a recycle's Destroyed + assert!(unfocused_for().expect("unfocused") >= first); + + set_focused(true); + assert_eq!(unfocused_for(), None); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 31ff79e9..7a945e76 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,6 +4,7 @@ use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder}; use tauri::Manager; +mod focus; mod notifications; mod recycle; mod text_behavior; @@ -131,10 +132,8 @@ fn main() { _ => {} } }) - // will_present runs on a thread UNUserNotificationCenter chooses, and - // only while the app is frontmost; an atomic kept from window events - // answers without a round trip to the main thread. A webview recycle - // destroys the focused window without a Focused(false) first — tao's + // The focus atomic now lives in focus.rs. A webview recycle destroys + // the focused window without a Focused(false) first — tao's // windowWillClose drops its delegate before that fires — so Destroyed // resets it too. .on_window_event(|window, event| { @@ -142,8 +141,8 @@ fn main() { return; } match event { - tauri::WindowEvent::Focused(focused) => notifications::set_focused(*focused), - tauri::WindowEvent::Destroyed => notifications::set_focused(false), + tauri::WindowEvent::Focused(focused) => focus::set_focused(*focused), + tauri::WindowEvent::Destroyed => focus::set_focused(false), _ => {} } }) diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index 3a1e50b5..45596e52 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -32,7 +32,7 @@ // banners carry no buttons; an older webview registers none. use std::collections::{HashMap, HashSet}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicUsize, Ordering}; use block2::{DynBlock, RcBlock}; use objc2::rc::{autoreleasepool, Retained}; @@ -71,16 +71,6 @@ const INFO_KEY: &str = "periscope"; static SEQ: AtomicUsize = AtomicUsize::new(0); -/// Whether the dashboard window has focus, kept by main.rs's window events. -/// will_present runs on a thread UNUserNotificationCenter chooses, and only -/// while the app is frontmost; an atomic kept from window events answers -/// without a round trip to the main thread. -static FOCUSED: AtomicBool = AtomicBool::new(false); - -pub fn set_focused(focused: bool) { - FOCUSED.store(focused, Ordering::Relaxed); -} - /// `#[serde(default)]` alone only covers a *missing* field — `"subtitle": /// null` still deserializes to `None`/unit and fails the `String` field, /// dropping the whole banner. This shell outlives the JS it talks to, so a @@ -408,7 +398,7 @@ define_class!( ) { let quiet = info_of(¬ification.request().content()).is_some_and(|i| i.quiet_when_focused); - let options = if should_present(quiet, FOCUSED.load(Ordering::Relaxed)) { + let options = if should_present(quiet, crate::focus::is_focused()) { UNNotificationPresentationOptions::Banner | UNNotificationPresentationOptions::List | UNNotificationPresentationOptions::Sound From 689cecc44fa74653d614780bd7fe0159a6d2816f Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 1 Nov 2026 18:19:59 -0500 Subject: [PATCH 05/26] shell: recycle once periscope has been unfocused 30 s, never within 30 min of a rebuild, above 2 GB (THI-449) Co-Authored-By: Claude Sonnet 5 --- src-tauri/src/main.rs | 8 +-- src-tauri/src/recycle.rs | 143 +++++++++++++++++++++++++++++++++------ 2 files changed, 127 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7a945e76..6174f9a7 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -132,10 +132,10 @@ fn main() { _ => {} } }) - // The focus atomic now lives in focus.rs. A webview recycle destroys - // the focused window without a Focused(false) first — tao's - // windowWillClose drops its delegate before that fires — so Destroyed - // resets it too. + // focus.rs keeps the window's focus for readers off the main thread. + // A webview recycle destroys the focused window without a + // Focused(false) first — tao's windowWillClose drops its delegate + // before that fires — so Destroyed resets it too. .on_window_event(|window, event| { if window.label() != "main" { return; diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index fec353df..6dd29fa7 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -10,9 +10,11 @@ // // Policy: every CHECK_INTERVAL_S the renderer's phys_footprint is read via // proc_pid_rusage (same number `footprint` prints; RSS is misleading — most -// of the leak compresses). Over PERISCOPE_RECYCLE_GB (default 1.0) AND the -// system input-idle for PERISCOPE_RECYCLE_IDLE_S (default 300s) → recycle. -// The idle gate keeps the ~2s blip invisible; there is also a manual +// of the leak compresses). Over PERISCOPE_RECYCLE_GB (default 2.0), the +// renderer at least PERISCOPE_RECYCLE_MIN_AGE_S old (default 1800s, so a +// fresh big renderer can't loop), and either unfocused for +// PERISCOPE_RECYCLE_UNFOCUSED_S (default 30s) or system input-idle for +// PERISCOPE_RECYCLE_IDLE_S (default 300s) → recycle. There is also a manual // View → Recycle Webview item with no gate. // // The WebContent pid comes from WKWebView's private `_webProcessIdentifier` @@ -22,7 +24,8 @@ // selector degrades to "manual recycle only" instead of crashing. use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; -use std::time::Duration; +use std::sync::Mutex; +use std::time::{Duration, Instant}; use objc2::runtime::AnyObject; use objc2::{msg_send, sel}; @@ -37,19 +40,56 @@ const DASHBOARD_URL: &str = "http://127.0.0.1:17374"; pub static RECYCLING: AtomicBool = AtomicBool::new(false); static WEBCONTENT_PID: AtomicI32 = AtomicI32::new(0); +fn env_f64(name: &str, default: f64) -> f64 { + std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default) +} + +// A fresh dashboard already measures 1.1–1.6 GB; leaked renderers reach 3–7.7. fn threshold_bytes() -> u64 { - let gb: f64 = std::env::var("PERISCOPE_RECYCLE_GB") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1.0); - (gb * 1024.0 * 1024.0 * 1024.0) as u64 + (env_f64("PERISCOPE_RECYCLE_GB", 2.0) * 1024.0 * 1024.0 * 1024.0) as u64 } fn idle_gate_s() -> f64 { - std::env::var("PERISCOPE_RECYCLE_IDLE_S") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(300.0) + env_f64("PERISCOPE_RECYCLE_IDLE_S", 300.0) +} + +fn unfocused_gate_s() -> f64 { + env_f64("PERISCOPE_RECYCLE_UNFOCUSED_S", 30.0) +} + +fn min_age_s() -> f64 { + env_f64("PERISCOPE_RECYCLE_MIN_AGE_S", 1800.0) +} + +// When the current renderer was built (the app's start, then each rebuild): +// D1's minimum age, so a fresh renderer that is already big can't loop. +static RENDERER_BORN: Mutex> = Mutex::new(None); + +fn mark_renderer_born() { + *RENDERER_BORN.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now()); +} + +fn renderer_age() -> Duration { + RENDERER_BORN.lock().unwrap_or_else(|e| e.into_inner()).map_or(Duration::ZERO, |t| t.elapsed()) +} + +struct Inputs { + footprint: u64, + threshold: u64, + age: Duration, + min_age: Duration, + unfocused_for: Option, + unfocused_gate: Duration, + idle: Duration, + idle_gate: Duration, +} + +/// Big enough, old enough, and either periscope has been left for another app +/// (the leak grows from input inside it) or the machine has gone idle. +fn should_recycle(i: &Inputs) -> bool { + i.footprint >= i.threshold + && i.age >= i.min_age + && (i.unfocused_for.is_some_and(|d| d >= i.unfocused_gate) || i.idle >= i.idle_gate) } // --- phys_footprint via proc_pid_rusage (flavor 0 already carries it) --- @@ -260,15 +300,64 @@ extern "C" { fn proc_pidpath(pid: i32, buffer: *mut std::ffi::c_void, buffersize: u32) -> i32; } +#[cfg(test)] +mod tests { + use super::*; + + const GB: u64 = 1024 * 1024 * 1024; + const MIN: u64 = 60; + + fn inputs(footprint: u64, age_s: u64, unfocused_s: Option, idle_s: u64) -> Inputs { + Inputs { + footprint, + threshold: 2 * GB, + age: Duration::from_secs(age_s), + min_age: Duration::from_secs(30 * MIN), + unfocused_for: unfocused_s.map(Duration::from_secs), + unfocused_gate: Duration::from_secs(30), + idle: Duration::from_secs(idle_s), + idle_gate: Duration::from_secs(300), + } + } + + #[test] + fn under_threshold_never_recycles() { + assert!(!should_recycle(&inputs(2 * GB - 1, 3600, Some(3600), 3600))); + } + + #[test] + fn a_young_renderer_never_recycles() { + // A fresh dashboard measures 1.1–1.6 GB at once; without this the gate loops. + assert!(!should_recycle(&inputs(3 * GB, 30 * MIN - 1, Some(3600), 3600))); + } + + #[test] + fn unfocused_long_enough_recycles() { + assert!(should_recycle(&inputs(3 * GB, 30 * MIN, Some(30), 0))); + assert!(!should_recycle(&inputs(3 * GB, 30 * MIN, Some(29), 0))); + } + + #[test] + fn focused_or_unknown_waits_for_system_idle() { + assert!(!should_recycle(&inputs(3 * GB, 30 * MIN, None, 299))); + assert!(should_recycle(&inputs(3 * GB, 30 * MIN, None, 300))); + } +} + pub fn start_monitor(app: &AppHandle) { + mark_renderer_born(); let handle = app.clone(); std::thread::spawn(move || { let threshold = threshold_bytes(); - let idle_needed = idle_gate_s(); + let min_age = Duration::from_secs_f64(min_age_s()); + let unfocused_gate = Duration::from_secs_f64(unfocused_gate_s()); + let idle_gate = Duration::from_secs_f64(idle_gate_s()); shell_log(&format!( - "[recycle] monitor started (threshold {} MB, idle gate {}s)", + "[recycle] monitor started (threshold {} MB, min age {}s, unfocused gate {}s, idle gate {}s)", threshold / (1024 * 1024), - idle_needed + min_age.as_secs(), + unfocused_gate.as_secs(), + idle_gate.as_secs() )); loop { std::thread::sleep(Duration::from_secs(CHECK_INTERVAL_S)); @@ -284,12 +373,26 @@ pub fn start_monitor(app: &AppHandle) { if fp < threshold { continue; } - let idle = seconds_since_last_input(); - if idle < idle_needed { + let inputs = Inputs { + footprint: fp, + threshold, + age: renderer_age(), + min_age, + unfocused_for: crate::focus::unfocused_for(), + unfocused_gate, + idle: Duration::from_secs_f64(seconds_since_last_input().max(0.0)), + idle_gate, + }; + if !should_recycle(&inputs) { + let focus = match inputs.unfocused_for { + Some(d) => format!("unfocused {}s", d.as_secs()), + None => "focused".to_string(), + }; shell_log(&format!( - "[recycle] over threshold ({} MB) but input {}s ago — waiting for idle", + "[recycle] over threshold ({} MB), renderer {}s old, {focus}, input {}s ago — waiting", fp / (1024 * 1024), - idle as u64 + inputs.age.as_secs(), + inputs.idle.as_secs() )); continue; } From bf37ec2276358398fb825c2d97efe9314419c25c Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 1 Nov 2026 18:25:49 -0500 Subject: [PATCH 06/26] shell: rebuild attempts share one result channel, and a rebuild only takes focus when periscope had it (THI-449) Co-Authored-By: Claude Sonnet 5 --- src-tauri/src/recycle.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index 6dd29fa7..2c4eb576 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -199,6 +199,7 @@ pub fn recycle_now(app: &AppHandle) { if RECYCLING.swap(true, Ordering::SeqCst) { return; // one at a time } + let was_focused = crate::focus::is_focused(); // before destroy() sets focus to false let old_pid = WEBCONTENT_PID.load(Ordering::Relaxed); let fp = phys_footprint(old_pid).unwrap_or(0); shell_log(&format!( @@ -213,10 +214,17 @@ pub fn recycle_now(app: &AppHandle) { } let handle = app.clone(); std::thread::spawn(move || { + // One channel for every attempt. The main thread runs the closures in + // order, so an attempt that timed out but later built the window sends + // its Ok ahead of the "already exists" errors of the attempts queued + // behind it — 10-28 20:51 gave up on exactly that and leaked a 1.1 GB + // renderer. An "already exists" that comes first is the OLD window still + // registered (22 of 440 recycles), and the loop retries as before. + let (tx, rx) = std::sync::mpsc::channel::>(); for attempt in 1..=10 { std::thread::sleep(Duration::from_millis(if attempt == 1 { 250 } else { 1000 })); - let (tx, rx) = std::sync::mpsc::channel::>(); let h = handle.clone(); + let tx = tx.clone(); let ok = handle.run_on_main_thread(move || { let result = tauri::WebviewWindowBuilder::new( &h, @@ -233,13 +241,22 @@ pub fn recycle_now(app: &AppHandle) { // recycle silently broke rail reordering until the app was // relaunched. .disable_drag_drop_handler() + .focused(false) .build(); let _ = tx.send(match result { Ok(window) => { - let _ = window.restore_state(StateFlags::all()); + // Everything but visibility: the plugin's VISIBLE restore + // calls set_focus, i.e. activateIgnoringOtherApps — it would + // pull you out of the app you left periscope for. + let _ = window.restore_state(StateFlags::all() - StateFlags::VISIBLE); + let _ = window.show(); + if was_focused { + let _ = window.set_focus(); + } // A fresh WKWebView starts at pageZoom 1.0. crate::zoom::apply(&window); WEBCONTENT_PID.store(0, Ordering::Relaxed); // re-learned next tick + mark_renderer_born(); Ok(()) } Err(e) => Err(e.to_string()), @@ -386,7 +403,8 @@ pub fn start_monitor(app: &AppHandle) { if !should_recycle(&inputs) { let focus = match inputs.unfocused_for { Some(d) => format!("unfocused {}s", d.as_secs()), - None => "focused".to_string(), + None if crate::focus::is_focused() => "focused".to_string(), + None => "focus unknown".to_string(), }; shell_log(&format!( "[recycle] over threshold ({} MB), renderer {}s old, {focus}, input {}s ago — waiting", From c0d28ba432dc67e1c1c15fd3f88ae71fcc606292 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 1 Nov 2026 18:28:03 -0500 Subject: [PATCH 07/26] shell: every check sweeps leftover renderers the app owns, but only with a freshly read live pid; the after-recycle reaper goes (THI-449) Co-Authored-By: Claude Sonnet 5 --- src-tauri/src/recycle.rs | 144 ++++++++++++++++++++++++++------------- 1 file changed, 95 insertions(+), 49 deletions(-) diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index 2c4eb576..8f6d8dd7 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -152,24 +152,33 @@ fn seconds_since_last_input() -> f64 { // --- WebContent pid off the live WKWebView (main thread only) --- -fn refresh_webcontent_pid(app: &AppHandle) { +/// The live WebContent pid, read off the WKWebView on the main thread. 0 with +/// no `main` window or an OS without the selector; None when the main thread +/// didn't answer in time — a stale pid must never reach the sweep (wry reloads +/// the page itself when its WebContent dies, so the live pid can change +/// without a recycle). +fn read_webcontent_pid(app: &AppHandle) -> Option { let Some(window) = app.get_webview_window("main") else { - return; + return Some(0); }; - let _ = window.with_webview(|webview| { - let wk = webview.inner() as *mut AnyObject; - if wk.is_null() { - return; - } - unsafe { - let wk = &*wk; - let responds: bool = msg_send![wk, respondsToSelector: sel!(_webProcessIdentifier)]; - if responds { - let pid: i32 = msg_send![wk, _webProcessIdentifier]; - WEBCONTENT_PID.store(pid, Ordering::Relaxed); + let (tx, rx) = std::sync::mpsc::channel::(); + window + .with_webview(move |webview| { + let wk = webview.inner() as *mut AnyObject; + let mut pid = 0; + if !wk.is_null() { + unsafe { + let wk = &*wk; + let responds: bool = msg_send![wk, respondsToSelector: sel!(_webProcessIdentifier)]; + if responds { + pid = msg_send![wk, _webProcessIdentifier]; + } + } } - } - }); + let _ = tx.send(pid); + }) + .ok()?; + rx.recv_timeout(Duration::from_secs(2)).ok() } // Append-only shell log — `open`-launched apps have no visible stderr, and @@ -270,7 +279,6 @@ pub fn recycle_now(app: &AppHandle) { Ok(Ok(())) => { shell_log("[recycle] webview rebuilt"); RECYCLING.store(false, Ordering::SeqCst); - reap_old_webcontent(old_pid); return; } Ok(Err(e)) => shell_log(&format!("[recycle] rebuild attempt {attempt} failed: {e}")), @@ -283,40 +291,64 @@ pub fn recycle_now(app: &AppHandle) { }); } -// WebKit parks the displaced WebContent in its process cache instead of -// exiting it — observed alive at 480MB 90s after the window it hosted was -// destroyed, which would hoard exactly the leaked regions recycling exists to -// free. Grace period, then SIGKILL — guarded by proc_pidpath so a reused pid -// (some new unrelated process) is never the target. -fn reap_old_webcontent(old_pid: i32) { - if old_pid <= 0 { - return; +// WebKit parks a displaced WebContent in its process cache at full leaked +// size instead of exiting it (observed alive at 480MB 90s after its window +// was destroyed). Every check kills each WebContent this app is responsible +// for, other than the live one, at or above SWEEP_FLOOR — which spares +// WebKit's ~7 MB pre-warmed spare (the smallest displaced renderer the old +// reaper ever killed was 522 MB). One path covers a normal recycle, a rebuild +// that gave up, and anything else that leaves one behind. +const SWEEP_FLOOR: u64 = 256 * 1024 * 1024; + +fn sweep_targets(candidates: &[(i32, u64)], live_pid: i32) -> Vec { + if live_pid <= 0 { + return Vec::new(); // the live renderer could be among them } - std::thread::spawn(move || { - std::thread::sleep(Duration::from_secs(10)); - let mut buf = [0u8; 4096]; - let n = unsafe { - proc_pidpath(old_pid, buf.as_mut_ptr() as *mut std::ffi::c_void, buf.len() as u32) - }; - if n <= 0 { - shell_log(&format!("[recycle] old WebContent {old_pid} exited on its own")); - return; - } - let path = String::from_utf8_lossy(&buf[..n as usize]).into_owned(); - if !path.contains("WebKit.WebContent") { - shell_log(&format!("[recycle] pid {old_pid} reused by {path}; not killing")); - return; - } - let fp = phys_footprint(old_pid).unwrap_or(0) / (1024 * 1024); - unsafe { libc::kill(old_pid, libc::SIGKILL) }; - shell_log(&format!("[recycle] killed cached old WebContent {old_pid} ({fp} MB)")); - }); + candidates + .iter() + .filter(|(pid, footprint)| *pid != live_pid && *footprint >= SWEEP_FLOOR) + .map(|(pid, _)| *pid) + .collect() } extern "C" { + fn proc_listallpids(buffer: *mut std::ffi::c_void, buffersize: i32) -> i32; + // Private libSystem: the process macOS holds responsible — for a WebKit + // helper, the app hosting the webview (periscope/resources.py uses it too). + fn responsibility_get_pid_responsible_for_pid(pid: i32) -> i32; fn proc_pidpath(pid: i32, buffer: *mut std::ffi::c_void, buffersize: u32) -> i32; } +/// (pid, footprint) of every WebContent process this app is responsible for. +fn app_webcontents() -> Vec<(i32, u64)> { + let me = std::process::id() as i32; + let mut pids = vec![0i32; 8192]; + // buffersize is in bytes; the return value is a count of pids. + let n = unsafe { + proc_listallpids(pids.as_mut_ptr() as *mut std::ffi::c_void, (pids.len() * 4) as i32) + }; + pids.truncate(n.max(0) as usize); + pids.into_iter() + .filter(|&pid| pid > 0 && unsafe { responsibility_get_pid_responsible_for_pid(pid) } == me) + .filter(|&pid| { + let mut buf = [0u8; 4096]; + let len = unsafe { + proc_pidpath(pid, buf.as_mut_ptr() as *mut std::ffi::c_void, buf.len() as u32) + }; + len > 0 && String::from_utf8_lossy(&buf[..len as usize]).contains("WebKit.WebContent") + }) + .filter_map(|pid| phys_footprint(pid).map(|fp| (pid, fp))) + .collect() +} + +fn sweep(live_pid: i32) { + for pid in sweep_targets(&app_webcontents(), live_pid) { + let fp = phys_footprint(pid).unwrap_or(0) / (1024 * 1024); + unsafe { libc::kill(pid, libc::SIGKILL) }; + shell_log(&format!("[recycle] killed leftover WebContent {pid} ({fp} MB)")); + } +} + #[cfg(test)] mod tests { use super::*; @@ -359,6 +391,19 @@ mod tests { assert!(!should_recycle(&inputs(3 * GB, 30 * MIN, None, 299))); assert!(should_recycle(&inputs(3 * GB, 30 * MIN, None, 300))); } + + const MB: u64 = 1024 * 1024; + + #[test] + fn sweep_takes_big_renderers_other_than_the_live_one() { + let candidates = [(10, 2048 * MB), (11, 7 * MB), (12, 300 * MB), (13, 255 * MB)]; + assert_eq!(sweep_targets(&candidates, 10), vec![12]); + } + + #[test] + fn sweep_does_nothing_while_the_live_renderer_is_unknown() { + assert!(sweep_targets(&[(12, 4096 * MB)], 0).is_empty()); + } } pub fn start_monitor(app: &AppHandle) { @@ -378,12 +423,13 @@ pub fn start_monitor(app: &AppHandle) { )); loop { std::thread::sleep(Duration::from_secs(CHECK_INTERVAL_S)); - // pid read must touch the webview → main thread; measurement and - // policy stay here on the worker. - let h = handle.clone(); - let _ = handle.run_on_main_thread(move || refresh_webcontent_pid(&h)); - std::thread::sleep(Duration::from_millis(200)); - let pid = WEBCONTENT_PID.load(Ordering::Relaxed); + let Some(pid) = read_webcontent_pid(&handle) else { + continue; // main thread busy: no fresh pid, so no sweep and no recycle this tick + }; + WEBCONTENT_PID.store(pid, Ordering::Relaxed); + if !RECYCLING.load(Ordering::SeqCst) { + sweep(pid); + } let Some(fp) = phys_footprint(pid) else { continue; }; From bdfa0593dafa010f8d9ea1886b20a5e29db24f2c Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 1 Nov 2026 18:37:38 -0500 Subject: [PATCH 08/26] docs: tauri-shell and persist.js describe the new recycle gate, rebuild and sweep (THI-449) Co-Authored-By: Claude Sonnet 5 --- docs/tauri-shell.md | 31 ++++++++++++++++++++++--------- static/src/notify/persist.js | 11 ++++++----- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/docs/tauri-shell.md b/docs/tauri-shell.md index 558f3b70..ceea4aff 100644 --- a/docs/tauri-shell.md +++ b/docs/tauri-shell.md @@ -38,15 +38,28 @@ process, memory_pressure doesn't reclaim, synthetic events don't even reproduce it. The shell therefore destroys and recreates the webview window when the renderer's phys_footprint (via WKWebView's private `_webProcessIdentifier` + `proc_pid_rusage`) tops `PERISCOPE_RECYCLE_GB` -(default 1.0) AND system input has been idle `PERISCOPE_RECYCLE_IDLE_S` -(default 300s) — plus a manual View → Recycle Webview item. Two hard-won -invariants: the rebuild must happen on a LATER tick than destroy() (same-tick -rebuild exits the whole app; an ExitRequested veto in main.rs covers the -windowless gap), and the displaced WebContent must be SIGKILLed after a grace -period (WebKit parks it in its process cache at full leaked size instead of -exiting — pid-identity-checked via proc_pidpath before the kill). Actions log -to `~/.config/periscope/shell.log`. Test by lowering the thresholds via -`launchctl setenv` (GUI apps don't inherit shell env) and watching that log. +(default 2.0), the renderer has lived `PERISCOPE_RECYCLE_MIN_AGE_S` (default +1800s) since the last rebuild, and either periscope has been unfocused +`PERISCOPE_RECYCLE_UNFOCUSED_S` (default 30s) or system input has been idle +`PERISCOPE_RECYCLE_IDLE_S` (default 300s) — plus a manual View → Recycle +Webview item with no gate. The rebuild restores every saved window state but +visibility and takes focus only if periscope had it when the recycle +started, so a recycle firing while you've left for another app never pulls +you back; every rebuild attempt's closure sends its result on one shared +channel, so an attempt that times out but later builds the window still +counts as the success it is instead of the loop giving up with the renderer +leaked. Two hard-won invariants: the rebuild must happen on a LATER tick than +destroy() (same-tick rebuild exits the whole app; an ExitRequested veto in +main.rs covers the windowless gap), and a WebContent WebKit parks in its +process cache at full leaked size instead of exiting must still be killed — +every check re-reads the live WebContent pid on the main thread and, once +that read answers with no recycle in flight, SIGKILLs any other app-owned +WebContent (by `proc_listallpids` + `responsibility_get_pid_responsible_for_pid`) +at or above 256 MB, sparing WebKit's ~7 MB pre-warmed spare. Actions log to +`~/.config/periscope/shell.log`. Test by lowering `PERISCOPE_RECYCLE_GB`, +`PERISCOPE_RECYCLE_MIN_AGE_S`, `PERISCOPE_RECYCLE_UNFOCUSED_S` and +`PERISCOPE_RECYCLE_IDLE_S` via `launchctl setenv` (GUI apps don't inherit +shell env; unset afterwards) and watching that log. **Zoom (`src-tauri/src/zoom.rs`).** A bare WKWebView has no ⌘+/⌘−, so View carries Zoom In (⌘=), Zoom Out (⌘-) and Actual Size (⌘0), stepping diff --git a/static/src/notify/persist.js b/static/src/notify/persist.js index 2e01c2d3..b8ea63ad 100644 --- a/static/src/notify/persist.js +++ b/static/src/notify/persist.js @@ -1,9 +1,10 @@ // The notify feed's dedupe state across reloads and webview recycles. -// recycle.rs rebuilds the webview once you've been idle a while, which is -// exactly when banners matter; a fresh sentinel would swallow whatever arrived -// across the rebuild. So the state is saved after every pass and resumed when -// young enough. Older than RESUME_MS (a reload after a long absence), it's -// dropped and the feed reseeds, so old news never replays. +// recycle.rs rebuilds the webview once you've left periscope or been idle a +// while, which is exactly when banners matter; a fresh sentinel would +// swallow whatever arrived across the rebuild. So the state is saved after +// every pass and resumed when young enough. Older than RESUME_MS (a reload +// after a long absence), it's dropped and the feed reseeds, so old news +// never replays. export const STORAGE_KEY = "periscope.notify.v1"; export const RESUME_MS = 15 * 60 * 1000; From 0cb374d57bcccfd87cb63c814a923c65b1eec030 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 1 Nov 2026 18:42:41 -0500 Subject: [PATCH 09/26] =?UTF-8?q?docs(specs):=20recycle=20gate=20=E2=80=94?= =?UTF-8?q?=20wry's=20own=20activate()=20is=20the=20remaining=20focus=20qu?= =?UTF-8?q?estion,=20each=20sweep=20kill=20re-checks,=20bad=20knobs=20fall?= =?UTF-8?q?=20back=20(THI-449)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../2026-11-01-webview-recycle-gate-design.md | 48 ++++++++++++------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md b/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md index fc174f83..03d036c6 100644 --- a/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md +++ b/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md @@ -53,24 +53,29 @@ but later builds the window, its `Ok` reaches the channel before the "already ex behind it; the loop takes that `Ok` as the success it is (clearing `RECYCLING`). An "already exists" that arrives first still means the old window is registered, so the loop keeps retrying as today. No error text is matched. -**D4 — A rebuild never activates the app unless periscope had focus.** `recycle_now` notes `focus::is_focused()` -before `destroy()`. The rebuild builds the window `.focused(false)`, restores every saved state except visibility -(`StateFlags::all() - StateFlags::VISIBLE`), shows it, and calls `set_focus()` only when periscope had focus — so the -manual menu recycle keeps the window focused, and an unfocused-gate recycle leaves you in your terminal. It also -records the rebuild time for D1's minimum age. +**D4 — A rebuild doesn't force focus unless periscope had it.** `recycle_now` notes `focus::is_focused()` +before `destroy()`. The rebuild restores every saved state except visibility +(`StateFlags::all() - StateFlags::VISIBLE`), which removes the plugin's forced `activateIgnoringOtherApps: YES`, and +calls `set_focus()` only when periscope had focus — so the manual menu recycle keeps the window focused. wry still +calls `NSApplication::activate` for every webview it creates (macOS 14+), but that request is cooperative: macOS may +decline it while another app is frontmost. Whether it does is the manual test's first question. It also records the +rebuild time for D1's minimum age. **D5 — Every check sweeps leftover renderers, but only with a fresh live pid; the after-recycle reaper goes.** Once per `CHECK_INTERVAL_S`, the live WebContent pid is read on the main thread and the answer comes back on a channel -with a timeout; the sweep runs only when this tick's read answered with a pid (and no recycle is in flight), so a -stale pid after an unannounced renderer restart (wry reloads the page itself when its WebContent dies) can never be -mistaken for a leftover. With no `main` window the pid is reset to 0. The sweep SIGKILLs every other WebContent the -app is responsible for at or above 256 MB: candidates come from `proc_listallpids`, kept when their executable path -(`proc_pidpath`) contains `WebKit.WebContent` and their responsible pid -(`responsibility_get_pid_responsible_for_pid`, private libSystem, the call `periscope/resources.py` also uses) is -this process. The floor spares WebKit's ~7 MB pre-warmed spare; the smallest displaced renderer the reaper ever -killed was 522 MB. This one path replaces `reap_old_webcontent`'s 10 s kill: it covers a normal recycle (within one -check, ≤ 60 s), a rebuild that gave up, and anything else that leaves one parked. Renderers left by an older run of -the app die with it, so quitting the current app clears today's 41485. +with a timeout; the sweep runs only when this tick's read answered with a pid and no recycle is in flight, and each +kill re-checks both (recycle flag, rebuild time) immediately before signalling, so a stale pid after an unannounced +renderer restart (wry reloads the page itself when its WebContent dies) or a recycle that finished mid-sweep is not +mistaken for a leftover; what remains is a microsecond gap against a crash-reload, whose cost is one page reload. +With no `main` window the pid is reset to 0. The sweep SIGKILLs every other WebContent the app is responsible for at +or above 256 MB: candidates come from `proc_listallpids`, kept when their executable path (`proc_pidpath`) contains +`WebKit.WebContent` and their responsible pid (`responsibility_get_pid_responsible_for_pid`, private libSystem, the +call `periscope/resources.py` also uses) is this process. The floor spares WebKit's ~7 MB pre-warmed spare; the +smallest displaced renderer the reaper ever killed was 522 MB. This one path replaces `reap_old_webcontent`'s 10 s +kill: it covers a normal recycle (within one check, ≤ 60 s), a rebuild that gave up but left a window, and anything +else that leaves one parked. (A give-up that left no window reads a live pid of 0 and sweeps nothing; quitting the +app clears those renderers.) Renderers left by an older run of the app die with it, so quitting the current app +clears today's 41485. **D6 — Policy is pure and unit-tested; the OS calls stay thin.** `should_recycle(Inputs) -> bool` and `sweep_targets(candidates, live_pid) -> Vec` hold the decisions and get `cargo test` cases next to @@ -108,10 +113,19 @@ the app die with it, so quitting the current app clears today's 41485. ## Assumptions +- wry's per-webview `NSApplication::activate` (macOS 14+) is declined while another app is frontmost, so an + unfocused-gate recycle leaves you where you are. If the manual test shows focus being taken, the fix is to record + the frontmost app before the rebuild and hand activation back to it. +- A window shown while the app is inactive doesn't emit `Focused(true)`: after an unfocused recycle the next "waiting" + line must still read "unfocused Ns" with the time still counting, or the unfocused gate would be stuck. +- The app has exactly one legitimate WebContent ≥ 256 MB. A service-worker renderer (`main.jsx` registers `/sw.js`) + or a future WebKit site-isolation process would break that; today's listing shows none. +- Bad knob values (negative, NaN, infinite) fall back to the default rather than stopping the monitor thread. + - `Focused(false)` fires on app switch and ⌘H (tao's `windowDidResignKey`; verified in source). Whether it fires on minimize is untested; if not, the idle gate still covers a minimized periscope. -- A window built `.focused(false)` and shown with `show()` (`makeKeyAndOrderFront`) while the app is inactive does - not activate the app or cover the frontmost app's window. Verify in the manual test. +- The rebuilt window, shown while the app is inactive, doesn't cover the frontmost app's window. Verify in the manual + test. - `proc_listallpids` takes a byte size and returns a count; it and `responsibility_get_pid_responsible_for_pid` link from Rust without a `#[link]` attribute (both verified in a scratch binary). - A displaced renderer is ≥ 256 MB by the time a check sees it; a smaller one is left until it grows or exits. From 74dc107759bb51892360b19115c5c174c1febba3 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 1 Nov 2026 18:54:36 -0500 Subject: [PATCH 10/26] shell: bad recycle knobs fall back instead of panicking, and the sweep rechecks against the live state before each kill (THI-449) Co-Authored-By: Claude Sonnet 5 --- src-tauri/src/focus.rs | 2 +- src-tauri/src/recycle.rs | 123 ++++++++++++++++++++++++++------------- 2 files changed, 85 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/focus.rs b/src-tauri/src/focus.rs index fac0480e..16bbc778 100644 --- a/src-tauri/src/focus.rs +++ b/src-tauri/src/focus.rs @@ -7,7 +7,7 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; -#[derive(Clone, Copy, PartialEq, Debug)] +#[derive(Clone, Copy, PartialEq)] enum Focus { Unknown, // launch, before the first window event Focused, diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index 8f6d8dd7..951157ae 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -46,31 +46,37 @@ fn env_f64(name: &str, default: f64) -> f64 { // A fresh dashboard already measures 1.1–1.6 GB; leaked renderers reach 3–7.7. fn threshold_bytes() -> u64 { - (env_f64("PERISCOPE_RECYCLE_GB", 2.0) * 1024.0 * 1024.0 * 1024.0) as u64 + let gb = env_f64("PERISCOPE_RECYCLE_GB", 2.0); + let gb = if gb.is_finite() && gb >= 0.0 { gb } else { 2.0 }; + (gb * 1024.0 * 1024.0 * 1024.0) as u64 } -fn idle_gate_s() -> f64 { - env_f64("PERISCOPE_RECYCLE_IDLE_S", 300.0) -} - -fn unfocused_gate_s() -> f64 { - env_f64("PERISCOPE_RECYCLE_UNFOCUSED_S", 30.0) -} - -fn min_age_s() -> f64 { - env_f64("PERISCOPE_RECYCLE_MIN_AGE_S", 1800.0) +// Duration::from_secs_f64 panics on a negative, NaN, infinite or overflowing +// value, and "inf"/"-1"/"1e30" all parse as f64 — a bad knob must not kill +// the monitor thread. +fn env_secs(name: &str, default_s: u64) -> Duration { + std::env::var(name) + .ok() + .and_then(|v| v.parse::().ok()) + .and_then(|s| Duration::try_from_secs_f64(s).ok()) + .unwrap_or(Duration::from_secs(default_s)) } // When the current renderer was built (the app's start, then each rebuild): -// D1's minimum age, so a fresh renderer that is already big can't loop. +// PERISCOPE_RECYCLE_MIN_AGE_S's minimum age, so a fresh renderer that is +// already big can't loop. static RENDERER_BORN: Mutex> = Mutex::new(None); fn mark_renderer_born() { *RENDERER_BORN.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now()); } +fn renderer_born_at() -> Option { + *RENDERER_BORN.lock().unwrap_or_else(|e| e.into_inner()) +} + fn renderer_age() -> Duration { - RENDERER_BORN.lock().unwrap_or_else(|e| e.into_inner()).map_or(Duration::ZERO, |t| t.elapsed()) + renderer_born_at().map_or(Duration::ZERO, |t| t.elapsed()) } struct Inputs { @@ -150,13 +156,14 @@ fn seconds_since_last_input() -> f64 { unsafe { CGEventSourceSecondsSinceLastEventType(1, u32::MAX) } } -// --- WebContent pid off the live WKWebView (main thread only) --- +// --- WebContent pid off the live WKWebView (the read dispatches to the main thread) --- /// The live WebContent pid, read off the WKWebView on the main thread. 0 with -/// no `main` window or an OS without the selector; None when the main thread -/// didn't answer in time — a stale pid must never reach the sweep (wry reloads -/// the page itself when its WebContent dies, so the live pid can change -/// without a recycle). +/// no `main` window or an OS without the selector; None with no fresh pid — +/// the window going away, with_webview failing, or no answer within the +/// timeout — a stale pid must never reach the sweep (wry reloads the page +/// itself when its WebContent dies, so the live pid can change without a +/// recycle). fn read_webcontent_pid(app: &AppHandle) -> Option { let Some(window) = app.get_webview_window("main") else { return Some(0); @@ -250,15 +257,18 @@ pub fn recycle_now(app: &AppHandle) { // recycle silently broke rail reordering until the app was // relaunched. .disable_drag_drop_handler() - .focused(false) .build(); let _ = tx.send(match result { Ok(window) => { - // Everything but visibility: the plugin's VISIBLE restore - // calls set_focus, i.e. activateIgnoringOtherApps — it would - // pull you out of the app you left periscope for. + // wry 0.55 calls NSApplication::activate on every webview it + // builds on macOS 14+, regardless of `focused` — so a + // `.focused(false)` builder flag and an explicit `show()` + // changed nothing (the builder's default window is already + // visible). Restoring everything but VISIBLE is what drops + // the forced activateIgnoringOtherApps: YES; wry's own + // activate() is cooperative, so macOS may decline it while + // another app is frontmost (verified by the manual test). let _ = window.restore_state(StateFlags::all() - StateFlags::VISIBLE); - let _ = window.show(); if was_focused { let _ = window.set_focus(); } @@ -281,7 +291,9 @@ pub fn recycle_now(app: &AppHandle) { RECYCLING.store(false, Ordering::SeqCst); return; } - Ok(Err(e)) => shell_log(&format!("[recycle] rebuild attempt {attempt} failed: {e}")), + // The channel is shared across every attempt, so a late Err can + // surface here for an earlier attempt than `attempt` names. + Ok(Err(e)) => shell_log(&format!("[recycle] a rebuild attempt failed: {e}")), Err(_) => shell_log(&format!("[recycle] rebuild attempt {attempt} timed out")), } } @@ -300,14 +312,14 @@ pub fn recycle_now(app: &AppHandle) { // that gave up, and anything else that leaves one behind. const SWEEP_FLOOR: u64 = 256 * 1024 * 1024; -fn sweep_targets(candidates: &[(i32, u64)], live_pid: i32) -> Vec { +fn sweep_targets(candidates: &[(i32, u64)], live_pid: i32) -> Vec<(i32, u64)> { if live_pid <= 0 { return Vec::new(); // the live renderer could be among them } candidates .iter() + .copied() .filter(|(pid, footprint)| *pid != live_pid && *footprint >= SWEEP_FLOOR) - .map(|(pid, _)| *pid) .collect() } @@ -325,7 +337,10 @@ fn app_webcontents() -> Vec<(i32, u64)> { let mut pids = vec![0i32; 8192]; // buffersize is in bytes; the return value is a count of pids. let n = unsafe { - proc_listallpids(pids.as_mut_ptr() as *mut std::ffi::c_void, (pids.len() * 4) as i32) + proc_listallpids( + pids.as_mut_ptr() as *mut std::ffi::c_void, + std::mem::size_of_val(pids.as_slice()) as i32, + ) }; pids.truncate(n.max(0) as usize); pids.into_iter() @@ -341,9 +356,15 @@ fn app_webcontents() -> Vec<(i32, u64)> { .collect() } -fn sweep(live_pid: i32) { - for pid in sweep_targets(&app_webcontents(), live_pid) { - let fp = phys_footprint(pid).unwrap_or(0) / (1024 * 1024); +// `renderer_born` is the value RENDERER_BORN held when the live pid was read; +// a recycle can finish between that read and a kill here, so every kill is +// rechecked against the live state instead of trusting the snapshot. +fn sweep(live_pid: i32, renderer_born: Option) { + for (pid, footprint) in sweep_targets(&app_webcontents(), live_pid) { + if RECYCLING.load(Ordering::SeqCst) || renderer_born_at() != renderer_born { + continue; // a rebuild landed since the pid read; this candidate may now be live + } + let fp = footprint / (1024 * 1024); unsafe { libc::kill(pid, libc::SIGKILL) }; shell_log(&format!("[recycle] killed leftover WebContent {pid} ({fp} MB)")); } @@ -392,12 +413,33 @@ mod tests { assert!(should_recycle(&inputs(3 * GB, 30 * MIN, None, 300))); } + #[test] + fn env_secs_falls_back_on_bad_values() { + assert_eq!(env_secs("PERISCOPE_TEST_ENV_SECS_UNSET", 7), Duration::from_secs(7)); + + std::env::set_var("PERISCOPE_TEST_ENV_SECS_INF", "inf"); + assert_eq!(env_secs("PERISCOPE_TEST_ENV_SECS_INF", 7), Duration::from_secs(7)); + + std::env::set_var("PERISCOPE_TEST_ENV_SECS_NEG", "-1"); + assert_eq!(env_secs("PERISCOPE_TEST_ENV_SECS_NEG", 7), Duration::from_secs(7)); + + std::env::set_var("PERISCOPE_TEST_ENV_SECS_OK", "45"); + assert_eq!(env_secs("PERISCOPE_TEST_ENV_SECS_OK", 7), Duration::from_secs_f64(45.0)); + } + + #[test] + fn threshold_bytes_falls_back_on_bad_gb() { + std::env::set_var("PERISCOPE_RECYCLE_GB", "-1"); + assert_eq!(threshold_bytes(), (2.0 * 1024.0 * 1024.0 * 1024.0) as u64); + std::env::remove_var("PERISCOPE_RECYCLE_GB"); + } + const MB: u64 = 1024 * 1024; #[test] fn sweep_takes_big_renderers_other_than_the_live_one() { let candidates = [(10, 2048 * MB), (11, 7 * MB), (12, 300 * MB), (13, 255 * MB)]; - assert_eq!(sweep_targets(&candidates, 10), vec![12]); + assert_eq!(sweep_targets(&candidates, 10), vec![(12, 300 * MB)]); } #[test] @@ -411,9 +453,9 @@ pub fn start_monitor(app: &AppHandle) { let handle = app.clone(); std::thread::spawn(move || { let threshold = threshold_bytes(); - let min_age = Duration::from_secs_f64(min_age_s()); - let unfocused_gate = Duration::from_secs_f64(unfocused_gate_s()); - let idle_gate = Duration::from_secs_f64(idle_gate_s()); + let min_age = env_secs("PERISCOPE_RECYCLE_MIN_AGE_S", 1800); + let unfocused_gate = env_secs("PERISCOPE_RECYCLE_UNFOCUSED_S", 30); + let idle_gate = env_secs("PERISCOPE_RECYCLE_IDLE_S", 300); shell_log(&format!( "[recycle] monitor started (threshold {} MB, min age {}s, unfocused gate {}s, idle gate {}s)", threshold / (1024 * 1024), @@ -423,12 +465,13 @@ pub fn start_monitor(app: &AppHandle) { )); loop { std::thread::sleep(Duration::from_secs(CHECK_INTERVAL_S)); + let renderer_born = renderer_born_at(); // snapshot before the pid read; sweep rechecks against it let Some(pid) = read_webcontent_pid(&handle) else { - continue; // main thread busy: no fresh pid, so no sweep and no recycle this tick + continue; // no fresh pid: no sweep and no recycle this tick }; WEBCONTENT_PID.store(pid, Ordering::Relaxed); if !RECYCLING.load(Ordering::SeqCst) { - sweep(pid); + sweep(pid, renderer_born); } let Some(fp) = phys_footprint(pid) else { continue; @@ -436,20 +479,22 @@ pub fn start_monitor(app: &AppHandle) { if fp < threshold { continue; } + let unfocused_for = crate::focus::unfocused_for(); + let is_focused = crate::focus::is_focused(); // read alongside unfocused_for, not later in the log path let inputs = Inputs { footprint: fp, threshold, age: renderer_age(), min_age, - unfocused_for: crate::focus::unfocused_for(), + unfocused_for, unfocused_gate, - idle: Duration::from_secs_f64(seconds_since_last_input().max(0.0)), + idle: Duration::try_from_secs_f64(seconds_since_last_input()).unwrap_or_default(), idle_gate, }; if !should_recycle(&inputs) { let focus = match inputs.unfocused_for { Some(d) => format!("unfocused {}s", d.as_secs()), - None if crate::focus::is_focused() => "focused".to_string(), + None if is_focused => "focused".to_string(), None => "focus unknown".to_string(), }; shell_log(&format!( From be16d4efd3b1ed6895352a93610cbfafdf4323a9 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 1 Nov 2026 21:54:50 -0500 Subject: [PATCH 11/26] =?UTF-8?q?shell:=20a=20recycle=20that=20started=20u?= =?UTF-8?q?nfocused=20hands=20focus=20back=20to=20the=20app=20you=20were?= =?UTF-8?q?=20in=20=E2=80=94=20wry's=20per-webview=20activate()=20is=20hon?= =?UTF-8?q?oured=20(THI-449)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/recycle.rs | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index 951157ae..8dff2c3c 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -28,7 +28,7 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; use objc2::runtime::AnyObject; -use objc2::{msg_send, sel}; +use objc2::{class, msg_send, sel}; use tauri::{AppHandle, Manager, WebviewUrl}; use tauri_plugin_window_state::{AppHandleExt, StateFlags, WindowExt}; @@ -207,6 +207,34 @@ fn dirs_path() -> std::path::PathBuf { std::path::PathBuf::from(home).join(".config/periscope/shell.log") } +// --- handing focus back after a rebuild (main thread) --- + +// wry activates the app for every webview it creates (macOS 14+), and macOS +// honours it even while you're in another app: on 2026-11-01 a recycle fired +// with Claude frontmost and left Periscope frontmost 0.5 s later. So a recycle +// that started unfocused notes the frontmost app and activates it again. +fn frontmost_other_app() -> Option { + unsafe { + let workspace: *mut AnyObject = msg_send![class!(NSWorkspace), sharedWorkspace]; + let app: *mut AnyObject = msg_send![workspace, frontmostApplication]; + if app.is_null() { + return None; + } + let pid: i32 = msg_send![app, processIdentifier]; + (pid != std::process::id() as i32).then_some(pid) + } +} + +fn activate_app(pid: i32) { + unsafe { + let app: *mut AnyObject = + msg_send![class!(NSRunningApplication), runningApplicationWithProcessIdentifier: pid]; + if !app.is_null() { + let _: bool = msg_send![app, activateWithOptions: 0usize]; + } + } +} + // Destroy the sole window, then rebuild it on a later tick. Rebuilding the // same label in the same main-thread closure as destroy() killed the whole // app (observed 2026-07-28: process gone, no window) — the deferred rebuild @@ -216,6 +244,7 @@ pub fn recycle_now(app: &AppHandle) { return; // one at a time } let was_focused = crate::focus::is_focused(); // before destroy() sets focus to false + let hand_back_to = if was_focused { None } else { frontmost_other_app() }; let old_pid = WEBCONTENT_PID.load(Ordering::Relaxed); let fp = phys_footprint(old_pid).unwrap_or(0); shell_log(&format!( @@ -289,6 +318,12 @@ pub fn recycle_now(app: &AppHandle) { Ok(Ok(())) => { shell_log("[recycle] webview rebuilt"); RECYCLING.store(false, Ordering::SeqCst); + if let Some(pid) = hand_back_to { + // After wry's own activate() has landed. + std::thread::sleep(Duration::from_millis(500)); + let _ = handle.run_on_main_thread(move || activate_app(pid)); + shell_log(&format!("[recycle] handed focus back to pid {pid}")); + } return; } // The channel is shared across every attempt, so a late Err can From f928294b9963150e4f5894557c4abdffea920bb8 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 1 Nov 2026 21:55:10 -0500 Subject: [PATCH 12/26] docs: the recycle hands focus back, and testing knobs go through open --env (THI-449) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../2026-11-01-webview-recycle-gate-design.md | 26 +++++++++++-------- docs/tauri-shell.md | 11 +++++--- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md b/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md index 03d036c6..05954d7d 100644 --- a/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md +++ b/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md @@ -57,9 +57,11 @@ first still means the old window is registered, so the loop keeps retrying as to before `destroy()`. The rebuild restores every saved state except visibility (`StateFlags::all() - StateFlags::VISIBLE`), which removes the plugin's forced `activateIgnoringOtherApps: YES`, and calls `set_focus()` only when periscope had focus — so the manual menu recycle keeps the window focused. wry still -calls `NSApplication::activate` for every webview it creates (macOS 14+), but that request is cooperative: macOS may -decline it while another app is frontmost. Whether it does is the manual test's first question. It also records the -rebuild time for D1's minimum age. +calls `NSApplication::activate` for every webview it creates (macOS 14+), and the manual test showed macOS honours +it while another app is frontmost (a recycle with Claude frontmost left Periscope frontmost 0.5 s later). So a +recycle that started unfocused notes the frontmost app's pid (`NSWorkspace.frontmostApplication`) before +`destroy()`, and 0.5 s after the rebuild lands activates it again (`NSRunningApplication activateWithOptions:`); +verified: Claude stayed frontmost through two recycles. It also records the rebuild time for D1's minimum age. **D5 — Every check sweeps leftover renderers, but only with a fresh live pid; the after-recycle reaper goes.** Once per `CHECK_INTERVAL_S`, the live WebContent pid is read on the main thread and the answer comes back on a channel @@ -101,9 +103,9 @@ clears today's 41485. - `sweep_targets`: skips the live pid, skips < 256 MB, takes nothing when the live pid is unknown (0), keeps ≥ 256 MB others. - `focus`: Unknown → Focused → UnfocusedSince (clock runs, a repeated false keeps it) → Focused. -- Manual, on the rebuilt `.app` (replacing the running app needs the user's go-ahead), with - `launchctl setenv PERISCOPE_RECYCLE_GB 0.5`, `…_MIN_AGE_S 60` and `…_UNFOCUSED_S 10` (GUI apps don't inherit shell - env; unset afterwards): +- Manual, on the rebuilt `.app` (replacing the running app needs the user's go-ahead), launched with + `open --env PERISCOPE_RECYCLE_GB=0.2 --env …_MIN_AGE_S=60 --env …_UNFOCUSED_S=10` (`launchctl setenv` from the + agent's shell did not reach the app): - switching to another app for 10 s recycles, and you **stay** in that app (D4); - no second recycle within the minimum age; - the manual menu recycle leaves periscope focused; @@ -113,11 +115,13 @@ clears today's 41485. ## Assumptions -- wry's per-webview `NSApplication::activate` (macOS 14+) is declined while another app is frontmost, so an - unfocused-gate recycle leaves you where you are. If the manual test shows focus being taken, the fix is to record - the frontmost app before the rebuild and hand activation back to it. -- A window shown while the app is inactive doesn't emit `Focused(true)`: after an unfocused recycle the next "waiting" - line must still read "unfocused Ns" with the time still counting, or the unfocused gate would be stuck. +- Handing focus back 0.5 s after the rebuild lands comes after wry's activation; verified on macOS 27 (Claude stayed + frontmost at every sample from 0.25 s to 5 s). A slower machine could show Periscope briefly before the hand-back. +- After a hand-back the unfocused clock restarts (wry's activation emits `Focused(true)`, the hand-back + `Focused(false)`); verified: the next "waiting" line read "unfocused 59s". With the 30 min minimum age this only + delays the next recycle by the 30 s gate. +- Not exercised by the manual test (no UI scripting available): the manual menu recycle while focused, and whether + minimizing counts as unfocused. - The app has exactly one legitimate WebContent ≥ 256 MB. A service-worker renderer (`main.jsx` registers `/sw.js`) or a future WebKit site-isolation process would break that; today's listing shows none. - Bad knob values (negative, NaN, infinite) fall back to the default rather than stopping the monitor thread. diff --git a/docs/tauri-shell.md b/docs/tauri-shell.md index ceea4aff..59c39370 100644 --- a/docs/tauri-shell.md +++ b/docs/tauri-shell.md @@ -44,8 +44,10 @@ when the renderer's phys_footprint (via WKWebView's private `PERISCOPE_RECYCLE_IDLE_S` (default 300s) — plus a manual View → Recycle Webview item with no gate. The rebuild restores every saved window state but visibility and takes focus only if periscope had it when the recycle -started, so a recycle firing while you've left for another app never pulls -you back; every rebuild attempt's closure sends its result on one shared +started. wry still activates the app for every webview it creates (macOS +14+), and macOS honours that even while you're in another app, so a recycle +that started unfocused notes the frontmost app and activates it again 0.5 s +after the rebuild — you stay where you were; every rebuild attempt's closure sends its result on one shared channel, so an attempt that times out but later builds the window still counts as the success it is instead of the loop giving up with the renderer leaked. Two hard-won invariants: the rebuild must happen on a LATER tick than @@ -58,8 +60,9 @@ WebContent (by `proc_listallpids` + `responsibility_get_pid_responsible_for_pid` at or above 256 MB, sparing WebKit's ~7 MB pre-warmed spare. Actions log to `~/.config/periscope/shell.log`. Test by lowering `PERISCOPE_RECYCLE_GB`, `PERISCOPE_RECYCLE_MIN_AGE_S`, `PERISCOPE_RECYCLE_UNFOCUSED_S` and -`PERISCOPE_RECYCLE_IDLE_S` via `launchctl setenv` (GUI apps don't inherit -shell env; unset afterwards) and watching that log. +`PERISCOPE_RECYCLE_IDLE_S` with `open --env NAME=value Periscope.app` (GUI +apps don't inherit shell env, and a `launchctl setenv` from a Claude Code +shell didn't reach an app launched with `open`) and watching that log. **Zoom (`src-tauri/src/zoom.rs`).** A bare WKWebView has no ⌘+/⌘−, so View carries Zoom In (⌘=), Zoom Out (⌘-) and Actual Size (⌘0), stepping From 14b2b93063e7cf115e5388ba1dd3de6291a6941b Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 1 Nov 2026 22:02:23 -0500 Subject: [PATCH 13/26] shell: the hand-back only runs while Periscope or the app you left is frontmost, and logs what it did (THI-449) Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/recycle.rs | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index 8dff2c3c..2bf27c23 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -289,14 +289,10 @@ pub fn recycle_now(app: &AppHandle) { .build(); let _ = tx.send(match result { Ok(window) => { - // wry 0.55 calls NSApplication::activate on every webview it - // builds on macOS 14+, regardless of `focused` — so a - // `.focused(false)` builder flag and an explicit `show()` - // changed nothing (the builder's default window is already - // visible). Restoring everything but VISIBLE is what drops - // the forced activateIgnoringOtherApps: YES; wry's own - // activate() is cooperative, so macOS may decline it while - // another app is frontmost (verified by the manual test). + // Everything but VISIBLE: the plugin's VISIBLE restore calls + // set_focus (activateIgnoringOtherApps: YES). wry's own + // activate() on every webview it builds still lands, so an + // unfocused recycle hands focus back once the rebuild is in. let _ = window.restore_state(StateFlags::all() - StateFlags::VISIBLE); if was_focused { let _ = window.set_focus(); @@ -319,10 +315,19 @@ pub fn recycle_now(app: &AppHandle) { shell_log("[recycle] webview rebuilt"); RECYCLING.store(false, Ordering::SeqCst); if let Some(pid) = hand_back_to { - // After wry's own activate() has landed. + // After wry's own activate() has landed. Only while Periscope + // (wry's activation) or that app is frontmost: if you moved + // to a third app meanwhile, stay there. std::thread::sleep(Duration::from_millis(500)); - let _ = handle.run_on_main_thread(move || activate_app(pid)); - shell_log(&format!("[recycle] handed focus back to pid {pid}")); + let _ = handle.run_on_main_thread(move || match frontmost_other_app() { + Some(other) if other != pid => { + shell_log(&format!("[recycle] left focus with pid {other}")) + } + _ => { + activate_app(pid); + shell_log(&format!("[recycle] handed focus back to pid {pid}")); + } + }); } return; } From 314d97a4e1f92f28a1f363b2ac6ec3a12660c532 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 1 Nov 2026 22:02:23 -0500 Subject: [PATCH 14/26] docs: tauri-shell's recycling paragraph re-wrapped; the spec's assumptions record the test results and what stays untested (THI-449) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../2026-11-01-webview-recycle-gate-design.md | 20 +++++++++------- docs/tauri-shell.md | 24 ++++++++++--------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md b/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md index 05954d7d..86268d2e 100644 --- a/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md +++ b/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md @@ -116,20 +116,24 @@ clears today's 41485. ## Assumptions - Handing focus back 0.5 s after the rebuild lands comes after wry's activation; verified on macOS 27 (Claude stayed - frontmost at every sample from 0.25 s to 5 s). A slower machine could show Periscope briefly before the hand-back. + frontmost at every sample from 0.25 s to 5 s). Periscope may show for about 0.5 s on each unfocused recycle; on a + slower machine wry's activation could land after the hand-back and Periscope would keep focus. +- The hand-back targets the app frontmost when the recycle started, and runs only while Periscope or that app is + frontmost, so moving to a third app during the ~1 s gap is respected. A deliberate click into Periscope during the + gap looks like wry's activation and is reverted. - After a hand-back the unfocused clock restarts (wry's activation emits `Focused(true)`, the hand-back `Focused(false)`); verified: the next "waiting" line read "unfocused 59s". With the 30 min minimum age this only delays the next recycle by the 30 s gate. -- Not exercised by the manual test (no UI scripting available): the manual menu recycle while focused, and whether - minimizing counts as unfocused. -- The app has exactly one legitimate WebContent ≥ 256 MB. A service-worker renderer (`main.jsx` registers `/sw.js`) - or a future WebKit site-isolation process would break that; today's listing shows none. +- Not exercised by the manual test (no UI scripting available): the manual menu recycle while focused; whether + minimizing counts as unfocused (a minimized or hidden Periscope comes back shown, since MINIMIZED isn't a restored + flag and wry's activation unhides the app); an idle-gate recycle while the screen is locked (the frontmost app is + then probably loginwindow); a fullscreen Periscope in another Space (the Space may switch twice). +- The app has exactly one legitimate WebContent ≥ 256 MB. A service-worker renderer (`main.jsx` registers `/sw.js`), + a future WebKit site-isolation process, or the Web Inspector frontend in a debug build would break that; today's + listing shows none. - Bad knob values (negative, NaN, infinite) fall back to the default rather than stopping the monitor thread. - - `Focused(false)` fires on app switch and ⌘H (tao's `windowDidResignKey`; verified in source). Whether it fires on minimize is untested; if not, the idle gate still covers a minimized periscope. -- The rebuilt window, shown while the app is inactive, doesn't cover the frontmost app's window. Verify in the manual - test. - `proc_listallpids` takes a byte size and returns a count; it and `responsibility_get_pid_responsible_for_pid` link from Rust without a `#[link]` attribute (both verified in a scratch binary). - A displaced renderer is ≥ 256 MB by the time a check sees it; a smaller one is left until it grows or exits. diff --git a/docs/tauri-shell.md b/docs/tauri-shell.md index 59c39370..d122ad4b 100644 --- a/docs/tauri-shell.md +++ b/docs/tauri-shell.md @@ -47,17 +47,19 @@ visibility and takes focus only if periscope had it when the recycle started. wry still activates the app for every webview it creates (macOS 14+), and macOS honours that even while you're in another app, so a recycle that started unfocused notes the frontmost app and activates it again 0.5 s -after the rebuild — you stay where you were; every rebuild attempt's closure sends its result on one shared -channel, so an attempt that times out but later builds the window still -counts as the success it is instead of the loop giving up with the renderer -leaked. Two hard-won invariants: the rebuild must happen on a LATER tick than -destroy() (same-tick rebuild exits the whole app; an ExitRequested veto in -main.rs covers the windowless gap), and a WebContent WebKit parks in its -process cache at full leaked size instead of exiting must still be killed — -every check re-reads the live WebContent pid on the main thread and, once -that read answers with no recycle in flight, SIGKILLs any other app-owned -WebContent (by `proc_listallpids` + `responsibility_get_pid_responsible_for_pid`) -at or above 256 MB, sparing WebKit's ~7 MB pre-warmed spare. Actions log to +after the rebuild, provided Periscope or that app is still frontmost — you +stay where you were. Every rebuild attempt's closure sends its result on one +shared channel, so an attempt that times out but later builds the window +still counts as the success it is instead of the loop giving up with the +renderer leaked. Two hard-won invariants: the rebuild must happen on a LATER +tick than destroy() (same-tick rebuild exits the whole app; an ExitRequested +veto in main.rs covers the windowless gap), and a WebContent WebKit parks in +its process cache at full leaked size instead of exiting must still be +killed — every check re-reads the live WebContent pid on the main thread +and, once that read answers with no recycle in flight, SIGKILLs any other +app-owned WebContent (by `proc_listallpids` + +`responsibility_get_pid_responsible_for_pid`) at or above 256 MB, sparing +WebKit's ~7 MB pre-warmed spare. Actions log to `~/.config/periscope/shell.log`. Test by lowering `PERISCOPE_RECYCLE_GB`, `PERISCOPE_RECYCLE_MIN_AGE_S`, `PERISCOPE_RECYCLE_UNFOCUSED_S` and `PERISCOPE_RECYCLE_IDLE_S` with `open --env NAME=value Periscope.app` (GUI From e66f5ab6aa8c42dc35dba175bf7a77b95647aa10 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Mon, 2 Nov 2026 12:01:41 -0500 Subject: [PATCH 15/26] shell: the window-state plugin no longer restores VISIBLE, whose set_focus was what pulled focus on a rebuild; the explicit restore, set_focus and focus hand-back go (THI-449) Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/main.rs | 11 ++++++- src-tauri/src/recycle.rs | 65 +++++----------------------------------- 2 files changed, 18 insertions(+), 58 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6174f9a7..3035fee0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,6 +3,7 @@ use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder}; use tauri::Manager; +use tauri_plugin_window_state::StateFlags; mod focus; mod notifications; @@ -19,7 +20,15 @@ fn main() { let _ = window.set_focus(); } })) - .plugin(tauri_plugin_window_state::Builder::default().build()) + // Everything but VISIBLE: the plugin restores state on every window it + // sees, each recycle's rebuild included, and its VISIBLE restore calls + // set_focus (activateIgnoringOtherApps: YES), pulling you out of the + // app you're in. tauri.conf.json already shows the startup window. + .plugin( + tauri_plugin_window_state::Builder::default() + .with_state_flags(StateFlags::all() - StateFlags::VISIBLE) + .build(), + ) .plugin(tauri_plugin_opener::init()) .setup(|app| { // macOS expects an App menu with at least Quit; without a menu diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index 2bf27c23..71ebb101 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -28,9 +28,9 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; use objc2::runtime::AnyObject; -use objc2::{class, msg_send, sel}; +use objc2::{msg_send, sel}; use tauri::{AppHandle, Manager, WebviewUrl}; -use tauri_plugin_window_state::{AppHandleExt, StateFlags, WindowExt}; +use tauri_plugin_window_state::{AppHandleExt, StateFlags}; const CHECK_INTERVAL_S: u64 = 60; const DASHBOARD_URL: &str = "http://127.0.0.1:17374"; @@ -207,34 +207,6 @@ fn dirs_path() -> std::path::PathBuf { std::path::PathBuf::from(home).join(".config/periscope/shell.log") } -// --- handing focus back after a rebuild (main thread) --- - -// wry activates the app for every webview it creates (macOS 14+), and macOS -// honours it even while you're in another app: on 2026-11-01 a recycle fired -// with Claude frontmost and left Periscope frontmost 0.5 s later. So a recycle -// that started unfocused notes the frontmost app and activates it again. -fn frontmost_other_app() -> Option { - unsafe { - let workspace: *mut AnyObject = msg_send![class!(NSWorkspace), sharedWorkspace]; - let app: *mut AnyObject = msg_send![workspace, frontmostApplication]; - if app.is_null() { - return None; - } - let pid: i32 = msg_send![app, processIdentifier]; - (pid != std::process::id() as i32).then_some(pid) - } -} - -fn activate_app(pid: i32) { - unsafe { - let app: *mut AnyObject = - msg_send![class!(NSRunningApplication), runningApplicationWithProcessIdentifier: pid]; - if !app.is_null() { - let _: bool = msg_send![app, activateWithOptions: 0usize]; - } - } -} - // Destroy the sole window, then rebuild it on a later tick. Rebuilding the // same label in the same main-thread closure as destroy() killed the whole // app (observed 2026-07-28: process gone, no window) — the deferred rebuild @@ -243,16 +215,14 @@ pub fn recycle_now(app: &AppHandle) { if RECYCLING.swap(true, Ordering::SeqCst) { return; // one at a time } - let was_focused = crate::focus::is_focused(); // before destroy() sets focus to false - let hand_back_to = if was_focused { None } else { frontmost_other_app() }; let old_pid = WEBCONTENT_PID.load(Ordering::Relaxed); let fp = phys_footprint(old_pid).unwrap_or(0); shell_log(&format!( "[recycle] destroying webview (WebContent pid {old_pid}, footprint {} MB)", fp / (1024 * 1024) )); - // Persist the frame explicitly; the plugin's own save fires on exit, not - // on destroy-and-rebuild. + // Persist the frame for the plugin to restore on the rebuilt window; its + // own save fires on exit only. let _ = app.save_window_state(StateFlags::all()); if let Some(window) = app.get_webview_window("main") { let _ = window.destroy(); @@ -287,16 +257,12 @@ pub fn recycle_now(app: &AppHandle) { // relaunched. .disable_drag_drop_handler() .build(); + // By now the window-state plugin has restored the frame (main.rs). + // Nothing in the rebuild activates the app — wry's own activate() + // is declined while another app is frontmost — so keystrokes stay + // with the app you're in. let _ = tx.send(match result { Ok(window) => { - // Everything but VISIBLE: the plugin's VISIBLE restore calls - // set_focus (activateIgnoringOtherApps: YES). wry's own - // activate() on every webview it builds still lands, so an - // unfocused recycle hands focus back once the rebuild is in. - let _ = window.restore_state(StateFlags::all() - StateFlags::VISIBLE); - if was_focused { - let _ = window.set_focus(); - } // A fresh WKWebView starts at pageZoom 1.0. crate::zoom::apply(&window); WEBCONTENT_PID.store(0, Ordering::Relaxed); // re-learned next tick @@ -314,21 +280,6 @@ pub fn recycle_now(app: &AppHandle) { Ok(Ok(())) => { shell_log("[recycle] webview rebuilt"); RECYCLING.store(false, Ordering::SeqCst); - if let Some(pid) = hand_back_to { - // After wry's own activate() has landed. Only while Periscope - // (wry's activation) or that app is frontmost: if you moved - // to a third app meanwhile, stay there. - std::thread::sleep(Duration::from_millis(500)); - let _ = handle.run_on_main_thread(move || match frontmost_other_app() { - Some(other) if other != pid => { - shell_log(&format!("[recycle] left focus with pid {other}")) - } - _ => { - activate_app(pid); - shell_log(&format!("[recycle] handed focus back to pid {pid}")); - } - }); - } return; } // The channel is shared across every attempt, so a late Err can From 884c2de22a624d2c8fe457afa7c1084a51c9ff23 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Mon, 2 Nov 2026 12:01:55 -0500 Subject: [PATCH 16/26] shell: a recycle of a minimized window minimizes the rebuilt one (THI-449) Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/recycle.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index 71ebb101..ae8c5e7b 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -224,9 +224,14 @@ pub fn recycle_now(app: &AppHandle) { // Persist the frame for the plugin to restore on the rebuilt window; its // own save fires on exit only. let _ = app.save_window_state(StateFlags::all()); - if let Some(window) = app.get_webview_window("main") { - let _ = window.destroy(); - } + let was_minimized = match app.get_webview_window("main") { + Some(window) => { + let minimized = window.is_minimized().unwrap_or(false); + let _ = window.destroy(); + minimized + } + None => false, + }; let handle = app.clone(); std::thread::spawn(move || { // One channel for every attempt. The main thread runs the closures in @@ -263,6 +268,11 @@ pub fn recycle_now(app: &AppHandle) { // with the app you're in. let _ = tx.send(match result { Ok(window) => { + // The builder makes a shown window, and the plugin has no + // minimized state to restore. + if was_minimized { + let _ = window.minimize(); + } // A fresh WKWebView starts at pageZoom 1.0. crate::zoom::apply(&window); WEBCONTENT_PID.store(0, Ordering::Relaxed); // re-learned next tick From dc8419b81bbc3ee6ecc9ced8a262970978b1da73 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Mon, 2 Nov 2026 12:03:09 -0500 Subject: [PATCH 17/26] shell: the sweep kills only renderers the monitor has seen live, not any other one over 256 MB; no pid is None, not 0 (THI-449) Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/recycle.rs | 109 ++++++++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 43 deletions(-) diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index ae8c5e7b..88b05530 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -23,7 +23,8 @@ // debug-build app, guarded by respondsToSelector so an OS that drops the // selector degrades to "manual recycle only" instead of crashing. -use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +use std::collections::BTreeSet; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use std::time::{Duration, Instant}; @@ -38,7 +39,19 @@ const DASHBOARD_URL: &str = "http://127.0.0.1:17374"; // Set while a recycle is mid-flight so the ExitRequested handler in main.rs // can veto the "last window closed → quit" path between destroy and rebuild. pub static RECYCLING: AtomicBool = AtomicBool::new(false); -static WEBCONTENT_PID: AtomicI32 = AtomicI32::new(0); + +// The WebContent pids the monitor has read as live: `last` names the renderer +// a recycle destroys, `seen` is what the sweep may kill once it isn't live. +struct LiveRenderers { + last: Option, + seen: BTreeSet, +} + +static LIVE_RENDERERS: Mutex = Mutex::new(LiveRenderers { last: None, seen: BTreeSet::new() }); + +fn live_renderers() -> std::sync::MutexGuard<'static, LiveRenderers> { + LIVE_RENDERERS.lock().unwrap_or_else(|e| e.into_inner()) +} fn env_f64(name: &str, default: f64) -> f64 { std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default) @@ -120,9 +133,6 @@ extern "C" { } fn phys_footprint(pid: i32) -> Option { - if pid <= 0 { - return None; - } let mut info = RusageInfoV0 { ri_uuid: [0; 16], ri_user_time: 0, @@ -158,16 +168,13 @@ fn seconds_since_last_input() -> f64 { // --- WebContent pid off the live WKWebView (the read dispatches to the main thread) --- -/// The live WebContent pid, read off the WKWebView on the main thread. 0 with -/// no `main` window or an OS without the selector; None with no fresh pid — -/// the window going away, with_webview failing, or no answer within the +/// The live WebContent pid, read off the WKWebView on the main thread. None +/// with no `main` window, an OS without the selector, or no answer within the /// timeout — a stale pid must never reach the sweep (wry reloads the page /// itself when its WebContent dies, so the live pid can change without a /// recycle). fn read_webcontent_pid(app: &AppHandle) -> Option { - let Some(window) = app.get_webview_window("main") else { - return Some(0); - }; + let window = app.get_webview_window("main")?; let (tx, rx) = std::sync::mpsc::channel::(); window .with_webview(move |webview| { @@ -185,7 +192,7 @@ fn read_webcontent_pid(app: &AppHandle) -> Option { let _ = tx.send(pid); }) .ok()?; - rx.recv_timeout(Duration::from_secs(2)).ok() + rx.recv_timeout(Duration::from_secs(2)).ok().filter(|&pid| pid > 0) } // Append-only shell log — `open`-launched apps have no visible stderr, and @@ -215,10 +222,11 @@ pub fn recycle_now(app: &AppHandle) { if RECYCLING.swap(true, Ordering::SeqCst) { return; // one at a time } - let old_pid = WEBCONTENT_PID.load(Ordering::Relaxed); - let fp = phys_footprint(old_pid).unwrap_or(0); + let old_pid = live_renderers().last; + let fp = old_pid.and_then(phys_footprint).unwrap_or(0); shell_log(&format!( - "[recycle] destroying webview (WebContent pid {old_pid}, footprint {} MB)", + "[recycle] destroying webview (WebContent pid {}, footprint {} MB)", + old_pid.map_or("unknown".to_string(), |pid| pid.to_string()), fp / (1024 * 1024) )); // Persist the frame for the plugin to restore on the rebuilt window; its @@ -275,7 +283,7 @@ pub fn recycle_now(app: &AppHandle) { } // A fresh WKWebView starts at pageZoom 1.0. crate::zoom::apply(&window); - WEBCONTENT_PID.store(0, Ordering::Relaxed); // re-learned next tick + live_renderers().last = None; // re-learned next tick mark_renderer_born(); Ok(()) } @@ -307,21 +315,18 @@ pub fn recycle_now(app: &AppHandle) { // WebKit parks a displaced WebContent in its process cache at full leaked // size instead of exiting it (observed alive at 480MB 90s after its window // was destroyed). Every check kills each WebContent this app is responsible -// for, other than the live one, at or above SWEEP_FLOOR — which spares -// WebKit's ~7 MB pre-warmed spare (the smallest displaced renderer the old -// reaper ever killed was 522 MB). One path covers a normal recycle, a rebuild -// that gave up, and anything else that leaves one behind. -const SWEEP_FLOOR: u64 = 256 * 1024 * 1024; - -fn sweep_targets(candidates: &[(i32, u64)], live_pid: i32) -> Vec<(i32, u64)> { - if live_pid <= 0 { - return Vec::new(); // the live renderer could be among them - } - candidates - .iter() - .copied() - .filter(|(pid, footprint)| *pid != live_pid && *footprint >= SWEEP_FLOOR) - .collect() +// for that the monitor read as live on an earlier tick and that isn't live +// now — never WebKit's pre-warmed spare or another renderer the page never +// ran in. The live pid is spared even when seen before: WebKit's process +// cache can hand a parked renderer back to a new webview. One path covers a +// normal recycle, a rebuild that gave up, and anything else that leaves one +// behind. + +/// The candidates in `seen` other than `live_pid`; drops the pids that are no +/// longer candidates from `seen`. +fn sweep_targets(candidates: &[i32], seen: &mut BTreeSet, live_pid: i32) -> Vec { + seen.retain(|pid| candidates.contains(pid)); + seen.iter().copied().filter(|&pid| pid != live_pid).collect() } extern "C" { @@ -332,8 +337,8 @@ extern "C" { fn proc_pidpath(pid: i32, buffer: *mut std::ffi::c_void, buffersize: u32) -> i32; } -/// (pid, footprint) of every WebContent process this app is responsible for. -fn app_webcontents() -> Vec<(i32, u64)> { +/// Every WebContent process this app is responsible for. +fn app_webcontents() -> Vec { let me = std::process::id() as i32; let mut pids = vec![0i32; 8192]; // buffersize is in bytes; the return value is a count of pids. @@ -353,7 +358,6 @@ fn app_webcontents() -> Vec<(i32, u64)> { }; len > 0 && String::from_utf8_lossy(&buf[..len as usize]).contains("WebKit.WebContent") }) - .filter_map(|pid| phys_footprint(pid).map(|fp| (pid, fp))) .collect() } @@ -361,11 +365,13 @@ fn app_webcontents() -> Vec<(i32, u64)> { // a recycle can finish between that read and a kill here, so every kill is // rechecked against the live state instead of trusting the snapshot. fn sweep(live_pid: i32, renderer_born: Option) { - for (pid, footprint) in sweep_targets(&app_webcontents(), live_pid) { + let candidates = app_webcontents(); + let targets = sweep_targets(&candidates, &mut live_renderers().seen, live_pid); + for pid in targets { if RECYCLING.load(Ordering::SeqCst) || renderer_born_at() != renderer_born { continue; // a rebuild landed since the pid read; this candidate may now be live } - let fp = footprint / (1024 * 1024); + let fp = phys_footprint(pid).unwrap_or(0) / (1024 * 1024); unsafe { libc::kill(pid, libc::SIGKILL) }; shell_log(&format!("[recycle] killed leftover WebContent {pid} ({fp} MB)")); } @@ -435,17 +441,30 @@ mod tests { std::env::remove_var("PERISCOPE_RECYCLE_GB"); } - const MB: u64 = 1024 * 1024; + #[test] + fn sweep_takes_renderers_seen_live_that_are_no_longer_live() { + let mut seen = BTreeSet::from([10, 12]); + assert_eq!(sweep_targets(&[10, 11, 12], &mut seen, 10), vec![12]); + } #[test] - fn sweep_takes_big_renderers_other_than_the_live_one() { - let candidates = [(10, 2048 * MB), (11, 7 * MB), (12, 300 * MB), (13, 255 * MB)]; - assert_eq!(sweep_targets(&candidates, 10), vec![(12, 300 * MB)]); + fn sweep_never_takes_the_live_renderer() { + // WebKit's process cache can hand a parked renderer back to a new webview. + let mut seen = BTreeSet::from([10]); + assert!(sweep_targets(&[10], &mut seen, 10).is_empty()); } #[test] - fn sweep_does_nothing_while_the_live_renderer_is_unknown() { - assert!(sweep_targets(&[(12, 4096 * MB)], 0).is_empty()); + fn sweep_never_takes_a_renderer_it_has_not_seen_live() { + let mut seen = BTreeSet::new(); + assert!(sweep_targets(&[11], &mut seen, 10).is_empty()); + } + + #[test] + fn sweep_forgets_renderers_that_are_gone() { + let mut seen = BTreeSet::from([10, 12, 99]); + assert_eq!(sweep_targets(&[10, 12], &mut seen, 10), vec![12]); + assert_eq!(seen, BTreeSet::from([10, 12])); } } @@ -470,10 +489,14 @@ pub fn start_monitor(app: &AppHandle) { let Some(pid) = read_webcontent_pid(&handle) else { continue; // no fresh pid: no sweep and no recycle this tick }; - WEBCONTENT_PID.store(pid, Ordering::Relaxed); if !RECYCLING.load(Ordering::SeqCst) { sweep(pid, renderer_born); } + { + let mut renderers = live_renderers(); + renderers.last = Some(pid); + renderers.seen.insert(pid); + } let Some(fp) = phys_footprint(pid) else { continue; }; From 7cb92da1934e10f59442986b304f103893cb8206 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Mon, 2 Nov 2026 12:03:43 -0500 Subject: [PATCH 18/26] shell: the sweep takes its per-kill recheck and its kill as parameters, and a test shows a failed recheck stops further kills (THI-449) Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/recycle.rs | 45 ++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index 88b05530..2bb17e56 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -361,19 +361,14 @@ fn app_webcontents() -> Vec { .collect() } -// `renderer_born` is the value RENDERER_BORN held when the live pid was read; -// a recycle can finish between that read and a kill here, so every kill is -// rechecked against the live state instead of trusting the snapshot. -fn sweep(live_pid: i32, renderer_born: Option) { - let candidates = app_webcontents(); - let targets = sweep_targets(&candidates, &mut live_renderers().seen, live_pid); - for pid in targets { - if RECYCLING.load(Ordering::SeqCst) || renderer_born_at() != renderer_born { - continue; // a rebuild landed since the pid read; this candidate may now be live +/// Kills each target while `still_valid` holds, rechecked before every kill: a +/// recycle can finish mid-sweep and make a target the live renderer. +fn sweep(targets: &[i32], still_valid: impl Fn() -> bool, mut kill: impl FnMut(i32)) { + for &pid in targets { + if !still_valid() { + return; } - let fp = phys_footprint(pid).unwrap_or(0) / (1024 * 1024); - unsafe { libc::kill(pid, libc::SIGKILL) }; - shell_log(&format!("[recycle] killed leftover WebContent {pid} ({fp} MB)")); + kill(pid); } } @@ -466,6 +461,21 @@ mod tests { assert_eq!(sweep_targets(&[10, 12], &mut seen, 10), vec![12]); assert_eq!(seen, BTreeSet::from([10, 12])); } + + #[test] + fn sweep_stops_killing_once_the_recheck_fails() { + let valid = std::cell::Cell::new(true); + let mut killed = Vec::new(); + sweep( + &[12, 13, 14], + || valid.get(), + |pid| { + killed.push(pid); + valid.set(false); // a rebuild landed after the first kill + }, + ); + assert_eq!(killed, vec![12]); + } } pub fn start_monitor(app: &AppHandle) { @@ -490,7 +500,16 @@ pub fn start_monitor(app: &AppHandle) { continue; // no fresh pid: no sweep and no recycle this tick }; if !RECYCLING.load(Ordering::SeqCst) { - sweep(pid, renderer_born); + let targets = sweep_targets(&app_webcontents(), &mut live_renderers().seen, pid); + sweep( + &targets, + || !RECYCLING.load(Ordering::SeqCst) && renderer_born_at() == renderer_born, + |target| { + let fp = phys_footprint(target).unwrap_or(0) / (1024 * 1024); + unsafe { libc::kill(target, libc::SIGKILL) }; + shell_log(&format!("[recycle] killed leftover WebContent {target} ({fp} MB)")); + }, + ); } { let mut renderers = live_renderers(); From 7559ccc7fff7a44147fc248457e2c6c22220410e Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Mon, 2 Nov 2026 12:05:03 -0500 Subject: [PATCH 19/26] shell: the rebuild loop's result handling is await_rebuild over an injected submit, tested for a late Ok, an old window still registered, and giving up (THI-449) Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/recycle.rs | 128 +++++++++++++++++++++++++++++++-------- 1 file changed, 103 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index 2bb17e56..b0b6922a 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -25,6 +25,7 @@ use std::collections::BTreeSet; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; use std::sync::Mutex; use std::time::{Duration, Instant}; @@ -242,18 +243,10 @@ pub fn recycle_now(app: &AppHandle) { }; let handle = app.clone(); std::thread::spawn(move || { - // One channel for every attempt. The main thread runs the closures in - // order, so an attempt that timed out but later built the window sends - // its Ok ahead of the "already exists" errors of the attempts queued - // behind it — 10-28 20:51 gave up on exactly that and leaked a 1.1 GB - // renderer. An "already exists" that comes first is the OLD window still - // registered (22 of 440 recycles), and the loop retries as before. - let (tx, rx) = std::sync::mpsc::channel::>(); - for attempt in 1..=10 { + let submit = |attempt: u32, tx: Sender>| { std::thread::sleep(Duration::from_millis(if attempt == 1 { 250 } else { 1000 })); let h = handle.clone(); - let tx = tx.clone(); - let ok = handle.run_on_main_thread(move || { + let queued = handle.run_on_main_thread(move || { let result = tauri::WebviewWindowBuilder::new( &h, "main", @@ -290,28 +283,51 @@ pub fn recycle_now(app: &AppHandle) { Err(e) => Err(e.to_string()), }); }); - if ok.is_err() { + if queued.is_err() { shell_log("[recycle] run_on_main_thread failed; retrying"); - continue; - } - match rx.recv_timeout(Duration::from_secs(5)) { - Ok(Ok(())) => { - shell_log("[recycle] webview rebuilt"); - RECYCLING.store(false, Ordering::SeqCst); - return; - } - // The channel is shared across every attempt, so a late Err can - // surface here for an earlier attempt than `attempt` names. - Ok(Err(e)) => shell_log(&format!("[recycle] a rebuild attempt failed: {e}")), - Err(_) => shell_log(&format!("[recycle] rebuild attempt {attempt} timed out")), } + queued.is_ok() + }; + let log = |msg: &str| shell_log(&format!("[recycle] {msg}")); + if await_rebuild(REBUILD_ATTEMPTS, Duration::from_secs(5), submit, log) { + shell_log("[recycle] webview rebuilt"); + } else { + shell_log(&format!("[recycle] giving up after {REBUILD_ATTEMPTS} attempts — app may now exit")); } - // Give up: clear the exit veto so the windowless app can quit normally. - shell_log("[recycle] giving up after 10 attempts — app may now exit"); + // Clears the exit veto too: after a give-up the windowless app can quit. RECYCLING.store(false, Ordering::SeqCst); }); } +const REBUILD_ATTEMPTS: u32 = 10; + +/// Submits rebuild attempts until one reports Ok; false after `attempts` +/// without one. Every attempt sends on one channel: the main thread runs them +/// in order, so an attempt that timed out but later built the window sends its +/// Ok ahead of the "already exists" errors of the attempts queued behind it. +/// An "already exists" that comes first is the old window still registered, +/// and the next attempt retries. `submit` returns whether it queued the attempt. +fn await_rebuild( + attempts: u32, + timeout: Duration, + mut submit: impl FnMut(u32, Sender>) -> bool, + log: impl Fn(&str), +) -> bool { + let (tx, rx) = std::sync::mpsc::channel(); + for attempt in 1..=attempts { + if !submit(attempt, tx.clone()) { + continue; + } + match rx.recv_timeout(timeout) { + Ok(Ok(())) => return true, + // A late Err can belong to an earlier attempt than `attempt` names. + Ok(Err(e)) => log(&format!("a rebuild attempt failed: {e}")), + Err(_) => log(&format!("rebuild attempt {attempt} timed out")), + } + } + false +} + // WebKit parks a displaced WebContent in its process cache at full leaked // size instead of exiting it (observed alive at 480MB 90s after its window // was destroyed). Every check kills each WebContent this app is responsible @@ -462,6 +478,68 @@ mod tests { assert_eq!(seen, BTreeSet::from([10, 12])); } + const ALREADY_EXISTS: &str = "a webview with label `main` already exists"; + const SHORT: Duration = Duration::from_millis(10); + + #[test] + fn a_late_ok_from_a_timed_out_attempt_is_a_rebuild() { + let mut first = None; + let rebuilt = await_rebuild( + REBUILD_ATTEMPTS, + SHORT, + |attempt, tx| { + match attempt { + 1 => first = Some(tx), // times out + 2 => { + // The main thread gets to attempt 1, then attempt 2 finds its window. + let _ = first.take().expect("attempt 1's sender").send(Ok(())); + let _ = tx.send(Err(ALREADY_EXISTS.into())); + } + _ => { + let _ = tx.send(Err(ALREADY_EXISTS.into())); + } + } + true + }, + |_| {}, + ); + assert!(rebuilt); + } + + #[test] + fn an_old_window_still_registered_is_retried() { + let mut submitted = 0; + let rebuilt = await_rebuild( + REBUILD_ATTEMPTS, + SHORT, + |attempt, tx| { + submitted += 1; + let _ = tx.send(if attempt == 1 { Err(ALREADY_EXISTS.into()) } else { Ok(()) }); + true + }, + |_| {}, + ); + assert!(rebuilt); + assert_eq!(submitted, 2); + } + + #[test] + fn a_rebuild_gives_up_after_every_attempt_fails() { + let mut submitted = 0; + let rebuilt = await_rebuild( + REBUILD_ATTEMPTS, + SHORT, + |_, tx| { + submitted += 1; + let _ = tx.send(Err("boom".into())); + true + }, + |_| {}, + ); + assert!(!rebuilt); + assert_eq!(submitted, REBUILD_ATTEMPTS); + } + #[test] fn sweep_stops_killing_once_the_recheck_fails() { let valid = std::cell::Cell::new(true); From f9e24b567330bdc74b94fdddfb099c8b35208538 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Mon, 2 Nov 2026 12:08:30 -0500 Subject: [PATCH 20/26] shell: tests pin the 2 GB default and the inf/NaN fallback for PERISCOPE_RECYCLE_GB, and 1e30/NaN for the second knobs (THI-449) Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/recycle.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index b0b6922a..5ddfb893 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -438,6 +438,12 @@ mod tests { std::env::set_var("PERISCOPE_TEST_ENV_SECS_INF", "inf"); assert_eq!(env_secs("PERISCOPE_TEST_ENV_SECS_INF", 7), Duration::from_secs(7)); + std::env::set_var("PERISCOPE_TEST_ENV_SECS_NAN", "NaN"); + assert_eq!(env_secs("PERISCOPE_TEST_ENV_SECS_NAN", 7), Duration::from_secs(7)); + + std::env::set_var("PERISCOPE_TEST_ENV_SECS_HUGE", "1e30"); + assert_eq!(env_secs("PERISCOPE_TEST_ENV_SECS_HUGE", 7), Duration::from_secs(7)); + std::env::set_var("PERISCOPE_TEST_ENV_SECS_NEG", "-1"); assert_eq!(env_secs("PERISCOPE_TEST_ENV_SECS_NEG", 7), Duration::from_secs(7)); @@ -445,10 +451,17 @@ mod tests { assert_eq!(env_secs("PERISCOPE_TEST_ENV_SECS_OK", 7), Duration::from_secs_f64(45.0)); } + // One test: PERISCOPE_RECYCLE_GB is process-global and cargo runs tests in parallel. #[test] - fn threshold_bytes_falls_back_on_bad_gb() { - std::env::set_var("PERISCOPE_RECYCLE_GB", "-1"); - assert_eq!(threshold_bytes(), (2.0 * 1024.0 * 1024.0 * 1024.0) as u64); + fn threshold_bytes_defaults_to_2_gb_and_falls_back_on_bad_gb() { + std::env::remove_var("PERISCOPE_RECYCLE_GB"); + assert_eq!(threshold_bytes(), 2 * GB); + for bad in ["-1", "inf", "NaN"] { + std::env::set_var("PERISCOPE_RECYCLE_GB", bad); + assert_eq!(threshold_bytes(), 2 * GB, "PERISCOPE_RECYCLE_GB={bad}"); + } + std::env::set_var("PERISCOPE_RECYCLE_GB", "0.5"); + assert_eq!(threshold_bytes(), GB / 2); std::env::remove_var("PERISCOPE_RECYCLE_GB"); } From 27337549af5da629cde62d8b3dc81e0b81d8bec6 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Mon, 2 Nov 2026 12:08:56 -0500 Subject: [PATCH 21/26] shell: one knob parser, env_number, for the threshold and the second knobs (THI-449) Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/recycle.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index 5ddfb893..1bf30d72 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -54,24 +54,22 @@ fn live_renderers() -> std::sync::MutexGuard<'static, LiveRenderers> { LIVE_RENDERERS.lock().unwrap_or_else(|e| e.into_inner()) } -fn env_f64(name: &str, default: f64) -> f64 { - std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default) +// A knob that isn't a finite, non-negative number falls back to its default +// ("inf", "NaN" and "-1" all parse as f64). +fn env_number(name: &str) -> Option { + std::env::var(name).ok()?.parse::().ok().filter(|n| n.is_finite() && *n >= 0.0) } // A fresh dashboard already measures 1.1–1.6 GB; leaked renderers reach 3–7.7. fn threshold_bytes() -> u64 { - let gb = env_f64("PERISCOPE_RECYCLE_GB", 2.0); - let gb = if gb.is_finite() && gb >= 0.0 { gb } else { 2.0 }; + let gb = env_number("PERISCOPE_RECYCLE_GB").unwrap_or(2.0); (gb * 1024.0 * 1024.0 * 1024.0) as u64 } -// Duration::from_secs_f64 panics on a negative, NaN, infinite or overflowing -// value, and "inf"/"-1"/"1e30" all parse as f64 — a bad knob must not kill -// the monitor thread. +// A finite value can still overflow a Duration ("1e30"), where from_secs_f64 +// would panic the monitor thread. fn env_secs(name: &str, default_s: u64) -> Duration { - std::env::var(name) - .ok() - .and_then(|v| v.parse::().ok()) + env_number(name) .and_then(|s| Duration::try_from_secs_f64(s).ok()) .unwrap_or(Duration::from_secs(default_s)) } From 01faa2147791cfbb9b19873ceaa505518e081f8e Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Mon, 2 Nov 2026 12:09:32 -0500 Subject: [PATCH 22/26] shell: the monitor reads focus once, as focus.rs's Focus snapshot, for both the gate and the waiting line (THI-449) Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/focus.rs | 35 +++++++++++++++++++---------------- src-tauri/src/recycle.rs | 15 ++++++++------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/focus.rs b/src-tauri/src/focus.rs index 16bbc778..7a77cf4c 100644 --- a/src-tauri/src/focus.rs +++ b/src-tauri/src/focus.rs @@ -8,7 +8,7 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; #[derive(Clone, Copy, PartialEq)] -enum Focus { +pub enum Focus { Unknown, // launch, before the first window event Focused, UnfocusedSince(Instant), @@ -16,7 +16,18 @@ enum Focus { static FOCUS: Mutex = Mutex::new(Focus::Unknown); -fn state() -> Focus { +impl Focus { + /// How long periscope has been unfocused; None while focused or not yet known. + pub fn unfocused_for(self) -> Option { + match self { + Focus::UnfocusedSince(t) => Some(t.elapsed()), + _ => None, + } + } +} + +/// One locked read: a reader that needs more than one answer asks this snapshot. +pub fn focus() -> Focus { *FOCUS.lock().unwrap_or_else(|e| e.into_inner()) } @@ -32,15 +43,7 @@ pub fn set_focused(focused: bool) { } pub fn is_focused() -> bool { - state() == Focus::Focused -} - -/// How long periscope has been unfocused; None while focused or not yet known. -pub fn unfocused_for() -> Option { - match state() { - Focus::UnfocusedSince(t) => Some(t.elapsed()), - _ => None, - } + focus() == Focus::Focused } #[cfg(test)] @@ -50,23 +53,23 @@ mod tests { // One test: the state is process-global and cargo runs tests in parallel. #[test] fn tracks_focus_and_how_long_it_has_been_lost() { - assert_eq!(unfocused_for(), None); // Unknown at start + assert_eq!(focus().unfocused_for(), None); // Unknown at start assert!(!is_focused()); set_focused(true); assert!(is_focused()); - assert_eq!(unfocused_for(), None); + assert_eq!(focus().unfocused_for(), None); set_focused(false); assert!(!is_focused()); std::thread::sleep(Duration::from_millis(30)); - let first = unfocused_for().expect("unfocused"); + let first = focus().unfocused_for().expect("unfocused"); assert!(first >= Duration::from_millis(30)); set_focused(false); // a recycle's Destroyed - assert!(unfocused_for().expect("unfocused") >= first); + assert!(focus().unfocused_for().expect("unfocused") >= first); set_focused(true); - assert_eq!(unfocused_for(), None); + assert_eq!(focus().unfocused_for(), None); } } diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index 1bf30d72..c86a140a 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -34,6 +34,8 @@ use objc2::{msg_send, sel}; use tauri::{AppHandle, Manager, WebviewUrl}; use tauri_plugin_window_state::{AppHandleExt, StateFlags}; +use crate::focus::Focus; + const CHECK_INTERVAL_S: u64 = 60; const DASHBOARD_URL: &str = "http://127.0.0.1:17374"; @@ -611,23 +613,22 @@ pub fn start_monitor(app: &AppHandle) { if fp < threshold { continue; } - let unfocused_for = crate::focus::unfocused_for(); - let is_focused = crate::focus::is_focused(); // read alongside unfocused_for, not later in the log path + let focus = crate::focus::focus(); // one snapshot for the gate and the log line let inputs = Inputs { footprint: fp, threshold, age: renderer_age(), min_age, - unfocused_for, + unfocused_for: focus.unfocused_for(), unfocused_gate, idle: Duration::try_from_secs_f64(seconds_since_last_input()).unwrap_or_default(), idle_gate, }; if !should_recycle(&inputs) { - let focus = match inputs.unfocused_for { - Some(d) => format!("unfocused {}s", d.as_secs()), - None if is_focused => "focused".to_string(), - None => "focus unknown".to_string(), + let focus = match focus { + Focus::UnfocusedSince(t) => format!("unfocused {}s", t.elapsed().as_secs()), + Focus::Focused => "focused".to_string(), + Focus::Unknown => "focus unknown".to_string(), }; shell_log(&format!( "[recycle] over threshold ({} MB), renderer {}s old, {focus}, input {}s ago — waiting", From 4bad78c6f4739244612028451a83ef68d47d8d69 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Mon, 2 Nov 2026 12:10:15 -0500 Subject: [PATCH 23/26] docs: tauri-shell's recycling paragraph becomes prose plus bullets with the two invariants on their own; recycle.rs comments drop log dates and counts (THI-449) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/tauri-shell.md | 66 ++++++++++++++++++++++------------------ src-tauri/src/recycle.rs | 13 ++++---- 2 files changed, 42 insertions(+), 37 deletions(-) diff --git a/docs/tauri-shell.md b/docs/tauri-shell.md index d122ad4b..243a59b2 100644 --- a/docs/tauri-shell.md +++ b/docs/tauri-shell.md @@ -35,36 +35,42 @@ IOSurface-backed graphics regions under real (trusted) user input — ~2.5 regions per rail click, measured 2026-07-28; 2645 regions ≈ 4.8GB killed a 4-day renderer. Nothing in-page fixes it: reload reuses the WebContent process, memory_pressure doesn't reclaim, synthetic events don't even -reproduce it. The shell therefore destroys and recreates the webview window -when the renderer's phys_footprint (via WKWebView's private -`_webProcessIdentifier` + `proc_pid_rusage`) tops `PERISCOPE_RECYCLE_GB` -(default 2.0), the renderer has lived `PERISCOPE_RECYCLE_MIN_AGE_S` (default -1800s) since the last rebuild, and either periscope has been unfocused -`PERISCOPE_RECYCLE_UNFOCUSED_S` (default 30s) or system input has been idle -`PERISCOPE_RECYCLE_IDLE_S` (default 300s) — plus a manual View → Recycle -Webview item with no gate. The rebuild restores every saved window state but -visibility and takes focus only if periscope had it when the recycle -started. wry still activates the app for every webview it creates (macOS -14+), and macOS honours that even while you're in another app, so a recycle -that started unfocused notes the frontmost app and activates it again 0.5 s -after the rebuild, provided Periscope or that app is still frontmost — you -stay where you were. Every rebuild attempt's closure sends its result on one -shared channel, so an attempt that times out but later builds the window -still counts as the success it is instead of the loop giving up with the -renderer leaked. Two hard-won invariants: the rebuild must happen on a LATER -tick than destroy() (same-tick rebuild exits the whole app; an ExitRequested -veto in main.rs covers the windowless gap), and a WebContent WebKit parks in -its process cache at full leaked size instead of exiting must still be -killed — every check re-reads the live WebContent pid on the main thread -and, once that read answers with no recycle in flight, SIGKILLs any other -app-owned WebContent (by `proc_listallpids` + -`responsibility_get_pid_responsible_for_pid`) at or above 256 MB, sparing -WebKit's ~7 MB pre-warmed spare. Actions log to -`~/.config/periscope/shell.log`. Test by lowering `PERISCOPE_RECYCLE_GB`, -`PERISCOPE_RECYCLE_MIN_AGE_S`, `PERISCOPE_RECYCLE_UNFOCUSED_S` and -`PERISCOPE_RECYCLE_IDLE_S` with `open --env NAME=value Periscope.app` (GUI -apps don't inherit shell env, and a `launchctl setenv` from a Claude Code -shell didn't reach an app launched with `open`) and watching that log. +reproduce it. So the shell destroys and recreates the webview window, and +logs what it does to `~/.config/periscope/shell.log`. + +- **Gate.** Every minute the monitor reads the renderer's phys_footprint + (WKWebView's private `_webProcessIdentifier` + `proc_pid_rusage`). It + recycles when that tops `PERISCOPE_RECYCLE_GB` (default 2.0), the renderer + has lived `PERISCOPE_RECYCLE_MIN_AGE_S` (default 1800s) since the last + rebuild, and either periscope has been unfocused + `PERISCOPE_RECYCLE_UNFOCUSED_S` (default 30s) or system input has been idle + `PERISCOPE_RECYCLE_IDLE_S` (default 300s). View → Recycle Webview recycles + with no gate. +- **Rebuild without activation.** The window-state plugin restores every + window it sees, each rebuild included, and its VISIBLE restore calls + `set_focus` (`activateIgnoringOtherApps: YES`), so main.rs gives it every + flag but VISIBLE. Nothing in the rebuild then activates the app — wry's own + `activate()` is declined while another app is frontmost — and keystrokes + stay with the app you're in. A minimized window is minimized again. +- **One result channel.** Every rebuild attempt sends its result on one + shared channel, so an attempt that times out but later builds the window + counts as the success it is instead of the loop giving up with the + renderer leaked. +- **Sweep of renderers seen live.** Every check kills each app-owned + WebContent (`proc_listallpids` + `responsibility_get_pid_responsible_for_pid`) + that the monitor read as live on an earlier tick and that isn't live now, + rechecking before each kill that no rebuild has landed since the live pid + was read. The live pid is spared even when seen before: WebKit's process + cache can hand a parked renderer back to a new webview. +- **Invariant: the rebuild runs on a LATER tick than `destroy()`.** A + same-tick rebuild exits the whole app; an ExitRequested veto in main.rs + covers the windowless gap. +- **Invariant: a displaced WebContent must be killed.** WebKit parks it in its + process cache at full leaked size instead of exiting it; the sweep is what + frees it. +- **Testing.** Lower the knobs with `open --env NAME=value Periscope.app` + (GUI apps don't inherit shell env, and a `launchctl setenv` from a Claude + Code shell didn't reach an app launched with `open`) and watch `shell.log`. **Zoom (`src-tauri/src/zoom.rs`).** A bare WKWebView has no ⌘+/⌘−, so View carries Zoom In (⌘=), Zoom Out (⌘-) and Actual Size (⌘0), stepping diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index c86a140a..1fcd9bb4 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -2,11 +2,10 @@ // // Real (trusted) user input mints IOSurface-backed regions in the WebContent // process that nothing reclaims: not idle, not page reload (same process), -// not memory_pressure -l critical. Measured 2026-07-28: ~2.5 regions per rail -// click, 197 → 922 regions in an hour of use, 2645 regions ≈ 4.8GB after four -// days — the renderer was swapping the machine. Only WebContent process death -// frees them, and a reload reuses the process — so we destroy and recreate -// the webview window itself. +// not memory_pressure -l critical. ~2.5 regions per rail click; 2645 regions +// ≈ 4.8GB after four days had the renderer swapping the machine. Only +// WebContent process death frees them, and a reload reuses the process — so +// we destroy and recreate the webview window itself. // // Policy: every CHECK_INTERVAL_S the renderer's phys_footprint is read via // proc_pid_rusage (same number `footprint` prints; RSS is misleading — most @@ -217,8 +216,8 @@ fn dirs_path() -> std::path::PathBuf { // Destroy the sole window, then rebuild it on a later tick. Rebuilding the // same label in the same main-thread closure as destroy() killed the whole -// app (observed 2026-07-28: process gone, no window) — the deferred rebuild -// plus the ExitRequested veto in main.rs covers the windowless gap. +// app (process gone, no window) — the deferred rebuild plus the +// ExitRequested veto in main.rs covers the windowless gap. pub fn recycle_now(app: &AppHandle) { if RECYCLING.swap(true, Ordering::SeqCst) { return; // one at a time From 4f0581fbf247c11b6389d6a5afc843629b216836 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Mon, 2 Nov 2026 12:11:27 -0500 Subject: [PATCH 24/26] =?UTF-8?q?docs(specs):=20recycle=20gate=20=E2=80=94?= =?UTF-8?q?=20the=20plugin's=20VISIBLE=20restore=20was=20the=20activation?= =?UTF-8?q?=20and=20there=20is=20no=20hand-back,=20the=20sweep=20targets?= =?UTF-8?q?=20renderers=20seen=20live,=20tests=20and=20assumptions=20updat?= =?UTF-8?q?ed=20(THI-449)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../2026-11-01-webview-recycle-gate-design.md | 143 ++++++++++-------- 1 file changed, 82 insertions(+), 61 deletions(-) diff --git a/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md b/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md index 86268d2e..3a5d0265 100644 --- a/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md +++ b/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md @@ -20,8 +20,9 @@ THI-449. reaper, which only runs on the success path, never killed WebContent 41485 (still alive at 1.1 GB three days later). The same error also means the opposite: in 22 recycles attempt 1 failed with it 250 ms after `destroy()`, because the OLD window was still registered, and attempt 2 then succeeded. -- **A rebuild activates the app.** `recycle_now` restores the window with `restore_state(StateFlags::all())`, which - in tauri-plugin-window-state 2.4.1 calls `show()` then `set_focus()`; tao's `set_focus` runs +- **A rebuild activates the app.** `recycle_now` restores the window with `restore_state(StateFlags::all())` (and the + plugin's own `on_window_ready` hook does the same for every window Tauri attaches), which in + tauri-plugin-window-state 2.4.1 calls `show()` then `set_focus()`; tao's `set_focus` runs `activateIgnoringOtherApps: YES`. Harmless while you were away; under a gate that fires while you work in another app, it would pull focus out from under you. @@ -44,54 +45,69 @@ focused (the unfocused gate makes that interruption unnecessary). to `focus.rs`, which holds one state behind a mutex — `Unknown` (from launch until the first focus event), `Focused`, or `UnfocusedSince(Instant)` — so a reader never sees "unfocused" paired with a stale time. `set_focused` is called from `main.rs`'s `on_window_event` (`Focused(f)` → f, `Destroyed` → false); only a real change moves the -clock, so a recycle's `Destroyed` after an earlier `Focused(false)` keeps it running. `is_focused()` serves the -banners' quiet-while-focused check; `unfocused_for() -> Option` is None while focused or Unknown. +clock, so a recycle's `Destroyed` after an earlier `Focused(false)` keeps it running. `focus()` is the one locked +read: the recycle monitor takes both its gate input (`Focus::unfocused_for()`, None while focused or Unknown) and +its "waiting" log text from one snapshot. `is_focused()` serves the banners' quiet-while-focused check. **D3 — The rebuild loop shares one result channel across attempts.** Every attempt's closure sends its result on the same channel instead of a per-attempt one. The main thread runs closures in order, so when an attempt times out but later builds the window, its `Ok` reaches the channel before the "already exists" errors of attempts queued behind it; the loop takes that `Ok` as the success it is (clearing `RECYCLING`). An "already exists" that arrives -first still means the old window is registered, so the loop keeps retrying as today. No error text is matched. - -**D4 — A rebuild doesn't force focus unless periscope had it.** `recycle_now` notes `focus::is_focused()` -before `destroy()`. The rebuild restores every saved state except visibility -(`StateFlags::all() - StateFlags::VISIBLE`), which removes the plugin's forced `activateIgnoringOtherApps: YES`, and -calls `set_focus()` only when periscope had focus — so the manual menu recycle keeps the window focused. wry still -calls `NSApplication::activate` for every webview it creates (macOS 14+), and the manual test showed macOS honours -it while another app is frontmost (a recycle with Claude frontmost left Periscope frontmost 0.5 s later). So a -recycle that started unfocused notes the frontmost app's pid (`NSWorkspace.frontmostApplication`) before -`destroy()`, and 0.5 s after the rebuild lands activates it again (`NSRunningApplication activateWithOptions:`); -verified: Claude stayed frontmost through two recycles. It also records the rebuild time for D1's minimum age. - -**D5 — Every check sweeps leftover renderers, but only with a fresh live pid; the after-recycle reaper goes.** Once -per `CHECK_INTERVAL_S`, the live WebContent pid is read on the main thread and the answer comes back on a channel -with a timeout; the sweep runs only when this tick's read answered with a pid and no recycle is in flight, and each -kill re-checks both (recycle flag, rebuild time) immediately before signalling, so a stale pid after an unannounced -renderer restart (wry reloads the page itself when its WebContent dies) or a recycle that finished mid-sweep is not -mistaken for a leftover; what remains is a microsecond gap against a crash-reload, whose cost is one page reload. -With no `main` window the pid is reset to 0. The sweep SIGKILLs every other WebContent the app is responsible for at -or above 256 MB: candidates come from `proc_listallpids`, kept when their executable path (`proc_pidpath`) contains -`WebKit.WebContent` and their responsible pid (`responsibility_get_pid_responsible_for_pid`, private libSystem, the -call `periscope/resources.py` also uses) is this process. The floor spares WebKit's ~7 MB pre-warmed spare; the -smallest displaced renderer the reaper ever killed was 522 MB. This one path replaces `reap_old_webcontent`'s 10 s -kill: it covers a normal recycle (within one check, ≤ 60 s), a rebuild that gave up but left a window, and anything -else that leaves one parked. (A give-up that left no window reads a live pid of 0 and sweeps nothing; quitting the -app clears those renderers.) Renderers left by an older run of the app die with it, so quitting the current app -clears today's 41485. - -**D6 — Policy is pure and unit-tested; the OS calls stay thin.** `should_recycle(Inputs) -> bool` and -`sweep_targets(candidates, live_pid) -> Vec` hold the decisions and get `cargo test` cases next to -`notifications.rs`'s and `zoom.rs`'s; `focus.rs`'s state transitions get one test. +first still means the old window is registered, so the loop keeps retrying as today. No error text is matched. The +loop is `await_rebuild(attempts, timeout, submit, log)`; `recycle_now` passes the real submit step (sleep, then +build on the main thread), so the result handling is tested without a window. + +**D4 — A rebuild never activates the app.** The window-state plugin's `on_window_ready` hook restores every window +Tauri attaches, each recycle's rebuild included, and with `StateFlags::all()` its VISIBLE restore calls `show()` then +`set_focus()`, which tao runs as `makeKeyAndOrderFront` + `activateIgnoringOtherApps: YES`. That was the activation. +`main.rs` registers the plugin with `StateFlags::all() - StateFlags::VISIBLE` (tauri.conf.json already shows the +startup window), so the hook restores the rebuilt window's frame and nothing in the rebuild activates the app. wry +still calls `NSApplication::activate` for every webview it builds (macOS 14+), but that cooperative activation is +declined while another app is frontmost. Verified 2026-11-02: two unfocused recycles, one while typing in another +app, left that app frontmost ("focus stayed with pid …" both times), and the unfocused clock kept counting. So there +is no explicit `restore_state`, no `set_focus`, and no focus hand-back; keystrokes stay with the app you're in. A +manual menu recycle runs with Periscope already active, and the builder's window becomes key. `recycle_now` saves +the window state before `destroy()` (the plugin's own save runs on exit only), and a window that was minimized +before `destroy()` is minimized again once rebuilt (the plugin has no minimized flag). The rebuild also records its +time for D1's minimum age. + +**D5 — Every check sweeps the renderers the monitor has seen live; the after-recycle reaper goes.** Once per +`CHECK_INTERVAL_S`, the live WebContent pid is read on the main thread and the answer comes back on a channel with a +timeout; the read is None with no `main` window, no selector, or no answer. Every pid read joins a set of renderers +seen live (one static behind a mutex, which also keeps the last pid read for `recycle_now`'s "destroying webview" +line, "unknown" when none). The sweep runs only when this tick's read answered and no recycle is in flight. Its +candidates are every WebContent the app is responsible for: `proc_listallpids`, kept when the executable path +(`proc_pidpath`) contains `WebKit.WebContent` and the responsible pid (`responsibility_get_pid_responsible_for_pid`, +private libSystem, the call `periscope/resources.py` also uses) is this process. It SIGKILLs each candidate in the +seen set other than this tick's live pid (WebKit's process cache can hand a parked renderer back to a new webview), +and drops seen pids that are no longer candidates. Each kill re-checks (recycle flag, rebuild time) immediately +before signalling, so a stale pid after an unannounced renderer restart (wry reloads the page itself when its +WebContent dies) or a recycle that finished mid-sweep is not mistaken for a leftover; what remains is a microsecond +gap against a crash-reload, whose cost is one page reload. Every renderer the sweep killed in the log was one the +monitor had read live (each "killed leftover WebContent N" names the pid of the preceding "destroying webview"), so +the seen set replaces a size floor: WebKit's pre-warmed spare, a service-worker renderer or the Web Inspector is +never a target. This one path replaces `reap_old_webcontent`'s 10 s kill: it covers a normal recycle (within one +check, ≤ 60 s), a rebuild that gave up but left a window, and anything else that parks a renderer the page ran in. +(A give-up that left no window reads no live pid and sweeps nothing; quitting the app clears those renderers.) +Renderers left by an older run of the app die with it, so quitting the current app clears today's 41485. + +**D6 — Policy is pure and unit-tested; the OS calls stay thin.** `should_recycle(Inputs) -> bool`, +`sweep_targets(candidates, seen, live_pid) -> Vec`, `sweep(targets, still_valid, kill)` (the per-kill recheck +and the kill injected), `await_rebuild` (D3) and the knob parser `env_number` hold the decisions and get +`cargo test` cases next to `notifications.rs`'s and `zoom.rs`'s; `focus.rs`'s state transitions get one test. ## Changes -- `src-tauri/src/focus.rs` (new): the focus state (D2). +- `src-tauri/src/focus.rs` (new): the focus state and its `focus()` snapshot (D2). - `src-tauri/src/notifications.rs`: drops `FOCUSED` / `set_focused`, reads `focus::is_focused()`. -- `src-tauri/src/main.rs`: `mod focus`; `on_window_event` calls `focus::set_focused`. -- `src-tauri/src/recycle.rs`: the gate and its knobs (D1), the shared channel (D3), the focus-preserving rebuild and - the rebuild timestamp (D4), the answered pid refresh and the sweep replacing `reap_old_webcontent` (D5), the pure - policy and its tests (D6). The "waiting" log line names what is holding the recycle back. -- `docs/tauri-shell.md`: the recycling paragraph describes the new gate, knobs, rebuild and sweep. +- `src-tauri/src/main.rs`: `mod focus`; `on_window_event` calls `focus::set_focused`; the window-state plugin + restores every flag but VISIBLE (D4). +- `src-tauri/src/recycle.rs`: the gate and its knobs (D1), `await_rebuild`'s shared channel (D3), the rebuild with + no explicit restore or focus call, re-minimized when it was minimized, and the rebuild timestamp (D4), the answered + pid read, the seen-live set and the sweep replacing `reap_old_webcontent` (D5), the pure policy and its tests (D6). + The "waiting" log line names what is holding the recycle back. +- `docs/tauri-shell.md`: the recycling paragraph becomes prose plus bullets: the gate and knobs, the rebuild without + activation, the shared channel, the sweep of renderers seen live, the two invariants, testing via `open --env`. - `static/src/notify/persist.js`: its header's "recycle.rs rebuilds the webview once you've been idle a while" becomes "…once you've left periscope or been idle a while". @@ -100,13 +116,19 @@ clears today's 41485. - `cargo test` in `src-tauri/`: - `should_recycle`: under threshold never; too young never; unfocused ≥ / < gate; focused or unknown with idle ≥ / < gate. - - `sweep_targets`: skips the live pid, skips < 256 MB, takes nothing when the live pid is unknown (0), keeps - ≥ 256 MB others. + - `sweep_targets`: takes renderers seen live that aren't live now; never the live pid; never an unseen one; drops + seen pids that are gone. + - `sweep`: a recheck that fails after the first kill stops the rest. + - `await_rebuild`: a late `Ok` from a timed-out attempt is a rebuild; an "already exists" first, then `Ok`, is a + rebuild on attempt 2; ten errors give up. + - knobs: an unset `PERISCOPE_RECYCLE_GB` is 2 GB, and `-1`, `inf`, `NaN` fall back to it; `env_secs` falls back on + unset, `inf`, `NaN`, `1e30`, `-1`. - `focus`: Unknown → Focused → UnfocusedSince (clock runs, a repeated false keeps it) → Focused. - Manual, on the rebuilt `.app` (replacing the running app needs the user's go-ahead), launched with `open --env PERISCOPE_RECYCLE_GB=0.2 --env …_MIN_AGE_S=60 --env …_UNFOCUSED_S=10` (`launchctl setenv` from the agent's shell did not reach the app): - - switching to another app for 10 s recycles, and you **stay** in that app (D4); + - switching to another app for 10 s recycles, and you **stay** in that app, typing included (D4; verified + 2026-11-02); - no second recycle within the minimum age; - the manual menu recycle leaves periscope focused; - whether minimizing produces a recycle (assumption below); `shell.log` shows the sweep never killing the live pid. @@ -115,25 +137,24 @@ clears today's 41485. ## Assumptions -- Handing focus back 0.5 s after the rebuild lands comes after wry's activation; verified on macOS 27 (Claude stayed - frontmost at every sample from 0.25 s to 5 s). Periscope may show for about 0.5 s on each unfocused recycle; on a - slower machine wry's activation could land after the hand-back and Periscope would keep focus. -- The hand-back targets the app frontmost when the recycle started, and runs only while Periscope or that app is - frontmost, so moving to a third app during the ~1 s gap is respected. A deliberate click into Periscope during the - gap looks like wry's activation and is reverted. -- After a hand-back the unfocused clock restarts (wry's activation emits `Focused(true)`, the hand-back - `Focused(false)`); verified: the next "waiting" line read "unfocused 59s". With the 30 min minimum age this only - delays the next recycle by the 30 s gate. +- wry's `NSApplication::activate` is declined while another app is frontmost (macOS 14+ cooperative activation); + verified on macOS 27 with the plugin's VISIBLE restore gone (D4). An OS that honours it again would put Periscope + in front on every unfocused recycle. +- A ⌘H-hidden app stays hidden through a rebuild: nothing activates the app, so a window built in a hidden app + should stay hidden (unverified). +- A minimized window is re-minimized after the rebuild (unverified manually); it is shown for the moment between + the build and `minimize()`. - Not exercised by the manual test (no UI scripting available): the manual menu recycle while focused; whether - minimizing counts as unfocused (a minimized or hidden Periscope comes back shown, since MINIMIZED isn't a restored - flag and wry's activation unhides the app); an idle-gate recycle while the screen is locked (the frontmost app is - then probably loginwindow); a fullscreen Periscope in another Space (the Space may switch twice). -- The app has exactly one legitimate WebContent ≥ 256 MB. A service-worker renderer (`main.jsx` registers `/sw.js`), - a future WebKit site-isolation process, or the Web Inspector frontend in a debug build would break that; today's - listing shows none. -- Bad knob values (negative, NaN, infinite) fall back to the default rather than stopping the monitor thread. + minimizing counts as unfocused; a fullscreen Periscope in another Space (the plugin restores fullscreen on the + rebuilt window). +- A renderer replaced before any check read it as live (two manual recycles within a minute) never joins the seen + set and isn't swept; quitting the app clears it. +- A seen pid stays in the set until a sweep no longer finds it among the candidates (one or two checks after it + dies); a new WebContent of this app reusing that pid within that window would be killed. macOS hands out pids in + increasing order, so that needs a wrap of the pid space within a couple of minutes. +- Bad knob values (negative, NaN, infinite, or too large for a `Duration`) fall back to the default rather than + disabling recycling or stopping the monitor thread. - `Focused(false)` fires on app switch and ⌘H (tao's `windowDidResignKey`; verified in source). Whether it fires on minimize is untested; if not, the idle gate still covers a minimized periscope. - `proc_listallpids` takes a byte size and returns a count; it and `responsibility_get_pid_responsible_for_pid` link from Rust without a `#[link]` attribute (both verified in a scratch binary). -- A displaced renderer is ≥ 256 MB by the time a check sees it; a smaller one is left until it grows or exits. From d1f0bb40d864bca5bfdfef073b2bfcf64005e0f5 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Mon, 2 Nov 2026 12:12:26 -0500 Subject: [PATCH 25/26] shell: recycle.rs's test module sits at the end of the file (clippy items_after_test_module) (THI-449) Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/recycle.rs | 151 ++++++++++++++++++++------------------- 1 file changed, 76 insertions(+), 75 deletions(-) diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index 1fcd9bb4..85d9b803 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -387,6 +387,82 @@ fn sweep(targets: &[i32], still_valid: impl Fn() -> bool, mut kill: impl FnMut(i } } + +pub fn start_monitor(app: &AppHandle) { + mark_renderer_born(); + let handle = app.clone(); + std::thread::spawn(move || { + let threshold = threshold_bytes(); + let min_age = env_secs("PERISCOPE_RECYCLE_MIN_AGE_S", 1800); + let unfocused_gate = env_secs("PERISCOPE_RECYCLE_UNFOCUSED_S", 30); + let idle_gate = env_secs("PERISCOPE_RECYCLE_IDLE_S", 300); + shell_log(&format!( + "[recycle] monitor started (threshold {} MB, min age {}s, unfocused gate {}s, idle gate {}s)", + threshold / (1024 * 1024), + min_age.as_secs(), + unfocused_gate.as_secs(), + idle_gate.as_secs() + )); + loop { + std::thread::sleep(Duration::from_secs(CHECK_INTERVAL_S)); + let renderer_born = renderer_born_at(); // snapshot before the pid read; sweep rechecks against it + let Some(pid) = read_webcontent_pid(&handle) else { + continue; // no fresh pid: no sweep and no recycle this tick + }; + if !RECYCLING.load(Ordering::SeqCst) { + let targets = sweep_targets(&app_webcontents(), &mut live_renderers().seen, pid); + sweep( + &targets, + || !RECYCLING.load(Ordering::SeqCst) && renderer_born_at() == renderer_born, + |target| { + let fp = phys_footprint(target).unwrap_or(0) / (1024 * 1024); + unsafe { libc::kill(target, libc::SIGKILL) }; + shell_log(&format!("[recycle] killed leftover WebContent {target} ({fp} MB)")); + }, + ); + } + { + let mut renderers = live_renderers(); + renderers.last = Some(pid); + renderers.seen.insert(pid); + } + let Some(fp) = phys_footprint(pid) else { + continue; + }; + if fp < threshold { + continue; + } + let focus = crate::focus::focus(); // one snapshot for the gate and the log line + let inputs = Inputs { + footprint: fp, + threshold, + age: renderer_age(), + min_age, + unfocused_for: focus.unfocused_for(), + unfocused_gate, + idle: Duration::try_from_secs_f64(seconds_since_last_input()).unwrap_or_default(), + idle_gate, + }; + if !should_recycle(&inputs) { + let focus = match focus { + Focus::UnfocusedSince(t) => format!("unfocused {}s", t.elapsed().as_secs()), + Focus::Focused => "focused".to_string(), + Focus::Unknown => "focus unknown".to_string(), + }; + shell_log(&format!( + "[recycle] over threshold ({} MB), renderer {}s old, {focus}, input {}s ago — waiting", + fp / (1024 * 1024), + inputs.age.as_secs(), + inputs.idle.as_secs() + )); + continue; + } + let h = handle.clone(); + let _ = handle.run_on_main_thread(move || recycle_now(&h)); + } + }); +} + #[cfg(test)] mod tests { use super::*; @@ -567,78 +643,3 @@ mod tests { assert_eq!(killed, vec![12]); } } - -pub fn start_monitor(app: &AppHandle) { - mark_renderer_born(); - let handle = app.clone(); - std::thread::spawn(move || { - let threshold = threshold_bytes(); - let min_age = env_secs("PERISCOPE_RECYCLE_MIN_AGE_S", 1800); - let unfocused_gate = env_secs("PERISCOPE_RECYCLE_UNFOCUSED_S", 30); - let idle_gate = env_secs("PERISCOPE_RECYCLE_IDLE_S", 300); - shell_log(&format!( - "[recycle] monitor started (threshold {} MB, min age {}s, unfocused gate {}s, idle gate {}s)", - threshold / (1024 * 1024), - min_age.as_secs(), - unfocused_gate.as_secs(), - idle_gate.as_secs() - )); - loop { - std::thread::sleep(Duration::from_secs(CHECK_INTERVAL_S)); - let renderer_born = renderer_born_at(); // snapshot before the pid read; sweep rechecks against it - let Some(pid) = read_webcontent_pid(&handle) else { - continue; // no fresh pid: no sweep and no recycle this tick - }; - if !RECYCLING.load(Ordering::SeqCst) { - let targets = sweep_targets(&app_webcontents(), &mut live_renderers().seen, pid); - sweep( - &targets, - || !RECYCLING.load(Ordering::SeqCst) && renderer_born_at() == renderer_born, - |target| { - let fp = phys_footprint(target).unwrap_or(0) / (1024 * 1024); - unsafe { libc::kill(target, libc::SIGKILL) }; - shell_log(&format!("[recycle] killed leftover WebContent {target} ({fp} MB)")); - }, - ); - } - { - let mut renderers = live_renderers(); - renderers.last = Some(pid); - renderers.seen.insert(pid); - } - let Some(fp) = phys_footprint(pid) else { - continue; - }; - if fp < threshold { - continue; - } - let focus = crate::focus::focus(); // one snapshot for the gate and the log line - let inputs = Inputs { - footprint: fp, - threshold, - age: renderer_age(), - min_age, - unfocused_for: focus.unfocused_for(), - unfocused_gate, - idle: Duration::try_from_secs_f64(seconds_since_last_input()).unwrap_or_default(), - idle_gate, - }; - if !should_recycle(&inputs) { - let focus = match focus { - Focus::UnfocusedSince(t) => format!("unfocused {}s", t.elapsed().as_secs()), - Focus::Focused => "focused".to_string(), - Focus::Unknown => "focus unknown".to_string(), - }; - shell_log(&format!( - "[recycle] over threshold ({} MB), renderer {}s old, {focus}, input {}s ago — waiting", - fp / (1024 * 1024), - inputs.age.as_secs(), - inputs.idle.as_secs() - )); - continue; - } - let h = handle.clone(); - let _ = handle.run_on_main_thread(move || recycle_now(&h)); - } - }); -} From 39e3578adfeca134931456dd7e4f4ddceee42a1f Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Mon, 2 Nov 2026 12:19:48 -0500 Subject: [PATCH 26/26] shell, docs: one blank line before recycle.rs's tests; the spec states the crash-reload gap as milliseconds (THI-449) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../specs/2026-11-01-webview-recycle-gate-design.md | 4 ++-- src-tauri/src/recycle.rs | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md b/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md index 3a5d0265..37baa0c4 100644 --- a/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md +++ b/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md @@ -82,8 +82,8 @@ private libSystem, the call `periscope/resources.py` also uses) is this process. seen set other than this tick's live pid (WebKit's process cache can hand a parked renderer back to a new webview), and drops seen pids that are no longer candidates. Each kill re-checks (recycle flag, rebuild time) immediately before signalling, so a stale pid after an unannounced renderer restart (wry reloads the page itself when its -WebContent dies) or a recycle that finished mid-sweep is not mistaken for a leftover; what remains is a microsecond -gap against a crash-reload, whose cost is one page reload. Every renderer the sweep killed in the log was one the +WebContent dies) or a recycle that finished mid-sweep is not mistaken for a leftover; what remains is a few-millisecond +gap (pid read, scan, kill) against a crash-reload, whose cost is one page reload. Every renderer the sweep killed in the log was one the monitor had read live (each "killed leftover WebContent N" names the pid of the preceding "destroying webview"), so the seen set replaces a size floor: WebKit's pre-warmed spare, a service-worker renderer or the Web Inspector is never a target. This one path replaces `reap_old_webcontent`'s 10 s kill: it covers a normal recycle (within one diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index 85d9b803..b477bde6 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -387,7 +387,6 @@ fn sweep(targets: &[i32], still_valid: impl Fn() -> bool, mut kill: impl FnMut(i } } - pub fn start_monitor(app: &AppHandle) { mark_renderer_born(); let handle = app.clone();