Skip to content

browser: non-permanent discard blockers + one shared discard event center - #7625

Closed
austinywang wants to merge 5 commits into
mainfrom
issue-7596-browser-discard
Closed

austinywang wants to merge 5 commits into
mainfrom
issue-7596-browser-discard

Conversation

@austinywang

@austinywang austinywang commented Jul 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Part of #7596 (memory audit, slice 5 — browser pane discard). The audit found 224 browser panes mapping to a ~5 GB resident WebKit helper tree; hidden-webview discard exists (on by default, 300 s) but its blocker list could immortalize hidden panes.

Four fixes:

  1. Silent media no longer immortalizes panes. media_playback blocked discard for any playing media frame, audible or not — a muted autoplaying hero video kept a hidden pane's WebContent process alive forever. The blocker now consumes the already-existing audible signal (hasAudibleMedia): background audio still blocks (real use), silent playback does not. media_capture (camera/mic) is unchanged, and the issue-Browser pane stops media playback (YouTube video) when hidden — discard doesn't exempt active playback #5409 media-report/glyph plumbing is untouched — only blocker consumption changed.
  2. DevTools blocker keys on reality. It combined a persisted intent flag (preferredDeveloperToolsVisible) with the live inspector probe; a stale intent flag with no actual inspector immortalized the pane. Now only the live probe blocks.
  3. Blocked panes self-heal. scheduleIfNeeded returned silently when blocked, and re-evaluation happened only on discrete events — any blocker whose clearing event didn't fire blocked discard forever. Blocked hidden discard candidates now arm a coarse one-shot re-check timer (max(60 s, hiddenDelay), same generation-token + webview-instance guards and cancellation paths as the discard timer, cannot bypass blockers). Terminal states (visible, closing, already-discarded, policy-disabled) never arm it, so a system wake doesn't create per-pane timer churn at 224-pane scale.
  4. One shared observer instead of ~672. Each panel's manager registered its own UserDefaults.didChangeNotification observer + 2 NSWorkspace sleep/wake observers, all re-resolving the same global policy on every defaults write. New BrowserHiddenWebViewDiscardEventCenter (singleton, injectable seams for tests) owns exactly one defaults observer + one sleep/wake pair, diffs the resolved policy once per change, and fans out to weakly-held subscribing managers. Per-panel delegate behavior is unchanged.

The issue's snapshot-and-release tier and a global live-WebContent cap are intentionally not in this slice (product-behavior changes; deferral rationale going on #7596). The post-wake discard guard (#5261) and memory-pressure immediate-discard path are unchanged.

Tests

  • New: silent-media-does-not-block, audible-media-blocks, devtools-preference-only-does-not-block, blocked-then-cleared self-heal via the re-check path, terminal-states-never-arm-recheck, and event-center suites (single observer per center, diffed fan-out, weak subscriber semantics) — cmuxTests/BrowserHiddenWebViewDiscardEventCenterTests.swift wired into pbxproj (lint-pbxproj-test-wiring.sh passes).
  • Updated: BrowserPanelTests media helpers/tests renamed to audible semantics; blocker tests now use isolated suite defaults through the policyDefaults seam instead of ambient .standard.
  • A red/green two-commit split isn't applicable here: the regression tests depend on the renamed snapshot field and new seams, so a test-only first commit could not compile against the old implementation.

