Skip to content

Keep background Browser WebViews automatable#6027

Open
lawrencecchen wants to merge 3 commits into
mainfrom
task-browser-background-resource-sync
Open

Keep background Browser WebViews automatable#6027
lawrencecchen wants to merge 3 commits into
mainfrom
task-browser-background-resource-sync

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Route Browser socket JS commands through a BrowserPanel automation lease.
  • Reuse the screenshot offscreen render host for hidden or background WebViews, and restore memory-discarded WebViews before commands capture their WKWebView.
  • Block hidden-WebView discard while socket automation is running, then restore the WebView and resume discard scheduling after the command.

Testing

  • xcodebuild test -project cmux.xcodeproj -scheme cmux-unit -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux-browser-bg-test -only-testing:cmuxTests/BrowserPanelVisualAutomationRestoreHostTests\n- ./scripts/lint-pbxproj-test-wiring.sh\n- ./scripts/reload-cloud.sh --tag bgbrws\n- Preflighted tagged app bgbrws: created Browser surface, backgrounded it, ran browser --surface surface:2 eval 'document.body.dataset.ready' and browser --surface surface:2 snapshot --compact, captured /tmp/cmux-bgbrws-browser.png.\n

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


Note

Medium Risk
Changes WebKit view hierarchy, hidden-webview memory discard timing, and the socket automation path; incorrect restore or lease teardown could crash WebKit or break background eval/snapshot, but behavior is guarded with tests and existing visual-automation blockers.

Overview
Socket-driven browser commands now run under a BrowserPanel automation lease that restores memory-discarded WKWebViews, cancels hidden-webview discard while work is in flight, and optionally hosts the view in the same offscreen render panel used for screenshots when the surface is hidden or not in a window.

OffscreenRenderHostLease centralizes attach/restore logic in BrowserScreenshotWebViewSnapshotter; TerminalController acquires the lease for the full v2BrowserWithPanelContext socket path. After a command, last automation activity resets the hidden-discard countdown (alongside wake time) so pages are not torn down immediately.

Tests move to BrowserPanelVisualAutomationRestoreHostTests and add coverage for portal-hosted hidden tabs and discard blocking during leases.

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


Summary by cubic

Keeps background browser surfaces automatable by hosting hidden or discarded WebViews in an offscreen window during socket commands, then restoring normal lifecycle after. Discard is paused during commands and the hidden-discard countdown restarts using the last automation time at command end to avoid immediate discard.

  • New Features

    • Added an automation command lease on BrowserPanel (beginAutomationCommandLease/endAutomationCommandLease), used by TerminalController for all socket JS commands.
    • The lease restores discarded WebViews before capture, hosts hidden WebViews offscreen, blocks discard while active, and records the last automation time so the discard timer restarts after the command.
  • Refactors

    • Introduced BrowserScreenshotWebViewSnapshotter.OffscreenRenderHostLease to reuse the screenshot offscreen render host for automation and snapshots.
    • Simplified BrowserScreenshotWebViewSnapshotter to use the lease, reducing duplicated attach/detach logic.

Written for commit e3fd55b. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added automation command leases with offscreen rendering support and automatic web view restoration around visual automation.
  • Improvements

    • Centralized offscreen render host setup/teardown for more reliable lifecycle handling.
    • Updated hidden-web view discard scheduling to consider the most recent automation activity, reducing premature discards.
  • Tests

    • Expanded browser panel visual automation coverage, including restoration ordering/timing and ensuring discard is blocked while a lease is active.

@vercel

vercel Bot commented Jun 13, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 14, 2026 2:01am
cmux-staging Building Building Preview, Comment Jun 14, 2026 2:01am

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements automation command leases for offscreen WebView hosting during browser automation. A new OffscreenRenderHostLease class encapsulates offscreen panel creation and WebView restoration. BrowserScreenshotSnapshotter methods are refactored to use the lease. BrowserPanel adds beginAutomationCommandLease() and endAutomationCommandLease() methods to manage lease lifecycle, tracks automation activity timestamps, and controls hidden-webview discard. BrowserHiddenWebViewDiscardManager consults automation activity when scheduling discard. TerminalController brackets automation execution with lease acquisition and cleanup. Tests verify lease behavior and discard suppression during automation.

