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
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ import os
/// screen and a status label). A visible view re-attaches by itself after
/// a capped backoff (500 ms doubling to 30 s, cx-wwtam) until an attach works, offering Retry
/// after about 90 s; a hidden one waits for an event: shown again, a key press, a click, focus or
/// Retry, or the App's ``reconnect()``. A restored view says "Restoring…" until its first replay.
/// Retry, or the App's ``reconnect()``. A restored view says "Restoring…" until its first replay
/// and, for its first 90 s or so, retries every 2 s at most until then (#23029).
/// ``processExited()`` ends it until the daemon reports the terminal
/// running again (``processRevived()``, R41).
nonisolated final class DaemonTerminalIO: TerminalIO {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ public import Foundation
/// view keeps retrying but says the attach failed, so Retry shows (and a
/// restored view stops saying `restoring`). One re-attach at a time. Until a view first went
/// live its status is `restoring` (when it was created as a restore) while it
/// attaches or retries. A terminal whose
/// attaches or retries, and its backoff stays short (``restoreBackoffCap``), so it comes
/// back with no input soon after the daemon answers (#23029). An event that comes while an
/// attach is in flight is kept: if that attach fails, the next one opens at once. A terminal whose
/// process ended is `exited` and never re-attaches on a view event; only the
/// daemon reporting the terminal running again (`processRevived`) re-attaches
/// it, because a dead report can be transient (R41).
Expand Down Expand Up @@ -55,6 +57,15 @@ public nonisolated struct TerminalAttachMachine<Link: Hashable & Sendable>: Send
/// before the view, still retrying, offers Retry: its status says the
/// attach failed and that a click or key re-attaches now.
public static var quietReconnects: Int { 8 }
/// The backoff step a restored view that has not gone live yet waits at
/// most (2 s with the App's backoff): it says "Restoring…", and only its
/// own retry or an event (a key, focus, the connection back) wakes it, so
/// it must not wait 30 s for an attach that would now work (#23029).
public static var restoreBackoffCap: Int { 3 }
/// ``quietReconnects`` for that view: about the same 90 s at its shorter backoff. Past it the
/// view offers Retry and backs off as any other, so a daemon that never answers is not asked
/// every 2 s for good.
public static var quietRestoreReconnects: Int { 45 }

public private(set) var phase: Phase = .detached
public private(set) var queuedInput: [Data] = []
Expand Down Expand Up @@ -87,6 +98,9 @@ public nonisolated struct TerminalAttachMachine<Link: Hashable & Sendable>: Send
public private(set) var autoReconnects = 0
/// Why the view last disconnected (the status of a re-attach an event cuts short).
private var lastDisconnect: DisconnectReason = .attachFailed
/// An event that re-attaches (the connection back, a key, focus) came while an attach was
/// in flight: if that attach fails, the next one opens at once instead of after the backoff.
private var reattachRequested = false
private let initialSize: CellSize
private let restoring: Bool
private var lastAttempt = 0
Expand Down Expand Up @@ -164,6 +178,7 @@ public nonisolated struct TerminalAttachMachine<Link: Hashable & Sendable>: Send
}
phase = .live(link)
wentLive = true
reattachRequested = false
// The attach itself reported the size it opened with.
reportedSize = pending.size
claimed = false
Expand Down Expand Up @@ -319,11 +334,18 @@ public nonisolated struct TerminalAttachMachine<Link: Hashable & Sendable>: Send
case .reattaching(let pending) where byEvent && pending.link == nil && pending.waitsBackoff:
reason = lastDisconnect
failed = 0
case .attaching, .reattaching:
// An attach is in flight: the event must not be lost if it fails (`disconnect`).
if byEvent { reattachRequested = true }
return []
default:
return []
}
if byEvent { autoReconnects = 0 }
let wait = byEvent ? 0 : max(failed, autoReconnects - 1)
reattachRequested = false
let stillRestoring = restoring && !wentLive
var wait = byEvent ? 0 : max(failed, autoReconnects - 1)
if stillRestoring, autoReconnects <= Self.quietRestoreReconnects { wait = min(wait, Self.restoreBackoffCap) }
lastAttempt += 1
var pending = Pending(attempt: lastAttempt, failures: 1, size: desiredSize ?? initialSize, link: nil,
reconnects: byEvent ? 0 : failed)
Expand All @@ -333,9 +355,10 @@ public nonisolated struct TerminalAttachMachine<Link: Hashable & Sendable>: Send
let open: Effect = wait == 0
? .open(attempt: pending.attempt, size: pending.size)
: .openAfterBackoff(attempt: pending.attempt, size: pending.size, failedReconnects: wait)
let status: LinkStatus = if !byEvent, autoReconnects > Self.quietReconnects {
let quiet = stillRestoring ? Self.quietRestoreReconnects : Self.quietReconnects
let status: LinkStatus = if !byEvent, autoReconnects > quiet {
.disconnected(reason, reconnecting: false)
} else if restoring, !wentLive {
} else if stillRestoring {
.restoring
} else {
.disconnected(reason, reconnecting: true)
Expand All @@ -351,6 +374,8 @@ public nonisolated struct TerminalAttachMachine<Link: Hashable & Sendable>: Send
let failed = pending?.reconnects.map { $0 + 1 } ?? 0
phase = .disconnected(Disconnected(reason: reason, failedReconnects: failed))
claimed = false; reportedSize = nil; echoesDue = []; announcedSize = nil; hiddenOn = nil
// The connection came back (or a key or focus) while this attach was in flight.
if reattachRequested { return reconnect() }
if visible, reason != .fellBehind {
autoReconnects += 1
return reconnect(byEvent: false)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import CmuxNextBridge
import CmuxNextDaemon
import Foundation
import Testing

/// A restored terminal pane said "Restoring…" for up to a minute and came back only when the
/// person typed (#23029): its attaches had failed while the daemon was busy, it backed off like
/// any dropped link (500 ms doubling to 30 s), and a key press was the only thing that cut the
/// wait short. A connection that came back while an attach was in flight was dropped as well.
struct TerminalRestoreAttachTests {
typealias Machine = TerminalAttachMachine<Int>

/// The attempt the effects open, and the backoff step it waits first (0: at once).
private func opened(_ effects: [Machine.Effect]) -> (attempt: Int, wait: Int)? {
for effect in effects {
switch effect {
case .open(let attempt, _): return (attempt, 0)
case .openAfterBackoff(let attempt, _, let failed): return (attempt, failed)
default: continue
}
}
return nil
}

@Test func aRestoredViewKeepsRetryingWithinTwoSecondsUntilItFirstAttaches() throws {
var machine = Machine(initialSize: CellSize(cols: 80, rows: 24), visible: true, restoring: true)
var next = try #require(opened(machine.reduce(.start)))
// About 90 s of failed attaches at the restore backoff: never more than step 3 (2 s)
// between tries, and still "Restoring…" (no Retry yet), with no input.
for failure in 1...40 {
next = try #require(opened(machine.reduce(.openFailed(attempt: next.attempt))))
#expect(next.wait <= 3, "failure \(failure) waits backoff step \(next.wait)")
#expect(machine.status == .restoring, "failure \(failure): \(machine.status)")
}
// The daemon answers: the next attach goes live by itself.
_ = machine.reduce(.opened(7, attempt: next.attempt))
_ = machine.reduce(.replayDelivered(7))
#expect(machine.status == .connected)
}

@Test func aRestoreThatNeverAttachesOffersRetryAndBacksOffAsBefore() throws {
var machine = Machine(initialSize: CellSize(cols: 80, rows: 24), visible: true, restoring: true)
var next = try #require(opened(machine.reduce(.start)))
for _ in 1...60 { next = try #require(opened(machine.reduce(.openFailed(attempt: next.attempt)))) }
// A daemon that never answers is not asked every 2 s for good: Retry shows, and the
// view still retries by itself at the longer backoff.
#expect(machine.status == .disconnected(.attachFailed, reconnecting: false))
#expect(next.wait > 3)
}

@Test func theConnectionBackDuringAnAttachReattachesAtOnceWhenThatAttachFails() throws {
var machine = Machine(initialSize: CellSize(cols: 80, rows: 24), visible: true, restoring: true)
var next = try #require(opened(machine.reduce(.start)))
for _ in 1...5 { next = try #require(opened(machine.reduce(.openFailed(attempt: next.attempt)))) }
#expect(next.wait > 0)
// That attach opened and waits for its replay when the daemon connection comes back.
_ = machine.reduce(.opened(7, attempt: next.attempt))
#expect(opened(machine.reduce(.reconnect)) == nil)
// Its stream then ends (it was on the old connection): the next attach opens at once.
let after = try #require(opened(machine.reduce(.ended(7, .connectionLost("gone")))))
#expect(after.wait == 0)
#expect(machine.status == .restoring)
}
}
4 changes: 4 additions & 0 deletions release-notes/next/highlights/restored-terminals-come-back.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
title: Restored terminals come back without a key press
category: fixed

After cmux reopens, a terminal or agent pane that says "Restoring…" now shows its session by itself as soon as cmux can reach it, instead of waiting until you type.