Budgets: BrowserPanel.swift 11,640 ≤ 11,641 (net −1, 1:1 replacements), BrowserPanelTests.swift exactly at 4,367 (net 0); no .github/*.tsv changes; swift_file_length_budget.py passes.

No user-facing strings → no localization changes.

Part of #7596

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Changes when hidden WKWebViews are torn down (audible vs silent media, DevTools probe, periodic recheck) and centralizes sleep/wake and policy notifications—behavioral risk around background playback and post-wake discard timing, mitigated by existing guards and new tests.

Overview
Improves hidden-browser-pane memory reclaim (#7596) by fixing blockers that never cleared and collapsing hundreds of duplicate observers into one shared fan-out.

Blocker behavior: media_playback now keys off audible media (hasAudibleMedia / isPlayingAudio) so muted autoplay no longer keeps WebContent alive; capture still blocks. DevTools blocking uses only the live inspector probe, not preferredDeveloperToolsVisible. When scheduling is blocked but the pane is still a hidden discard candidate, a one-shot recheck (max(60s, hiddenDelay)) re-runs scheduleIfNeeded (reason blocked_recheck); visible, closing, already-discarded, and policy-disabled panes do not arm it.

Observer model: New BrowserHiddenWebViewDiscardEventCenter owns a single UserDefaults policy diff plus one sleep/wake pair and notifies weak subscribers; BrowserHiddenWebViewDiscardManager drops per-manager policy/sleep observers and uses installEventCenterSubscription() instead. BrowserPanel wires the new snapshot field and subscription API.

Reviewed by Cursor Bugbot for commit 8e82e92. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Strengthens hidden-webview discard and reduces memory by fixing non-permanent blockers and moving policy/sleep observers to one shared event center. Aligns recheck reporting, removes test-only seams, and resolves a Swift 6 actor-isolation warning; addresses #7596.

  • Bug Fixes

    • Only audible media blocks discard; silent playback no longer does. Camera/mic capture still blocks.
    • DevTools blocker keys off the live inspector only, not the persisted preference.
    • Blocked hidden panes auto-recheck after max(60s, hiddenDelay); visible, closing, discarded, and policy-disabled panes don’t arm rechecks.
    • Blocked recheck reports the "blocked_recheck" reason in production for accurate diagnostics.
  • Refactors

    • Added BrowserHiddenWebViewDiscardEventCenter to replace per-panel UserDefaults and sleep/wake observers with one diffing fan-out and weak subscribers.
    • Managers now subscribe via installEventCenterSubscription(), removing duplicate observers at scale.
    • Unified the recheck path via performBlockedRecheckNow and removed ...ForTesting seams; event-center internals are internal private(set) for @testable access.
    • Fixed a Swift 6 actor-isolation warning by making eventCenter optional in init and resolving .shared inside the main-actor initializer.

Written for commit 8e82e92. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Hidden web view discard handling is now driven by a centralized flow that reacts to policy changes and system sleep/wake events.
    • Added delayed “blocked” recheck so discard can proceed automatically after blockers clear.
  • Bug Fixes
    • Media playback blocking is more accurate using audible-media status (including media capture).
    • Improved scheduling behavior to avoid stale or duplicate discard-triggering.
  • Tests
    • Expanded test coverage for the new event flow and blocked recheck/discard rules, including developer-tools and blocker scenarios.

…nter

Hidden-webview discard fixes from the #7596 audit (224 browser panes
held a ~5 GB hidden WebKit tree):

- media_playback blocked discard for ANY playing media, so a muted
  autoplaying video immortalized its hidden pane's WebContent process.
  The blocker now keys on audible playback (background audio still
  blocks; camera/mic capture blocking unchanged; the #5409 media glyph
  plumbing is untouched).
- developer_tools blocked on a persisted intent flag that can outlive
  the actual inspector; it now keys on the live inspector probe only.
- A blocked hidden pane was never re-checked (blockers re-evaluated
  only on discrete events), so any stale blocker permanently prevented
  discard. Blocked hidden discard candidates now arm a coarse one-shot
  re-check timer (max(60s, hidden delay)) with the same generation and
  instance guards as the discard timer; terminal states (visible,
  closing, discarded, policy disabled) never arm it.
- Every panel's discard manager registered its own UserDefaults
  observer plus sleep/wake observers (~672 observers at 224 panes, all
  doing identical work). One shared BrowserHiddenWebViewDiscardEventCenter
  now owns a single defaults observer and one sleep/wake pair, diffs
  the resolved policy once, and fans out to weakly-held managers.

Part of #7596

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 8, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Canceled Canceled Jul 9, 2026 12:23am
cmux-staging Building Building Preview, Comment Jul 9, 2026 12:23am

@coderabbitai

coderabbitai Bot commented Jul 8, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a shared discard-event center, routes hidden-webview discard manager subscriptions through it, adds blocked recheck scheduling, and updates media blocker handling from isPlayingMedia to hasAudibleMedia across code, wiring, and tests.

Changes

Hidden WebView Discard Event Center and Blocked Recheck

Layer / File(s) Summary
Event center implementation
Sources/Panels/BrowserHiddenWebViewDiscardEventCenter.swift
Defines the main-actor subscriber protocol and singleton event center, installs defaults and sleep/wake observers, caches resolved policy state, and forwards notifications to live weak subscribers.
Discard manager scheduling
Sources/Panels/BrowserHiddenWebViewDiscardManager.swift
Updates blocker evaluation to use audible media, injects the event center, replaces raw observer management with event-center subscription wiring, and adds blocked-recheck timer state, scheduling, immediate execution, and cleanup paths.
BrowserPanel wiring and project registration
Sources/Panels/BrowserPanel.swift, cmux.xcodeproj/project.pbxproj
Switches BrowserPanel to the single event-center subscription, updates blocker snapshot media metadata to hasAudibleMedia, and registers the new implementation and test files in the Xcode project.
Event center tests
cmuxTests/BrowserHiddenWebViewDiscardEventCenterTests.swift
Adds a serialized test suite with an isolated UserDefaults helper, a recording subscriber, and coverage for observer registration, policy-change fan-out, subscriber pruning, and sleep/wake forwarding.
Manager and panel tests
cmuxTests/BrowserHiddenWebViewDiscardMemoryPressureTests.swift, cmuxTests/BrowserMediaPlaybackAudioActivityTests.swift, cmuxTests/BrowserPanelTests.swift
Extends hidden-webview discard tests with parameterized snapshot builders, blocked-recheck coverage, minimum delay and developer-tools blocker assertions, and audible-media test renames and expectations.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant UserDefaults
  participant BrowserHiddenWebViewDiscardEventCenter
  participant BrowserHiddenWebViewDiscardManager
  participant BrowserPanel

  UserDefaults->>BrowserHiddenWebViewDiscardEventCenter: didChangeNotification
  BrowserHiddenWebViewDiscardEventCenter->>BrowserHiddenWebViewDiscardEventCenter: re-resolve discard policy
  BrowserHiddenWebViewDiscardEventCenter->>BrowserHiddenWebViewDiscardManager: discardPolicyDidChange
  BrowserHiddenWebViewDiscardManager->>BrowserHiddenWebViewDiscardManager: scheduleIfNeeded()
  BrowserHiddenWebViewDiscardManager->>BrowserPanel: update hidden-webview discard state
Loading

Possibly related PRs

  • manaflow-ai/cmux#4245: Both PRs use the hidden-webview discard UserDefaults keys and react to policy changes from UserDefaults.didChangeNotification.
  • manaflow-ai/cmux#6585: Both PRs modify the hidden-webview discard manager and panel-triggered discard flow, including scheduling behavior.

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (2 errors)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error Production BrowserHiddenWebViewDiscardManager adds blockedRecheckTimer via DispatchSource.makeTimerSource(queue: .main) and schedules max(60, hiddenDelay) rechecks. Replace coarse timer rechecks with explicit blocker-cleared notifications/actor messages so scheduling stays event-driven and non-blocking.
Cmux No Ambient Global State ❌ Error Sources/Panels/BrowserHiddenWebViewDiscardEventCenter.swift:18 adds a new runtime singleton (static let shared), which this rule forbids. Move the event-center instance onto an owning scope such as BrowserPanel, construct it there, and inject it into BrowserHiddenWebViewDiscardManager; remove shared.
✅ Passed checks (23 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed Both new discard types are @MainActor, and .shared is only resolved inside a MainActor init; no new background UI access or unsafe Sendable sharing was introduced.
Cmux Browser Automation Off-Main ✅ Passed PR only changes hidden-webview discard manager/event-center code; it doesn’t touch TerminalController or ControlCommandExecutionPolicy browser socket routing.
Cmux Expensive Synchronous Load ✅ Passed Diff only rewires discard observers/timers and blocker fields; no agent-history load, large JSON parse, or broad scan was added on a main-actor/interactive path.
Cmux Cache Substitution Correctness ✅ Passed The diff uses event-driven policy fan-out and live audio state for the snapshot; no persistence/history/undo/snapshot path replaces a fresh read with an unbounded cache.
Cmux No Hacky Sleeps ✅ Passed PASS: the PR only changes Swift sources/tests and an Xcode project file; no TS/JS/shell/build/runtime scripts are touched, so the non-Swift hacky-sleeps rule doesn’t apply.
Cmux Algorithmic Complexity ✅ Passed New event-center fan-out is linear over pane subscribers and replaces per-panel observers; no nested scans, batch rescans, or hot-path sorting/filtering were introduced.
Cmux Swift Concurrency ✅ Passed No banned concurrency patterns were introduced; the new async behavior is AppKit/OS notification callbacks and timers, and the lone Task is deinit teardown.
Cmux Swift @Concurrent ✅ Passed PASS: the changed Swift code is either @MainActor-bound or sync; there’s no new nonisolated async work missing @concurrent, and off-main cleanup hops via Task { @MainActor }.
Cmux Swift File And Package Boundaries ✅ Passed New production code is small and focused; the only oversized file touched gets an incidental 2-line change, and the discard logic is AppKit-specific glue, not misplaced package-domain code.
Cmux Swiftpm Lockfiles ✅ Passed The PR only adds source/test file entries to cmux.xcodeproj; no SwiftPM package-reference, Package.swift, Package.resolved, .gitignore, or workflow dependency changes are present.
Cmux Swift Logging ✅ Passed No new runtime logging was introduced; the PR diff shows only the init default-arg tweak, and patch searches found no added print/NSLog/Logger usage in app code.
Cmux User-Facing Error Privacy ✅ Passed The only production diff is an init default-argument/comment change; no user-facing errors, alerts, command output, or recovery copy were added or exposed.
Cmux Full Internationalization ✅ Passed PASS: PR adds internal observer/blocker wiring and tests only; no locale files changed and no new user-facing Swift text was introduced.
Cmux Swiftui State Layout ✅ Passed Diff only rewires discard observers and renames a snapshot flag; no new SwiftUI state, GeometryReader/layout changes, lazy-row store refs, or render-time state writes.
Cmux Architecture Rethink ✅ Passed PASS: The new event center centralizes duplicated observers into one shared owner, and the blocked recheck is a bounded one-shot with clear guards and invariant.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR only changes discard-policy plumbing/tests; no standalone window/panel/controller/WindowGroup ownership or cmuxAuxiliaryWindowIdentifiers changes.
Cmux Source Artifacts ✅ Passed Only a hand-written Swift source file changed; no logs, caches, build output, or scratch/artifact paths were added.
Cmux No Test Or Debug Seam In Production Source ✅ Passed PASS: The production diff adds only generic dependency injection and behavior changes; no new DEBUG/test-only accessor or ForTesting seam appears in Sources/.
Title check ✅ Passed The title is concise and accurately captures the main changes: shared discard event center and non-permanent blockers.
Description check ✅ Passed The description is detailed and on-topic, with clear summary and testing sections, though it omits the demo video, review trigger, and checklist template sections.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-7596-browser-discard

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 8, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR updates hidden browser webview discard behavior and centralizes its event handling. The main changes are:

  • Silent media no longer blocks hidden-webview discard.
  • Live DevTools visibility is the discard blocker instead of stored intent.
  • Blocked hidden panes get a coarse recheck timer.
  • Defaults and sleep/wake observers are shared through one event center.
  • New discard event-center and blocker behavior tests are wired into the Xcode project.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
Sources/Panels/BrowserHiddenWebViewDiscardEventCenter.swift Adds the shared event center for discard policy and sleep/wake fan-out.
Sources/Panels/BrowserHiddenWebViewDiscardManager.swift Updates discard blockers, event-center subscription, and blocked recheck handling.
Sources/Panels/BrowserPanel.swift Connects panels to the shared discard event center and passes audible media state to the discard snapshot.
cmux.xcodeproj/project.pbxproj Wires the new event-center source and tests into the project.

Reviews (5): Last reviewed commit: "browser: avoid main-actor .shared defaul..." | Re-trigger Greptile

/// discard policy state for every defaults write.
@MainActor
final class BrowserHiddenWebViewDiscardEventCenter {
static let shared = BrowserHiddenWebViewDiscardEventCenter()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Discard State Becomes Global

This line adds a process-wide runtime owner for discard observer state, and every manager now defaults to it. That makes subscriber and policy fan-out ownership ambient instead of scoped to the browser owner, so separate browser lifetimes cannot construct and tear down their discard event source at the app seam.

Rule Used: Flag new ambient global state in production Swift:... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The process-wide singleton is the point of this change: issue #7596 measured ~224 hidden panes each installing 3 observers that re-resolved the same global UserDefaults policy on every defaults write. The observed state (app-wide defaults + system sleep/wake) is inherently process-global, so one owner is the correct scope. The initializer keeps injectable seams (defaults, both notification centers, observerQueue) and BrowserHiddenWebViewDiscardManager accepts an injected center, so tests construct isolated instances — only the production default is shared. There is no per-browser-owner lifetime seam in the app today for this to attach to.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a fair correction. When the observed state is inherently process-global (UserDefaults + NSWorkspace sleep/wake), a process-wide singleton is the correct scope — not a smell. The injectable seams and injected-center constructor on BrowserHiddenWebViewDiscardManager address the isolation concern for tests, which is the real requirement. My comment about "separate browser lifetimes" assumed an architectural boundary that doesn't exist here.

Withdrawing the concern.

Shall I remember this as a rule: "singletons wrapping inherently process-global state (UserDefaults, NSWorkspace sleep/wake) are acceptable in this codebase as long as injectable seams exist for tests"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, please remember that rule.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. View rule

Comment on lines +61 to +68
var subscriberCountForTesting: Int {
compactSubscribers()
return subscribers.count
}

var observerInstallCountForTesting: (defaults: Int, workspace: Int) {
(defaultsObserver == nil ? 0 : 1, sleepObservers.count)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test State Ships In Source

These ForTesting accessors expose private event-center state from a production Sources/ file and compile into the app target. The tests only need observer/subscriber counts, so this adds test-only surface to shipping code instead of observing internal state through @testable import or test-target scaffolding.

Rule Used: Flag Swift files under a production Sources path (... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*ForTesting members in production sources are the established convention in this codebase — 218 existing occurrences across 20+ files under Sources/ (e.g. TabManager.swift, AppDelegate.swift, TerminalController.swift). cmuxTests compiles the app target sources directly, so the accessors follow the same pattern as their siblings. Keeping the counters behind explicit ForTesting names is preferable to widening the underlying stored properties' access.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — with 218 existing occurrences of the same pattern across production sources, this is clearly the codebase convention and not an anomaly. The ForTesting suffix naming convention already provides the right signal at the call site. I'll withdraw this concern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correcting my earlier reply: the existing ForTesting occurrences are grandfathered, but the repo has an explicit rule against adding new ones — .github/review-bot-rules/no-test-debug-seam-in-production-source.md (canonical fix: #6452). Your original finding was right for this codebase. Fixed in 4749a2c: accessors removed, state widened to internal private(set), tests read it via @testable import. Please remember the rule as: new test/debug seams (ForTesting-style members) must not be added to production Sources/; tests reach internal state via @testable import instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the correction — my original finding was right and I should have held the position. Rule saved: new ForTesting-style seams must not be added to production Sources/; tests use @testable import to reach internal state instead.

View rule

Comment on lines +302 to +306
observedWebViewInstanceID: UUID,
generation: UInt64
) {
guard blockedRecheckTimer == nil else { return }
let policy = BrowserHiddenWebViewDiscardPolicy.resolved(defaults: policyDefaults)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Recheck Timer Can Be Starved

The blocked-pane self-heal is driven by a production timer, and every later scheduleIfNeeded call cancels and recreates it before checking blockers. When memory pressure keeps retrying immediate discard while a blocker is still present, each retry restarts the countdown, so the hidden pane can stay alive until those retries stop instead of rechecking from the original blocker time.

Rule Used: Flag new blocking or timing-based synchronization ... (source)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The memory-pressure premise doesn't hold: requestImmediateDiscardIfSafe returns false on the blocker guard before touching any timer state, so pressure retries while blocked never cancel or restart the recheck timer (see the guard at the top of the method — the blockedRecheckTimer?.cancel() only runs after blockers pass).

For scheduleIfNeeded: it does cancel and re-arm the recheck, but every such call performs a full fresh blocker evaluation at that instant — a reset is always accompanied by an immediate re-check, so the self-heal invariant ("a blocked hidden candidate always has either a just-completed evaluation or an armed recheck") holds and no evaluation is ever lost. Keeping the original deadline across resets would add generation-tracking complexity without changing when blockers are actually observed. The recheck is deliberately coarse (max(60s, hiddenDelay)) and never bypasses blockers.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right on both counts.

For requestImmediateDiscardIfSafe, the second guard exits before any timer state is touched — pressure retries while blocked are pure no-ops and cannot interfere with the recheck timer. That leg of my concern was wrong.

For scheduleIfNeeded: the cancel-then-re-arm pattern does reset the recheck deadline, but since the scheduleBlockedRecheckIfNeeded guard (blockedRecheckTimer == nil) passes immediately after the cancel, every reset produces a fresh evaluation and a new armed timer in the same call. No evaluation window is lost. The 60 s floor makes the deadline drift inconsequential in practice.

Both points stand — the self-heal invariant holds. Thanks for walking through the control flow.

guard delegate.hiddenWebViewDiscardWebViewInstanceID == observedWebViewInstanceID else { return }
self.blockedRecheckTimer?.cancel()
self.blockedRecheckTimer = nil
self.scheduleIfNeeded(reason: reason, now: Date())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Recheck Reports Stale Reason

When the runtime blocked-recheck timer fires, this call reuses the original scheduling reason captured when the pane was blocked. If blockers have cleared and the pane is discarded, the delegate receives that stale reason instead of blocked_recheck, so discard diagnostics and any reason-based handling report the wrong trigger.

Suggested change
self.scheduleIfNeeded(reason: reason, now: Date())
self.scheduleIfNeeded(reason: "blocked_recheck", now: Date())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a7aeb4b: the production recheck timer now passes "blocked_recheck", matching performScheduledBlockedRecheckForTesting and the regression test that asserts lastDiscardReason == "blocked_recheck". The now-unused reason parameter was removed from scheduleBlockedRecheckIfNeeded.

Comment on lines 95 to +97
if snapshot.isCapturingMedia { blockers.append("media_capture") }
if snapshot.isPlayingMedia { blockers.append("media_playback") }
if snapshot.preferredDeveloperToolsVisible || snapshot.isDeveloperToolsVisible {
if snapshot.hasAudibleMedia { blockers.append("media_playback") }
if snapshot.isDeveloperToolsVisible {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Inspector Intent Can Be Lost

This now ignores preferredDeveloperToolsVisible and only blocks when the live inspector probe is true. If the user has requested DevTools but the live probe is temporarily false during inspector startup or hidden-pane transition, the pane becomes discardable and the WebView can be destroyed before the inspector state becomes visible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional, and the core of fix #2 in the PR description: preferredDeveloperToolsVisible is a persisted intent flag, so a stale value immortalized a hidden pane's WebContent process forever even with no inspector attached — exactly the permanent-blocker class this PR removes. The startup race is bounded and benign: discard only fires for panes hidden ≥ hiddenDelay (default 300s) with blockers re-evaluated at fire time, so an inspector that actually opens flips the live probe long before the countdown lands; in the worst case the discard is non-destructive (session state is retained and the pane restores on reveal). A permanent leak on one side vs. a rare re-open of the inspector on the other.

The runtime blocked-recheck timer reused the reason captured when the
pane was first blocked, while performScheduledBlockedRecheckForTesting
(and the regression test asserting lastDiscardReason) used
"blocked_recheck". Align production with the tested contract so discard
diagnostics report the actual trigger.

Addresses Greptile review on #7625.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +111 to +112
blockedRecheckTimer?.cancel()
blockedRecheckTimer = nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Recheck Timer Resets

This cancels the blocked-recheck timer before checking whether the pane is still blocked. Hidden blocked panes can re-enter scheduleIfNeeded from normal reevaluations such as loading, media, download, popup, or DevTools state changes. If the blocker is still present, each reevaluation cancels the existing recheck and starts a new full delay. A pane stuck behind a long provisional navigation can keep pushing the self-heal timer out and remain undiscarded after the original recheck should have run.

Rule Used: Flag new blocking or timing-based synchronization ... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same control flow resolved in the thread on scheduleBlockedRecheckIfNeeded (#7625 (comment)), where the conclusion was: every reset produces a fresh evaluation and a new armed timer in the same call, so no evaluation window is lost.

Concretely: the cancel at the top of scheduleIfNeeded is always immediately followed by a full blocker evaluation in that same call. The recheck timer exists only for blockers whose clearing emits no discrete event; pushing the deadline out requires continuous event churn, and each churn event is itself a fresh evaluation confirming the pane is still blocked — correct behavior, not a stall. For the provisional-navigation example: while the navigation is in flight every re-entry re-evaluates; when it completes, the navigation delegates fire a discrete re-evaluation that arms the real discard timer, and if no completion event fires, the last armed recheck fires after max(60s, hiddenDelay). There is no path where the pane stays undiscarded without an evaluation having just confirmed a live blocker.

Preserving the original deadline across resets would add generation bookkeeping without changing when blocker state is actually observed, so keeping the simple cancel-then-re-arm is deliberate.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/Panels/BrowserHiddenWebViewDiscardEventCenter.swift`:
- Around line 61-68: The production-only debug seam is exposed through the
`BrowserHiddenWebViewDiscardEventCenter` properties `subscriberCountForTesting`
and `observerInstallCountForTesting`. Rename these members to drop the
`ForTesting` suffix (for example, `subscriberCount` and `observerInstallCount`)
or wrap them in `#if DEBUG` so they are not compiled into production source, and
update any test references that use these accessors to match the new names.
- Around line 70-100: Remove the test-only accessors from
BrowserHiddenWebViewDiscardEventCenter in production code:
subscriberCountForTesting and observerInstallCountForTesting should not live in
Sources. Move any test-only inspection into the test target or rely on `@testable`
import to access the internal state directly, while keeping installObservers and
the production notification wiring unchanged.

In `@Sources/Panels/BrowserHiddenWebViewDiscardManager.swift`:
- Around line 160-168: The test-only recheck helper should not be exposed on the
production type. Remove or hide performScheduledBlockedRecheckForTesting from
BrowserHiddenWebViewDiscardManager, and instead make the underlying blocked
recheck path internal/private so BrowserHiddenWebViewDiscardMemoryPressureTests
can reach it via `@testable` import, or update the test to exercise the existing
scheduleIfNeeded state flow directly. Keep the production API free of any
...ForTesting seam while preserving the same blocked_recheck behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d4fab393-4a6e-48c1-af53-94adede86dae

📥 Commits

Reviewing files that changed from the base of the PR and between 0b365eb and bc98a97.

📒 Files selected for processing (8)
  • Sources/Panels/BrowserHiddenWebViewDiscardEventCenter.swift
  • Sources/Panels/BrowserHiddenWebViewDiscardManager.swift
  • Sources/Panels/BrowserPanel.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/BrowserHiddenWebViewDiscardEventCenterTests.swift
  • cmuxTests/BrowserHiddenWebViewDiscardMemoryPressureTests.swift
  • cmuxTests/BrowserMediaPlaybackAudioActivityTests.swift
  • cmuxTests/BrowserPanelTests.swift

Comment thread Sources/Panels/BrowserHiddenWebViewDiscardEventCenter.swift Outdated
Comment thread Sources/Panels/BrowserHiddenWebViewDiscardEventCenter.swift
Comment thread Sources/Panels/BrowserHiddenWebViewDiscardManager.swift
Per .github/review-bot-rules/no-test-debug-seam-in-production-source.md
(canonical fix: #6452), production Sources/ must not grow new
...ForTesting members:

- Event center: drop subscriberCountForTesting and
  observerInstallCountForTesting; widen defaultsObserver, sleepObservers,
  and subscribers (plus WeakSubscriber) to internal private(set) so the
  tests observe them via @testable import.
- Manager: rename performScheduledBlockedRecheckForTesting to
  performBlockedRecheckNow and route the production blocked-recheck
  timer handler through it, so production and tests share one re-check
  fire path instead of the test helper duplicating the handler body.

Addresses CodeRabbit review on #7625.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Sources/Panels/BrowserHiddenWebViewDiscardManager.swift (1)

111-122: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Replace the blocked-recheck timer with causal blocker-clear signals.

This adds a production DispatchSourceTimer as a coarse self-healing path for hidden-webview blocker state. That leaves discard correctness dependent on a delayed poll instead of the real state transition that cleared loading, DevTools, media, popup, fullscreen, download, or capture blockers.

Route each blocker-clear event through the shared discard action path / event center and re-run scheduling immediately; fail closed if a reliable signal is missing.

As per coding guidelines, production Swift must flag timing-based coordination such as timers/polling when used to paper over lifecycle/rendering/shared-state races. As per path instructions, hidden-webview discard decisions should avoid throttling/polling staleness and prefer structured lifecycle/typed events.

Also applies to: 297-323

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Panels/BrowserHiddenWebViewDiscardManager.swift` around lines 111 -
122, The blocked-recheck timer in BrowserHiddenWebViewDiscardManager is a
polling fallback that should be removed in favor of causal blocker-clear
signals. Update the hidden-webview discard flow so each blocker-clearing event
(loading, DevTools, media, popup, fullscreen, download, capture) routes through
the shared discard action path/event center and triggers scheduling immediately
via the existing schedule logic, rather than waiting for blockedRecheckTimer or
scheduleBlockedRecheckIfNeeded. Keep the behavior fail-closed when a reliable
clear signal is missing, and use the existing symbols blockedRecheckTimer,
scheduleBlockedRecheckIfNeeded, and hiddenWebViewDiscardSnapshot to locate the
affected paths.

Sources: Coding guidelines, Path instructions

Sources/Panels/BrowserHiddenWebViewDiscardEventCenter.swift (1)

64-95: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

MainActor.assumeIsolated relies on observerQueue always being main-associated.

Both observer closures call MainActor.assumeIsolated unconditionally, which is only safe because observerQueue defaults to .main (and tests pass nil, delivering synchronously on the calling actor). If a future call site passes a non-main OperationQueue, the closure will run off the main thread and assumeIsolated will trap at runtime. Consider documenting this constraint on the observerQueue parameter, or asserting queue?.underlyingQueue === DispatchQueue.main defensively.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Panels/BrowserHiddenWebViewDiscardEventCenter.swift` around lines 64
- 95, The observer closures in BrowserHiddenWebViewDiscardEventCenter’s
installObservers() unconditionally call MainActor.assumeIsolated, so they must
only ever run on the main actor. Make the observerQueue constraint explicit in
the initializer or installObservers() by documenting that only .main or nil is
allowed, and add a defensive precondition/assertion before registering observers
to verify the queue is main-associated (for example via underlyingQueue). This
keeps handleDefaultsChanged(), notifySystemWillSleep(), and
notifySystemDidWake() from trapping if a non-main OperationQueue is passed
later.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@Sources/Panels/BrowserHiddenWebViewDiscardEventCenter.swift`:
- Around line 64-95: The observer closures in
BrowserHiddenWebViewDiscardEventCenter’s installObservers() unconditionally call
MainActor.assumeIsolated, so they must only ever run on the main actor. Make the
observerQueue constraint explicit in the initializer or installObservers() by
documenting that only .main or nil is allowed, and add a defensive
precondition/assertion before registering observers to verify the queue is
main-associated (for example via underlyingQueue). This keeps
handleDefaultsChanged(), notifySystemWillSleep(), and notifySystemDidWake() from
trapping if a non-main OperationQueue is passed later.

In `@Sources/Panels/BrowserHiddenWebViewDiscardManager.swift`:
- Around line 111-122: The blocked-recheck timer in
BrowserHiddenWebViewDiscardManager is a polling fallback that should be removed
in favor of causal blocker-clear signals. Update the hidden-webview discard flow
so each blocker-clearing event (loading, DevTools, media, popup, fullscreen,
download, capture) routes through the shared discard action path/event center
and triggers scheduling immediately via the existing schedule logic, rather than
waiting for blockedRecheckTimer or scheduleBlockedRecheckIfNeeded. Keep the
behavior fail-closed when a reliable clear signal is missing, and use the
existing symbols blockedRecheckTimer, scheduleBlockedRecheckIfNeeded, and
hiddenWebViewDiscardSnapshot to locate the affected paths.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 07ff4cfb-693a-4536-9448-b99625ca96a7

📥 Commits

Reviewing files that changed from the base of the PR and between bc98a97 and 4749a2c.

📒 Files selected for processing (4)
  • Sources/Panels/BrowserHiddenWebViewDiscardEventCenter.swift
  • Sources/Panels/BrowserHiddenWebViewDiscardManager.swift
  • cmuxTests/BrowserHiddenWebViewDiscardEventCenterTests.swift
  • cmuxTests/BrowserHiddenWebViewDiscardMemoryPressureTests.swift

austinywang and others added 2 commits July 8, 2026 03:19
Default-argument expressions are evaluated in a nonisolated context, so
'eventCenter: ... = .shared' referencing the @MainActor-isolated static
tripped the Swift 6 actor-isolation warning and the CI warning budget
(tests-build-and-lag). Default to nil and resolve .shared inside the
main-actor-isolated init body instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — 8e82e927 Deployed Jul 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants