Show SSH heartbeat status on blank browser tabs

Propagate workspace remote state + heartbeat into browser panels, add heartbeat emission in remote session controller, extend docker bootstrap regression for heartbeat continuity after blank browser open, and scrub workspace/surface env vars in tagged reload launches.
This commit is contained in:
Lawrence Chen 2026-03-01 19:40:22 -08:00
parent 803912a9e3
commit 0d3d71661d
5 changed files with 279 additions and 9 deletions

View file

@ -1948,6 +1948,10 @@ private final class WorkspaceRemoteSessionController {
private var daemonRemotePath: String?
private var reconnectRetryCount = 0
private var reconnectWorkItem: DispatchWorkItem?
private var heartbeatWorkItem: DispatchWorkItem?
private var heartbeatCount: Int = 0
private static let heartbeatInterval: TimeInterval = 3.0
init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, controllerID: UUID) {
self.workspace = workspace
@ -1979,6 +1983,7 @@ private final class WorkspaceRemoteSessionController {
reconnectWorkItem?.cancel()
reconnectWorkItem = nil
reconnectRetryCount = 0
stopHeartbeatLocked(reset: true)
proxyLease?.release()
proxyLease = nil
@ -1994,6 +1999,7 @@ private final class WorkspaceRemoteSessionController {
guard !isStopping else { return }
reconnectWorkItem = nil
stopHeartbeatLocked(reset: true)
let connectDetail: String
let bootstrapDetail: String
if reconnectRetryCount > 0 {
@ -2072,7 +2078,10 @@ private final class WorkspaceRemoteSessionController {
reconnectWorkItem?.cancel()
reconnectWorkItem = nil
reconnectRetryCount = 0
guard proxyEndpoint != endpoint else { return }
guard proxyEndpoint != endpoint else {
startHeartbeatLocked()
return
}
proxyEndpoint = endpoint
publishProxyEndpoint(endpoint)
publishPortsSnapshotLocked()
@ -2080,8 +2089,10 @@ private final class WorkspaceRemoteSessionController {
.connected,
detail: "Connected to \(configuration.displayTarget) via shared local proxy \(endpoint.host):\(endpoint.port)"
)
startHeartbeatLocked()
case .error(let detail):
proxyEndpoint = nil
stopHeartbeatLocked(reset: false)
publishProxyEndpoint(nil)
publishPortsSnapshotLocked()
publishState(.error, detail: "Remote proxy to \(configuration.displayTarget) unavailable: \(detail)")
@ -2183,6 +2194,55 @@ private final class WorkspaceRemoteSessionController {
}
}
private func startHeartbeatLocked() {
guard !isStopping else { return }
guard daemonReady else { return }
guard proxyLease != nil else { return }
guard heartbeatWorkItem == nil else { return }
heartbeatCount += 1
publishHeartbeat(count: heartbeatCount, at: Date())
scheduleNextHeartbeatLocked()
}
private func scheduleNextHeartbeatLocked() {
guard !isStopping else { return }
guard daemonReady else { return }
guard proxyLease != nil else { return }
heartbeatWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.heartbeatWorkItem = nil
guard !self.isStopping else { return }
guard self.daemonReady else { return }
guard self.proxyLease != nil else { return }
self.heartbeatCount += 1
self.publishHeartbeat(count: self.heartbeatCount, at: Date())
self.scheduleNextHeartbeatLocked()
}
heartbeatWorkItem = workItem
queue.asyncAfter(deadline: .now() + Self.heartbeatInterval, execute: workItem)
}
private func stopHeartbeatLocked(reset: Bool) {
heartbeatWorkItem?.cancel()
heartbeatWorkItem = nil
if reset {
heartbeatCount = 0
publishHeartbeat(count: 0, at: nil)
}
}
private func publishHeartbeat(count: Int, at date: Date?) {
let controllerID = self.controllerID
DispatchQueue.main.async { [weak workspace] in
guard let workspace else { return }
guard workspace.activeRemoteSessionControllerID == controllerID else { return }
workspace.applyRemoteHeartbeatUpdate(count: count, lastSeenAt: date)
}
}
private func sshCommonArguments(batchMode: Bool) -> [String] {
let effectiveSSHOptions: [String] = {
if batchMode {
@ -3160,6 +3220,8 @@ final class Workspace: Identifiable, ObservableObject {
@Published var remoteForwardedPorts: [Int] = []
@Published var remotePortConflicts: [Int] = []
@Published var remoteProxyEndpoint: BrowserProxyEndpoint?
@Published var remoteHeartbeatCount: Int = 0
@Published var remoteLastHeartbeatAt: Date?
@Published var listeningPorts: [Int] = []
var surfaceTTYNames: [UUID: String] = [:]
private var remoteSessionController: WorkspaceRemoteSessionController?
@ -3170,6 +3232,11 @@ final class Workspace: Identifiable, ObservableObject {
private static let remoteErrorStatusKey = "remote.error"
private static let remotePortConflictStatusKey = "remote.port_conflicts"
private static let remoteHeartbeatDateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:]
private static func isProxyOnlyRemoteError(_ detail: String) -> Bool {
@ -3478,6 +3545,25 @@ final class Workspace: Identifiable, ObservableObject {
}
panelSubscriptions[browserPanel.id] = subscription
}
private func browserRemoteWorkspaceStatusSnapshot() -> BrowserRemoteWorkspaceStatus? {
guard let target = remoteDisplayTarget else { return nil }
return BrowserRemoteWorkspaceStatus(
target: target,
connectionState: remoteConnectionState,
heartbeatCount: remoteHeartbeatCount,
lastHeartbeatAt: remoteLastHeartbeatAt
)
}
private func applyBrowserRemoteWorkspaceStatusToPanels() {
let snapshot = browserRemoteWorkspaceStatusSnapshot()
for panel in panels.values {
guard let browserPanel = panel as? BrowserPanel else { continue }
browserPanel.setRemoteWorkspaceStatus(snapshot)
}
}
// MARK: - Panel Access
func panel(for surfaceId: TabID) -> (any Panel)? {
@ -3906,6 +3992,14 @@ final class Workspace: Identifiable, ObservableObject {
}
func remoteStatusPayload() -> [String: Any] {
let heartbeatAgeSeconds: Any = {
guard let last = remoteLastHeartbeatAt else { return NSNull() }
return max(0, Date().timeIntervalSince(last))
}()
let heartbeatTimestamp: Any = {
guard let last = remoteLastHeartbeatAt else { return NSNull() }
return Self.remoteHeartbeatDateFormatter.string(from: last)
}()
var payload: [String: Any] = [
"enabled": remoteConfiguration != nil,
"state": remoteConnectionState.rawValue,
@ -3915,6 +4009,11 @@ final class Workspace: Identifiable, ObservableObject {
"forwarded_ports": remoteForwardedPorts,
"conflicted_ports": remotePortConflicts,
"detail": remoteConnectionDetail ?? NSNull(),
"heartbeat": [
"count": remoteHeartbeatCount,
"last_seen_at": heartbeatTimestamp,
"age_seconds": heartbeatAgeSeconds,
],
]
if let endpoint = remoteProxyEndpoint {
payload["proxy"] = [
@ -3965,6 +4064,8 @@ final class Workspace: Identifiable, ObservableObject {
remoteForwardedPorts = []
remotePortConflicts = []
remoteProxyEndpoint = nil
remoteHeartbeatCount = 0
remoteLastHeartbeatAt = nil
remoteConnectionDetail = nil
remoteDaemonStatus = WorkspaceRemoteDaemonStatus()
statusEntries.removeValue(forKey: Self.remoteErrorStatusKey)
@ -3979,13 +4080,16 @@ final class Workspace: Identifiable, ObservableObject {
remoteSessionController = nil
previousController?.stop()
applyRemoteProxyEndpointUpdate(nil)
applyBrowserRemoteWorkspaceStatusToPanels()
guard autoConnect else {
remoteConnectionState = .disconnected
applyBrowserRemoteWorkspaceStatusToPanels()
return
}
remoteConnectionState = .connecting
applyBrowserRemoteWorkspaceStatusToPanels()
let controllerID = UUID()
let controller = WorkspaceRemoteSessionController(
workspace: self,
@ -4011,6 +4115,8 @@ final class Workspace: Identifiable, ObservableObject {
remoteForwardedPorts = []
remotePortConflicts = []
remoteProxyEndpoint = nil
remoteHeartbeatCount = 0
remoteLastHeartbeatAt = nil
remoteConnectionState = .disconnected
remoteConnectionDetail = nil
remoteDaemonStatus = WorkspaceRemoteDaemonStatus()
@ -4023,6 +4129,7 @@ final class Workspace: Identifiable, ObservableObject {
remoteConfiguration = nil
}
applyRemoteProxyEndpointUpdate(nil)
applyBrowserRemoteWorkspaceStatusToPanels()
recomputeListeningPorts()
}
@ -4037,6 +4144,7 @@ final class Workspace: Identifiable, ObservableObject {
) {
remoteConnectionState = state
remoteConnectionDetail = detail
applyBrowserRemoteWorkspaceStatusToPanels()
let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines)
if state == .error, let trimmedDetail, !trimmedDetail.isEmpty {
@ -4080,6 +4188,7 @@ final class Workspace: Identifiable, ObservableObject {
fileprivate func applyRemoteDaemonStatusUpdate(_ status: WorkspaceRemoteDaemonStatus, target: String) {
remoteDaemonStatus = status
applyBrowserRemoteWorkspaceStatusToPanels()
guard status.state == .error else {
remoteLastDaemonErrorFingerprint = nil
return
@ -4101,6 +4210,13 @@ final class Workspace: Identifiable, ObservableObject {
guard let browserPanel = panel as? BrowserPanel else { continue }
browserPanel.setRemoteProxyEndpoint(endpoint)
}
applyBrowserRemoteWorkspaceStatusToPanels()
}
fileprivate func applyRemoteHeartbeatUpdate(count: Int, lastSeenAt: Date?) {
remoteHeartbeatCount = max(0, count)
remoteLastHeartbeatAt = lastSeenAt
applyBrowserRemoteWorkspaceStatusToPanels()
}
fileprivate func applyRemotePortsSnapshot(detected: [Int], forwarded: [Int], conflicts: [Int], target: String) {
@ -4522,6 +4638,7 @@ final class Workspace: Identifiable, ObservableObject {
}
installBrowserPanelSubscription(browserPanel)
browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot())
return browserPanel
}
@ -4580,6 +4697,7 @@ final class Workspace: Identifiable, ObservableObject {
}
installBrowserPanelSubscription(browserPanel)
browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot())
return browserPanel
}
@ -5037,6 +5155,7 @@ final class Workspace: Identifiable, ObservableObject {
} else if let browserPanel = detached.panel as? BrowserPanel {
browserPanel.updateWorkspaceId(id)
browserPanel.setRemoteProxyEndpoint(remoteProxyEndpoint)
browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot())
installBrowserPanelSubscription(browserPanel)
}