Fix SSH workspace priming and restore state

This commit is contained in:
Lawrence Chen 2026-03-13 20:01:26 -07:00
parent 2eae782739
commit 5e7458b920
7 changed files with 345 additions and 51 deletions

View file

@ -1987,16 +1987,26 @@ struct ContentView: View {
let isSelectedWorkspace = selectedWorkspaceId == tab.id
let isRetiringWorkspace = retiringWorkspaceId == tab.id
let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id)
let isRenderedVisible = isSelectedWorkspace || isRetiringWorkspace
let isWorkspaceVisibleToPanels = isRenderedVisible || shouldPrimeInBackground
let workspaceRenderOpacity: Double = {
if isRenderedVisible {
return 1
}
if shouldPrimeInBackground {
return 0.001
}
return 0
}()
// Keep the retiring workspace visible during handoff, but never input-active.
// Allowing both selected+retiring workspaces to be input-active lets the
// old workspace steal first responder (notably with WKWebView), which can
// delay handoff completion and make browser returns feel laggy.
let isInputActive = isSelectedWorkspace
let isVisible = isSelectedWorkspace || isRetiringWorkspace
let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)
WorkspaceContentView(
workspace: tab,
isWorkspaceVisible: isVisible,
isWorkspaceVisible: isWorkspaceVisibleToPanels,
isWorkspaceInputActive: isInputActive,
workspacePortalPriority: portalPriority,
onThemeRefreshRequest: { reason, eventId, source, payloadHex in
@ -2009,9 +2019,9 @@ struct ContentView: View {
)
}
)
.opacity(isVisible ? 1 : 0)
.opacity(workspaceRenderOpacity)
.allowsHitTesting(isSelectedWorkspace)
.accessibilityHidden(!isVisible)
.accessibilityHidden(!isRenderedVisible)
.zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0))
.task(id: shouldPrimeInBackground ? tab.id : nil) {
await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id)

View file

@ -1798,6 +1798,13 @@ final class BrowserPanel: Panel, ObservableObject {
private let developerToolsRestoreRetryMaxAttempts: Int = 40
private var remoteProxyEndpoint: BrowserProxyEndpoint?
@Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus?
private let usesRemoteWorkspaceProxy: Bool
private struct PendingRemoteNavigation {
let request: URLRequest
let recordTypedNavigation: Bool
let preserveRestoredSessionHistory: Bool
}
private var pendingRemoteNavigation: PendingRemoteNavigation?
private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35
private var developerToolsDetachedOpenGraceDeadline: Date?
private var developerToolsTransitionTargetVisible: Bool?
@ -2045,15 +2052,17 @@ final class BrowserPanel: Panel, ObservableObject {
initialURL: URL? = nil,
bypassInsecureHTTPHostOnce: String? = nil,
proxyEndpoint: BrowserProxyEndpoint? = nil,
isRemoteWorkspace: Bool = false
isRemoteWorkspace: Bool = false,
remoteWebsiteDataStoreIdentifier: UUID? = nil
) {
self.id = UUID()
self.workspaceId = workspaceId
self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "")
self.remoteProxyEndpoint = proxyEndpoint
self.usesRemoteWorkspaceProxy = isRemoteWorkspace
self.browserThemeMode = BrowserThemeSettings.mode()
self.websiteDataStore = isRemoteWorkspace
? WKWebsiteDataStore(forIdentifier: self.id)
? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? workspaceId)
: .default()
let webView = Self.makeWebView(websiteDataStore: websiteDataStore)
@ -2143,6 +2152,7 @@ final class BrowserPanel: Panel, ObservableObject {
guard remoteProxyEndpoint != endpoint else { return }
remoteProxyEndpoint = endpoint
applyRemoteProxyConfigurationIfAvailable()
resumePendingRemoteNavigationIfNeeded()
}
func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) {
@ -2785,6 +2795,46 @@ final class BrowserPanel: Panel, ObservableObject {
preserveRestoredSessionHistory: Bool = false
) {
guard let url = request.url else { return }
if usesRemoteWorkspaceProxy, remoteProxyEndpoint == nil {
pendingRemoteNavigation = PendingRemoteNavigation(
request: request,
recordTypedNavigation: recordTypedNavigation,
preserveRestoredSessionHistory: preserveRestoredSessionHistory
)
shouldRenderWebView = true
currentURL = Self.remoteProxyDisplayURL(for: url) ?? url
navigationDelegate?.lastAttemptedURL = url
return
}
performNavigation(
request: request,
originalURL: url,
recordTypedNavigation: recordTypedNavigation,
preserveRestoredSessionHistory: preserveRestoredSessionHistory
)
}
private func resumePendingRemoteNavigationIfNeeded() {
guard remoteProxyEndpoint != nil,
let pendingRemoteNavigation else {
return
}
self.pendingRemoteNavigation = nil
guard let originalURL = pendingRemoteNavigation.request.url else { return }
performNavigation(
request: pendingRemoteNavigation.request,
originalURL: originalURL,
recordTypedNavigation: pendingRemoteNavigation.recordTypedNavigation,
preserveRestoredSessionHistory: pendingRemoteNavigation.preserveRestoredSessionHistory
)
}
private func performNavigation(
request: URLRequest,
originalURL: URL,
recordTypedNavigation: Bool,
preserveRestoredSessionHistory: Bool
) {
if !preserveRestoredSessionHistory {
abandonRestoredSessionHistoryIfNeeded()
}
@ -2793,9 +2843,9 @@ final class BrowserPanel: Panel, ObservableObject {
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
shouldRenderWebView = true
if recordTypedNavigation {
BrowserHistoryStore.shared.recordTypedNavigation(url: url)
BrowserHistoryStore.shared.recordTypedNavigation(url: originalURL)
}
navigationDelegate?.lastAttemptedURL = url
navigationDelegate?.lastAttemptedURL = originalURL
browserLoadRequest(effectiveRequest, in: webView)
}

View file

@ -943,6 +943,9 @@ class TabManager: ObservableObject {
newWorkspace.owningTabManager = self
wireClosedBrowserTracking(for: newWorkspace)
let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride)
if eagerLoadTerminal && !select {
requestBackgroundWorkspaceLoad(for: newWorkspace.id)
}
var updatedTabs = snapshot.tabs
if insertIndex >= 0 && insertIndex <= updatedTabs.count {
updatedTabs.insert(newWorkspace, at: insertIndex)
@ -959,7 +962,9 @@ class TabManager: ObservableObject {
)
}
if eagerLoadTerminal {
newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded()
if select {
newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded()
}
}
if select {
#if DEBUG
@ -1169,21 +1174,33 @@ class TabManager: ObservableObject {
}
func requestBackgroundWorkspaceLoad(for workspaceId: UUID) {
guard pendingBackgroundWorkspaceLoadIds.insert(workspaceId).inserted else { return }
guard !pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return }
var updated = pendingBackgroundWorkspaceLoadIds
updated.insert(workspaceId)
pendingBackgroundWorkspaceLoadIds = updated
}
func completeBackgroundWorkspaceLoad(for workspaceId: UUID) {
guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return }
guard pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return }
var updated = pendingBackgroundWorkspaceLoadIds
updated.remove(workspaceId)
pendingBackgroundWorkspaceLoadIds = updated
}
func retainDebugWorkspaceLoads(for workspaceIds: Set<UUID>) {
guard !workspaceIds.isEmpty else { return }
debugPinnedWorkspaceLoadIds.formUnion(workspaceIds)
var updated = debugPinnedWorkspaceLoadIds
updated.formUnion(workspaceIds)
guard updated != debugPinnedWorkspaceLoadIds else { return }
debugPinnedWorkspaceLoadIds = updated
}
func releaseDebugWorkspaceLoads(for workspaceIds: Set<UUID>) {
guard !workspaceIds.isEmpty else { return }
debugPinnedWorkspaceLoadIds.subtract(workspaceIds)
var updated = debugPinnedWorkspaceLoadIds
updated.subtract(workspaceIds)
guard updated != debugPinnedWorkspaceLoadIds else { return }
debugPinnedWorkspaceLoadIds = updated
}
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
@ -4046,11 +4063,13 @@ extension TabManager {
}
func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot {
let workspaceSnapshots = tabs
let restorableTabs = tabs
.filter { !$0.isRemoteWorkspace }
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
let workspaceSnapshots = restorableTabs
.map { $0.sessionSnapshot(includeScrollback: includeScrollback) }
let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in
tabs.firstIndex(where: { $0.id == selectedTabId })
restorableTabs.firstIndex(where: { $0.id == selectedTabId })
}
return SessionTabManagerSnapshot(
selectedWorkspaceIndex: selectedWorkspaceIndex,

View file

@ -6256,9 +6256,12 @@ final class Workspace: Identifiable, ObservableObject {
}
private func remoteTerminalStartupCommand() -> String? {
guard hasActiveRemoteTerminalSessions else { return nil }
return remoteConfiguration?.terminalStartupCommand?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let command = remoteConfiguration?.terminalStartupCommand?
.trimmingCharacters(in: .whitespacesAndNewlines),
!command.isEmpty else {
return nil
}
return command
}
/// Create a new browser panel split
@ -6288,7 +6291,8 @@ final class Workspace: Identifiable, ObservableObject {
workspaceId: id,
initialURL: url,
proxyEndpoint: remoteProxyEndpoint,
isRemoteWorkspace: isRemoteWorkspace
isRemoteWorkspace: isRemoteWorkspace,
remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil
)
panels[browserPanel.id] = browserPanel
panelTitles[browserPanel.id] = browserPanel.displayTitle
@ -6357,7 +6361,8 @@ final class Workspace: Identifiable, ObservableObject {
initialURL: url,
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce,
proxyEndpoint: remoteProxyEndpoint,
isRemoteWorkspace: isRemoteWorkspace
isRemoteWorkspace: isRemoteWorkspace,
remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil
)
panels[browserPanel.id] = browserPanel
panelTitles[browserPanel.id] = browserPanel.displayTitle

View file

@ -47,6 +47,19 @@ final class GhosttyConfigTests: XCTestCase {
let blue: Int
}
private func writeAppSupportConfig(
root: URL,
bundleIdentifier: String,
name: String = "config",
contents: String = "font-size = 14\n"
) throws -> URL {
let directory = root.appendingPathComponent(bundleIdentifier, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent(name, isDirectory: false)
try contents.write(to: url, atomically: true, encoding: .utf8)
return url
}
func testResolveThemeNamePrefersLightEntryForPairedTheme() {
let resolved = GhosttyConfig.resolveThemeName(
from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark",
@ -312,48 +325,69 @@ final class GhosttyConfigTests: XCTestCase {
}
func testReleaseAppSupportFallbackLoadsForDebugWhenOnlyReleaseConfigExists() {
XCTAssertTrue(
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let releaseURL = try? writeAppSupportConfig(
root: root,
bundleIdentifier: "com.cmuxterm.app"
)
XCTAssertEqual(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app.debug",
currentConfigFileSize: nil,
currentLegacyConfigFileSize: nil,
releaseConfigFileSize: 128,
releaseLegacyConfigFileSize: nil
)
appSupportDirectory: root
),
[releaseURL].compactMap { $0 }
)
}
func testReleaseAppSupportFallbackSkipsWhenDebugConfigAlreadyExists() {
XCTAssertFalse(
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
_ = try? writeAppSupportConfig(root: root, bundleIdentifier: "com.cmuxterm.app")
let debugURL = try? writeAppSupportConfig(
root: root,
bundleIdentifier: "com.cmuxterm.app.debug.issue-829",
name: "config.ghostty"
)
XCTAssertEqual(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829",
currentConfigFileSize: nil,
currentLegacyConfigFileSize: 64,
releaseConfigFileSize: 128,
releaseLegacyConfigFileSize: nil
)
appSupportDirectory: root
),
[debugURL].compactMap { $0 }
)
}
func testReleaseAppSupportFallbackSkipsForNonDebugBundleOrMissingReleaseConfig() {
XCTAssertFalse(
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
_ = try? writeAppSupportConfig(root: root, bundleIdentifier: "com.cmuxterm.app")
XCTAssertEqual(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app",
currentConfigFileSize: nil,
currentLegacyConfigFileSize: nil,
releaseConfigFileSize: 128,
releaseLegacyConfigFileSize: nil
)
appSupportDirectory: root
).count,
1
)
XCTAssertFalse(
GhosttyApp.shouldLoadReleaseAppSupportGhosttyConfig(
XCTAssertEqual(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app.debug",
currentConfigFileSize: nil,
currentLegacyConfigFileSize: nil,
releaseConfigFileSize: nil,
releaseLegacyConfigFileSize: 0
)
appSupportDirectory: root.appendingPathComponent("missing", isDirectory: true)
),
[]
)
}
@ -831,12 +865,79 @@ final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase {
@MainActor
final class BrowserPanelRemoteStoreTests: XCTestCase {
func testRemoteWorkspaceUsesDedicatedWebsiteDataStore() {
func testRemoteWorkspacePanelsShareWorkspaceScopedWebsiteDataStore() {
let localPanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false)
let remotePanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: true)
let remoteWorkspaceId = UUID()
let firstRemotePanel = BrowserPanel(
workspaceId: remoteWorkspaceId,
isRemoteWorkspace: true,
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
)
let secondRemotePanel = BrowserPanel(
workspaceId: remoteWorkspaceId,
isRemoteWorkspace: true,
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
)
XCTAssertTrue(localPanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
XCTAssertFalse(remotePanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
XCTAssertFalse(firstRemotePanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default())
XCTAssertTrue(
firstRemotePanel.webView.configuration.websiteDataStore ===
secondRemotePanel.webView.configuration.websiteDataStore
)
}
func testRemoteWorkspaceDefersInitialNavigationUntilProxyEndpointIsReady() {
let remoteWorkspaceId = UUID()
let url = URL(string: "http://localhost:3000/demo")!
let panel = BrowserPanel(
workspaceId: remoteWorkspaceId,
initialURL: url,
isRemoteWorkspace: true,
remoteWebsiteDataStoreIdentifier: remoteWorkspaceId
)
XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString)
XCTAssertNil(panel.webView.url)
panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876))
let deadline = Date().addingTimeInterval(1.0)
while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {}
XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString)
XCTAssertEqual(panel.webView.url?.host, "cmux-loopback.localtest.me")
}
func testNewTerminalSurfaceStaysRemoteWhileBrowserPanelsKeepWorkspaceRemote() throws {
let workspace = Workspace()
let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first)
let initialTerminalId = try XCTUnwrap(workspace.focusedPanelId)
let configuration = WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: nil,
identityFile: nil,
sshOptions: [],
localProxyPort: nil,
relayPort: 64000,
relayID: "relay-test",
relayToken: String(repeating: "a", count: 64),
localSocketPath: "/tmp/cmux-test.sock",
terminalStartupCommand: "ssh cmux-macmini"
)
workspace.configureRemoteConnection(configuration, autoConnect: false)
_ = workspace.newBrowserSurface(inPane: paneId, url: URL(string: "https://example.com"), focus: false)
workspace.markRemoteTerminalSessionEnded(surfaceId: initialTerminalId, relayPort: configuration.relayPort)
XCTAssertTrue(workspace.isRemoteWorkspace)
XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0)
_ = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: false))
XCTAssertTrue(workspace.isRemoteWorkspace)
XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1)
}
}

