Add devtools split diagnostics and restore retries

This commit is contained in:
Lawrence Chen 2026-02-19 20:31:00 -08:00
parent c186cb5722
commit 397e46a667
4 changed files with 198 additions and 8 deletions

View file

@ -2042,8 +2042,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
@discardableResult
func performSplitShortcut(direction: SplitDirection) -> Bool {
#if DEBUG
let directionLabel: String
switch direction {
case .left: directionLabel = "left"
case .right: directionLabel = "right"
case .up: directionLabel = "up"
case .down: directionLabel = "down"
}
if let browser = tabManager?.focusedBrowserPanel {
dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary())")
} else {
dlog("split.shortcut dir=\(directionLabel) pre panel=nil")
}
#endif
tabManager?.createSplit(direction: direction)
#if DEBUG
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
if let browser = self?.tabManager?.focusedBrowserPanel {
dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary())")
} else {
dlog("split.shortcut dir=\(directionLabel) post panel=nil")
}
}
recordGotoSplitSplitIfNeeded(direction: direction)
#endif
return true

View file

@ -827,6 +827,10 @@ final class BrowserPanel: Panel, ObservableObject {
private let pageZoomStep: CGFloat = 0.1
// Persist user intent across WebKit detach/reattach churn (split/layout updates).
private var preferredDeveloperToolsVisible: Bool = false
private var developerToolsRestoreRetryWorkItem: DispatchWorkItem?
private var developerToolsRestoreRetryAttempt: Int = 0
private let developerToolsRestoreRetryDelay: TimeInterval = 0.05
private let developerToolsRestoreRetryMaxAttempts: Int = 40
var displayTitle: String {
if !pageTitle.isEmpty {
@ -1236,6 +1240,8 @@ final class BrowserPanel: Panel, ObservableObject {
}
deinit {
developerToolsRestoreRetryWorkItem?.cancel()
developerToolsRestoreRetryWorkItem = nil
webViewObservers.removeAll()
}
}
@ -1312,6 +1318,11 @@ extension BrowserPanel {
guard inspector.responds(to: selector) else { return false }
inspector.cmuxCallVoid(selector: selector)
preferredDeveloperToolsVisible = targetVisible
if targetVisible {
developerToolsRestoreRetryAttempt = 0
} else {
cancelDeveloperToolsRestoreRetry()
}
return true
}
@ -1325,6 +1336,11 @@ extension BrowserPanel {
inspector.cmuxCallVoid(selector: showSelector)
}
preferredDeveloperToolsVisible = true
if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) {
cancelDeveloperToolsRestoreRetry()
} else {
scheduleDeveloperToolsRestoreRetry()
}
return true
}
@ -1349,23 +1365,49 @@ extension BrowserPanel {
}
/// Called before WKWebView detaches so manual inspector closes are respected.
func syncDeveloperToolsPreferenceFromInspector() {
func syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: Bool = false) {
guard let inspector = webView.cmuxInspectorObject() else { return }
if let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) {
preferredDeveloperToolsVisible = visible
guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return }
if visible {
preferredDeveloperToolsVisible = true
cancelDeveloperToolsRestoreRetry()
return
}
if preserveVisibleIntent && preferredDeveloperToolsVisible {
return
}
preferredDeveloperToolsVisible = false
cancelDeveloperToolsRestoreRetry()
}
/// Called after WKWebView reattaches to keep inspector stable across split/layout churn.
func restoreDeveloperToolsAfterAttachIfNeeded() {
guard preferredDeveloperToolsVisible else { return }
guard let inspector = webView.cmuxInspectorObject() else { return }
guard preferredDeveloperToolsVisible else {
cancelDeveloperToolsRestoreRetry()
return
}
guard let inspector = webView.cmuxInspectorObject() else {
scheduleDeveloperToolsRestoreRetry()
return
}
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
guard !visible else { return }
guard !visible else {
cancelDeveloperToolsRestoreRetry()
return
}
let selector = NSSelectorFromString("show")
guard inspector.responds(to: selector) else { return }
guard inspector.responds(to: selector) else {
cancelDeveloperToolsRestoreRetry()
return
}
inspector.cmuxCallVoid(selector: selector)
preferredDeveloperToolsVisible = true
let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if visibleAfterShow {
cancelDeveloperToolsRestoreRetry()
} else {
scheduleDeveloperToolsRestoreRetry()
}
}
@discardableResult
@ -1384,6 +1426,7 @@ extension BrowserPanel {
inspector.cmuxCallVoid(selector: selector)
}
preferredDeveloperToolsVisible = false
cancelDeveloperToolsRestoreRetry()
return true
}
@ -1495,6 +1538,42 @@ extension BrowserPanel {
}
private extension BrowserPanel {
func scheduleDeveloperToolsRestoreRetry() {
guard preferredDeveloperToolsVisible else { return }
guard developerToolsRestoreRetryWorkItem == nil else { return }
guard developerToolsRestoreRetryAttempt < developerToolsRestoreRetryMaxAttempts else { return }
developerToolsRestoreRetryAttempt += 1
let work = DispatchWorkItem { [weak self] in
guard let self else { return }
self.developerToolsRestoreRetryWorkItem = nil
self.restoreDeveloperToolsAfterAttachIfNeeded()
}
developerToolsRestoreRetryWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + developerToolsRestoreRetryDelay, execute: work)
}
func cancelDeveloperToolsRestoreRetry() {
developerToolsRestoreRetryWorkItem?.cancel()
developerToolsRestoreRetryWorkItem = nil
developerToolsRestoreRetryAttempt = 0
}
}
#if DEBUG
extension BrowserPanel {
func debugDeveloperToolsStateSummary() -> String {
let preferred = preferredDeveloperToolsVisible ? 1 : 0
let visible = isDeveloperToolsVisible() ? 1 : 0
let inspector = webView.cmuxInspectorObject() == nil ? 0 : 1
let attached = webView.superview == nil ? 0 : 1
let inWindow = webView.window == nil ? 0 : 1
return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt)"
}
}
#endif
private extension BrowserPanel {
@discardableResult
func applyPageZoom(_ candidate: CGFloat) -> Bool {

View file

@ -2560,6 +2560,19 @@ struct WebViewRepresentable: NSViewRepresentable {
var attachGeneration: Int = 0
}
#if DEBUG
private static func logDevToolsState(
_ panel: BrowserPanel,
event: String,
generation: Int,
retryCount: Int
) {
dlog(
"browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())"
)
}
#endif
private static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool {
var r = start
var hops = 0
@ -2632,6 +2645,16 @@ struct WebViewRepresentable: NSViewRepresentable {
// is in a window during bonsplit tree updates; moving the webview too early can be flaky.
guard host.window != nil else {
coordinator.attachRetryCount += 1
#if DEBUG
if coordinator.attachRetryCount == 1 || coordinator.attachRetryCount % 20 == 0 {
logDevToolsState(
panel,
event: "retry.waitingForWindow",
generation: generation,
retryCount: coordinator.attachRetryCount
)
}
#endif
// Be generous here: bonsplit structural updates can keep a representable
// container off-window longer than a few seconds under load.
if coordinator.attachRetryCount < 400 {
@ -2651,6 +2674,9 @@ struct WebViewRepresentable: NSViewRepresentable {
coordinator.attachRetryCount = 0
attachWebView(webView, to: host)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
#if DEBUG
logDevToolsState(panel, event: "retry.attached", generation: generation, retryCount: 0)
#endif
}
coordinator.attachRetryWorkItem = work
@ -2665,7 +2691,23 @@ struct WebViewRepresentable: NSViewRepresentable {
// in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce
// WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane.
if !shouldAttachWebView {
panel.syncDeveloperToolsPreferenceFromInspector()
#if DEBUG
Self.logDevToolsState(
panel,
event: "detach.beforeSync",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount
)
#endif
panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true)
#if DEBUG
Self.logDevToolsState(
panel,
event: "detach.afterSync",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount
)
#endif
context.coordinator.attachRetryWorkItem?.cancel()
context.coordinator.attachRetryWorkItem = nil
context.coordinator.attachRetryCount = 0
@ -2681,6 +2723,14 @@ struct WebViewRepresentable: NSViewRepresentable {
webView.removeFromSuperview()
}
nsView.subviews.forEach { $0.removeFromSuperview() }
#if DEBUG
Self.logDevToolsState(
panel,
event: "detach.done",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount
)
#endif
return
}
@ -2693,6 +2743,14 @@ struct WebViewRepresentable: NSViewRepresentable {
if nsView.window == nil {
// Avoid attaching to off-window containers; during bonsplit structural updates SwiftUI
// can create containers that are never inserted into the window.
#if DEBUG
Self.logDevToolsState(
panel,
event: "attach.defer.offWindow",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount
)
#endif
Self.scheduleAttachRetry(
webView,
panel: panel,
@ -2703,6 +2761,14 @@ struct WebViewRepresentable: NSViewRepresentable {
} else {
Self.attachWebView(webView, to: nsView)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
#if DEBUG
Self.logDevToolsState(
panel,
event: "attach.immediate",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount
)
#endif
}
} else {
// Already attached; no need for any pending retry.
@ -2711,6 +2777,14 @@ struct WebViewRepresentable: NSViewRepresentable {
context.coordinator.attachRetryCount = 0
context.coordinator.attachGeneration += 1
panel.restoreDeveloperToolsAfterAttachIfNeeded()
#if DEBUG
Self.logDevToolsState(
panel,
event: "attach.alreadyAttached",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount
)
#endif
}
// Focus handling. Avoid fighting the address bar when it is focused.

View file

@ -281,6 +281,21 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
XCTAssertFalse(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 1)
}
func testSyncCanPreserveVisibleIntentDuringDetachChurn() {
let (panel, inspector) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertEqual(inspector.showCount, 1)
// Simulate a transient close caused by view detach, not user intent.
inspector.close()
panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 2)
}
}
final class WorkspaceShortcutMapperTests: XCTestCase {