pmacs/src/attach_reconnect.rs

660 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// attach_reconnect.rs --- Reconnect controller types for the SSH attach (T M5.8a).
//! Reconnect controller types: backoff schedule + verdict enum.
//!
//! M5.8 adds mosh-style resilience to the SSH attach path: a dropped
//! channel is treated as temporary detachment, the frontend retains
//! the last-rendered cell grid, and we re-attempt connection on an
//! exponential backoff bounded at ~30s. This module owns the *pure*
//! pieces of that machinery — the schedule and the classification —
//! so the IO-heavy outer loop in [`crate::attach::run_attach_ssh`]
//! can be reasoned about and tested separately from the policy.
//!
//! # The schedule
//!
//! [`BackoffSchedule`] is a deterministic sequence:
//!
//! ```text
//! 0.5s, 1s, 2s, 4s, 8s, 16s, 30s, 30s, 30s, …
//! ```
//!
//! The pre-cap curve is the standard exponential doubling. The cap
//! at 30s is per spec — the user is waiting on a wall clock, and 30s
//! is the longest delay where "press a key, see something happen
//! soon" still feels intentional rather than broken.
//!
//! No jitter in v0.1. Jitter (±20% randomization) is the standard
//! defence against thundering-herd reconnect storms. The v0.1
//! single-frontend case can never produce a herd; if v0.3
//! multi-frontend attach reveals the need, add jitter then under a
//! concrete signal rather than preemptively.
//!
//! # Verdicts and the handshake cap
//!
//! After each session attempt — successful or not — the outer loop
//! consults [`classify_for_reconnect`] with the resulting
//! [`crate::attach::AttachError`] to produce a [`ReconnectVerdict`].
//! The verdict tells the loop what to do next:
//!
//! * [`ReconnectVerdict::Reconnect`] — mid-session disconnect, full
//! exponential backoff, indefinite retries. The user has a working
//! session somewhere on the remote and we're going to get them back
//! to it.
//! * [`ReconnectVerdict::ReconnectHandshake`] — pre-session failure
//! (initial connect, Hello / `AttachRequest` exchange). Bounded by
//! [`HANDSHAKE_RETRY_CAP`] so a permanently misconfigured target
//! doesn't loop forever.
//! * [`ReconnectVerdict::ExitClean`] — user pressed F12; the session
//! ended on purpose. No reconnect.
//! * [`ReconnectVerdict::ExitProtocolError`] — version skew, transport
//! corruption, terminal failure. Retry will not help.
//! * [`ReconnectVerdict::ExitPolicy`] — v0.1-policy rejection
//! (currently `Rejected(AlreadyAttached)`). The split from
//! `ExitProtocolError` is forward-looking: v0.2 multi-frontend
//! will reclassify this as transient (the other frontend may
//! detach), and a separate variant lets that be a one-line policy
//! change rather than an audit through the whole error surface.
//!
//! The `initial_handshake_complete` flag in [`classify_for_reconnect`]
//! is the seam: once a session has succeeded even once in a single
//! `run_attach_ssh` invocation, all subsequent transient failures
//! are classified as full `Reconnect` (no cap), regardless of which
//! stage they happen in. Pre-success transient failures are
//! `ReconnectHandshake` (capped). The rationale is the user-perceived
//! contract: if you've ever seen the editor render, you expect
//! reconnect to keep trying; if you've never gotten past the
//! handshake, three quick attempts is courtesy and after that we owe
//! you a clear failure message.
use std::time::Duration;
use crate::attach::AttachError;
use crate::protocol::GoodbyeReason;
/// Maximum number of pre-success handshake retries before the outer
/// loop converts [`ReconnectVerdict::ReconnectHandshake`] into a
/// give-up error.
///
/// Sized for "DNS hiccup + SSH agent re-prompt + retry-with-fresh-key"
/// scenarios without letting permanent misconfiguration loop forever.
/// The handshake-failure tail (stderr from each attempt) is folded
/// into the give-up error for diagnostic purposes.
pub const HANDSHAKE_RETRY_CAP: u32 = 3;
/// Maximum delay between reconnect attempts. The schedule plateaus
/// here once the pre-cap doubling reaches it.
const MAX_DELAY: Duration = Duration::from_secs(30);
/// Pre-cap delay sequence. The schedule yields these in order, then
/// plateaus at [`MAX_DELAY`]. Kept as a const slice so the test asserts
/// against the exact contract rather than a re-implementation.
const PRE_CAP_DELAYS_MS: [u64; 6] = [500, 1000, 2000, 4000, 8000, 16000];
/// Environment variable for tests to compress the backoff schedule.
///
/// Acceptance tests that exercise the reconnect loop end-to-end need
/// the schedule to fire fast enough that a CI run doesn't take
/// minutes. Setting this to a positive integer `BASE_MS` overrides
/// the production curve with a doubling sequence based on `BASE_MS`,
/// capped at 60 × `BASE_MS` (mirroring the production 500ms → 30s
/// ratio):
///
/// * `PMACS_TEST_BACKOFF_SCALE_MS=1` → 1, 2, 4, 8, 16, 32, 60, 60, …
/// (`HANDSHAKE_RETRY_CAP` retries elapse in ~7ms total.)
/// * `PMACS_TEST_BACKOFF_SCALE_MS=500` (or unset) → production
/// 0.5, 1, 2, 4, 8, 16, 30, 30, … s.
///
/// Test-flavored: production users do not set this. The env var is
/// read once at [`BackoffSchedule::new`] time so a test can scope
/// the override per-subprocess without affecting other concurrently
/// running tests in the same binary.
pub const PMACS_TEST_BACKOFF_SCALE_MS: &str = "PMACS_TEST_BACKOFF_SCALE_MS";
/// Exponential backoff scheduler for reconnect attempts.
///
/// Stateful: each call to [`next_delay`](Self::next_delay) advances
/// the curve. [`reset`](Self::reset) returns the schedule to its
/// initial state — used by the outer loop after a successful
/// reconnect, so the next disconnect starts at 0.5s rather than
/// staying parked at 30s.
///
/// Cheap to construct, no heap allocation. Single-threaded by design;
/// the outer reconnect loop is the only consumer.
#[derive(Debug, Clone)]
pub struct BackoffSchedule {
/// Number of `next_delay` calls observed so far. Saturating, so
/// it cannot wrap on a pathologically long-running session.
step: u32,
/// Test override read from [`PMACS_TEST_BACKOFF_SCALE_MS`] at
/// construction. `None` → production curve. `Some(base_ms)` →
/// doubling sequence on `base_ms` with cap `60 × base_ms`.
test_scale_ms: Option<u64>,
}
impl BackoffSchedule {
/// Construct a fresh schedule. The first [`next_delay`](Self::next_delay)
/// call returns 0.5s in production, or `base_ms` if
/// [`PMACS_TEST_BACKOFF_SCALE_MS`] is set.
///
/// Reads the env var once and caches the result. Constructing a
/// new `BackoffSchedule` after the env var changes will pick up
/// the new value; existing instances will not.
#[must_use]
pub fn new() -> Self {
Self {
step: 0,
test_scale_ms: read_test_scale_ms(),
}
}
/// Compute the next delay and advance the schedule.
///
/// Successive calls in production yield 0.5s, 1s, 2s, 4s, 8s,
/// 16s, 30s, 30s, … Once the cap is reached, every subsequent
/// call returns the same [`MAX_DELAY`]. With
/// [`PMACS_TEST_BACKOFF_SCALE_MS`] set, the analogous doubling
/// sequence on the configured base is yielded instead.
pub fn next_delay(&mut self) -> Duration {
let step = self.step;
self.step = self.step.saturating_add(1);
match self.test_scale_ms {
Some(base_ms) => compute_test_delay(base_ms, step),
None => PRE_CAP_DELAYS_MS
.get(step as usize)
.copied()
.map_or(MAX_DELAY, Duration::from_millis),
}
}
/// Reset the schedule to its initial state.
///
/// Called by the outer reconnect loop after a session attempt
/// succeeds: the next disconnect should feel responsive (0.5s)
/// rather than inheriting the long delay that finally got us back
/// last time. Without this, a flaky link reconnects every 30s
/// after the first cycle, even when the link recovers in
/// milliseconds — annoying but not breaking, exactly the kind of
/// bug that ships.
pub fn reset(&mut self) {
self.step = 0;
}
}
impl Default for BackoffSchedule {
fn default() -> Self {
Self::new()
}
}
/// Compute the test-mode delay for `step` given a `base_ms` override.
///
/// Pure: same `(base_ms, step)` produces the same `Duration`,
/// regardless of any process state. Tested directly without env-var
/// manipulation.
///
/// Algorithm: `min(base_ms × 2^step, 60 × base_ms)`. Saturates
/// safely on absurd `step` values (the `1u64 << shift` is clamped to
/// 31 bits to avoid undefined behavior on the shift, and the
/// multiplication uses `saturating_mul`).
fn compute_test_delay(base_ms: u64, step: u32) -> Duration {
let cap_ms = base_ms.saturating_mul(60);
let shift = step.min(31);
let scaled = base_ms.saturating_mul(1u64 << shift);
Duration::from_millis(scaled.min(cap_ms))
}
fn read_test_scale_ms() -> Option<u64> {
std::env::var(PMACS_TEST_BACKOFF_SCALE_MS)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|&n| n > 0)
}
/// What the outer reconnect loop should do after a session attempt
/// returns.
///
/// Each variant carries the [`AttachError`] that produced the verdict
/// so the loop can fold per-attempt diagnostic context into a final
/// give-up message (handshake retries) or surface it directly to the
/// user (terminal cases). The `Reconnect*` variants pass the error
/// through so the modeline indicator can show *why* the last attempt
/// failed during the next sleep.
#[derive(Debug)]
pub enum ReconnectVerdict {
/// Mid-session disconnect: the session previously succeeded and
/// we should reconnect with full exponential backoff.
Reconnect {
/// The error that ended the session (broken pipe, EOF, daemon
/// shutdown, etc.). Surfaced on the modeline indicator and
/// folded into a give-up message only on user-driven exit.
error: AttachError,
},
/// Pre-success handshake failure: bounded by
/// [`HANDSHAKE_RETRY_CAP`]. The outer loop tracks attempts and
/// converts to a give-up error past the cap.
ReconnectHandshake {
/// The error from this handshake attempt. Each attempt's
/// error is logged (stderr tail preserved internally) so the
/// final give-up message can summarize the full sequence.
error: AttachError,
},
/// User-initiated clean detach (F12 → Detach → daemon Goodbye →
/// SSH child exit 0). No reconnect, no error to surface.
ExitClean,
/// Protocol-permanent error: version mismatch, transport
/// corruption, terminal failure. Retry will not help; surface to
/// the user immediately and exit.
ExitProtocolError {
/// The terminal error.
error: AttachError,
},
/// v0.1-policy rejection: currently `Rejected(AlreadyAttached)`.
/// Distinct from [`Self::ExitProtocolError`] so v0.2's
/// multi-frontend reclassification (other frontend may detach,
/// retry could succeed) is a one-line policy change in
/// [`classify_for_reconnect`] rather than a sweep through the
/// error-handling surface.
ExitPolicy {
/// The rejection error.
error: AttachError,
},
}
/// Classify an [`AttachError`] into a [`ReconnectVerdict`].
///
/// `initial_handshake_complete` is the outer-loop's "have we ever
/// successfully attached on this `run_attach_ssh` invocation" flag.
/// Once true, transient failures become [`ReconnectVerdict::Reconnect`]
/// (no cap) regardless of which stage of the attempt they happened
/// in — the user expects mid-session reconnect to keep trying. While
/// false, transient failures are [`ReconnectVerdict::ReconnectHandshake`]
/// so a permanently misconfigured target doesn't loop forever.
///
/// Permanent errors (`VersionMismatch`, `Rejected(VersionMismatch | ProtocolError)`,
/// `Terminal`) ignore the flag and always exit.
#[must_use]
pub fn classify_for_reconnect(
error: AttachError,
initial_handshake_complete: bool,
) -> ReconnectVerdict {
match &error {
// Permanent errors:
// * Version skew (locally observed or daemon-reported) and
// protocol corruption — retry won't fix the wire mismatch.
// * `Terminal` — crossterm broke. Even if SSH recovered, we'd
// have nowhere to draw the recovered grid.
AttachError::VersionMismatch { .. }
| AttachError::Rejected(
GoodbyeReason::VersionMismatch { .. } | GoodbyeReason::ProtocolError,
)
| AttachError::Terminal(_) => ReconnectVerdict::ExitProtocolError { error },
// v0.2 multi-frontend will reclassify this as transient
// (other frontend may detach). Keep the variant distinct so
// that change is one match arm, not a survey.
AttachError::Rejected(GoodbyeReason::AlreadyAttached) => {
ReconnectVerdict::ExitPolicy { error }
}
// Everything else — daemon shutting down, broken pipes,
// transport / decode errors, SSH child exits / spawn
// failures — is transient. The cap depends on whether we've
// ever had a working session.
_ => {
if initial_handshake_complete {
ReconnectVerdict::Reconnect { error }
} else {
ReconnectVerdict::ReconnectHandshake { error }
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
// -----------------------------------------------------------------
// BackoffSchedule
// -----------------------------------------------------------------
#[test]
fn schedule_yields_first_seven_delays_per_documented_curve() {
let mut s = BackoffSchedule::new();
let expected = [
Duration::from_millis(500),
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(4),
Duration::from_secs(8),
Duration::from_secs(16),
Duration::from_secs(30),
];
for (i, &want) in expected.iter().enumerate() {
let got = s.next_delay();
assert_eq!(got, want, "step {i}: expected {want:?}, got {got:?}");
}
}
#[test]
fn schedule_plateaus_at_30s_indefinitely() {
let mut s = BackoffSchedule::new();
// Burn through the pre-cap curve.
for _ in 0..PRE_CAP_DELAYS_MS.len() {
let _ = s.next_delay();
}
// Every subsequent call should pin at 30s. 100 iterations is
// enough to catch any "off-by-one accidentally re-enters the
// curve" regression.
for _ in 0..100 {
assert_eq!(s.next_delay(), Duration::from_secs(30));
}
}
#[test]
fn schedule_reset_returns_to_first_delay() {
// Regression guard for the bug class flagged in the M5.8 plan:
// a flaky link reconnects successfully once, then stays parked
// at 30s for subsequent disconnects when it should drop back
// to 0.5s. The reset path is easy to forget; the consequence
// is annoying-but-not-breaking, exactly the kind of bug that
// ships if not gated by a test.
let mut s = BackoffSchedule::new();
for _ in 0..10 {
let _ = s.next_delay();
}
assert_eq!(s.next_delay(), Duration::from_secs(30));
s.reset();
assert_eq!(s.next_delay(), Duration::from_millis(500));
assert_eq!(s.next_delay(), Duration::from_secs(1));
}
#[test]
fn schedule_default_matches_new() {
let mut a = BackoffSchedule::default();
let mut b = BackoffSchedule::new();
for _ in 0..5 {
assert_eq!(a.next_delay(), b.next_delay());
}
}
#[test]
fn schedule_step_counter_saturates_at_u32_max() {
// Pathologically long-running session: the schedule must not
// panic on overflow. The saturating add guarantees this.
let mut s = BackoffSchedule {
step: u32::MAX,
test_scale_ms: None,
};
// Pre-saturation: returns MAX_DELAY (we're past the curve).
assert_eq!(s.next_delay(), Duration::from_secs(30));
// Post-saturation: still returns MAX_DELAY, no panic.
assert_eq!(s.step, u32::MAX);
assert_eq!(s.next_delay(), Duration::from_secs(30));
}
// -----------------------------------------------------------------
// PMACS_TEST_BACKOFF_SCALE_MS test override
// -----------------------------------------------------------------
#[test]
fn compute_test_delay_at_500ms_base_matches_production_curve() {
// The pure helper, given base_ms=500, should produce exactly
// the production sequence — proving the test path and the
// production path agree on the same input.
let base = 500;
let expected = [500, 1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000];
for (step, &want_ms) in expected.iter().enumerate() {
let got = compute_test_delay(base, step as u32);
assert_eq!(
got,
Duration::from_millis(want_ms),
"step {step}: expected {want_ms}ms, got {got:?}"
);
}
}
#[test]
fn compute_test_delay_at_1ms_base_yields_fast_curve() {
// Base 1ms → 1, 2, 4, 8, 16, 32, 60 (cap), 60, 60. The cap
// is 60 × base = 60ms, mirroring 60 × 500ms = 30s.
let base = 1;
let expected = [1, 2, 4, 8, 16, 32, 60, 60, 60, 60];
for (step, &want_ms) in expected.iter().enumerate() {
let got = compute_test_delay(base, step as u32);
assert_eq!(
got,
Duration::from_millis(want_ms),
"step {step} at base 1ms: expected {want_ms}ms, got {got:?}"
);
}
}
#[test]
fn compute_test_delay_at_50ms_base_yields_proportional_curve() {
// 50ms × {1, 2, 4, 8, 16, 32, 60} = 50, 100, 200, 400, 800,
// 1600, 3000.
let base = 50;
let expected = [50, 100, 200, 400, 800, 1600, 3000, 3000];
for (step, &want_ms) in expected.iter().enumerate() {
let got = compute_test_delay(base, step as u32);
assert_eq!(
got,
Duration::from_millis(want_ms),
"step {step} at base 50ms: expected {want_ms}ms, got {got:?}"
);
}
}
#[test]
fn compute_test_delay_saturates_on_huge_step() {
// Pathological step value — the bit-shift would UB without
// the `min(31)` clamp, and the multiplication would overflow
// without `saturating_mul`. Both guards verified.
let base = 100;
let got = compute_test_delay(base, u32::MAX);
// Whatever the result is, it must be at least the cap and
// not panic. With saturation it ends up at cap exactly.
assert_eq!(got, Duration::from_millis(base * 60));
}
#[test]
fn read_test_scale_ms_returns_none_when_unset() {
// We can't safely set/unset env vars in parallel tests, but
// we CAN verify the helper's parsing behavior on synthetic
// inputs by constructing the schedule directly with a known
// value. The pure-helper test above already covers the math;
// this is a regression guard for the field-level wiring.
let s = BackoffSchedule {
step: 0,
test_scale_ms: None,
};
assert!(s.test_scale_ms.is_none());
}
#[test]
fn schedule_with_test_scale_uses_test_curve() {
// Construct a schedule with the override pre-set (bypassing
// the env-var read so this test is hermetic).
let mut s = BackoffSchedule {
step: 0,
test_scale_ms: Some(10),
};
let expected = [10, 20, 40, 80, 160, 320, 600, 600, 600];
for (i, &want_ms) in expected.iter().enumerate() {
let got = s.next_delay();
assert_eq!(
got,
Duration::from_millis(want_ms),
"step {i}: expected {want_ms}ms, got {got:?}"
);
}
}
// -----------------------------------------------------------------
// classify_for_reconnect
// -----------------------------------------------------------------
fn io_err() -> AttachError {
AttachError::Io(io::Error::other("test transient io error"))
}
#[test]
fn version_mismatch_is_protocol_error_regardless_of_session_state() {
for &flag in &[false, true] {
let err = AttachError::VersionMismatch {
server: 99,
client: 1,
};
match classify_for_reconnect(err, flag) {
ReconnectVerdict::ExitProtocolError { .. } => {}
other => panic!("flag={flag}: expected ExitProtocolError, got {other:?}"),
}
}
}
#[test]
fn rejected_version_mismatch_is_protocol_error() {
let err = AttachError::Rejected(GoodbyeReason::VersionMismatch {
server: 99,
client: 1,
});
match classify_for_reconnect(err, false) {
ReconnectVerdict::ExitProtocolError { .. } => {}
other => panic!("expected ExitProtocolError, got {other:?}"),
}
}
#[test]
fn rejected_protocol_error_is_protocol_error() {
let err = AttachError::Rejected(GoodbyeReason::ProtocolError);
match classify_for_reconnect(err, true) {
ReconnectVerdict::ExitProtocolError { .. } => {}
other => panic!("expected ExitProtocolError, got {other:?}"),
}
}
#[test]
fn rejected_already_attached_is_policy_in_v0_1() {
// The deliberate forward-compatibility seam: keep this case
// separate from ExitProtocolError so v0.2 multi-frontend
// (other frontend may detach, retry could succeed) is a
// one-arm change, not a refactor.
let err = AttachError::Rejected(GoodbyeReason::AlreadyAttached);
match classify_for_reconnect(err, true) {
ReconnectVerdict::ExitPolicy { .. } => {}
other => panic!("expected ExitPolicy, got {other:?}"),
}
}
#[test]
fn terminal_error_is_protocol_error_regardless_of_session_state() {
for &flag in &[false, true] {
let err = AttachError::Terminal(io::Error::other("crossterm broke"));
match classify_for_reconnect(err, flag) {
ReconnectVerdict::ExitProtocolError { .. } => {}
other => panic!("flag={flag}: expected ExitProtocolError, got {other:?}"),
}
}
}
#[test]
fn rejected_shutting_down_is_transient() {
// ShuttingDown (daemon SIGTERM'd or restarting) is transient:
// the daemon may come back, retry should succeed.
let err = AttachError::Rejected(GoodbyeReason::ShuttingDown);
match classify_for_reconnect(err, true) {
ReconnectVerdict::Reconnect { .. } => {}
other => panic!("expected Reconnect, got {other:?}"),
}
}
#[test]
fn io_error_pre_session_is_handshake_reconnect() {
match classify_for_reconnect(io_err(), false) {
ReconnectVerdict::ReconnectHandshake { .. } => {}
other => panic!("expected ReconnectHandshake, got {other:?}"),
}
}
#[test]
fn io_error_post_session_is_unbounded_reconnect() {
match classify_for_reconnect(io_err(), true) {
ReconnectVerdict::Reconnect { .. } => {}
other => panic!("expected Reconnect, got {other:?}"),
}
}
#[test]
fn ssh_spawn_failed_is_handshake_reconnect_pre_session() {
let err = AttachError::SshSpawnFailed {
command: std::path::PathBuf::from("ssh"),
source: io::Error::other("ENOENT"),
};
match classify_for_reconnect(err, false) {
ReconnectVerdict::ReconnectHandshake { .. } => {}
other => panic!("expected ReconnectHandshake, got {other:?}"),
}
}
#[test]
fn ssh_spawn_failed_post_session_is_unbounded_reconnect() {
// A reconnect attempt's spawn fails, but we've had a working
// session before — the user expects retries to keep going.
let err = AttachError::SshSpawnFailed {
command: std::path::PathBuf::from("ssh"),
source: io::Error::other("ENOENT"),
};
match classify_for_reconnect(err, true) {
ReconnectVerdict::Reconnect { .. } => {}
other => panic!("expected Reconnect, got {other:?}"),
}
}
#[test]
fn ssh_child_exited_127_is_transient_in_both_states() {
// 127 = command not found on remote. Treated like any other
// SSH-child exit: handshake-bounded pre-session, unbounded
// post-session. The user might be installing pmacs on the
// remote while we wait.
let make = || AttachError::SshChildExited {
code: Some(127),
stderr_tail: String::new(),
};
match classify_for_reconnect(make(), false) {
ReconnectVerdict::ReconnectHandshake { .. } => {}
other => panic!("pre-session: expected ReconnectHandshake, got {other:?}"),
}
match classify_for_reconnect(make(), true) {
ReconnectVerdict::Reconnect { .. } => {}
other => panic!("post-session: expected Reconnect, got {other:?}"),
}
}
#[test]
fn transport_error_is_transient() {
let make = || {
AttachError::Transport(crate::transport::TransportError::Io(io::Error::other(
"decode fail",
)))
};
match classify_for_reconnect(make(), false) {
ReconnectVerdict::ReconnectHandshake { .. } => {}
other => panic!("pre-session: expected ReconnectHandshake, got {other:?}"),
}
match classify_for_reconnect(make(), true) {
ReconnectVerdict::Reconnect { .. } => {}
other => panic!("post-session: expected Reconnect, got {other:?}"),
}
}
#[test]
fn handshake_retry_cap_is_three() {
// The cap is part of the user contract (per M5.8 plan). Pin
// it so a casual edit changes the test, not the user
// experience. Bumping the cap is fine; doing it accidentally
// is not.
assert_eq!(HANDSHAKE_RETRY_CAP, 3);
}
}