View file

@ -46,4 +46,30 @@ final class TabManagerSessionSnapshotTests: XCTestCase {
XCTAssertEqual(manager.tabs.count, 1)
XCTAssertNotNil(manager.selectedTabId)
}
func testSessionSnapshotExcludesRemoteWorkspacesFromRestore() throws {
let manager = TabManager()
let remoteWorkspace = manager.addWorkspace(select: true)
let configuration = WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: nil,
identityFile: nil,
sshOptions: [],
localProxyPort: nil,
relayPort: 64001,
relayID: "relay-test",
relayToken: String(repeating: "b", count: 64),
localSocketPath: "/tmp/cmux-test.sock",
terminalStartupCommand: "ssh cmux-macmini"
)
remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false)
let paneId = try XCTUnwrap(remoteWorkspace.bonsplitController.allPaneIds.first)
_ = remoteWorkspace.newBrowserSurface(inPane: paneId, url: URL(string: "http://localhost:3000"), focus: false)
let snapshot = manager.sessionSnapshot(includeScrollback: false)
XCTAssertEqual(snapshot.workspaces.count, 1)
XCTAssertNil(snapshot.selectedWorkspaceIndex)
XCTAssertFalse(snapshot.workspaces.contains { $0.processTitle == remoteWorkspace.title })
}
}