Changes

Automation Command Lease for Offscreen Hosting

Layer / File(s) Summary
OffscreenRenderHostLease abstraction
Sources/Panels/BrowserScreenshotSnapshotter.swift
OffscreenRenderHostLease final class captures WebView's original superview, frame, window state; creates transparent borderless offscreen panel; swaps WebView into panel; restores WebView and tears down panel via end() and deinit safety path.
BrowserScreenshotSnapshotter integration with lease
Sources/Panels/BrowserScreenshotSnapshotter.swift
Both withOffscreenRenderHost async and callback overloads refactored to instantiate OffscreenRenderHostLease: async variant uses defer { lease.end() }; callback variant calls lease.end() in guarded finish path. Removes duplicated inline restoration/close logic.
BrowserPanel automation activity tracking
Sources/Panels/BrowserPanel.swift
BrowserPanel adds webViewLastAutomationActivityAt timestamp field, clears it when webview lifecycle metadata resets, and implements hiddenWebViewDiscardLastAutomationActivityAt delegate property to expose the timestamp to the discard manager.
BrowserPanel automation command lease API
Sources/Panels/BrowserPanel.swift
beginAutomationCommandLease(reason:) timestamps activity, increments capture count, cancels discard, restores discarded webview, refreshes lifecycle state, returns optional lease. endAutomationCommandLease(_:reason:) ends lease, updates timestamp, decrements count (clamped non-negative), refreshes state, schedules discard when count reaches zero and webview not visible in UI.
BrowserHiddenWebViewDiscardManager automation-aware scheduling
Sources/Panels/BrowserHiddenWebViewDiscardManager.swift
BrowserHiddenWebViewDiscardManagerDelegate gains hiddenWebViewDiscardLastAutomationActivityAt property. scheduleIfNeeded updates effectiveHiddenAt calculation to use maximum of hiddenAt, lastSystemWakeAt, and delegate-provided last automation activity time, deferring discard when automation activity is recent.
TerminalController automation execution with lease
Sources/TerminalController.swift
In v2BrowserWithPanelContext, records automationLease acquired from browserPanel.beginAutomationCommandLease() after resolution, and reliably ends lease via defer block after resolved context handed to caller. Brackets offscreen render host lifecycle around automation command execution.
Automation lease and discard behavior tests
cmuxTests/BrowserPanelTests.swift, cmuxTests/BrowserPanelVisualAutomationRestoreHostTests.swift, cmux.xcodeproj/project.pbxproj
BrowserHiddenWebViewDiscardTestDelegate gains hiddenWebViewDiscardLastAutomationActivityAt property. New testAutomationActivityRestartsHiddenWebViewDiscardCountdown verifies automation activity timestamps restart discard countdown. New test file with BrowserPanelVisualAutomationRestoreHostTests includes testRestoredDiscardedHiddenWebViewGetsRestoreHostBeforeOffscreenCapture, testAutomationCommandLeaseTemporarilyHostsHiddenPortalWebViewOffscreen, and testAutomationCommandLeaseRestoresDiscardedHiddenWebViewBeforeHosting verifying lease rehosting, restoration, and discard blocking. Test class moved from BrowserPanelTests.swift to separate file; project wiring updated.

Sequence Diagram

