Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion Sources/TabManager+SessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,19 @@ extension TabManager {
}

func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot {
// Only a *live* remote workspace is excluded -- a workspace the user disconnected from
// its remote host but kept using locally must not silently vanish from every snapshot
// for the rest of its life (see incident: three live local terminals lost on relaunch
// because `isRemoteWorkspace` alone stays true after disconnect). Whatever is skipped
// here is logged so the gap is never silent again.
for tab in tabs where tab.isLiveRemoteWorkspace {
dilog(
"session.snapshot",
"skipped workspace=\(tab.id.uuidString.prefix(8)) reason=remote panels=\(tab.panels.count)"
)
}
let restorableTabs = tabs
.filter { !$0.isRemoteWorkspace }
.filter { !$0.isLiveRemoteWorkspace }
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
let workspaceSnapshots = restorableTabs
.map { $0.sessionSnapshot(includeScrollback: includeScrollback) }
Expand Down
14 changes: 10 additions & 4 deletions Sources/TerminalController+Telemetry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,17 @@ extension TerminalController {
validSurfaceIds: validSurfaceIds
)
guard let surfaceId, validSurfaceIds.contains(surfaceId) else {
if tab.isRemoteWorkspace, validSurfaceIds.isEmpty {
// `isLiveRemoteWorkspace`, not `isRemoteWorkspace`: a disconnected-but-configured
// workspace has no active remote session to buffer this report for, and its
// panels are ordinary local shells again -- route it like any local workspace.
if tab.isLiveRemoteWorkspace, validSurfaceIds.isEmpty {
tab.rememberPendingRemoteSurfaceTTY(ttyName, requestedSurfaceId: requestedSurfaceId)
}
return
}

tab.surfaceTTYNames[surfaceId] = ttyName
if tab.isRemoteWorkspace {
if tab.isLiveRemoteWorkspace {
tab.syncRemotePortScanTTYs()
_ = tab.applyPendingRemoteSurfacePortKickIfNeeded(to: surfaceId)
} else {
Expand Down Expand Up @@ -127,7 +130,10 @@ extension TerminalController {
validSurfaceIds: validSurfaceIds
)
guard let surfaceId, validSurfaceIds.contains(surfaceId) else {
if tab.isRemoteWorkspace, validSurfaceIds.isEmpty {
// See v2SurfaceReportTTY above: `isLiveRemoteWorkspace`, not `isRemoteWorkspace`
// -- a disconnected-but-configured workspace has no active remote session to
// buffer this kick for.
if tab.isLiveRemoteWorkspace, validSurfaceIds.isEmpty {
tab.rememberPendingRemoteSurfacePortKick(
reason: reason,
requestedSurfaceId: requestedSurfaceId
Expand All @@ -136,7 +142,7 @@ extension TerminalController {
return
}

if tab.isRemoteWorkspace {
if tab.isLiveRemoteWorkspace {
tab.kickRemotePortScan(panelId: surfaceId, reason: reason)
} else {
PortScanner.shared.kick(workspaceId: workspaceId, panelId: surfaceId)
Expand Down
15 changes: 15 additions & 0 deletions Sources/Workspace+Remote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ extension Workspace {
remoteConfiguration != nil
}

/// Whether this workspace is a *live* remote session right now -- connected, connecting, or
/// erroring while still configured -- as opposed to merely having been configured for remote
/// at some point. `remoteConfiguration` intentionally survives a user-initiated disconnect
/// (see `disconnectRemoteConnection(clearConfiguration:)`, whose default and the sidebar's
/// disconnect action both pass `false`) so `reconnectRemoteConnection()` still has something
/// to reconnect to. That means `isRemoteWorkspace` alone is NOT a safe proxy for "this
/// workspace's panels currently live on a remote host" -- a disconnected-but-configured
/// workspace is, for every practical purpose (running shells, port telemetry, session
/// persistence), a local workspace again. Use this property anywhere that distinction
/// matters; use `isRemoteWorkspace` only for "has a remote destination configured" facts
/// (e.g. whether the sidebar should offer Reconnect).
var isLiveRemoteWorkspace: Bool {
isRemoteWorkspace && remoteConnectionState != .disconnected
}

@MainActor
func isRemoteTerminalSurface(_ panelId: UUID) -> Bool {
activeRemoteTerminalSurfaceIds.contains(panelId)
Expand Down
7 changes: 7 additions & 0 deletions programaTests/TabManagerSessionSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ final class TabManagerSessionSnapshotTests: XCTestCase {
XCTAssertNotNil(manager.selectedTabId)
}

/// Only a *live* remote workspace is excluded from restore -- `remoteConfiguration` alone
/// isn't enough, since it deliberately survives a user-initiated disconnect so Reconnect
/// keeps working (see `Workspace.isLiveRemoteWorkspace`'s doc comment). This test therefore
/// simulates an actually-connected remote session; the disconnected case (which must persist
/// its local panels) is covered by
/// `WorkspaceRemoteConnectionTests.testDisconnectedRemoteWorkspacePersistsLocalPanelsInSessionSnapshot`.
func testSessionSnapshotExcludesRemoteWorkspacesFromRestore() throws {
let manager = TabManager()
let remoteWorkspace = manager.addWorkspace(select: true)
Expand All @@ -63,6 +69,7 @@ final class TabManagerSessionSnapshotTests: XCTestCase {
terminalStartupCommand: "ssh cmux-macmini"
)
remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false)
remoteWorkspace.applyRemoteConnectionStateUpdate(.connected, detail: nil, target: "cmux-macmini")
let paneId = try XCTUnwrap(remoteWorkspace.bonsplitController.allPaneIds.first)
_ = remoteWorkspace.newBrowserSurface(inPane: paneId, url: URL(string: "http://localhost:3000"), focus: false)

Expand Down
60 changes: 60 additions & 0 deletions programaTests/WorkspaceRemoteConnectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,66 @@ final class WorkspaceRemoteConnectionTests: XCTestCase {
XCTAssertEqual(workspace.remoteConnectionState, .disconnected)
}

/// Regression for the "disconnect a remote workspace, keep using it locally, lose every
/// terminal in it at the next restart" incident: `remoteConfiguration` intentionally
/// survives a user-initiated disconnect (see `disconnectRemoteConnection`'s doc comment) so
/// `reconnectRemoteConnection()` keeps working, but that meant `isRemoteWorkspace` alone --
/// config presence, not live connection state -- excluded the whole workspace, and every
/// local pane in it, from `sessionSnapshot(includeScrollback:)` forever after disconnect.
@MainActor
func testDisconnectedRemoteWorkspacePersistsLocalPanelsInSessionSnapshot() throws {
let manager = TabManager()
let remoteWorkspace = manager.addWorkspace(select: true)
let paneId = try XCTUnwrap(remoteWorkspace.bonsplitController.allPaneIds.first)
let config = WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: nil,
identityFile: nil,
sshOptions: [],
localProxyPort: nil,
relayPort: 64034,
relayID: String(repeating: "a", count: 16),
relayToken: String(repeating: "b", count: 64),
localSocketPath: "/tmp/programa-debug-test.sock",
terminalStartupCommand: "ssh cmux-macmini"
)

// Configuring seeds the workspace's sole existing terminal panel as a tracked remote
// surface; two more splits created while still configured pick up the same tracking --
// mirroring the incident's three live remote-tracked terminals.
remoteWorkspace.configureRemoteConnection(config, autoConnect: false)
let firstPanelId = try XCTUnwrap(remoteWorkspace.focusedTerminalPanel?.id)
let secondPanelId = try XCTUnwrap(remoteWorkspace.newTerminalSurface(inPane: paneId, focus: false)?.id)
let thirdPanelId = try XCTUnwrap(remoteWorkspace.newTerminalSurface(inPane: paneId, focus: false)?.id)
for panelId in [firstPanelId, secondPanelId, thirdPanelId] {
XCTAssertTrue(remoteWorkspace.isRemoteTerminalSurface(panelId))
}

remoteWorkspace.applyRemoteConnectionStateUpdate(.connected, detail: nil, target: "cmux-macmini")
XCTAssertEqual(remoteWorkspace.remoteConnectionState, .connected)

// The user disconnects from the sidebar -- mirrors `TabItemView`'s disconnect action,
// which passes `clearConfiguration: false` so Reconnect keeps working.
remoteWorkspace.disconnectRemoteConnection(clearConfiguration: false)
XCTAssertTrue(remoteWorkspace.isRemoteWorkspace)
XCTAssertEqual(remoteWorkspace.remoteConnectionState, .disconnected)
for panelId in [firstPanelId, secondPanelId, thirdPanelId] {
XCTAssertFalse(remoteWorkspace.isRemoteTerminalSurface(panelId))
}

let snapshot = manager.sessionSnapshot(includeScrollback: false)
let restoredPanelIds = Set(
snapshot.workspaces
.first(where: { $0.panels.contains { $0.id == firstPanelId } })?
.panels
.map(\.id) ?? []
)

XCTAssertTrue(restoredPanelIds.contains(firstPanelId))
XCTAssertTrue(restoredPanelIds.contains(secondPanelId))
XCTAssertTrue(restoredPanelIds.contains(thirdPanelId))
}

@MainActor
func testRemoteTerminalSessionEndRequestsControlMasterCleanupWhenWorkspaceDemotes() throws {
let workspace = Workspace()
Expand Down
Loading