From 0804a9297d281c601a38b4df4301d7894d846ace Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 7 Jul 2026 13:13:30 -0700 Subject: [PATCH 01/35] Add failing regression tests for discarded browser webview restore retry (#7504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A discarded browser webview whose restore navigation never commits (connection refused, WebKit content-process death, dead localhost dev server) permanently consumes its discard state, so every later reveal, reload, or automation touch no-ops and the pane stays black forever. Red tests only, per the two-commit regression policy: - R1: manager-level — a restore whose navigation never starts/commits must leave the pane discarded and retryable. - R2: panel end-to-end — connection-refused restore must leave the next restore touch able to retry. CI on this commit is expected to fail these tests; the fix lands in the next commit. Co-Authored-By: Claude Fable 5 --- cmux.xcodeproj/project.pbxproj | 4 + ...serDiscardedWebViewRestoreRetryTests.swift | 111 ++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 5424313ffad8..cbfe2f8588f2 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -139,6 +139,7 @@ D7032A050000000000000001 /* BrowserClientCertificateCredentialPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7032A050000000000000002 /* BrowserClientCertificateCredentialPicker.swift */; }; D7032A030000000000000001 /* BrowserClientCertificateCredentialPickerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7032A030000000000000002 /* BrowserClientCertificateCredentialPickerTests.swift */; }; E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */; }; + B75040020000000000000001 /* BrowserDiscardedWebViewRestoreRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75040020000000000000002 /* BrowserDiscardedWebViewRestoreRetryTests.swift */; }; C42660030000000000000001 /* BrowserDownloadDelegate+PDFPreviewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42660030000000000000002 /* BrowserDownloadDelegate+PDFPreviewData.swift */; }; C59240010000000000000001 /* BrowserDownloadFilenameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C59240010000000000000002 /* BrowserDownloadFilenameResolver.swift */; }; C59240010000000000000003 /* BrowserDownloadFilenameResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C59240010000000000000004 /* BrowserDownloadFilenameResolverTests.swift */; }; @@ -1626,6 +1627,7 @@ D7032A050000000000000002 /* BrowserClientCertificateCredentialPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserClientCertificateCredentialPicker.swift; sourceTree = ""; }; D7032A030000000000000002 /* BrowserClientCertificateCredentialPickerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserClientCertificateCredentialPickerTests.swift; sourceTree = ""; }; 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = ""; }; + B75040020000000000000002 /* BrowserDiscardedWebViewRestoreRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDiscardedWebViewRestoreRetryTests.swift; sourceTree = ""; }; C42660030000000000000002 /* BrowserDownloadDelegate+PDFPreviewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserDownloadDelegate+PDFPreviewData.swift"; sourceTree = ""; }; C59240010000000000000002 /* BrowserDownloadFilenameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDownloadFilenameResolver.swift; sourceTree = ""; }; C59240010000000000000004 /* BrowserDownloadFilenameResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDownloadFilenameResolverTests.swift; sourceTree = ""; }; @@ -4143,6 +4145,7 @@ D3622101A1B2C3D4E5F60718 /* EditableTextViewArrowKeyForwardingTests.swift */, B65060010000000000000001 /* BrowserPanelSessionRestoreTests.swift */, B6585002B6585002B6585002 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift */, + B75040020000000000000002 /* BrowserDiscardedWebViewRestoreRetryTests.swift */, 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, C42660040000000000000002 /* BrowserPDFPreviewActionRegressionTests.swift */, C42660050000000000000002 /* BrowserPDFPreviewActionUnitTests.swift */, @@ -5839,6 +5842,7 @@ BCBC0A0E0000000000000D01 /* BrowserChromeMetricsTests.swift in Sources */, D7032A030000000000000001 /* BrowserClientCertificateCredentialPickerTests.swift in Sources */, E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */, + B75040020000000000000001 /* BrowserDiscardedWebViewRestoreRetryTests.swift in Sources */, C59240010000000000000003 /* BrowserDownloadFilenameResolverTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, B6585001B6585001B6585001 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift in Sources */, diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift new file mode 100644 index 000000000000..0dba6e661012 --- /dev/null +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +private func withBrowserDiscardRestoreRetryPolicyEnabled(_ body: (UserDefaults) -> Void) { + let suiteName = "com.cmux.BrowserDiscardedWebViewRestoreRetryTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.set(true, forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey) + defaults.set( + BrowserHiddenWebViewDiscardPolicy.defaultHiddenDelay, + forKey: BrowserHiddenWebViewDiscardPolicy.hiddenDelayKey + ) + defer { + defaults.removePersistentDomain(forName: suiteName) + } + body(defaults) +} + +@MainActor +private func makeDiscardRestoreRetryBlockerSnapshot() -> BrowserHiddenWebViewDiscardManager.BlockerSnapshot { + BrowserHiddenWebViewDiscardManager.BlockerSnapshot( + isClosing: false, + isVisibleInUI: false, + shouldRenderWebView: true, + hasPendingRemoteNavigation: false, + hasCurrentURL: true, + isLoading: false, + webViewIsLoading: false, + hasActiveMainFrameProvisionalNavigation: false, + isDownloading: false, + activeDownloadCount: 0, + preferredDeveloperToolsVisible: false, + isDeveloperToolsVisible: false, + isElementFullscreenActive: false, + isReactGrabActive: false, + isVisualAutomationCaptureActive: false, + hasPopups: false, + isCapturingMedia: false, + isPlayingMedia: false + ) +} + +@MainActor +@discardableResult +private func waitForDiscardRestoreRetryWebViewToSettle( + _ panel: BrowserPanel, + timeout: TimeInterval = 5.0 +) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while panel.webView.isLoading || panel.isLoading, + Date() < deadline { + _ = RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } + return !panel.webView.isLoading && !panel.isLoading +} + +@MainActor +@Suite(.serialized) +struct BrowserDiscardedWebViewRestoreRetryTests { + @Test func discardedManagerRetriesWhenRestoreNeverStartsOrCommits() { + // RED(#7504): a restore closure that never starts navigation must not consume discard state. + withBrowserDiscardRestoreRetryPolicyEnabled { defaults in + let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) + manager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 100)) + + var restoreCount = 0 + #expect(manager.restoreIfNeeded(reason: "test.restore1") { + restoreCount += 1 + }) + + #expect(manager.isDiscardedForMemory) + #expect(restoreCount == 1) + + #expect(manager.restoreIfNeeded(reason: "test.restore2") { + restoreCount += 1 + }) + #expect(restoreCount == 2) + } + } + + @Test func browserPanelRetriesDiscardedRestoreAfterConnectionRefused() throws { + // RED(#7504): connection-refused restore must leave the pane retryable on the next restore touch. + let url = try #require(URL(string: "http://127.0.0.1:1/cmux-issue-7504")) + let discardedAt = Date(timeIntervalSince1970: 200) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: url, + isRemoteWorkspace: false + ) + defer { panel.close() } + + #expect(waitForDiscardRestoreRetryWebViewToSettle(panel)) + + panel.noteWebViewVisibility(false, reason: "test.hidden", now: discardedAt) + let originalWebView = panel.webView + + #expect(panel.discardHiddenWebViewForMemory(reason: "test.discard", now: discardedAt)) + #expect(panel.webView !== originalWebView) + + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore1")) + _ = waitForDiscardRestoreRetryWebViewToSettle(panel) + + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore2")) + } +} From 17684f931f3fb9b9a7046a07a9e6b76fe5d985c2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 7 Jul 2026 13:13:31 -0700 Subject: [PATCH 02/35] Fix browser panes stuck black after a failed discard-restore (#7504) Keep the discard state armed until a restore navigation actually commits, so failed restores retry on the next touch instead of leaving the pane permanently black: - BrowserHiddenWebViewDiscardManager: restoreIfNeeded no longer clears the discard state before navigating; new isRestoreNavigationPending state machine (noteRestoreNavigationStarted / Committed / DidNotCommit) driven by real navigation-delegate signals; in-flight restores dedupe instead of double-navigating; reactivateWithoutNavigation no longer consumes state without a commit. - BrowserPanel: didCommit / didFailNavigation / didCancelProvisionalNavigation hooks drive the state machine; error-page commits do not clear the state; stall detection retries silently-dead restores on the next reveal/automation touch; blank-shell heal re-navigates a never-committed shell that still has a URL intent on reveal transitions (never on visibility heartbeats, and never while an insecure-HTTP consent alert is pending); restore_pending / has_committed_document diagnostics. - BrowserDiscardRestoreHeal (new): pure, unit-testable predicates for heal and stall eligibility, plus relocated lifecycle diagnostics helpers to stay inside the BrowserPanel.swift length budget. - Green tests for the new state machine and heal predicates. Fixes https://github.com/manaflow-ai/cmux/issues/7504 Co-Authored-By: Claude Fable 5 --- .../Panels/BrowserDiscardRestoreHeal.swift | 141 +++++++++++++ .../BrowserHiddenWebViewDiscardManager.swift | 25 ++- Sources/Panels/BrowserPanel.swift | 192 ++++++++--------- cmux.xcodeproj/project.pbxproj | 4 + ...serDiscardedWebViewRestoreRetryTests.swift | 195 ++++++++++++++++++ 5 files changed, 448 insertions(+), 109 deletions(-) create mode 100644 Sources/Panels/BrowserDiscardRestoreHeal.swift diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift new file mode 100644 index 000000000000..0f86b7300ad9 --- /dev/null +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -0,0 +1,141 @@ +import Foundation + +@MainActor +enum BrowserDiscardRestoreHeal { + private static let webViewLifecycleTimestampFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static func webViewLifecycleTimestamp(_ date: Date?) -> Any { + guard let date else { return NSNull() } + return webViewLifecycleTimestampFormatter.string(from: date) + } + + static func webViewHiddenDurationMilliseconds( + hiddenAt: Date?, + visible: Bool, + now: Date + ) -> Any { + guard !visible, let hiddenAt else { return NSNull() } + return max(0, Int((now.timeIntervalSince(hiddenAt) * 1000.0).rounded())) + } + + static func isAboutBlankURL(_ url: URL?) -> Bool { + guard let url else { return false } + let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) + return value.caseInsensitiveCompare("about:blank") == .orderedSame + } + + static func shouldHealBlankShell( + shouldRenderWebView: Bool, + isClosing: Bool, + hasPendingRemoteNavigation: Bool, + isWebViewLoading: Bool, + isMainFrameProvisionalNavigationActive: Bool, + hasCommittedDocument: Bool, + isNavigationBlockedPendingConsent: Bool, + intentURL: URL? + ) -> Bool { + guard shouldRenderWebView else { return false } + guard !isClosing else { return false } + guard !hasPendingRemoteNavigation else { return false } + guard !isWebViewLoading else { return false } + guard !isMainFrameProvisionalNavigationActive else { return false } + guard !hasCommittedDocument else { return false } + guard !isNavigationBlockedPendingConsent else { return false } + guard let intentURL else { return false } + return !isAboutBlankURL(intentURL) + } + + static func isRestoreStalled( + isRestoreNavigationPending: Bool, + isWebViewLoading: Bool, + isMainFrameProvisionalNavigationActive: Bool, + hasPendingRemoteNavigation: Bool, + hasCommittedDocument: Bool + ) -> Bool { + guard isRestoreNavigationPending else { return false } + guard !isWebViewLoading else { return false } + guard !isMainFrameProvisionalNavigationActive else { return false } + guard !hasPendingRemoteNavigation else { return false } + return !hasCommittedDocument + } +} + +extension BrowserPanel { + /// Whether browser native/SwiftUI fills should draw over the window root + /// backdrop. Mirrors terminal/markdown panel background decisions. + static func drawsConfiguredWebViewBackground( + isBlankPage: Bool, + usesTransparentBackground: Bool = false + ) -> Bool { + drawsWebViewBackground( + isBlankPage: isBlankPage, + usesTransparentBackground: usesTransparentBackground, + opacity: GhosttyApp.shared.defaultBackgroundOpacity, + usesGhosttyGlassStyle: GhosttyApp.shared.defaultBackgroundBlur.isMacOSGlassStyle, + usesTransparentWindow: WindowBackgroundComposition.policy + .shouldUseTransparentBackgroundWindow(glassEffectAvailable: false) + ) + } + + nonisolated static func isBlankBrowserPageURL(_ url: URL?) -> Bool { + guard let url else { return true } + let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) + return value.caseInsensitiveCompare("about:blank") == .orderedSame + } + + nonisolated static func isBlankBrowserPage( + liveURL: URL?, + currentURL: URL?, + pendingNavigationURL: URL?, + isMainFrameProvisionalNavigationActive: Bool + ) -> Bool { + if isMainFrameProvisionalNavigationActive, + !isBlankBrowserPageURL(pendingNavigationURL) { + return false + } + if !isBlankBrowserPageURL(pendingNavigationURL), + isBlankBrowserPageURL(liveURL), + isBlankBrowserPageURL(currentURL) { + return false + } + return isBlankBrowserPageURL(liveURL) && isBlankBrowserPageURL(currentURL) + } + + nonisolated static func drawsWebViewBackground( + isBlankPage: Bool, + usesTransparentBackground: Bool = false, + opacity: Double, + usesGhosttyGlassStyle: Bool, + usesTransparentWindow: Bool + ) -> Bool { + if usesTransparentBackground { + return drawsWebViewBackground( + opacity: opacity, + usesGhosttyGlassStyle: usesGhosttyGlassStyle, + usesTransparentWindow: usesTransparentWindow + ) + } + guard isBlankPage else { return true } + return drawsWebViewBackground( + opacity: opacity, + usesGhosttyGlassStyle: usesGhosttyGlassStyle, + usesTransparentWindow: usesTransparentWindow + ) + } + + nonisolated static func drawsWebViewBackground( + opacity: Double, + usesGhosttyGlassStyle: Bool, + usesTransparentWindow: Bool + ) -> Bool { + !PanelAppearance.shouldUseClearContentBackground( + opacity: opacity, + usesGhosttyGlassStyle: usesGhosttyGlassStyle, + usesTransparentWindow: usesTransparentWindow + ) + } +} diff --git a/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift b/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift index b447b5e51559..1b5cc279565e 100644 --- a/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift +++ b/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift @@ -66,6 +66,7 @@ final class BrowserHiddenWebViewDiscardManager { private(set) var lastDiscardReason: String? private(set) var lastRestoreReason: String? private(set) var restoredSessionShouldRenderWebView: Bool? + private(set) var isRestoreNavigationPending: Bool = false var hasScheduledDiscard: Bool { discardTimer != nil @@ -239,6 +240,7 @@ final class BrowserHiddenWebViewDiscardManager { func markDiscarded(reason: String, now: Date) { isDiscardedForMemory = true + isRestoreNavigationPending = false discardedAt = now lastDiscardReason = reason updateRestoredSessionRenderIntent(true) @@ -248,18 +250,33 @@ final class BrowserHiddenWebViewDiscardManager { func restoreIfNeeded(reason: String, performRestore: () -> Void) -> Bool { guard isDiscardedForMemory else { return false } cancel() - guard clearDiscardState(reason: reason) else { return false } + if isRestoreNavigationPending { return true } + lastRestoreReason = reason updateRestoredSessionRenderIntent(nil) performRestore() return true } + func noteRestoreNavigationStarted(reason: String) { + guard isDiscardedForMemory else { return } + isRestoreNavigationPending = true + } + + @discardableResult + func noteRestoreNavigationCommitted(reason: String) -> Bool { + isRestoreNavigationPending = false + return clearDiscardState(reason: reason) + } + + func noteRestoreNavigationDidNotCommit(reason: String) { + guard isDiscardedForMemory else { return } + isRestoreNavigationPending = false + } + @discardableResult func reactivateWithoutNavigation(reason: String, performReactivate: () -> Void) -> Bool { guard isDiscardedForMemory else { return false } cancel() - guard clearDiscardState(reason: reason) else { return false } - updateRestoredSessionRenderIntent(nil) performReactivate() return true } @@ -272,6 +289,7 @@ final class BrowserHiddenWebViewDiscardManager { func clearDiscardState(reason: String) -> Bool { guard isDiscardedForMemory else { return false } isDiscardedForMemory = false + isRestoreNavigationPending = false discardedAt = nil lastRestoreReason = reason return true @@ -280,6 +298,7 @@ final class BrowserHiddenWebViewDiscardManager { func resetMetadata() { cancel() isDiscardedForMemory = false + isRestoreNavigationPending = false discardedAt = nil lastDiscardReason = nil lastRestoreReason = nil diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 5ca38c9334be..3ed6e9271a69 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2844,6 +2844,7 @@ final class BrowserPanel: Panel, ObservableObject { } @Published private(set) var backgroundAppearanceRevision: UInt64 = 0 private let hiddenWebViewDiscardManager = BrowserHiddenWebViewDiscardManager() + private var hasCommittedDocumentSinceWebViewReplacement = false @Published private(set) var webViewLifecycleState: BrowserWebViewLifecycleState = .newTab private(set) var webViewLastVisibleAt: Date? @@ -3234,7 +3235,10 @@ final class BrowserPanel: Panel, ObservableObject { if visible { cancelHiddenWebViewDiscard() - restoreDiscardedWebViewIfNeeded(reason: "visible.\(reason)") + restoreDiscardedWebViewIfNeeded( + reason: "visible.\(reason)", + allowBlankShellHeal: changed || isFirstVisibilityRecord + ) drainPendingInteractiveBrowserPromptsIfPossible(reason: "visible.\(reason)") } else if changed || isFirstVisibilityRecord || !hiddenWebViewDiscardManager.hasScheduledDiscard { scheduleHiddenWebViewDiscardIfNeeded(reason: reason, now: now) @@ -3249,14 +3253,16 @@ final class BrowserPanel: Panel, ObservableObject { "should_render": shouldRenderWebView, "discard_eligible": discardBlockers.isEmpty, "discard_blockers": discardBlockers, - "discarded_at": Self.webViewLifecycleTimestamp(hiddenWebViewDiscardManager.discardedAt), + "restore_pending": hiddenWebViewDiscardManager.isRestoreNavigationPending, + "has_committed_document": hasCommittedDocumentSinceWebViewReplacement, + "discarded_at": BrowserDiscardRestoreHeal.webViewLifecycleTimestamp(hiddenWebViewDiscardManager.discardedAt), "last_discard_reason": hiddenWebViewDiscardManager.lastDiscardReason.map { $0 as Any } ?? NSNull(), "last_restore_reason": hiddenWebViewDiscardManager.lastRestoreReason.map { $0 as Any } ?? NSNull(), - "last_visible_at": Self.webViewLifecycleTimestamp(webViewLastVisibleAt), - "last_hidden_at": Self.webViewLifecycleTimestamp(webViewLastHiddenAt), - "last_visibility_change_at": Self.webViewLifecycleTimestamp(webViewLastVisibilityChangeAt), + "last_visible_at": BrowserDiscardRestoreHeal.webViewLifecycleTimestamp(webViewLastVisibleAt), + "last_hidden_at": BrowserDiscardRestoreHeal.webViewLifecycleTimestamp(webViewLastHiddenAt), + "last_visibility_change_at": BrowserDiscardRestoreHeal.webViewLifecycleTimestamp(webViewLastVisibilityChangeAt), "last_visibility_change_reason": webViewLastVisibilityChangeReason.map { $0 as Any } ?? NSNull(), - "hidden_duration_ms": Self.webViewHiddenDurationMilliseconds( + "hidden_duration_ms": BrowserDiscardRestoreHeal.webViewHiddenDurationMilliseconds( hiddenAt: webViewLastHiddenAt, visible: isWebViewVisibleInUI, now: now @@ -3268,7 +3274,7 @@ final class BrowserPanel: Panel, ObservableObject { let nextState: BrowserWebViewLifecycleState if isClosingWebViewLifecycle { nextState = .closing - } else if hiddenWebViewDiscardManager.isDiscardedForMemory { + } else if hiddenWebViewDiscardManager.isDiscardedForMemory && !shouldRenderWebView { nextState = .discarded } else if !shouldRenderWebView { nextState = preferredURLStringForOmnibar() == nil ? .newTab : .deferredURL @@ -3281,26 +3287,6 @@ final class BrowserPanel: Panel, ObservableObject { webViewLifecycleState = nextState } - private static let webViewLifecycleTimestampFormatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - - private static func webViewLifecycleTimestamp(_ date: Date?) -> Any { - guard let date else { return NSNull() } - return webViewLifecycleTimestampFormatter.string(from: date) - } - - private static func webViewHiddenDurationMilliseconds( - hiddenAt: Date?, - visible: Bool, - now: Date - ) -> Any { - guard !visible, let hiddenAt else { return NSNull() } - return max(0, Int((now.timeIntervalSince(hiddenAt) * 1000.0).rounded())) - } - private func resetWebViewLifecycleMetadata(resetVisibility: Bool = true) { cancelHiddenWebViewDiscard() webViewLifecycleState = .newTab @@ -3379,6 +3365,7 @@ final class BrowserPanel: Panel, ObservableObject { ) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() + hasCommittedDocumentSinceWebViewReplacement = false webView = replacement hiddenWebViewDiscardManager.markDiscarded(reason: reason, now: now) currentURL = restoreURL @@ -3412,9 +3399,21 @@ final class BrowserPanel: Panel, ObservableObject { @discardableResult func restoreDiscardedWebViewIfNeeded( reason: String, - cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy + cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy, + allowBlankShellHeal: Bool = true ) -> Bool { - return hiddenWebViewDiscardManager.restoreIfNeeded(reason: reason) { + if BrowserDiscardRestoreHeal.isRestoreStalled( + isRestoreNavigationPending: hiddenWebViewDiscardManager.isRestoreNavigationPending, + isWebViewLoading: webView.isLoading, + isMainFrameProvisionalNavigationActive: isMainFrameProvisionalNavigationActive, + hasPendingRemoteNavigation: pendingRemoteNavigation != nil, + hasCommittedDocument: hasCommittedDocumentSinceWebViewReplacement + ) { + hiddenWebViewDiscardManager.noteRestoreNavigationDidNotCommit(reason: "\(reason).stalled") + refreshWebViewLifecycleState() + } + + if hiddenWebViewDiscardManager.restoreIfNeeded(reason: reason, performRestore: { shouldRenderWebView = true guard let restoreURL = restoredHistoryCurrentURL ?? currentURL else { refreshNavigationAvailability() @@ -3426,11 +3425,57 @@ final class BrowserPanel: Panel, ObservableObject { preserveRestoredSessionHistory: true, cachePolicy: cachePolicy ) + }) { + return true + } + + guard allowBlankShellHeal else { return false } + return healBlankRestoredWebViewIfNeeded(reason: reason, cachePolicy: cachePolicy) + } + + @discardableResult + private func healBlankRestoredWebViewIfNeeded( + reason _: String, + cachePolicy: URLRequest.CachePolicy + ) -> Bool { + let intentURL = restoredHistoryCurrentURL ?? currentURL + let isNavigationBlockedPendingConsent = intentURL.map { browserShouldBlockInsecureHTTPURL($0) } ?? false + guard BrowserDiscardRestoreHeal.shouldHealBlankShell( + shouldRenderWebView: shouldRenderWebView, + isClosing: isClosingWebViewLifecycle, + hasPendingRemoteNavigation: pendingRemoteNavigation != nil, + isWebViewLoading: webView.isLoading, + isMainFrameProvisionalNavigationActive: isMainFrameProvisionalNavigationActive, + hasCommittedDocument: hasCommittedDocumentSinceWebViewReplacement, + isNavigationBlockedPendingConsent: isNavigationBlockedPendingConsent, + intentURL: intentURL + ) else { + return false + } + guard let intentURL else { return false } + navigateWithoutInsecureHTTPPrompt( + to: intentURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true, + cachePolicy: cachePolicy + ) + return true + } + + private func noteDiscardedWebViewRestoreNavigationStarted() { + hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "navigation") + refreshWebViewLifecycleState() + } + + private func noteDiscardedWebViewRestoreNavigationCommitted() { + guard hiddenWebViewDiscardManager.noteRestoreNavigationCommitted(reason: "navigation_commit") else { + return } + refreshWebViewLifecycleState() } - private func clearWebViewDiscardState(reason: String) { - guard hiddenWebViewDiscardManager.clearDiscardState(reason: reason) else { return } + private func noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: String) { + hiddenWebViewDiscardManager.noteRestoreNavigationDidNotCommit(reason: reason) refreshWebViewLifecycleState() } @@ -3828,6 +3873,7 @@ final class BrowserPanel: Panel, ObservableObject { MainActor.assumeIsolated { guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } self.isMainFrameProvisionalNavigationActive = false + self.hasCommittedDocumentSinceWebViewReplacement = true // Reset playback tracking only once the new top-level document has // actually replaced the old one. Resetting earlier (on provisional // start) would drop a still-playing page's frames if the @@ -3837,6 +3883,9 @@ final class BrowserPanel: Panel, ObservableObject { self.resetMediaPlaybackTracking() self.publishCommittedURL(from: webView) self.applyMuteState(to: webView, reason: "navigationCommit") + if self.navigationDelegate?.activeErrorPageDisplayURL == nil { + self.noteDiscardedWebViewRestoreNavigationCommitted() + } } } navigationDelegate.didFinish = { [weak self] webView in @@ -3867,6 +3916,7 @@ final class BrowserPanel: Panel, ObservableObject { self.faviconPNGData = nil self.lastFaviconURLString = nil self.applyMuteState(to: failedWebView, reason: "navigationFail") + self.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_failed") // Keep find-in-page open and clear stale counters on failed loads. self.restoreFindStateAfterNavigation(replaySearch: false) } @@ -3877,6 +3927,7 @@ final class BrowserPanel: Panel, ObservableObject { self.isMainFrameProvisionalNavigationActive = false self.navigationDelegate?.clearAttemptedRequest() self.refreshBackgroundAppearance() + self.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_cancelled") } } } @@ -4625,6 +4676,7 @@ final class BrowserPanel: Panel, ObservableObject { ) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() + hasCommittedDocumentSinceWebViewReplacement = false resetWebViewLifecycleMetadata(resetVisibility: false) webView = replacement currentURL = restoreURL @@ -5052,28 +5104,6 @@ final class BrowserPanel: Panel, ObservableObject { ) } - /// Whether browser native/SwiftUI fills should draw over the window root - /// backdrop. Mirrors terminal/markdown panel background decisions. - static func drawsConfiguredWebViewBackground( - isBlankPage: Bool, - usesTransparentBackground: Bool = false - ) -> Bool { - drawsWebViewBackground( - isBlankPage: isBlankPage, - usesTransparentBackground: usesTransparentBackground, - opacity: GhosttyApp.shared.defaultBackgroundOpacity, - usesGhosttyGlassStyle: GhosttyApp.shared.defaultBackgroundBlur.isMacOSGlassStyle, - usesTransparentWindow: WindowBackgroundComposition.policy - .shouldUseTransparentBackgroundWindow(glassEffectAvailable: false) - ) - } - - nonisolated static func isBlankBrowserPageURL(_ url: URL?) -> Bool { - guard let url else { return true } - let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) - return value.caseInsensitiveCompare("about:blank") == .orderedSame - } - private func restorableDisplayURLForCurrentErrorPage(liveURL: URL?) -> URL? { Self.restorableDisplayURL( liveURL: liveURL, @@ -5082,58 +5112,6 @@ final class BrowserPanel: Panel, ObservableObject { ) } - nonisolated static func isBlankBrowserPage( - liveURL: URL?, - currentURL: URL?, - pendingNavigationURL: URL?, - isMainFrameProvisionalNavigationActive: Bool - ) -> Bool { - if isMainFrameProvisionalNavigationActive, - !isBlankBrowserPageURL(pendingNavigationURL) { - return false - } - if !isBlankBrowserPageURL(pendingNavigationURL), - isBlankBrowserPageURL(liveURL), - isBlankBrowserPageURL(currentURL) { - return false - } - return isBlankBrowserPageURL(liveURL) && isBlankBrowserPageURL(currentURL) - } - - nonisolated static func drawsWebViewBackground( - isBlankPage: Bool, - usesTransparentBackground: Bool = false, - opacity: Double, - usesGhosttyGlassStyle: Bool, - usesTransparentWindow: Bool - ) -> Bool { - if usesTransparentBackground { - return drawsWebViewBackground( - opacity: opacity, - usesGhosttyGlassStyle: usesGhosttyGlassStyle, - usesTransparentWindow: usesTransparentWindow - ) - } - guard isBlankPage else { return true } - return drawsWebViewBackground( - opacity: opacity, - usesGhosttyGlassStyle: usesGhosttyGlassStyle, - usesTransparentWindow: usesTransparentWindow - ) - } - - nonisolated static func drawsWebViewBackground( - opacity: Double, - usesGhosttyGlassStyle: Bool, - usesTransparentWindow: Bool - ) -> Bool { - !PanelAppearance.shouldUseClearContentBackground( - opacity: opacity, - usesGhosttyGlassStyle: usesGhosttyGlassStyle, - usesTransparentWindow: usesTransparentWindow - ) - } - private func replaceWebViewAfterContentProcessTermination(for terminatedWebView: WKWebView) { replaceWebViewPreservingState( from: terminatedWebView, @@ -5207,6 +5185,7 @@ final class BrowserPanel: Panel, ObservableObject { ) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() + hasCommittedDocumentSinceWebViewReplacement = false resetWebViewLifecycleMetadata(resetVisibility: false) webView = replacement shouldRenderWebView = wasRenderable @@ -5781,7 +5760,7 @@ final class BrowserPanel: Panel, ObservableObject { ) { guard let url = request.url else { return } cancelHiddenWebViewDiscard() - clearWebViewDiscardState(reason: "navigation") + noteDiscardedWebViewRestoreNavigationStarted() if usesRemoteWorkspaceProxy, remoteProxyEndpoint == nil { pendingRemoteNavigation = PendingRemoteNavigation( request: request, @@ -6198,6 +6177,7 @@ extension BrowserPanel { websiteDataStore: websiteDataStore ) webViewInstanceID = UUID() + hasCommittedDocumentSinceWebViewReplacement = false webView = replacement shouldRenderWebView = false refreshWebViewLifecycleState() diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index cbfe2f8588f2..ef8330553d4d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -140,6 +140,7 @@ D7032A030000000000000001 /* BrowserClientCertificateCredentialPickerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7032A030000000000000002 /* BrowserClientCertificateCredentialPickerTests.swift */; }; E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */; }; B75040020000000000000001 /* BrowserDiscardedWebViewRestoreRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75040020000000000000002 /* BrowserDiscardedWebViewRestoreRetryTests.swift */; }; + B75040010000000000000001 /* BrowserDiscardRestoreHeal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75040010000000000000002 /* BrowserDiscardRestoreHeal.swift */; }; C42660030000000000000001 /* BrowserDownloadDelegate+PDFPreviewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42660030000000000000002 /* BrowserDownloadDelegate+PDFPreviewData.swift */; }; C59240010000000000000001 /* BrowserDownloadFilenameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C59240010000000000000002 /* BrowserDownloadFilenameResolver.swift */; }; C59240010000000000000003 /* BrowserDownloadFilenameResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C59240010000000000000004 /* BrowserDownloadFilenameResolverTests.swift */; }; @@ -1628,6 +1629,7 @@ D7032A030000000000000002 /* BrowserClientCertificateCredentialPickerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserClientCertificateCredentialPickerTests.swift; sourceTree = ""; }; 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = ""; }; B75040020000000000000002 /* BrowserDiscardedWebViewRestoreRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDiscardedWebViewRestoreRetryTests.swift; sourceTree = ""; }; + B75040010000000000000002 /* BrowserDiscardRestoreHeal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDiscardRestoreHeal.swift; sourceTree = ""; }; C42660030000000000000002 /* BrowserDownloadDelegate+PDFPreviewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserDownloadDelegate+PDFPreviewData.swift"; sourceTree = ""; }; C59240010000000000000002 /* BrowserDownloadFilenameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDownloadFilenameResolver.swift; sourceTree = ""; }; C59240010000000000000004 /* BrowserDownloadFilenameResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDownloadFilenameResolverTests.swift; sourceTree = ""; }; @@ -3605,6 +3607,7 @@ C0DE6B420000000000000002 /* TerminalPanelTextBoxState.swift */, B42450020000000000000001 /* BrowserHiddenWebViewDiscardPolicy.swift */, B42450040000000000000001 /* BrowserHiddenWebViewDiscardManager.swift */, + B75040010000000000000002 /* BrowserDiscardRestoreHeal.swift */, D7032A070000000000000002 /* BrowserAuthPromptTextFormatter.swift */, D7032A020000000000000002 /* BrowserClientCertificateAuthenticationController.swift */, D7032A050000000000000002 /* BrowserClientCertificateCredentialPicker.swift */, @@ -4900,6 +4903,7 @@ BCBC0A0E0000000000000C01 /* BrowserChromeMetrics.swift in Sources */, D7032A020000000000000001 /* BrowserClientCertificateAuthenticationController.swift in Sources */, D7032A050000000000000001 /* BrowserClientCertificateCredentialPicker.swift in Sources */, + B75040010000000000000001 /* BrowserDiscardRestoreHeal.swift in Sources */, C42660030000000000000001 /* BrowserDownloadDelegate+PDFPreviewData.swift in Sources */, C59240010000000000000001 /* BrowserDownloadFilenameResolver.swift in Sources */, C67540020000000000000001 /* BrowserDownloadHTTPStatusDecision.swift in Sources */, diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index 0dba6e661012..9da3d2c21db7 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -109,3 +109,198 @@ struct BrowserDiscardedWebViewRestoreRetryTests { #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore2")) } } + +// MARK: - GREEN(#7504) new-API coverage (added with the fix) + +@MainActor +@Suite(.serialized) +struct BrowserDiscardedWebViewRestoreRetryGreenTests { + @Test func managerKeepsDiscardStateUntilRestoreNavigationCommits() { + withBrowserDiscardRestoreRetryPolicyEnabled { defaults in + let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) + manager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 300)) + + var restoreCount = 0 + #expect(manager.restoreIfNeeded(reason: "test.restore1") { + restoreCount += 1 + }) + manager.noteRestoreNavigationStarted(reason: "test.navigation1") + manager.noteRestoreNavigationDidNotCommit(reason: "test.failed") + + #expect(manager.isDiscardedForMemory) + #expect(!manager.isRestoreNavigationPending) + #expect(manager.blockers(for: makeDiscardRestoreRetryBlockerSnapshot()).contains("already_discarded")) + + #expect(manager.restoreIfNeeded(reason: "test.restore2") { + restoreCount += 1 + }) + manager.noteRestoreNavigationStarted(reason: "test.navigation2") + #expect(manager.noteRestoreNavigationCommitted(reason: "test.commit")) + + #expect(!manager.isDiscardedForMemory) + #expect(!manager.isRestoreNavigationPending) + let didRestoreAfterCommit = manager.restoreIfNeeded(reason: "test.restore3") { + restoreCount += 1 + } + #expect(!didRestoreAfterCommit) + #expect(restoreCount == 2) + } + } + + @Test func managerDeduplicatesRestoreWhileNavigationIsPending() { + withBrowserDiscardRestoreRetryPolicyEnabled { defaults in + let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) + manager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 400)) + + var restoreCount = 0 + #expect(manager.restoreIfNeeded(reason: "test.restore1") { + restoreCount += 1 + }) + manager.noteRestoreNavigationStarted(reason: "test.navigation") + + #expect(manager.restoreIfNeeded(reason: "test.restore2") { + restoreCount += 1 + }) + #expect(restoreCount == 1) + #expect(manager.isRestoreNavigationPending) + } + } + + @Test func markDiscardedResetsStalePendingRestoreNavigation() { + withBrowserDiscardRestoreRetryPolicyEnabled { defaults in + let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) + manager.markDiscarded(reason: "test.discard1", now: Date(timeIntervalSince1970: 500)) + #expect(manager.restoreIfNeeded(reason: "test.restore") {}) + manager.noteRestoreNavigationStarted(reason: "test.navigation") + #expect(manager.isRestoreNavigationPending) + + manager.markDiscarded(reason: "test.discard2", now: Date(timeIntervalSince1970: 501)) + + #expect(manager.isDiscardedForMemory) + #expect(!manager.isRestoreNavigationPending) + } + } + + @Test func reactivationWithoutNavigationDoesNotClearDiscardState() { + withBrowserDiscardRestoreRetryPolicyEnabled { defaults in + let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) + manager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 600)) + + var reactivationCount = 0 + #expect(manager.reactivateWithoutNavigation(reason: "test.reactivate") { + reactivationCount += 1 + }) + + #expect(reactivationCount == 1) + #expect(manager.isDiscardedForMemory) + #expect(manager.blockers(for: makeDiscardRestoreRetryBlockerSnapshot()).contains("already_discarded")) + + var restoreCount = 0 + #expect(manager.restoreIfNeeded(reason: "test.restore") { + restoreCount += 1 + }) + #expect(restoreCount == 1) + } + } + + @Test func pureRestoreHealPredicatesCoverBlankShellAndStalledCases() throws { + let intentURL = try #require(URL(string: "http://127.0.0.1:7777/app")) + let aboutBlankURL = try #require(URL(string: "about:blank")) + let mixedCaseAboutBlankURL = try #require(URL(string: "ABOUT:BLANK")) + + #expect(BrowserDiscardRestoreHeal.isAboutBlankURL(aboutBlankURL)) + #expect(BrowserDiscardRestoreHeal.isAboutBlankURL(mixedCaseAboutBlankURL)) + #expect(!BrowserDiscardRestoreHeal.isAboutBlankURL(intentURL)) + #expect(!BrowserDiscardRestoreHeal.isAboutBlankURL(nil)) + + #expect(BrowserDiscardRestoreHeal.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + intentURL: intentURL + )) + #expect(!BrowserDiscardRestoreHeal.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: true, + isNavigationBlockedPendingConsent: false, + intentURL: intentURL + )) + #expect(!BrowserDiscardRestoreHeal.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + intentURL: aboutBlankURL + )) + #expect(!BrowserDiscardRestoreHeal.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: true, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + intentURL: intentURL + )) + #expect(!BrowserDiscardRestoreHeal.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + intentURL: nil + )) + #expect(!BrowserDiscardRestoreHeal.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: true, + intentURL: intentURL + )) + + #expect(BrowserDiscardRestoreHeal.isRestoreStalled( + isRestoreNavigationPending: true, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasPendingRemoteNavigation: false, + hasCommittedDocument: false + )) + #expect(!BrowserDiscardRestoreHeal.isRestoreStalled( + isRestoreNavigationPending: true, + isWebViewLoading: true, + isMainFrameProvisionalNavigationActive: false, + hasPendingRemoteNavigation: false, + hasCommittedDocument: false + )) + #expect(!BrowserDiscardRestoreHeal.isRestoreStalled( + isRestoreNavigationPending: true, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasPendingRemoteNavigation: true, + hasCommittedDocument: false + )) + #expect(!BrowserDiscardRestoreHeal.isRestoreStalled( + isRestoreNavigationPending: true, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasPendingRemoteNavigation: false, + hasCommittedDocument: true + )) + } +} From 07b21c46470a0abb9080e0443fd35f65c0a9a964 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 7 Jul 2026 16:34:35 -0700 Subject: [PATCH 03/35] Fix remote queued discard restore state --- Sources/Panels/BrowserPanel.swift | 6 ++-- ...serDiscardedWebViewRestoreRetryTests.swift | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 3ed6e9271a69..f8c6f1d9b1ad 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -5760,7 +5760,6 @@ final class BrowserPanel: Panel, ObservableObject { ) { guard let url = request.url else { return } cancelHiddenWebViewDiscard() - noteDiscardedWebViewRestoreNavigationStarted() if usesRemoteWorkspaceProxy, remoteProxyEndpoint == nil { pendingRemoteNavigation = PendingRemoteNavigation( request: request, @@ -5828,7 +5827,10 @@ final class BrowserPanel: Panel, ObservableObject { if recordTypedNavigation { historyStore.recordTypedNavigation(url: originalURL) } - browserLoadRequest(effectiveRequest, in: webView) + noteDiscardedWebViewRestoreNavigationStarted() + if browserLoadRequest(effectiveRequest, in: webView) == nil { + noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_not_started") + } } private func remoteProxyPreparedRequest(from request: URLRequest, logScope: String) -> URLRequest { diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index 9da3d2c21db7..6ee7717ed15b 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -108,6 +108,35 @@ struct BrowserDiscardedWebViewRestoreRetryTests { #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore2")) } + + @Test func remoteSessionRestoreQueuedForProxyEndpointDoesNotMarkNavigationPending() throws { + let url = try #require(URL(string: "http://localhost:3000/cmux-issue-7504")) + let workspaceId = UUID() + let panel = BrowserPanel( + workspaceId: workspaceId, + isRemoteWorkspace: true, + remoteWebsiteDataStoreIdentifier: workspaceId + ) + defer { panel.close() } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: url.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false + )) + + #expect(panel.webViewLifecycleState == .discarded) + #expect(panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool == false) + + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore.remote")) + + #expect(panel.hiddenWebViewDiscardSnapshot.hasPendingRemoteNavigation) + #expect(panel.webViewLifecycleState == .liveHidden) + #expect(panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool == false) + #expect(panel.webView.url == nil) + } } // MARK: - GREEN(#7504) new-API coverage (added with the fix) From 1dcab6435cc33fcf3282902e7d84d70fabf4b43a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 7 Jul 2026 17:10:59 -0700 Subject: [PATCH 04/35] Handle discarded restore review edge cases --- .../Panels/BrowserDiscardRestoreHeal.swift | 19 +++---- .../BrowserHiddenWebViewDiscardManager.swift | 9 ++- .../Panels/BrowserNavigationDelegate.swift | 3 + Sources/Panels/BrowserPanel.swift | 25 +++++--- ...serDiscardedWebViewRestoreRetryTests.swift | 57 ++++++++++++------- 5 files changed, 71 insertions(+), 42 deletions(-) diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift index 0f86b7300ad9..036da69c91e8 100644 --- a/Sources/Panels/BrowserDiscardRestoreHeal.swift +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -1,16 +1,11 @@ import Foundation -@MainActor -enum BrowserDiscardRestoreHeal { - private static let webViewLifecycleTimestampFormatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - +extension BrowserPanel { static func webViewLifecycleTimestamp(_ date: Date?) -> Any { guard let date else { return NSNull() } - return webViewLifecycleTimestampFormatter.string(from: date) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) } static func webViewHiddenDurationMilliseconds( @@ -22,13 +17,13 @@ enum BrowserDiscardRestoreHeal { return max(0, Int((now.timeIntervalSince(hiddenAt) * 1000.0).rounded())) } - static func isAboutBlankURL(_ url: URL?) -> Bool { + nonisolated static func isAboutBlankURL(_ url: URL?) -> Bool { guard let url else { return false } let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) return value.caseInsensitiveCompare("about:blank") == .orderedSame } - static func shouldHealBlankShell( + nonisolated static func shouldHealBlankShell( shouldRenderWebView: Bool, isClosing: Bool, hasPendingRemoteNavigation: Bool, @@ -49,7 +44,7 @@ enum BrowserDiscardRestoreHeal { return !isAboutBlankURL(intentURL) } - static func isRestoreStalled( + nonisolated static func isRestoreStalled( isRestoreNavigationPending: Bool, isWebViewLoading: Bool, isMainFrameProvisionalNavigationActive: Bool, diff --git a/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift b/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift index 1b5cc279565e..f59fc6f6661c 100644 --- a/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift +++ b/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift @@ -260,6 +260,9 @@ final class BrowserHiddenWebViewDiscardManager { func noteRestoreNavigationStarted(reason: String) { guard isDiscardedForMemory else { return } isRestoreNavigationPending = true +#if DEBUG + cmuxDebugLog("browser.discard.restoreNavigation.start reason=\(reason)") +#endif } @discardableResult @@ -271,6 +274,9 @@ final class BrowserHiddenWebViewDiscardManager { func noteRestoreNavigationDidNotCommit(reason: String) { guard isDiscardedForMemory else { return } isRestoreNavigationPending = false +#if DEBUG + cmuxDebugLog("browser.discard.restoreNavigation.didNotCommit reason=\(reason)") +#endif } @discardableResult @@ -278,7 +284,7 @@ final class BrowserHiddenWebViewDiscardManager { guard isDiscardedForMemory else { return false } cancel() performReactivate() - return true + return clearDiscardState(reason: reason) } func updateRestoredSessionRenderIntent(_ shouldRenderWebView: Bool?) { @@ -292,6 +298,7 @@ final class BrowserHiddenWebViewDiscardManager { isRestoreNavigationPending = false discardedAt = nil lastRestoreReason = reason + updateRestoredSessionRenderIntent(nil) return true } diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index 5974e48e1386..1778ae6e9fa2 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -10,6 +10,7 @@ import WebKit var didFinish: ((WKWebView) -> Void)? var didFailNavigation: ((WKWebView, String) -> Void)? var didCancelProvisionalNavigation: ((WKWebView) -> Void)? + var didBecomeDownload: ((WKWebView, Bool) -> Void)? var didTerminateWebContentProcess: ((WKWebView) -> Void)? var openInNewTab: ((URL) -> Void)? var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? @@ -591,6 +592,7 @@ import WebKit cmuxDebugLog("download.didBecome source=navigationAction") #endif NSLog("BrowserPanel download didBecome from navigationAction") + didBecomeDownload?(webView, navigationAction.targetFrame?.isMainFrame ?? true) download.delegate = downloadDelegate } @@ -599,6 +601,7 @@ import WebKit cmuxDebugLog("download.didBecome source=navigationResponse") #endif NSLog("BrowserPanel download didBecome from navigationResponse") + didBecomeDownload?(webView, navigationResponse.isForMainFrame) download.delegate = downloadDelegate } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f8c6f1d9b1ad..2b14403c60f2 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3255,14 +3255,14 @@ final class BrowserPanel: Panel, ObservableObject { "discard_blockers": discardBlockers, "restore_pending": hiddenWebViewDiscardManager.isRestoreNavigationPending, "has_committed_document": hasCommittedDocumentSinceWebViewReplacement, - "discarded_at": BrowserDiscardRestoreHeal.webViewLifecycleTimestamp(hiddenWebViewDiscardManager.discardedAt), + "discarded_at": Self.webViewLifecycleTimestamp(hiddenWebViewDiscardManager.discardedAt), "last_discard_reason": hiddenWebViewDiscardManager.lastDiscardReason.map { $0 as Any } ?? NSNull(), "last_restore_reason": hiddenWebViewDiscardManager.lastRestoreReason.map { $0 as Any } ?? NSNull(), - "last_visible_at": BrowserDiscardRestoreHeal.webViewLifecycleTimestamp(webViewLastVisibleAt), - "last_hidden_at": BrowserDiscardRestoreHeal.webViewLifecycleTimestamp(webViewLastHiddenAt), - "last_visibility_change_at": BrowserDiscardRestoreHeal.webViewLifecycleTimestamp(webViewLastVisibilityChangeAt), + "last_visible_at": Self.webViewLifecycleTimestamp(webViewLastVisibleAt), + "last_hidden_at": Self.webViewLifecycleTimestamp(webViewLastHiddenAt), + "last_visibility_change_at": Self.webViewLifecycleTimestamp(webViewLastVisibilityChangeAt), "last_visibility_change_reason": webViewLastVisibilityChangeReason.map { $0 as Any } ?? NSNull(), - "hidden_duration_ms": BrowserDiscardRestoreHeal.webViewHiddenDurationMilliseconds( + "hidden_duration_ms": Self.webViewHiddenDurationMilliseconds( hiddenAt: webViewLastHiddenAt, visible: isWebViewVisibleInUI, now: now @@ -3402,7 +3402,7 @@ final class BrowserPanel: Panel, ObservableObject { cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy, allowBlankShellHeal: Bool = true ) -> Bool { - if BrowserDiscardRestoreHeal.isRestoreStalled( + if Self.isRestoreStalled( isRestoreNavigationPending: hiddenWebViewDiscardManager.isRestoreNavigationPending, isWebViewLoading: webView.isLoading, isMainFrameProvisionalNavigationActive: isMainFrameProvisionalNavigationActive, @@ -3440,7 +3440,7 @@ final class BrowserPanel: Panel, ObservableObject { ) -> Bool { let intentURL = restoredHistoryCurrentURL ?? currentURL let isNavigationBlockedPendingConsent = intentURL.map { browserShouldBlockInsecureHTTPURL($0) } ?? false - guard BrowserDiscardRestoreHeal.shouldHealBlankShell( + guard Self.shouldHealBlankShell( shouldRenderWebView: shouldRenderWebView, isClosing: isClosingWebViewLifecycle, hasPendingRemoteNavigation: pendingRemoteNavigation != nil, @@ -3467,8 +3467,8 @@ final class BrowserPanel: Panel, ObservableObject { refreshWebViewLifecycleState() } - private func noteDiscardedWebViewRestoreNavigationCommitted() { - guard hiddenWebViewDiscardManager.noteRestoreNavigationCommitted(reason: "navigation_commit") else { + private func noteDiscardedWebViewRestoreNavigationCommitted(reason: String = "navigation_commit") { + guard hiddenWebViewDiscardManager.noteRestoreNavigationCommitted(reason: reason) else { return } refreshWebViewLifecycleState() @@ -3930,6 +3930,13 @@ final class BrowserPanel: Panel, ObservableObject { self.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_cancelled") } } + navigationDelegate.didBecomeDownload = { [weak self] webView, isMainFrame in + MainActor.assumeIsolated { + guard isMainFrame else { return } + guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } + self.noteDiscardedWebViewRestoreNavigationCommitted(reason: "navigation_download") + } + } } private func publishCommittedURL(from webView: WKWebView) { diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index 6ee7717ed15b..c0aec4982dcd 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -124,7 +124,9 @@ struct BrowserDiscardedWebViewRestoreRetryTests { profileID: nil, shouldRenderWebView: true, pageZoom: 1.0, - developerToolsVisible: false + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] )) #expect(panel.webViewLifecycleState == .discarded) @@ -195,6 +197,20 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { } } + @Test func managerClearsDiscardStateWhenRestoreBecomesDownload() { + withBrowserDiscardRestoreRetryPolicyEnabled { defaults in + let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) + manager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 450)) + + #expect(manager.restoreIfNeeded(reason: "test.restore") {}) + manager.noteRestoreNavigationStarted(reason: "test.navigation") + #expect(manager.noteRestoreNavigationCommitted(reason: "test.download")) + + #expect(!manager.isDiscardedForMemory) + #expect(!manager.isRestoreNavigationPending) + } + } + @Test func markDiscardedResetsStalePendingRestoreNavigation() { withBrowserDiscardRestoreRetryPolicyEnabled { defaults in let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) @@ -210,7 +226,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { } } - @Test func reactivationWithoutNavigationDoesNotClearDiscardState() { + @Test func reactivationWithoutNavigationClearsDiscardState() { withBrowserDiscardRestoreRetryPolicyEnabled { defaults in let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) manager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 600)) @@ -221,14 +237,15 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { }) #expect(reactivationCount == 1) - #expect(manager.isDiscardedForMemory) - #expect(manager.blockers(for: makeDiscardRestoreRetryBlockerSnapshot()).contains("already_discarded")) + #expect(!manager.isDiscardedForMemory) + #expect(!manager.isRestoreNavigationPending) + #expect(!manager.blockers(for: makeDiscardRestoreRetryBlockerSnapshot()).contains("already_discarded")) var restoreCount = 0 - #expect(manager.restoreIfNeeded(reason: "test.restore") { + #expect(!manager.restoreIfNeeded(reason: "test.restore") { restoreCount += 1 }) - #expect(restoreCount == 1) + #expect(restoreCount == 0) } } @@ -237,12 +254,12 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { let aboutBlankURL = try #require(URL(string: "about:blank")) let mixedCaseAboutBlankURL = try #require(URL(string: "ABOUT:BLANK")) - #expect(BrowserDiscardRestoreHeal.isAboutBlankURL(aboutBlankURL)) - #expect(BrowserDiscardRestoreHeal.isAboutBlankURL(mixedCaseAboutBlankURL)) - #expect(!BrowserDiscardRestoreHeal.isAboutBlankURL(intentURL)) - #expect(!BrowserDiscardRestoreHeal.isAboutBlankURL(nil)) + #expect(BrowserPanel.isAboutBlankURL(aboutBlankURL)) + #expect(BrowserPanel.isAboutBlankURL(mixedCaseAboutBlankURL)) + #expect(!BrowserPanel.isAboutBlankURL(intentURL)) + #expect(!BrowserPanel.isAboutBlankURL(nil)) - #expect(BrowserDiscardRestoreHeal.shouldHealBlankShell( + #expect(BrowserPanel.shouldHealBlankShell( shouldRenderWebView: true, isClosing: false, hasPendingRemoteNavigation: false, @@ -252,7 +269,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { isNavigationBlockedPendingConsent: false, intentURL: intentURL )) - #expect(!BrowserDiscardRestoreHeal.shouldHealBlankShell( + #expect(!BrowserPanel.shouldHealBlankShell( shouldRenderWebView: true, isClosing: false, hasPendingRemoteNavigation: false, @@ -262,7 +279,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { isNavigationBlockedPendingConsent: false, intentURL: intentURL )) - #expect(!BrowserDiscardRestoreHeal.shouldHealBlankShell( + #expect(!BrowserPanel.shouldHealBlankShell( shouldRenderWebView: true, isClosing: false, hasPendingRemoteNavigation: false, @@ -272,7 +289,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { isNavigationBlockedPendingConsent: false, intentURL: aboutBlankURL )) - #expect(!BrowserDiscardRestoreHeal.shouldHealBlankShell( + #expect(!BrowserPanel.shouldHealBlankShell( shouldRenderWebView: true, isClosing: false, hasPendingRemoteNavigation: true, @@ -282,7 +299,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { isNavigationBlockedPendingConsent: false, intentURL: intentURL )) - #expect(!BrowserDiscardRestoreHeal.shouldHealBlankShell( + #expect(!BrowserPanel.shouldHealBlankShell( shouldRenderWebView: true, isClosing: false, hasPendingRemoteNavigation: false, @@ -292,7 +309,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { isNavigationBlockedPendingConsent: false, intentURL: nil )) - #expect(!BrowserDiscardRestoreHeal.shouldHealBlankShell( + #expect(!BrowserPanel.shouldHealBlankShell( shouldRenderWebView: true, isClosing: false, hasPendingRemoteNavigation: false, @@ -303,28 +320,28 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { intentURL: intentURL )) - #expect(BrowserDiscardRestoreHeal.isRestoreStalled( + #expect(BrowserPanel.isRestoreStalled( isRestoreNavigationPending: true, isWebViewLoading: false, isMainFrameProvisionalNavigationActive: false, hasPendingRemoteNavigation: false, hasCommittedDocument: false )) - #expect(!BrowserDiscardRestoreHeal.isRestoreStalled( + #expect(!BrowserPanel.isRestoreStalled( isRestoreNavigationPending: true, isWebViewLoading: true, isMainFrameProvisionalNavigationActive: false, hasPendingRemoteNavigation: false, hasCommittedDocument: false )) - #expect(!BrowserDiscardRestoreHeal.isRestoreStalled( + #expect(!BrowserPanel.isRestoreStalled( isRestoreNavigationPending: true, isWebViewLoading: false, isMainFrameProvisionalNavigationActive: false, hasPendingRemoteNavigation: true, hasCommittedDocument: false )) - #expect(!BrowserDiscardRestoreHeal.isRestoreStalled( + #expect(!BrowserPanel.isRestoreStalled( isRestoreNavigationPending: true, isWebViewLoading: false, isMainFrameProvisionalNavigationActive: false, From 449e8cd781902cf69c6de45d8e183a4f51fdfe69 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 7 Jul 2026 23:55:13 -0700 Subject: [PATCH 05/35] Ignore about:blank commits when tracking discarded-restore recovery A navigation commit to about:blank (e.g. the placeholder document) must not count as a successful discarded-webview restore; gate the restore-commit bookkeeping on a real committed URL. Harden the retry test to wait for the restore-pending flag to clear instead of only waiting for loading to settle. Co-Authored-By: Claude Fable 5 --- Sources/Panels/BrowserPanel.swift | 8 +++++++- ...serDiscardedWebViewRestoreRetryTests.swift | 19 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 2b14403c60f2..95e31320bfb0 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3883,7 +3883,7 @@ final class BrowserPanel: Panel, ObservableObject { self.resetMediaPlaybackTracking() self.publishCommittedURL(from: webView) self.applyMuteState(to: webView, reason: "navigationCommit") - if self.navigationDelegate?.activeErrorPageDisplayURL == nil { + if self.shouldTreatCommitAsDiscardedRestoreCommit(from: webView) { self.noteDiscardedWebViewRestoreNavigationCommitted() } } @@ -3939,6 +3939,12 @@ final class BrowserPanel: Panel, ObservableObject { } } + private func shouldTreatCommitAsDiscardedRestoreCommit(from webView: WKWebView) -> Bool { + guard navigationDelegate?.activeErrorPageDisplayURL == nil else { return false } + guard let committedURL = webView.url else { return false } + return !Self.isAboutBlankURL(committedURL) + } + private func publishCommittedURL(from webView: WKWebView) { if let errorPageDisplayURL = navigationDelegate?.activeErrorPageDisplayURL { currentURL = Self.remoteProxyDisplayURL(for: errorPageDisplayURL) ?? errorPageDisplayURL diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index c0aec4982dcd..18b0da0687d6 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -60,6 +60,23 @@ private func waitForDiscardRestoreRetryWebViewToSettle( return !panel.webView.isLoading && !panel.isLoading } +@MainActor +@discardableResult +private func waitForDiscardRestoreRetryWebViewToBecomeRetryable( + _ panel: BrowserPanel, + timeout: TimeInterval = 20.0 +) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let restorePending = panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool ?? false + if !restorePending, !panel.webView.isLoading, !panel.isLoading { + return true + } + _ = RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.05)) + } + return false +} + @MainActor @Suite(.serialized) struct BrowserDiscardedWebViewRestoreRetryTests { @@ -104,7 +121,7 @@ struct BrowserDiscardedWebViewRestoreRetryTests { #expect(panel.webView !== originalWebView) #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore1")) - _ = waitForDiscardRestoreRetryWebViewToSettle(panel) + #expect(waitForDiscardRestoreRetryWebViewToBecomeRetryable(panel)) #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore2")) } From 1e042d2aecdf64e7b1bef7d4920ec48b7a27ee67 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 7 Jul 2026 23:57:13 -0700 Subject: [PATCH 06/35] Refresh Swift file length budget for BrowserNavigationDelegate download callback The discarded-restore fix adds a didBecomeDownload callback (property plus two delegate call sites, +3 lines) to BrowserNavigationDelegate.swift. Accept the growth in the checked-in budget. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index a2a1f70434e7..74cc7c7ba5c5 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -179,7 +179,7 @@ 635 cmuxUITests/RightSidebarChromeHeightUITests.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift 623 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift -620 Sources/Panels/BrowserNavigationDelegate.swift +623 Sources/Panels/BrowserNavigationDelegate.swift 620 cmuxTests/FinderFileDropRegressionTests.swift 618 Sources/SettingsNavigation.swift 617 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift From e190d94841b3a59470282145ed2f64fb6aa82308 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 7 Jul 2026 23:59:46 -0700 Subject: [PATCH 07/35] Mark pure lifecycle formatter helpers nonisolated webViewLifecycleTimestamp and webViewHiddenDurationMilliseconds are pure formatters and do not need MainActor isolation; align them with the sibling nonisolated helpers in BrowserDiscardRestoreHeal. Co-Authored-By: Claude Fable 5 --- Sources/Panels/BrowserDiscardRestoreHeal.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift index 036da69c91e8..2a158eba92dd 100644 --- a/Sources/Panels/BrowserDiscardRestoreHeal.swift +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -1,14 +1,14 @@ import Foundation extension BrowserPanel { - static func webViewLifecycleTimestamp(_ date: Date?) -> Any { + nonisolated static func webViewLifecycleTimestamp(_ date: Date?) -> Any { guard let date else { return NSNull() } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter.string(from: date) } - static func webViewHiddenDurationMilliseconds( + nonisolated static func webViewHiddenDurationMilliseconds( hiddenAt: Date?, visible: Bool, now: Date From 6394ab34f1760e756705e0d69bc42d7bd078acac Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 00:45:12 -0700 Subject: [PATCH 08/35] Fix download and no-URL edge cases in discarded webview restore Two review findings on the restore retry state machine: - A main-frame download cleared discard state but never committed a document, so blank-shell healing re-navigated to the download URL on every reveal, restarting the download. Treat a main-frame download as a committed terminal outcome for the replaced web view. - A discarded pane whose restore URL is nil or about:blank navigated (or skipped navigating) into a state whose commit is intentionally ignored, leaving the manager marked discarded (or restore-pending) forever and blocking future discards. Reactivate such panes in place through the existing reactivateWithoutNavigation path. Widen navigationDelegate to internal so the download regression test can drive the didBecomeDownload callback via @testable import, and raise the test settle timeout for loaded CI hosts. Co-Authored-By: Claude Fable 5 --- Sources/Panels/BrowserPanel.swift | 26 ++++++-- ...serDiscardedWebViewRestoreRetryTests.swift | 59 ++++++++++++++++++- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f705ae9f43a0..b68f71e83b14 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2991,7 +2991,7 @@ final class BrowserPanel: Panel, ObservableObject { private var pendingDistinctPortalHostReplacementPaneId: UUID? private var lockedPortalHost: PortalHostLock? private var webViewCancellables = Set() - private var navigationDelegate: BrowserNavigationDelegate? + var navigationDelegate: BrowserNavigationDelegate? private var uiDelegate: BrowserUIDelegate? var downloadDelegate: BrowserDownloadDelegate? private let webAuthnCoordinator = BrowserWebAuthnCoordinator() @@ -3365,12 +3365,23 @@ final class BrowserPanel: Panel, ObservableObject { refreshWebViewLifecycleState() } - if hiddenWebViewDiscardManager.restoreIfNeeded(reason: reason, performRestore: { - shouldRenderWebView = true - guard let restoreURL = restoredHistoryCurrentURL ?? currentURL else { + let restoreURL = restoredHistoryCurrentURL ?? currentURL + guard let restoreURL, !Self.isAboutBlankURL(restoreURL) else { + // No restorable document: the replacement web view already shows the + // blank shell, and an about:blank restore commit is ignored by + // shouldTreatCommitAsDiscardedRestoreCommit, so navigating would + // leave the manager pending forever. Reactivate in place instead so + // the pane does not stay marked discarded. + if reactivateDiscardedWebViewWithoutNavigation(reason: "\(reason).no_restore_url") { refreshNavigationAvailability() - return + refreshWebViewLifecycleState() + return true } + return false + } + + if hiddenWebViewDiscardManager.restoreIfNeeded(reason: reason, performRestore: { + shouldRenderWebView = true navigateWithoutInsecureHTTPPrompt( to: restoreURL, recordTypedNavigation: false, @@ -3888,6 +3899,11 @@ final class BrowserPanel: Panel, ObservableObject { MainActor.assumeIsolated { guard isMainFrame else { return } guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } + // A main-frame download is a terminal outcome for this web view: + // no document will commit, so treat it as committed to keep + // blank-shell healing and stall retries from restarting the + // download on the next reveal. + self.hasCommittedDocumentSinceWebViewReplacement = true self.noteDiscardedWebViewRestoreNavigationCommitted(reason: "navigation_download") } } diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index 18b0da0687d6..0403cab73c15 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -50,7 +50,7 @@ private func makeDiscardRestoreRetryBlockerSnapshot() -> BrowserHiddenWebViewDis @discardableResult private func waitForDiscardRestoreRetryWebViewToSettle( _ panel: BrowserPanel, - timeout: TimeInterval = 5.0 + timeout: TimeInterval = 30.0 ) -> Bool { let deadline = Date().addingTimeInterval(timeout) while panel.webView.isLoading || panel.isLoading, @@ -366,4 +366,61 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { hasCommittedDocument: true )) } + + @Test func mainFrameDownloadCompletesRestoreAndSuppressesBlankShellHeal() throws { + let url = try #require(URL(string: "http://127.0.0.1:1/cmux-issue-7504-download")) + let discardedAt = Date(timeIntervalSince1970: 700) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: url, + isRemoteWorkspace: false + ) + defer { panel.close() } + + #expect(waitForDiscardRestoreRetryWebViewToSettle(panel)) + + panel.noteWebViewVisibility(false, reason: "test.hidden", now: discardedAt) + #expect(panel.discardHiddenWebViewForMemory(reason: "test.discard", now: discardedAt)) + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore")) + + // Simulate WebKit converting the pending restore navigation into a + // main-frame download before any document commits. + panel.navigationDelegate?.didBecomeDownload?(panel.webView, true) + + let payload = panel.webViewLifecycleTopPayload() + #expect(payload["restore_pending"] as? Bool == false) + #expect(payload["has_committed_document"] as? Bool == true) + #expect(payload["state"] as? String != "discarded") + + // A later reveal touch must not blank-shell-heal into re-triggering the + // download navigation. + #expect(!panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + } + + @Test func aboutBlankDiscardedPaneReactivatesWithoutRestoreNavigation() throws { + let url = try #require(URL(string: "about:blank")) + let discardedAt = Date(timeIntervalSince1970: 800) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: url, + isRemoteWorkspace: false + ) + defer { panel.close() } + + #expect(waitForDiscardRestoreRetryWebViewToSettle(panel)) + + panel.noteWebViewVisibility(false, reason: "test.hidden", now: discardedAt) + #expect(panel.discardHiddenWebViewForMemory(reason: "test.discard", now: discardedAt)) + + // Restoring a pane whose only URL is about:blank must reactivate in + // place (no navigation) and fully clear discard bookkeeping instead of + // waiting on a restore commit that shouldTreatCommitAsDiscardedRestoreCommit ignores. + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore")) + + let payload = panel.webViewLifecycleTopPayload() + #expect(payload["state"] as? String != "discarded") + #expect(payload["restore_pending"] as? Bool == false) + #expect(payload["discard_blockers"] as? [String] != nil) + #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == false) + } } From 9f9ad5b6dd83a9e7c7a049da42e9f7d19efb4456 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 00:59:44 -0700 Subject: [PATCH 09/35] Seed committed flag for adopted prewarmed webviews; move commit predicate A prewarmed webview is only claimable after its load finished, but the commit happened under the pool's delegate, so the panel's hasCommittedDocumentSinceWebViewReplacement stayed false and blank-shell healing reloaded the adopted page on first reveal. Seed the flag at adoption. Move shouldTreatCommitAsDiscardedRestoreCommit next to its sibling restore-heal predicates in BrowserDiscardRestoreHeal.swift and refresh the BrowserPanel.swift length budget for the net restore-retry growth. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 2 +- .../Panels/BrowserDiscardRestoreHeal.swift | 7 ++++++ Sources/Panels/BrowserPanel.swift | 24 +++++++------------ 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 74cc7c7ba5c5..52631c338b40 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,7 +8,7 @@ 13232 Sources/Workspace.swift 12511 Sources/GhosttyTerminalView.swift 12348 cmuxTests/AppDelegateShortcutRoutingTests.swift -11641 Sources/Panels/BrowserPanel.swift +11646 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8032 Sources/Panels/BrowserPanelView.swift 8016 CLI/cmux_open.swift diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift index 2a158eba92dd..781e2c9ceaa6 100644 --- a/Sources/Panels/BrowserDiscardRestoreHeal.swift +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -1,6 +1,13 @@ import Foundation +import WebKit extension BrowserPanel { + func shouldTreatCommitAsDiscardedRestoreCommit(from webView: WKWebView) -> Bool { + guard navigationDelegate?.activeErrorPageDisplayURL == nil else { return false } + guard let committedURL = webView.url else { return false } + return !Self.isAboutBlankURL(committedURL) + } + nonisolated static func webViewLifecycleTimestamp(_ date: Date?) -> Any { guard let date else { return NSNull() } let formatter = ISO8601DateFormatter() diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index b68f71e83b14..403bda77c8b1 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3367,11 +3367,9 @@ final class BrowserPanel: Panel, ObservableObject { let restoreURL = restoredHistoryCurrentURL ?? currentURL guard let restoreURL, !Self.isAboutBlankURL(restoreURL) else { - // No restorable document: the replacement web view already shows the - // blank shell, and an about:blank restore commit is ignored by - // shouldTreatCommitAsDiscardedRestoreCommit, so navigating would - // leave the manager pending forever. Reactivate in place instead so - // the pane does not stay marked discarded. + // No restorable document (nil or about:blank): navigating would wait + // on a commit that shouldTreatCommitAsDiscardedRestoreCommit ignores, + // so reactivate in place instead of leaving the manager discarded. if reactivateDiscardedWebViewWithoutNavigation(reason: "\(reason).no_restore_url") { refreshNavigationAvailability() refreshWebViewLifecycleState() @@ -3899,22 +3897,15 @@ final class BrowserPanel: Panel, ObservableObject { MainActor.assumeIsolated { guard isMainFrame else { return } guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } - // A main-frame download is a terminal outcome for this web view: - // no document will commit, so treat it as committed to keep - // blank-shell healing and stall retries from restarting the - // download on the next reveal. + // A main-frame download is a terminal outcome with no document + // commit; treat it as committed so blank-shell healing and stall + // retries never restart the download on the next reveal. self.hasCommittedDocumentSinceWebViewReplacement = true self.noteDiscardedWebViewRestoreNavigationCommitted(reason: "navigation_download") } } } - private func shouldTreatCommitAsDiscardedRestoreCommit(from webView: WKWebView) -> Bool { - guard navigationDelegate?.activeErrorPageDisplayURL == nil else { return false } - guard let committedURL = webView.url else { return false } - return !Self.isAboutBlankURL(committedURL) - } - private func publishCommittedURL(from webView: WKWebView) { if let errorPageDisplayURL = navigationDelegate?.activeErrorPageDisplayURL { currentURL = Self.remoteProxyDisplayURL(for: errorPageDisplayURL) ?? errorPageDisplayURL @@ -4262,6 +4253,9 @@ final class BrowserPanel: Panel, ObservableObject { if adoptedPrewarmedWebView { // Already navigated while hidden; record for recovery paths. navigationDelegate?.recordAttemptedRequest(URLRequest(url: url), displayURL: url) + // The pool only vends finished loads; seed the committed flag so + // blank-shell healing never reloads the adopted page on reveal. + hasCommittedDocumentSinceWebViewReplacement = true refreshBackgroundAppearance() } else { navigate(to: url) From 4d71ea1845d525b29cfd610ae8be2a207d85fcb1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 01:21:10 -0700 Subject: [PATCH 10/35] Gate blank-shell heal off during pending WebContent crash recovery A webview replaced after WebContent process termination waits for the user's explicit Reload (hasRecoverableWebContentTermination). The blank-shell heal predicate did not know about that gate, so a hidden crashed pane would auto-navigate on the next reveal, clear the recovery overlay, and could re-enter the crash loop. Add the recovery flag to shouldHealBlankShell and cover it in the predicate tests. Also move the no-restorable-URL restore fallback into BrowserDiscardRestoreHeal so BrowserPanel.swift stays below its pre-PR length (the guard job's hard cap forbids any growth of files over 900 lines), and drop the now-unneeded budget bump. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 2 +- .../Panels/BrowserDiscardRestoreHeal.swift | 17 +++++++++++++++++ Sources/Panels/BrowserPanel.swift | 17 +++++------------ ...serDiscardedWebViewRestoreRetryTests.swift | 19 +++++++++++++++++++ 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 52631c338b40..74cc7c7ba5c5 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,7 +8,7 @@ 13232 Sources/Workspace.swift 12511 Sources/GhosttyTerminalView.swift 12348 cmuxTests/AppDelegateShortcutRoutingTests.swift -11646 Sources/Panels/BrowserPanel.swift +11641 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8032 Sources/Panels/BrowserPanelView.swift 8016 CLI/cmux_open.swift diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift index 781e2c9ceaa6..d6e4f4d5bbf2 100644 --- a/Sources/Panels/BrowserDiscardRestoreHeal.swift +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -8,6 +8,19 @@ extension BrowserPanel { return !Self.isAboutBlankURL(committedURL) } + /// Restore fallback for a discarded pane with no restorable document (nil + /// or about:blank restore URL): navigating would wait on a commit that + /// ``shouldTreatCommitAsDiscardedRestoreCommit(from:)`` ignores, leaving the + /// manager pending forever, so reactivate in place instead. + func reactivateDiscardedPaneWithoutRestorableURL(reason: String) -> Bool { + guard reactivateDiscardedWebViewWithoutNavigation(reason: "\(reason).no_restore_url") else { + return false + } + refreshNavigationAvailability() + refreshWebViewLifecycleState() + return true + } + nonisolated static func webViewLifecycleTimestamp(_ date: Date?) -> Any { guard let date else { return NSNull() } let formatter = ISO8601DateFormatter() @@ -38,6 +51,7 @@ extension BrowserPanel { isMainFrameProvisionalNavigationActive: Bool, hasCommittedDocument: Bool, isNavigationBlockedPendingConsent: Bool, + hasRecoverableWebContentTermination: Bool, intentURL: URL? ) -> Bool { guard shouldRenderWebView else { return false } @@ -47,6 +61,9 @@ extension BrowserPanel { guard !isMainFrameProvisionalNavigationActive else { return false } guard !hasCommittedDocument else { return false } guard !isNavigationBlockedPendingConsent else { return false } + // A crashed WebContent process waits for the user's explicit Reload; + // auto-healing here would bypass that gate and can re-enter the crash. + guard !hasRecoverableWebContentTermination else { return false } guard let intentURL else { return false } return !isAboutBlankURL(intentURL) } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 403bda77c8b1..dc32eab228e2 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3222,7 +3222,7 @@ final class BrowserPanel: Panel, ObservableObject { ] } - private func refreshWebViewLifecycleState() { + func refreshWebViewLifecycleState() { let nextState: BrowserWebViewLifecycleState if isClosingWebViewLifecycle { nextState = .closing @@ -3367,15 +3367,7 @@ final class BrowserPanel: Panel, ObservableObject { let restoreURL = restoredHistoryCurrentURL ?? currentURL guard let restoreURL, !Self.isAboutBlankURL(restoreURL) else { - // No restorable document (nil or about:blank): navigating would wait - // on a commit that shouldTreatCommitAsDiscardedRestoreCommit ignores, - // so reactivate in place instead of leaving the manager discarded. - if reactivateDiscardedWebViewWithoutNavigation(reason: "\(reason).no_restore_url") { - refreshNavigationAvailability() - refreshWebViewLifecycleState() - return true - } - return false + return reactivateDiscardedPaneWithoutRestorableURL(reason: reason) } if hiddenWebViewDiscardManager.restoreIfNeeded(reason: reason, performRestore: { @@ -3409,6 +3401,7 @@ final class BrowserPanel: Panel, ObservableObject { isMainFrameProvisionalNavigationActive: isMainFrameProvisionalNavigationActive, hasCommittedDocument: hasCommittedDocumentSinceWebViewReplacement, isNavigationBlockedPendingConsent: isNavigationBlockedPendingConsent, + hasRecoverableWebContentTermination: hasRecoverableWebContentTermination, intentURL: intentURL ) else { return false @@ -3441,7 +3434,7 @@ final class BrowserPanel: Panel, ObservableObject { } @discardableResult - private func reactivateDiscardedWebViewWithoutNavigation(reason: String) -> Bool { + func reactivateDiscardedWebViewWithoutNavigation(reason: String) -> Bool { return hiddenWebViewDiscardManager.reactivateWithoutNavigation(reason: reason) { shouldRenderWebView = true } @@ -8050,7 +8043,7 @@ extension BrowserPanel { return restoredHistoryCurrentURL } - private func refreshNavigationAvailability() { + func refreshNavigationAvailability() { let availability = restoredSessionHistory.availability( nativeCanGoBack: nativeCanGoBack, nativeCanGoForward: nativeCanGoForward diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index 0403cab73c15..91350d3ab89d 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -284,6 +284,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { isMainFrameProvisionalNavigationActive: false, hasCommittedDocument: false, isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, intentURL: intentURL )) #expect(!BrowserPanel.shouldHealBlankShell( @@ -294,6 +295,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { isMainFrameProvisionalNavigationActive: false, hasCommittedDocument: true, isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, intentURL: intentURL )) #expect(!BrowserPanel.shouldHealBlankShell( @@ -304,6 +306,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { isMainFrameProvisionalNavigationActive: false, hasCommittedDocument: false, isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, intentURL: aboutBlankURL )) #expect(!BrowserPanel.shouldHealBlankShell( @@ -314,6 +317,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { isMainFrameProvisionalNavigationActive: false, hasCommittedDocument: false, isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, intentURL: intentURL )) #expect(!BrowserPanel.shouldHealBlankShell( @@ -324,6 +328,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { isMainFrameProvisionalNavigationActive: false, hasCommittedDocument: false, isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, intentURL: nil )) #expect(!BrowserPanel.shouldHealBlankShell( @@ -334,6 +339,20 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { isMainFrameProvisionalNavigationActive: false, hasCommittedDocument: false, isNavigationBlockedPendingConsent: true, + hasRecoverableWebContentTermination: false, + intentURL: intentURL + )) + // A crashed WebContent process must wait for the user's explicit + // Reload; blank-shell healing never auto-navigates over that gate. + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: true, intentURL: intentURL )) From 2f2bef9fcfaabebae906c73cc547b1c494ede296 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 01:32:54 -0700 Subject: [PATCH 11/35] Cache the lifecycle-payload ISO8601 formatter webViewLifecycleTopPayload runs on the polled debug-socket/top path for every browser panel; allocate the documented-thread-safe formatter once instead of per timestamp field, matching CmuxEventBus and Workspace. Co-Authored-By: Claude Fable 5 --- Sources/Panels/BrowserDiscardRestoreHeal.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift index d6e4f4d5bbf2..d41a5d893f1b 100644 --- a/Sources/Panels/BrowserDiscardRestoreHeal.swift +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -21,11 +21,17 @@ extension BrowserPanel { return true } - nonisolated static func webViewLifecycleTimestamp(_ date: Date?) -> Any { - guard let date else { return NSNull() } + /// ISO8601DateFormatter is documented thread-safe; cached so the polled + /// lifecycle-payload path stays allocation-free. + private nonisolated(unsafe) static let webViewLifecycleTimestampFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.string(from: date) + return formatter + }() + + nonisolated static func webViewLifecycleTimestamp(_ date: Date?) -> Any { + guard let date else { return NSNull() } + return webViewLifecycleTimestampFormatter.string(from: date) } nonisolated static func webViewHiddenDurationMilliseconds( From 70e89e7d14badaa8296da1edaf09a0666e32f30f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 01:48:00 -0700 Subject: [PATCH 12/35] Scope committed-document tracking to each discarded restore attempt didCommit sets hasCommittedDocumentSinceWebViewReplacement even for error-page commits, where the discard manager intentionally stays discarded. A later restore retry that produced no navigation callbacks was then never detected as stalled, leaving the pane stuck pending. Reset the flag when a discarded restore navigation starts so each attempt tracks its own commit. Move the restore-milestone helpers next to the other discard-restore logic in BrowserDiscardRestoreHeal. Co-Authored-By: Claude Fable 5 --- .../Panels/BrowserDiscardRestoreHeal.swift | 23 +++++++++++++++++++ Sources/Panels/BrowserPanel.swift | 21 ++--------------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift index d41a5d893f1b..f4e2355500e5 100644 --- a/Sources/Panels/BrowserDiscardRestoreHeal.swift +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -8,6 +8,29 @@ extension BrowserPanel { return !Self.isAboutBlankURL(committedURL) } + func noteDiscardedWebViewRestoreNavigationStarted() { + if hiddenWebViewDiscardManager.isDiscardedForMemory { + // Each restore attempt tracks its own commit. Without this reset, a + // previous attempt's error-page commit would satisfy the stall + // detector forever and a silently stalled retry could never re-arm. + hasCommittedDocumentSinceWebViewReplacement = false + } + hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "navigation") + refreshWebViewLifecycleState() + } + + func noteDiscardedWebViewRestoreNavigationCommitted(reason: String = "navigation_commit") { + guard hiddenWebViewDiscardManager.noteRestoreNavigationCommitted(reason: reason) else { + return + } + refreshWebViewLifecycleState() + } + + func noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: String) { + hiddenWebViewDiscardManager.noteRestoreNavigationDidNotCommit(reason: reason) + refreshWebViewLifecycleState() + } + /// Restore fallback for a discarded pane with no restorable document (nil /// or about:blank restore URL): navigating would wait on a commit that /// ``shouldTreatCommitAsDiscardedRestoreCommit(from:)`` ignores, leaving the diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index dc32eab228e2..474b4dd10a23 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2795,8 +2795,8 @@ final class BrowserPanel: Panel, ObservableObject { } } @Published private(set) var backgroundAppearanceRevision: UInt64 = 0 - private let hiddenWebViewDiscardManager = BrowserHiddenWebViewDiscardManager() - private var hasCommittedDocumentSinceWebViewReplacement = false + let hiddenWebViewDiscardManager = BrowserHiddenWebViewDiscardManager() + var hasCommittedDocumentSinceWebViewReplacement = false @Published private(set) var webViewLifecycleState: BrowserWebViewLifecycleState = .newTab private(set) var webViewLastVisibleAt: Date? @@ -3416,23 +3416,6 @@ final class BrowserPanel: Panel, ObservableObject { return true } - private func noteDiscardedWebViewRestoreNavigationStarted() { - hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "navigation") - refreshWebViewLifecycleState() - } - - private func noteDiscardedWebViewRestoreNavigationCommitted(reason: String = "navigation_commit") { - guard hiddenWebViewDiscardManager.noteRestoreNavigationCommitted(reason: reason) else { - return - } - refreshWebViewLifecycleState() - } - - private func noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: String) { - hiddenWebViewDiscardManager.noteRestoreNavigationDidNotCommit(reason: reason) - refreshWebViewLifecycleState() - } - @discardableResult func reactivateDiscardedWebViewWithoutNavigation(reason: String) -> Bool { return hiddenWebViewDiscardManager.reactivateWithoutNavigation(reason: reason) { From 7b5b343a46fbae49fd94229dfba42a570ef5f50b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 01:53:37 -0700 Subject: [PATCH 13/35] ci: keep staged macOS jobs alive when web jobs skip Staging macOS CI behind linux-preflight (#7583) left the staged jobs with plain conditions. linux-preflight survives its skipped web-job ancestors via always(), but app-host-unit-tests, swift-package-tests, tests-build-and-lag, and release-build did not use !cancelled(), so on macOS-only diffs GitHub propagated the ancestors' skip through linux-preflight and skipped every required macOS job; the tests aggregation then failed with 'required but did not pass: skipped'. The staging PR's own run masked this because it touched .github and ran all web jobs. Require linux-preflight (and for release-build, swift-package-tests) to have succeeded explicitly instead. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3335fee17806..49f7356b20ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -388,7 +388,10 @@ jobs: needs: - changes - linux-preflight - if: ${{ needs.changes.outputs.macos == 'true' }} + # !cancelled() opts out of implicit success(), which would otherwise + # propagate the skip from linux-preflight's skipped web-job ancestors on + # macOS-only diffs and skip this job entirely. + if: ${{ !cancelled() && needs.linux-preflight.result == 'success' && needs.changes.outputs.macos == 'true' }} name: app-host unit tests (${{ matrix.shard }}/4) # App-host XCTest needs a runner that can broker testmanagerd control # sessions. Validated head-to-head that warp-macos-15-arm64-6x runs the @@ -772,7 +775,10 @@ jobs: needs: - changes - linux-preflight - if: ${{ needs.changes.outputs.macos == 'true' }} + # !cancelled() opts out of implicit success(), which would otherwise + # propagate the skip from linux-preflight's skipped web-job ancestors on + # macOS-only diffs and skip this job entirely. + if: ${{ !cancelled() && needs.linux-preflight.result == 'success' && needs.changes.outputs.macos == 'true' }} runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }} timeout-minutes: 40 env: @@ -1076,7 +1082,10 @@ jobs: needs: - changes - linux-preflight - if: ${{ needs.changes.outputs.macos == 'true' }} + # !cancelled() opts out of implicit success(), which would otherwise + # propagate the skip from linux-preflight's skipped web-job ancestors on + # macOS-only diffs and skip this job entirely. + if: ${{ !cancelled() && needs.linux-preflight.result == 'success' && needs.changes.outputs.macos == 'true' }} # Build the full cmux scheme once, then run the required display/runtime # regressions from the same DerivedData instead of queuing a second display # runner for UI-only checks. @@ -1412,7 +1421,8 @@ jobs: - changes - linux-preflight - swift-package-tests - if: ${{ needs.changes.outputs.macos == 'true' }} + # See app-host-unit-tests: skipped web-job ancestors must not skip this job. + if: ${{ !cancelled() && needs.linux-preflight.result == 'success' && needs.swift-package-tests.result == 'success' && needs.changes.outputs.macos == 'true' }} # Compile the same unsigned universal Release app that nightly builds before # signing, notarization, and publishing. This catches DEBUG/Release boundary # mistakes before they reach main. From 74be6ba079e5c22a37929b8b4c9bd7c043708dae Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 02:01:02 -0700 Subject: [PATCH 14/35] ci: align staged macOS job gates with upstream fix and update guard test Adopt the exact conditions from ci-fix-macos-staged-skip (PR #7620) so the staged macOS jobs survive skipped routed linux ancestors, and teach tests/test_ci_change_areas.py the new explicit direct-needs gate (it asserted the old literal if-string, which also fails PR #7620 as pushed). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 36 +++++++++++++++++++++-------------- tests/test_ci_change_areas.py | 8 +++++++- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49f7356b20ba..b7b6b762386b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -388,10 +388,12 @@ jobs: needs: - changes - linux-preflight - # !cancelled() opts out of implicit success(), which would otherwise - # propagate the skip from linux-preflight's skipped web-job ancestors on - # macOS-only diffs and skip this job entirely. - if: ${{ !cancelled() && needs.linux-preflight.result == 'success' && needs.changes.outputs.macos == 'true' }} + # !cancelled() disables the implicit success() gate, which GitHub evaluates + # over the transitive needs chain: linux-preflight runs behind routed linux + # jobs that legitimately skip (web/go/agent-session paths), and that + # transitive skip otherwise marks every macOS job skipped even when + # linux-preflight itself succeeds. Require the direct needs explicitly. + if: ${{ !cancelled() && needs.changes.result == 'success' && needs.linux-preflight.result == 'success' && needs.changes.outputs.macos == 'true' }} name: app-host unit tests (${{ matrix.shard }}/4) # App-host XCTest needs a runner that can broker testmanagerd control # sessions. Validated head-to-head that warp-macos-15-arm64-6x runs the @@ -775,10 +777,12 @@ jobs: needs: - changes - linux-preflight - # !cancelled() opts out of implicit success(), which would otherwise - # propagate the skip from linux-preflight's skipped web-job ancestors on - # macOS-only diffs and skip this job entirely. - if: ${{ !cancelled() && needs.linux-preflight.result == 'success' && needs.changes.outputs.macos == 'true' }} + # !cancelled() disables the implicit success() gate, which GitHub evaluates + # over the transitive needs chain: linux-preflight runs behind routed linux + # jobs that legitimately skip (web/go/agent-session paths), and that + # transitive skip otherwise marks every macOS job skipped even when + # linux-preflight itself succeeds. Require the direct needs explicitly. + if: ${{ !cancelled() && needs.changes.result == 'success' && needs.linux-preflight.result == 'success' && needs.changes.outputs.macos == 'true' }} runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }} timeout-minutes: 40 env: @@ -1082,10 +1086,12 @@ jobs: needs: - changes - linux-preflight - # !cancelled() opts out of implicit success(), which would otherwise - # propagate the skip from linux-preflight's skipped web-job ancestors on - # macOS-only diffs and skip this job entirely. - if: ${{ !cancelled() && needs.linux-preflight.result == 'success' && needs.changes.outputs.macos == 'true' }} + # !cancelled() disables the implicit success() gate, which GitHub evaluates + # over the transitive needs chain: linux-preflight runs behind routed linux + # jobs that legitimately skip (web/go/agent-session paths), and that + # transitive skip otherwise marks every macOS job skipped even when + # linux-preflight itself succeeds. Require the direct needs explicitly. + if: ${{ !cancelled() && needs.changes.result == 'success' && needs.linux-preflight.result == 'success' && needs.changes.outputs.macos == 'true' }} # Build the full cmux scheme once, then run the required display/runtime # regressions from the same DerivedData instead of queuing a second display # runner for UI-only checks. @@ -1421,8 +1427,10 @@ jobs: - changes - linux-preflight - swift-package-tests - # See app-host-unit-tests: skipped web-job ancestors must not skip this job. - if: ${{ !cancelled() && needs.linux-preflight.result == 'success' && needs.swift-package-tests.result == 'success' && needs.changes.outputs.macos == 'true' }} + # See app-host-unit-tests: explicit direct-needs gate instead of the + # implicit success() so skipped routed linux jobs upstream of + # linux-preflight do not skip this job transitively. + if: ${{ !cancelled() && needs.changes.result == 'success' && needs.linux-preflight.result == 'success' && needs.swift-package-tests.result == 'success' && needs.changes.outputs.macos == 'true' }} # Compile the same unsigned universal Release app that nightly builds before # signing, notarization, and publishing. This catches DEBUG/Release boundary # mistakes before they reach main. diff --git a/tests/test_ci_change_areas.py b/tests/test_ci_change_areas.py index b11e518bc847..20c62223273d 100644 --- a/tests/test_ci_change_areas.py +++ b/tests/test_ci_change_areas.py @@ -500,7 +500,13 @@ def test_macos_jobs_wait_for_linux_preflight() -> None: block = workflow_job_block(job_name) assert " - changes" in block assert " - linux-preflight" in block - assert "if: ${{ needs.changes.outputs.macos == 'true' }}" in block + # The explicit direct-needs gate replaces the implicit success() so + # routed linux jobs that legitimately skip upstream of linux-preflight + # cannot transitively skip the staged macOS jobs. + assert "!cancelled()" in block + assert "needs.changes.result == 'success'" in block + assert "needs.linux-preflight.result == 'success'" in block + assert "needs.changes.outputs.macos == 'true'" in block def test_linux_preflight_blocks_macos_on_cheap_layer_failure() -> None: From 2555004f8f32ca27fb72cde3db02fc1537d9d3f4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 02:04:35 -0700 Subject: [PATCH 15/35] Keep restore-stall detection armed after an about:blank commit A restore navigation that dead-ends in WebKit's about:blank placeholder set hasCommittedDocumentSinceWebViewReplacement, which disabled the stall detector while the discard manager stayed pending, wedging the pane in restore bookkeeping. Only real document commits (including error pages) set the flag now, so the next restore touch detects the stall, clears the pending state, and retries. Co-Authored-By: Claude Fable 5 --- Sources/Panels/BrowserPanel.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 474b4dd10a23..faba42f2b7ba 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3812,7 +3812,12 @@ final class BrowserPanel: Panel, ObservableObject { MainActor.assumeIsolated { guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } self.isMainFrameProvisionalNavigationActive = false - self.hasCommittedDocumentSinceWebViewReplacement = true + // An about:blank commit is WebKit's placeholder document, not + // content; leaving the flag false keeps the restore-stall + // detector armed so a restore that dead-ends there retries. + if !Self.isAboutBlankURL(webView.url) { + self.hasCommittedDocumentSinceWebViewReplacement = true + } // Reset playback tracking only once the new top-level document has // actually replaced the old one. Resetting earlier (on provisional // start) would drop a still-playing page's frames if the From 6c2596900ae52a2369e996ab65ff27ec22c52d0c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 02:22:55 -0700 Subject: [PATCH 16/35] Let explicit reloads restart pending restores; attribute restore callbacks Two review findings on the pending-restore state: - restoreIfNeeded deduplicated while a restore navigation was pending, so an explicit reload/hard-reload during an in-flight restore was swallowed as handled. Add a force flag that clears the pending bit and restarts the restore; reload paths pass it. - WebKit can deliver an older provisional load's failure/cancellation after a newer attempt already started; the shared callbacks then cleared the pending bit for the active attempt, letting a visibility touch hijack the in-flight navigation with a restore reload. Track the WKNavigation returned by the restore load and only clear pending state for callbacks that match it. Co-Authored-By: Claude Fable 5 --- .../Panels/BrowserDiscardRestoreHeal.swift | 14 +++++++++++ .../BrowserHiddenWebViewDiscardManager.swift | 9 +++++-- .../Panels/BrowserNavigationDelegate.swift | 12 ++++----- Sources/Panels/BrowserPanel.swift | 25 +++++++++++++------ ...serDiscardedWebViewRestoreRetryTests.swift | 20 +++++++++++++++ 5 files changed, 64 insertions(+), 16 deletions(-) diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift index f4e2355500e5..16dc6a145447 100644 --- a/Sources/Panels/BrowserDiscardRestoreHeal.swift +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -23,14 +23,28 @@ extension BrowserPanel { guard hiddenWebViewDiscardManager.noteRestoreNavigationCommitted(reason: reason) else { return } + pendingDiscardRestoreNavigation = nil refreshWebViewLifecycleState() } func noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: String) { hiddenWebViewDiscardManager.noteRestoreNavigationDidNotCommit(reason: reason) + pendingDiscardRestoreNavigation = nil refreshWebViewLifecycleState() } + /// Whether a WebKit failure/cancel callback belongs to the navigation the + /// discard-restore bookkeeping is tracking. WebKit can deliver an older + /// provisional load's cancellation after a newer attempt has already + /// started; clearing the pending state for that stale callback would let a + /// visibility touch hijack the in-flight navigation with a restore reload. + /// A nil callback navigation or no tracked navigation matches conservatively. + func isDiscardRestoreBookkeepingNavigation(_ navigation: WKNavigation?) -> Bool { + guard let tracked = pendingDiscardRestoreNavigation else { return true } + guard let navigation else { return true } + return navigation === tracked + } + /// Restore fallback for a discarded pane with no restorable document (nil /// or about:blank restore URL): navigating would wait on a commit that /// ``shouldTreatCommitAsDiscardedRestoreCommit(from:)`` ignores, leaving the diff --git a/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift b/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift index f59fc6f6661c..2d547ad125ce 100644 --- a/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift +++ b/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift @@ -247,10 +247,15 @@ final class BrowserHiddenWebViewDiscardManager { } @discardableResult - func restoreIfNeeded(reason: String, performRestore: () -> Void) -> Bool { + func restoreIfNeeded(reason: String, force: Bool = false, performRestore: () -> Void) -> Bool { guard isDiscardedForMemory else { return false } cancel() - if isRestoreNavigationPending { return true } + if isRestoreNavigationPending { + // An explicit user reload restarts an in-flight restore instead of + // being swallowed by the pending dedup. + guard force else { return true } + isRestoreNavigationPending = false + } lastRestoreReason = reason updateRestoredSessionRenderIntent(nil) performRestore() diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index 1778ae6e9fa2..f4fd5f1ba1aa 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -8,8 +8,8 @@ import WebKit var didStartProvisionalNavigation: ((WKWebView) -> Void)? var didCommit: ((WKWebView) -> Void)? var didFinish: ((WKWebView) -> Void)? - var didFailNavigation: ((WKWebView, String) -> Void)? - var didCancelProvisionalNavigation: ((WKWebView) -> Void)? + var didFailNavigation: ((WKWebView, String, WKNavigation?) -> Void)? + var didCancelProvisionalNavigation: ((WKWebView, WKNavigation?) -> Void)? var didBecomeDownload: ((WKWebView, Bool) -> Void)? var didTerminateWebContentProcess: ((WKWebView) -> Void)? var openInNewTab: ((URL) -> Void)? @@ -111,7 +111,7 @@ import WebKit // Treat committed-navigation failures the same as provisional ones so // stale favicon/title state from the prior page gets cleared. let failedURL = webView.url?.absoluteString ?? "" - didFailNavigation?(webView, failedURL) + didFailNavigation?(webView, failedURL, navigation) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { @@ -120,7 +120,7 @@ import WebKit // Cancelled navigations (e.g. rapid typing) are not real errors. if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { - didCancelProvisionalNavigation?(webView) + didCancelProvisionalNavigation?(webView, navigation) return } @@ -128,14 +128,14 @@ import WebKit // navigation response is converted into a download via .download policy. // This is expected and should not show an error page. if nsError.domain == "WebKitErrorDomain", nsError.code == 102 { - didCancelProvisionalNavigation?(webView) + didCancelProvisionalNavigation?(webView, navigation) return } let failedURL = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String ?? lastAttemptedURL?.absoluteString ?? "" - didFailNavigation?(webView, failedURL) + didFailNavigation?(webView, failedURL, navigation) loadErrorPage( in: webView, failedURL: failedURL, diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index faba42f2b7ba..3595f2469154 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2797,6 +2797,7 @@ final class BrowserPanel: Panel, ObservableObject { @Published private(set) var backgroundAppearanceRevision: UInt64 = 0 let hiddenWebViewDiscardManager = BrowserHiddenWebViewDiscardManager() var hasCommittedDocumentSinceWebViewReplacement = false + weak var pendingDiscardRestoreNavigation: WKNavigation? @Published private(set) var webViewLifecycleState: BrowserWebViewLifecycleState = .newTab private(set) var webViewLastVisibleAt: Date? @@ -3352,7 +3353,8 @@ final class BrowserPanel: Panel, ObservableObject { func restoreDiscardedWebViewIfNeeded( reason: String, cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy, - allowBlankShellHeal: Bool = true + allowBlankShellHeal: Bool = true, + forceRestartPendingRestore: Bool = false ) -> Bool { if Self.isRestoreStalled( isRestoreNavigationPending: hiddenWebViewDiscardManager.isRestoreNavigationPending, @@ -3370,7 +3372,7 @@ final class BrowserPanel: Panel, ObservableObject { return reactivateDiscardedPaneWithoutRestorableURL(reason: reason) } - if hiddenWebViewDiscardManager.restoreIfNeeded(reason: reason, performRestore: { + if hiddenWebViewDiscardManager.restoreIfNeeded(reason: reason, force: forceRestartPendingRestore, performRestore: { shouldRenderWebView = true navigateWithoutInsecureHTTPPrompt( to: restoreURL, @@ -3847,7 +3849,7 @@ final class BrowserPanel: Panel, ObservableObject { self.restoreFindStateAfterNavigation(replaySearch: true) } } - navigationDelegate.didFailNavigation = { [weak self] failedWebView, failedURL in + navigationDelegate.didFailNavigation = { [weak self] failedWebView, failedURL, failedNavigation in MainActor.assumeIsolated { guard let self, self.isCurrentWebView(failedWebView, instanceID: boundWebViewInstanceID) else { return } self.isMainFrameProvisionalNavigationActive = false @@ -3860,18 +3862,22 @@ final class BrowserPanel: Panel, ObservableObject { self.faviconPNGData = nil self.lastFaviconURLString = nil self.applyMuteState(to: failedWebView, reason: "navigationFail") - self.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_failed") + if self.isDiscardRestoreBookkeepingNavigation(failedNavigation) { + self.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_failed") + } // Keep find-in-page open and clear stale counters on failed loads. self.restoreFindStateAfterNavigation(replaySearch: false) } } - navigationDelegate.didCancelProvisionalNavigation = { [weak self] webView in + navigationDelegate.didCancelProvisionalNavigation = { [weak self] webView, cancelledNavigation in MainActor.assumeIsolated { guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } self.isMainFrameProvisionalNavigationActive = false self.navigationDelegate?.clearAttemptedRequest() self.refreshBackgroundAppearance() - self.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_cancelled") + if self.isDiscardRestoreBookkeepingNavigation(cancelledNavigation) { + self.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_cancelled") + } } } navigationDelegate.didBecomeDownload = { [weak self] webView, isMainFrame in @@ -5804,8 +5810,11 @@ final class BrowserPanel: Panel, ObservableObject { historyStore.recordTypedNavigation(url: originalURL) } noteDiscardedWebViewRestoreNavigationStarted() - if browserLoadRequest(effectiveRequest, in: webView) == nil { + let startedNavigation = browserLoadRequest(effectiveRequest, in: webView) + if startedNavigation == nil { noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_not_started") + } else if hiddenWebViewDiscardManager.isDiscardedForMemory { + pendingDiscardRestoreNavigation = startedNavigation } } @@ -6405,7 +6414,7 @@ extension BrowserPanel { if recoverTerminatedWebContent(reason: reason, cachePolicy: mode.recoveryCachePolicy) { return true } - if restoreDiscardedWebViewIfNeeded(reason: reason, cachePolicy: mode.recoveryCachePolicy) { + if restoreDiscardedWebViewIfNeeded(reason: reason, cachePolicy: mode.recoveryCachePolicy, forceRestartPendingRestore: true) { return true } webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index 91350d3ab89d..625e773dbd24 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -228,6 +228,26 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { } } + @Test func explicitReloadForcesRestartOfPendingRestore() { + withBrowserDiscardRestoreRetryPolicyEnabled { defaults in + let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) + manager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 900)) + + var restoreCount = 0 + #expect(manager.restoreIfNeeded(reason: "test.restore") { restoreCount += 1 }) + manager.noteRestoreNavigationStarted(reason: "test.navigation") + + // A plain restore touch is deduplicated while a restore is pending… + #expect(manager.restoreIfNeeded(reason: "test.touch") { restoreCount += 1 }) + #expect(restoreCount == 1) + + // …but an explicit reload restarts the pending restore. + #expect(manager.restoreIfNeeded(reason: "test.reload", force: true) { restoreCount += 1 }) + #expect(restoreCount == 2) + #expect(manager.isDiscardedForMemory) + } + } + @Test func markDiscardedResetsStalePendingRestoreNavigation() { withBrowserDiscardRestoreRetryPolicyEnabled { defaults in let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) From 09b0dac9db9e3fb0cea4b10bd8b347af0a4624a4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 02:36:39 -0700 Subject: [PATCH 17/35] Never blank-shell-heal over an explicit user Stop Stopping a pre-commit load left the heal predicate satisfied (rendered, idle, no committed document, non-blank intent URL), so the next reveal silently restarted the stopped navigation. Track an explicit-stop flag per webview replacement, clear it when a new navigation starts, and fail the heal predicate closed while it is set; covered in the predicate test. Co-Authored-By: Claude Fable 5 --- .../Panels/BrowserDiscardRestoreHeal.swift | 4 ++++ Sources/Panels/BrowserPanel.swift | 13 ++++++++---- ...serDiscardedWebViewRestoreRetryTests.swift | 21 +++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift index 16dc6a145447..0ecc8180741c 100644 --- a/Sources/Panels/BrowserDiscardRestoreHeal.swift +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -95,6 +95,7 @@ extension BrowserPanel { hasCommittedDocument: Bool, isNavigationBlockedPendingConsent: Bool, hasRecoverableWebContentTermination: Bool, + userStoppedLoad: Bool, intentURL: URL? ) -> Bool { guard shouldRenderWebView else { return false } @@ -107,6 +108,9 @@ extension BrowserPanel { // A crashed WebContent process waits for the user's explicit Reload; // auto-healing here would bypass that gate and can re-enter the crash. guard !hasRecoverableWebContentTermination else { return false } + // A load the user explicitly stopped before first commit must stay + // stopped; healing on reveal would silently undo the Stop. + guard !userStoppedLoad else { return false } guard let intentURL else { return false } return !isAboutBlankURL(intentURL) } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 3595f2469154..52e37ef5ce91 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2797,6 +2797,7 @@ final class BrowserPanel: Panel, ObservableObject { @Published private(set) var backgroundAppearanceRevision: UInt64 = 0 let hiddenWebViewDiscardManager = BrowserHiddenWebViewDiscardManager() var hasCommittedDocumentSinceWebViewReplacement = false + var userStoppedLoadSinceWebViewReplacement = false weak var pendingDiscardRestoreNavigation: WKNavigation? @Published private(set) var webViewLifecycleState: BrowserWebViewLifecycleState = .newTab @@ -3318,7 +3319,7 @@ final class BrowserPanel: Panel, ObservableObject { ) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() - hasCommittedDocumentSinceWebViewReplacement = false + hasCommittedDocumentSinceWebViewReplacement = false; userStoppedLoadSinceWebViewReplacement = false webView = replacement hiddenWebViewDiscardManager.markDiscarded(reason: reason, now: now) currentURL = restoreURL @@ -3404,6 +3405,7 @@ final class BrowserPanel: Panel, ObservableObject { hasCommittedDocument: hasCommittedDocumentSinceWebViewReplacement, isNavigationBlockedPendingConsent: isNavigationBlockedPendingConsent, hasRecoverableWebContentTermination: hasRecoverableWebContentTermination, + userStoppedLoad: userStoppedLoadSinceWebViewReplacement, intentURL: intentURL ) else { return false @@ -4658,7 +4660,7 @@ final class BrowserPanel: Panel, ObservableObject { ) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() - hasCommittedDocumentSinceWebViewReplacement = false + hasCommittedDocumentSinceWebViewReplacement = false; userStoppedLoadSinceWebViewReplacement = false resetWebViewLifecycleMetadata(resetVisibility: false) webView = replacement currentURL = restoreURL @@ -5167,7 +5169,7 @@ final class BrowserPanel: Panel, ObservableObject { ) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() - hasCommittedDocumentSinceWebViewReplacement = false + hasCommittedDocumentSinceWebViewReplacement = false; userStoppedLoadSinceWebViewReplacement = false resetWebViewLifecycleMetadata(resetVisibility: false) webView = replacement shouldRenderWebView = wasRenderable @@ -5810,6 +5812,7 @@ final class BrowserPanel: Panel, ObservableObject { historyStore.recordTypedNavigation(url: originalURL) } noteDiscardedWebViewRestoreNavigationStarted() + userStoppedLoadSinceWebViewReplacement = false let startedNavigation = browserLoadRequest(effectiveRequest, in: webView) if startedNavigation == nil { noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_not_started") @@ -6164,7 +6167,7 @@ extension BrowserPanel { websiteDataStore: websiteDataStore ) webViewInstanceID = UUID() - hasCommittedDocumentSinceWebViewReplacement = false + hasCommittedDocumentSinceWebViewReplacement = false; userStoppedLoadSinceWebViewReplacement = false webView = replacement shouldRenderWebView = false refreshWebViewLifecycleState() @@ -6454,6 +6457,8 @@ extension BrowserPanel { /// Stop loading func stopLoading() { + // Fail closed: a reveal must never blank-shell-heal over an explicit Stop. + userStoppedLoadSinceWebViewReplacement = true webView.stopLoading() isMainFrameProvisionalNavigationActive = false } diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index 625e773dbd24..cfed648d2e3c 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -305,6 +305,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { hasCommittedDocument: false, isNavigationBlockedPendingConsent: false, hasRecoverableWebContentTermination: false, + userStoppedLoad: false, intentURL: intentURL )) #expect(!BrowserPanel.shouldHealBlankShell( @@ -316,6 +317,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { hasCommittedDocument: true, isNavigationBlockedPendingConsent: false, hasRecoverableWebContentTermination: false, + userStoppedLoad: false, intentURL: intentURL )) #expect(!BrowserPanel.shouldHealBlankShell( @@ -327,6 +329,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { hasCommittedDocument: false, isNavigationBlockedPendingConsent: false, hasRecoverableWebContentTermination: false, + userStoppedLoad: false, intentURL: aboutBlankURL )) #expect(!BrowserPanel.shouldHealBlankShell( @@ -338,6 +341,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { hasCommittedDocument: false, isNavigationBlockedPendingConsent: false, hasRecoverableWebContentTermination: false, + userStoppedLoad: false, intentURL: intentURL )) #expect(!BrowserPanel.shouldHealBlankShell( @@ -349,6 +353,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { hasCommittedDocument: false, isNavigationBlockedPendingConsent: false, hasRecoverableWebContentTermination: false, + userStoppedLoad: false, intentURL: nil )) #expect(!BrowserPanel.shouldHealBlankShell( @@ -360,6 +365,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { hasCommittedDocument: false, isNavigationBlockedPendingConsent: true, hasRecoverableWebContentTermination: false, + userStoppedLoad: false, intentURL: intentURL )) // A crashed WebContent process must wait for the user's explicit @@ -373,6 +379,21 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { hasCommittedDocument: false, isNavigationBlockedPendingConsent: false, hasRecoverableWebContentTermination: true, + userStoppedLoad: false, + intentURL: intentURL + )) + // A load the user explicitly stopped before first commit must stay + // stopped; a reveal never heals over the Stop. + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, + userStoppedLoad: true, intentURL: intentURL )) From 8012061de4967948ba9f06c03703c69428199296 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 02:53:01 -0700 Subject: [PATCH 18/35] Make explicit Stop sticky for discarded restores; move restore flow to heal file A user Stop during a discarded-webview restore left the manager discarded, so the next visibility touch restarted the stopped load through restoreIfNeeded. Honor the explicit-stop flag in the restore touch as well, with explicit reload (forceRestartPendingRestore) as the override that clears it. Move restoreDiscardedWebViewIfNeeded and healBlankRestoredWebViewIfNeeded next to the rest of the discard-restore logic in BrowserDiscardRestoreHeal, widening the members they use, so BrowserPanel.swift stays under its no-growth cap. Co-Authored-By: Claude Fable 5 --- .../Panels/BrowserDiscardRestoreHeal.swift | 82 +++++++++++++++++++ Sources/Panels/BrowserPanel.swift | 80 ++---------------- 2 files changed, 88 insertions(+), 74 deletions(-) diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift index 0ecc8180741c..c9c915ca1714 100644 --- a/Sources/Panels/BrowserDiscardRestoreHeal.swift +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -45,6 +45,88 @@ extension BrowserPanel { return navigation === tracked } + /// Restore touch for a possibly-discarded pane: detects stalled restore + /// attempts, honors an explicit user Stop, restores through the discard + /// manager, and falls back to blank-shell healing. + @discardableResult + func restoreDiscardedWebViewIfNeeded( + reason: String, + cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy, + allowBlankShellHeal: Bool = true, + forceRestartPendingRestore: Bool = false + ) -> Bool { + if Self.isRestoreStalled( + isRestoreNavigationPending: hiddenWebViewDiscardManager.isRestoreNavigationPending, + isWebViewLoading: webView.isLoading, + isMainFrameProvisionalNavigationActive: isMainFrameProvisionalNavigationActive, + hasPendingRemoteNavigation: hasPendingRemoteNavigation, + hasCommittedDocument: hasCommittedDocumentSinceWebViewReplacement + ) { + hiddenWebViewDiscardManager.noteRestoreNavigationDidNotCommit(reason: "\(reason).stalled") + refreshWebViewLifecycleState() + } + + if forceRestartPendingRestore { + userStoppedLoadSinceWebViewReplacement = false + } + // Stop is sticky for discarded restores too: routine visibility touches + // must not restart a stopped load; explicit reload is the override. + guard !userStoppedLoadSinceWebViewReplacement else { return false } + + let restoreURL = restoredHistoryCurrentURL ?? currentURL + guard let restoreURL, !Self.isAboutBlankURL(restoreURL) else { + return reactivateDiscardedPaneWithoutRestorableURL(reason: reason) + } + + if hiddenWebViewDiscardManager.restoreIfNeeded(reason: reason, force: forceRestartPendingRestore, performRestore: { + shouldRenderWebView = true + navigateWithoutInsecureHTTPPrompt( + to: restoreURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true, + cachePolicy: cachePolicy + ) + }) { + return true + } + + guard allowBlankShellHeal else { return false } + return healBlankRestoredWebViewIfNeeded(reason: reason, cachePolicy: cachePolicy) + } + + /// Re-navigates a rendered-but-empty web view (for example after a failed + /// discard restore whose state was already consumed) back to its intent URL. + @discardableResult + private func healBlankRestoredWebViewIfNeeded( + reason _: String, + cachePolicy: URLRequest.CachePolicy + ) -> Bool { + let intentURL = restoredHistoryCurrentURL ?? currentURL + let isNavigationBlockedPendingConsent = intentURL.map { browserShouldBlockInsecureHTTPURL($0) } ?? false + guard Self.shouldHealBlankShell( + shouldRenderWebView: shouldRenderWebView, + isClosing: isClosingWebViewLifecycle, + hasPendingRemoteNavigation: hasPendingRemoteNavigation, + isWebViewLoading: webView.isLoading, + isMainFrameProvisionalNavigationActive: isMainFrameProvisionalNavigationActive, + hasCommittedDocument: hasCommittedDocumentSinceWebViewReplacement, + isNavigationBlockedPendingConsent: isNavigationBlockedPendingConsent, + hasRecoverableWebContentTermination: hasRecoverableWebContentTermination, + userStoppedLoad: userStoppedLoadSinceWebViewReplacement, + intentURL: intentURL + ) else { + return false + } + guard let intentURL else { return false } + navigateWithoutInsecureHTTPPrompt( + to: intentURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true, + cachePolicy: cachePolicy + ) + return true + } + /// Restore fallback for a discarded pane with no restorable document (nil /// or about:blank restore URL): navigating would wait on a commit that /// ``shouldTreatCommitAsDiscardedRestoreCommit(from:)`` ignores, leaving the diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 52e37ef5ce91..d02d1d737172 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2786,7 +2786,7 @@ final class BrowserPanel: Panel, ObservableObject { /// Whether the browser panel should render its WKWebView in the content area. /// New browser tabs stay in an empty "new tab" state until first navigation. - @Published private(set) var shouldRenderWebView: Bool = false { + @Published var shouldRenderWebView: Bool = false { didSet { if oldValue != shouldRenderWebView { refreshWebViewLifecycleState() @@ -2819,7 +2819,7 @@ final class BrowserPanel: Panel, ObservableObject { private var pendingInteractiveBrowserPrompts: [PendingInteractiveBrowserPrompt] = [] private var isPresentingPendingInteractiveBrowserPrompt = false private var isWebViewVisibleInUI: Bool = false - private var isClosingWebViewLifecycle: Bool = false + var isClosingWebViewLifecycle: Bool = false /// True while a canvas pane hosts this browser's webview inline (in the /// pane's own hierarchy). Portal-side reconcilers must not rebind or @@ -2887,10 +2887,10 @@ final class BrowserPanel: Panel, ObservableObject { private var usesRestoredSessionHistory: Bool { restoredSessionHistory.usesRestoredSessionHistory } - private var restoredHistoryCurrentURL: URL? { + var restoredHistoryCurrentURL: URL? { restoredSessionHistory.current } - private var isMainFrameProvisionalNavigationActive: Bool = false + var isMainFrameProvisionalNavigationActive: Bool = false /// Published estimated progress (0.0 - 1.0) @Published private(set) var estimatedProgress: Double = 0.0 @@ -3350,75 +3350,7 @@ final class BrowserPanel: Panel, ObservableObject { hiddenWebViewDiscardManager.requestImmediateDiscardIfSafe(reason: "system_memory_pressure", now: now) } - @discardableResult - func restoreDiscardedWebViewIfNeeded( - reason: String, - cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy, - allowBlankShellHeal: Bool = true, - forceRestartPendingRestore: Bool = false - ) -> Bool { - if Self.isRestoreStalled( - isRestoreNavigationPending: hiddenWebViewDiscardManager.isRestoreNavigationPending, - isWebViewLoading: webView.isLoading, - isMainFrameProvisionalNavigationActive: isMainFrameProvisionalNavigationActive, - hasPendingRemoteNavigation: pendingRemoteNavigation != nil, - hasCommittedDocument: hasCommittedDocumentSinceWebViewReplacement - ) { - hiddenWebViewDiscardManager.noteRestoreNavigationDidNotCommit(reason: "\(reason).stalled") - refreshWebViewLifecycleState() - } - - let restoreURL = restoredHistoryCurrentURL ?? currentURL - guard let restoreURL, !Self.isAboutBlankURL(restoreURL) else { - return reactivateDiscardedPaneWithoutRestorableURL(reason: reason) - } - - if hiddenWebViewDiscardManager.restoreIfNeeded(reason: reason, force: forceRestartPendingRestore, performRestore: { - shouldRenderWebView = true - navigateWithoutInsecureHTTPPrompt( - to: restoreURL, - recordTypedNavigation: false, - preserveRestoredSessionHistory: true, - cachePolicy: cachePolicy - ) - }) { - return true - } - - guard allowBlankShellHeal else { return false } - return healBlankRestoredWebViewIfNeeded(reason: reason, cachePolicy: cachePolicy) - } - - @discardableResult - private func healBlankRestoredWebViewIfNeeded( - reason _: String, - cachePolicy: URLRequest.CachePolicy - ) -> Bool { - let intentURL = restoredHistoryCurrentURL ?? currentURL - let isNavigationBlockedPendingConsent = intentURL.map { browserShouldBlockInsecureHTTPURL($0) } ?? false - guard Self.shouldHealBlankShell( - shouldRenderWebView: shouldRenderWebView, - isClosing: isClosingWebViewLifecycle, - hasPendingRemoteNavigation: pendingRemoteNavigation != nil, - isWebViewLoading: webView.isLoading, - isMainFrameProvisionalNavigationActive: isMainFrameProvisionalNavigationActive, - hasCommittedDocument: hasCommittedDocumentSinceWebViewReplacement, - isNavigationBlockedPendingConsent: isNavigationBlockedPendingConsent, - hasRecoverableWebContentTermination: hasRecoverableWebContentTermination, - userStoppedLoad: userStoppedLoadSinceWebViewReplacement, - intentURL: intentURL - ) else { - return false - } - guard let intentURL else { return false } - navigateWithoutInsecureHTTPPrompt( - to: intentURL, - recordTypedNavigation: false, - preserveRestoredSessionHistory: true, - cachePolicy: cachePolicy - ) - return true - } + var hasPendingRemoteNavigation: Bool { pendingRemoteNavigation != nil } @discardableResult func reactivateDiscardedWebViewWithoutNavigation(reason: String) -> Bool { @@ -5723,7 +5655,7 @@ final class BrowserPanel: Panel, ObservableObject { navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation) } - private func navigateWithoutInsecureHTTPPrompt( + func navigateWithoutInsecureHTTPPrompt( to url: URL, recordTypedNavigation: Bool, preserveRestoredSessionHistory: Bool = false, From 32ed2aa55b49d83fcbef3b32866f565d12f500af Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 03:04:50 -0700 Subject: [PATCH 19/35] Never blank-shell-heal over the browser error page The error page commits as about:blank (baseURL nil), so the commit gate left it looking uncommitted and healing re-requested the failed URL on the next reveal. Treat an active error page as content awaiting the user's Reload in shouldHealBlankShell. The discarded-restore retry path is unaffected (it flows through the manager, not healing). Split the pure predicate coverage into BrowserDiscardRestoreHealPredicateTests (wired into the Xcode project) so the retry test file stays under the 500-line tracking threshold. Co-Authored-By: Claude Fable 5 --- .../Panels/BrowserDiscardRestoreHeal.swift | 5 + cmux.xcodeproj/project.pbxproj | 4 + ...wserDiscardRestoreHealPredicateTests.swift | 180 ++++++++++++++++++ ...serDiscardedWebViewRestoreRetryTests.swift | 141 -------------- 4 files changed, 189 insertions(+), 141 deletions(-) create mode 100644 cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift index c9c915ca1714..cd9d7e62c1ae 100644 --- a/Sources/Panels/BrowserDiscardRestoreHeal.swift +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -113,6 +113,7 @@ extension BrowserPanel { isNavigationBlockedPendingConsent: isNavigationBlockedPendingConsent, hasRecoverableWebContentTermination: hasRecoverableWebContentTermination, userStoppedLoad: userStoppedLoadSinceWebViewReplacement, + isShowingErrorPage: navigationDelegate?.activeErrorPageDisplayURL != nil, intentURL: intentURL ) else { return false @@ -178,6 +179,7 @@ extension BrowserPanel { isNavigationBlockedPendingConsent: Bool, hasRecoverableWebContentTermination: Bool, userStoppedLoad: Bool, + isShowingErrorPage: Bool, intentURL: URL? ) -> Bool { guard shouldRenderWebView else { return false } @@ -193,6 +195,9 @@ extension BrowserPanel { // A load the user explicitly stopped before first commit must stay // stopped; healing on reveal would silently undo the Stop. guard !userStoppedLoad else { return false } + // The browser's own error page commits as about:blank; it is content + // awaiting the user's Reload, not a blank shell to heal over. + guard !isShowingErrorPage else { return false } guard let intentURL else { return false } return !isAboutBlankURL(intentURL) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 586d9ff9de28..5adaa565f690 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -147,6 +147,7 @@ E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */; }; B75040020000000000000001 /* BrowserDiscardedWebViewRestoreRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75040020000000000000002 /* BrowserDiscardedWebViewRestoreRetryTests.swift */; }; B75040010000000000000001 /* BrowserDiscardRestoreHeal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75040010000000000000002 /* BrowserDiscardRestoreHeal.swift */; }; + B75040030000000000000001 /* BrowserDiscardRestoreHealPredicateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75040030000000000000002 /* BrowserDiscardRestoreHealPredicateTests.swift */; }; C42660030000000000000001 /* BrowserDownloadDelegate+PDFPreviewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42660030000000000000002 /* BrowserDownloadDelegate+PDFPreviewData.swift */; }; C59240010000000000000001 /* BrowserDownloadFilenameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C59240010000000000000002 /* BrowserDownloadFilenameResolver.swift */; }; C59240010000000000000003 /* BrowserDownloadFilenameResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C59240010000000000000004 /* BrowserDownloadFilenameResolverTests.swift */; }; @@ -1693,6 +1694,7 @@ 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = ""; }; B75040020000000000000002 /* BrowserDiscardedWebViewRestoreRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDiscardedWebViewRestoreRetryTests.swift; sourceTree = ""; }; B75040010000000000000002 /* BrowserDiscardRestoreHeal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDiscardRestoreHeal.swift; sourceTree = ""; }; + B75040030000000000000002 /* BrowserDiscardRestoreHealPredicateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDiscardRestoreHealPredicateTests.swift; sourceTree = ""; }; C42660030000000000000002 /* BrowserDownloadDelegate+PDFPreviewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserDownloadDelegate+PDFPreviewData.swift"; sourceTree = ""; }; C59240010000000000000002 /* BrowserDownloadFilenameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDownloadFilenameResolver.swift; sourceTree = ""; }; C59240010000000000000004 /* BrowserDownloadFilenameResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDownloadFilenameResolverTests.swift; sourceTree = ""; }; @@ -4313,6 +4315,7 @@ B65060010000000000000001 /* BrowserPanelSessionRestoreTests.swift */, B6585002B6585002B6585002 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift */, B75040020000000000000002 /* BrowserDiscardedWebViewRestoreRetryTests.swift */, + B75040030000000000000002 /* BrowserDiscardRestoreHealPredicateTests.swift */, B6585002B6585002B658PW02 /* BrowserPrewarmedWebViewPoolTests.swift */, 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, C42660040000000000000002 /* BrowserPDFPreviewActionRegressionTests.swift */, @@ -6065,6 +6068,7 @@ D7032A030000000000000001 /* BrowserClientCertificateCredentialPickerTests.swift in Sources */, E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */, B75040020000000000000001 /* BrowserDiscardedWebViewRestoreRetryTests.swift in Sources */, + B75040030000000000000001 /* BrowserDiscardRestoreHealPredicateTests.swift in Sources */, C59240010000000000000003 /* BrowserDownloadFilenameResolverTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, B6585001B6585001B6585001 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift in Sources */, diff --git a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift new file mode 100644 index 000000000000..8457dbf6d122 --- /dev/null +++ b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift @@ -0,0 +1,180 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Pure-predicate coverage for the discard-restore heal decision helpers in +/// BrowserDiscardRestoreHeal (blank-shell healing gates and restore-stall +/// detection). Panel-level restore-retry behavior lives in +/// BrowserDiscardedWebViewRestoreRetryTests. +@MainActor +struct BrowserDiscardRestoreHealPredicateTests { + @Test func pureRestoreHealPredicatesCoverBlankShellAndStalledCases() throws { + let intentURL = try #require(URL(string: "http://127.0.0.1:7777/app")) + let aboutBlankURL = try #require(URL(string: "about:blank")) + let mixedCaseAboutBlankURL = try #require(URL(string: "ABOUT:BLANK")) + + #expect(BrowserPanel.isAboutBlankURL(aboutBlankURL)) + #expect(BrowserPanel.isAboutBlankURL(mixedCaseAboutBlankURL)) + #expect(!BrowserPanel.isAboutBlankURL(intentURL)) + #expect(!BrowserPanel.isAboutBlankURL(nil)) + + #expect(BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, + userStoppedLoad: false, + isShowingErrorPage: false, + intentURL: intentURL + )) + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: true, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, + userStoppedLoad: false, + isShowingErrorPage: false, + intentURL: intentURL + )) + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, + userStoppedLoad: false, + isShowingErrorPage: false, + intentURL: aboutBlankURL + )) + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: true, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, + userStoppedLoad: false, + isShowingErrorPage: false, + intentURL: intentURL + )) + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, + userStoppedLoad: false, + isShowingErrorPage: false, + intentURL: nil + )) + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: true, + hasRecoverableWebContentTermination: false, + userStoppedLoad: false, + isShowingErrorPage: false, + intentURL: intentURL + )) + // A crashed WebContent process must wait for the user's explicit + // Reload; blank-shell healing never auto-navigates over that gate. + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: true, + userStoppedLoad: false, + isShowingErrorPage: false, + intentURL: intentURL + )) + // A load the user explicitly stopped before first commit must stay + // stopped; a reveal never heals over the Stop. + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, + userStoppedLoad: true, + isShowingErrorPage: false, + intentURL: intentURL + )) + // The browser's own error page is content awaiting the user's Reload; + // a reveal never heals over it into re-requesting the failed URL. + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, + userStoppedLoad: false, + isShowingErrorPage: true, + intentURL: intentURL + )) + + #expect(BrowserPanel.isRestoreStalled( + isRestoreNavigationPending: true, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasPendingRemoteNavigation: false, + hasCommittedDocument: false + )) + #expect(!BrowserPanel.isRestoreStalled( + isRestoreNavigationPending: true, + isWebViewLoading: true, + isMainFrameProvisionalNavigationActive: false, + hasPendingRemoteNavigation: false, + hasCommittedDocument: false + )) + #expect(!BrowserPanel.isRestoreStalled( + isRestoreNavigationPending: true, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasPendingRemoteNavigation: true, + hasCommittedDocument: false + )) + #expect(!BrowserPanel.isRestoreStalled( + isRestoreNavigationPending: true, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasPendingRemoteNavigation: false, + hasCommittedDocument: true + )) + } + +} diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index cfed648d2e3c..6f538fe022ca 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -286,147 +286,6 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { } } - @Test func pureRestoreHealPredicatesCoverBlankShellAndStalledCases() throws { - let intentURL = try #require(URL(string: "http://127.0.0.1:7777/app")) - let aboutBlankURL = try #require(URL(string: "about:blank")) - let mixedCaseAboutBlankURL = try #require(URL(string: "ABOUT:BLANK")) - - #expect(BrowserPanel.isAboutBlankURL(aboutBlankURL)) - #expect(BrowserPanel.isAboutBlankURL(mixedCaseAboutBlankURL)) - #expect(!BrowserPanel.isAboutBlankURL(intentURL)) - #expect(!BrowserPanel.isAboutBlankURL(nil)) - - #expect(BrowserPanel.shouldHealBlankShell( - shouldRenderWebView: true, - isClosing: false, - hasPendingRemoteNavigation: false, - isWebViewLoading: false, - isMainFrameProvisionalNavigationActive: false, - hasCommittedDocument: false, - isNavigationBlockedPendingConsent: false, - hasRecoverableWebContentTermination: false, - userStoppedLoad: false, - intentURL: intentURL - )) - #expect(!BrowserPanel.shouldHealBlankShell( - shouldRenderWebView: true, - isClosing: false, - hasPendingRemoteNavigation: false, - isWebViewLoading: false, - isMainFrameProvisionalNavigationActive: false, - hasCommittedDocument: true, - isNavigationBlockedPendingConsent: false, - hasRecoverableWebContentTermination: false, - userStoppedLoad: false, - intentURL: intentURL - )) - #expect(!BrowserPanel.shouldHealBlankShell( - shouldRenderWebView: true, - isClosing: false, - hasPendingRemoteNavigation: false, - isWebViewLoading: false, - isMainFrameProvisionalNavigationActive: false, - hasCommittedDocument: false, - isNavigationBlockedPendingConsent: false, - hasRecoverableWebContentTermination: false, - userStoppedLoad: false, - intentURL: aboutBlankURL - )) - #expect(!BrowserPanel.shouldHealBlankShell( - shouldRenderWebView: true, - isClosing: false, - hasPendingRemoteNavigation: true, - isWebViewLoading: false, - isMainFrameProvisionalNavigationActive: false, - hasCommittedDocument: false, - isNavigationBlockedPendingConsent: false, - hasRecoverableWebContentTermination: false, - userStoppedLoad: false, - intentURL: intentURL - )) - #expect(!BrowserPanel.shouldHealBlankShell( - shouldRenderWebView: true, - isClosing: false, - hasPendingRemoteNavigation: false, - isWebViewLoading: false, - isMainFrameProvisionalNavigationActive: false, - hasCommittedDocument: false, - isNavigationBlockedPendingConsent: false, - hasRecoverableWebContentTermination: false, - userStoppedLoad: false, - intentURL: nil - )) - #expect(!BrowserPanel.shouldHealBlankShell( - shouldRenderWebView: true, - isClosing: false, - hasPendingRemoteNavigation: false, - isWebViewLoading: false, - isMainFrameProvisionalNavigationActive: false, - hasCommittedDocument: false, - isNavigationBlockedPendingConsent: true, - hasRecoverableWebContentTermination: false, - userStoppedLoad: false, - intentURL: intentURL - )) - // A crashed WebContent process must wait for the user's explicit - // Reload; blank-shell healing never auto-navigates over that gate. - #expect(!BrowserPanel.shouldHealBlankShell( - shouldRenderWebView: true, - isClosing: false, - hasPendingRemoteNavigation: false, - isWebViewLoading: false, - isMainFrameProvisionalNavigationActive: false, - hasCommittedDocument: false, - isNavigationBlockedPendingConsent: false, - hasRecoverableWebContentTermination: true, - userStoppedLoad: false, - intentURL: intentURL - )) - // A load the user explicitly stopped before first commit must stay - // stopped; a reveal never heals over the Stop. - #expect(!BrowserPanel.shouldHealBlankShell( - shouldRenderWebView: true, - isClosing: false, - hasPendingRemoteNavigation: false, - isWebViewLoading: false, - isMainFrameProvisionalNavigationActive: false, - hasCommittedDocument: false, - isNavigationBlockedPendingConsent: false, - hasRecoverableWebContentTermination: false, - userStoppedLoad: true, - intentURL: intentURL - )) - - #expect(BrowserPanel.isRestoreStalled( - isRestoreNavigationPending: true, - isWebViewLoading: false, - isMainFrameProvisionalNavigationActive: false, - hasPendingRemoteNavigation: false, - hasCommittedDocument: false - )) - #expect(!BrowserPanel.isRestoreStalled( - isRestoreNavigationPending: true, - isWebViewLoading: true, - isMainFrameProvisionalNavigationActive: false, - hasPendingRemoteNavigation: false, - hasCommittedDocument: false - )) - #expect(!BrowserPanel.isRestoreStalled( - isRestoreNavigationPending: true, - isWebViewLoading: false, - isMainFrameProvisionalNavigationActive: false, - hasPendingRemoteNavigation: true, - hasCommittedDocument: false - )) - #expect(!BrowserPanel.isRestoreStalled( - isRestoreNavigationPending: true, - isWebViewLoading: false, - isMainFrameProvisionalNavigationActive: false, - hasPendingRemoteNavigation: false, - hasCommittedDocument: true - )) - } - @Test func mainFrameDownloadCompletesRestoreAndSuppressesBlankShellHeal() throws { let url = try #require(URL(string: "http://127.0.0.1:1/cmux-issue-7504-download")) let discardedAt = Date(timeIntervalSince1970: 700) From cb2851cd79e3c00adcc07e81259592ae673e5031 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 19:46:05 -0700 Subject: [PATCH 20/35] Add queued remote restore regression test --- ...serDiscardedWebViewRestoreRetryTests.swift | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index 6f538fe022ca..938a47442570 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -248,6 +248,42 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { } } + @Test func queuedRemoteRestoreDeduplicatesUntilExplicitReload() throws { + let url = try #require(URL(string: "http://localhost:3000/cmux-issue-7504-dedupe")) + let workspaceId = UUID() + let panel = BrowserPanel( + workspaceId: workspaceId, + isRemoteWorkspace: true, + remoteWebsiteDataStoreIdentifier: workspaceId + ) + defer { panel.close() } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: url.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore.remote1")) + #expect(panel.hiddenWebViewDiscardSnapshot.hasPendingRemoteNavigation) + #expect(panel.hiddenWebViewDiscardManager.lastRestoreReason == "test.restore.remote1") + + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore.remote2")) + #expect(panel.hiddenWebViewDiscardSnapshot.hasPendingRemoteNavigation) + #expect(panel.hiddenWebViewDiscardManager.lastRestoreReason == "test.restore.remote1") + + #expect(panel.restoreDiscardedWebViewIfNeeded( + reason: "test.restore.remote3", + forceRestartPendingRestore: true + )) + #expect(panel.hiddenWebViewDiscardSnapshot.hasPendingRemoteNavigation) + #expect(panel.hiddenWebViewDiscardManager.lastRestoreReason == "test.restore.remote3") + } + @Test func markDiscardedResetsStalePendingRestoreNavigation() { withBrowserDiscardRestoreRetryPolicyEnabled { defaults in let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) @@ -339,7 +375,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { let payload = panel.webViewLifecycleTopPayload() #expect(payload["state"] as? String != "discarded") #expect(payload["restore_pending"] as? Bool == false) - #expect(payload["discard_blockers"] as? [String] != nil) + #expect(payload["discard_blockers"] is [String]) #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == false) } } From c3667e77a22b3821209d4df0fb6e0e68c5e2d1ff Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 19:46:09 -0700 Subject: [PATCH 21/35] Deduplicate queued remote discard restores --- .../Panels/BrowserDiscardRestoreHeal.swift | 23 +++++++++++++++++++ Sources/Panels/BrowserPanel.swift | 2 +- ...wserDiscardRestoreHealPredicateTests.swift | 21 +++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift index cd9d7e62c1ae..8479c1803996 100644 --- a/Sources/Panels/BrowserDiscardRestoreHeal.swift +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -73,6 +73,14 @@ extension BrowserPanel { // must not restart a stopped load; explicit reload is the override. guard !userStoppedLoadSinceWebViewReplacement else { return false } + if Self.isQueuedRemoteRestoreInFlight( + isDiscardedForMemory: hiddenWebViewDiscardManager.isDiscardedForMemory, + hasPendingRemoteNavigation: hasPendingRemoteNavigation, + forceRestartPendingRestore: forceRestartPendingRestore + ) { + return true + } + let restoreURL = restoredHistoryCurrentURL ?? currentURL guard let restoreURL, !Self.isAboutBlankURL(restoreURL) else { return reactivateDiscardedPaneWithoutRestorableURL(reason: reason) @@ -202,6 +210,21 @@ extension BrowserPanel { return !isAboutBlankURL(intentURL) } + /// Whether a discarded pane's restore is already queued waiting for the + /// remote workspace proxy endpoint. A queued remote restore never enters + /// performNavigation, so isRestoreNavigationPending stays false; without + /// this check every later restore touch would re-run the restore closure + /// and re-queue the navigation instead of treating the queue as in-flight. + /// An explicit reload (force) still restarts the queued restore. + nonisolated static func isQueuedRemoteRestoreInFlight( + isDiscardedForMemory: Bool, + hasPendingRemoteNavigation: Bool, + forceRestartPendingRestore: Bool + ) -> Bool { + guard isDiscardedForMemory, hasPendingRemoteNavigation else { return false } + return !forceRestartPendingRestore + } + nonisolated static func isRestoreStalled( isRestoreNavigationPending: Bool, isWebViewLoading: Bool, diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 40245e181b4c..f6fa36731afc 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2992,7 +2992,7 @@ final class BrowserPanel: Panel, ObservableObject { private var pendingDistinctPortalHostReplacementPaneId: UUID? private var lockedPortalHost: PortalHostLock? private var webViewCancellables = Set() - var navigationDelegate: BrowserNavigationDelegate? + private(set) var navigationDelegate: BrowserNavigationDelegate? private var uiDelegate: BrowserUIDelegate? var downloadDelegate: BrowserDownloadDelegate? private let webAuthnCoordinator = BrowserWebAuthnCoordinator() diff --git a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift index 8457dbf6d122..02afa97fba8e 100644 --- a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift +++ b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift @@ -147,6 +147,27 @@ struct BrowserDiscardRestoreHealPredicateTests { intentURL: intentURL )) + #expect(BrowserPanel.isQueuedRemoteRestoreInFlight( + isDiscardedForMemory: true, + hasPendingRemoteNavigation: true, + forceRestartPendingRestore: false + )) + #expect(!BrowserPanel.isQueuedRemoteRestoreInFlight( + isDiscardedForMemory: true, + hasPendingRemoteNavigation: true, + forceRestartPendingRestore: true + )) + #expect(!BrowserPanel.isQueuedRemoteRestoreInFlight( + isDiscardedForMemory: false, + hasPendingRemoteNavigation: true, + forceRestartPendingRestore: false + )) + #expect(!BrowserPanel.isQueuedRemoteRestoreInFlight( + isDiscardedForMemory: true, + hasPendingRemoteNavigation: false, + forceRestartPendingRestore: false + )) + #expect(BrowserPanel.isRestoreStalled( isRestoreNavigationPending: true, isWebViewLoading: false, From 7b9eda652778fcaa639e67650b6d917ec809af62 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 20:03:59 -0700 Subject: [PATCH 22/35] Complete policy-cancelled discard restores --- Sources/Panels/BrowserPanel.swift | 8 ++++- ...serDiscardedWebViewRestoreRetryTests.swift | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f6fa36731afc..154f3b144d74 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3795,10 +3795,16 @@ final class BrowserPanel: Panel, ObservableObject { MainActor.assumeIsolated { guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } self.isMainFrameProvisionalNavigationActive = false + let didPolicyClearAttemptedRequest = self.navigationDelegate?.lastAttemptedURL == nil self.navigationDelegate?.clearAttemptedRequest() self.refreshBackgroundAppearance() if self.isDiscardRestoreBookkeepingNavigation(cancelledNavigation) { - self.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_cancelled") + if didPolicyClearAttemptedRequest { + self.hasCommittedDocumentSinceWebViewReplacement = true + self.noteDiscardedWebViewRestoreNavigationCommitted(reason: "navigation_policy_cancelled") + } else { + self.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_cancelled") + } } } } diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index 938a47442570..b83586ae14dc 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -284,6 +284,37 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { #expect(panel.hiddenWebViewDiscardManager.lastRestoreReason == "test.restore.remote3") } + @Test func policyCancelledRestoreClearsDiscardStateInsteadOfReplaying() throws { + let url = try #require(URL(string: "https://example.com/cmux-issue-7504-policy-cancel")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: url.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + panel.hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "test.restore") + panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: url)) + panel.navigationDelegate?.clearAttemptedRequest(discardPendingBypasses: true) + + panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, nil) + + let payload = panel.webViewLifecycleTopPayload() + #expect(payload["state"] as? String != "discarded") + #expect(payload["restore_pending"] as? Bool == false) + #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == false) + #expect(!panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + } + @Test func markDiscardedResetsStalePendingRestoreNavigation() { withBrowserDiscardRestoreRetryPolicyEnabled { defaults in let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) From 2610eff7b9b80d50893465fe07164356d21cf0ea Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 20:46:12 -0700 Subject: [PATCH 23/35] Handle policy-cancelled browser restores explicitly --- .../Panels/BrowserNavigationDelegate.swift | 20 ++++++------ Sources/Panels/BrowserPanel.swift | 18 ++++++----- ...serDiscardedWebViewRestoreRetryTests.swift | 31 ++++++++++++++++++- 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index f4fd5f1ba1aa..bc5222fd95a9 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -3,6 +3,7 @@ import Foundation import WebKit @MainActor final class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { + enum PolicyCancellationKind { case terminal } private let subframeDownloadIntents = BrowserSubframeDownloadIntentTracker() private var shouldPrintAfterCurrentNavigationFinishes = false var didStartProvisionalNavigation: ((WKWebView) -> Void)? @@ -10,6 +11,7 @@ import WebKit var didFinish: ((WKWebView) -> Void)? var didFailNavigation: ((WKWebView, String, WKNavigation?) -> Void)? var didCancelProvisionalNavigation: ((WKWebView, WKNavigation?) -> Void)? + var didCancelNavigationPolicy: ((WKWebView, PolicyCancellationKind) -> Void)? var didBecomeDownload: ((WKWebView, Bool) -> Void)? var didTerminateWebContentProcess: ((WKWebView) -> Void)? var openInNewTab: ((URL) -> Void)? @@ -22,8 +24,7 @@ import WebKit var didClearPDFDocument: (() -> Void)? /// Direct reference to the download delegate - must be set synchronously in didBecome callbacks. var downloadDelegate: WKDownloadDelegate? - /// The URL of the last navigation that was attempted. Used to preserve the omnibar URL - /// when a provisional navigation fails (e.g. connection refused on localhost:3000). + /// Last attempted navigation URL, used to preserve the omnibar URL after provisional failures. var lastAttemptedURL: URL? private(set) var activeErrorPageDisplayURL: URL? private let basicAuthPromptCoordinator = BrowserHTTPBasicAuthPromptCoordinator() @@ -280,6 +281,7 @@ import WebKit "url=\(browserNavigationDebugURL(url))" ) #endif + if opened { reportTerminalPolicyCancellation(for: navigationAction, in: webView) } decisionHandler(opened ? .cancel : .allow) return } @@ -304,8 +306,6 @@ import WebKit return } - // WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.). - // Hand these off to macOS so the owning app can handle them. if let url = navigationAction.request.url, browserShouldRouteExternalNavigation(url) { clearAttemptedRequest(discardPendingBypasses: true) @@ -318,6 +318,7 @@ import WebKit }, presentAlert: presentAlert ) + reportTerminalPolicyCancellation(for: navigationAction, in: webView) decisionHandler(.cancel) return } @@ -353,13 +354,11 @@ import WebKit #endif clearAttemptedRequest(discardPendingBypasses: true) openRequestInNewTab(navigationAction.request) + reportTerminalPolicyCancellation(for: navigationAction, in: webView) decisionHandler(.cancel) return } - // target=_blank link navigations should open in a new tab. - // Scripted popups (navigationType == .other) are handled in - // WKUIDelegate.createWebViewWith so OAuth opener linkage survives. if navigationAction.targetFrame == nil, browserNavigationShouldFallbackNilTargetToNewTab( navigationType: navigationAction.navigationType @@ -413,6 +412,10 @@ import WebKit return true } + private func reportTerminalPolicyCancellation(for navigationAction: WKNavigationAction, in webView: WKWebView) { + if navigationAction.targetFrame?.isMainFrame == true { didCancelNavigationPolicy?(webView, .terminal) } + } + func canHandleSSLTrustBypassToken(_ token: String) -> Bool { acceptsSSLTrustBypassMessages && sslBypassState.hasPendingBypassToken(token) } @@ -487,9 +490,6 @@ import WebKit let mime = navigationResponse.response.mimeType ?? "unknown" let canShow = navigationResponse.canShowMIMEType - // Only classify HTTP(S) responses as downloads. Subframes are eligible - // only for explicit attachment/force-download MIME decisions; the - // resolver keeps cannot-show MIME fallback scoped to main-frame loads. if let scheme = navigationResponse.response.url?.scheme?.lowercased(), scheme != "http", scheme != "https" { decisionHandler(.allow) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 154f3b144d74..091326c083fb 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3791,20 +3791,24 @@ final class BrowserPanel: Panel, ObservableObject { self.restoreFindStateAfterNavigation(replaySearch: false) } } + navigationDelegate.didCancelNavigationPolicy = { [weak self] webView, cancellationKind in + MainActor.assumeIsolated { + guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } + switch cancellationKind { + case .terminal: + self.hasCommittedDocumentSinceWebViewReplacement = true + self.noteDiscardedWebViewRestoreNavigationCommitted(reason: "navigation_policy_cancelled") + } + } + } navigationDelegate.didCancelProvisionalNavigation = { [weak self] webView, cancelledNavigation in MainActor.assumeIsolated { guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } self.isMainFrameProvisionalNavigationActive = false - let didPolicyClearAttemptedRequest = self.navigationDelegate?.lastAttemptedURL == nil self.navigationDelegate?.clearAttemptedRequest() self.refreshBackgroundAppearance() if self.isDiscardRestoreBookkeepingNavigation(cancelledNavigation) { - if didPolicyClearAttemptedRequest { - self.hasCommittedDocumentSinceWebViewReplacement = true - self.noteDiscardedWebViewRestoreNavigationCommitted(reason: "navigation_policy_cancelled") - } else { - self.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_cancelled") - } + self.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_cancelled") } } } diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index b83586ae14dc..d495ceb1647b 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -304,8 +304,8 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { )) panel.hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "test.restore") panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: url)) - panel.navigationDelegate?.clearAttemptedRequest(discardPendingBypasses: true) + panel.navigationDelegate?.didCancelNavigationPolicy?(panel.webView, .terminal) panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, nil) let payload = panel.webViewLifecycleTopPayload() @@ -315,6 +315,35 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { #expect(!panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) } + @Test func unknownCancellationAfterClearedAttemptedURLKeepsRestoreRetryable() throws { + let url = try #require(URL(string: "file:///tmp/cmux-issue-7504-policy-cancel.html")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: url.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + panel.hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "test.restore") + panel.navigationDelegate?.clearAttemptedRequest(discardPendingBypasses: true) + + panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, nil) + + let payload = panel.webViewLifecycleTopPayload() + #expect(payload["restore_pending"] as? Bool == false) + #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == true) + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + } + @Test func markDiscardedResetsStalePendingRestoreNavigation() { withBrowserDiscardRestoreRetryPolicyEnabled { defaults in let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) From 03ebf17ffecce043d60ecfa418c9d8d1e877d60b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 21:10:10 -0700 Subject: [PATCH 24/35] Keep intent fallback restores retryable --- .../Panels/BrowserNavigationDelegate.swift | 6 +-- Sources/Panels/BrowserPanel.swift | 18 +++++-- ...serDiscardedWebViewRestoreRetryTests.swift | 53 +++++++++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index bc5222fd95a9..138a1c0b1658 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -309,7 +309,7 @@ import WebKit if let url = navigationAction.request.url, browserShouldRouteExternalNavigation(url) { clearAttemptedRequest(discardPendingBypasses: true) - browserHandleExternalNavigation( + let didCancelTerminalPolicy = browserHandleExternalNavigation( url, source: "navDelegate", webView: webView, @@ -317,8 +317,8 @@ import WebKit requestNavigation?(request, .currentTab) }, presentAlert: presentAlert - ) - reportTerminalPolicyCancellation(for: navigationAction, in: webView) + ).isTerminalPolicyCancellation + if didCancelTerminalPolicy { reportTerminalPolicyCancellation(for: navigationAction, in: webView) } decisionHandler(.cancel) return } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 091326c083fb..2ace9df1631c 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1005,6 +1005,16 @@ enum BrowserExternalNavigationAction: Equatable { case promptToOpenApp(URL) } +enum BrowserExternalNavigationHandlingResult: Equatable { + case notHandled + case browserFallback + case externalNavigation + + var isTerminalPolicyCancellation: Bool { + self == .externalNavigation + } +} + func browserShouldRouteExternalNavigation(_ url: URL) -> Bool { return browserExternalNavigationAction(for: url) != nil } @@ -1179,8 +1189,8 @@ func browserHandleExternalNavigation( webView: WKWebView, loadFallbackRequest: (URLRequest) -> Void, presentAlert: @escaping BrowserAlertPresenter = browserPresentAlert -) -> Bool { - guard let action = browserExternalNavigationAction(for: url) else { return false } +) -> BrowserExternalNavigationHandlingResult { + guard let action = browserExternalNavigationAction(for: url) else { return .notHandled } switch action { case let .browserFallback(fallbackURL): @@ -1192,7 +1202,7 @@ func browserHandleExternalNavigation( "fallbackURL=\(browserNavigationDebugURL(fallbackURL)) url=\(browserNavigationDebugURL(url))" ) #endif - return true + return .browserFallback case let .promptToOpenApp(externalURL): browserPresentExternalNavigationPrompt( @@ -1217,7 +1227,7 @@ func browserHandleExternalNavigation( }, presentAlert: presentAlert ) - return true + return .externalNavigation } } diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index d495ceb1647b..61eb9bad82a0 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -315,6 +315,59 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { #expect(!panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) } + @Test func intentBrowserFallbackPolicyCancelStaysRetryableUntilFallbackCommits() throws { + let intentURLString = [ + "intent://join/abc#Intent", + "scheme=zoommtg", + "package=us.zoom.videomeetings", + "S.browser_fallback_url=https%3A%2F%2Fzoom.us%2Fjoin%2Fabc", + "end", + ].joined(separator: ";") + let intentURL = try #require(URL(string: intentURLString)) + let fallbackURL = try #require(URL(string: "https://zoom.us/join/abc")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: intentURL.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + panel.hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "test.restore") + panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: intentURL)) + panel.navigationDelegate?.clearAttemptedRequest(discardPendingBypasses: true) + + var fallbackRequest: URLRequest? + let handlingResult = browserHandleExternalNavigation( + intentURL, + source: "test", + webView: panel.webView, + loadFallbackRequest: { fallbackRequest = $0 }, + presentAlert: { _, _, _, cancel in cancel() } + ) + #expect(handlingResult == .browserFallback) + #expect(!handlingResult.isTerminalPolicyCancellation) + #expect(fallbackRequest?.url == fallbackURL) + + if handlingResult.isTerminalPolicyCancellation { + panel.navigationDelegate?.didCancelNavigationPolicy?(panel.webView, .terminal) + } + panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, nil) + + let payload = panel.webViewLifecycleTopPayload() + #expect(payload["restore_pending"] as? Bool == false) + #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == true) + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + } + @Test func unknownCancellationAfterClearedAttemptedURLKeepsRestoreRetryable() throws { let url = try #require(URL(string: "file:///tmp/cmux-issue-7504-policy-cancel.html")) let panel = BrowserPanel( From 2623e8da86bd132bb4eece527d86983aff32dda5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 21:27:34 -0700 Subject: [PATCH 25/35] Complete insecure HTTP prompt restores --- Sources/Panels/BrowserPanel.swift | 47 ++++++++++++++-- ...wserDiscardRestoreHealPredicateTests.swift | 56 ++++++++++++++++++- 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 2ace9df1631c..43fcd9963f21 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1853,6 +1853,22 @@ enum BrowserInsecureHTTPNavigationIntent { case newTab } +enum BrowserInsecureHTTPNavigationResolution { + case openedExternally + case proceededInCurrentTab + case proceededInNewTab + case cancelled + + var isTerminalPolicyCancellation: Bool { + switch self { + case .openedExternally, .proceededInNewTab, .cancelled: + true + case .proceededInCurrentTab: + false + } + } +} + nonisolated enum BrowserWebViewLifecycleState: String { case newTab = "new_tab" case deferredURL = "deferred_url" @@ -3806,8 +3822,7 @@ final class BrowserPanel: Panel, ObservableObject { guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } switch cancellationKind { case .terminal: - self.hasCommittedDocumentSinceWebViewReplacement = true - self.noteDiscardedWebViewRestoreNavigationCommitted(reason: "navigation_policy_cancelled") + self.noteDiscardedWebViewRestoreNavigationTerminallyCancelled() } } } @@ -3848,6 +3863,11 @@ final class BrowserPanel: Panel, ObservableObject { GlobalSearchCoordinator.shared.captureBrowserPanel(self) } + private func noteDiscardedWebViewRestoreNavigationTerminallyCancelled() { + hasCommittedDocumentSinceWebViewReplacement = true + noteDiscardedWebViewRestoreNavigationCommitted(reason: "navigation_policy_cancelled") + } + private func isCurrentWebView(_ candidate: WKWebView, instanceID: UUID? = nil) -> Bool { guard candidate === webView else { return false } guard let instanceID else { return true } @@ -4007,7 +4027,15 @@ final class BrowserPanel: Panel, ObservableObject { navDelegate.shouldBlockInsecureHTTPNavigation = { [weak self] in self?.shouldBlockInsecureHTTPNavigation(to: $0) ?? false } navDelegate.shouldBlockInsecureHTTPSubframeDownload = { browserShouldBlockInsecureHTTPURL($0) } navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] request, intent in - self?.presentInsecureHTTPAlert(for: request, intent: intent, recordTypedNavigation: false) + self?.presentInsecureHTTPAlert( + for: request, + intent: intent, + recordTypedNavigation: false, + onResolution: { [weak self] resolution in + guard resolution.isTerminalPolicyCancellation else { return } + self?.noteDiscardedWebViewRestoreNavigationTerminallyCancelled() + } + ) } navDelegate.didTerminateWebContentProcess = { [weak self] webView in self?.replaceWebViewAfterContentProcessTermination(for: webView) @@ -5859,7 +5887,8 @@ final class BrowserPanel: Panel, ObservableObject { private func presentInsecureHTTPAlert( for request: URLRequest, intent: BrowserInsecureHTTPNavigationIntent, - recordTypedNavigation: Bool + recordTypedNavigation: Bool, + onResolution: @escaping (BrowserInsecureHTTPNavigationResolution) -> Void = { _ in } ) { guard let url = request.url else { return } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return } @@ -5882,7 +5911,8 @@ final class BrowserPanel: Panel, ObservableObject { request: request, url: url, intent: intent, - recordTypedNavigation: recordTypedNavigation + recordTypedNavigation: recordTypedNavigation, + onResolution: onResolution ) } @@ -5906,7 +5936,8 @@ final class BrowserPanel: Panel, ObservableObject { request: URLRequest, url: URL, intent: BrowserInsecureHTTPNavigationIntent, - recordTypedNavigation: Bool + recordTypedNavigation: Bool, + onResolution: (BrowserInsecureHTTPNavigationResolution) -> Void ) { if browserShouldPersistInsecureHTTPAllowlistSelection( response: response, @@ -5916,16 +5947,20 @@ final class BrowserPanel: Panel, ObservableObject { } switch response { case .alertFirstButtonReturn: + onResolution(.openedExternally) NSWorkspace.shared.open(url) case .alertSecondButtonReturn: switch intent { case .currentTab: + onResolution(.proceededInCurrentTab) insecureHTTPBypassHostOnce = host navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation) case .newTab: + onResolution(.proceededInNewTab) openLinkInNewTab(request: request, bypassInsecureHTTPHostOnce: host) } default: + onResolution(.cancelled) return } } diff --git a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift index 02afa97fba8e..0f92e6f8a346 100644 --- a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift +++ b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import Testing @@ -9,7 +10,7 @@ import Testing /// Pure-predicate coverage for the discard-restore heal decision helpers in /// BrowserDiscardRestoreHeal (blank-shell healing gates and restore-stall -/// detection). Panel-level restore-retry behavior lives in +/// detection). Broader panel-level restore-retry behavior lives in /// BrowserDiscardedWebViewRestoreRetryTests. @MainActor struct BrowserDiscardRestoreHealPredicateTests { @@ -199,3 +200,56 @@ struct BrowserDiscardRestoreHealPredicateTests { } } + +private final class BrowserDiscardRestorePolicyCancelAlert: NSAlert { + var response: NSApplication.ModalResponse = .alertThirdButtonReturn + + override func runModal() -> NSApplication.ModalResponse { + response + } +} + +@MainActor +struct BrowserDiscardRestorePolicyCancelTests { + @Test func cancelledInsecureHTTPPromptCompletesDiscardRestore() throws { + let url = try #require(URL(string: "http://example.com/cmux-issue-7504-insecure-prompt")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { + panel.resetInsecureHTTPAlertHooksForTesting() + panel.close() + } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: url.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + panel.hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "test.restore") + panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: url)) + panel.configureInsecureHTTPAlertHooksForTesting( + alertFactory: { + let alert = BrowserDiscardRestorePolicyCancelAlert() + alert.response = .alertThirdButtonReturn + return alert + }, + windowProvider: { nil } + ) + + panel.navigationDelegate?.handleBlockedInsecureHTTPNavigation?(URLRequest(url: url), .currentTab) + panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, nil) + + let payload = panel.webViewLifecycleTopPayload() + #expect(payload["state"] as? String != "discarded") + #expect(payload["restore_pending"] as? Bool == false) + #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == false) + #expect(!panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + } +} From 8a6307b1ed4f3dce9589da2446c3c7d5ff639487 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 21:41:23 -0700 Subject: [PATCH 26/35] Preserve current restore attempts on stale cancels --- Sources/Panels/BrowserPanel.swift | 7 +++- ...wserDiscardRestoreHealPredicateTests.swift | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 43fcd9963f21..0b066380233e 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3829,10 +3829,13 @@ final class BrowserPanel: Panel, ObservableObject { navigationDelegate.didCancelProvisionalNavigation = { [weak self] webView, cancelledNavigation in MainActor.assumeIsolated { guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } + let isRestoreBookkeepingNavigation = self.isDiscardRestoreBookkeepingNavigation(cancelledNavigation) self.isMainFrameProvisionalNavigationActive = false - self.navigationDelegate?.clearAttemptedRequest() + if isRestoreBookkeepingNavigation { + self.navigationDelegate?.clearAttemptedRequest() + } self.refreshBackgroundAppearance() - if self.isDiscardRestoreBookkeepingNavigation(cancelledNavigation) { + if isRestoreBookkeepingNavigation { self.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_cancelled") } } diff --git a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift index 0f92e6f8a346..1e22f0a595a0 100644 --- a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift +++ b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift @@ -1,6 +1,7 @@ import AppKit import Foundation import Testing +import WebKit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -211,6 +212,42 @@ private final class BrowserDiscardRestorePolicyCancelAlert: NSAlert { @MainActor struct BrowserDiscardRestorePolicyCancelTests { + @Test func staleRestoreCancelDoesNotClearCurrentAttemptedRequest() throws { + let staleURL = try #require(URL(string: "https://example.com/cmux-issue-7504-stale")) + let currentURL = try #require(URL(string: "https://example.com/cmux-issue-7504-current")) + let staleNavigation = WKNavigation() + let currentNavigation = WKNavigation() + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: staleURL.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + panel.hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "test.current") + panel.pendingDiscardRestoreNavigation = currentNavigation + panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: currentURL)) + + panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, staleNavigation) + + #expect(panel.navigationDelegate?.lastAttemptedURL == currentURL) + #expect(panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool == true) + + panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, currentNavigation) + + #expect(panel.navigationDelegate?.lastAttemptedURL == nil) + #expect(panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool == false) + } + @Test func cancelledInsecureHTTPPromptCompletesDiscardRestore() throws { let url = try #require(URL(string: "http://example.com/cmux-issue-7504-insecure-prompt")) let panel = BrowserPanel( From 7ba62624977a032121f68ab05b5f52b9f4d4557d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 21:50:41 -0700 Subject: [PATCH 27/35] Defer external prompt restore completion --- .../Panels/BrowserNavigationDelegate.swift | 8 +++---- Sources/Panels/BrowserPanel.swift | 14 +++++------ ...wserDiscardRestoreHealPredicateTests.swift | 23 +++++++++++++++++++ ...serDiscardedWebViewRestoreRetryTests.swift | 8 ++++--- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index 138a1c0b1658..8c0dc18fceb0 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -309,16 +309,16 @@ import WebKit if let url = navigationAction.request.url, browserShouldRouteExternalNavigation(url) { clearAttemptedRequest(discardPendingBypasses: true) - let didCancelTerminalPolicy = browserHandleExternalNavigation( + browserHandleExternalNavigation( url, source: "navDelegate", webView: webView, loadFallbackRequest: { [requestNavigation] request in requestNavigation?(request, .currentTab) }, - presentAlert: presentAlert - ).isTerminalPolicyCancellation - if didCancelTerminalPolicy { reportTerminalPolicyCancellation(for: navigationAction, in: webView) } + presentAlert: presentAlert, + onTerminalExternalNavigation: { [weak self] in self?.reportTerminalPolicyCancellation(for: navigationAction, in: webView) } + ) decisionHandler(.cancel) return } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 0b066380233e..e08030112ea3 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1008,11 +1008,7 @@ enum BrowserExternalNavigationAction: Equatable { enum BrowserExternalNavigationHandlingResult: Equatable { case notHandled case browserFallback - case externalNavigation - - var isTerminalPolicyCancellation: Bool { - self == .externalNavigation - } + case externalPrompt } func browserShouldRouteExternalNavigation(_ url: URL) -> Bool { @@ -1188,7 +1184,8 @@ func browserHandleExternalNavigation( source: String, webView: WKWebView, loadFallbackRequest: (URLRequest) -> Void, - presentAlert: @escaping BrowserAlertPresenter = browserPresentAlert + presentAlert: @escaping BrowserAlertPresenter = browserPresentAlert, + onTerminalExternalNavigation: @escaping () -> Void = {} ) -> BrowserExternalNavigationHandlingResult { guard let action = browserExternalNavigationAction(for: url) else { return .notHandled } @@ -1218,16 +1215,17 @@ func browserHandleExternalNavigation( #endif return } - browserOpenExternalNavigationURL( + let opened = browserOpenExternalNavigationURL( externalURL, source: source, webView: webView, presentAlert: presentAlert ) + if opened { onTerminalExternalNavigation() } }, presentAlert: presentAlert ) - return .externalNavigation + return .externalPrompt } } diff --git a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift index 1e22f0a595a0..330c1c997bd8 100644 --- a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift +++ b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift @@ -212,6 +212,29 @@ private final class BrowserDiscardRestorePolicyCancelAlert: NSAlert { @MainActor struct BrowserDiscardRestorePolicyCancelTests { + @Test func cancelledExternalAppPromptDoesNotReportTerminalRestore() throws { + let url = try #require(URL(string: "cmux-issue-7504-external://open")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + var terminalCancellationCount = 0 + let result = browserHandleExternalNavigation( + url, + source: "test", + webView: panel.webView, + loadFallbackRequest: { _ in Issue.record("custom scheme should not use a browser fallback") }, + presentAlert: { _, _, completion, _ in completion(.alertSecondButtonReturn) }, + onTerminalExternalNavigation: { terminalCancellationCount += 1 } + ) + + #expect(result == .externalPrompt) + #expect(terminalCancellationCount == 0) + } + @Test func staleRestoreCancelDoesNotClearCurrentAttemptedRequest() throws { let staleURL = try #require(URL(string: "https://example.com/cmux-issue-7504-stale")) let currentURL = try #require(URL(string: "https://example.com/cmux-issue-7504-current")) diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index 61eb9bad82a0..09fd99f41d40 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -346,18 +346,20 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { panel.navigationDelegate?.clearAttemptedRequest(discardPendingBypasses: true) var fallbackRequest: URLRequest? + var terminalCancellationCount = 0 let handlingResult = browserHandleExternalNavigation( intentURL, source: "test", webView: panel.webView, loadFallbackRequest: { fallbackRequest = $0 }, - presentAlert: { _, _, _, cancel in cancel() } + presentAlert: { _, _, _, cancel in cancel() }, + onTerminalExternalNavigation: { terminalCancellationCount += 1 } ) #expect(handlingResult == .browserFallback) - #expect(!handlingResult.isTerminalPolicyCancellation) + #expect(terminalCancellationCount == 0) #expect(fallbackRequest?.url == fallbackURL) - if handlingResult.isTerminalPolicyCancellation { + if terminalCancellationCount > 0 { panel.navigationDelegate?.didCancelNavigationPolicy?(panel.webView, .terminal) } panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, nil) From 63459876f9e44e518a83eca4959f017b1c55c93c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 22:00:49 -0700 Subject: [PATCH 28/35] Clear stale restore navigation on stalls --- .../Panels/BrowserDiscardRestoreHeal.swift | 3 +-- ...wserDiscardRestoreHealPredicateTests.swift | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift index 8479c1803996..5ccc6cd2744a 100644 --- a/Sources/Panels/BrowserDiscardRestoreHeal.swift +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -62,8 +62,7 @@ extension BrowserPanel { hasPendingRemoteNavigation: hasPendingRemoteNavigation, hasCommittedDocument: hasCommittedDocumentSinceWebViewReplacement ) { - hiddenWebViewDiscardManager.noteRestoreNavigationDidNotCommit(reason: "\(reason).stalled") - refreshWebViewLifecycleState() + noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "\(reason).stalled") } if forceRestartPendingRestore { diff --git a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift index 330c1c997bd8..10767ae1ab5f 100644 --- a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift +++ b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift @@ -212,6 +212,28 @@ private final class BrowserDiscardRestorePolicyCancelAlert: NSAlert { @MainActor struct BrowserDiscardRestorePolicyCancelTests { + @Test func stalledRestoreClearsTrackedNavigationBeforeReactivation() { + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + panel.hiddenWebViewDiscardManager.markDiscarded( + reason: "test.discard", + now: Date(timeIntervalSince1970: 100) + ) + panel.hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "test.restore") + panel.pendingDiscardRestoreNavigation = WKNavigation() + + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + + #expect(panel.pendingDiscardRestoreNavigation == nil) + #expect(panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool == false) + #expect(panel.webViewLifecycleTopPayload()["state"] as? String != "discarded") + } + @Test func cancelledExternalAppPromptDoesNotReportTerminalRestore() throws { let url = try #require(URL(string: "cmux-issue-7504-external://open")) let panel = BrowserPanel( From 620bd086ca91e31996d2868ff78238926df1b298 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 22:21:31 -0700 Subject: [PATCH 29/35] Tokenize browser restore policy cancels --- .../Panels/BrowserDiscardRestoreHeal.swift | 3 + .../Panels/BrowserNavigationDelegate.swift | 16 ++-- Sources/Panels/BrowserPanel.swift | 26 ++++-- ...wserDiscardRestoreHealPredicateTests.swift | 79 ++++++++++++++++++- ...serDiscardedWebViewRestoreRetryTests.swift | 10 ++- 5 files changed, 114 insertions(+), 20 deletions(-) diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift index 5ccc6cd2744a..4392e7ff6152 100644 --- a/Sources/Panels/BrowserDiscardRestoreHeal.swift +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -14,6 +14,7 @@ extension BrowserPanel { // previous attempt's error-page commit would satisfy the stall // detector forever and a silently stalled retry could never re-arm. hasCommittedDocumentSinceWebViewReplacement = false + currentDiscardRestoreAttemptID = UUID() } hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "navigation") refreshWebViewLifecycleState() @@ -24,12 +25,14 @@ extension BrowserPanel { return } pendingDiscardRestoreNavigation = nil + currentDiscardRestoreAttemptID = nil refreshWebViewLifecycleState() } func noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: String) { hiddenWebViewDiscardManager.noteRestoreNavigationDidNotCommit(reason: reason) pendingDiscardRestoreNavigation = nil + currentDiscardRestoreAttemptID = nil refreshWebViewLifecycleState() } diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index 8c0dc18fceb0..5218ceb6a396 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -3,7 +3,7 @@ import Foundation import WebKit @MainActor final class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { - enum PolicyCancellationKind { case terminal } + enum PolicyCancellationKind { case terminal(restoreAttemptID: UUID?) } private let subframeDownloadIntents = BrowserSubframeDownloadIntentTracker() private var shouldPrintAfterCurrentNavigationFinishes = false var didStartProvisionalNavigation: ((WKWebView) -> Void)? @@ -20,6 +20,7 @@ import WebKit var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)? var shouldBlockInsecureHTTPSubframeDownload: ((URL) -> Bool)? var handleBlockedInsecureHTTPNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? + var terminalPolicyCancellationReporter: ((WKNavigationAction, WKWebView) -> () -> Void)? var didRenderPDFDocument: ((URL, Bool) -> Void)? var didClearPDFDocument: (() -> Void)? /// Direct reference to the download delegate - must be set synchronously in didBecome callbacks. @@ -274,6 +275,7 @@ import WebKit if let url = navigationAction.request.url, shouldOpenCheckoutInSystemBrowser(navigationAction, url: url) { clearAttemptedRequest(discardPendingBypasses: true) + let reportTerminalCancellation = terminalPolicyCancellationReporter?(navigationAction, webView) ?? {} let opened = NSWorkspace.shared.open(url) #if DEBUG cmuxDebugLog( @@ -281,7 +283,7 @@ import WebKit "url=\(browserNavigationDebugURL(url))" ) #endif - if opened { reportTerminalPolicyCancellation(for: navigationAction, in: webView) } + if opened { reportTerminalCancellation() } decisionHandler(opened ? .cancel : .allow) return } @@ -309,6 +311,7 @@ import WebKit if let url = navigationAction.request.url, browserShouldRouteExternalNavigation(url) { clearAttemptedRequest(discardPendingBypasses: true) + let reportTerminalCancellation = terminalPolicyCancellationReporter?(navigationAction, webView) ?? {} browserHandleExternalNavigation( url, source: "navDelegate", @@ -317,7 +320,7 @@ import WebKit requestNavigation?(request, .currentTab) }, presentAlert: presentAlert, - onTerminalExternalNavigation: { [weak self] in self?.reportTerminalPolicyCancellation(for: navigationAction, in: webView) } + onTerminalExternalNavigation: reportTerminalCancellation ) decisionHandler(.cancel) return @@ -353,8 +356,9 @@ import WebKit ) #endif clearAttemptedRequest(discardPendingBypasses: true) + let reportTerminalCancellation = terminalPolicyCancellationReporter?(navigationAction, webView) ?? {} openRequestInNewTab(navigationAction.request) - reportTerminalPolicyCancellation(for: navigationAction, in: webView) + reportTerminalCancellation() decisionHandler(.cancel) return } @@ -412,10 +416,6 @@ import WebKit return true } - private func reportTerminalPolicyCancellation(for navigationAction: WKNavigationAction, in webView: WKWebView) { - if navigationAction.targetFrame?.isMainFrame == true { didCancelNavigationPolicy?(webView, .terminal) } - } - func canHandleSSLTrustBypassToken(_ token: String) -> Bool { acceptsSSLTrustBypassMessages && sslBypassState.hasPendingBypassToken(token) } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index e08030112ea3..ce5ff42c0597 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2822,7 +2822,8 @@ final class BrowserPanel: Panel, ObservableObject { let hiddenWebViewDiscardManager = BrowserHiddenWebViewDiscardManager() var hasCommittedDocumentSinceWebViewReplacement = false var userStoppedLoadSinceWebViewReplacement = false - weak var pendingDiscardRestoreNavigation: WKNavigation? + var pendingDiscardRestoreNavigation: WKNavigation? + var currentDiscardRestoreAttemptID: UUID? @Published private(set) var webViewLifecycleState: BrowserWebViewLifecycleState = .newTab private(set) var webViewLastVisibleAt: Date? @@ -3379,9 +3380,11 @@ final class BrowserPanel: Panel, ObservableObject { @discardableResult func reactivateDiscardedWebViewWithoutNavigation(reason: String) -> Bool { - return hiddenWebViewDiscardManager.reactivateWithoutNavigation(reason: reason) { + let reactivated = hiddenWebViewDiscardManager.reactivateWithoutNavigation(reason: reason) { shouldRenderWebView = true } + if reactivated { pendingDiscardRestoreNavigation = nil; currentDiscardRestoreAttemptID = nil } + return reactivated } /// Popups inherit this panel's exact WebKit storage context. @@ -3819,8 +3822,7 @@ final class BrowserPanel: Panel, ObservableObject { MainActor.assumeIsolated { guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } switch cancellationKind { - case .terminal: - self.noteDiscardedWebViewRestoreNavigationTerminallyCancelled() + case let .terminal(restoreAttemptID): self.noteDiscardedWebViewRestoreNavigationTerminallyCancelled(restoreAttemptID: restoreAttemptID) } } } @@ -3864,7 +3866,8 @@ final class BrowserPanel: Panel, ObservableObject { GlobalSearchCoordinator.shared.captureBrowserPanel(self) } - private func noteDiscardedWebViewRestoreNavigationTerminallyCancelled() { + private func noteDiscardedWebViewRestoreNavigationTerminallyCancelled(restoreAttemptID: UUID?) { + guard let restoreAttemptID, restoreAttemptID == currentDiscardRestoreAttemptID else { return } hasCommittedDocumentSinceWebViewReplacement = true noteDiscardedWebViewRestoreNavigationCommitted(reason: "navigation_policy_cancelled") } @@ -4028,16 +4031,25 @@ final class BrowserPanel: Panel, ObservableObject { navDelegate.shouldBlockInsecureHTTPNavigation = { [weak self] in self?.shouldBlockInsecureHTTPNavigation(to: $0) ?? false } navDelegate.shouldBlockInsecureHTTPSubframeDownload = { browserShouldBlockInsecureHTTPURL($0) } navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] request, intent in - self?.presentInsecureHTTPAlert( + guard let self else { return } + let restoreAttemptID = self.currentDiscardRestoreAttemptID + self.presentInsecureHTTPAlert( for: request, intent: intent, recordTypedNavigation: false, onResolution: { [weak self] resolution in guard resolution.isTerminalPolicyCancellation else { return } - self?.noteDiscardedWebViewRestoreNavigationTerminallyCancelled() + self?.noteDiscardedWebViewRestoreNavigationTerminallyCancelled(restoreAttemptID: restoreAttemptID) } ) } + navDelegate.terminalPolicyCancellationReporter = { [weak self] navigationAction, webView in + let restoreAttemptID = self?.currentDiscardRestoreAttemptID + return { [weak self, weak webView] in + guard let self, let webView, navigationAction.targetFrame?.isMainFrame == true, self.isCurrentWebView(webView) else { return } + self.noteDiscardedWebViewRestoreNavigationTerminallyCancelled(restoreAttemptID: restoreAttemptID) + } + } navDelegate.didTerminateWebContentProcess = { [weak self] webView in self?.replaceWebViewAfterContentProcessTermination(for: webView) } diff --git a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift index 10767ae1ab5f..96e78f8e08f1 100644 --- a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift +++ b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift @@ -210,6 +210,21 @@ private final class BrowserDiscardRestorePolicyCancelAlert: NSAlert { } } +private final class BrowserDiscardRestoreDeferredPolicyAlert: NSAlert { + var completionHandler: ((NSApplication.ModalResponse) -> Void)? + + override func beginSheetModal( + for sheetWindow: NSWindow, + completionHandler handler: ((NSApplication.ModalResponse) -> Void)? + ) { + completionHandler = handler + } + + override func runModal() -> NSApplication.ModalResponse { + .alertThirdButtonReturn + } +} + @MainActor struct BrowserDiscardRestorePolicyCancelTests { @Test func stalledRestoreClearsTrackedNavigationBeforeReactivation() { @@ -314,7 +329,11 @@ struct BrowserDiscardRestorePolicyCancelTests { backHistoryURLStrings: [], forwardHistoryURLStrings: [] )) - panel.hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "test.restore") + panel.hiddenWebViewDiscardManager.markDiscarded( + reason: "test.discard", + now: Date(timeIntervalSince1970: 200) + ) + panel.noteDiscardedWebViewRestoreNavigationStarted() panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: url)) panel.configureInsecureHTTPAlertHooksForTesting( alertFactory: { @@ -334,4 +353,62 @@ struct BrowserDiscardRestorePolicyCancelTests { #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == false) #expect(!panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) } + + @Test func staleInsecureHTTPPromptDoesNotCompleteNewerRestore() throws { + let url = try #require(URL(string: "http://example.com/cmux-issue-7504-stale-prompt")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + defer { + panel.resetInsecureHTTPAlertHooksForTesting() + window.close() + panel.close() + } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: url.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + panel.hiddenWebViewDiscardManager.markDiscarded( + reason: "test.discard", + now: Date(timeIntervalSince1970: 300) + ) + panel.noteDiscardedWebViewRestoreNavigationStarted() + panel.pendingDiscardRestoreNavigation = WKNavigation() + panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: url)) + + let alert = BrowserDiscardRestoreDeferredPolicyAlert() + panel.configureInsecureHTTPAlertHooksForTesting( + alertFactory: { alert }, + windowProvider: { window } + ) + panel.navigationDelegate?.handleBlockedInsecureHTTPNavigation?(URLRequest(url: url), .currentTab) + let staleCompletion = try #require(alert.completionHandler) + + panel.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "test.old_cancel") + let currentNavigation = WKNavigation() + panel.noteDiscardedWebViewRestoreNavigationStarted() + panel.pendingDiscardRestoreNavigation = currentNavigation + panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: url)) + + staleCompletion(.alertThirdButtonReturn) + + let payload = panel.webViewLifecycleTopPayload() + #expect(panel.pendingDiscardRestoreNavigation === currentNavigation) + #expect(payload["restore_pending"] as? Bool == true) + #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == true) + } } diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index 09fd99f41d40..794e577c3ce7 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -302,10 +302,11 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { backHistoryURLStrings: [], forwardHistoryURLStrings: [] )) - panel.hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "test.restore") + panel.hiddenWebViewDiscardManager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 400)) + panel.noteDiscardedWebViewRestoreNavigationStarted() panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: url)) - panel.navigationDelegate?.didCancelNavigationPolicy?(panel.webView, .terminal) + panel.navigationDelegate?.didCancelNavigationPolicy?(panel.webView, .terminal(restoreAttemptID: panel.currentDiscardRestoreAttemptID)) panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, nil) let payload = panel.webViewLifecycleTopPayload() @@ -341,7 +342,8 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { backHistoryURLStrings: [], forwardHistoryURLStrings: [] )) - panel.hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "test.restore") + panel.hiddenWebViewDiscardManager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 500)) + panel.noteDiscardedWebViewRestoreNavigationStarted() panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: intentURL)) panel.navigationDelegate?.clearAttemptedRequest(discardPendingBypasses: true) @@ -360,7 +362,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { #expect(fallbackRequest?.url == fallbackURL) if terminalCancellationCount > 0 { - panel.navigationDelegate?.didCancelNavigationPolicy?(panel.webView, .terminal) + panel.navigationDelegate?.didCancelNavigationPolicy?(panel.webView, .terminal(restoreAttemptID: panel.currentDiscardRestoreAttemptID)) } panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, nil) From 9167d38c3b3b48b96b0123da970f9bf1623a359f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 22:38:58 -0700 Subject: [PATCH 30/35] Keep insecure HTTP restore prompts retryable --- .github/swift-file-length-budget.tsv | 2 +- Sources/Panels/BrowserPanel.swift | 21 ++++++---- ...wserDiscardRestoreHealPredicateTests.swift | 38 +++++++++++++++++-- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 6c0c47f0f77d..5415dc3746e6 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,7 +8,7 @@ 13164 Sources/Workspace.swift 12501 Sources/GhosttyTerminalView.swift 12348 cmuxTests/AppDelegateShortcutRoutingTests.swift -11388 Sources/Panels/BrowserPanel.swift +11395 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift 7959 Sources/Panels/BrowserPanelView.swift diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index ce5ff42c0597..7481a098485e 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1163,9 +1163,10 @@ private func browserOpenExternalNavigationURL( _ url: URL, source: String, webView: WKWebView, - presentAlert: BrowserAlertPresenter = browserPresentAlert + presentAlert: BrowserAlertPresenter = browserPresentAlert, + openURL: (URL) -> Bool = { NSWorkspace.shared.open($0) } ) -> Bool { - let opened = NSWorkspace.shared.open(url) + let opened = openURL(url) if !opened { browserPresentExternalNavigationFailure(for: url, in: webView, presentAlert: presentAlert) } @@ -1859,9 +1860,9 @@ enum BrowserInsecureHTTPNavigationResolution { var isTerminalPolicyCancellation: Bool { switch self { - case .openedExternally, .proceededInNewTab, .cancelled: + case .openedExternally, .proceededInNewTab: true - case .proceededInCurrentTab: + case .proceededInCurrentTab, .cancelled: false } } @@ -5942,7 +5943,7 @@ final class BrowserPanel: Panel, ObservableObject { handleResponse(alert.runModal()) } - private func handleInsecureHTTPAlertResponse( + func handleInsecureHTTPAlertResponse( _ response: NSApplication.ModalResponse, alert: NSAlert?, host: String, @@ -5950,7 +5951,8 @@ final class BrowserPanel: Panel, ObservableObject { url: URL, intent: BrowserInsecureHTTPNavigationIntent, recordTypedNavigation: Bool, - onResolution: (BrowserInsecureHTTPNavigationResolution) -> Void + onResolution: (BrowserInsecureHTTPNavigationResolution) -> Void, + openExternalURL: (URL) -> Bool = { NSWorkspace.shared.open($0) } ) { if browserShouldPersistInsecureHTTPAllowlistSelection( response: response, @@ -5960,8 +5962,13 @@ final class BrowserPanel: Panel, ObservableObject { } switch response { case .alertFirstButtonReturn: + guard browserOpenExternalNavigationURL( + url, + source: "insecure_http", + webView: webView, + openURL: openExternalURL + ) else { return } onResolution(.openedExternally) - NSWorkspace.shared.open(url) case .alertSecondButtonReturn: switch intent { case .currentTab: diff --git a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift index 96e78f8e08f1..91902b4f9033 100644 --- a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift +++ b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift @@ -308,7 +308,7 @@ struct BrowserDiscardRestorePolicyCancelTests { #expect(panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool == false) } - @Test func cancelledInsecureHTTPPromptCompletesDiscardRestore() throws { + @Test func cancelledInsecureHTTPPromptKeepsDiscardRestoreRetryable() throws { let url = try #require(URL(string: "http://example.com/cmux-issue-7504-insecure-prompt")) let panel = BrowserPanel( workspaceId: UUID(), @@ -348,10 +348,40 @@ struct BrowserDiscardRestorePolicyCancelTests { panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, nil) let payload = panel.webViewLifecycleTopPayload() - #expect(payload["state"] as? String != "discarded") + #expect(payload["state"] as? String == "discarded") #expect(payload["restore_pending"] as? Bool == false) - #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == false) - #expect(!panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == true) + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + } + + @Test func failedInsecureHTTPExternalOpenDoesNotReportTerminalRestore() throws { + let url = try #require(URL(string: "http://example.com/cmux-issue-7504-open-failure")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + var resolutions: [BrowserInsecureHTTPNavigationResolution] = [] + var openedURL: URL? + panel.handleInsecureHTTPAlertResponse( + .alertFirstButtonReturn, + alert: nil, + host: "example.com", + request: URLRequest(url: url), + url: url, + intent: .currentTab, + recordTypedNavigation: false, + onResolution: { resolutions.append($0) }, + openExternalURL: { url in + openedURL = url + return false + } + ) + + #expect(openedURL == url) + #expect(resolutions.isEmpty) } @Test func staleInsecureHTTPPromptDoesNotCompleteNewerRestore() throws { From daa077b8670924ccd9bb220651ef9ac8e4f55d4e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 22:52:01 -0700 Subject: [PATCH 31/35] Avoid browser panel budget growth --- .github/swift-file-length-budget.tsv | 2 +- Sources/Panels/BrowserPanel.swift | 17 +++++------------ ...rowserDiscardRestoreHealPredicateTests.swift | 4 ++-- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 5415dc3746e6..6c0c47f0f77d 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,7 +8,7 @@ 13164 Sources/Workspace.swift 12501 Sources/GhosttyTerminalView.swift 12348 cmuxTests/AppDelegateShortcutRoutingTests.swift -11395 Sources/Panels/BrowserPanel.swift +11388 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift 7959 Sources/Panels/BrowserPanelView.swift diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 7481a098485e..19bd0bf3bf62 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1163,10 +1163,9 @@ private func browserOpenExternalNavigationURL( _ url: URL, source: String, webView: WKWebView, - presentAlert: BrowserAlertPresenter = browserPresentAlert, - openURL: (URL) -> Bool = { NSWorkspace.shared.open($0) } + presentAlert: BrowserAlertPresenter = browserPresentAlert ) -> Bool { - let opened = openURL(url) + let opened = NSWorkspace.shared.open(url) if !opened { browserPresentExternalNavigationFailure(for: url, in: webView, presentAlert: presentAlert) } @@ -5950,9 +5949,8 @@ final class BrowserPanel: Panel, ObservableObject { request: URLRequest, url: URL, intent: BrowserInsecureHTTPNavigationIntent, - recordTypedNavigation: Bool, - onResolution: (BrowserInsecureHTTPNavigationResolution) -> Void, - openExternalURL: (URL) -> Bool = { NSWorkspace.shared.open($0) } + recordTypedNavigation: Bool, openExternalURL: (URL) -> Bool = { NSWorkspace.shared.open($0) }, + onResolution: (BrowserInsecureHTTPNavigationResolution) -> Void ) { if browserShouldPersistInsecureHTTPAllowlistSelection( response: response, @@ -5962,12 +5960,7 @@ final class BrowserPanel: Panel, ObservableObject { } switch response { case .alertFirstButtonReturn: - guard browserOpenExternalNavigationURL( - url, - source: "insecure_http", - webView: webView, - openURL: openExternalURL - ) else { return } + if !openExternalURL(url) { return } onResolution(.openedExternally) case .alertSecondButtonReturn: switch intent { diff --git a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift index 91902b4f9033..4de93f015b51 100644 --- a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift +++ b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift @@ -373,11 +373,11 @@ struct BrowserDiscardRestorePolicyCancelTests { url: url, intent: .currentTab, recordTypedNavigation: false, - onResolution: { resolutions.append($0) }, openExternalURL: { url in openedURL = url return false - } + }, + onResolution: { resolutions.append($0) } ) #expect(openedURL == url) From 3042ee2d6516e4133b26d9b8906900bfe9b29ca4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 22:56:23 -0700 Subject: [PATCH 32/35] Preserve restore tokens through policy prompts --- .../Panels/BrowserDiscardRestoreHeal.swift | 1 - ...wserDiscardRestoreHealPredicateTests.swift | 38 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift index 4392e7ff6152..e83254c1e0b3 100644 --- a/Sources/Panels/BrowserDiscardRestoreHeal.swift +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -32,7 +32,6 @@ extension BrowserPanel { func noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: String) { hiddenWebViewDiscardManager.noteRestoreNavigationDidNotCommit(reason: reason) pendingDiscardRestoreNavigation = nil - currentDiscardRestoreAttemptID = nil refreshWebViewLifecycleState() } diff --git a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift index 4de93f015b51..6a997483efa8 100644 --- a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift +++ b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift @@ -384,6 +384,44 @@ struct BrowserDiscardRestorePolicyCancelTests { #expect(resolutions.isEmpty) } + @Test func terminalPolicyCompletionAfterProvisionalCancelCompletesDiscardRestore() throws { + let url = try #require(URL(string: "http://example.com/cmux-issue-7504-delayed-policy-terminal")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: url.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + panel.hiddenWebViewDiscardManager.markDiscarded( + reason: "test.discard", + now: Date(timeIntervalSince1970: 250) + ) + panel.noteDiscardedWebViewRestoreNavigationStarted() + let restoreAttemptID = try #require(panel.currentDiscardRestoreAttemptID) + panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: url)) + + panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, nil) + #expect(panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool == false) + #expect(panel.webViewLifecycleTopPayload()["state"] as? String == "discarded") + + panel.navigationDelegate?.didCancelNavigationPolicy?(panel.webView, .terminal(restoreAttemptID: restoreAttemptID)) + + let payload = panel.webViewLifecycleTopPayload() + #expect(payload["state"] as? String != "discarded") + #expect(payload["restore_pending"] as? Bool == false) + #expect(!panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + } + @Test func staleInsecureHTTPPromptDoesNotCompleteNewerRestore() throws { let url = try #require(URL(string: "http://example.com/cmux-issue-7504-stale-prompt")) let panel = BrowserPanel( From 1acb3b46b4e36e3d50294898500398f7576532f5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 23:16:31 -0700 Subject: [PATCH 33/35] Scope restore downloads to attempts --- .github/swift-file-length-budget.tsv | 2 +- Sources/Panels/BrowserNavigationDelegate.swift | 16 +++++++++++++--- Sources/Panels/BrowserPanel.swift | 13 ++++++------- ...rowserDiscardedWebViewRestoreRetryTests.swift | 2 +- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 6c0c47f0f77d..124d45d7242b 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -180,7 +180,7 @@ 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift 624 Sources/SettingsNavigation.swift 623 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift -623 Sources/Panels/BrowserNavigationDelegate.swift +633 Sources/Panels/BrowserNavigationDelegate.swift 620 cmuxTests/FinderFileDropRegressionTests.swift 608 cmuxUITests/FeedSidebarUITests.swift 607 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index 5218ceb6a396..93c83fe960a9 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -12,7 +12,7 @@ import WebKit var didFailNavigation: ((WKWebView, String, WKNavigation?) -> Void)? var didCancelProvisionalNavigation: ((WKWebView, WKNavigation?) -> Void)? var didCancelNavigationPolicy: ((WKWebView, PolicyCancellationKind) -> Void)? - var didBecomeDownload: ((WKWebView, Bool) -> Void)? + var didBecomeDownload: ((WKWebView, Bool, UUID?) -> Void)? var didTerminateWebContentProcess: ((WKWebView) -> Void)? var openInNewTab: ((URL) -> Void)? var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? @@ -20,6 +20,7 @@ import WebKit var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)? var shouldBlockInsecureHTTPSubframeDownload: ((URL) -> Bool)? var handleBlockedInsecureHTTPNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? + var currentRestoreAttemptID: (() -> UUID?)? var terminalPolicyCancellationReporter: ((WKNavigationAction, WKWebView) -> () -> Void)? var didRenderPDFDocument: ((URL, Bool) -> Void)? var didClearPDFDocument: (() -> Void)? @@ -37,6 +38,7 @@ import WebKit private var activeSSLTrustBypassErrorPageFailedURL: String? private var activeSSLTrustBypassReplayRequest: URLRequest? private var activeSSLTrustBypassErrorPageRetryRequest: URLRequest? + private var pendingMainFrameDownloadRestoreAttemptID: UUID? func cancelPendingAuthenticationPrompts(allowFuturePrompts: Bool = false) { basicAuthPromptCoordinator.cancelAll(allowFuturePrompts: allowFuturePrompts) @@ -255,6 +257,9 @@ import WebKit ) let hasUserActivation = browserNavigationHasSimpleUserActivation() subframeDownloadIntents.updateIfNeeded(navigationAction, hasUserActivation: hasUserActivation) + if navigationAction.targetFrame?.isMainFrame == true { + pendingMainFrameDownloadRestoreAttemptID = currentRestoreAttemptID?() + } #if DEBUG let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" @@ -588,20 +593,25 @@ import WebKit } func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true + let restoreAttemptID = isMainFrame ? pendingMainFrameDownloadRestoreAttemptID : nil #if DEBUG cmuxDebugLog("download.didBecome source=navigationAction") #endif NSLog("BrowserPanel download didBecome from navigationAction") - didBecomeDownload?(webView, navigationAction.targetFrame?.isMainFrame ?? true) + didBecomeDownload?(webView, isMainFrame, restoreAttemptID) + if isMainFrame { pendingMainFrameDownloadRestoreAttemptID = nil } download.delegate = downloadDelegate } func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + let restoreAttemptID = navigationResponse.isForMainFrame ? pendingMainFrameDownloadRestoreAttemptID : nil #if DEBUG cmuxDebugLog("download.didBecome source=navigationResponse") #endif NSLog("BrowserPanel download didBecome from navigationResponse") - didBecomeDownload?(webView, navigationResponse.isForMainFrame) + didBecomeDownload?(webView, navigationResponse.isForMainFrame, restoreAttemptID) + if navigationResponse.isForMainFrame { pendingMainFrameDownloadRestoreAttemptID = nil } download.delegate = downloadDelegate } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 19bd0bf3bf62..d3c7fe973f33 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3269,7 +3269,7 @@ final class BrowserPanel: Panel, ObservableObject { private func resetWebViewLifecycleMetadata(resetVisibility: Bool = true) { cancelHiddenWebViewDiscard() - webViewLifecycleState = .newTab + webViewLifecycleState = .newTab; pendingDiscardRestoreNavigation = nil; currentDiscardRestoreAttemptID = nil if resetVisibility { webViewLastVisibleAt = nil webViewLastHiddenAt = nil @@ -3840,13 +3840,11 @@ final class BrowserPanel: Panel, ObservableObject { } } } - navigationDelegate.didBecomeDownload = { [weak self] webView, isMainFrame in + navigationDelegate.didBecomeDownload = { [weak self] webView, isMainFrame, restoreAttemptID in MainActor.assumeIsolated { - guard isMainFrame else { return } - guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } - // A main-frame download is a terminal outcome with no document - // commit; treat it as committed so blank-shell healing and stall - // retries never restart the download on the next reveal. + guard isMainFrame, let restoreAttemptID else { return } + guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID), restoreAttemptID == self.currentDiscardRestoreAttemptID else { return } + // A main-frame download is a terminal outcome with no document commit; never restart it on the next reveal. self.hasCommittedDocumentSinceWebViewReplacement = true self.noteDiscardedWebViewRestoreNavigationCommitted(reason: "navigation_download") } @@ -4043,6 +4041,7 @@ final class BrowserPanel: Panel, ObservableObject { } ) } + navDelegate.currentRestoreAttemptID = { [weak self] in self?.currentDiscardRestoreAttemptID } navDelegate.terminalPolicyCancellationReporter = { [weak self] navigationAction, webView in let restoreAttemptID = self?.currentDiscardRestoreAttemptID return { [weak self, weak webView] in diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift index 794e577c3ce7..078dd6e3070a 100644 --- a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -457,7 +457,7 @@ struct BrowserDiscardedWebViewRestoreRetryGreenTests { // Simulate WebKit converting the pending restore navigation into a // main-frame download before any document commits. - panel.navigationDelegate?.didBecomeDownload?(panel.webView, true) + panel.navigationDelegate?.didBecomeDownload?(panel.webView, true, panel.currentDiscardRestoreAttemptID) let payload = panel.webViewLifecycleTopPayload() #expect(payload["restore_pending"] as? Bool == false) From 74d350736d3b46aabd1d3fec990afd987846341a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Jul 2026 23:40:02 -0700 Subject: [PATCH 34/35] Complete terminal restore handling for nil-target tabs --- .../Panels/BrowserNavigationDelegate.swift | 2 ++ Sources/Panels/BrowserPanel.swift | 36 ++----------------- ...wserDiscardRestoreHealPredicateTests.swift | 32 +++++++++++++++++ 3 files changed, 37 insertions(+), 33 deletions(-) diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index e0a223c54295..86c10b9c8a99 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -395,7 +395,9 @@ import WebKit ) #endif clearAttemptedRequest(discardPendingBypasses: true) + let reportTerminalCancellation = terminalPolicyCancellationReporter?(navigationAction, webView) ?? {} openRequestInNewTab(navigationAction.request) + reportTerminalCancellation() decisionHandler(.cancel) return } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index e315a37cdb9d..d04601392c48 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3016,8 +3016,8 @@ final class BrowserPanel: Panel, ObservableObject { private let maxPageZoom: CGFloat = 5.0 private let pageZoomStep: CGFloat = 0.1 private var insecureHTTPBypassHostOnce: String? - private var insecureHTTPAlertFactory: () -> NSAlert - private var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow } + var insecureHTTPAlertFactory: () -> NSAlert + var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow } // Persist user intent across WebKit detach/reattach churn (split/layout updates). @Published private(set) var preferredDeveloperToolsVisible: Bool = false @Published var isReactGrabActive: Bool = false { @@ -5882,7 +5882,7 @@ final class BrowserPanel: Panel, ObservableObject { } } - private func presentInsecureHTTPAlert( + func presentInsecureHTTPAlert( for request: URLRequest, intent: BrowserInsecureHTTPNavigationIntent, recordTypedNavigation: Bool, @@ -7885,37 +7885,7 @@ extension BrowserPanel { } } -#if DEBUG extension BrowserPanel { - func configureInsecureHTTPAlertHooksForTesting( - alertFactory: @escaping () -> NSAlert, - windowProvider: @escaping () -> NSWindow? - ) { - insecureHTTPAlertFactory = alertFactory - insecureHTTPAlertWindowProvider = windowProvider - } - - func resetInsecureHTTPAlertHooksForTesting() { - insecureHTTPAlertFactory = { NSAlert() } - insecureHTTPAlertWindowProvider = { [weak self] in - if let self, let window = browserInteractiveModalHostWindow(for: self.webView) { - return window - } - return browserFallbackInteractiveModalHostWindow() - } - } - - func presentInsecureHTTPAlertForTesting( - url: URL, - recordTypedNavigation: Bool = false - ) { - presentInsecureHTTPAlert( - for: URLRequest(url: url), - intent: .currentTab, - recordTypedNavigation: recordTypedNavigation - ) - } - private static func debugRectDescription(_ rect: NSRect) -> String { String( format: "%.1f,%.1f %.1fx%.1f", diff --git a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift index 6a997483efa8..b5ee4d4e74cd 100644 --- a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift +++ b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift @@ -9,6 +9,38 @@ import WebKit @testable import cmux #endif +@MainActor +extension BrowserPanel { + func configureInsecureHTTPAlertHooksForTesting( + alertFactory: @escaping () -> NSAlert, + windowProvider: @escaping () -> NSWindow? + ) { + insecureHTTPAlertFactory = alertFactory + insecureHTTPAlertWindowProvider = windowProvider + } + + func resetInsecureHTTPAlertHooksForTesting() { + insecureHTTPAlertFactory = { NSAlert() } + insecureHTTPAlertWindowProvider = { [weak self] in + if let self, let window = browserInteractiveModalHostWindow(for: self.webView) { + return window + } + return browserFallbackInteractiveModalHostWindow() + } + } + + func presentInsecureHTTPAlertForTesting( + url: URL, + recordTypedNavigation: Bool = false + ) { + presentInsecureHTTPAlert( + for: URLRequest(url: url), + intent: .currentTab, + recordTypedNavigation: recordTypedNavigation + ) + } +} + /// Pure-predicate coverage for the discard-restore heal decision helpers in /// BrowserDiscardRestoreHeal (blank-shell healing gates and restore-stall /// detection). Broader panel-level restore-retry behavior lives in From 348e3ee2b1e27bc81e63bbe0a91a103e468ebb9f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 9 Jul 2026 00:12:17 -0700 Subject: [PATCH 35/35] Fix PR comment CI guard regressions --- Sources/Panels/BrowserPanel.swift | 1 + cmux.xcodeproj/project.pbxproj | 4 ++ ...wserDiscardRestoreHealPredicateTests.swift | 32 --------------- .../BrowserInsecureHTTPAlertTestSupport.swift | 40 +++++++++++++++++++ 4 files changed, 45 insertions(+), 32 deletions(-) create mode 100644 cmuxTests/BrowserInsecureHTTPAlertTestSupport.swift diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index d04601392c48..82686c11c543 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -7885,6 +7885,7 @@ extension BrowserPanel { } } +#if DEBUG extension BrowserPanel { private static func debugRectDescription(_ rect: NSRect) -> String { String( diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 547b4f8e23b1..3de1c0f430e9 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -183,6 +183,7 @@ BABA2500000000000000000B /* BrowserHTTPBasicAuthProtectionSpaceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = BABA2500000000000000000C /* BrowserHTTPBasicAuthProtectionSpaceKey.swift */; }; FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; }; FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; }; + B75040040000000000000001 /* BrowserInsecureHTTPAlertTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75040040000000000000002 /* BrowserInsecureHTTPAlertTestSupport.swift */; }; B1F0C0010000000000000001 /* BrowserInspectorFocusHandoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0010000000000000002 /* BrowserInspectorFocusHandoff.swift */; }; B1F0C0020000000000000001 /* BrowserInspectorFocusHandoffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0020000000000000002 /* BrowserInspectorFocusHandoffTests.swift */; }; BCBC0A0E0000000000000F01 /* BrowserMediaActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC0A0E0000000000000F02 /* BrowserMediaActivity.swift */; }; @@ -1823,6 +1824,7 @@ BABA2500000000000000000C /* BrowserHTTPBasicAuthProtectionSpaceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserHTTPBasicAuthProtectionSpaceKey.swift; sourceTree = ""; }; FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = ""; }; FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = ""; }; + B75040040000000000000002 /* BrowserInsecureHTTPAlertTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserInsecureHTTPAlertTestSupport.swift; sourceTree = ""; }; B1F0C0010000000000000002 /* BrowserInspectorFocusHandoff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/BrowserInspectorFocusHandoff.swift; sourceTree = ""; }; B1F0C0020000000000000002 /* BrowserInspectorFocusHandoffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserInspectorFocusHandoffTests.swift; sourceTree = ""; }; BCBC0A0E0000000000000F02 /* BrowserMediaActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserMediaActivity.swift; sourceTree = ""; }; @@ -4579,6 +4581,7 @@ B6585002B6585002B6585002 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift */, B75040020000000000000002 /* BrowserDiscardedWebViewRestoreRetryTests.swift */, B75040030000000000000002 /* BrowserDiscardRestoreHealPredicateTests.swift */, + B75040040000000000000002 /* BrowserInsecureHTTPAlertTestSupport.swift */, B6585002B6585002B658PW02 /* BrowserPrewarmedWebViewPoolTests.swift */, 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, C42660040000000000000002 /* BrowserPDFPreviewActionRegressionTests.swift */, @@ -6427,6 +6430,7 @@ BABA25000000000000000005 /* BrowserHTTPBasicAuthPromptCoordinatorTests.swift in Sources */, BABA25000000000000000003 /* BrowserHTTPBasicAuthPromptTests.swift in Sources */, FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */, + B75040040000000000000001 /* BrowserInsecureHTTPAlertTestSupport.swift in Sources */, B1F0C0020000000000000001 /* BrowserInspectorFocusHandoffTests.swift in Sources */, BCBC0A0E0000000000000E11 /* BrowserMediaActivityAggregationTests.swift in Sources */, BCBC0A0E0000000000000E21 /* BrowserMediaPlaybackAudioActivityTests.swift in Sources */, diff --git a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift index b5ee4d4e74cd..6a997483efa8 100644 --- a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift +++ b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift @@ -9,38 +9,6 @@ import WebKit @testable import cmux #endif -@MainActor -extension BrowserPanel { - func configureInsecureHTTPAlertHooksForTesting( - alertFactory: @escaping () -> NSAlert, - windowProvider: @escaping () -> NSWindow? - ) { - insecureHTTPAlertFactory = alertFactory - insecureHTTPAlertWindowProvider = windowProvider - } - - func resetInsecureHTTPAlertHooksForTesting() { - insecureHTTPAlertFactory = { NSAlert() } - insecureHTTPAlertWindowProvider = { [weak self] in - if let self, let window = browserInteractiveModalHostWindow(for: self.webView) { - return window - } - return browserFallbackInteractiveModalHostWindow() - } - } - - func presentInsecureHTTPAlertForTesting( - url: URL, - recordTypedNavigation: Bool = false - ) { - presentInsecureHTTPAlert( - for: URLRequest(url: url), - intent: .currentTab, - recordTypedNavigation: recordTypedNavigation - ) - } -} - /// Pure-predicate coverage for the discard-restore heal decision helpers in /// BrowserDiscardRestoreHeal (blank-shell healing gates and restore-stall /// detection). Broader panel-level restore-retry behavior lives in diff --git a/cmuxTests/BrowserInsecureHTTPAlertTestSupport.swift b/cmuxTests/BrowserInsecureHTTPAlertTestSupport.swift new file mode 100644 index 000000000000..2ee99eea3ef6 --- /dev/null +++ b/cmuxTests/BrowserInsecureHTTPAlertTestSupport.swift @@ -0,0 +1,40 @@ +import AppKit +import Foundation + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +extension BrowserPanel { + func configureInsecureHTTPAlertHooksForTesting( + alertFactory: @escaping () -> NSAlert, + windowProvider: @escaping () -> NSWindow? + ) { + insecureHTTPAlertFactory = alertFactory + insecureHTTPAlertWindowProvider = windowProvider + } + + func resetInsecureHTTPAlertHooksForTesting() { + insecureHTTPAlertFactory = { NSAlert() } + insecureHTTPAlertWindowProvider = { [weak self] in + if let self, let window = browserInteractiveModalHostWindow(for: self.webView) { + return window + } + return browserFallbackInteractiveModalHostWindow() + } + } + + func presentInsecureHTTPAlertForTesting( + url: URL, + recordTypedNavigation: Bool = false + ) { + presentInsecureHTTPAlert( + for: URLRequest(url: url), + intent: .currentTab, + recordTypedNavigation: recordTypedNavigation + ) + } +}