sequenceDiagram
  participant TerminalController
  participant BrowserPanel
  participant BrowserHiddenWebViewDiscardManager
  participant OffscreenRenderHostLease
  participant ScreenshotSnapshotter
  TerminalController->>BrowserPanel: beginAutomationCommandLease("browser.socketCommand")
  BrowserPanel->>BrowserPanel: increment activeVisualAutomationCaptureCount
  BrowserPanel->>BrowserPanel: cancel hiddenWebViewDiscard
  BrowserPanel->>OffscreenRenderHostLease: create lease (if offscreen enabled)
  BrowserPanel-->>TerminalController: return lease
  TerminalController->>ScreenshotSnapshotter: capture with offscreen render
  ScreenshotSnapshotter->>OffscreenRenderHostLease: use lease for hosting
  ScreenshotSnapshotter-->>TerminalController: return screenshot
  TerminalController->>BrowserPanel: endAutomationCommandLease(lease, "browser.socketCommand")
  BrowserPanel->>OffscreenRenderHostLease: end() - restore WebView
  BrowserPanel->>BrowserPanel: decrement activeVisualAutomationCaptureCount
  BrowserPanel->>BrowserPanel: update webViewLastAutomationActivityAt
  BrowserPanel->>BrowserHiddenWebViewDiscardManager: scheduleIfNeeded (if count == 0)
  BrowserHiddenWebViewDiscardManager->>BrowserPanel: read hiddenWebViewDiscardLastAutomationActivityAt
  BrowserHiddenWebViewDiscardManager->>BrowserHiddenWebViewDiscardManager: compute effectiveHiddenAt (max of hidden, wake, automation activity)
  BrowserHiddenWebViewDiscardManager-->>BrowserPanel: schedule discard if needed
Loading

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • manaflow-ai/cmux#5315: Both PRs modify BrowserHiddenWebViewDiscardManager.scheduleIfNeeded's effective hidden-at countdown logic—main PR extends it with hiddenWebViewDiscardLastAutomationActivityAt alongside lastSystemWakeAt, while the retrieved PR introduces the sleep/wake-aware lastSystemWakeAt behavior.
  • manaflow-ai/cmux#5424: Both PRs refactor the visual-automation/offscreen render capture flow by introducing/using an automation "lease" in BrowserPanel/TerminalController and tying it to hidden-webview discard behavior in BrowserHiddenWebViewDiscardManager.
  • manaflow-ai/cmux#4244: Both PRs modify the hidden-webview discard/lifecycle machinery in BrowserPanel/BrowserHiddenWebViewDiscardManager—retrieved PR adds the core discard scheduling + restore behavior, while main PR extends that scheduling with an automation-activity timestamp and automation offscreen leases that block/restore discards.

Poem

🐰 A lease is born to host and hold,
While WebViews swap through panels bold,
Discard waits patiently, held at bay,
Until the automation dance is done for the day! ✨

