stderr default

This commit is contained in:
Levi Neuwirth 2026-05-17 22:17:36 -04:00
parent 789633a56b
commit 04ac1fbd8d
1 changed files with 186 additions and 42 deletions

View File

@ -68,11 +68,51 @@ pub const PMACS_TEST_SSH_BIN: &str = "PMACS_TEST_SSH_BIN";
/// need to set it on the initiating shell. /// need to set it on the initiating shell.
const PMACS_ATTACH_DEBUG: &str = "PMACS_ATTACH_DEBUG"; const PMACS_ATTACH_DEBUG: &str = "PMACS_ATTACH_DEBUG";
/// Diagnostic fallback: carry the SSH protocol stream over the /// Explicit per-invocation override of the SSH protocol channel:
/// SSH stderr channel instead of stdout. Useful when a host's SSH /// `stdout` / `1` or `stderr` / `2`. Unset → [`SSH_PROTOCOL_DEFAULT`].
/// stdout path appears to buffer indefinitely while stderr streams. /// 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"; 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`. /// Remote-side env var consumed by `daemon_attach.rs`.
const PMACS_ATTACH_PROTOCOL_FD: &str = "PMACS_ATTACH_PROTOCOL_FD"; const PMACS_ATTACH_PROTOCOL_FD: &str = "PMACS_ATTACH_PROTOCOL_FD";
@ -1022,8 +1062,44 @@ fn attach_debug_enabled() -> bool {
std::env::var_os(PMACS_ATTACH_DEBUG).is_some_and(|v| !v.is_empty() && v != "0") std::env::var_os(PMACS_ATTACH_DEBUG).is_some_and(|v| !v.is_empty() && v != "0")
} }
fn protocol_stderr_enabled() -> bool { /// Pure channel resolver (env-free core, unit-tested without env
std::env::var_os(PMACS_ATTACH_SSH_PROTOCOL_STDERR).is_some_and(|v| !v.is_empty() && v != "0") /// 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,
)
} }
fn attach_debug(msg: impl AsRef<str>) { fn attach_debug(msg: impl AsRef<str>) {
@ -1103,11 +1179,9 @@ impl<R: Read> Read for DebugReader<R> {
/// directly by [`run_attach_ssh`] and by unit tests that want to /// directly by [`run_attach_ssh`] and by unit tests that want to
/// assert argument shape without spawning a real `ssh`. /// assert argument shape without spawning a real `ssh`.
/// ///
/// Argument order: `-T [user-flag] host exec [env VAR=val] pmacs /// Argument order: `-T [user-flag] host [env VAR=val] pmacs
/// --daemon-attach [--socket NAME]`. The leading `exec` (F8b fix) /// --daemon-attach [--socket NAME]`. The user flag is `-l USER` if
/// makes the remote login shell exec-replace itself with the bridge /// `user` is set;
/// so no shell parent lingers holding the SSH channel fds. The user
/// flag is `-l USER` if `user` is set;
/// SSH's own `~/.ssh/config` is consulted by the binary, so we /// SSH's own `~/.ssh/config` is consulted by the binary, so we
/// don't try to second-guess host aliases here. /// don't try to second-guess host aliases here.
/// ///
@ -1132,26 +1206,27 @@ pub(crate) fn build_ssh_command(target: &AttachTarget) -> Option<Command> {
cmd.arg("-l").arg(u); cmd.arg("-l").arg(u);
} }
cmd.arg(host); cmd.arg(host);
// F8b fix: `exec`-replace the remote login shell with the bridge. // Single source of truth (F8b): the resolved channel drives the
// ssh runs the remote command via the user's login shell // remote bridge's protocol fd. Stdout is fd 1 (the bridge's
// (`$SHELL -c "joined args"`). If that shell lingers as a parent // default when unset), so we only pass the fd env when it
// process while the long-lived bridge runs, the SSH session's // differs — keeps the stdout path's argv minimal and unchanged.
// stdout/stderr fds have an extra holder, and OpenSSH keeps the // Debug is independent and additive (the remote bridge already
// channel's output buffered until *every* holder exits — so the // self-suppresses its stderr breadcrumbs when the protocol rides
// bridge's `Hello` (and all traffic) is withheld until the bridge // fd 2, so the two can coexist; the old mutually-exclusive
// dies, i.e. forever during a session: the F8b hang. Confound-free // `else if` could not).
// probing (`f8b-shell-probe.sh`, sh==fish==login-shell all let mut env_pairs: Vec<String> = Vec::new();
// withheld; an `exec`'d single long-lived process streamed at the let fd = ssh_protocol_channel().remote_fd();
// SSH floor while alive) pinned this exactly. `exec` is a builtin if fd != 1 {
// honored by every standard login shell ssh dispatches through; env_pairs.push(format!("{PMACS_ATTACH_PROTOCOL_FD}={fd}"));
// the shell has nothing to do after the bridge anyway, so }
// exec-replacing it is unconditionally correct and removes the if attach_debug_enabled() {
// lingering fd holder. env_pairs.push(format!("{PMACS_ATTACH_DEBUG}=1"));
cmd.arg("exec"); }
if protocol_stderr_enabled() { if !env_pairs.is_empty() {
cmd.arg("env").arg(format!("{PMACS_ATTACH_PROTOCOL_FD}=2")); cmd.arg("env");
} else if attach_debug_enabled() { for kv in &env_pairs {
cmd.arg("env").arg(format!("{PMACS_ATTACH_DEBUG}=1")); cmd.arg(kv);
}
} }
cmd.arg("pmacs").arg("--daemon-attach"); cmd.arg("pmacs").arg("--daemon-attach");
if let Some(name) = instance_name { if let Some(name) = instance_name {
@ -1538,13 +1613,16 @@ type ProtocolChannel = (
/// Pick the SSH channel the protocol rides on. /// Pick the SSH channel the protocol rides on.
/// ///
/// Default: protocol on stdout, SSH stderr tee'd to our stderr only /// `protocol_over_stderr` is [`SSH_PROTOCOL_DEFAULT`]-derived (F8b:
/// on the first attempt (`tee_to_stderr`) — on reconnect raw mode is /// stderr is the default — see `SshProtocolChannel`). When stderr:
/// active and live tee'd bytes would corrupt the cell grid, so the /// protocol reads SSH stderr, remote stderr diagnostics are disabled
/// tail is still captured for the give-up message but not echoed. /// by the caller, and there is no tee (a no-op join handle / empty
/// F8b workaround (`protocol_over_stderr`): protocol on SSH stderr; /// tail keep the return shape). When stdout (the F8b fallback /
/// remote stderr diagnostics are disabled by the caller, and there /// override): protocol reads SSH stdout, and SSH stderr is tee'd to
/// is no tee (a no-op join handle / empty tail keep the shape). /// 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( fn open_protocol_channel(
child_stdout: std::process::ChildStdout, child_stdout: std::process::ChildStdout,
child_stderr: std::process::ChildStderr, child_stderr: std::process::ChildStderr,
@ -1663,7 +1741,7 @@ fn run_one_session(
.take() .take()
.expect("Stdio::piped on stderr guarantees a handle"); .expect("Stdio::piped on stderr guarantees a handle");
let protocol_over_stderr = protocol_stderr_enabled(); let protocol_over_stderr = ssh_protocol_channel() == SshProtocolChannel::Stderr;
let (mut protocol_reader, stderr_handle, stderr_tail) = open_protocol_channel( let (mut protocol_reader, stderr_handle, stderr_tail) = open_protocol_channel(
child_stdout, child_stdout,
child_stderr, child_stderr,
@ -2270,8 +2348,16 @@ mod tests {
let arg_strs: Vec<&str> = args.iter().filter_map(|s| s.to_str()).collect(); let arg_strs: Vec<&str> = args.iter().filter_map(|s| s.to_str()).collect();
assert_eq!( assert_eq!(
arg_strs, arg_strs,
vec!["-T", "mac-studio", "exec", "pmacs", "--daemon-attach"], vec![
"bare host should produce: -T <host> exec pmacs --daemon-attach", "-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",
); );
} }
@ -2291,7 +2377,8 @@ mod tests {
"-l", "-l",
"alice", "alice",
"workstation", "workstation",
"exec", "env",
"PMACS_ATTACH_PROTOCOL_FD=2",
"pmacs", "pmacs",
"--daemon-attach", "--daemon-attach",
], ],
@ -2314,7 +2401,8 @@ mod tests {
"-l", "-l",
"bob", "bob",
"workstation", "workstation",
"exec", "env",
"PMACS_ATTACH_PROTOCOL_FD=2",
"pmacs", "pmacs",
"--daemon-attach", "--daemon-attach",
"--socket", "--socket",
@ -2333,6 +2421,62 @@ mod tests {
); );
} }
// 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] #[test]
fn ssh_child_exited_127_message_names_path_diagnostic() { fn ssh_child_exited_127_message_names_path_diagnostic() {
let err = AttachError::SshChildExited { let err = AttachError::SshChildExited {