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..77fcac34 --- /dev/null +++ b/docs/superpowers/plans/2026-11-01-webview-recycle-gate.md @@ -0,0 +1,486 @@ +# 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 (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 (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, 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 (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/` (`cargo test --quiet`: 30 tests at the start, ~40 s cold). + +**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:** `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 + 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 | 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` + +**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: Create the module with its test** + +```rust +// 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); + } +} +``` + +- [ ] **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()`. + +- [ ] **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 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 gate — unfocused, minimum age, 2 GB + +**Files:** Modify `src-tauri/src/recycle.rs` (header comment, knobs, a renderer-born clock, `start_monitor`, `should_recycle` + tests). + +- [ ] **Step 1: Failing tests** — append to `recycle.rs`: + +```rust +#[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))); + } +} +``` + +`cargo test --quiet recycle 2>&1 | tail -3` → fails to compile (`Inputs`, `should_recycle`). + +- [ ] **Step 2: Implement** + +Knobs (replace `threshold_bytes`'s default and add two): + +```rust +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 { + (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, + 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) +} +``` + +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 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), renderer {}s old, {focus}, input {}s ago — waiting", + fp / (1024 * 1024), + inputs.age.as_secs(), + inputs.idle.as_secs() + )); + continue; + } +``` + +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** — `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: the rebuild — one channel, no focus theft + +**Files:** Modify `src-tauri/src/recycle.rs` (`recycle_now`). + +No unit test: this is main-thread/OS orchestration; Task 6 verifies it on the real app. + +- [ ] **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 + // 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::>(); +``` + + 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(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(()) + } +``` + + `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 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: answered pid read + the sweep + +**Files:** Modify `src-tauri/src/recycle.rs` (replace `refresh_webcontent_pid` and `reap_old_webcontent`; `start_monitor`). + +- [ ] **Step 1: Failing tests** — 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()); + } +``` + +`cargo test --quiet sweep 2>&1 | tail -3` → fails to compile. + +- [ ] **Step 2: Replace `refresh_webcontent_pid`** with a read that answers: + +```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 — 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 + } + 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]; + // 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)")); + } +} +``` + +Remove the `reap_old_webcontent(old_pid);` call from `recycle_now`'s success arm (`old_pid` stays for the "destroying" log line). + +- [ ] **Step 4: Wire into `start_monitor`** — replace the refresh + 200 ms sleep + `WEBCONTENT_PID.load` with: + +```rust + 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); + } +``` + +(the existing `let Some(fp) = phys_footprint(pid) else { continue; };` follows unchanged). + +- [ ] **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:** `docs/tauri-shell.md` (Webview recycling paragraph), `static/src/notify/persist.js` (header comment). + +- [ ] **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:** `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 new file mode 100644 index 00000000..37baa0c4 --- /dev/null +++ b/docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md @@ -0,0 +1,160 @@ +# Webview recycle gate + +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-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, 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())` (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. + +## Decisions + +**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. `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. 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 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 +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 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`; 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". + +## Testing + +- `cargo test` in `src-tauri/`: + - `should_recycle`: under threshold never; too young never; unfocused ≥ / < gate; focused or unknown with idle + ≥ / < gate. + - `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, 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. +- After merge: the popover's "webview" line (THI-448) shows whether the renderer stays near or under 2 GB through a + working day. + +## Assumptions + +- 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 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). diff --git a/docs/tauri-shell.md b/docs/tauri-shell.md index 558f3b70..243a59b2 100644 --- a/docs/tauri-shell.md +++ b/docs/tauri-shell.md @@ -35,18 +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 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. +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/focus.rs b/src-tauri/src/focus.rs new file mode 100644 index 00000000..7a77cf4c --- /dev/null +++ b/src-tauri/src/focus.rs @@ -0,0 +1,75 @@ +// 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)] +pub enum Focus { + Unknown, // launch, before the first window event + Focused, + UnfocusedSince(Instant), +} + +static FOCUS: Mutex = Mutex::new(Focus::Unknown); + +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()) +} + +/// 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 { + focus() == Focus::Focused +} + +#[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!(focus().unfocused_for(), None); // Unknown at start + assert!(!is_focused()); + + set_focused(true); + assert!(is_focused()); + assert_eq!(focus().unfocused_for(), None); + + set_focused(false); + assert!(!is_focused()); + std::thread::sleep(Duration::from_millis(30)); + let first = focus().unfocused_for().expect("unfocused"); + assert!(first >= Duration::from_millis(30)); + + set_focused(false); // a recycle's Destroyed + assert!(focus().unfocused_for().expect("unfocused") >= first); + + set_focused(true); + assert_eq!(focus().unfocused_for(), None); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 31ff79e9..3035fee0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,7 +3,9 @@ use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder}; use tauri::Manager; +use tauri_plugin_window_state::StateFlags; +mod focus; mod notifications; mod recycle; mod text_behavior; @@ -18,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 @@ -131,19 +141,17 @@ 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 - // 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; } 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 diff --git a/src-tauri/src/recycle.rs b/src-tauri/src/recycle.rs index fec353df..b477bde6 100644 --- a/src-tauri/src/recycle.rs +++ b/src-tauri/src/recycle.rs @@ -2,17 +2,18 @@ // // 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 -// 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` @@ -21,13 +22,18 @@ // 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::time::Duration; +use std::collections::BTreeSet; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::Mutex; +use std::time::{Duration, Instant}; use objc2::runtime::AnyObject; 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}; + +use crate::focus::Focus; const CHECK_INTERVAL_S: u64 = 60; const DASHBOARD_URL: &str = "http://127.0.0.1:17374"; @@ -35,21 +41,74 @@ 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()) +} + +// 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: f64 = std::env::var("PERISCOPE_RECYCLE_GB") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1.0); + let gb = env_number("PERISCOPE_RECYCLE_GB").unwrap_or(2.0); (gb * 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) +// 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 { + env_number(name) + .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): +// 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_at().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) --- @@ -74,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, @@ -110,26 +166,33 @@ 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) --- -fn refresh_webcontent_pid(app: &AppHandle) { - let Some(window) = app.get_webview_window("main") else { - return; - }; - 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); +/// 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 window = app.get_webview_window("main")?; + 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().filter(|&pid| pid > 0) } // Append-only shell log — `open`-launched apps have no visible stderr, and @@ -153,31 +216,36 @@ 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 } - 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 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(); - } + 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 || { - for attempt in 1..=10 { + let submit = |attempt: u32, tx: Sender>| { std::thread::sleep(Duration::from_millis(if attempt == 1 { 250 } else { 1000 })); - let (tx, rx) = std::sync::mpsc::channel::>(); let h = handle.clone(); - let ok = handle.run_on_main_thread(move || { + let queued = handle.run_on_main_thread(move || { let result = tauri::WebviewWindowBuilder::new( &h, "main", @@ -194,102 +262,197 @@ 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) => { - let _ = window.restore_state(StateFlags::all()); + // 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 + live_renderers().last = None; // re-learned next tick + mark_renderer_born(); Ok(()) } 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); - reap_old_webcontent(old_pid); - return; - } - Ok(Err(e)) => shell_log(&format!("[recycle] rebuild attempt {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); }); } -// 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; - } - 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; +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; } - 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; + 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")), } - 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)")); - }); + } + 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 +// 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" { + 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; } +/// 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. + let n = unsafe { + 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() + .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") + }) + .collect() +} + +/// 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; + } + kill(pid); + } +} + 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 = 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, 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)); - // 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 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 idle = seconds_since_last_input(); - if idle < idle_needed { + 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) 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; } @@ -298,3 +461,184 @@ pub fn start_monitor(app: &AppHandle) { } }); } + +#[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))); + } + + #[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_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)); + + 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)); + } + + // One test: PERISCOPE_RECYCLE_GB is process-global and cargo runs tests in parallel. + #[test] + 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"); + } + + #[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_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_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])); + } + + 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); + 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]); + } +} 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;