🚥 Pre-merge checks | ✅ 20 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (20 passed)
Check name Status Explanation
Title check ✅ Passed The title concisely captures the main objective: enabling socket JS commands to work on background/hidden WebViews by implementing automation leases.
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 Production code additions maintain explicit MainActor isolation: BrowserPanel, delegate protocol, and snapshotter enum/class are @MainActor. V2BrowserPanelContext struct passes MainActor references...
Cmux Swift Blocking Runtime ✅ Passed PR introduces no new blocking/timing synchronization: beginAutomationCommandLease/endAutomationCommandLease use only timestamp updates and counters; OffscreenRenderHostLease manages WebView view hi...
Cmux Expensive Synchronous Load ✅ Passed No expensive synchronous loaders added to main actor or socket paths. New automation lease methods perform only property updates, timer cancellation, and view hierarchy manipulation without calling...
Cmux Cache Substitution Correctness ✅ Passed The PR adds webViewLastAutomationActivityAt, a transient, non-persisted runtime timestamp for discard scheduling, not a cache substitution in a persistence/history/undo/snapshot path. No authorit...
Cmux No Hacky Sleeps ✅ Passed Check applies only to non-Swift code (TypeScript, JavaScript, shell, build/runtime scripts), but this PR contains only Swift file modifications. Swift timing is covered by a separate rule.
Cmux Algorithmic Complexity ✅ Passed No algorithmic complexity violations found. Socket path uses O(1) operations; OffscreenRenderHostLease scans only subviews (fixed-size ~1-2 items); discard scheduling operates on 3-element arrays....
Cmux Swift Concurrency ✅ Passed PR introduces modern async patterns: synchronous lease APIs, RAII-style resource lifecycle (OffscreenRenderHostLease), and defer-based cleanup. No fire-and-forget Tasks, new Combine, background que...
Cmux Swift @Concurrent ✅ Passed PR adds synchronous @MainActor methods and OffscreenRenderHostLease (also @MainActor with proper deinit), properly bridges nonisolated to MainActor via v2MainSync, and adds protocol property. No vi...
Cmux Swift File And Package Boundaries ✅ Passed PR adds only 34 lines to BrowserPanel (13,640 total) and 7 to TerminalController (14,629 total), both well under the 250-line threshold for files >800 lines. OffscreenRenderHostLease refactors dupl...
Cmux Swift Logging ✅ Passed No logging rule violations detected. PR adds no new print/NSLog in production code; debug-only NSLog guarded by #if DEBUG and existing log parameter removal are both allowed per rules.
Cmux User-Facing Error Privacy ✅ Passed No user-facing errors, alerts, or sensitive information were added or modified. Changes are internal implementation (BrowserPanel lease API, OffscreenRenderHostLease class) and tests only; error me...
Cmux Full Internationalization ✅ Passed PR adds only internal APIs and developer-facing reason parameters (e.g., "browser.socketCommand", window identifiers); no user-facing Swift text, localization catalog entries, or web message change...
Cmux Swiftui State Layout ✅ Passed No SwiftUI state violations found. New properties in BrowserPanel are private (not @Published/@observable), and all changes are in AppKit/WebKit infrastructure code without SwiftUI rendering patterns.
Cmux Architecture Rethink ✅ Passed PR maintains single ownership (BrowserPanel), names invariants (discard-block counter with deinit guarantee), has one entrypoint path through v2BrowserWithPanelContext, and introduces no timing/blo...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed The PR adds BrowserScreenshotOffscreenRenderPanel (NSPanel) with identifier "cmux.browserVisualAutomationRender", which is properly documented in IGNORED_IDENTIFIERS as an internal, hidden WebKit h...
Cmux Source Artifacts ✅ Passed All changed files are legitimate source code (.swift, .pbxproj), test files, hand-written documentation, and intentional configuration files. No hidden scratch directories, local tool output, gener...
Description check ✅ Passed The PR description includes a concise summary explaining what changed and why, along with comprehensive testing details (unit tests, script validation, cloud reload, and manual preflight). However, it lacks sections for a demo video, an explicit checklist, and bot review trigger instructions as specified in the template.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 task-browser-background-resource-sync

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 and usage tips.

@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: 1

