pmacs/src/attach.rs

2861 lines
114 KiB
Rust

// attach.rs --- Frontend attach client for the local-attach transport.
//! Frontend `--attach` entry point (T M5.5g).
//!
//! Connects to a daemon's Unix socket, performs the
//! [`Hello`] / [`AttachRequest`] handshake, sets up the local terminal
//! with the same [`Frontend`] used by the in-process TUI, and then
//! pumps events bidirectionally:
//!
//! - Local input ([`crossterm::event::Event`]) → translated to
//! [`FrontendEvent`] and written to the socket.
//! - Instance messages ([`InstanceMessage`]) read from the socket →
//! applied to the [`Frontend`] (cell deltas, cursor moves).
//!
//! # Detach
//!
//! v0.1 uses **F12** as the detach key: a single keystroke that the
//! client intercepts (does not forward) and translates into
//! [`FrontendEvent::Detach`]. The daemon closes the connection
//! cleanly without sending a Goodbye (the frontend asked first).
//!
//! Closing the terminal (SIGHUP) also works — the daemon sees the
//! socket close and cleans up the per-attach state. The choice
//! between F12 and "just close the terminal" is up to the user;
//! both result in the same daemon-side cleanup.
//!
//! The F12 choice is tentative for v0.1; it conflicts with any user
//! keybind on F12, which we accept because F12 is rarely bound. A
//! later release will make it configurable.
use std::collections::VecDeque;
use std::io::{self, Read, Write};
use std::net::Shutdown;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicBool, Ordering},
mpsc,
};
use std::thread;
use std::time::Duration;
use std::time::Instant;
use crate::attach_reconnect::{
BackoffSchedule, HANDSHAKE_RETRY_CAP, ReconnectVerdict, classify_for_reconnect,
};
use crate::cell::CellSize;
use crate::frontend::{Event, Frontend, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crate::protocol::crossterm_translate::{key_from_crossterm, mouse_from_crossterm};
use crate::protocol::{
AttachRequest, AttachTarget, FrontendCapabilities, FrontendEvent, FrontendId, GoodbyeReason,
Hello, InstanceMessage, PROTOCOL_VERSION,
};
use crate::transport::{TransportError, read_message, write_message};
/// Environment variable that overrides the SSH binary used by
/// [`run_attach_ssh`]. Test-flavored — production users do not set
/// this; users who want to substitute a non-`ssh` binary in v0.2+
/// will get a dedicated CLI flag.
pub const PMACS_TEST_SSH_BIN: &str = "PMACS_TEST_SSH_BIN";
/// Enable stderr breadcrumbs for SSH attach and the far-side
/// `--daemon-attach` bridge. The local side propagates this to the
/// remote command via `env PMACS_ATTACH_DEBUG=1 ...`, so users only
/// need to set it on the initiating shell.
const PMACS_ATTACH_DEBUG: &str = "PMACS_ATTACH_DEBUG";
/// Optional path for the full attach-debug log. When debug is
/// enabled, breadcrumbs are *always* appended here (including
/// live-session protocol reads, which must not hit the terminal the
/// TUI renders on). Unset → a default under the temp dir. Stderr
/// mirroring is additional and only happens before the interactive
/// frontend takes the terminal.
const PMACS_ATTACH_DEBUG_FILE: &str = "PMACS_ATTACH_DEBUG_FILE";
/// Explicit per-invocation override of the SSH protocol channel:
/// `stdout` / `1` or `stderr` / `2`. Unset → [`SSH_PROTOCOL_DEFAULT`].
/// This is the supported way to opt back to stdout (the F8b
/// "fallback") without a rebuild.
const PMACS_ATTACH_SSH_PROTOCOL: &str = "PMACS_ATTACH_SSH_PROTOCOL";
/// Legacy/back-compat override (pre-F8b name). `=1`/non-empty →
/// stderr, `=0` → stdout. Honored only when [`PMACS_ATTACH_SSH_PROTOCOL`]
/// is unset/unrecognized. New code/users should prefer the clearer
/// `PMACS_ATTACH_SSH_PROTOCOL`.
const PMACS_ATTACH_SSH_PROTOCOL_STDERR: &str = "PMACS_ATTACH_SSH_PROTOCOL_STDERR";
/// Which SSH channel carries the wire protocol.
///
/// **F8b (see `M10.11-AUDIT.md`).** At least one tested host
/// (`OpenSSH_10.3p1`) does not forward a live non-PTY remote process's
/// **stdout (fd1)** while it stays alive, but forwards **stderr
/// (fd2)** in real time. The `--daemon-attach` bridge is exactly
/// such a long-lived non-PTY process, so the protocol must ride
/// stderr there; stdout hangs forever. Evidence is n=1, so this is
/// deliberately a **single switch**: change [`SSH_PROTOCOL_DEFAULT`]
/// (one line) to flip the default if breadth evidence ever shows
/// stdout should win; nothing downstream needs to change. A
/// per-invocation override (`PMACS_ATTACH_SSH_PROTOCOL=stdout|stderr`)
/// selects without any rebuild.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum SshProtocolChannel {
Stdout,
Stderr,
}
impl SshProtocolChannel {
/// The remote fd the far-side bridge must write the protocol to.
fn remote_fd(self) -> u8 {
match self {
Self::Stdout => 1,
Self::Stderr => 2,
}
}
}
/// THE switch. Flip this one line to change the SSH-attach protocol
/// channel default; everything else derives from it.
const SSH_PROTOCOL_DEFAULT: SshProtocolChannel = SshProtocolChannel::Stderr;
/// Remote-side env var consumed by `daemon_attach.rs`.
const PMACS_ATTACH_PROTOCOL_FD: &str = "PMACS_ATTACH_PROTOCOL_FD";
/// Maximum bytes of remote stderr retained for diagnostic surfacing
/// in [`AttachError::SshChildExited`]. Sized to catch typical SSH
/// failure messages (one or two short lines plus optional banner)
/// without unbounded growth on a chatty remote.
const SSH_STDERR_TAIL_CAP: usize = 4096;
/// Watchdog timeout for the SSH-transport kick. After this delay,
/// the watchdog SIGTERMs the child to ensure the reader thread can
/// exit even if the EOF cascade is wedged. Typical clean-exit cases
/// complete well under this; the SIGTERM is harmless against an
/// already-exited child.
const SSH_KICK_WATCHDOG: Duration = Duration::from_secs(1);
/// Errors that abort the attach client.
#[derive(Debug)]
pub enum AttachError {
/// Could not connect to the socket, or write to it.
Io(std::io::Error),
/// Transport-layer error (encode / decode / framing).
Transport(TransportError),
/// Daemon reports a different protocol version than ours.
VersionMismatch {
/// Protocol version the daemon advertised in `Hello`.
server: u32,
/// Protocol version this client supports.
client: u32,
},
/// Daemon rejected the attach (already attached, etc.).
Rejected(GoodbyeReason),
/// Terminal-side error setting up or driving the TUI.
Terminal(std::io::Error),
/// `Command::spawn` of `ssh` (or the [`PMACS_TEST_SSH_BIN`]
/// override) failed. The most common cause is the binary not
/// being on `PATH`, which the error message names explicitly.
SshSpawnFailed {
/// Path or name of the binary the spawn attempted.
command: PathBuf,
/// The underlying `Command::spawn` error.
source: std::io::Error,
},
/// SSH child exited non-zero. The exit code is classified into
/// a user-facing diagnostic — code 127 in particular gets the
/// "command not found on remote" treatment, since that's the
/// failure mode users hit most often when their dotfiles set
/// `PATH` only for interactive shells.
SshChildExited {
/// Exit code if available. `None` means the child was
/// terminated by a signal.
code: Option<i32>,
/// Up to [`SSH_STDERR_TAIL_CAP`] bytes from the tail of the
/// child's stderr. The bytes were also inherited to our
/// stderr in real time (the user has likely already seen
/// them in their terminal scrollback).
stderr_tail: String,
},
}
impl std::fmt::Display for AttachError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "attach I/O error: {e}"),
Self::Transport(e) => write!(f, "{e}"),
Self::VersionMismatch { server, client } => {
// T M10.7 criterion 5: the message must tell the user
// which side is at the older version. Comparing
// `server` against `client` produces an unambiguous
// identification without the user needing to decode
// version-number semantics.
let which_older = match server.cmp(client) {
std::cmp::Ordering::Less => {
" The pmacs daemon is at the older version — upgrade the daemon \
(or restart it after upgrading the pmacs binary)."
}
std::cmp::Ordering::Greater => {
" Your pmacs binary is at the older version — upgrade the binary."
}
std::cmp::Ordering::Equal => "",
};
write!(
f,
"protocol version mismatch (instance v{server}, client v{client}).{which_older}"
)
}
Self::Rejected(reason) => match reason {
// T M10.7: capability negotiation mismatch — name the
// capabilities the frontend asked for that the
// instance can't provide. The strings on the wire are
// exactly the `FrontendCapabilities` field names
// (e.g., `multi_frontend`); user-facing translation
// happens here.
GoodbyeReason::CapabilityMismatch { missing } => {
let translated: Vec<&str> = missing
.iter()
.map(|name| match name.as_str() {
"multi_frontend" => "multi-frontend collaboration",
"crdt_replica" => "CRDT replica participation",
other => other,
})
.collect();
write!(
f,
"instance does not support the requested capabilities: {}",
translated.join(", ")
)
}
_ => write!(f, "instance rejected attach: {reason:?}"),
},
Self::Terminal(e) => write!(f, "terminal error: {e}"),
Self::SshSpawnFailed { command, source } => write!(
f,
"could not spawn {} for SSH attach: {source}; \
verify the binary is on PATH and executable",
command.display(),
),
Self::SshChildExited { code, stderr_tail } => {
let tail_hint = if stderr_tail.trim().is_empty() {
String::new()
} else {
format!(
"; details may appear in your terminal scrollback above \
(last bytes: {})",
format_stderr_tail_for_display(stderr_tail),
)
};
match code {
Some(127) => write!(
f,
"SSH session exited 127 (command not found on remote). \
This usually means `pmacs` is not on the remote PATH for \
non-interactive SSH. Try: `ssh <host> 'which pmacs'` to \
verify{tail_hint}"
),
Some(c) => write!(f, "SSH session exited {c}{tail_hint}"),
None => write!(
f,
"SSH session terminated by signal (no exit code){tail_hint}"
),
}
}
}
}
}
impl std::error::Error for AttachError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(e) | Self::Terminal(e) => Some(e),
Self::Transport(e) => Some(e),
Self::SshSpawnFailed { source, .. } => Some(source),
Self::VersionMismatch { .. } | Self::Rejected(_) | Self::SshChildExited { .. } => None,
}
}
}
/// Trim the captured stderr tail to a single short line for
/// inclusion in the [`AttachError::SshChildExited`] message. The
/// full bytes are available via the `stderr_tail` field; this is
/// just a one-liner preview.
fn format_stderr_tail_for_display(tail: &str) -> String {
let trimmed = tail.trim_end();
let last_line = trimmed.lines().next_back().unwrap_or("");
let max_len = 200;
if last_line.len() <= max_len {
last_line.to_string()
} else {
format!("...{}", &last_line[last_line.len() - max_len..])
}
}
impl From<std::io::Error> for AttachError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<TransportError> for AttachError {
fn from(e: TransportError) -> Self {
Self::Transport(e)
}
}
/// Transport-agnostic IO trio for the attach pump.
///
/// `run_attach_pair` consumes one of these and drives the bidirectional
/// protocol: instance messages flow in through `reader`, frontend
/// events flow out through `writer`, and `kick` is the tear-down
/// escape hatch.
///
/// # Construction
///
/// Each transport builds its own `AttachIo` from the primitives it has
/// available. The local-socket transport (M5.5g) clones a `UnixStream`
/// three ways and uses a kick-aware reader plus socket shutdown for
/// the kick. The SSH
/// transport (M5.7e) takes a child process's `stdout` and `stdin`
/// halves and uses `SIGTERM` to the child for the kick.
pub(crate) struct AttachIo {
/// Reader half. Moved into the reader thread; that thread owns
/// blocking reads of `InstanceMessage` frames until the channel
/// disconnects or the underlying transport closes.
pub reader: Box<dyn Read + Send>,
/// Writer half. Stays on the main thread, used for outbound
/// `FrontendEvent` writes.
pub writer: Box<dyn Write>,
/// Force the reader thread to exit promptly, by any means
/// necessary.
///
/// Called by the main thread after the message loop has decided
/// to terminate, immediately before joining the reader thread.
/// The reader thread is presumed to be blocked on a read; this
/// wakes it.
///
/// Implementations may be destructive. Callers do not distinguish
/// — by the time the kick runs, the pump has already decided to
/// exit.
pub kick: Box<dyn FnOnce() + Send>,
}
impl std::fmt::Debug for AttachIo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AttachIo")
.field("reader", &"<dyn Read>")
.field("writer", &"<dyn Write>")
.field("kick", &"<FnOnce>")
.finish()
}
}
/// Frontend operations the attach pump consumes.
///
/// Pulled out as a trait so unit tests can substitute a mock without
/// taking over a real terminal. The production impl is on
/// [`Frontend`]; tests in this module supply their own.
pub(crate) trait AttachPumpFrontend {
fn present_messages(&mut self, msgs: &[InstanceMessage]) -> std::io::Result<()>;
fn poll_event(&mut self, timeout: Duration) -> std::io::Result<Option<Event>>;
fn size(&self) -> CellSize;
/// T M10.10 Day 3 step 5 Path β — paint an optimistic insert
/// at the terminal's current cursor position. Used when the
/// optimistic-apply orchestrator landed a `CrdtOp` AND the
/// mirror reports `cursor_at_end_of_line == true` for the active
/// buffer.
///
/// Default impl no-ops; the production `Frontend` overrides with
/// the actual terminal-write path. Tests using stub frontends
/// inherit the no-op (visual paint isn't being asserted at the
/// unit-test level).
///
/// Feature-gated: the orchestrator's call site is `#[cfg(feature =
/// "crdt")]`; the trait method exists only in CRDT builds to keep
/// the non-CRDT trait surface minimal.
#[cfg(feature = "crdt")]
fn paint_optimistic_insert(&mut self, _c: char) -> std::io::Result<()> {
Ok(())
}
/// T M10.10 Day 3 step 5 Path β — paint an optimistic
/// delete-back: erase the cell to the left of the cursor and
/// retreat the cursor one column. Cells match what the daemon's
/// `CellDelta` will eventually carry (last char of line becomes a
/// space at the cursor position before the cursor returns).
#[cfg(feature = "crdt")]
fn paint_optimistic_delete_back(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl AttachPumpFrontend for Frontend {
fn present_messages(&mut self, msgs: &[InstanceMessage]) -> std::io::Result<()> {
Frontend::present_messages(self, msgs)
}
fn poll_event(&mut self, timeout: Duration) -> std::io::Result<Option<Event>> {
Frontend::poll_event(self, timeout)
}
fn size(&self) -> CellSize {
Frontend::size(self)
}
#[cfg(feature = "crdt")]
fn paint_optimistic_insert(&mut self, c: char) -> std::io::Result<()> {
Frontend::paint_optimistic_insert(self, c)
}
#[cfg(feature = "crdt")]
fn paint_optimistic_delete_back(&mut self) -> std::io::Result<()> {
Frontend::paint_optimistic_delete_back(self)
}
}
/// Connect to the daemon at `socket_path` and run the attach client.
///
/// Returns when the daemon disconnects (clean detach, instance
/// shutdown, version mismatch, etc.) or when the user presses F12.
// Owned `PathBuf` lets callers hand the result of
// `resolve_socket_path` straight in; clippy's pedantic
// pass-by-value is wrong for this entry point.
#[allow(clippy::needless_pass_by_value)]
pub fn run_attach(socket_path: PathBuf) -> Result<(), AttachError> {
let mut stream = UnixStream::connect(&socket_path)?;
// Hello / AttachRequest handshake on the raw stream, before
// raw mode engages. If the daemon refuses the attach (version
// mismatch, malformed Hello, EOF), the error message reaches a
// normal terminal — `Frontend::new` hasn't taken over yet.
let hello: Hello = read_message(&mut stream)?;
// T M10.5: relaxed from strict equality to range membership per
// `§sec:m10-backward-compat`. A v1.0 frontend accepts a Hello
// from a v0.1 daemon (protocol_version=1) and downgrades its own
// request to match the server's version; symmetric to the daemon-
// side relaxation. Versions outside `SUPPORTED_PROTOCOL_VERSIONS`
// are still rejected.
if !crate::protocol::is_supported_protocol_version(hello.protocol_version) {
return Err(AttachError::VersionMismatch {
server: hello.protocol_version,
client: PROTOCOL_VERSION,
});
}
print_attach_info(&hello);
let (cols, rows) = crossterm::terminal::size().map_err(AttachError::Terminal)?;
let initial_size = CellSize::new(u32::from(rows), u32::from(cols));
// T M10.5: match the server's protocol version so a v1.0 frontend
// connecting to a v0.1 daemon advertises protocol_version=1 in
// its AttachRequest (the v0.1 daemon's strict-equality check will
// accept). The frontend's runtime behavior on the wire is the
// intersection of features both sides support.
//
// Bottom-panel Stage 2B-3: that echo is now a *floor*, not the whole
// rule. `Hello` is server-first, so the daemon must advertise a
// baseline every shipped frontend accepts; the session's real version
// is settled here, by this frontend counter-offering its own
// `PROTOCOL_VERSION` when the baseline is the current one. Anything
// older is still echoed verbatim.
let req = AttachRequest {
protocol_version: crate::protocol::requested_protocol_version(hello.protocol_version),
frontend_capabilities: build_capabilities(),
initial_size,
};
write_message(&mut stream, &req)?;
// Take over the terminal. Frontend's Drop tears it down.
let mut frontend = Frontend::new().map_err(AttachError::Terminal)?;
let io = build_local_socket_io(stream)?;
let result = run_attach_pair(io, &mut frontend, hello.assigned_frontend_id);
// Frontend drops here; raw mode + alternate screen + mouse capture
// all torn down before we return.
drop(frontend);
result
}
/// Build an [`AttachIo`] for a connected `UnixStream`.
///
/// The kick sets a shared flag and clones a third handle for
/// `shutdown(Both)`. Cloning may
/// fail (rare — the kernel is out of file descriptors), in which
/// case the caller propagates the error before raw mode engages.
fn build_local_socket_io(stream: UnixStream) -> Result<AttachIo, std::io::Error> {
let reader = stream.try_clone()?;
reader.set_nonblocking(true)?;
let kick_handle = stream.try_clone()?;
let kicked = Arc::new(AtomicBool::new(false));
let reader_kicked = Arc::clone(&kicked);
Ok(AttachIo {
reader: Box::new(KickAwareUnixReader {
stream: reader,
kicked: reader_kicked,
}),
writer: Box::new(stream),
kick: Box::new(move || {
kicked.store(true, Ordering::SeqCst);
let _ = kick_handle.shutdown(Shutdown::Both);
}),
})
}
/// Non-blocking poll-based reader with a kick flag.
///
/// # Wake semantics
///
/// This reader has two cooperating wake paths, only one of which is
/// load-bearing:
///
/// 1. **Atomic flag (correctness):** the reader runs a non-blocking
/// poll loop with a 10ms sleep between iterations. After the kick
/// sets `kicked`, the next loop iteration observes it and returns
/// `Ok(0)`. Worst-case wake latency is one poll cycle (~10ms).
/// This path is platform-independent and is the mechanism the
/// caller relies on for correctness.
///
/// 2. **`shutdown(Both)` on a sibling clone (best-effort speedup):**
/// if the reader happens to be inside `self.stream.read()` when
/// the kick fires, and the platform honors cross-clone shutdown
/// wakes, the read returns `Ok(0)` immediately and the loop
/// skips its sleep. This path is **not** load-bearing — Unix
/// socket cross-clone shutdown semantics are not portably
/// guaranteed, and any wake it provides is a bonus on top of
/// path 1.
///
/// In other words: the atomic flag wakes the reader; the shutdown
/// just shaves up to ~10ms off the wake when the platform plays
/// along. Tests asserting wake bounds should treat the budget as
/// "≤ one poll cycle plus scheduler jitter," not as a measure of
/// shutdown latency.
struct KickAwareUnixReader {
stream: UnixStream,
kicked: Arc<AtomicBool>,
}
impl Read for KickAwareUnixReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
loop {
match self.stream.read(buf) {
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) =>
{
if self.kicked.load(Ordering::SeqCst) {
return Ok(0);
}
thread::sleep(Duration::from_millis(10));
}
other => return other,
}
}
}
}
// `pub` so the post-audit Finding 6 production-path test in
// `tests/m5_5_acceptance.rs` can verify the production caps directly
// rather than reconstructing them in the test (which is exactly the
// gap that allowed Finding 1 to survive M10.10's first audit —
// `attach_multi()`'s custom caps bypassed the production function).
//
// Not part of the stable public API; reserved for internal test use.
#[doc(hidden)]
pub fn build_capabilities() -> FrontendCapabilities {
// The v0.1 TUI implements all of these; we report them honestly
// so the daemon doesn't strip features that work fine.
//
// T M10.10 — `multi_frontend` and `crdt_replica` advertise the
// M10.10 BufferMirror + optimistic-apply infrastructure. Gated
// on the `crdt` Cargo feature because the relevant modules
// (`buffer_mirror`, `optimistic`) are conditionally compiled.
// A non-CRDT build's frontend can't bootstrap a mirror and
// shouldn't claim it can. CRDT-feature builds advertise true;
// the daemon's per-tick CursorByte + BufferSnapshot bootstrap +
// CrdtOp routing are then negotiated correctly.
FrontendCapabilities {
synchronized_output: true,
unicode_smp: true,
true_color: true,
mouse: true,
bracketed_paste: true,
terminal_kind: std::env::var("TERM").ok(),
multi_frontend: cfg!(feature = "crdt"),
crdt_replica: cfg!(feature = "crdt"),
// T M11.1 — the v0.1/v1.0 TUI is a grid frontend, not a
// semantic (layout-local) renderer. It never consumes the
// SemanticFrame family. A future GPU/GUI frontend sets this
// true; the TUI stays false.
semantic_render: false,
}
}
fn print_attach_info(hello: &Hello) {
let id = &hello.instance_identity;
let name = id.instance_name.as_deref().unwrap_or("pmacs");
let hash = id
.build_hash
.as_deref()
.map(|h| format!(" ({h})"))
.unwrap_or_default();
let uptime = format_uptime(id.uptime_secs);
let _ = writeln!(
std::io::stderr(),
"pmacs: attached to {name} (pmacs {version}{hash}, running {uptime})",
version = id.pmacs_version,
);
let _ = writeln!(
std::io::stderr(),
" press F12 to detach (or close the terminal)"
);
}
fn format_uptime(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m{}s", secs / 60, secs % 60)
} else {
format!("{}h{}m", secs / 3600, (secs % 3600) / 60)
}
}
/// Bidirectional pump: forward local input to the writer, apply
/// instance messages to the terminal.
///
/// Threading: a background thread reads instance messages from the
/// reader half and forwards them on a channel. The main thread polls
/// the terminal for input, drains the channel, and renders.
///
/// # Preconditions
///
/// Callers must complete the [`Hello`] / [`AttachRequest`] handshake
/// on the raw transport *before* calling this function. The next
/// bytes on `io.reader` are protocol traffic — typed
/// [`InstanceMessage`] frames. The handshake happens at the
/// construction site so handshake errors reach a normal terminal,
/// while pump errors happen behind raw mode.
///
/// # Tear-down contract
///
/// On any exit path — clean Goodbye, reader EOF, F12 detach, IO
/// error, or terminal error — `io.kick` fires and `reader_handle`
/// is joined before this function returns. The closure-and-call
/// wind-down pattern guarantees this regardless of which `return`
/// the loop takes.
// M10.10 grew this function with optimistic-apply orchestration +
// BufferSnapshot/CursorByte/CrdtOp routing in the message-drain
// loop. The 146-line size is intentionally cohesive: the closure
// captures the AttachIo writer and BufferMirror together, and
// splitting would require parameterizing both across helper
// functions or restructuring the wind-down pattern (drop(writer)
// → kick → join) which is the function's primary correctness
// invariant. The lint flags growth without naming a structural
// problem; defer to v0.2+ refactor if growth continues.
#[allow(clippy::too_many_lines)]
pub(crate) fn run_attach_pair(
io: AttachIo,
frontend: &mut dyn AttachPumpFrontend,
assigned_id: FrontendId,
) -> Result<(), AttachError> {
let AttachIo {
reader,
mut writer,
kick,
} = io;
let (tx, rx) = mpsc::channel::<InstanceMessage>();
let reader_handle = thread::spawn(move || run_reader(reader, tx));
// T M10.10: per-session CRDT replica state. Bootstrapped by
// `InstanceMessage::BufferSnapshot` messages routed in the
// drain loop below; consumed by the optimistic-apply predicate
// wired in Day 3.
#[cfg(feature = "crdt")]
let mut buffer_mirror = crate::buffer_mirror::BufferMirror::new(assigned_id);
// T M11.6 — daemon-side input-dispatcher idleness. `false` =
// pessimistic default before the first `DispatchIdle` arrives;
// the daemon emits an initial value once per attach so this
// converges to the true state on the first frame. While `false`,
// every keystroke round-trips as `FrontendEvent::Key` regardless
// of optimistic-apply eligibility — this is the fix for the
// M10.10-era latent bug where chars typed into a minibuffer
// prompt were optimistically applied to the previously-active
// document.
#[cfg(feature = "crdt")]
let mut dispatch_idle = false;
// Closure-and-call: any `return` from this closure still falls
// through to `kick()` and `reader_handle.join()` below. Without
// this wrapping a writer-side IO error inside the loop would skip
// the wind-down.
let result: Result<(), AttachError> = (|| {
loop {
// Drain instance messages. Goodbye exits immediately;
// BufferSnapshot routes to the mirror; other messages are
// batched into a single present_messages call.
let mut batch: Vec<InstanceMessage> = Vec::new();
let mut goodbye: Option<GoodbyeReason> = None;
let mut reader_eof = false;
loop {
match rx.try_recv() {
Ok(InstanceMessage::Goodbye(reason)) => {
goodbye = Some(reason);
break;
}
#[cfg(feature = "crdt")]
Ok(InstanceMessage::BufferSnapshot {
buffer_id,
crdt_snapshot,
}) => {
// T M10.10: bootstrap the mirror for
// `buffer_id`. AlreadyInitialized errors
// surface a daemon-side bug (double-send) but
// shouldn't abort the session — log and
// continue with prior state. Loro decode
// errors are similarly logged.
if let Err(e) = buffer_mirror.init_from_snapshot(buffer_id, &crdt_snapshot)
{
eprintln!("pmacs: BufferMirror init for {buffer_id:?} failed: {e}");
}
}
#[cfg(feature = "crdt")]
Ok(InstanceMessage::CursorByte {
buffer_id,
byte_pos,
}) => {
// T M10.10 Finding 2: authoritative cursor
// byte-position update from the daemon. The
// optimistic-apply path consults
// `buffer_mirror.cursor_byte_pos(buffer_id)`
// before generating a local CrdtOp; this
// keeps that lookup current. Convert wire
// u64 → usize for the loro API.
buffer_mirror.set_cursor_byte_pos(buffer_id, byte_pos as usize);
}
#[cfg(feature = "crdt")]
Ok(InstanceMessage::CrdtOp { buffer_id, op }) => {
// T M10.10 step 4 — remote CrdtOp routing.
//
// The filter site is the message loop, NOT
// inside BufferMirror. Echoes of locally-
// applied edits arrive via CrdtOp broadcasts
// (the daemon fans out every op including the
// originator's own); the mirror has already
// applied these via apply_local_insert /
// apply_local_delete at keystroke time;
// re-applying would double-insert. The
// BufferMirror layer stays identity-ignorant
// by design — `apply_incoming_crdt_op` does
// the FrontendId comparison before invoking
// the mirror.
//
// Source FrontendId is derived from
// `op.peer_id` via the identity mapping
// documented in `crdt::peer_id_from_frontend`
// (FrontendId(n).0 == n).
let source = FrontendId(op.peer_id);
match crate::optimistic::apply_incoming_crdt_op(
&mut buffer_mirror,
assigned_id,
source,
buffer_id,
&op.bytes,
) {
Ok(_outcome) => {
// Applied or SkippedEcho — both are
// success. Paint reconciliation
// (step 5) handles the visible diff.
}
Err(e) => {
// NotReady is the common case for a
// buffer this frontend hasn't been
// snapshotted for (mid-session
// buffer creation; v0.2's broadcast
// will close this gap). Log and
// continue.
eprintln!("pmacs: CrdtOp routing for {buffer_id:?} failed: {e}");
}
}
}
#[cfg(feature = "crdt")]
Ok(InstanceMessage::DispatchIdle { idle }) => {
// T M11.6 — daemon's input-dispatcher state.
// Consumed *here only*: no frontend rendering
// depends on the value; the optimistic-apply
// gate reads `dispatch_idle` below.
dispatch_idle = idle;
}
Ok(msg) => batch.push(msg),
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
reader_eof = true;
break;
}
}
}
if !batch.is_empty()
&& let Err(e) = frontend.present_messages(&batch)
{
return Err(AttachError::Terminal(e));
}
if let Some(reason) = goodbye {
// Stderr buffered behind raw mode; surfaces after
// Frontend's Drop runs.
eprintln!("pmacs: instance disconnected: {reason:?}");
return Ok(());
}
if reader_eof {
eprintln!("pmacs: instance closed connection");
return Ok(());
}
let event = match frontend.poll_event(Duration::from_millis(50)) {
Ok(opt) => opt,
Err(e) => return Err(AttachError::Terminal(e)),
};
let Some(ev) = event else {
continue;
};
if is_detach_key(&ev) {
let _ = write_message(&mut writer, &FrontendEvent::Detach(assigned_id));
return Ok(());
}
// T M10.10 Day 3 step 3b — text-input optimistic-apply
// orchestration. For Press/Repeat key events, the
// orchestrator either:
// - returns FrontendEvent::CrdtOp (after applying the
// edit to the local mirror) when the mirror is ready
// for the active buffer, or
// - returns FrontendEvent::Key (graceful Refinement 4
// fallback) when the optimistic path isn't viable.
// The caller writes whatever event was produced. Other
// event kinds (mouse, resize, paste, focus, Release-kind
// keys) fall through to the existing forward_event path.
#[cfg(feature = "crdt")]
let optimistic_handled = if let Event::Key(k) = &ev {
// T M11.6 — gate on daemon idleness. When the daemon
// would intercept this key (minibuffer prompt active
// or dispatcher holds a pending prefix), force the
// keystroke to round-trip via `FrontendEvent::Key`
// regardless of its optimistic-apply classification.
// The orchestrator below still runs for non-key
// events; only the Press/Repeat→CrdtOp path is gated.
if !dispatch_idle {
false
} else if matches!(k.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
let timestamp_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(0));
let pmacs_key = key_from_crossterm(k, assigned_id, timestamp_ns);
// T M10.10 Day 3 step 5 Path β — determine
// visual-paint eligibility BEFORE the orchestrator
// mutates the mirror's cursor. End-of-line typing
// is the only case where single-Print optimistic
// paint matches the daemon's eventual CellDelta
// exactly (no cells right of cursor to shift). The
// action enum is captured so we can dispatch to
// the right paint primitive after the orchestrator
// produces a CrdtOp.
let action = crate::optimistic::classify_key(pmacs_key.key, pmacs_key.mods);
// Insert paint requires cursor at end-of-line (the
// single-Print sequence matches the daemon's
// eventual CellDelta exactly).
// Delete-back paint requires the stricter
// `cursor_at_end_of_line_safe_for_delete_back`
// predicate per post-audit Finding 5: also requires
// prev char != '\n'. Backspace that joins lines
// (prev char = newline) can't be represented by the
// single-column-erase paint sequence; falls
// through to v0.1 round-trip.
let active_buf = buffer_mirror.active_buffer();
let insert_paint_eligible = active_buf
.and_then(|b| buffer_mirror.cursor_at_end_of_line(b))
== Some(true);
let delete_back_paint_eligible = active_buf
.and_then(|b| buffer_mirror.cursor_at_end_of_line_safe_for_delete_back(b))
== Some(true);
let frontend_event = crate::optimistic::frontend_event_for_keystroke(
&mut buffer_mirror,
assigned_id,
pmacs_key,
);
if let Err(e) = write_message(&mut writer, &frontend_event) {
eprintln!("pmacs: write keystroke failed: {e}");
return Err(AttachError::from(e));
}
// Post-audit-round-4 F22 — if we round-tripped via
// `FrontendEvent::Key`, the daemon's command
// pipeline may move the cursor in ways the mirror
// can't predict locally (motion, Enter/Tab,
// mid-line edits, delete-forward, etc.). Mark the
// active buffer's cursor stale so subsequent
// keystrokes round-trip too until the daemon's
// next `CursorByte` re-grounds the mirror cursor.
if matches!(frontend_event, FrontendEvent::Key(_))
&& let Some(active_buf) = buffer_mirror.active_buffer()
{
buffer_mirror.mark_cursor_stale(active_buf);
}
// Visual optimistic paint (Path β). Fires only when
// the orchestrator landed a CrdtOp (mirror was
// ready, action was optimistic-eligible) AND the
// pre-edit cursor was at an action-specific safe
// position. Mid-line operations, line-joining
// backspace, and round-trip cases skip — the
// daemon's CellDelta drives paint for those.
//
// Daemon-side CellDelta suppression is NOT needed:
// under Path β, optimistic paint either matches
// the eventual CellDelta exactly (end-of-line)
// or doesn't exist (mid-line / line-join). Either
// way, no flicker.
if matches!(frontend_event, FrontendEvent::CrdtOp { .. }) {
let paint_result = match action {
crate::optimistic::OptimisticAction::Insert(c)
if insert_paint_eligible =>
{
frontend.paint_optimistic_insert(c)
}
crate::optimistic::OptimisticAction::DeleteBack
if delete_back_paint_eligible =>
{
frontend.paint_optimistic_delete_back()
}
_ => Ok(()),
};
if let Err(e) = paint_result {
eprintln!("pmacs: optimistic paint failed: {e}");
}
}
true
} else {
false
}
} else {
false
};
#[cfg(not(feature = "crdt"))]
let optimistic_handled = false;
if !optimistic_handled
&& let Err(e) = forward_event(&mut writer, &ev, assigned_id, frontend.size())
{
// Likely a broken pipe — instance went away.
eprintln!("pmacs: {e}");
return Err(e);
}
// Post-audit-round-6 F30 — `forward_event` (success
// path) may write a Mouse / Paste / Resize /
// FocusGained / FocusLost event (or no-op for a Key
// Release). Mouse down/drag in particular can move
// the daemon's active window cursor, change the
// active buffer, or both. Anything except an
// optimistic CrdtOp can desync the mirror's cursor
// from the daemon's view; conservatively mark the
// active buffer's cursor stale so subsequent
// keystrokes round-trip until the daemon's next
// `CursorByte` re-grounds the mirror.
#[cfg(feature = "crdt")]
if let Some(active_buf) = buffer_mirror.active_buffer() {
buffer_mirror.mark_cursor_stale(active_buf);
}
}
})();
// Wind down. The order matters:
//
// 1. `drop(writer)` — close our side of the protocol stream. For
// transports where the writer is the only handle to that
// half (SSH `ChildStdin`), this triggers an EOF cascade that
// ends with the reader thread seeing EOF naturally. For the
// local-socket transport, dropping one of three `UnixStream`
// clones is a no-op (other clones keep the FD alive); the
// reader needs `kick` to wake.
//
// 2. `kick()` — wake the reader thread, by any means necessary
// (per the kick contract). For local-socket: `shutdown(Both)`.
// For SSH: a watchdog that SIGTERMs the child if the EOF
// cascade hasn't reached the reader within the watchdog's
// grace period.
//
// 3. `join` — wait for the reader thread to exit.
//
// The drop-before-kick ordering is what lets SSH's clean-detach
// path produce exit code 0: writer close starts the cascade,
// kick is best-effort backup, the cascade typically completes
// before kick has any effect.
drop(writer);
kick();
let _ = reader_handle.join();
result
}
/// F12 is the v0.1 detach key. Any modifier combination triggers
/// detach so users with sticky modifier keys can still exit.
fn is_detach_key(ev: &Event) -> bool {
matches!(
ev,
Event::Key(crate::frontend::KeyEvent {
code: KeyCode::F(12),
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
})
)
}
fn forward_event<W: Write>(
writer: &mut W,
ev: &Event,
assigned_id: FrontendId,
term_size: CellSize,
) -> Result<(), AttachError> {
let _ = term_size; // reserved for future use (Resize coordinate-ref)
match ev {
Event::Key(k) => {
if !matches!(k.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return Ok(());
}
// Strip Caps/Scroll/NumLock and so on — they shouldn't
// pollute the modifier mask. The protocol only knows
// SHIFT/CTRL/ALT/META/HYPER. Crossterm's translation
// already handles this; we just need a stable timestamp.
let _ = KeyModifiers::empty(); // import touch: keeps lint happy if unused
let timestamp_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(0));
let pmacs_key = key_from_crossterm(k, assigned_id, timestamp_ns);
write_message(writer, &FrontendEvent::Key(pmacs_key))?;
}
Event::Mouse(m) => {
let pmacs_mouse = mouse_from_crossterm(m, assigned_id);
write_message(writer, &FrontendEvent::Mouse(pmacs_mouse))?;
}
Event::Resize(cols, rows) => {
let size = CellSize::new(u32::from(*rows), u32::from(*cols));
write_message(
writer,
&FrontendEvent::Resize {
frontend_id: assigned_id,
size,
},
)?;
}
Event::Paste(data) => {
write_message(
writer,
&FrontendEvent::Paste {
frontend_id: assigned_id,
data: data.clone().into_bytes(),
},
)?;
}
Event::FocusGained => {
write_message(writer, &FrontendEvent::FocusGained(assigned_id))?;
}
Event::FocusLost => {
write_message(writer, &FrontendEvent::FocusLost(assigned_id))?;
}
}
Ok(())
}
// Same rationale as `daemon::run_reader`: reader is owned so the fd
// closes when the thread returns; tx is owned so the channel
// disconnects.
//
// Generic over any `Read + Send` so the same loop drives a
// `UnixStream` clone (local-socket transport) or a `ChildStdout`
// (SSH transport, M5.7e) without per-transport branching.
#[allow(clippy::needless_pass_by_value)]
fn run_reader(mut reader: Box<dyn Read + Send>, tx: mpsc::Sender<InstanceMessage>) {
loop {
match read_message::<InstanceMessage>(&mut reader) {
Ok(msg) => {
if tx.send(msg).is_err() {
return;
}
}
Err(_) => return,
}
}
}
// ---------------------------------------------------------------------------
// SSH transport (T M5.7e)
// ---------------------------------------------------------------------------
/// Resolve the SSH binary the transport will spawn.
///
/// Production uses plain `ssh` (resolved through `PATH` by
/// `Command::new`). Tests may set [`PMACS_TEST_SSH_BIN`] to
/// substitute a stand-in that simulates the SSH child without
/// needing a real network or sshd.
fn ssh_binary() -> PathBuf {
std::env::var_os(PMACS_TEST_SSH_BIN).map_or_else(|| PathBuf::from("ssh"), PathBuf::from)
}
fn attach_debug_enabled() -> bool {
std::env::var_os(PMACS_ATTACH_DEBUG).is_some_and(|v| !v.is_empty() && v != "0")
}
/// Pure channel resolver (env-free core, unit-tested without env
/// mutation). Precedence: explicit `PMACS_ATTACH_SSH_PROTOCOL` >
/// legacy `PMACS_ATTACH_SSH_PROTOCOL_STDERR` > `default`.
fn resolve_ssh_protocol(
explicit: Option<&str>,
legacy: Option<&str>,
default: SshProtocolChannel,
) -> SshProtocolChannel {
if let Some(e) = explicit {
match e.trim().to_ascii_lowercase().as_str() {
"stdout" | "1" => return SshProtocolChannel::Stdout,
"stderr" | "2" => return SshProtocolChannel::Stderr,
_ => {} // unrecognized → fall through
}
}
if let Some(l) = legacy {
let l = l.trim();
if !l.is_empty() {
return if l == "0" {
SshProtocolChannel::Stdout
} else {
SshProtocolChannel::Stderr
};
}
}
default
}
/// Env-reading wrapper over [`resolve_ssh_protocol`]. The single
/// place env is consulted for the channel decision.
fn ssh_protocol_channel() -> SshProtocolChannel {
resolve_ssh_protocol(
std::env::var(PMACS_ATTACH_SSH_PROTOCOL).ok().as_deref(),
std::env::var(PMACS_ATTACH_SSH_PROTOCOL_STDERR)
.ok()
.as_deref(),
SSH_PROTOCOL_DEFAULT,
)
}
/// True once the interactive frontend owns the terminal (raw mode +
/// alternate screen). After that point, attach breadcrumbs must not
/// touch stderr — it *is* the screen the renderer paints — so they go
/// to the log file only. Stays true across reconnects (the TUI is
/// still up while a reconnect handshake runs).
static TUI_TERMINAL_OWNED: AtomicBool = AtomicBool::new(false);
/// Lazily-opened append handle for the full debug log. `None` inside
/// the `Option` means open failed; debug then degrades to stderr-only
/// (best-effort — diagnostics never abort the attach).
static DEBUG_FILE: OnceLock<Mutex<Option<std::fs::File>>> = OnceLock::new();
/// Call right before handing the terminal to the interactive pump so
/// subsequent breadcrumbs stop mirroring to stderr.
fn mark_tui_terminal_owned() {
TUI_TERMINAL_OWNED.store(true, Ordering::Relaxed);
}
fn debug_file_path() -> PathBuf {
std::env::var_os(PMACS_ATTACH_DEBUG_FILE).map_or_else(
|| std::env::temp_dir().join("pmacs-attach-debug.log"),
PathBuf::from,
)
}
fn debug_file() -> &'static Mutex<Option<std::fs::File>> {
DEBUG_FILE.get_or_init(|| {
let path = debug_file_path();
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
Ok(f) => {
// Emitted during the handshake phase (terminal not yet
// owned), so the user sees where the full log lives.
eprintln!("pmacs attach debug: logging to {}", path.display());
Mutex::new(Some(f))
}
Err(e) => {
eprintln!(
"pmacs attach debug: could not open {} ({e}); stderr only",
path.display()
);
Mutex::new(None)
}
}
})
}
fn attach_debug(msg: impl AsRef<str>) {
if !attach_debug_enabled() {
return;
}
let line = format!("pmacs attach debug: {}", msg.as_ref());
// Always append the full stream to the log file — including the
// live-session protocol reads that would otherwise paint over the
// TUI's alternate screen.
if let Ok(mut guard) = debug_file().lock()
&& let Some(f) = guard.as_mut()
{
let _ = writeln!(f, "{line}");
}
// Mirror to stderr only while it is still a normal terminal —
// i.e., before the interactive frontend takes it over.
if !TUI_TERMINAL_OWNED.load(Ordering::Relaxed) {
eprintln!("{line}");
}
}
fn hex_preview(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for (idx, byte) in bytes.iter().take(16).enumerate() {
if idx > 0 {
out.push(' ');
}
let _ = write!(&mut out, "{byte:02x}");
}
if bytes.len() > 16 {
out.push_str(" ...");
}
out
}
struct DebugReader<R> {
label: &'static str,
inner: R,
total: u64,
logged_chunks: u8,
max_logged_chunks: u8,
}
impl<R> DebugReader<R> {
fn new(label: &'static str, inner: R, max_logged_chunks: u8) -> Self {
Self {
label,
inner,
total: 0,
logged_chunks: 0,
max_logged_chunks,
}
}
}
impl<R: Read> Read for DebugReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let n = self.inner.read(buf)?;
if n > 0 {
self.total += n as u64;
}
if attach_debug_enabled() {
if n == 0 {
attach_debug(format!("{}: EOF after {} bytes", self.label, self.total));
} else if self.logged_chunks < self.max_logged_chunks {
self.logged_chunks = self.logged_chunks.saturating_add(1);
attach_debug(format!(
"{}: read chunk {} bytes (total {}), first bytes [{}]",
self.label,
n,
self.total,
hex_preview(&buf[..n])
));
if self.logged_chunks == self.max_logged_chunks {
attach_debug(format!(
"{}: suppressing further chunk logs after {} chunks",
self.label, self.max_logged_chunks
));
}
}
}
Ok(n)
}
}
/// Construct the [`Command`] for SSH attach without spawning.
///
/// Pure (modulo the env-var lookup for test substitution): used
/// directly by [`run_attach_ssh`] and by unit tests that want to
/// assert argument shape without spawning a real `ssh`.
///
/// Argument order: `-T [user-flag] host [env VAR=val] pmacs
/// --daemon-attach [--socket NAME]`. The user flag is `-l USER` if
/// `user` is set;
/// SSH's own `~/.ssh/config` is consulted by the binary, so we
/// don't try to second-guess host aliases here.
///
/// # Panics
///
/// Does not panic on its own; passing a non-`Ssh` `AttachTarget`
/// returns `None`.
pub(crate) fn build_ssh_command(target: &AttachTarget) -> Option<Command> {
let AttachTarget::Ssh {
host,
user,
instance_name,
} = target
else {
return None;
};
let mut cmd = Command::new(ssh_binary());
// This is a binary stdio transport. Force no remote pseudo-tty
// even if the user's ssh_config requests one for the host.
cmd.arg("-T");
if let Some(u) = user {
cmd.arg("-l").arg(u);
}
cmd.arg(host);
// Single source of truth (F8b): the resolved channel drives the
// remote bridge's protocol fd. Stdout is fd 1 (the bridge's
// default when unset), so we only pass the fd env when it
// differs — keeps the stdout path's argv minimal and unchanged.
// Debug is independent and additive (the remote bridge already
// self-suppresses its stderr breadcrumbs when the protocol rides
// fd 2, so the two can coexist; the old mutually-exclusive
// `else if` could not).
let mut env_pairs: Vec<String> = Vec::new();
let fd = ssh_protocol_channel().remote_fd();
if fd != 1 {
env_pairs.push(format!("{PMACS_ATTACH_PROTOCOL_FD}={fd}"));
}
if attach_debug_enabled() {
env_pairs.push(format!("{PMACS_ATTACH_DEBUG}=1"));
}
if !env_pairs.is_empty() {
cmd.arg("env");
for kv in &env_pairs {
cmd.arg(kv);
}
}
cmd.arg("pmacs").arg("--daemon-attach");
if let Some(name) = instance_name {
cmd.arg("--socket").arg(name);
}
Some(cmd)
}
/// Spawn a thread that copies child stderr to our own stderr while
/// retaining the tail bytes for diagnostic surfacing.
///
/// "Inherit always" was the explicit user requirement (so SSH
/// errors land in the user's terminal scrollback for diagnosis),
/// but we also wanted a tail for [`AttachError::SshChildExited`].
/// The tee thread does both when `tee_to_stderr` is `true`.
///
/// Returns the join handle and a shared ring buffer; the buffer
/// contains the most recent [`SSH_STDERR_TAIL_CAP`] bytes when the
/// thread exits.
///
/// `tee_to_stderr` controls whether SSH stderr is forwarded to our
/// real stderr in real time. The first attach attempt sets this to
/// `true` so handshake-time errors (DNS lookup, host key, auth) reach
/// the user's scrollback before raw mode engages. Reconnect attempts
/// (T M5.8d) set it to `false`: raw mode is already on, so live
/// stderr would corrupt the cell grid; the tail is still captured for
/// the give-up message.
fn spawn_stderr_tee(
mut stderr: std::process::ChildStderr,
tee_to_stderr: bool,
) -> (thread::JoinHandle<()>, Arc<Mutex<VecDeque<u8>>>) {
let tail: Arc<Mutex<VecDeque<u8>>> =
Arc::new(Mutex::new(VecDeque::with_capacity(SSH_STDERR_TAIL_CAP)));
let tail_for_thread = tail.clone();
let handle = thread::spawn(move || {
let mut buf = [0u8; 1024];
let mut local_stderr = if tee_to_stderr {
Some(std::io::stderr().lock())
} else {
None
};
loop {
let n = match stderr.read(&mut buf) {
Ok(n) if n > 0 => n,
_ => break,
};
if let Some(s) = local_stderr.as_mut() {
let _ = s.write_all(&buf[..n]);
let _ = s.flush();
}
let mut tail = tail_for_thread.lock().expect("tail mutex");
for &b in &buf[..n] {
if tail.len() >= SSH_STDERR_TAIL_CAP {
tail.pop_front();
}
tail.push_back(b);
}
}
});
(handle, tail)
}
/// Build the SSH-transport kick.
///
/// The kick spawns a watchdog thread: after [`SSH_KICK_WATCHDOG`]
/// elapses, SIGTERM the child if it's still running. In the
/// expected case (clean detach via Detach → daemon Goodbye → EOF
/// cascade), the child has already exited 0 well before the
/// watchdog fires; the SIGTERM goes to a non-existent process and
/// is harmlessly ignored. In the wedged case (network split,
/// daemon hung), the SIGTERM unsticks the reader thread.
///
/// Per the kick contract, this is best-effort: if SIGTERM itself
/// fails (ESRCH because the child already exited, EPERM in some
/// container scenarios), the error is dropped. The reader thread
/// will exit either way (cascade or signal).
fn ssh_kick(child_pid: u32) -> Box<dyn FnOnce() + Send> {
Box::new(move || {
thread::spawn(move || {
thread::sleep(SSH_KICK_WATCHDOG);
// Cast to nix's `Pid` (an `i32` newtype). On Linux the
// PID space fits in `i32`; the cast is lossless for any
// real process ID.
#[allow(clippy::cast_possible_wrap)]
let pid = nix::unistd::Pid::from_raw(child_pid as i32);
let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM);
});
})
}
/// Connect to a remote daemon over SSH and run the attach client.
///
/// Spawns `ssh [-l USER] HOST pmacs --daemon-attach [--socket NAME]`,
/// performs the [`Hello`]/[`AttachRequest`] handshake on the child's
/// stdio (so handshake errors reach a normal terminal before raw
/// mode engages), then drives the standard pump. On exit, the
/// child is reaped and its exit code is classified into a
/// user-visible diagnostic — exit 127 in particular maps to a
/// "command not found on remote" message that names the most
/// likely cause (PATH missing in non-interactive SSH).
///
/// Stderr is **inherited and tee'd**: bytes flow to our stderr in
/// real time (so users see SSH errors in their scrollback) and
/// also into a ring buffer surfaced through
/// [`AttachError::SshChildExited`] for diagnostic context.
// Owned `AttachTarget` matches `run_attach`'s `PathBuf` and lets
// callers hand the dispatcher's `RunAttachSsh(target)` straight in;
// pedantic clippy is wrong for this seam.
#[allow(clippy::needless_pass_by_value)]
pub fn run_attach_ssh(target: AttachTarget) -> Result<(), AttachError> {
// Pre-flight: target shape must be `Ssh`. The dispatcher
// (`AttachDispatch::RunAttachSsh`) is the only production caller
// and only constructs this with `Ssh`, so the check is defensive
// — a wrong shape never reaches here at runtime, and we exit
// before any side effects (no SSH spawn, no Frontend, no eprintln
// races).
if !matches!(&target, AttachTarget::Ssh { .. }) {
return Err(AttachError::Io(std::io::Error::other(
"run_attach_ssh requires AttachTarget::Ssh",
)));
}
// The Frontend lives in the outer scope so it persists across
// reconnect attempts. Lazy-initialized on the first successful
// handshake — until then, errors reach a cooked terminal
// directly via main.rs's `eprintln!`. The slot's `Some`-ness
// doubles as the `initial_handshake_complete` signal: once
// Frontend is up, transient failures reconnect indefinitely
// (mid-session disconnect should not bounce the user out).
let mut frontend_slot: Option<Frontend> = None;
let mut backoff = BackoffSchedule::new();
let mut handshake_attempts: u32 = 0;
let mut iteration: u32 = 0;
let result: Result<(), AttachError> = loop {
// Pre-attempt overlay (skipped on the very first iteration —
// no Frontend yet, and "reconnecting" would lie about state).
// The chosen banner depends on whether we're reconnecting
// mid-session (Frontend is up) or still trying to land the
// first handshake (Frontend never came up).
if iteration > 0 {
if let Some(f) = frontend_slot.as_mut() {
let banner = banner_session_reconnect();
let _ = f.draw_status_overlay(banner);
} else {
// Slot is None and we're past iteration 0 — only
// reachable through `ReconnectHandshake`. Show the
// bounded counter so the user knows how many tries
// remain before we give up.
//
// No Frontend means no overlay; the cooked terminal
// already saw the previous attempt's stderr. Fall
// through to the attempt without painting.
}
}
let initial_handshake_complete = frontend_slot.is_some();
let verdict = attempt_session(&mut frontend_slot, &target, initial_handshake_complete);
match verdict {
ReconnectVerdict::ExitClean => {
if let Some(f) = frontend_slot.as_mut() {
let _ = f.clear_status_overlay();
}
break Ok(());
}
ReconnectVerdict::ExitProtocolError { error }
| ReconnectVerdict::ExitPolicy { error } => {
break Err(error);
}
ReconnectVerdict::Reconnect { error: _last_error } => {
// Mid-session disconnect: indefinite retry. The
// backoff schedule is intentionally NOT reset between
// failed attempts — it walks the curve and plateaus
// at 30s, matching mosh. Reset on a sustained
// re-pump is a v0.3 refinement (we'd need a
// "pump_ran_for_at_least_N_seconds" signal that
// doesn't exist yet).
let delay = backoff.next_delay();
match sleep_with_countdown(frontend_slot.as_mut(), delay) {
SleepOutcome::Cancelled => break Ok(()),
SleepOutcome::Elapsed => {}
}
}
ReconnectVerdict::ReconnectHandshake { error } => {
handshake_attempts = handshake_attempts.saturating_add(1);
if handshake_attempts >= HANDSHAKE_RETRY_CAP {
// Exhausted the handshake retry budget. Surface
// the most recent error; the SSH stderr tail
// (if any) is already inside it via
// `AttachError::SshChildExited`.
break Err(error);
}
// Paint the bounded counter for the next attempt.
// Frontend may or may not be up — paint only if it
// is. (See note above: handshake-reconnect implies
// slot is None, but we re-check in case future
// refactors loosen the invariant.)
if let Some(f) = frontend_slot.as_mut() {
let banner = banner_handshake_reconnect(
handshake_attempts.saturating_add(1),
HANDSHAKE_RETRY_CAP,
);
let _ = f.draw_status_overlay(&banner);
}
let delay = backoff.next_delay();
match sleep_with_countdown(frontend_slot.as_mut(), delay) {
SleepOutcome::Cancelled => break Ok(()),
SleepOutcome::Elapsed => {}
}
}
}
iteration = iteration.saturating_add(1);
};
// Tear down raw mode (if engaged) before main.rs's eprintln.
drop(frontend_slot);
result
}
/// Banner painted while a session-reconnect attempt is in flight.
///
/// Used when the Frontend is already up (the user has seen the
/// editor render at least once on this `run_attach_ssh` invocation).
/// No counter — session reconnects are unbounded.
fn banner_session_reconnect() -> &'static str {
"[pmacs reconnecting...]"
}
/// Banner painted while a handshake-reconnect attempt is in flight.
///
/// Shows the bounded counter (`attempt N of M`) so the user knows
/// when to stop waiting and investigate. `attempt` is the 1-based
/// index of the next attempt about to fire; `cap` is
/// [`HANDSHAKE_RETRY_CAP`].
fn banner_handshake_reconnect(attempt: u32, cap: u32) -> String {
format!("[pmacs reconnecting (attempt {attempt} of {cap})...]")
}
/// Banner painted while sleeping between attempts.
///
/// Shows a per-second countdown so the user can predict the next
/// retry. Rounds remaining time UP so the displayed value matches
/// "you'll see at most this much delay" — a sub-second leftover at
/// the bottom of the wait shows as `1s`, not `0s`, until it actually
/// elapses.
fn banner_disconnected_countdown(remaining: Duration) -> String {
// Round up: 4001ms → 5s, 4000ms → 4s, 1ms → 1s.
let secs = remaining.as_secs() + u64::from(remaining.subsec_millis() > 0);
if secs == 0 {
"[pmacs disconnected — reconnecting now — Ctrl-C to exit]".to_string()
} else {
format!("[pmacs disconnected — reconnecting in {secs}s — Ctrl-C to exit]")
}
}
/// Outcome of [`sleep_with_countdown`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SleepOutcome {
/// User pressed Ctrl-C during the wait. The reconnect loop
/// should exit cleanly (no error message — the user knows they
/// canceled).
Cancelled,
/// The full delay elapsed without a cancellation. The loop
/// should proceed with the next attempt.
Elapsed,
}
/// Sleep for `total` while painting a per-second countdown banner.
///
/// Uses [`Frontend::poll_event`] as the wait primitive so the loop
/// can react to Ctrl-C immediately (raw mode delivers Ctrl-C as a
/// `KeyEvent`, not as `SIGINT` — terminal `ISIG` is disabled by
/// `enable_raw_mode`). Other events (resize, mouse, F-keys) are
/// drained but ignored; resize updates `Frontend::size` as a side
/// effect of `poll_event`, which is the exact behavior the next
/// `attempt_session` needs for its `initial_size`.
///
/// If `frontend` is `None` (handshake-reconnect with no Frontend up
/// yet), the function falls back to a plain `thread::sleep` and
/// cannot be cancelled — the user has nothing to interact with
/// anyway, and the cooked-terminal stderr already shows the SSH
/// error.
fn sleep_with_countdown(frontend: Option<&mut Frontend>, total: Duration) -> SleepOutcome {
let Some(f) = frontend else {
// No Frontend — no overlay, no input pump. Plain sleep.
thread::sleep(total);
return SleepOutcome::Elapsed;
};
let deadline = Instant::now() + total;
// Initial paint so the banner appears immediately, not after the
// first poll tick.
let initial_banner = banner_disconnected_countdown(total);
let _ = f.draw_status_overlay(&initial_banner);
let mut last_painted_secs: Option<u64> =
Some(total.as_secs() + u64::from(total.subsec_millis() > 0));
loop {
let now = Instant::now();
if now >= deadline {
return SleepOutcome::Elapsed;
}
let remaining = deadline - now;
let secs = remaining.as_secs() + u64::from(remaining.subsec_millis() > 0);
if last_painted_secs != Some(secs) {
let banner = banner_disconnected_countdown(remaining);
let _ = f.draw_status_overlay(&banner);
last_painted_secs = Some(secs);
}
// Poll for at most `remaining` and at most 250ms — short
// enough that the countdown updates feel live, long enough
// to avoid burning CPU in a tight poll loop.
let tick = std::cmp::min(Duration::from_millis(250), remaining);
match f.poll_event(tick) {
Ok(Some(Event::Key(KeyEvent {
code: KeyCode::Char('c'),
modifiers,
kind: KeyEventKind::Press,
..
}))) if modifiers.contains(KeyModifiers::CONTROL) => {
return SleepOutcome::Cancelled;
}
Ok(_) | Err(_) => {
// Any other event (resize, mouse, key) → drain and
// continue. `poll_event` already updated
// `frontend.size` if it was a resize, which is what
// we want for the next attempt's `initial_size`.
// Errors from `poll_event` are extremely rare
// (terminal disconnected); we treat them like
// "tick elapsed" and keep waiting — if the terminal
// is genuinely gone, the next attempt's pump will
// surface the failure cleanly.
}
}
}
}
/// One reconnect-aware attach attempt: spawn SSH, run the
/// `Hello`/`AttachRequest` handshake, lazy-init the Frontend on
/// first success, run the pump, reap the child, return a
/// [`ReconnectVerdict`].
///
/// `frontend_slot` is the outer loop's persistent slot. On entry:
/// * `None` → first attempt; the function will print the attach
/// info banner and call [`Frontend::new`] after the handshake.
/// * `Some(_)` → reconnect; no terminal teardown, no info banner,
/// the existing Frontend is reused so the cell grid stays on
/// screen behind the M5.8c status overlay.
///
/// Postcondition (on `Ok`-mapped verdicts): if the function got past
/// `Frontend::new`, `frontend_slot` is `Some`. Errors before that
/// point keep it `None`, preserving the cooked-terminal contract for
/// pre-Frontend failures.
///
/// Doc rule for M5.8: this function does NOT toggle terminal modes
/// or clear the screen. Frontend lifecycle is the outer scope's
/// responsibility, and the cell grid must persist across attempts so
/// the M5.8c overlay is visually layered on top of stable content.
fn attempt_session(
frontend_slot: &mut Option<Frontend>,
target: &AttachTarget,
initial_handshake_complete: bool,
) -> ReconnectVerdict {
let raw_result = run_one_session(frontend_slot, target);
match raw_result {
Ok(()) => ReconnectVerdict::ExitClean,
Err(error) => classify_for_reconnect(error, initial_handshake_complete),
}
}
/// Protocol byte source plus the SSH-stderr tee's join handle and
/// captured tail. Returned by [`open_protocol_channel`]; named so the
/// handshake's binding site isn't a four-line inline tuple type.
type ProtocolChannel = (
Box<dyn Read + Send>,
thread::JoinHandle<()>,
Arc<Mutex<VecDeque<u8>>>,
);
/// Pick the SSH channel the protocol rides on.
///
/// `protocol_over_stderr` is [`SSH_PROTOCOL_DEFAULT`]-derived (F8b:
/// stderr is the default — see `SshProtocolChannel`). When stderr:
/// protocol reads SSH stderr, remote stderr diagnostics are disabled
/// by the caller, and there is no tee (a no-op join handle / empty
/// tail keep the return shape). When stdout (the F8b fallback /
/// override): protocol reads SSH stdout, and SSH stderr is tee'd to
/// our stderr only on the first attempt (`tee_to_stderr`) — on
/// reconnect raw mode is active and live tee'd bytes would corrupt
/// the cell grid, so the tail is still captured for the give-up
/// message but not echoed.
fn open_protocol_channel(
child_stdout: std::process::ChildStdout,
child_stderr: std::process::ChildStderr,
protocol_over_stderr: bool,
tee_to_stderr: bool,
) -> ProtocolChannel {
if protocol_over_stderr {
attach_debug("using SSH stderr as protocol stream; remote stderr diagnostics disabled");
(
Box::new(DebugReader::new("ssh stderr(protocol)", child_stderr, 4)),
thread::spawn(|| {}),
Arc::new(Mutex::new(VecDeque::new())),
)
} else {
let (stderr_handle, stderr_tail) = spawn_stderr_tee(child_stderr, tee_to_stderr);
(
Box::new(DebugReader::new("ssh stdout", child_stdout, 4)),
stderr_handle,
stderr_tail,
)
}
}
/// Spawn the F8b watchdog: with attach-debug on, warn if the `Hello`
/// hasn't arrived within 5s (the F8b symptom — remote bridge
/// connected but no daemon bytes reached local stdout). Returns the
/// flag the caller sets once `Hello` is read or fails, silencing it.
fn spawn_hello_watchdog() -> Arc<AtomicBool> {
let done = Arc::new(AtomicBool::new(false));
if attach_debug_enabled() {
let done = Arc::clone(&done);
thread::spawn(move || {
thread::sleep(Duration::from_secs(5));
if !done.load(Ordering::SeqCst) {
eprintln!(
"pmacs attach debug: still waiting for Hello after 5s; \
remote bridge connected but no daemon bytes reached local stdout"
);
}
});
}
done
}
/// Run the attach pump to completion, then reap the child and drain
/// the stderr tee with the Frontend still alive (none of these steps
/// emit terminal output, so raw mode being on is harmless). The
/// Frontend is NOT dropped — the outer scope owns its lifetime so it
/// persists into the next reconnect attempt.
fn run_pump_and_reap(
mut child: Child,
child_stdin: std::process::ChildStdin,
protocol_reader: Box<dyn Read + Send>,
stderr_handle: thread::JoinHandle<()>,
stderr_tail: &Arc<Mutex<VecDeque<u8>>>,
frontend: &mut Frontend,
assigned_frontend_id: FrontendId,
) -> Result<(), AttachError> {
let pid = child.id();
let io = AttachIo {
reader: protocol_reader,
writer: Box::new(child_stdin),
kick: ssh_kick(pid),
};
let pump_result = run_attach_pair(io, frontend, assigned_frontend_id);
attach_debug(format!(
"attach pump exited: {:?}",
pump_result.as_ref().err()
));
let exit_status = child.wait().ok();
let _ = stderr_handle.join();
let stderr_text = drain_stderr_tail(stderr_tail);
classify_ssh_exit(pump_result, exit_status, stderr_text)
}
/// Body of [`attempt_session`] expressed in raw `AttachError` so the
/// caller can fold the result through [`classify_for_reconnect`].
/// Splitting this out keeps the IO path linear and tests-friendly:
/// `attempt_session` is just classify + dispatch.
// The protocol-channel, Hello-watchdog, and pump-and-reap phases are
// already factored out (`open_protocol_channel`, `spawn_hello_watchdog`,
// `run_pump_and_reap`). What remains is a linear Hello/AttachRequest
// handshake whose several early-return error paths each need ownership
// of `child` + the stderr handles to tear down via
// `handshake_error_with_child`; extracting it further would fragment
// that error-ownership flow rather than clarify it. Same precedent as
// the `#[allow(clippy::too_many_lines)]` on `run_attach_pair` above.
#[allow(clippy::too_many_lines)]
fn run_one_session(
frontend_slot: &mut Option<Frontend>,
target: &AttachTarget,
) -> Result<(), AttachError> {
let mut cmd = build_ssh_command(target).expect("target shape pre-validated by caller");
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let bin_for_error = ssh_binary();
attach_debug(format!("spawning ssh command: {cmd:?}"));
let mut child = cmd.spawn().map_err(|source| AttachError::SshSpawnFailed {
command: bin_for_error,
source,
})?;
attach_debug(format!("ssh child spawned with pid {}", child.id()));
let child_stdout = child
.stdout
.take()
.expect("Stdio::piped on stdout guarantees a handle");
let mut child_stdin = child
.stdin
.take()
.expect("Stdio::piped on stdin guarantees a handle");
let child_stderr = child
.stderr
.take()
.expect("Stdio::piped on stderr guarantees a handle");
let protocol_over_stderr = ssh_protocol_channel() == SshProtocolChannel::Stderr;
let (mut protocol_reader, stderr_handle, stderr_tail) = open_protocol_channel(
child_stdout,
child_stderr,
protocol_over_stderr,
frontend_slot.is_none(),
);
// Hello / AttachRequest handshake. On the FIRST attempt
// (`frontend_slot` is `None`) raw mode is not engaged yet, so
// failures here reach a cooked terminal via main.rs's eprintln.
// On reconnect (`Some`) the Frontend is up and the M5.8c
// overlay communicates state; handshake errors here are bubbled
// up so the M5.8d loop can decide whether to retry.
attach_debug("waiting for Hello from remote daemon bridge");
let hello_wait_done = spawn_hello_watchdog();
let hello: Hello = match read_message(&mut protocol_reader) {
Ok(h) => h,
Err(e) => {
hello_wait_done.store(true, Ordering::SeqCst);
attach_debug(format!("failed reading Hello: {e}"));
return Err(handshake_error_with_child(
child,
stderr_handle,
stderr_tail,
e.into(),
));
}
};
hello_wait_done.store(true, Ordering::SeqCst);
attach_debug(format!(
"received Hello: protocol_version={}, assigned_frontend_id={}",
hello.protocol_version, hello.assigned_frontend_id.0
));
// T M10.5: relaxed to range membership per
// `§sec:m10-backward-compat`. Symmetric with the local-socket
// attach path above.
if !crate::protocol::is_supported_protocol_version(hello.protocol_version) {
return Err(handshake_error_with_child(
child,
stderr_handle,
stderr_tail,
AttachError::VersionMismatch {
server: hello.protocol_version,
client: PROTOCOL_VERSION,
},
));
}
// Determine initial_size. On reconnect, use the Frontend's
// already-known size (it tracks resizes). On first attempt, query
// crossterm directly because the Frontend isn't up yet.
let initial_size = match frontend_slot.as_ref() {
Some(f) => f.size(),
None => match crossterm::terminal::size() {
Ok((c, r)) => CellSize::new(u32::from(r), u32::from(c)),
Err(e) => {
return Err(handshake_error_with_child(
child,
stderr_handle,
stderr_tail,
AttachError::Terminal(e),
));
}
},
};
// T M10.5: match the server's protocol version so v1.0 frontends
// attaching to v0.1 daemons advertise protocol_version=1. Same
// pattern as the local-socket path above, including Stage 2B-3's
// counter-offer: this path *spawns* the daemon from the running
// executable, so the peer is always this same ladder rung and the
// counter-offer is always accepted.
let req = AttachRequest {
protocol_version: crate::protocol::requested_protocol_version(hello.protocol_version),
frontend_capabilities: build_capabilities(),
initial_size,
};
if let Err(e) = write_message(&mut child_stdin, &req) {
attach_debug(format!("failed writing AttachRequest: {e}"));
return Err(handshake_error_with_child(
child,
stderr_handle,
stderr_tail,
e.into(),
));
}
attach_debug("sent AttachRequest");
// First-attempt-only side effects. On reconnect the user already
// saw the info banner and the Frontend is already up; we skip
// both so the reconnect is visually a no-op except for the
// overlay clearing.
if frontend_slot.is_none() {
print_attach_info(&hello);
match Frontend::new() {
Ok(f) => *frontend_slot = Some(f),
Err(e) => {
return Err(handshake_error_with_child(
child,
stderr_handle,
stderr_tail,
AttachError::Terminal(e),
));
}
}
}
let frontend = frontend_slot
.as_mut()
.expect("frontend_slot was just initialized or already Some");
// The frontend now owns the terminal (raw mode + alt-screen).
// From here, including any later reconnect handshake, breadcrumbs
// go to the log file only — stderr is the screen the renderer
// paints.
mark_tui_terminal_owned();
run_pump_and_reap(
child,
child_stdin,
protocol_reader,
stderr_handle,
&stderr_tail,
frontend,
hello.assigned_frontend_id,
)
}
/// Helper for the early-exit paths in [`run_attach_ssh`] that want
/// to surface a handshake error: reaps the child, joins the stderr
/// tee, returns `original_error` (raw mode never engaged, so the
/// error reaches a normal terminal directly).
fn handshake_error_with_child(
mut child: Child,
stderr_handle: thread::JoinHandle<()>,
_stderr_tail: Arc<Mutex<VecDeque<u8>>>,
original_error: AttachError,
) -> AttachError {
// Best-effort tear-down of the child. SIGTERM unsticks any read
// it might be doing on its end of our broken handshake; wait
// reaps. Errors are ignored — if the child has already exited
// (e.g., remote `pmacs` not on PATH), `kill` returns ESRCH and
// we move on.
#[allow(clippy::cast_possible_wrap)]
let pid = nix::unistd::Pid::from_raw(child.id() as i32);
let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM);
let _ = child.wait();
let _ = stderr_handle.join();
original_error
}
/// Drain the stderr tail ring buffer into an owned `String`,
/// lossily decoding non-UTF-8 bytes (the user's terminal already
/// rendered the original bytes; the string is just for the
/// diagnostic message).
fn drain_stderr_tail(tail: &Arc<Mutex<VecDeque<u8>>>) -> String {
let bytes: Vec<u8> = tail.lock().expect("tail mutex").iter().copied().collect();
String::from_utf8_lossy(&bytes).into_owned()
}
/// Map (pump result, child exit status, stderr tail) → final
/// `AttachError` or success.
///
/// * Pump returned `Err` → propagate it; the child's exit is
/// noise compared to the pump's failure.
/// * Pump returned `Ok` and child exited 0 → `Ok(())`.
/// * Pump returned `Ok` and child exited non-zero → classify into
/// [`AttachError::SshChildExited`] with the captured stderr tail.
/// * Pump returned `Ok` and child status unknown (wait failed) →
/// treat as success; the bridge ran to completion as far as we
/// could tell.
fn classify_ssh_exit(
pump_result: Result<(), AttachError>,
exit_status: Option<std::process::ExitStatus>,
stderr_tail: String,
) -> Result<(), AttachError> {
pump_result?;
match exit_status {
Some(s) if s.success() => Ok(()),
Some(s) => Err(AttachError::SshChildExited {
code: s.code(),
stderr_tail,
}),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Try to bind a `UnixListener` at `path`. On `PermissionDenied`
/// (e.g., a sandboxed CI environment that disallows `AF_UNIX`
/// socket creation), prints a skip notice and returns `None`;
/// the calling test should early-return so the suite reports
/// `0 failed` rather than a misleading panic. Mirror of the
/// helper in `daemon_attach.rs`'s test module.
fn bind_or_skip(path: &std::path::Path) -> Option<UnixListener> {
match UnixListener::bind(path) {
Ok(l) => Some(l),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
eprintln!(
"test skipped: UnixListener::bind {} → PermissionDenied \
(sandboxed environment).",
path.display()
);
None
}
Err(e) => panic!("UnixListener::bind {} failed: {e}", path.display()),
}
}
#[test]
fn format_uptime_shapes() {
assert_eq!(format_uptime(5), "5s");
assert_eq!(format_uptime(59), "59s");
assert_eq!(format_uptime(60), "1m0s");
assert_eq!(format_uptime(125), "2m5s");
assert_eq!(format_uptime(3599), "59m59s");
assert_eq!(format_uptime(3600), "1h0m");
assert_eq!(format_uptime(7325), "2h2m");
}
#[test]
fn detach_key_recognized() {
use crossterm::event::{KeyEvent as CtKey, KeyEventState, KeyModifiers as CtMods};
let f12 = Event::Key(CtKey {
code: KeyCode::F(12),
modifiers: CtMods::empty(),
kind: KeyEventKind::Press,
state: KeyEventState::empty(),
});
assert!(is_detach_key(&f12));
let f11 = Event::Key(CtKey {
code: KeyCode::F(11),
modifiers: CtMods::empty(),
kind: KeyEventKind::Press,
state: KeyEventState::empty(),
});
assert!(!is_detach_key(&f11));
let f12_release = Event::Key(CtKey {
code: KeyCode::F(12),
modifiers: CtMods::empty(),
kind: KeyEventKind::Release,
state: KeyEventState::empty(),
});
assert!(!is_detach_key(&f12_release));
}
#[test]
fn build_capabilities_advertises_all_v01_features() {
let c = build_capabilities();
assert!(c.synchronized_output);
assert!(c.unicode_smp);
assert!(c.true_color);
assert!(c.mouse);
assert!(c.bracketed_paste);
// terminal_kind depends on TERM env var; not asserted.
}
// -----------------------------------------------------------------
// M5.7a — pump generalization tests
// -----------------------------------------------------------------
//
// These tests drive `run_attach_pair` end-to-end without taking
// over a real terminal. They use:
//
// * `UnixStream::pair()` for an in-process socket pair (one end
// is the "daemon," the other is the AttachIo).
// * `FakeFrontend`, a tiny `AttachPumpFrontend` impl that records
// `present_messages` calls and never produces input.
//
// The tests cover four contracts:
//
// 1. Pump routes instance messages to the frontend (test 2).
// 2. `kick` fires on a clean Goodbye exit (test 3a).
// 3. `kick` fires on an error exit (test 3b).
// 4. `kick` actually wakes a blocked reader within bounded time
// (test 4).
// 5. Hello/AttachRequest handshake errors surface at the
// construction site, not from inside the pump (test 5).
use crate::protocol::{
GoodbyeReason, Hello, InstanceCapabilities, InstanceIdentity, PROTOCOL_VERSION,
};
use crate::transport::write_message;
use std::os::unix::net::UnixListener;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
/// Records `present_messages` calls; never produces input.
///
/// `present_returns_error = true` makes the next `present_messages`
/// call fail, which forces the pump into the error-exit path so
/// tests can observe `kick` firing under both clean and error
/// terminations.
#[derive(Default)]
struct FakeFrontend {
presented: Vec<InstanceMessage>,
present_returns_error: bool,
}
impl AttachPumpFrontend for FakeFrontend {
fn present_messages(&mut self, msgs: &[InstanceMessage]) -> std::io::Result<()> {
if self.present_returns_error {
return Err(std::io::Error::other("test forced present error"));
}
self.presented.extend(msgs.iter().cloned());
Ok(())
}
fn poll_event(&mut self, _timeout: Duration) -> std::io::Result<Option<Event>> {
// Tests drive the daemon side; no local input is generated.
Ok(None)
}
fn size(&self) -> CellSize {
CellSize::new(24, 80)
}
}
/// Test-only `Read` wrapper that flips an `AtomicBool` whenever
/// its inner reader is called. Used to synchronize the test
/// against "the reader thread has entered its read call" without
/// resorting to wall-clock sleeps.
///
/// External wrapper by design: production types stay free of
/// test-only hooks. The signal fires on every `read` call (not
/// just the first); the test only cares about observing it
/// transition once, so cheap repeated stores are harmless.
struct EnteredReadSignaler<R: Read> {
inner: R,
entered: Arc<AtomicBool>,
}
impl<R: Read> Read for EnteredReadSignaler<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.entered.store(true, Ordering::Release);
self.inner.read(buf)
}
}
/// Build an `AttachIo` with a kick that increments `counter`.
fn pipe_io_with_counting_kick(socket: UnixStream, counter: Arc<AtomicUsize>) -> AttachIo {
let reader = socket.try_clone().expect("try_clone reader");
AttachIo {
reader: Box::new(reader),
writer: Box::new(socket),
kick: Box::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
}),
}
}
#[test]
fn pump_routes_instance_messages_to_frontend() {
let (daemon_side, frontend_side) = UnixStream::pair().expect("UnixStream::pair");
let io = build_local_socket_io(frontend_side).expect("build_local_socket_io");
let daemon = thread::spawn(move || {
let mut w = daemon_side;
write_message(&mut w, &InstanceMessage::Cursor(None)).unwrap();
// Brief gap so the pump definitely processes Cursor before
// the Goodbye-exit path latches; not load-bearing for
// correctness (the drain loop handles either order) but
// makes the test cover the typical sequencing.
thread::sleep(Duration::from_millis(50));
write_message(
&mut w,
&InstanceMessage::Goodbye(GoodbyeReason::ShuttingDown),
)
.unwrap();
});
let mut frontend = FakeFrontend::default();
let result = run_attach_pair(io, &mut frontend, FrontendId::LOCAL);
daemon.join().expect("daemon thread");
assert!(result.is_ok(), "pump exits Ok on Goodbye: got {result:?}");
assert_eq!(frontend.presented.len(), 1, "exactly one message presented");
assert!(matches!(
frontend.presented[0],
InstanceMessage::Cursor(None)
));
}
#[test]
fn kick_fires_on_clean_goodbye_exit() {
let (daemon_side, frontend_side) = UnixStream::pair().expect("UnixStream::pair");
let counter = Arc::new(AtomicUsize::new(0));
let io = pipe_io_with_counting_kick(frontend_side, counter.clone());
let daemon = thread::spawn(move || {
let mut w = daemon_side;
write_message(
&mut w,
&InstanceMessage::Goodbye(GoodbyeReason::ShuttingDown),
)
.unwrap();
});
let mut frontend = FakeFrontend::default();
let result = run_attach_pair(io, &mut frontend, FrontendId::LOCAL);
daemon.join().expect("daemon thread");
assert!(result.is_ok());
assert_eq!(
counter.load(Ordering::SeqCst),
1,
"kick must fire exactly once on clean exit",
);
}
#[test]
fn kick_fires_on_present_error_exit() {
let (daemon_side, frontend_side) = UnixStream::pair().expect("UnixStream::pair");
let counter = Arc::new(AtomicUsize::new(0));
let io = pipe_io_with_counting_kick(frontend_side, counter.clone());
let daemon = thread::spawn(move || {
let mut w = daemon_side;
write_message(&mut w, &InstanceMessage::Cursor(None)).unwrap();
// Drop daemon_side here. The pump will either hit the
// present-error path first (return Err) or the EOF path
// first (return Ok). The kick must fire either way; the
// assertion is on the kick count, not the exit reason.
});
let mut frontend = FakeFrontend {
presented: Vec::new(),
present_returns_error: true,
};
let result = run_attach_pair(io, &mut frontend, FrontendId::LOCAL);
daemon.join().expect("daemon thread");
// The race between "Cursor reaches the pump and present errors"
// and "daemon socket closes and reader signals disconnect" is
// not deterministic in a unit test. What IS deterministic: kick
// fires exactly once, regardless of which path the pump took.
let _ = result; // either Ok (clean EOF) or Err(Terminal) (present errored)
assert_eq!(
counter.load(Ordering::SeqCst),
1,
"kick must fire exactly once on any exit path",
);
}
#[test]
fn kick_wakes_blocked_reader() {
// What this test asserts: after `kick()` fires, the reader
// thread terminates. That is, the kick mechanism wakes a
// reader that would otherwise wait on the socket forever.
//
// What this test does NOT assert: a specific wake latency.
// The 5s join bound is intentionally generous so that CI
// scheduler jitter (heavily loaded VM hosts can stall threads
// for hundreds of ms cumulatively) does not turn a correctness
// test into a flake. The steady-state wake budget per
// `KickAwareUnixReader`'s contract is ~10ms (one poll cycle),
// but observing that bound under timing pressure is not what
// this test is for. **Do not tighten the 5s bound back toward
// 1s on the grounds that 5s is much larger than the
// steady-state budget** — the steady-state budget is not what
// is being tested. A real bug (kick mechanism is broken, the
// reader runs forever) hits this bound; CI jitter does not
// come close.
//
// Synchronization: rather than guessing how long the reader
// thread takes to start with `thread::sleep`, the test wraps
// the reader in an `EnteredReadSignaler` that flips an
// `AtomicBool` when the inner reader is first called. The
// test spins on that flag (bounded) so kick fires only once
// we know the reader is actively reading from the socket.
// No wall-clock guesses; no test-only paths in production
// types.
// Hold the daemon side so the kick is the only thing that can
// wake the reader. If we let the daemon side close, the reader
// sees EOF naturally and we'd be testing nothing.
let (_daemon_side, frontend_side) = UnixStream::pair().expect("UnixStream::pair");
let io = build_local_socket_io(frontend_side).expect("build_local_socket_io");
let AttachIo {
reader,
writer: _writer,
kick,
} = io;
let entered = Arc::new(AtomicBool::new(false));
let signaling_reader: Box<dyn Read + Send> = Box::new(EnteredReadSignaler {
inner: reader,
entered: Arc::clone(&entered),
});
let (tx, _rx) = mpsc::channel::<InstanceMessage>();
let reader_handle = thread::spawn(move || run_reader(signaling_reader, tx));
// Wait for the reader to enter its read call. Bounded so a
// never-spawning reader fails the test instead of hanging.
let entry_deadline = Instant::now() + Duration::from_secs(1);
while !entered.load(Ordering::Acquire) {
assert!(
Instant::now() < entry_deadline,
"reader thread did not enter its read call within 1s",
);
thread::sleep(Duration::from_millis(1));
}
kick();
// Bound the join: spawn a watcher that joins and signals via
// a channel, then recv_timeout against the channel.
let (done_tx, done_rx) = mpsc::channel();
thread::spawn(move || {
let _ = reader_handle.join();
let _ = done_tx.send(());
});
done_rx
.recv_timeout(Duration::from_secs(5))
.expect("reader thread must exit within 5s after kick");
}
#[test]
fn version_mismatch_errors_at_construction_site() {
let tmp = tempfile::tempdir().expect("tempdir");
let socket_path = tmp.path().join("test.sock");
let Some(listener) = bind_or_skip(&socket_path) else {
return;
};
// Fake daemon: accept one connection, write a Hello with a
// bogus protocol version, exit. The accept blocks until
// run_attach connects.
let server = thread::spawn(move || {
let (mut stream, _addr) = listener.accept().expect("accept");
let bad_hello = Hello {
protocol_version: PROTOCOL_VERSION + 999,
assigned_frontend_id: FrontendId::LOCAL,
instance_identity: InstanceIdentity::for_running_process(
None,
std::time::Instant::now(),
),
instance_capabilities: InstanceCapabilities::default(),
};
write_message(&mut stream, &bad_hello).expect("write Hello");
});
let result = run_attach(socket_path);
server.join().expect("server thread");
// The handshake check happens BEFORE Frontend::new, so the
// error reaches us without raw mode ever engaging. The
// boundary contract: pump-related errors never wear the
// VersionMismatch shape; this shape can only come from the
// construction-site handshake.
match result {
Err(AttachError::VersionMismatch {
server: srv,
client,
}) => {
assert_eq!(srv, PROTOCOL_VERSION + 999);
assert_eq!(client, PROTOCOL_VERSION);
}
other => panic!("expected VersionMismatch, got {other:?}"),
}
}
// -----------------------------------------------------------------
// M5.7e — SSH transport tests
// -----------------------------------------------------------------
//
// These tests cover the unit-testable surface of the SSH path:
//
// * `build_ssh_command` — argument construction for various
// `AttachTarget::Ssh` shapes, including the `PMACS_TEST_SSH_BIN`
// substitution.
// * Error message content — exit-127 names "command not found";
// generic non-zero hints at scrollback; the spawn error names
// the binary path.
//
// End-to-end SSH activation (real subprocess via PMACS_TEST_SSH_BIN
// pointing at a daemon-attach helper) lives in
// `tests/m5_7_acceptance.rs` (M5.7g).
use std::ffi::OsStr;
/// Extract `Command`'s arg list as a borrowed `&OsStr` vector.
/// `Command::get_args` returns the args sans program; we read the
/// program separately via `Command::get_program`.
fn ssh_args(cmd: &Command) -> Vec<&OsStr> {
cmd.get_args().collect()
}
#[test]
fn build_ssh_command_for_bare_host() {
// SAFETY: env mutation is only safe when no other thread is
// touching env. Cargo's default test runner spawns threads,
// but `PMACS_TEST_SSH_BIN` is read only by `ssh_binary` which
// is called once per `build_ssh_command`. We avoid the race
// by *not* mutating env here — the bare-host test asserts the
// default binary is `ssh`.
let target = AttachTarget::Ssh {
host: "mac-studio".into(),
user: None,
instance_name: None,
};
let cmd = build_ssh_command(&target).expect("ssh target");
// Default binary is `ssh` (no env override).
// Note: we don't assert the exact program when the env var
// might leak from a parallel test; assert only the args.
let args = ssh_args(&cmd);
let arg_strs: Vec<&str> = args.iter().filter_map(|s| s.to_str()).collect();
assert_eq!(
arg_strs,
vec![
"-T",
"mac-studio",
"env",
"PMACS_ATTACH_PROTOCOL_FD=2",
"pmacs",
"--daemon-attach",
],
"bare host, F8b stderr default: -T <host> env \
PMACS_ATTACH_PROTOCOL_FD=2 pmacs --daemon-attach",
);
}
#[test]
fn build_ssh_command_with_user_emits_dash_l() {
let target = AttachTarget::Ssh {
host: "workstation".into(),
user: Some("alice".into()),
instance_name: None,
};
let cmd = build_ssh_command(&target).expect("ssh target");
let arg_strs: Vec<&str> = ssh_args(&cmd).iter().filter_map(|s| s.to_str()).collect();
assert_eq!(
arg_strs,
vec![
"-T",
"-l",
"alice",
"workstation",
"env",
"PMACS_ATTACH_PROTOCOL_FD=2",
"pmacs",
"--daemon-attach",
],
);
}
#[test]
fn build_ssh_command_with_instance_name_passes_through_socket_arg() {
let target = AttachTarget::Ssh {
host: "workstation".into(),
user: Some("bob".into()),
instance_name: Some("research".into()),
};
let cmd = build_ssh_command(&target).expect("ssh target");
let arg_strs: Vec<&str> = ssh_args(&cmd).iter().filter_map(|s| s.to_str()).collect();
assert_eq!(
arg_strs,
vec![
"-T",
"-l",
"bob",
"workstation",
"env",
"PMACS_ATTACH_PROTOCOL_FD=2",
"pmacs",
"--daemon-attach",
"--socket",
"research",
],
);
}
#[test]
fn build_ssh_command_returns_none_for_non_ssh_target() {
// Defensive — the function is documented to return None for
// non-SSH targets so misuse fails loudly at the call site
// instead of silently constructing a malformed command.
assert!(
build_ssh_command(&AttachTarget::LocalSocket(PathBuf::from("/tmp/x.sock"))).is_none(),
);
}
// F8b: pure channel-resolver tests. No env mutation (the resolver
// core is env-free by design, so this is race-free under cargo's
// threaded runner). Precedence: explicit > legacy > default.
#[test]
fn ssh_protocol_resolution_precedence_and_default() {
use SshProtocolChannel::{Stderr, Stdout};
// Default applies when nothing is set. The shipped default is
// stderr (F8b); assert via the constant so flipping the one
// switch keeps this test honest rather than brittle.
assert_eq!(resolve_ssh_protocol(None, None, Stdout), Stdout);
assert_eq!(resolve_ssh_protocol(None, None, Stderr), Stderr);
assert_eq!(
resolve_ssh_protocol(None, None, SSH_PROTOCOL_DEFAULT),
Stderr
);
// Explicit override wins, case/space-insensitive, accepts fd
// numbers too.
for s in ["stdout", "STDOUT", " Stdout ", "1"] {
assert_eq!(resolve_ssh_protocol(Some(s), None, Stderr), Stdout, "{s:?}");
}
for s in ["stderr", "STDERR", "2"] {
assert_eq!(resolve_ssh_protocol(Some(s), None, Stdout), Stderr, "{s:?}");
}
// Unrecognized explicit → ignored, falls through to legacy
// then default.
assert_eq!(resolve_ssh_protocol(Some("bogus"), None, Stderr), Stderr);
assert_eq!(
resolve_ssh_protocol(Some("bogus"), Some("0"), Stderr),
Stdout
);
// Legacy back-compat: =0 → stdout, anything else non-empty →
// stderr; empty → ignored.
assert_eq!(resolve_ssh_protocol(None, Some("0"), Stderr), Stdout);
assert_eq!(resolve_ssh_protocol(None, Some("1"), Stdout), Stderr);
assert_eq!(resolve_ssh_protocol(None, Some("yes"), Stdout), Stderr);
assert_eq!(resolve_ssh_protocol(None, Some(""), Stdout), Stdout);
// Explicit beats legacy even when they disagree.
assert_eq!(
resolve_ssh_protocol(Some("stdout"), Some("1"), Stderr),
Stdout
);
assert_eq!(
resolve_ssh_protocol(Some("stderr"), Some("0"), Stdout),
Stderr
);
// The remote fd mapping the bridge consumes.
assert_eq!(Stdout.remote_fd(), 1);
assert_eq!(Stderr.remote_fd(), 2);
}
#[test]
fn ssh_child_exited_127_message_names_path_diagnostic() {
let err = AttachError::SshChildExited {
code: Some(127),
stderr_tail: String::new(),
};
let msg = format!("{err}");
assert!(msg.contains("127"), "{msg}");
assert!(msg.contains("command not found on remote"), "{msg}");
assert!(msg.contains("which pmacs"), "{msg}");
}
#[test]
fn ssh_child_exited_other_code_includes_scrollback_hint_when_stderr_nonempty() {
let err = AttachError::SshChildExited {
code: Some(255),
stderr_tail: "Permission denied (publickey).\n".into(),
};
let msg = format!("{err}");
assert!(msg.contains("255"), "{msg}");
assert!(msg.contains("scrollback"), "{msg}");
assert!(msg.contains("Permission denied"), "{msg}");
}
#[test]
fn ssh_child_exited_omits_scrollback_hint_when_stderr_empty() {
let err = AttachError::SshChildExited {
code: Some(1),
stderr_tail: String::new(),
};
let msg = format!("{err}");
assert!(msg.contains("exited 1"), "{msg}");
// No "scrollback" mention when there's nothing to point at.
assert!(
!msg.contains("scrollback"),
"scrollback hint should be omitted on empty stderr: {msg}",
);
}
#[test]
fn ssh_child_terminated_by_signal_message() {
let err = AttachError::SshChildExited {
code: None,
stderr_tail: String::new(),
};
let msg = format!("{err}");
assert!(msg.contains("terminated by signal"), "{msg}");
}
#[test]
fn ssh_spawn_failed_message_names_command_and_workaround() {
let err = AttachError::SshSpawnFailed {
command: PathBuf::from("ssh"),
source: std::io::Error::other("not found"),
};
let msg = format!("{err}");
assert!(msg.contains("ssh"), "{msg}");
assert!(msg.contains("not found"), "{msg}");
assert!(
msg.contains("PATH"),
"spawn failure must hint at PATH: {msg}",
);
}
#[test]
fn classify_ssh_exit_zero_yields_ok() {
// Synthesize an ExitStatus(0) via std's no-op subprocess
// (`/bin/true` exits 0 on every Unix). We only need the
// classification logic, not a real SSH session.
let status = Command::new("true")
.status()
.expect("/bin/true should be runnable in tests");
let result = classify_ssh_exit(Ok(()), Some(status), String::new());
assert!(result.is_ok(), "exit 0 should classify as Ok: {result:?}");
}
#[test]
fn classify_ssh_exit_nonzero_yields_ssh_child_exited() {
let status = Command::new("false")
.status()
.expect("/bin/false should be runnable in tests");
match classify_ssh_exit(Ok(()), Some(status), "scrollback".into()) {
Err(AttachError::SshChildExited { code, stderr_tail }) => {
assert_eq!(code, Some(1));
assert_eq!(stderr_tail, "scrollback");
}
other => panic!("expected SshChildExited, got {other:?}"),
}
}
#[test]
fn classify_ssh_exit_propagates_pump_error_over_exit_status() {
let status = Command::new("false").status().unwrap();
let pump_err = AttachError::Terminal(std::io::Error::other("terminal busted"));
match classify_ssh_exit(Err(pump_err), Some(status), String::new()) {
Err(AttachError::Terminal(e)) => {
assert!(format!("{e}").contains("terminal busted"));
}
other => panic!("pump error should win over exit status: got {other:?}"),
}
}
#[test]
fn classify_ssh_exit_no_status_treats_as_success() {
// wait() failure: we have no exit status but the pump ran
// cleanly. Treat as success — the bridge ran to completion
// as far as we could tell.
let result = classify_ssh_exit(Ok(()), None, String::new());
assert!(
result.is_ok(),
"no status + Ok pump should be Ok: {result:?}"
);
}
// -----------------------------------------------------------------
// M5.8d — reconnect overlay banner formatters
// -----------------------------------------------------------------
#[test]
fn banner_session_reconnect_is_static_text() {
assert_eq!(banner_session_reconnect(), "[pmacs reconnecting...]");
}
#[test]
fn banner_handshake_reconnect_includes_attempt_and_cap() {
assert_eq!(
banner_handshake_reconnect(2, 3),
"[pmacs reconnecting (attempt 2 of 3)...]"
);
assert_eq!(
banner_handshake_reconnect(1, HANDSHAKE_RETRY_CAP),
format!("[pmacs reconnecting (attempt 1 of {HANDSHAKE_RETRY_CAP})...]")
);
}
#[test]
fn banner_countdown_rounds_up_subsecond_remainders() {
// 4001ms → "5s" so the user never sees the displayed value
// jitter below the actual remaining time.
assert_eq!(
banner_disconnected_countdown(Duration::from_millis(4001)),
"[pmacs disconnected — reconnecting in 5s — Ctrl-C to exit]"
);
// Whole seconds round to themselves.
assert_eq!(
banner_disconnected_countdown(Duration::from_secs(4)),
"[pmacs disconnected — reconnecting in 4s — Ctrl-C to exit]"
);
// 1ms still shows as 1s — sub-second leftovers count as a
// whole second of remaining wait.
assert_eq!(
banner_disconnected_countdown(Duration::from_millis(1)),
"[pmacs disconnected — reconnecting in 1s — Ctrl-C to exit]"
);
}
#[test]
fn banner_countdown_zero_remainder_says_now() {
// True zero — the deadline arrived. Show "now" so the user
// sees a meaningful tail before the next attempt fires.
assert_eq!(
banner_disconnected_countdown(Duration::ZERO),
"[pmacs disconnected — reconnecting now — Ctrl-C to exit]"
);
}
#[test]
fn banner_countdown_full_30s_plateau() {
// The schedule plateaus at 30s; verify the banner displays
// that as the steady-state value, not e.g. "30s + epsilon".
assert_eq!(
banner_disconnected_countdown(Duration::from_secs(30)),
"[pmacs disconnected — reconnecting in 30s — Ctrl-C to exit]"
);
}
// T M10.7 — AttachError message formatting.
//
// Criterion 5 of the spec: the version-mismatch message must
// tell the user which side is at the older version. These tests
// pin the substring assertions explicitly so a future regression
// (the message no longer naming the older side) fails visibly.
#[test]
fn version_mismatch_daemon_older_message_names_daemon() {
let err = AttachError::VersionMismatch {
server: 1,
client: 2,
};
let msg = err.to_string();
assert!(
msg.contains("daemon is at the older version"),
"criterion 5: message must name daemon as older when server < client; got: {msg}"
);
}
#[test]
fn version_mismatch_binary_older_message_names_binary() {
let err = AttachError::VersionMismatch {
server: 2,
client: 1,
};
let msg = err.to_string();
assert!(
msg.contains("binary is at the older version"),
"criterion 5: message must name client binary as older when server > client; got: {msg}"
);
}
#[test]
fn version_mismatch_equal_no_older_clause() {
// Pathological case (the daemon shouldn't emit
// VersionMismatch when versions match) — but the formatter
// shouldn't claim an older side when there isn't one.
let err = AttachError::VersionMismatch {
server: 2,
client: 2,
};
let msg = err.to_string();
assert!(
!msg.contains("older version"),
"no older clause when equal; got: {msg}"
);
}
#[test]
fn capability_mismatch_message_names_multi_frontend() {
// T M10.7 criterion 4 — the error names the specific
// capability the frontend asked for that wasn't available.
let err = AttachError::Rejected(GoodbyeReason::CapabilityMismatch {
missing: vec!["multi_frontend".to_string()],
});
let msg = err.to_string();
assert!(
msg.contains("multi-frontend collaboration"),
"criterion 4: message must name the capability in user-readable form; got: {msg}"
);
}
#[test]
fn capability_mismatch_message_names_crdt_replica() {
let err = AttachError::Rejected(GoodbyeReason::CapabilityMismatch {
missing: vec!["crdt_replica".to_string()],
});
let msg = err.to_string();
assert!(
msg.contains("CRDT replica participation"),
"message must translate crdt_replica to user-readable form; got: {msg}"
);
}
#[test]
fn capability_mismatch_message_lists_multiple() {
let err = AttachError::Rejected(GoodbyeReason::CapabilityMismatch {
missing: vec!["multi_frontend".to_string(), "crdt_replica".to_string()],
});
let msg = err.to_string();
assert!(msg.contains("multi-frontend collaboration"));
assert!(msg.contains("CRDT replica participation"));
}
}