From 156b245422e1f9f9aad35836d35bf2b6a62191f9 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sat, 31 Oct 2026 02:22:38 +0000 Subject: [PATCH 1/5] cmux NEXT: an SSH machine whose reconnect gave up reads Offline; reconnect on use never takes over a session in use HQ #4877 G4: once a later daemon reconnect spends its timed retries, the connection tells the service (DaemonConnection.observeRetriesSpent), the store marks it (DaemonStore.retriesSpent, cleared by the next connect or a new attempt), and the SSH header reads Offline instead of Connecting forever. Reconnect on it restarts the link client and the daemon connection with a fresh retry budget (SSHMachineSession.restart), instead of waking the spent loop or doing nothing on a connected link. HQ #4877 G5: the pane's reconnect on use runs machine.reconnect with origin .page (was .user), machine.reconnect forwards its origin, and remote.reconnect skips a link whose session is in use by another cmux daemon unless a person asked (user or cli origin). paneUse reads such a link as stuck, so the pane does not wait on it. Tests: SSHMachineLinkRecoveryTests (not run locally: no Swift on this VM; CI runs it). Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4bbc87e99e6352cca67a --- .../CmuxNextApp/AgentTabs+Wiring.swift | 4 +- .../Handlers/RemoteGroupHandlers.swift | 2 +- .../CmuxNextApp/Handlers/RemoteHandlers.swift | 2 +- .../Remote/SSHMachineSession.swift | 17 +++ .../CmuxNextApp/Remote/SSHService.swift | 33 +---- .../Remote/SidebarBridge+SSH.swift | 14 ++- .../Connection/DaemonConnection.swift | 10 ++ .../Store/DaemonConnectionChange.swift | 1 + .../CmuxNextDaemon/Store/DaemonStore.swift | 10 ++ .../DaemonService+RetriesSpent.swift | 17 +++ .../CmuxNextDaemonService/DaemonService.swift | 2 +- .../SSH/SSHConnectionMachine.swift | 4 + .../CmuxNextRemote/SSH/SSHMachineLink.swift | 8 ++ .../CmuxNextRemote/SSH/SSHReconnectStep.swift | 45 +++++++ .../RemoteConnectRecoveryTests.swift | 8 +- .../SSHMachineLinkRecoveryTests.swift | 113 ++++++++++++++++++ 16 files changed, 249 insertions(+), 41 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService+RetriesSpent.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHReconnectStep.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift index 356d854eb0e0..ceb275f059a6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift @@ -151,7 +151,7 @@ extension AgentTabStore { // The environment picker's Cloud machines: the account's, with Open and Resume (`cloudMachine.*`). tabs.cloudMachines.wire(services) // The composer strip's machine dot: the sidebar header's same link state; using a machine - // that is down runs its header's Reconnect. + // that is down runs its header's Reconnect, as `.page`: never a takeover (HQ #4877 G5). tabs.machineLink.state = { [weak tabs, weak services] view in guard let tabs, let services, let key = tabs.views.first(where: { $0.value === view })?.key, let machine = tabs.remoteMachineID(of: key) else { return nil } @@ -161,7 +161,7 @@ extension AgentTabStore { services.map { SidebarBridge.paneUse(machine, in: $0.machines) } ?? .stuck } tabs.machineLink.reconnect = { [weak tabs] machine in - _ = tabs?.actionRegistry?.perform("machine.reconnect", invocation: ActionInvocation(target: ActionTargetRef(kind: .machine, id: machine), origin: .user)) + _ = tabs?.actionRegistry?.perform("machine.reconnect", invocation: ActionInvocation(target: ActionTargetRef(kind: .machine, id: machine), origin: .page)) } // The composer chips and shell commands chat pages held at the last quit (every relaunch's quit). tabs.relaunchWake = services.relaunchWake diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteGroupHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteGroupHandlers.swift index b3aba056cfde..788a0e84178a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteGroupHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteGroupHandlers.swift @@ -58,7 +58,7 @@ enum RemoteGroupHandlers { switch route(machine, machines) { case .wakeServer: machines.server(machine)?.wake() case .retryCloud: machines.session(machine)?.retry() - case .ssh: _ = registry.perform("remote.reconnect", invocation: ActionInvocation(target: ActionTargetRef(kind: .machine, id: machine))) + case .ssh: _ = registry.perform("remote.reconnect", invocation: ActionInvocation(target: ActionTargetRef(kind: .machine, id: machine), origin: invocation.origin)) case .none: throw ActionFailure.invalidTarget(WorkspaceVerbStrings.noMachine) } }) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteHandlers.swift index 36b9fed20e83..e3f2fd196d7b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteHandlers.swift @@ -106,7 +106,7 @@ enum RemoteHandlers { registry.track(work) } CloudHandlers.bind("remote.reconnect", registry, reason: hasMachine) { invocation in - ssh.reconnect(try machine(invocation, context)) + ssh.reconnect(try machine(invocation, context), byPerson: [.user, .cli].contains(invocation.origin)) } CloudHandlers.bind("remote.disconnect", registry, reason: hasMachine) { invocation in ssh.disconnect(try machine(invocation, context)) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHMachineSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHMachineSession.swift index 0e4292a1a71a..f98c19744012 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHMachineSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHMachineSession.swift @@ -61,6 +61,10 @@ final class SSHMachineSession { @ObservationIgnored var offersInstall = false @ObservationIgnored var onStatusChange: ((SSHMachineSession, SSHConnectionMachine.Status) -> Void)? @ObservationIgnored var connectRecovery: SSHConnectRecovery? + /// Its session is in use by another cmux daemon: only a person's Reconnect takes it over (G5). + var waitsForUser: Bool { if case .failed = linkStatus { link.waitsForUser } else { false } } + /// The daemon's reconnect spent its timed retries: Offline, not Connecting (HQ #4877 G4). + var retriesSpent: Bool { daemon.store.retriesSpent } /// Changes at the start of every user or automatic connection attempt, even when the /// transport and daemon report the same failure again. private(set) var connectionAttemptID: UInt64 = 0 @@ -185,9 +189,22 @@ final class SSHMachineSession { // mirror moves now, so the new attempt never shows the old failure. The link // then reports `.connecting` (already mirrored) and the retry's own result. if linkStatus.needsRecovery { linkStatusChanged(.connecting) } + daemon.store.noteRetriesSpent(false) daemon.beginAttempt() } + /// Disconnect then Connect, with a new retry budget: Reconnect once the retries are spent. + func restart() { + daemon.shutdownConnection() + beginConnectionAttempt() + linkStatus = .connecting + // task-owner: teardown hop; connect() runs once the old link client is stopped + Task { [weak self, link] in + await link.stop() + if self?.autoConnect == true { self?.connect() } + } + } + /// Reopens a daemon attempt without disturbing an already-connected SSH transport. func reconnectDaemon() { beginConnectionAttempt() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHService.swift index ce7c252e91fa..b648fd3e2446 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHService.swift @@ -214,13 +214,16 @@ final class SSHService { return text } - func reconnect(_ session: SSHMachineSession) { - guard !policyDisabled else { return } + /// Only a person's Reconnect (`byPerson`) takes over a session in use elsewhere (HQ #4877 G5). + func reconnect(_ session: SSHMachineSession, byPerson: Bool = true) { + guard !policyDisabled, byPerson || !session.waitsForUser else { return } // A retry gets a fresh error from the new attempt. Do not let the // recovery sheet copy the previous attempt while the link is working. session.lastError = nil - switch Self.reconnectStep(link: session.linkStatus, daemon: session.daemon.startup, daemonFailed: session.daemonFailure != nil) { + switch SSHReconnectStep(link: session.linkStatus, daemonStarted: session.daemon.startup == .connected, + daemonFailed: session.daemonFailure != nil, retriesSpent: session.retriesSpent) { case .connect: session.connect() + case .restart: session.restart() case .reconnectDaemon: session.reconnectDaemon() case .wake: session.wake(.user) case .newAttempt: @@ -230,30 +233,6 @@ final class SSHService { } } - /// What Reconnect does for a link in status `link` whose daemon startup is `daemon`. - enum ReconnectStep: Equatable { case connect, reconnectDaemon, wake, newAttempt, nothing } - - static func reconnectStep(link: SSHConnectionMachine.Status, daemon: DaemonStartupState, daemonFailed: Bool) -> ReconnectStep { - switch link { - case .offline, .unreachable, .failed: - // These failures leave the gate open for automatic backoff, so a user - // Retry must start a fresh attempt rather than only waking the loop. - return .connect - case .connected: - // A healthy transport has no reconnect to perform. If only the daemon - // handshake failed, retry that daemon attempt explicitly; otherwise a - // plain Reconnect must not arm a timeout on a live connection. - return daemonFailed ? .reconnectDaemon : .nothing - case .reconnecting where daemon == .connected: - // A transient interruption under an established daemon connection: the link may - // recover with its socket and connection intact, and no didConnect would then cancel - // a new startup deadline. Only wake the link. - return .wake - default: - return .newAttempt - } - } - func disconnect(_ session: SSHMachineSession) { session.disconnect() updateSavedHost(session) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SidebarBridge+SSH.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SidebarBridge+SSH.swift index 7867f9daff34..1a8067b09666 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SidebarBridge+SSH.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SidebarBridge+SSH.swift @@ -37,8 +37,10 @@ extension SidebarBridge { } /// What a chat that needs machine `machineID` finds its link doing: only a link that dropped - /// or never came up reconnects on use (``MachineLinkState/reconnectsOnUse(_:)``). + /// or never came up reconnects on use (``MachineLinkState/reconnectsOnUse(_:)``), never an + /// SSH session in use elsewhere (HQ #4877 G5). static func paneUse(_ machineID: String, in machines: MachineRegistry) -> AgentPaneMachineLink.Use { + if machines.sshSession(machineID)?.waitsForUser == true { return .stuck } guard let status = machineHeader(machineID, in: machines)?.status else { return .stuck } switch status.linkState { case .connected: return .up @@ -58,18 +60,20 @@ extension SidebarBridge { static func sshStatus(_ session: SSHMachineSession, compatibility: DaemonCompatibility?) -> SidebarMachine.Status { let connected = if case .connected = session.daemon.store.connectionState { true } else { false } - let status = sshStatus(link: session.linkStatus, startupFailed: session.daemonFailure != nil, daemonConnected: connected, compatibility: compatibility) + let status = sshStatus(link: session.linkStatus, startupFailed: session.daemonFailure != nil, daemonConnected: connected, + compatibility: compatibility, retriesSpent: session.retriesSpent) return status == .connected && session.updateOffered ? .updateAvailable : status } /// `startupFailed`: the daemon gave up after SSH reached the machine /// (`SSHMachineSession.daemonFailure`): Failed to start, not Connecting (cx-zdh8). + /// `retriesSpent`: a later reconnect gave up: Offline, not Connecting forever (HQ #4877 G4). static func sshStatus(link: SSHConnectionMachine.Status, startupFailed: Bool, daemonConnected: Bool, - compatibility: DaemonCompatibility?) -> SidebarMachine.Status { + compatibility: DaemonCompatibility?, retriesSpent: Bool = false) -> SidebarMachine.Status { switch link { case .offline: return .offline case .installing: return .installing - case .reconnecting: return .reconnecting + case .reconnecting: return daemonConnected || !retriesSpent ? .reconnecting : .offline case .authFailed, .hostKeyUntrusted: return .authFailed case .unreachable: return .unreachable case .needsInstall(.protocolMismatch), .needsInstall(.refused(.protocolOlder)): return .updateRequired @@ -79,7 +83,7 @@ extension SidebarBridge { case .connecting, .connected: guard daemonConnected else { if let compatibility, compatibility.level == .incompatible, startupFailed { return .updateRequired } - return startupFailed ? .failed : .connecting + return startupFailed ? .failed : retriesSpent ? .offline : .connecting } if compatibility?.level == .limited { return .updateAvailable } return .connected diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift index b0ad18a89b32..6dfac1512a0a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift @@ -72,6 +72,8 @@ public actor DaemonConnection { var handoffHoldTimer: DemandTimer? /// Shows a request refused because it names a pane still opening (``PaneNotReady``); see ``observePaneNotReady(_:)``. private var paneNotReadyObserver: (@Sendable (PaneNotReady) -> Void)? + /// Told each time a reconnect fails with the timed retries spent; see ``observeRetriesSpent(_:)``. + private var retriesSpentObserver: (@Sendable () -> Void)? /// The launch's first snapshot, fetched right after the handshake (``prefetchSnapshot()``). var prefetchedSnapshot: (serial: UInt64, fetch: Task<(tree: DaemonTree, barrier: UInt64), any Error>)? @@ -208,6 +210,13 @@ public actor DaemonConnection { paneNotReadyObserver = observer } + /// Sets what is told when the reconnect loop's timed retries are spent and it waits for an + /// event only (a remote machine's header then reads offline, not connecting, HQ #4877 G4). + /// Runs on this connection's executor, after each failed attempt past the budget. + public func observeRetriesSpent(_ observer: (@Sendable () -> Void)?) { + retriesSpentObserver = observer + } + /// The event sequence this connection has routed so far, or nil when /// not connected. Taken after a command's reply, it is a write barrier: /// once `DaemonStore.appliedSequence` reaches it, the store reflects @@ -378,6 +387,7 @@ public actor DaemonConnection { } if pacer.isExhausted { logger.info("cmux-tui reconnect: timed retries spent; waiting for the daemon socket or another event") + retriesSpentObserver?() } } reconnectTask = nil diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Store/DaemonConnectionChange.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Store/DaemonConnectionChange.swift index 4e14d3fa8557..426859363d3e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Store/DaemonConnectionChange.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Store/DaemonConnectionChange.swift @@ -12,6 +12,7 @@ enum DaemonConnectionChange { store.sideEvents.noteControl(envelope) store.session.connected(servesStateResources: identity.supports(DaemonCapability.stateResources.rawValue)) store.connectionState = .connected(identity) + store.noteRetriesSpent(false) store.noteHandshake(identity) return .resync case .disconnected(let reason): diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Store/DaemonStore.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Store/DaemonStore.swift index 32ab7e6e85af..4ab7dc3aea5b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Store/DaemonStore.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Store/DaemonStore.swift @@ -378,6 +378,16 @@ public final class DaemonStore: StateResourceQueries { connectionState = .failed(message) } + /// A remote machine's reconnect spent its timed retries and waits for an event only + /// (`DaemonConnection.observeRetriesSpent`): its header reads offline, not connecting (HQ + /// #4877 G4). Cleared by the next connect and by a new attempt. + public private(set) var retriesSpent = false + + public func noteRetriesSpent(_ spent: Bool) { + if spent, case .connected = connectionState { return } + if retriesSpent != spent { retriesSpent = spent } + } + // MARK: Derived state /// Indexes the whole tree again, primes every record and flattens the diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService+RetriesSpent.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService+RetriesSpent.swift new file mode 100644 index 000000000000..63fb6fa028af --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService+RetriesSpent.swift @@ -0,0 +1,17 @@ +public import CmuxNextDaemon + +extension DaemonService { + /// A remote machine's connection, as the service starts using it: requests refused for a pane + /// still opening show their notice, and a reconnect that spent its timed retries marks the + /// store (``DaemonStore/retriesSpent``), so its header reads offline instead of connecting + /// forever (HQ #4877 G4). A hop that lands after the link came back changes nothing + /// (``DaemonStore/noteRetriesSpent(_:)``). + func observeRemote(_ connection: DaemonConnection) async { + await connection.observePaneNotReady(paneNotReadyReporter) + let store = store + await connection.observeRetriesSpent { + // task-owner: one hop to main per failed attempt past the budget; nothing to cancel + Task { @MainActor in store.noteRetriesSpent(true) } + } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift index 0b0e5a1940a5..06d069dcc4de 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift @@ -337,7 +337,7 @@ public final class DaemonService { await connection.close() return } - await connection.observePaneNotReady(self.paneNotReadyReporter) + await self.observeRemote(connection) self.didConnect(connection, identity: identity) logger.info("\(machineID, privacy: .public): cmux-tui \(identity.version, privacy: .public) session \(identity.session, privacy: .public)") await store.run(connection: connection, scheduler: drainScheduler) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHConnectionMachine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHConnectionMachine.swift index 13d1514f42df..b325ccbabe47 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHConnectionMachine.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHConnectionMachine.swift @@ -81,6 +81,10 @@ public struct SSHConnectionMachine: Hashable, Sendable { public init() {} + /// Only Reconnect may open the gate now: the machine's session is in use by another cmux + /// daemon. Nothing automatic (reconnect on use included) may take it over (HQ #4877 G5). + public var waitsForUser: Bool { blocked && onlyUserUnblocks } + /// Whether the link may probe and dial now. public var mayAttempt: Bool { switch status { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHMachineLink.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHMachineLink.swift index dccb279dbac8..cfd67ed8c0d5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHMachineLink.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHMachineLink.swift @@ -1,4 +1,5 @@ import CmuxNextCloud +import CmuxNextCompat import CryptoKit public import Foundation import os @@ -81,6 +82,8 @@ public actor SSHMachineLink { private let environment: @Sendable () async -> [String: String] private let onStatus: @Sendable (SSHConnectionMachine.Status) -> Void private var machine = SSHConnectionMachine() + /// ``SSHConnectionMachine/waitsForUser``, mirrored at each event for readers off the actor. + private nonisolated let waitsForUserMirror = Mutex(false) private var child: ChildProcess? /// The stderr tail of the last link that exited. private var exitedOutput: String? @@ -106,6 +109,9 @@ public actor SSHMachineLink { } public var status: SSHConnectionMachine.Status { machine.status } + /// Only Reconnect may open the gate (``SSHConnectionMachine/waitsForUser``): read without a hop, + /// set before the status that follows it is reported. + public nonisolated var waitsForUser: Bool { waitsForUserMirror.withLock { $0 } } /// The link's stderr tail (ssh and the machine's cmux-tui write their /// errors there, the daemon's start failure included): the running @@ -120,6 +126,8 @@ public actor SSHMachineLink { public func handle(_ event: SSHConnectionMachine.Event) { let before = machine.status machine.handle(event) + let waits = machine.waitsForUser + waitsForUserMirror.withLock { $0 = waits } if event == .disconnect { startGeneration += 1 starting?.cancel() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHReconnectStep.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHReconnectStep.swift new file mode 100644 index 000000000000..a57fbe37d508 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHReconnectStep.swift @@ -0,0 +1,45 @@ +/// What Reconnect does for an SSH machine whose link is in `link`, given how its daemon +/// connection stands (`SSHService.reconnect` runs it). +public enum SSHReconnectStep: Equatable, Sendable { + /// Connect again (the link's gate opens, the daemon loop wakes). + case connect + /// Stop the link client and the daemon connection, then connect with a new retry budget. + case restart + /// Retry the daemon handshake on a connected link. + case reconnectDaemon + /// Wake the link only. + case wake + /// Stamp a new attempt, then wake the link. + case newAttempt + case nothing + + /// - Parameters: + /// - daemonStarted: the daemon's first connect finished (`DaemonStartupState.connected`). + /// - daemonFailed: the daemon gave up after SSH reached the machine. + /// - retriesSpent: a later daemon reconnect spent its timed retries; nothing tries any + /// more, so a wake would try once on the same link client (HQ #4877 G4). + public init(link: SSHConnectionMachine.Status, daemonStarted: Bool, daemonFailed: Bool, retriesSpent: Bool = false) { + if retriesSpent, link == .connecting || link == .connected || link == .reconnecting { + self = .restart + return + } + switch link { + case .offline, .unreachable, .failed: + // These failures leave the gate open for automatic backoff, so a user + // Retry must start a fresh attempt rather than only waking the loop. + self = .connect + case .connected: + // A healthy transport has no reconnect to perform. If only the daemon + // handshake failed, retry that daemon attempt explicitly; otherwise a + // plain Reconnect must not arm a timeout on a live connection. + self = daemonFailed ? .reconnectDaemon : .nothing + case .reconnecting where daemonStarted: + // A transient interruption under an established daemon connection: the link may + // recover with its socket and connection intact, and no didConnect would then cancel + // a new startup deadline. Only wake the link. + self = .wake + default: + self = .newAttempt + } + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/RemoteConnectRecoveryTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/RemoteConnectRecoveryTests.swift index bba1c0f0762b..0a8e3248319d 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/RemoteConnectRecoveryTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/RemoteConnectRecoveryTests.swift @@ -74,10 +74,10 @@ import Testing } @Test func reconnectDuringATransientInterruptionKeepsAnEstablishedDaemonsStartup() { - #expect(SSHService.reconnectStep(link: .reconnecting, daemon: .connected, daemonFailed: false) == .wake) - #expect(SSHService.reconnectStep(link: .reconnecting, daemon: .connecting, daemonFailed: false) == .newAttempt) - #expect(SSHService.reconnectStep(link: .connected, daemon: .connected, daemonFailed: false) == .nothing) - #expect(SSHService.reconnectStep(link: .offline, daemon: .connected, daemonFailed: false) == .connect) + #expect(SSHReconnectStep(link: .reconnecting, daemonStarted: true, daemonFailed: false) == .wake) + #expect(SSHReconnectStep(link: .reconnecting, daemonStarted: false, daemonFailed: false) == .newAttempt) + #expect(SSHReconnectStep(link: .connected, daemonStarted: true, daemonFailed: false) == .nothing) + #expect(SSHReconnectStep(link: .offline, daemonStarted: true, daemonFailed: false) == .connect) } @Test func aRetryChangesTheDaemonObservationForAnIdenticalFailure() { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift new file mode 100644 index 000000000000..69fe044f04ce --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift @@ -0,0 +1,113 @@ +@testable import CmuxNextApp +import CmuxNextActions +import CmuxNextAgentPane +@testable import CmuxNextDaemon +import CmuxNextDaemonService +import CmuxNextRemote +import CmuxNextSidebar +import CmuxNextWakeups +import Foundation +import Testing + +/// HQ #4877 G4 and G5: an SSH machine whose later reconnect spent its retries reads Offline with +/// a Reconnect that starts a fresh connect, never Connecting forever; and reconnect on use never +/// takes over a session another cmux daemon holds, which only a person's Reconnect may. +@MainActor @Suite(.serialized) struct SSHMachineLinkRecoveryTests { + private static func session() throws -> SSHMachineSession { + let host = try SSHHost(destination: SSHDestination(parsing: "nohost.invalid")) + return SSHMachineSession(host: host, binary: URL(fileURLWithPath: "/usr/bin/false"), + paths: SSHPaths(root: URL(fileURLWithPath: "/private/tmp/ssh-link-recovery-tests")), + environment: { [:] }, mainWork: MainWorkScheduler(budget: MainFrameBudget())) + } + + private static func service(with session: SSHMachineSession? = nil) -> SSHService { + let machines = MachineRegistry(local: DaemonService()) + if let session { machines.add(session) } + return SSHService(machines: machines, bundleID: nil, mainWork: MainWorkScheduler(budget: MainFrameBudget())) + } + + private static func identity() throws -> DaemonIdentity { + let json = #"{"app":"cmux-tui","protocol":1,"session":"ssh-link-recovery-test","generation":"g1"}"# + return try JSONDecoder().decode(DaemonIdentity.self, from: Data(json.utf8)) + } + + // MARK: G4 + + @Test func aSpentReconnectReadsOfflineNotConnecting() { + for link: SSHConnectionMachine.Status in [.connecting, .connected, .reconnecting] { + #expect(SidebarBridge.sshStatus(link: link, startupFailed: false, daemonConnected: false, compatibility: nil, + retriesSpent: true) == .offline, "\(link)") + #expect(SidebarBridge.sshStatus(link: link, startupFailed: false, daemonConnected: true, compatibility: nil, + retriesSpent: true) == (link == .reconnecting ? .reconnecting : .connected)) + } + #expect(SidebarBridge.sshStatus(link: .connecting, startupFailed: false, daemonConnected: false, compatibility: nil) == .connecting) + #expect(SidebarBridge.sshStatus(link: .connected, startupFailed: true, daemonConnected: false, compatibility: nil, + retriesSpent: true) == .failed, "A startup failure keeps its own word") + #expect(MachineLinkState.reconnectsOnUse(.offline), "Using the machine reconnects it") + } + + @Test func reconnectAfterSpentRetriesIsAFreshConnect() { + for link: SSHConnectionMachine.Status in [.connecting, .connected, .reconnecting] { + #expect(SSHReconnectStep(link: link, daemonStarted: true, daemonFailed: false, retriesSpent: true) == .restart) + } + #expect(SSHReconnectStep(link: .offline, daemonStarted: true, daemonFailed: false, retriesSpent: true) == .connect) + #expect(SSHReconnectStep(link: .connected, daemonStarted: true, daemonFailed: false) == .nothing) + } + + @Test func theStoreMarksSpentRetriesUntilTheNextConnect() throws { + let store = DaemonStore() + store.connectionState = .disconnected("link lost") + store.noteRetriesSpent(true) + #expect(store.retriesSpent) + _ = DaemonConnectionChange.connected(try Self.identity(), nil).apply(to: store) + #expect(!store.retriesSpent, "A reconnect clears it") + store.noteRetriesSpent(true) + #expect(!store.retriesSpent, "A hop that lands after the link came back changes nothing") + } + + @Test func reconnectOnASpentSessionStartsANewAttemptAndClearsTheMark() throws { + let session = try Self.session() + session.linkStatus = .connected + session.daemon.store.connectionState = .disconnected("link lost") + session.daemon.store.noteRetriesSpent(true) + #expect(SidebarBridge.sshStatus(session, compatibility: nil) == .offline) + Self.service().reconnect(session) + #expect(session.connectionAttemptID == 1) + #expect(!session.retriesSpent) + #expect(session.linkStatus == .connecting) + session.close() + } + + // MARK: G5 + + @Test func aSessionInUseWaitsForAPerson() { + var machine = SSHConnectionMachine() + machine.handle(.connect) + machine.handle(.failed(.sessionInUse("in use by another cmux daemon"))) + #expect(machine.waitsForUser) + machine.handle(.wake(.appActivated)) + #expect(machine.waitsForUser) + machine.handle(.wake(.user)) + #expect(!machine.waitsForUser) + + var other = SSHConnectionMachine() + other.handle(.connect) + other.handle(.failed(.remoteFailed("link closed during handshake"))) + #expect(!other.waitsForUser) + } + + @Test func reconnectOnUseLeavesASessionInUseAndAPersonTakesItOver() async throws { + let session = try Self.session() + await session.link.handle(.connect) + await session.link.handle(.failed(.sessionInUse("in use by another cmux daemon"))) + session.linkStatus = .failed("in use by another cmux daemon") + #expect(session.waitsForUser) + let service = Self.service(with: session) + #expect(SidebarBridge.paneUse(session.machineID, in: service.machines) == .stuck) + service.reconnect(session, byPerson: false) + #expect(session.connectionAttemptID == 0, "Reconnect on use starts nothing") + service.reconnect(session, byPerson: true) + #expect(session.connectionAttemptID == 1, "A person's Reconnect takes it over") + session.close() + } +} From 81ca52959df541f32cf31fb2af20cf2107d45309 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sat, 31 Oct 2026 02:35:50 +0000 Subject: [PATCH 2/5] cmux NEXT: a busy daemon past the reconnect budget is not "retries spent" Review follow-up on HQ #4877 G4: a server.busy refusal keeps a timed retry (RetryPacer.refusedBusy), so it must not mark the store offline. RetryPacer.waitsForEventOnly gates the retries-spent report. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4bbc87e99e6352cca67a --- .../Connection/DaemonConnection.swift | 3 ++- .../CmuxNextDaemon/Connection/RetryPacer.swift | 4 ++++ .../BusyRefusalReconnectTests.swift | 13 +++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift index 6dfac1512a0a..951d4432a6c8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift @@ -387,7 +387,8 @@ public actor DaemonConnection { } if pacer.isExhausted { logger.info("cmux-tui reconnect: timed retries spent; waiting for the daemon socket or another event") - retriesSpentObserver?() + // A busy daemon keeps its timed retry: still reconnecting, not offline. + if pacer.waitsForEventOnly { retriesSpentObserver?() } } } reconnectTask = nil diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/RetryPacer.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/RetryPacer.swift index 948ea2d236c4..8e5d1cd92ed9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/RetryPacer.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/RetryPacer.swift @@ -62,6 +62,10 @@ public struct RetryPacer: Sendable { /// The timed-retry budget is spent: wait for an event only. public var isExhausted: Bool { failures >= policy.timedRetries } + /// The next wait arms no timer: the budget is spent and the last attempt was not a busy + /// refusal (``refusedBusy()``), whose daemon is alive and keeps a timed retry. + public var waitsForEventOnly: Bool { isExhausted && !busyRefusal } + /// A socket change may wake the next wait: false in a crash loop. public var wakesOnSocket: Bool { socketFailures < policy.socketRetries } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDaemonTests/BusyRefusalReconnectTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDaemonTests/BusyRefusalReconnectTests.swift index b31202592b03..636c2ad0d939 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextDaemonTests/BusyRefusalReconnectTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDaemonTests/BusyRefusalReconnectTests.swift @@ -28,6 +28,19 @@ import Testing #expect(pacer.failed() == nil, "a real failure still spends the budget") } + /// Past the budget, a busy refusal still keeps a timed retry: the reconnect is not spent, so a + /// remote machine's header does not read offline while its daemon is only busy (HQ #4877 G4). + @Test func aBusyRefusalPastTheBudgetIsNotEventOnly() { + var pacer = RetryPacer(RetryPolicy(initial: .milliseconds(250), maximum: .seconds(30), timedRetries: 1)) + _ = pacer.failed() + #expect(pacer.waitsForEventOnly) + pacer.refusedBusy() + #expect(pacer.isExhausted) + #expect(!pacer.waitsForEventOnly) + _ = pacer.failed() + #expect(pacer.waitsForEventOnly) + } + /// A busy refusal noted by a reconnect loop that then stopped does not make the next drop's /// first real failure free. @Test func aResetForgetsAnUnspentBusyRefusal() { From 77564d20968703168932a2011e8e21910fde7127 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sat, 31 Oct 2026 02:48:28 +0000 Subject: [PATCH 3/5] cmux NEXT: a late retries-spent hop or a closed session's restart changes nothing Review follow-ups on HQ #4877 G4: the retries-spent hop applies only while its connection is still the service's (a Reconnect's restart replaced it), and is not reported from a cancelled or closed loop. SSHMachineSession.restart keeps its teardown task, cancelled by the next restart, Disconnect and close(), so a closed session never connects again. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4bbc87e99e6352cca67a --- .../CmuxNextApp/Remote/SSHMachineSession.swift | 13 ++++++++----- .../Sources/CmuxNextApp/Remote/SSHService.swift | 3 +-- .../CmuxNextApp/Remote/SidebarBridge+SSH.swift | 6 ++---- .../Connection/DaemonConnection.swift | 2 +- .../DaemonService+RetriesSpent.swift | 13 ++++++++----- .../SSHMachineLinkRecoveryTests.swift | 11 +++++++++++ 6 files changed, 31 insertions(+), 17 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHMachineSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHMachineSession.swift index f98c19744012..533415b7d0b0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHMachineSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHMachineSession.swift @@ -61,9 +61,8 @@ final class SSHMachineSession { @ObservationIgnored var offersInstall = false @ObservationIgnored var onStatusChange: ((SSHMachineSession, SSHConnectionMachine.Status) -> Void)? @ObservationIgnored var connectRecovery: SSHConnectRecovery? - /// Its session is in use by another cmux daemon: only a person's Reconnect takes it over (G5). + /// In use by another cmux daemon: only a person's Reconnect takes it over (G5). Retries spent: Offline (G4). var waitsForUser: Bool { if case .failed = linkStatus { link.waitsForUser } else { false } } - /// The daemon's reconnect spent its timed retries: Offline, not Connecting (HQ #4877 G4). var retriesSpent: Bool { daemon.store.retriesSpent } /// Changes at the start of every user or automatic connection attempt, even when the /// transport and daemon report the same failure again. @@ -71,6 +70,7 @@ final class SSHMachineSession { @ObservationIgnored var connectPresentationTask: Task? @ObservationIgnored private var statusTask: Task? @ObservationIgnored private var startupTask: Task? + @ObservationIgnored private var restartTask: Task? /// - Parameter mainWork: The app's main-thread scheduler (the local daemon's), for the machine's event batches. init(host: SSHHost, binary: URL, paths: SSHPaths, environment: @escaping @Sendable () async -> [String: String], @@ -198,10 +198,12 @@ final class SSHMachineSession { daemon.shutdownConnection() beginConnectionAttempt() linkStatus = .connecting - // task-owner: teardown hop; connect() runs once the old link client is stopped - Task { [weak self, link] in + // task-owner: teardown hop; cancelled by the next restart and by close(), so a closed session never connects + restartTask?.cancel() + restartTask = Task { [weak self, link] in await link.stop() - if self?.autoConnect == true { self?.connect() } + guard !Task.isCancelled, let self, self.autoConnect else { return } + self.connect() } } @@ -224,6 +226,7 @@ final class SSHMachineSession { func disconnect(keepAutoConnect: Bool = false) { if !keepAutoConnect { autoConnect = false } + restartTask?.cancel() daemon.shutdownConnection() linkStatus = .offline let link = link diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHService.swift index b648fd3e2446..e9d3f6d5ed4b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHService.swift @@ -214,9 +214,8 @@ final class SSHService { return text } - /// Only a person's Reconnect (`byPerson`) takes over a session in use elsewhere (HQ #4877 G5). func reconnect(_ session: SSHMachineSession, byPerson: Bool = true) { - guard !policyDisabled, byPerson || !session.waitsForUser else { return } + guard !policyDisabled, byPerson || !session.waitsForUser else { return } // only a person takes over a session in use (G5) // A retry gets a fresh error from the new attempt. Do not let the // recovery sheet copy the previous attempt while the link is working. session.lastError = nil diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SidebarBridge+SSH.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SidebarBridge+SSH.swift index 1a8067b09666..9704794e7c73 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SidebarBridge+SSH.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SidebarBridge+SSH.swift @@ -37,8 +37,7 @@ extension SidebarBridge { } /// What a chat that needs machine `machineID` finds its link doing: only a link that dropped - /// or never came up reconnects on use (``MachineLinkState/reconnectsOnUse(_:)``), never an - /// SSH session in use elsewhere (HQ #4877 G5). + /// or never came up reconnects on use (``MachineLinkState/reconnectsOnUse(_:)``), never an SSH session in use (G5). static func paneUse(_ machineID: String, in machines: MachineRegistry) -> AgentPaneMachineLink.Use { if machines.sshSession(machineID)?.waitsForUser == true { return .stuck } guard let status = machineHeader(machineID, in: machines)?.status else { return .stuck } @@ -60,8 +59,7 @@ extension SidebarBridge { static func sshStatus(_ session: SSHMachineSession, compatibility: DaemonCompatibility?) -> SidebarMachine.Status { let connected = if case .connected = session.daemon.store.connectionState { true } else { false } - let status = sshStatus(link: session.linkStatus, startupFailed: session.daemonFailure != nil, daemonConnected: connected, - compatibility: compatibility, retriesSpent: session.retriesSpent) + let status = sshStatus(link: session.linkStatus, startupFailed: session.daemonFailure != nil, daemonConnected: connected, compatibility: compatibility, retriesSpent: session.retriesSpent) return status == .connected && session.updateOffered ? .updateAvailable : status } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift index 951d4432a6c8..0069bedbf1a9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift @@ -388,7 +388,7 @@ public actor DaemonConnection { if pacer.isExhausted { logger.info("cmux-tui reconnect: timed retries spent; waiting for the daemon socket or another event") // A busy daemon keeps its timed retry: still reconnecting, not offline. - if pacer.waitsForEventOnly { retriesSpentObserver?() } + if pacer.waitsForEventOnly, !Task.isCancelled, !isClosedPhase { retriesSpentObserver?() } } } reconnectTask = nil diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService+RetriesSpent.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService+RetriesSpent.swift index 63fb6fa028af..fe9decb81cf4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService+RetriesSpent.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService+RetriesSpent.swift @@ -4,14 +4,17 @@ extension DaemonService { /// A remote machine's connection, as the service starts using it: requests refused for a pane /// still opening show their notice, and a reconnect that spent its timed retries marks the /// store (``DaemonStore/retriesSpent``), so its header reads offline instead of connecting - /// forever (HQ #4877 G4). A hop that lands after the link came back changes nothing - /// (``DaemonStore/noteRetriesSpent(_:)``). + /// forever (HQ #4877 G4). A hop that lands after the link came back + /// (``DaemonStore/noteRetriesSpent(_:)``) or after this connection was replaced or shut down + /// (a Reconnect's restart) changes nothing. func observeRemote(_ connection: DaemonConnection) async { await connection.observePaneNotReady(paneNotReadyReporter) - let store = store - await connection.observeRetriesSpent { + await connection.observeRetriesSpent { [weak self, weak connection] in // task-owner: one hop to main per failed attempt past the budget; nothing to cancel - Task { @MainActor in store.noteRetriesSpent(true) } + Task { @MainActor in + guard let self, let connection, self.connection === connection else { return } + self.store.noteRetriesSpent(true) + } } } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift index 69fe044f04ce..9f77cc2ea9c6 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift @@ -78,6 +78,17 @@ import Testing session.close() } + @Test func aRestartedSessionThatClosesNeverConnectsAgain() async throws { + let session = try Self.session() + session.linkStatus = .connected + session.daemon.store.noteRetriesSpent(true) + Self.service().reconnect(session) + session.close() + for _ in 0..<40 { await Task.yield() } + #expect(session.linkStatus == .offline, "The restart's connect was cancelled by close()") + #expect(session.connectionAttemptID == 1) + } + // MARK: G5 @Test func aSessionInUseWaitsForAPerson() { From a6efbbae1042043d0d925cb98514642458fa02e9 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sat, 31 Oct 2026 05:37:59 +0000 Subject: [PATCH 4/5] cmux NEXT: an automatic attempt keeps an in-use SSH session's user-only gate Review follow-up on HQ #4877 G5: SSHMachineLink.start sends .attemptStarted before it checks mayAttempt, and the reducer cleared onlyUserUnblocks on it, so after one automatic endpoint retry reconnect on use (and an app activation wake) could take over a session in use elsewhere. .attemptStarted now keeps the flag, like a wake; it reopens only a gate that was open. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4bbc87e99e6352cca67a --- .../Sources/CmuxNextRemote/SSH/SSHConnectionMachine.swift | 7 ++++++- .../CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHConnectionMachine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHConnectionMachine.swift index b325ccbabe47..1c116944efdb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHConnectionMachine.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextRemote/SSH/SSHConnectionMachine.swift @@ -95,7 +95,12 @@ public struct SSHConnectionMachine: Hashable, Sendable { public mutating func handle(_ event: Event) { if status == .offline, event != .connect { return } - if case .wake = event {} else { onlyUserUnblocks = false } + // A wake, or an automatic attempt the closed gate turns away, keeps an in-use session's + // user-only gate (HQ #4877 G5): `.attemptStarted` only ever reopens a gate that was open. + switch event { + case .wake, .attemptStarted: break + default: onlyUserUnblocks = false + } switch event { case .connect: status = .connecting diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift index 9f77cc2ea9c6..e71297c13ee2 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift @@ -98,6 +98,10 @@ import Testing #expect(machine.waitsForUser) machine.handle(.wake(.appActivated)) #expect(machine.waitsForUser) + machine.handle(.attemptStarted) + #expect(machine.waitsForUser, "An automatic attempt the gate turns away keeps it user-only") + machine.handle(.wake(.networkChanged)) + #expect(!machine.mayAttempt) machine.handle(.wake(.user)) #expect(!machine.waitsForUser) @@ -111,6 +115,7 @@ import Testing let session = try Self.session() await session.link.handle(.connect) await session.link.handle(.failed(.sessionInUse("in use by another cmux daemon"))) + await session.link.handle(.attemptStarted) session.linkStatus = .failed("in use by another cmux daemon") #expect(session.waitsForUser) let service = Self.service(with: session) From 09a1ccd4a5123a13d36a91d5a9902d2685f93cf4 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sat, 31 Oct 2026 05:54:08 +0000 Subject: [PATCH 5/5] cmux NEXT: remote.connect, remote.newGroup and reconnectWorkspace keep the in-use takeover person-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on HQ #4877 G5: SSH… on a saved host (remote.connect, remote.newGroup) and a workspace's Reconnect ran reconnect as a person whatever their caller. ActionInvocation.isPersonsRequest (user or cli) now decides byPerson on every action path, and SSHService.connect leaves a session in use alone for any other caller. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4bbc87e99e6352cca67a --- .../Sources/CmuxNextActions/ActionInvocation.swift | 4 ++++ .../Sources/CmuxNextApp/Handlers/RemoteGroupHandlers.swift | 2 +- .../Sources/CmuxNextApp/Handlers/RemoteHandlers.swift | 4 ++-- .../Sources/CmuxNextApp/Handlers/SSHTargetHandlers.swift | 2 +- .../CmuxNext/Sources/CmuxNextApp/Remote/SSHService.swift | 7 ++++--- .../Sources/CmuxNextApp/Remote/SidebarBridge+SSH.swift | 3 +-- .../CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift | 6 ++++++ 7 files changed, 19 insertions(+), 9 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionInvocation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionInvocation.swift index 1eb608aa772f..0310389c537f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionInvocation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionInvocation.swift @@ -118,6 +118,10 @@ public nonisolated struct ActionInvocation: Sendable, Hashable { origin == .user || focusRequested || arguments["focus"]?.boolValue == true } + /// A person asked for this run (a click, the palette, a key, a `cmux` command): only such a + /// run may take over a remote session another daemon holds (HQ #4877 G5). + public var isPersonsRequest: Bool { origin == .user || origin == .cli } + public subscript(_ name: String) -> ActionValue? { arguments[name] } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteGroupHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteGroupHandlers.swift index 1cfb1c5af2c2..8169fedc78e2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteGroupHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteGroupHandlers.swift @@ -31,7 +31,7 @@ enum RemoteGroupHandlers { // `cmux workspace group create --remote HOST --name N` names the group's header. let name = invocation["name"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) let make = { (text: String) throws in - let session = try ssh.connect(destination: text, session: nil, offerInstall: interactive) + let session = try ssh.connect(destination: text, session: nil, offerInstall: interactive, byPerson: invocation.isPersonsRequest) markWhenNamed(session, name: name) if interactive { RemoteHandlers.showWhenConnected(session, context) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteHandlers.swift index e3f2fd196d7b..678a88e05ac4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/RemoteHandlers.swift @@ -49,7 +49,7 @@ enum RemoteHandlers { let connect = { (text: String) in let session = try ssh.connect(destination: text, session: invocation["session"]?.stringValue, binary: invocation["binary"]?.stringValue, stateDir: invocation["stateDir"]?.stringValue, - offerInstall: interactive) + offerInstall: interactive, byPerson: invocation.isPersonsRequest) if interactive { showWhenConnected(session, context) } } if let text = invocation["destination"]?.stringValue { @@ -106,7 +106,7 @@ enum RemoteHandlers { registry.track(work) } CloudHandlers.bind("remote.reconnect", registry, reason: hasMachine) { invocation in - ssh.reconnect(try machine(invocation, context), byPerson: [.user, .cli].contains(invocation.origin)) + ssh.reconnect(try machine(invocation, context), byPerson: invocation.isPersonsRequest) } CloudHandlers.bind("remote.disconnect", registry, reason: hasMachine) { invocation in ssh.disconnect(try machine(invocation, context)) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/SSHTargetHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/SSHTargetHandlers.swift index e64240399083..d652f579f974 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/SSHTargetHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/SSHTargetHandlers.swift @@ -12,7 +12,7 @@ enum SSHTargetHandlers { static func bind(into registry: ActionRegistry, context: AppActionContext) { let ssh = context.services.ssh let available: @MainActor () -> String? = { ssh.unavailableReason } - CloudHandlers.bind("reconnectWorkspace", registry, reason: available) { ssh.reconnect(try workspaceMachine($0, context)) } + CloudHandlers.bind("reconnectWorkspace", registry, reason: available) { ssh.reconnect(try workspaceMachine($0, context), byPerson: $0.isPersonsRequest) } CloudHandlers.bind("disconnectWorkspace", registry, reason: available) { ssh.disconnect(try workspaceMachine($0, context)) } CloudHandlers.bind("copyWorkspaceSSHError", registry, reason: available) { invocation in let session = try workspaceMachine(invocation, context) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHService.swift index e9d3f6d5ed4b..99c9570b9b17 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SSHService.swift @@ -145,10 +145,10 @@ final class SSHService { // MARK: Connect, disconnect, forget - /// Connects to `destination` (or reconnects the saved machine it names). + /// Connects to `destination` (or reconnects the saved machine it names); `byPerson` as ``reconnect(_:byPerson:)``. @discardableResult func connect(destination text: String, session name: String?, binary: String? = nil, stateDir: String? = nil, - offerInstall: Bool) throws -> SSHMachineSession { + offerInstall: Bool, byPerson: Bool = true) throws -> SSHMachineSession { if let reason = unavailableReason { throw ActionFailure(message: reason) } let destination: SSHDestination do { destination = try SSHDestination(parsing: text) } catch { throw ActionFailure(message: RemoteStrings.invalidDestination(text, error)) } @@ -159,7 +159,7 @@ final class SSHService { rememberHost(saved) saved.offersInstall = offerInstall if offerInstall { offerInstallWhenOld(saved) } - reconnect(saved) + reconnect(saved, byPerson: byPerson) logger.info("connect \(destination.description, privacy: .public): reusing \(saved.machineID, privacy: .public)") return saved } @@ -183,6 +183,7 @@ final class SSHService { } let session = machines.sshSession(host.machineID) ?? makeSession(host) guard let session else { throw ActionFailure(message: RemoteStrings.noClient) } + if !byPerson, session.waitsForUser { return session } // Saved now, before the first connect succeeds (no session id yet). session.autoConnect = true rememberHost(session) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SidebarBridge+SSH.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SidebarBridge+SSH.swift index 9704794e7c73..b9f1a071bad6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SidebarBridge+SSH.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/SidebarBridge+SSH.swift @@ -39,8 +39,7 @@ extension SidebarBridge { /// What a chat that needs machine `machineID` finds its link doing: only a link that dropped /// or never came up reconnects on use (``MachineLinkState/reconnectsOnUse(_:)``), never an SSH session in use (G5). static func paneUse(_ machineID: String, in machines: MachineRegistry) -> AgentPaneMachineLink.Use { - if machines.sshSession(machineID)?.waitsForUser == true { return .stuck } - guard let status = machineHeader(machineID, in: machines)?.status else { return .stuck } + guard machines.sshSession(machineID)?.waitsForUser != true, let status = machineHeader(machineID, in: machines)?.status else { return .stuck } switch status.linkState { case .connected: return .up case .connecting: return .coming diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift index e71297c13ee2..a89d01ed9cf7 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SSHMachineLinkRecoveryTests.swift @@ -91,6 +91,12 @@ import Testing // MARK: G5 + @Test func onlyAPersonsRunIsAPersonsRequest() { + #expect(ActionInvocation(origin: .user).isPersonsRequest) + #expect(ActionInvocation(origin: .cli).isPersonsRequest) + for origin: ActionOrigin in [.page, .mcp, .script, .remote] { #expect(!ActionInvocation(origin: origin).isPersonsRequest) } + } + @Test func aSessionInUseWaitsForAPerson() { var machine = SSHConnectionMachine() machine.handle(.connect)