🤖 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/BrowserScreenshotSnapshotter.swift`:
- Around line 63-184: Move the OffscreenRenderHostLease class (and the
BrowserScreenshotOffscreenRenderPanel type if it’s declared in the same block)
out of BrowserScreenshotSnapshotter.swift into a new file under Sources/Panels
(e.g., OffscreenRenderHostLease.swift); keep the `@MainActor` attribute, any
imports, and the same access level, and ensure the new file declares the same
types (OffscreenRenderHostLease and BrowserScreenshotOffscreenRenderPanel) so
existing callers (e.g., BrowserScreenshotWebViewSnapshotter.restoreWebView and
any usages in BrowserScreenshotSnapshotter) still compile; after moving, remove
the nested/inline definition from BrowserScreenshotSnapshotter.swift and run a
build to fix any missing import/access issues or to adjust visibility if
necessary.
🪄 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: 1efa4673-b211-4e5d-9981-2bd079346d37

📥 Commits

Reviewing files that changed from the base of the PR and between af32009 and b960b34.

📒 Files selected for processing (4)
  • Sources/Panels/BrowserPanel.swift
  • Sources/Panels/BrowserScreenshotSnapshotter.swift
  • Sources/TerminalController.swift
  • cmuxTests/BrowserPanelTests.swift
👮 Files not reviewed due to content moderation or server errors (1)
  • Sources/TerminalController.swift

Comment on lines +63 to +184
@MainActor
final class OffscreenRenderHostLease {
private weak var webView: WKWebView?
private let previousSuperview: NSView?
private let previousFrame: NSRect
private let previousBounds: NSRect
private let previousAutoresizingMask: NSView.AutoresizingMask
private let previousTranslatesAutoresizingMaskIntoConstraints: Bool
private let restoreAnchor: NSView?
private let restorePosition: NSWindow.OrderingMode
private let window: BrowserScreenshotOffscreenRenderPanel
private var isActive = true

init(webView: WKWebView, viewportSize: NSSize) {
self.webView = webView
previousSuperview = webView.superview
let previousSubviews = previousSuperview?.subviews ?? []
let previousIndex = previousSubviews.firstIndex(of: webView)
previousFrame = webView.frame
previousBounds = webView.bounds
previousAutoresizingMask = webView.autoresizingMask
previousTranslatesAutoresizingMaskIntoConstraints = webView.translatesAutoresizingMaskIntoConstraints

if let previousIndex, previousIndex > 0 {
restoreAnchor = previousSubviews[previousIndex - 1]
restorePosition = .above
} else if let previousIndex, previousIndex == 0, previousSubviews.count > 1 {
restoreAnchor = previousSubviews[1]
restorePosition = .below
} else {
restoreAnchor = nil
restorePosition = .above
}

let normalizedSize = Self.normalizedViewportSize(viewportSize)
let frame = NSRect(
x: -100_000 - normalizedSize.width,
y: -100_000 - normalizedSize.height,
width: normalizedSize.width,
height: normalizedSize.height
)
let window = BrowserScreenshotOffscreenRenderPanel(
contentRect: frame,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.browserVisualAutomationRender")
window.hasShadow = false
window.isOpaque = false
window.backgroundColor = .clear
window.alphaValue = 0.01
window.ignoresMouseEvents = true
window.hidesOnDeactivate = false
window.collectionBehavior = [.transient, .ignoresCycle, .stationary, .canJoinAllSpaces]
window.isExcludedFromWindowsMenu = true
self.window = window

let contentView = NSView(frame: NSRect(origin: .zero, size: normalizedSize))
contentView.wantsLayer = true
webView.removeFromSuperview()
webView.frame = contentView.bounds
webView.autoresizingMask = [.width, .height]
contentView.addSubview(webView)
window.contentView = contentView
window.orderFrontRegardless()
}

func end() {
guard isActive else { return }
isActive = false
if let webView {
Self.restoreWebView(
webView,
to: previousSuperview,
frame: previousFrame,
bounds: previousBounds,
autoresizingMask: previousAutoresizingMask,
translatesAutoresizingMaskIntoConstraints: previousTranslatesAutoresizingMaskIntoConstraints,
anchor: restoreAnchor,
position: restorePosition
)
}
window.orderOut(nil)
window.contentView = nil
window.close()
}

deinit {
guard isActive else { return }
MainActor.assumeIsolated {
end()
}
}

private static func normalizedViewportSize(_ viewportSize: NSSize) -> NSSize {
BrowserScreenshotWebViewSnapshotter.normalizedViewportSize(viewportSize)
}

private static func restoreWebView(
_ webView: WKWebView,
to superview: NSView?,
frame: NSRect,
bounds: NSRect,
autoresizingMask: NSView.AutoresizingMask,
translatesAutoresizingMaskIntoConstraints: Bool,
anchor: NSView?,
position: NSWindow.OrderingMode
) {
BrowserScreenshotWebViewSnapshotter.restoreWebView(
webView,
to: superview,
frame: frame,
bounds: bounds,
autoresizingMask: autoresizingMask,
translatesAutoresizingMaskIntoConstraints: translatesAutoresizingMaskIntoConstraints,
anchor: anchor,
position: position
)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Extract the lease/panel helpers to a dedicated file to stay within the Swift file-size budget.

This new lease abstraction is coherent, but adding it here pushes Sources/Panels/BrowserScreenshotSnapshotter.swift past the 800-line budget. Please move OffscreenRenderHostLease (and, if appropriate, BrowserScreenshotOffscreenRenderPanel) into a focused companion file under Sources/Panels/.

As per coding guidelines, {Sources,CLI,Packages,cmuxTests,cmuxUITests}/**/*.swift should be flagged when a production Swift file exceeds 800 lines, even with coherent responsibility.

🤖 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/BrowserScreenshotSnapshotter.swift` around lines 63 - 184,
Move the OffscreenRenderHostLease class (and the
BrowserScreenshotOffscreenRenderPanel type if it’s declared in the same block)
out of BrowserScreenshotSnapshotter.swift into a new file under Sources/Panels
(e.g., OffscreenRenderHostLease.swift); keep the `@MainActor` attribute, any
imports, and the same access level, and ensure the new file declares the same
types (OffscreenRenderHostLease and BrowserScreenshotOffscreenRenderPanel) so
existing callers (e.g., BrowserScreenshotWebViewSnapshotter.restoreWebView and
any usages in BrowserScreenshotSnapshotter) still compile; after moving, remove
the nested/inline definition from BrowserScreenshotSnapshotter.swift and run a
build to fix any missing import/access issues or to adjust visibility if
necessary.

Source: Coding guidelines

@greptile-apps

greptile-apps Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes background and portal-hidden browser WebViews automatable by wrapping every socket browser command in a BrowserPanel automation lease. The lease restores discarded WebViews, blocks hidden-WebView discard for the command duration, optionally hosts the WKWebView in a new OffscreenRenderHostLease window (refactored from the duplicate withOffscreenRenderHost setup code), and—on completion—restarts the hidden-discard countdown from the automation end time rather than the original hidden time.

  • OffscreenRenderHostLease centralises the repeated offscreen-window attach/detach logic previously duplicated across both withOffscreenRenderHost overloads; BrowserScreenshotWebViewSnapshotter and the new automation lease both delegate to it.
  • beginAutomationCommandLease/endAutomationCommandLease in BrowserPanel increment/decrement activeVisualAutomationCaptureCount and record webViewLastAutomationActivityAt, so effectiveHiddenAt in the discard manager now takes automation recency into account alongside hidden time and last wake.
  • TerminalController.v2BrowserWithPanelContext acquires the lease synchronously on the main actor before yielding to the socket command body, and releases it unconditionally in a defer block.

Confidence Score: 4/5

The core accounting logic (counter increment/decrement, discard cancellation/rescheduling, timestamp restart) is correct and well-tested; the main risks—noted in prior review threads—centre on OffscreenRenderHostLease teardown and concurrent lease creation on the same panel.

The new OffscreenRenderHostLease class and the beginAutomationCommandLease/endAutomationCommandLease pair handle the sequential case cleanly: the counter is always balanced, effectiveHiddenAt correctly takes lastAutomationActivityAt as an additional lower bound, and tests cover the portal-hosted, discarded, and offscreen-only paths. The concerns already raised about deinit/assumeIsolated and concurrent same-panel leases remain in the code and represent real edge-case risks for WebKit process stability.

Sources/Panels/BrowserScreenshotSnapshotter.swift (OffscreenRenderHostLease deinit path) and Sources/TerminalController.swift (concurrent v2BrowserWithPanelContext calls for the same hidden panel)

Important Files Changed

Filename Overview
Sources/Panels/BrowserScreenshotSnapshotter.swift Introduces OffscreenRenderHostLease, centralizing the repeated attach/detach logic from both withOffscreenRenderHost overloads. The deinit safety net still uses MainActor.assumeIsolated (raised in a prior thread).
Sources/Panels/BrowserPanel.swift Adds beginAutomationCommandLease/endAutomationCommandLease pair, webViewLastAutomationActivityAt timestamp, and protocol conformance. Lease correctly increments/decrements activeVisualAutomationCaptureCount and restarts discard scheduling on end.
Sources/TerminalController.swift Wraps all socket browser commands in an automation lease via beginAutomationCommandLease/endAutomationCommandLease inside the existing v2MainSync + defer pattern. Concurrent-lease concerns for simultaneous commands were raised in a prior thread.
Sources/Panels/BrowserHiddenWebViewDiscardManager.swift Adds lastAutomationActivityAt to effectiveHiddenAt calculation using a clean compactMap/max chain; correctly restarts the discard countdown after automation ends.
cmuxTests/BrowserPanelVisualAutomationRestoreHostTests.swift New test file covering portal-hosted, discarded, and offscreen-hosted hidden WebView lease behaviour; uses RunLoop and XCTWaiter only for test-scaffolding settlement of UI state.

Sequence Diagram

sequenceDiagram
    participant W as Worker Thread
    participant M as Main Actor
    participant BP as BrowserPanel
    participant L as OffscreenRenderHostLease
    participant DM as HiddenWebViewDiscardManager

    W->>M: v2MainSync beginAutomationCommandLease
    activate M
    M->>BP: "webViewLastAutomationActivityAt = now"
    M->>BP: "activeVisualAutomationCaptureCount += 1"
    M->>DM: cancelHiddenWebViewDiscard
    M->>BP: restoreDiscardedWebViewIfNeeded
    alt shouldUseOffscreenRenderHost
        M->>L: new OffscreenRenderHostLease
        activate L
        L->>L: move webView to OffscreenWindow
    end
    M-->>W: automationLease or nil
    deactivate M
    W->>W: body runs JS eval or snapshot
    W->>M: defer v2MainSync endAutomationCommandLease
    activate M
    alt lease not nil
        M->>L: end
        L->>L: restore webView to previousSuperview
        deactivate L
    end
    M->>BP: "webViewLastAutomationActivityAt = now"
    M->>BP: "activeVisualAutomationCaptureCount -= 1"
    M->>BP: refreshWebViewLifecycleState
    alt count 0 and still hidden
        M->>DM: scheduleHiddenWebViewDiscardIfNeeded
    end
    deactivate M
Loading

Reviews (3): Last reviewed commit: "Fix browser automation test budget" | Re-trigger Greptile

Comment on lines +152 to +157
deinit {
guard isActive else { return }
MainActor.assumeIsolated {
end()
}
}

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 assumeIsolated in deinit is an unchecked crash

@MainActor final class does not guarantee that deinit runs on the main actor in current Swift (SE-0371 "Isolated synchronous deinit" was returned for revision and is not in the language). If the last strong reference to an OffscreenRenderHostLease is released off the main thread when isActive is still true, MainActor.assumeIsolated will preconditionFailure-crash the app.

In the current flow the guard short-circuits in every reachable path (endAutomationCommandLease sets isActive = false before dropping the lease), but the safety net is meant to fire for unexpected drops — precisely the case where thread provenance is unknown. The correct defensive pattern is DispatchQueue.main.async { self.end() } (accepting that cleanup is deferred by one run-loop turn) rather than an unchecked assertion about the current thread.

Comment on lines 5304 to 5343
@@ -5325,6 +5326,7 @@ class TerminalController {
failure = .err(code: "invalid_params", message: "Surface is not a browser", data: ["surface_id": surfaceId.uuidString])
return
}
automationLease = browserPanel.beginAutomationCommandLease(reason: "browser.socketCommand")
resolved = V2BrowserPanelContext(
workspaceId: ws.id,
surfaceId: surfaceId,
@@ -5333,6 +5335,11 @@ class TerminalController {
)
}
guard let resolved else { return failure }
defer {
v2MainSync {
resolved.browserPanel.endAutomationCommandLease(automationLease, reason: "browser.socketCommand")
}
}
return body(resolved)

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 Concurrent leases on the same hidden panel can corrupt the webview view hierarchy

v2BrowserWithPanelContext is nonisolated and its body runs on a worker thread. When two socket commands arrive simultaneously for the same hidden panel, both enter v2MainSync and create OffscreenRenderHostLeases sequentially on the main thread — lease A moves the webview to window_A, then lease B moves it to window_B (capturing window_A.contentView as previousSuperview). The two defers then race to post back to the main queue via DispatchQueue.main.sync.

If lease A's endAutomationCommandLease is processed first (FIFO, wrong order): restoreWebView removes the webview from window_B and adds it to originalSuperview, then window_A.contentView = nil is called — making lease B's strong previousSuperview reference point to a detached view. Lease B then removes the webview from originalSuperview and adds it to that detached view. The webview ends up parented to a view that belongs to no window, and scheduleHiddenWebViewDiscardIfNeeded is called with the panel in an inconsistent state.

The fix is to record which lease is outermost and only allow restoration when the counter returns to its pre-lease value, or to adopt a stack/push-pop model where each lease validates that it still owns the current position before restoring.

Comment thread Sources/Panels/BrowserPanel.swift

@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 (1)
Sources/Panels/BrowserPanel.swift (1)

3784-3794: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear automation-activity timestamp on every lifecycle reset

Line 3792 only clears webViewLastAutomationActivityAt when resetVisibility is true. Paths that call resetWebViewLifecycleMetadata(resetVisibility: false) after replacing/swapping the webview can carry stale automation activity into discard decisions for a different webview identity. Clear this timestamp unconditionally in resetWebViewLifecycleMetadata(...).

Suggested fix
     private func resetWebViewLifecycleMetadata(resetVisibility: Bool = true) {
         cancelHiddenWebViewDiscard()
         webViewLifecycleState = .newTab
+        webViewLastAutomationActivityAt = nil
         if resetVisibility {
             webViewLastVisibleAt = nil
             webViewLastHiddenAt = nil
             webViewLastVisibilityChangeAt = nil
             webViewLastVisibilityChangeReason = nil
-            webViewLastAutomationActivityAt = nil
             isWebViewVisibleInUI = false
         }
         hiddenWebViewDiscardManager.resetMetadata()
         isClosingWebViewLifecycle = false
     }

As per coding guidelines, correctness-sensitive discard/automation paths must not decide using stale cached identity state.

🤖 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/BrowserPanel.swift` around lines 3784 - 3794, The
webViewLastAutomationActivityAt timestamp is only cleared when resetVisibility
is true in the resetWebViewLifecycleMetadata function, but it should be cleared
unconditionally to prevent stale automation activity timestamps from a previous
webview identity from affecting discard decisions when the function is called
with resetVisibility: false. Move the line that sets
webViewLastAutomationActivityAt to nil outside of the if resetVisibility block
so it executes on every call to resetWebViewLifecycleMetadata.

Source: Coding guidelines

🤖 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/BrowserPanel.swift`:
- Around line 3784-3794: The webViewLastAutomationActivityAt timestamp is only
cleared when resetVisibility is true in the resetWebViewLifecycleMetadata
function, but it should be cleared unconditionally to prevent stale automation
activity timestamps from a previous webview identity from affecting discard
decisions when the function is called with resetVisibility: false. Move the line
that sets webViewLastAutomationActivityAt to nil outside of the if
resetVisibility block so it executes on every call to
resetWebViewLifecycleMetadata.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c12623d6-b489-4cfe-890d-20cd18cfea36

📥 Commits

Reviewing files that changed from the base of the PR and between b960b34 and 1fff7d9.

📒 Files selected for processing (3)
  • Sources/Panels/BrowserHiddenWebViewDiscardManager.swift
  • Sources/Panels/BrowserPanel.swift
  • cmuxTests/BrowserPanelTests.swift

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e3fd55b. Configure here.

if activeVisualAutomationCaptureCount == 0, !isWebViewVisibleInUI {
scheduleHiddenWebViewDiscardIfNeeded(reason: "\(reason).finished")
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Lease ends during active capture

Medium Severity

endAutomationCommandLease always tears down the automation offscreen host and restores the WKWebView when a socket command finishes, even if activeVisualAutomationCaptureCount is still above zero after decrement. Overlapping browser.screenshot (main actor) with a socket-worker command that held the lease can move the webview back to a hidden portal while PNG capture is still in progress, causing failed or incorrect screenshots.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e3fd55b. Configure here.

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