Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,33 @@ final class AgentPaneBridge: NSObject, WKScriptMessageHandlerWithReply {
}

func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) async -> (Any?, String?) {
guard let view, message.webView === view.webView, message.frameInfo.isMainFrame,
view.source.isTrusted(message.frameInfo.request.url)
else {
guard isTrusted(message) else {
return (AgentPaneReply.failure(code: "untrusted_frame", message: "Untrusted frame"), nil)
}
let request = AgentPaneRequest(body: message.body)
return (await reply(to: AgentPaneRequest(body: message.body)), nil)
}

/// The reply for a request from the pane's trusted page.
func reply(to request: AgentPaneRequest) async -> [String: Any] {
guard let model = prepare(for: request) else { return AgentPaneReply.failure(code: "closed", message: "Closed") }
// The handshake can wait up to 20 seconds for acpmux to start. Only
// the model is held across it, so closing the tab frees the view and
// its web view right away.
return await model.respond(to: request)
}

private func isTrusted(_ message: WKScriptMessage) -> Bool {
guard let view else { return false }
return message.webView === view.webView && message.frameInfo.isMainFrame
&& view.source.isTrusted(message.frameInfo.request.url)
}

private func prepare(for request: AgentPaneRequest) -> AgentPaneModel? {
guard let view else { return nil }
// The page installs its bridge and registry before asking for the
// handshake, which can be after didFinish; replay the customization
// so registry.js finds them.
if request == .ready { view.replayCustomization() }
let reply = await view.model.respond(to: request)
return (reply, nil)
return view.model
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Foundation

/// Decides whether a pane reloads its page after the web content process
/// crashes. A page that crashes as it loads would otherwise reload forever.
nonisolated struct AgentPaneCrashReloads: Equatable, Sendable {
/// Reloads allowed within ``window``. The window is long because one
/// load-to-crash cycle can take 20 seconds or more (the handshake waits
/// for acpmux), and a slow crash loop must stop too.
static let limit = 3
static let window: TimeInterval = 600

private var crashes: [Date] = []

/// Records a crash at `now`; true when the page should reload.
mutating func shouldReload(at now: Date) -> Bool {
crashes.removeAll { now.timeIntervalSince($0) >= Self.window }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This elapsed-time cap uses wall-clock Date, so system-clock corrections can suppress reloads for too long or allow them too soon. Track crashes with ContinuousClock.Instant (or uptime) instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCrashReloads.swift, line 16:

<comment>This elapsed-time cap uses wall-clock `Date`, so system-clock corrections can suppress reloads for too long or allow them too soon. Track crashes with `ContinuousClock.Instant` (or uptime) instead.</comment>

<file context>
@@ -0,0 +1,20 @@
+
+    /// Records a crash at `now`; true when the page should reload.
+    mutating func shouldReload(at now: Date) -> Bool {
+        crashes.removeAll { now.timeIntervalSince($0) >= Self.window }
+        crashes.append(now)
+        return crashes.count <= Self.limit
</file context>

crashes.append(now)
return crashes.count <= Self.limit
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ final class AgentPaneNavigation: NSObject, WKNavigationDelegate {
view?.replayCustomization()
}

/// A crashed web content process leaves a blank pane; reload the page,
/// which asks for a fresh handshake and reattaches the session.
/// A crashed web content process leaves a blank pane; the view reloads
/// the page, which asks for a fresh handshake and reattaches the session.
Comment on lines +27 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This comment still describes an unconditional reload, but webContentProcessDidTerminate() shows a notice after the crash cap instead. Document the capped behavior so this delegate does not promise a handshake when the pane is waiting for the user.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNavigation.swift, line 27:

<comment>This comment still describes an unconditional reload, but `webContentProcessDidTerminate()` shows a notice after the crash cap instead. Document the capped behavior so this delegate does not promise a handshake when the pane is waiting for the user.</comment>

<file context>
@@ -24,10 +24,10 @@ final class AgentPaneNavigation: NSObject, WKNavigationDelegate {
 
-    /// A crashed web content process leaves a blank pane; reload the page,
-    /// which asks for a fresh handshake and reattaches the session.
+    /// A crashed web content process leaves a blank pane; the view reloads
+    /// the page, which asks for a fresh handshake and reattaches the session.
     func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
</file context>
Suggested change
/// A crashed web content process leaves a blank pane; the view reloads
/// the page, which asks for a fresh handshake and reattaches the session.
/// A crashed web content process leaves a blank pane; the view reloads
/// within the cap, then shows a Reload notice instead.

func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
view?.source.load(into: webView)
view?.webContentProcessDidTerminate()
}

enum Decision: Equatable {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import Foundation

// User-facing text of the agent pane host. The page's own text is in the
Expand All @@ -10,6 +11,17 @@ extension AgentPaneModel {
}
}

extension AgentPaneView {
/// Shown when the pane's page keeps crashing and no longer reloads itself.
static var crashedMessage: String {
String(localized: "agentPane.crashed.message", defaultValue: "The agent pane crashed repeatedly.", bundle: .module)
}

static var reloadTitle: String {
String(localized: "agentPane.crashed.reload", defaultValue: "Reload", bundle: .module)
}
}

extension AgentPaneHostError {
/// What the page shows for `error`; anything but a host error reads as a
/// timeout.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public final class AgentPaneView: NSView {
}
}
private let navigation = AgentPaneNavigation()
private var crashReloads = AgentPaneCrashReloads()
/// Shown instead of reloading once the page keeps crashing.
private var crashNotice: NSView?

/// The bundled page, nil when it is missing (a broken build).
public static var bundledPage: URL? {
Expand Down Expand Up @@ -89,6 +92,56 @@ public final class AgentPaneView: NSView {
applyTheme()
}

/// Reloads the page after its web content process crashed, unless it
/// keeps crashing; then the pane says so and waits for the user.
func webContentProcessDidTerminate() {
if crashReloads.shouldReload(at: .now) {
source.load(into: webView)
} else {
showCrashNotice()
}
}

private func showCrashNotice() {
guard crashNotice == nil else { return }
let message = NSTextField(wrappingLabelWithString: Self.crashedMessage)
message.alignment = .center
let reload = NSButton(title: Self.reloadTitle, target: self, action: #selector(reloadAfterCrashes))
let notice = NSStackView(views: [message, reload])
notice.orientation = .vertical
notice.spacing = 12
notice.translatesAutoresizingMaskIntoConstraints = false
addSubview(notice)
let inset = notice.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor, constant: -48)
// A pane narrower than the inset clips the notice instead of
// breaking the layout.
inset.priority = .defaultHigh
NSLayoutConstraint.activate([
notice.centerXAnchor.constraint(equalTo: centerXAnchor),
notice.centerYAnchor.constraint(equalTo: centerYAnchor),
inset,
])
crashNotice = notice
themeCrashNotice(themeTokens)
}

/// The notice sits on the pane's background, so it takes the pane's
/// theme rather than the system appearance.
private func themeCrashNotice(_ tokens: ThemeTokens) {
guard let notice = crashNotice else { return }
notice.appearance = NSAppearance(named: tokens.isDark ? .darkAqua : .aqua)
for case let label as NSTextField in notice.subviews {
label.textColor = tokens.textSecondary.nsColor
}
}

@objc private func reloadAfterCrashes() {
crashNotice?.removeFromSuperview()
crashNotice = nil
crashReloads = AgentPaneCrashReloads()
source.load(into: webView)
}

/// Pushes ``customization`` to the page, even an empty one (it clears
/// what removed files left behind).
func applyCustomization() {
Expand All @@ -110,6 +163,7 @@ public final class AgentPaneView: NSView {
func applyTheme() {
let tokens = themeTokens
webView.underPageBackgroundColor = tokens.contentBackground.nsColor
themeCrashNotice(tokens)
guard let script = AgentPaneTheme.script(tokens) else { return }
webView.evaluateJavaScript(script, completionHandler: nil)
}
Expand Down
Loading
Loading