Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2d86a54
test: cover browser automation watchdog recovery
austinywang Jul 14, 2026
087d82f
fix: recover unresponsive browser automation
austinywang Jul 14, 2026
f761f82
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 14, 2026
9c00737
fix: cover all browser automation callback channels
austinywang Jul 14, 2026
72c969a
refactor: isolate browser watchdog signal
austinywang Jul 14, 2026
e29cafb
fix: preserve browser automation recovery state
austinywang Jul 14, 2026
cf69bd1
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 14, 2026
c1c3fbf
test: cover shared browser recovery
austinywang Jul 14, 2026
ea1ec4b
fix: share browser automation recovery
austinywang Jul 15, 2026
961ed32
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 15, 2026
7824157
test: cover superseded browser recovery
austinywang Jul 15, 2026
7095778
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 15, 2026
f3f1f52
fix: preserve page-world automation state
austinywang Jul 15, 2026
8307d58
fix: await recovered browser document readiness
austinywang Jul 15, 2026
c41152f
test: preserve browser consent during recovery
austinywang Jul 15, 2026
6ff8adf
fix: preserve consent across browser recovery
austinywang Jul 15, 2026
9000695
fix: preserve interactive prompts during browser recovery
austinywang Jul 15, 2026
0cbd187
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 15, 2026
905ac49
test: cover browser recovery lifecycle races
austinywang Jul 15, 2026
39f7b8c
fix: guard browser automation recovery lifecycle
austinywang Jul 15, 2026
056e6cf
test: remove browser readiness registration hook
austinywang Jul 15, 2026
d0a7fdb
fix: preserve browser recovery timeout headroom
austinywang Jul 15, 2026
335c745
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 15, 2026
6f2807c
test: cover browser omnibar draft isolation
austinywang Jul 15, 2026
1f0ce4e
fix: isolate browser omnibar drafts by panel
austinywang Jul 15, 2026
05fd181
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 15, 2026
c1c839b
ci: gate browser panel identity regression
austinywang Jul 15, 2026
b39ad9d
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13997,7 +13997,12 @@ struct CMUXCLI {
let (outPathOpt, _) = parseOption(subArgs, name: "--out")
let localJSONOutput = hasFlag(subArgs, name: "--json")
let outputAsJSON = effectiveJSONOutput || localJSONOutput
var payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid])
// Leave room beyond the app's capture deadline for its liveness probe and recovery reply.
var payload = try client.sendV2(
method: "browser.screenshot",
params: ["surface_id": sid],
responseTimeout: 25
)

func fileURL(fromPath rawPath: String) -> URL {
let resolvedPath = resolvePath(rawPath)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/// Result of checking whether a browser automation callback pipeline is alive.
public enum BrowserAutomationRecoveryOutcome: Sendable, Equatable {
/// The liveness callback arrived before the deadline, so the current WebView remains authoritative.
case responsive

/// The callback missed its deadline and the owning browser surface replaced the unresponsive WebView.
case recovered

/// The callback missed its deadline, but another lifecycle path had already replaced the observed WebView.
case superseded

/// The check was cancelled before liveness or timeout produced an outcome.
case cancelled
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import Foundation

/// Applies one bounded liveness deadline before asking a browser surface to replace an unresponsive WebView.
///
/// The watchdog owns no WebKit state. Its caller supplies the callback-based liveness probe and the
/// synchronous recovery mutation, keeping the package testable without launching AppKit or WebKit.
/// A completed probe always wins over recovery, while a missing callback reaches the injected deadline.
@MainActor
public final class BrowserAutomationWatchdog {
/// Starts a liveness probe and invokes its completion when the browser automation pipeline responds.
public typealias Probe = @MainActor (
_ completion: @escaping @MainActor @Sendable () -> Void
) -> Void

/// Replaces the observed WebView, returning `false` when another lifecycle path already superseded it.
public typealias Recovery = @MainActor () -> Bool

/// Cancellable timing source used for the liveness deadline.
public typealias Sleep = @Sendable (_ duration: Duration) async throws -> Void

private enum ProbeSignal: Sendable {
case responsive
case timedOut
case cancelled
}

private let probeTimeout: Duration
private let sleep: Sleep

/// Creates a browser automation watchdog.
/// - Parameters:
/// - probeTimeout: Maximum time to wait for a liveness callback before recovery. Defaults to one second.
/// - sleep: Cancellable timing source. Tests can inject an immediate or controlled deadline.
public init(
probeTimeout: Duration = .seconds(1),
sleep: @escaping Sleep = { try await ContinuousClock().sleep(for: $0) }
) {
self.probeTimeout = probeTimeout
self.sleep = sleep
}

/// Probes the browser automation pipeline once and recovers only when that callback misses its deadline.
/// - Parameters:
/// - probe: Starts a cheap, side-effect-free liveness operation.
/// - recover: Replaces the WebView if it is still the instance observed by the failed operation.
/// - Returns: The liveness or recovery outcome.
public func recoverIfUnresponsive(
probe: Probe,
recover: Recovery
) async -> BrowserAutomationRecoveryOutcome {
guard !Task.isCancelled else { return .cancelled }

let (signals, continuation) = AsyncStream.makeStream(
of: ProbeSignal.self,
bufferingPolicy: .bufferingOldest(1)
)
probe {
continuation.yield(.responsive)
continuation.finish()
}

let signal = await withTaskGroup(of: ProbeSignal.self, returning: ProbeSignal.self) { group in
group.addTask {
var iterator = signals.makeAsyncIterator()
return await iterator.next() ?? .cancelled
}
group.addTask { [probeTimeout, sleep] in
do {
try await sleep(probeTimeout)
} catch {
return .cancelled
}
return Task.isCancelled ? .cancelled : .timedOut
}

let first = await group.next() ?? .cancelled
group.cancelAll()
continuation.finish()
return first
}

guard !Task.isCancelled else { return .cancelled }
switch signal {
case .responsive:
return .responsive
case .timedOut:
return recover() ? .recovered : .superseded
case .cancelled:
return .cancelled
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Testing

@testable import CmuxBrowser

@MainActor
@Suite("Browser automation watchdog")
struct BrowserAutomationWatchdogTests {
@Test("A completed liveness probe preserves the current browser process")
func responsiveProbeDoesNotRecover() async {
var recoveryCount = 0
let watchdog = BrowserAutomationWatchdog(
sleep: { duration in
try await ContinuousClock().sleep(for: duration)
}
)

let outcome = await watchdog.recoverIfUnresponsive(
probe: { finish in finish() },
recover: {
recoveryCount += 1
return true
}
)

#expect(outcome == .responsive)
#expect(recoveryCount == 0)
}

@Test("A missing liveness callback replaces the unresponsive browser process")
func timedOutProbeRecovers() async {
var recoveryCount = 0
let watchdog = BrowserAutomationWatchdog(sleep: { _ in })

let outcome = await watchdog.recoverIfUnresponsive(
probe: { _ in },
recover: {
recoveryCount += 1
return true
}
)

#expect(outcome == .recovered)
#expect(recoveryCount == 1)
}

@Test("A WebView replaced during the probe is not replaced a second time")
func supersededProbeDoesNotRecoverAgain() async {
var recoveryCount = 0
let watchdog = BrowserAutomationWatchdog(sleep: { _ in })

let outcome = await watchdog.recoverIfUnresponsive(
probe: { _ in },
recover: {
recoveryCount += 1
return false
}
)

#expect(outcome == .superseded)
#expect(recoveryCount == 1)
}
}
Loading