From e913467a9f5a9d9170bc990ab16b3a85223a21d6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 17 May 2026 19:28:48 -0400 Subject: [PATCH] force -T 2 --- src/attach.rs | 54 +++++++++++++++++++++++++++++++++++--------- src/daemon_attach.rs | 48 ++++++++++++++++++++++++++++----------- 2 files changed, 78 insertions(+), 24 deletions(-) diff --git a/src/attach.rs b/src/attach.rs index 5b2138c..212aa19 100644 --- a/src/attach.rs +++ b/src/attach.rs @@ -68,6 +68,14 @@ pub const PMACS_TEST_SSH_BIN: &str = "PMACS_TEST_SSH_BIN"; /// need to set it on the initiating shell. const PMACS_ATTACH_DEBUG: &str = "PMACS_ATTACH_DEBUG"; +/// Diagnostic fallback: carry the SSH protocol stream over the +/// SSH stderr channel instead of stdout. Useful when a host's SSH +/// stdout path appears to buffer indefinitely while stderr streams. +const PMACS_ATTACH_SSH_PROTOCOL_STDERR: &str = "PMACS_ATTACH_SSH_PROTOCOL_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) @@ -1014,6 +1022,10 @@ fn attach_debug_enabled() -> bool { std::env::var_os(PMACS_ATTACH_DEBUG).is_some_and(|v| !v.is_empty() && v != "0") } +fn protocol_stderr_enabled() -> bool { + std::env::var_os(PMACS_ATTACH_SSH_PROTOCOL_STDERR).is_some_and(|v| !v.is_empty() && v != "0") +} + fn attach_debug(msg: impl AsRef) { if attach_debug_enabled() { eprintln!("pmacs attach debug: {}", msg.as_ref()); @@ -1104,7 +1116,9 @@ pub(crate) fn build_ssh_command(target: &AttachTarget) -> Option { cmd.arg("-l").arg(u); } cmd.arg(host); - if attach_debug_enabled() { + if protocol_stderr_enabled() { + cmd.arg("env").arg(format!("{PMACS_ATTACH_PROTOCOL_FD}=2")); + } else if attach_debug_enabled() { cmd.arg("env").arg(format!("{PMACS_ATTACH_DEBUG}=1")); } cmd.arg("pmacs").arg("--daemon-attach"); @@ -1506,7 +1520,6 @@ fn run_one_session( .stdout .take() .expect("Stdio::piped on stdout guarantees a handle"); - let mut child_stdout = DebugReader::new("ssh stdout", child_stdout); let mut child_stdin = child .stdin .take() @@ -1516,13 +1529,32 @@ fn run_one_session( .take() .expect("Stdio::piped on stderr guarantees a handle"); - // Tee SSH stderr to our stderr only on the first attempt, where - // raw mode hasn't engaged yet. On reconnect attempts (slot is - // `Some`), raw mode is active and live tee'd bytes would - // corrupt the cell grid; the tail is still captured for the - // give-up message via `AttachError::SshChildExited`. - let tee_to_stderr = frontend_slot.is_none(); - let (stderr_handle, stderr_tail) = spawn_stderr_tee(child_stderr, tee_to_stderr); + let protocol_over_stderr = protocol_stderr_enabled(); + let (mut protocol_reader, stderr_handle, stderr_tail): ( + Box, + thread::JoinHandle<()>, + Arc>>, + ) = 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)), + thread::spawn(|| {}), + Arc::new(Mutex::new(VecDeque::new())), + ) + } else { + // Tee SSH stderr to our stderr only on the first attempt, + // where raw mode hasn't engaged yet. On reconnect attempts + // (slot is `Some`), raw mode is active and live tee'd bytes + // would corrupt the cell grid; the tail is still captured + // for the give-up message via `AttachError::SshChildExited`. + let tee_to_stderr = frontend_slot.is_none(); + let (stderr_handle, stderr_tail) = spawn_stderr_tee(child_stderr, tee_to_stderr); + ( + Box::new(DebugReader::new("ssh stdout", child_stdout)), + stderr_handle, + stderr_tail, + ) + }; // Hello / AttachRequest handshake. On the FIRST attempt // (`frontend_slot` is `None`) raw mode is not engaged yet, so @@ -1544,7 +1576,7 @@ fn run_one_session( } }); } - let hello: Hello = match read_message(&mut child_stdout) { + let hello: Hello = match read_message(&mut protocol_reader) { Ok(h) => h, Err(e) => { hello_wait_done.store(true, Ordering::SeqCst); @@ -1638,7 +1670,7 @@ fn run_one_session( let pid = child.id(); let io = AttachIo { - reader: Box::new(child_stdout), + reader: protocol_reader, writer: Box::new(child_stdin), kick: ssh_kick(pid), }; diff --git a/src/daemon_attach.rs b/src/daemon_attach.rs index e8889cb..0354f61 100644 --- a/src/daemon_attach.rs +++ b/src/daemon_attach.rs @@ -164,16 +164,28 @@ const AUTO_START_POLL_INTERVAL: Duration = Duration::from_millis(50); /// child's stderr, so this stays off the protocol stdout stream. const PMACS_ATTACH_DEBUG: &str = "PMACS_ATTACH_DEBUG"; +const PMACS_ATTACH_PROTOCOL_FD: &str = "PMACS_ATTACH_PROTOCOL_FD"; + fn bridge_debug_enabled() -> bool { std::env::var_os(PMACS_ATTACH_DEBUG).is_some_and(|v| !v.is_empty() && v != "0") } fn bridge_debug(msg: impl AsRef) { - if bridge_debug_enabled() { + // If fd 2 carries the protocol stream, stderr diagnostics would + // corrupt it. The local attach side deliberately does not enable + // remote debug in that mode, but keep this defensive. + if bridge_debug_enabled() && protocol_fd() != 2 { eprintln!("pmacs daemon-attach debug: {}", msg.as_ref()); } } +fn protocol_fd() -> i32 { + match std::env::var(PMACS_ATTACH_PROTOCOL_FD).ok().as_deref() { + Some("2") => 2, + _ => 1, + } +} + /// Run `pmacs --daemon-attach`: ensure a daemon is listening at /// `socket_path` (auto-starting one if not), then bridge our /// stdin/stdout to it. @@ -197,8 +209,8 @@ pub fn run_daemon_attach(socket_path: PathBuf) -> Result<(), BridgeError> { // `Send` and behave as expected for line-oriented or byte // streaming use. let stdin = std::io::stdin(); - let stdout = RawStdout::new(); - run_bridge_connected(socket, stdin, stdout) + let output = RawProtocolOutput::new(protocol_fd()); + run_bridge_connected(socket, stdin, output) } /// Make sure a daemon is listening at `socket_path`, auto-starting @@ -388,28 +400,38 @@ fn copy_with_flush_named( } } -/// Unbuffered writer for the bridge's protocol stdout. +/// Unbuffered writer for the bridge's protocol output fd. /// /// `std::io::Stdout` is allowed to buffer internally, which is toxic /// for `pmacs --daemon-attach`: the first daemon `Hello` is a small /// binary frame with no newline and the local frontend is blocked /// waiting for it. This writer bypasses Rust's stdout buffering and -/// writes directly to fd 1 using safe `nix::unistd::write`. -struct RawStdout { - stdout: std::io::Stdout, +/// writes directly to fd 1 (or fd 2 for a diagnostic SSH fallback) +/// using safe `nix::unistd::write`. +enum RawProtocolOutput { + Stdout(std::io::Stdout), + Stderr(std::io::Stderr), } -impl RawStdout { - fn new() -> Self { - Self { - stdout: std::io::stdout(), +impl RawProtocolOutput { + fn new(fd: i32) -> Self { + match fd { + 2 => Self::Stderr(std::io::stderr()), + _ => Self::Stdout(std::io::stdout()), } } } -impl Write for RawStdout { +impl Write for RawProtocolOutput { fn write(&mut self, buf: &[u8]) -> std::io::Result { - nix::unistd::write(self.stdout.as_fd(), buf).map_err(std::io::Error::from) + match self { + Self::Stdout(stdout) => { + nix::unistd::write(stdout.as_fd(), buf).map_err(std::io::Error::from) + } + Self::Stderr(stderr) => { + nix::unistd::write(stderr.as_fd(), buf).map_err(std::io::Error::from) + } + } } fn flush(&mut self) -> std::io::Result<()> {