View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Regression: background workspace.create should start its initial terminal before selection."""
from __future__ import annotations
import os
import shlex
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _wait_for_file_text(path: Path, needle: str, timeout_s: float = 8.0) -> str:
deadline = time.time() + timeout_s
last_text = ""
while time.time() < deadline:
if path.exists():
last_text = path.read_text(encoding="utf-8", errors="replace")
if needle in last_text:
return last_text
time.sleep(0.1)
raise cmuxError(f"Timed out waiting for {needle!r} in background workspace file: {last_text!r}")
def main() -> int:
with cmux(SOCKET_PATH) as c:
baseline_workspace = c.current_workspace()
created_workspace = ""
marker_path = Path(tempfile.gettempdir()) / f"cmux-bg-start-{int(time.time() * 1000)}.txt"
try:
token = f"CMUX_BG_START_{int(time.time() * 1000)}"
initial_command = (
"python3 -c " +
shlex.quote(
f"from pathlib import Path; Path({marker_path.as_posix()!r}).write_text({token!r}, encoding='utf-8')"
)
)
payload = c._call(
"workspace.create",
{"initial_command": initial_command},
) or {}
created_workspace = str(payload.get("workspace_id") or "")
_must(bool(created_workspace), f"workspace.create returned no workspace_id: {payload}")
_must(
c.current_workspace() == baseline_workspace,
"workspace.create should preserve selected workspace",
)
text = _wait_for_file_text(marker_path, token)
_must(token in text, f"Background workspace did not run its initial command: {text!r}")
_must(
c.current_workspace() == baseline_workspace,
"background eager load should not switch the selected workspace",
)
finally:
try:
marker_path.unlink()
except FileNotFoundError:
pass
if created_workspace:
try:
c.close_workspace(created_workspace)
except Exception:
pass
print("PASS: workspace.create eager background load starts the initial terminal without focus")
return 0
if __name__ == "__main__":
raise SystemExit(main())