stderr default
This commit is contained in:
parent
789633a56b
commit
04ac1fbd8d
228
src/attach.rs
228
src/attach.rs
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue