Skip to content

Keep background Browser WebViews automatable#6027

Open
lawrencecchen wants to merge 1 commit into
mainfrom
task-browser-background-resource-sync
Open

Keep background Browser WebViews automatable#6027
lawrencecchen wants to merge 1 commit 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 WKWebView superview/window lifecycle and memory-discard gating during automation; mistakes could cause UI glitches or premature discard, but scope is isolated to hidden/background browser paths and reuses existing screenshot hosting.

Overview
Browser socket commands (eval, snapshot, etc.) now acquire an automation command lease for the whole RPC so hidden or background surfaces stay usable without the WKWebView being discarded mid-command.

BrowserPanel gains beginAutomationCommandLease / endAutomationCommandLease, which bump the existing visual-automation capture counter, cancel pending hidden-webview discard, restore memory-discarded webviews when needed, and—when the view isn’t visibly on-screen—attach it via a reusable OffscreenRenderHostLease (same offscreen panel path as screenshots). When the lease ends and the surface is still hidden, discard scheduling resumes.

Offscreen hosting is refactored into OffscreenRenderHostLease so screenshot capture and panel automation share one implementation; v2BrowserWithPanelContext takes the lease on the main actor before running the worker-thread body and releases it in a defer.

Tests cover portal-hidden hosting, discard blocking during automation, and restore-before-host for discarded webviews.

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


Summary by cubic

Background browser surfaces remain automatable by temporarily hosting hidden or discarded WebViews in an offscreen render window during socket commands, then restoring normal lifecycle after the command. This blocks memory discard while automation runs and restores discarded WebViews before capture.

  • New Features

    • Added an automation command lease in BrowserPanel, used by TerminalController for all socket JS commands.
    • Lease restores discarded WebViews before capture and pauses hidden-WebView discard while active; discard scheduling resumes after.
  • Refactors

    • Introduced 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 b960b34. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added automation command leases to enhance browser automation with improved offscreen rendering and automatic webview restoration upon completion.
  • Improvements

    • Refactored offscreen rendering infrastructure to consolidate webview lifecycle management and eliminate duplicated restoration logic across rendering modes.
  • Tests

    • Added tests for automation lease behavior, webview restoration timing, and discard scenarios during offscreen operations.

@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 13, 2026 1:40am
cmux-staging Building Building Preview, Comment Jun 13, 2026 1:40am

@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 refactored to use the lease. BrowserPanel gains beginAutomationCommandLease() and endAutomationCommandLease() methods to manage lease lifecycle and control hidden-webview discard. TerminalController brackets automation execution with lease acquisition and cleanup. Tests verify lease behavior and discard suppression.

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 command lease API
Sources/Panels/BrowserPanel.swift
beginAutomationCommandLease(reason:) increments capture count, cancels discard, restores discarded webview, refreshes lifecycle state, returns optional lease. endAutomationCommandLease(_:reason:) ends lease, decrements count (clamped non-negative), refreshes state, schedules discard when count reaches zero and webview not visible in UI.
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
Updated testRestoredDiscardedHiddenWebViewGetsRestoreHostBeforeOffscreenCapture with data URL payload and expanded wait conditions. Added realizeWindowLayout(_:) helper. New testAutomationCommandLeaseTemporarilyHostsHiddenPortalWebViewOffscreen verifies lease temporarily rehosts hidden webview offscreen and blocks discard until lease ends. New testAutomationCommandLeaseRestoresDiscardedHiddenWebViewBeforeHosting verifies lease restores discarded webview to .liveHidden before hosting and blocks discard during lease.

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • manaflow-ai/cmux#4245: Added hidden-webview discard policy, timers, and lifecycle state tracking that the automation lease methods now explicitly suppress/restore.
  • manaflow-ai/cmux#5315: Modified BrowserHiddenWebViewDiscardManager discard scheduling and blocker logic that the automation lease cancels/re-schedules via BrowserPanel.
  • manaflow-ai/cmux#4243: Introduced WebView lifecycle tracking and refresh logic in BrowserPanel that the automation lease explicitly invokes on begin/end.

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! ✨


Important

Pre-merge checks failed

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

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error endAutomationCommandLease schedules hidden-WebView discard via DispatchSourceTimer(timer.schedule(deadline: .now()+remaining)); TerminalController ends lease via defer->v2MainSync using DispatchQue... Refactor to avoid delayed dispatch/timer countdowns and main-queue sync on lease end; coordinate discard/restore via actor state + explicit async handoff/signals instead of DispatchSourceTimer/DispatchQueue.main.sync.
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (19 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Keep background Browser WebViews automatable' directly and specifically summarizes the main change: enabling background browser WebViews to remain automatable during socket commands.
Description check ✅ Passed The PR description includes Summary and Testing sections as required by the template, covering the key changes (automation lease implementation) and testing approach (unit tests, lint checks, manual preflight verification).
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 BrowserPanel is @MainActor; OffscreenRenderHostLease/init/end are @MainActor and use MainActor.assumeIsolated in deinit; socket path brackets begin/end inside v2MainSync (@MainActor closure).
Cmux Expensive Synchronous Load ✅ Passed In PR-touched Swift files (BrowserPanel.swift, BrowserScreenshotSnapshotter.swift, TerminalController.swift), there is no RestorableAgentSessionIndex.load or SharedLiveAgentIndex.shared usage, so n...
Cmux Cache Substitution Correctness ✅ Passed Lease captures webView superview/frame/bounds at acquire and restores in end(); withOffscreenRenderHost calls lease.end via defer/finish; no persistence/history/undo cached reads swapped.
Cmux No Hacky Sleeps ✅ Passed No non-Swift (TS/JS/shell/build-runtime) changes detected (git diff empty) and PR summary indicates only Swift/tests; runtime-no-hacky-sleeps not triggered.
Cmux Algorithmic Complexity ✅ Passed Added lease logic doesn’t introduce banned complexity patterns: OffscreenRenderHostLease does one subviews.firstIndex scan and BrowserPanel viewport size loops over 3 fixed candidates; no repeated...
Cmux Swift Concurrency ✅ Passed Inspected new/edited lease/automation code: begin/endAutomationCommandLease and OffscreenRenderHostLease use synchronous/defer/Timer finish; no new DispatchQueue.global async, Combine app state, or...
Cmux Swift @Concurrent ✅ Passed Diff adds sync automation/lease helpers and moves async capture into @MainActor code; no @concurrent or nonisolated async is introduced/expanded in changed Swift.
Cmux Swift File And Package Boundaries ✅ Passed Rules only fail on new >800-line Swift files or >250 insertions into an existing >800-line file. PR changes 4 existing files; BrowserScreenshotSnapshotter.swift has 127 insertions (already 863 line...
Cmux Swift Logging ✅ Passed Diff (origin/main..HEAD) for the 4 changed files adds no print/debugPrint/dump/NSLog and no file-scoped Logger constants, per swift-logging rules.
Cmux User-Facing Error Privacy ✅ Passed PR diff adds offscreen/automation lease logic; no forbidden user-facing error text (vendor/provider/upstream/credentials/tokens/headers) found. Only generic API errors like “Surface is not a browse...
Cmux Full Internationalization ✅ Passed Touched files only add internal automation “reason” strings/identifiers and test fixtures; no new user-facing Swift UI/web text or i18n/catalog entries were changed in this diff.
Cmux Swiftui State Layout ✅ Passed Reviewed rule file and the PR-marked Swift files; none import SwiftUI, and the added automation/lease helpers are AppKit-only (no new @Published/@State/GeometryReader render-time SwiftUI patterns).
Cmux Architecture Rethink ✅ Passed Diff introduces automation/begin-end offscreen render host leasing, with defer-bracketed lifecycle; searched changed Swift files—no new sleeps/delayed dispatch/polling/locks/observers/duplicate wir...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed Offscreen automation uses NSPanel BrowserScreenshotOffscreenRenderPanel with identifier cmux.browserVisualAutomationRender; lint ignore list includes it (not in cmuxAuxiliaryWindowIdentifiers), so...
Cmux Source Artifacts ✅ Passed PR #6027 changes only 4 files, all Swift source/test (.swift filter), with no added generated logs/screenshots/DerivedData/temp artifacts in the diff.
✨ 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

Routes all socket JS commands through a new beginAutomationCommandLease/endAutomationCommandLease pair on BrowserPanel, and refactors the screenshot offscreen-window setup into a reusable OffscreenRenderHostLease class so hidden/discarded WebViews can be temporarily hosted off-screen during automation without being memory-discarded mid-command.

  • BrowserScreenshotSnapshotter.swift extracts the duplicated offscreen-window lifecycle into OffscreenRenderHostLease, reducing copy-paste across the async and callback snapshot paths.
  • BrowserPanel.swift adds beginAutomationCommandLease (increments the existing activeVisualAutomationCaptureCount, cancels pending discard, optionally creates a lease) and a symmetric endAutomationCommandLease that restores the counter and re-arms the discard timer when the panel returns to hidden.
  • TerminalController.v2BrowserWithPanelContext acquires the lease inside v2MainSync before the worker body runs and releases it in a defer; the deinit safety-net in OffscreenRenderHostLease uses MainActor.assumeIsolated, which crashes if triggered off the main thread.

Confidence Score: 3/5

Functional for single-concurrent-command usage, but two simultaneous socket commands on the same hidden panel can leave the webview parented to a detached view; the deinit safety net in OffscreenRenderHostLease also uses an unchecked main-thread assertion that crashes if ever triggered off-thread.

The happy path (one socket command at a time, hidden panel) is correct and well-tested. The two issues — non-LIFO lease teardown when commands overlap, and the assumeIsolated in deinit — are real defects on reachable (if uncommon) code paths. The view-hierarchy corruption from concurrent leases would silently leave the WKWebView detached and suppress future memory-discard scheduling for the panel.

Sources/Panels/BrowserScreenshotSnapshotter.swift (deinit safety net) and Sources/TerminalController.swift (concurrent lease ordering).

Important Files Changed

Filename Overview
Sources/Panels/BrowserScreenshotSnapshotter.swift Introduces OffscreenRenderHostLease refactoring the duplicated offscreen-window setup/teardown; the deinit safety net uses MainActor.assumeIsolated which is an unchecked crash if the path fires off the main thread.
Sources/Panels/BrowserPanel.swift Adds beginAutomationCommandLease/endAutomationCommandLease as a balanced begin/end pair on the existing activeVisualAutomationCaptureCount counter; logic is correct for single-caller usage.
Sources/TerminalController.swift Acquires the automation lease before the socket-worker body and releases it in a defer; the nonisolated, concurrent nature of v2BrowserWithPanelContext enables non-LIFO lease ordering for simultaneous socket commands on the same hidden panel.
cmuxTests/BrowserPanelTests.swift Adds three new tests covering portal-hidden hosting, discard blocking during automation, and restore-before-host for discarded webviews; test scaffolding (fixed-interval RunLoop drains) is appropriate for synchronous test harnesses.

Sequence Diagram

sequenceDiagram
    participant WT as Worker Thread
    participant Main as Main Actor
    participant BP as BrowserPanel
    participant Lease as OffscreenRenderHostLease

    WT->>Main: "v2MainSync { beginAutomationCommandLease }"
    Main->>BP: "activeVisualAutomationCaptureCount += 1"
    BP->>BP: cancelHiddenWebViewDiscard()
    BP->>BP: restoreDiscardedWebViewIfNeeded()
    BP->>Lease: init(webView, viewportSize)
    Lease->>Lease: capture previousSuperview
    Lease->>Lease: move webView to offscreen window
    Main-->>WT: return lease

    WT->>WT: body(ctx) — JS eval / snapshot

    WT->>Main: "defer: v2MainSync { endAutomationCommandLease(lease) }"
    Main->>Lease: end()
    Lease->>Lease: restore webView to previousSuperview
    Lease->>Lease: close offscreen window
    Main->>BP: "activeVisualAutomationCaptureCount -= 1"
    BP->>BP: scheduleHiddenWebViewDiscardIfNeeded (if still hidden)
Loading

Reviews (1): Last reviewed commit: "Keep background browser surfaces automat..." | 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.

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