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

@ -10,6 +10,13 @@ struct BrowserProxyEndpoint: Equatable {
let port: Int
}
struct BrowserRemoteWorkspaceStatus: Equatable {
let target: String
let connectionState: WorkspaceRemoteConnectionState
let heartbeatCount: Int
let lastHeartbeatAt: Date?
}
enum BrowserSearchEngine: String, CaseIterable, Identifiable {
case google
case duckduckgo
@ -1305,6 +1312,9 @@ final class BrowserPanel: Panel, ObservableObject {
/// Published loading state
@Published private(set) var isLoading: Bool = false
/// Snapshot of remote SSH connection status for this panel's workspace.
@Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus?
/// Published download state for browser downloads (navigation + context menu).
@Published private(set) var isDownloading: Bool = false
@ -1513,6 +1523,11 @@ final class BrowserPanel: Panel, ObservableObject {
applyRemoteProxyConfigurationIfAvailable()
}
func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) {
guard remoteWorkspaceStatus != status else { return }
remoteWorkspaceStatus = status
}
private func applyRemoteProxyConfigurationIfAvailable() {
guard #available(macOS 14.0, *) else { return }

View file

@ -729,19 +729,101 @@ struct BrowserPanelView: View {
}
})
} else {
Color(nsColor: browserChromeBackgroundColor)
.contentShape(Rectangle())
.onTapGesture {
onRequestPanelFocus()
if addressBarFocused {
addressBarFocused = false
}
ZStack(alignment: .topLeading) {
Color(nsColor: browserChromeBackgroundColor)
if let status = panel.remoteWorkspaceStatus {
remoteWorkspaceBlankStateIndicator(status)
.padding(.top, 12)
.padding(.leading, 12)
.allowsHitTesting(false)
}
}
.contentShape(Rectangle())
.onTapGesture {
onRequestPanelFocus()
if addressBarFocused {
addressBarFocused = false
}
}
}
}
.zIndex(0)
}
@ViewBuilder
private func remoteWorkspaceBlankStateIndicator(_ status: BrowserRemoteWorkspaceStatus) -> some View {
HStack(spacing: 8) {
Image(systemName: remoteStatusSymbolName(status.connectionState))
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(remoteStatusColor(status.connectionState))
VStack(alignment: .leading, spacing: 1) {
Text("SSH \(status.target)")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Color.primary.opacity(0.92))
.lineLimit(1)
Text(remoteStatusDetailText(status))
.font(.system(size: 10, weight: .regular))
.foregroundStyle(Color.primary.opacity(0.72))
.lineLimit(1)
}
.fixedSize(horizontal: true, vertical: false)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.85))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.primary.opacity(0.12), lineWidth: 1)
)
}
}
private func remoteStatusSymbolName(_ state: WorkspaceRemoteConnectionState) -> String {
switch state {
case .connected:
return "network"
case .connecting:
return "arrow.triangle.2.circlepath"
case .error:
return "exclamationmark.triangle.fill"
case .disconnected:
return "network.slash"
}
}
private func remoteStatusColor(_ state: WorkspaceRemoteConnectionState) -> Color {
switch state {
case .connected:
return .green
case .connecting:
return .orange
case .error:
return .red
case .disconnected:
return .secondary
}
}
private func remoteStatusDetailText(_ status: BrowserRemoteWorkspaceStatus) -> String {
switch status.connectionState {
case .connected:
guard let lastHeartbeat = status.lastHeartbeatAt else {
return "Connected, waiting for heartbeat"
}
let ageSeconds = max(0, Int(Date().timeIntervalSince(lastHeartbeat)))
return "Connected, heartbeat #\(status.heartbeatCount) \(ageSeconds)s ago"
case .connecting:
return "Connecting..."
case .error:
return "Connection error"
case .disconnected:
return "Disconnected"
}
}
private func triggerFocusFlashAnimation() {
focusFlashAnimationGeneration &+= 1
let generation = focusFlashAnimationGeneration

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)
}

View file

@ -392,6 +392,8 @@ fi
OPEN_CLEAN_ENV=(
env
-u CMUX_SOCKET_PATH
-u CMUX_WORKSPACE_ID
-u CMUX_SURFACE_ID
-u CMUX_TAB_ID
-u CMUX_PANEL_ID
-u CMUXD_UNIX_PATH

View file

@ -101,6 +101,29 @@ def _wait_for_remote_connected(client: cmux, workspace_id: str, timeout: float =
raise cmuxError(f"Remote did not converge to connected/ready under slow login profile: {last_status}")
def _heartbeat_count(status: dict) -> int:
remote = status.get("remote") or {}
heartbeat = remote.get("heartbeat") or {}
raw = heartbeat.get("count")
try:
return int(raw or 0)
except Exception: # noqa: BLE001
return 0
def _wait_for_heartbeat_advance(client: cmux, workspace_id: str, minimum_count: int, timeout: float = 20.0) -> dict:
deadline = time.time() + timeout
last_status: dict = {}
while time.time() < deadline:
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
if _heartbeat_count(last_status) >= minimum_count:
return last_status
time.sleep(0.5)
raise cmuxError(
f"Remote heartbeat did not advance to >= {minimum_count} within {timeout:.1f}s: {last_status}"
)
def main() -> int:
if not _docker_available():
print("SKIP: docker is not available")
@ -181,6 +204,36 @@ chmod 0644 "$HOME/.profile"
detail = str(remote.get("detail") or "").lower()
_must("timed out" not in detail, f"remote detail should not report bootstrap timeout: {status}")
baseline_heartbeat = _heartbeat_count(status)
status = _wait_for_heartbeat_advance(
client,
workspace_id,
minimum_count=max(1, baseline_heartbeat + 1),
timeout=15.0,
)
opened = client._call("browser.open_split", {"workspace_id": workspace_id}) or {}
browser_surface_id = str(opened.get("surface_id") or "")
_must(bool(browser_surface_id), f"browser.open_split returned no surface_id: {opened}")
after_open_heartbeat = _heartbeat_count(status)
status_after_blank_tab = _wait_for_heartbeat_advance(
client,
workspace_id,
minimum_count=after_open_heartbeat + 2,
timeout=20.0,
)
remote_after_blank_tab = status_after_blank_tab.get("remote") or {}
_must(
str(remote_after_blank_tab.get("state") or "") == "connected",
f"remote should remain connected after blank browser open: {status_after_blank_tab}",
)
heartbeat_payload = remote_after_blank_tab.get("heartbeat") or {}
_must(
heartbeat_payload.get("last_seen_at") is not None,
f"remote heartbeat should expose last_seen_at after bootstrap: {status_after_blank_tab}",
)
try:
client.close_workspace(workspace_id)
except Exception:
@ -203,4 +256,3 @@ chmod 0644 "$HOME/.profile"
if __name__ == "__main__":
raise SystemExit(main())