Skip to content

THI-449: recycle the webview when you leave periscope, without looping or stealing focus - #54

Merged
tdody merged 26 commits into
mainfrom
thibaultdody/thi-449-webview-recycle-never-fires-while-the-user-is-active-so-the
Nov 2, 2026
Merged

tdody merged 26 commits into
mainfrom
thibaultdody/thi-449-webview-recycle-never-fires-while-the-user-is-active-so-the

Conversation

@tdody

@tdody tdody commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The Tauri shell recycles the leaking WKWebView (destroy + rebuild the window) to free graphics memory WebKit never gives back. Until now it only did so after 5 minutes of no input to any app, so while you worked the renderer grew unchecked, to 7.7 GB at peak (THI-449).

  • Gate (recycle.rs), recycle when all hold:
    • footprint ≥ PERISCOPE_RECYCLE_GB, default raised from 1 to 2 GB. A freshly loaded dashboard is already 1.1–1.6 GB, and the old 1 GB gate looped: 62 recycles came under 90 s apart.
    • the renderer has lived ≥ PERISCOPE_RECYCLE_MIN_AGE_S (30 min) since the last rebuild, so no gate can loop;
    • periscope has been unfocused ≥ PERISCOPE_RECYCLE_UNFOCUSED_S (30 s), or input has been idle ≥ PERISCOPE_RECYCLE_IDLE_S (300 s).
    • One parser reads the knobs, and a bad value falls back to its default instead of panicking the monitor thread.
  • Focus (focus.rs, new): one locked source for the window's focus state and how long it has been lost. Notification banners and the recycle gate both read it.
  • A rebuild never activates the app.
    • Cause: the window-state plugin's on_window_ready hook restored VISIBLE on every new window, which calls set_focus (activateIgnoringOtherApps: YES). That is what pulled the user out of their app, and keystrokes typed meanwhile could reach a restored terminal pane.
    • Fix: the plugin is now registered with every state except VISIBLE. wry's own activate() is cooperative, and macOS declines it while another app is frontmost.
    • Minimized windows: a minimized window is re-minimized after the rebuild.
  • Rebuild: the attempts share one result channel (await_rebuild). An attempt that timed out but did build the window counts as success; on 10-28 the loop gave up in exactly that case and leaked a 1.1 GB renderer.
  • Sweep: replaces the 10 s after-recycle reaper. Every check SIGKILLs any WebContent the app is responsible for that the monitor has seen live before and that isn't this tick's live renderer. That covers WebKit parking a displaced renderer at full size.
    • It runs only when this tick's live-pid read answered.
    • Each kill re-checks that no recycle started or finished meanwhile.
  • Docs: docs/tauri-shell.md's recycling section and the persist.js header are updated. Design: docs/superpowers/specs/2026-11-01-webview-recycle-gate-design.md. Plan: docs/superpowers/plans/2026-11-01-webview-recycle-gate.md (historical; the spec is current).

Test plan

  • cargo test in src-tauri/: 45 passed, covering the gate, the knob fallbacks, the focus snapshot, the sweep selection (kills only renderers seen live and never the live one), the per-kill recheck, and the rebuild loop (a late Ok, "already exists" then Ok, give-up). cargo build and cargo clippy --tests are clean.
  • Manual, on a release build with open --env PERISCOPE_RECYCLE_GB=0.2 --env …_MIN_AGE_S=60 --env …_UNFOCUSED_S=10:
    • Unfocused recycles, one while the user was typing, left the user's app frontmost (Claude, then Chrome).
    • The unfocused clock kept counting through each rebuild.
    • The minimum age held: it waited at 59 s and recycled at 119 s.
    • The sweep killed only the previous recycle's displaced renderer, at the next check.
  • Reviews:
    • an adversarial review of the spec and plan;
    • per-task reviews;
    • a final branch review;
    • a thorough multi-agent review: 11 findings, 1 blocking (the keystroke routing). All are fixed, and a follow-up review re-verified them.
  • Not exercised (needs a click): View → Recycle Webview while focused; ⌘H-hiding or minimizing Periscope across a recycle; a fullscreen Periscope in another Space.

Assumptions to challenge

  • wry's per-webview NSApplication::activate is declined while another app is frontmost. Verified on macOS 27 only. An OS that honoured it would bring back focus theft.
  • Untested: a ⌘H-hidden app staying hidden through a rebuild, and a fullscreen-in-another-Space rebuild not switching Spaces. The plugin still restores FULLSCREEN.
  • A minimized window is briefly visible before it is re-minimized.
  • The sweep only kills renderers it has seen live. A renderer replaced before any check read it (two manual recycles within a minute) lives until the app quits.
  • Testing knobs: they reach the app through open --env. launchctl setenv from the agent's shell did not.
  • Installing: this ships in the .app, not the server. The prod app needs a cargo tauri build in the prod checkout and a relaunch after merge.

🤖 Generated with Claude Code

tdody and others added 26 commits November 1, 2026 11:29
…already exists' rebuild counts as success, a per-check sweep kills leftover renderers (THI-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nimum age and 2 GB threshold, one rebuild channel, no focus theft, sweep only with a fresh pid (THI-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…; notifications reads it (THI-449)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…0 min of a rebuild, above 2 GB (THI-449)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…takes focus when periscope had it (THI-449)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ith a freshly read live pid; the after-recycle reaper goes (THI-449)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ld and sweep (THI-449)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…us question, each sweep kill re-checks, bad knobs fall back (THI-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…p rechecks against the live state before each kill (THI-449)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ou were in — wry's per-webview activate() is honoured (THI-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… --env (THI-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… frontmost, and logs what it did (THI-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tions record the test results and what stays untested (THI-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
…-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…any other one over 256 MB; no pid is None, not 0 (THI-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s, and a test shows a failed recheck stops further kills (THI-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…jected 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) <noreply@anthropic.com>
…OPE_RECYCLE_GB, and 1e30/NaN for the second knobs (THI-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…knobs (THI-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r both the gate and the waiting line (THI-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…th 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) <noreply@anthropic.com>
…vation and there is no hand-back, the sweep targets renderers seen live, tests and assumptions updated (THI-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tems_after_test_module) (THI-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s the crash-reload gap as milliseconds (THI-449)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@tdody
tdody merged commit 6c1ca90 into main Nov 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant