daemon remote fix
This commit is contained in:
parent
8490e79bbb
commit
a8a8697c6f
|
|
@ -170,29 +170,30 @@ const AUTO_START_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||||
// (`run_attach`, `run_daemon`); pedantic clippy is wrong here.
|
// (`run_attach`, `run_daemon`); pedantic clippy is wrong here.
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
pub fn run_daemon_attach(socket_path: PathBuf) -> Result<(), BridgeError> {
|
pub fn run_daemon_attach(socket_path: PathBuf) -> Result<(), BridgeError> {
|
||||||
ensure_daemon_running(&socket_path)?;
|
let socket = ensure_daemon_running(&socket_path)?;
|
||||||
// Use the owned `Stdin` / `Stdout` (not `.lock()`) so the halves
|
// Use the owned `Stdin` / `Stdout` (not `.lock()`) so the halves
|
||||||
// can move into threads with `'static` lifetimes. Both are
|
// can move into threads with `'static` lifetimes. Both are
|
||||||
// `Send` and behave as expected for line-oriented or byte
|
// `Send` and behave as expected for line-oriented or byte
|
||||||
// streaming use.
|
// streaming use.
|
||||||
let stdin = std::io::stdin();
|
let stdin = std::io::stdin();
|
||||||
let stdout = std::io::stdout();
|
let stdout = std::io::stdout();
|
||||||
run_bridge(socket_path, stdin, stdout)
|
run_bridge_connected(socket, stdin, stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Make sure a daemon is listening at `socket_path`, auto-starting
|
/// Make sure a daemon is listening at `socket_path`, auto-starting
|
||||||
/// one if necessary.
|
/// one if necessary.
|
||||||
///
|
///
|
||||||
/// 1. Try to connect. If that succeeds, the daemon is already
|
/// 1. Try to connect. If that succeeds, the daemon is already
|
||||||
/// running — return immediately.
|
/// running — return that stream for the bridge to use.
|
||||||
/// 2. Otherwise spawn `pmacs --daemon --socket PATH` as a child,
|
/// 2. Otherwise spawn `pmacs --daemon --socket PATH` as a child,
|
||||||
/// then poll the socket every [`AUTO_START_POLL_INTERVAL`] until
|
/// then poll the socket every [`AUTO_START_POLL_INTERVAL`] until
|
||||||
/// a connect succeeds or [`AUTO_START_TIMEOUT`] elapses.
|
/// a connect succeeds or [`AUTO_START_TIMEOUT`] elapses. The
|
||||||
|
/// successful poll stream is returned to the bridge.
|
||||||
///
|
///
|
||||||
/// On timeout the spawned daemon child is **not** killed — it may
|
/// On timeout the spawned daemon child is **not** killed — it may
|
||||||
/// be slow to start rather than broken. The user can investigate by
|
/// be slow to start rather than broken. The user can investigate by
|
||||||
/// running the daemon directly to see its stderr.
|
/// running the daemon directly to see its stderr.
|
||||||
pub(crate) fn ensure_daemon_running(socket_path: &Path) -> Result<(), BridgeError> {
|
pub(crate) fn ensure_daemon_running(socket_path: &Path) -> Result<UnixStream, BridgeError> {
|
||||||
ensure_daemon_running_with(socket_path, AUTO_START_TIMEOUT, spawn_daemon_subprocess)
|
ensure_daemon_running_with(socket_path, AUTO_START_TIMEOUT, spawn_daemon_subprocess)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,14 +210,16 @@ fn ensure_daemon_running_with<F>(
|
||||||
socket_path: &Path,
|
socket_path: &Path,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
spawner: F,
|
spawner: F,
|
||||||
) -> Result<(), BridgeError>
|
) -> Result<UnixStream, BridgeError>
|
||||||
where
|
where
|
||||||
F: FnOnce(&Path) -> Result<(), BridgeError>,
|
F: FnOnce(&Path) -> Result<(), BridgeError>,
|
||||||
{
|
{
|
||||||
// Fast path: daemon already listening. Drop the test stream
|
// Fast path: daemon already listening. Keep this connection and
|
||||||
// immediately so the daemon doesn't see a stuck connection.
|
// use it as the bridge socket. The daemon speaks first, so a
|
||||||
if UnixStream::connect(socket_path).is_ok() {
|
// disposable connect probe is not transparent: dropping it before
|
||||||
return Ok(());
|
// reading the daemon's Hello can make the daemon log BrokenPipe.
|
||||||
|
if let Ok(stream) = UnixStream::connect(socket_path) {
|
||||||
|
return Ok(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slow path: ask the spawner to arrange a daemon, then poll.
|
// Slow path: ask the spawner to arrange a daemon, then poll.
|
||||||
|
|
@ -224,8 +227,8 @@ where
|
||||||
|
|
||||||
let deadline = Instant::now() + timeout;
|
let deadline = Instant::now() + timeout;
|
||||||
loop {
|
loop {
|
||||||
if UnixStream::connect(socket_path).is_ok() {
|
if let Ok(stream) = UnixStream::connect(socket_path) {
|
||||||
return Ok(());
|
return Ok(stream);
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
return Err(BridgeError::AutoStartTimeout {
|
return Err(BridgeError::AutoStartTimeout {
|
||||||
|
|
@ -321,13 +324,49 @@ fn copy_with_flush<R: Read, W: Write>(reader: &mut R, writer: &mut W) -> std::io
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F8 diagnostic byte-capture. Active only when the marker directory
|
||||||
|
/// `/tmp/pmacs-bridge-capture` exists on the bridge's host (or
|
||||||
|
/// `PMACS_BRIDGE_CAPTURE_DIR` points at a directory). When active,
|
||||||
|
/// each direction's bytes are mirrored verbatim to a file there so
|
||||||
|
/// the exact wire image the daemon sees can be decoded offline. The
|
||||||
|
/// mirror is best-effort and never alters the forwarded stream;
|
||||||
|
/// when inactive this is a single `is_dir` syscall and nothing else.
|
||||||
|
fn capture_sink(name: &str) -> Option<std::fs::File> {
|
||||||
|
let dir = std::env::var_os("PMACS_BRIDGE_CAPTURE_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.filter(|p| p.is_dir())
|
||||||
|
.or_else(|| {
|
||||||
|
let marker = PathBuf::from("/tmp/pmacs-bridge-capture");
|
||||||
|
marker.is_dir().then_some(marker)
|
||||||
|
})?;
|
||||||
|
std::fs::File::create(dir.join(name)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reader that mirrors every byte it yields to a side file
|
||||||
|
/// (best-effort) before returning it to the caller. Diagnostic only;
|
||||||
|
/// the byte stream the bridge forwards is unchanged.
|
||||||
|
struct TeeReader<R> {
|
||||||
|
inner: R,
|
||||||
|
sink: std::fs::File,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: Read> Read for TeeReader<R> {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
let n = self.inner.read(buf)?;
|
||||||
|
let _ = self.sink.write_all(&buf[..n]);
|
||||||
|
let _ = self.sink.flush();
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Owned `PathBuf` matches the public entry-point signature; clippy's
|
// Owned `PathBuf` matches the public entry-point signature; clippy's
|
||||||
// pedantic pass-by-value is wrong for this seam.
|
// pedantic pass-by-value is wrong for this seam.
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) fn run_bridge<R, W>(
|
pub(crate) fn run_bridge<R, W>(
|
||||||
socket_path: PathBuf,
|
socket_path: PathBuf,
|
||||||
local_input: R,
|
local_input: R,
|
||||||
mut local_output: W,
|
local_output: W,
|
||||||
) -> Result<(), BridgeError>
|
) -> Result<(), BridgeError>
|
||||||
where
|
where
|
||||||
R: Read + Send + 'static,
|
R: Read + Send + 'static,
|
||||||
|
|
@ -337,7 +376,18 @@ where
|
||||||
socket: socket_path.clone(),
|
socket: socket_path.clone(),
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
|
run_bridge_connected(socket, local_input, local_output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_bridge_connected<R, W>(
|
||||||
|
socket: UnixStream,
|
||||||
|
local_input: R,
|
||||||
|
mut local_output: W,
|
||||||
|
) -> Result<(), BridgeError>
|
||||||
|
where
|
||||||
|
R: Read + Send + 'static,
|
||||||
|
W: Write + Send + 'static,
|
||||||
|
{
|
||||||
let socket_for_writer = socket.try_clone().map_err(BridgeError::Io)?;
|
let socket_for_writer = socket.try_clone().map_err(BridgeError::Io)?;
|
||||||
let kick_handle = socket.try_clone().map_err(BridgeError::Io)?;
|
let kick_handle = socket.try_clone().map_err(BridgeError::Io)?;
|
||||||
let mut socket_reader = socket;
|
let mut socket_reader = socket;
|
||||||
|
|
@ -346,7 +396,18 @@ where
|
||||||
let stdin_thread = thread::spawn(move || {
|
let stdin_thread = thread::spawn(move || {
|
||||||
let mut local_input = local_input;
|
let mut local_input = local_input;
|
||||||
let mut socket_writer = socket_for_writer;
|
let mut socket_writer = socket_for_writer;
|
||||||
let result = copy_with_flush(&mut local_input, &mut socket_writer);
|
// Diagnostic: mirror exactly the bytes we forward toward the
|
||||||
|
// daemon (the client's AttachRequest et seq).
|
||||||
|
let result = match capture_sink("to_daemon.bin") {
|
||||||
|
Some(sink) => {
|
||||||
|
let mut tee = TeeReader {
|
||||||
|
inner: local_input,
|
||||||
|
sink,
|
||||||
|
};
|
||||||
|
copy_with_flush(&mut tee, &mut socket_writer)
|
||||||
|
}
|
||||||
|
None => copy_with_flush(&mut local_input, &mut socket_writer),
|
||||||
|
};
|
||||||
// After stdin EOF, signal write-side close so the daemon's
|
// After stdin EOF, signal write-side close so the daemon's
|
||||||
// blocking read returns 0. Without this, the daemon never
|
// blocking read returns 0. Without this, the daemon never
|
||||||
// sees EOF (we hold other socket FDs on the main thread)
|
// sees EOF (we hold other socket FDs on the main thread)
|
||||||
|
|
@ -362,7 +423,18 @@ where
|
||||||
// swallowed — once we've decided to tear down, the caller cares
|
// swallowed — once we've decided to tear down, the caller cares
|
||||||
// only about whether the connect succeeded, not about the exact
|
// only about whether the connect succeeded, not about the exact
|
||||||
// shape of the disconnect.
|
// shape of the disconnect.
|
||||||
let _ = copy_with_flush(&mut socket_reader, &mut local_output);
|
// Diagnostic: mirror exactly the bytes the daemon sent us (its
|
||||||
|
// Hello et seq), as the client receives them.
|
||||||
|
let _ = match capture_sink("from_daemon.bin") {
|
||||||
|
Some(sink) => {
|
||||||
|
let mut tee = TeeReader {
|
||||||
|
inner: socket_reader,
|
||||||
|
sink,
|
||||||
|
};
|
||||||
|
copy_with_flush(&mut tee, &mut local_output)
|
||||||
|
}
|
||||||
|
None => copy_with_flush(&mut socket_reader, &mut local_output),
|
||||||
|
};
|
||||||
|
|
||||||
// Wake the stdin thread's pending socket write. If the thread is
|
// Wake the stdin thread's pending socket write. If the thread is
|
||||||
// currently blocked in a *read* of local_input, this doesn't
|
// currently blocked in a *read* of local_input, this doesn't
|
||||||
|
|
@ -631,6 +703,36 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_running_reuses_existing_connection_instead_of_probe_drop() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let socket_path = tmp.path().join("test.sock");
|
||||||
|
let Some(listener) = bind_or_skip(&socket_path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let daemon = thread::spawn(move || {
|
||||||
|
let (mut stream, _addr) = listener.accept().unwrap();
|
||||||
|
stream.write_all(b"HELLO").unwrap();
|
||||||
|
stream.flush().unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut stream = ensure_daemon_running_with(
|
||||||
|
&socket_path,
|
||||||
|
Duration::from_secs(1),
|
||||||
|
|_path: &Path| -> Result<(), BridgeError> {
|
||||||
|
panic!("spawner must not run when daemon is already listening")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("existing daemon connection");
|
||||||
|
|
||||||
|
let mut received = [0u8; 5];
|
||||||
|
stream.read_exact(&mut received).unwrap();
|
||||||
|
assert_eq!(&received, b"HELLO");
|
||||||
|
|
||||||
|
daemon.join().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ensure_running_invokes_spawner_then_waits_for_socket_to_appear() {
|
fn ensure_running_invokes_spawner_then_waits_for_socket_to_appear() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|
@ -763,4 +865,96 @@ mod tests {
|
||||||
assert!(msg.contains("permission denied"));
|
assert!(msg.contains("permission denied"));
|
||||||
assert!(msg.contains("PATH"), "should hint at PATH check: {msg}",);
|
assert!(msg.contains("PATH"), "should hint at PATH check: {msg}",);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F8 reproduction (M10.11 acceptance criterion 1, SSH transport).
|
||||||
|
///
|
||||||
|
/// Models the production failure: the daemon speaks first
|
||||||
|
/// (`per_attach_thread` writes `Hello` *before* reading
|
||||||
|
/// `AttachRequest`, see `src/daemon.rs:617`), and the bridge's
|
||||||
|
/// local input reaches EOF immediately — exactly what
|
||||||
|
/// `std::io::empty()` provides, and what was observed when
|
||||||
|
/// `pmacs --attach ssh:...` drove the remote `pmacs
|
||||||
|
/// --daemon-attach`.
|
||||||
|
///
|
||||||
|
/// The daemon thread deliberately sleeps before writing `Hello`
|
||||||
|
/// so the bridge's immediate-stdin-EOF teardown path
|
||||||
|
/// (`copy_with_flush` returns `Ok(0)` → `shutdown(Write)` →
|
||||||
|
/// `done_tx`) wins the race, reproducing the production ordering
|
||||||
|
/// where the daemon's first write lands *after* the bridge has
|
||||||
|
/// reacted to EOF.
|
||||||
|
///
|
||||||
|
/// Acceptance: the daemon's `Hello` write must NOT fail with
|
||||||
|
/// `BrokenPipe`, and the `Hello` bytes must reach the bridge's
|
||||||
|
/// local output. If this test fails, F8 is reproduced in-process
|
||||||
|
/// and the defect is in `run_bridge`'s local teardown logic — not
|
||||||
|
/// ssh-specific. If it passes against unfixed code, the in-process
|
||||||
|
/// model does NOT capture F8 and the cause is ssh-transport
|
||||||
|
/// specific (report honestly; do not claim a `run_bridge` fix).
|
||||||
|
#[test]
|
||||||
|
fn f8_daemon_speaks_first_survives_immediate_stdin_eof() {
|
||||||
|
// Length-prefixed Hello-shaped frame. Content is irrelevant —
|
||||||
|
// the bridge is byte-transparent; only survival matters.
|
||||||
|
const HELLO_FRAME: &[u8] = &[0x00, 0x00, 0x00, 0x05, b'H', b'E', b'L', b'L', b'O'];
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let socket_path = tmp.path().join("f8.sock");
|
||||||
|
let Some(listener) = bind_or_skip(&socket_path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (write_result_tx, write_result_rx) = mpsc::channel::<std::io::Result<()>>();
|
||||||
|
let daemon = thread::spawn(move || {
|
||||||
|
let (mut stream, _addr) = listener.accept().unwrap();
|
||||||
|
// Production ordering: the bridge has process/network
|
||||||
|
// latency before our first write reaches it. Sleep so the
|
||||||
|
// bridge's immediate-EOF teardown definitely happens first.
|
||||||
|
thread::sleep(Duration::from_millis(150));
|
||||||
|
let result = stream.write_all(HELLO_FRAME).and_then(|()| stream.flush());
|
||||||
|
let _ = write_result_tx.send(result);
|
||||||
|
// Mirror per_attach_thread: after Hello, read AttachRequest
|
||||||
|
// (here just drain to EOF so the socket lifecycle matches).
|
||||||
|
let mut sink = Vec::new();
|
||||||
|
let _ = stream.read_to_end(&mut sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
let (mut output_reader, output_writer) = pipe();
|
||||||
|
let bridge_socket = socket_path.clone();
|
||||||
|
// std::io::empty() == immediate stdin EOF.
|
||||||
|
let bridge =
|
||||||
|
thread::spawn(move || run_bridge(bridge_socket, std::io::empty(), output_writer));
|
||||||
|
|
||||||
|
// 1. The daemon's Hello write must not EPIPE.
|
||||||
|
let write_result = write_result_rx
|
||||||
|
.recv_timeout(Duration::from_secs(3))
|
||||||
|
.expect("daemon thread should attempt the Hello write within 3s");
|
||||||
|
assert!(
|
||||||
|
write_result.is_ok(),
|
||||||
|
"F8 reproduced: daemon's Hello write failed ({:?}) — \
|
||||||
|
run_bridge tore the socket down on immediate stdin-EOF \
|
||||||
|
before the daemon's first write could land",
|
||||||
|
write_result.as_ref().err().map(std::io::Error::kind),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. The Hello bytes must reach the bridge's local output.
|
||||||
|
let mut received = vec![0u8; HELLO_FRAME.len()];
|
||||||
|
output_reader
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||||
|
.unwrap();
|
||||||
|
match output_reader.read_exact(&mut received) {
|
||||||
|
Ok(()) => assert_eq!(received, HELLO_FRAME, "Hello bytes corrupted in transit",),
|
||||||
|
Err(e) => panic!(
|
||||||
|
"F8 reproduced: Hello never reached local output ({e}) — \
|
||||||
|
the bridge dropped the daemon→client direction on \
|
||||||
|
immediate stdin-EOF",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounded teardown so a deadlock fails loudly, not silently.
|
||||||
|
let (done_tx, done_rx) = mpsc::channel();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let _ = done_tx.send(bridge.join());
|
||||||
|
});
|
||||||
|
let _ = done_rx.recv_timeout(Duration::from_secs(3));
|
||||||
|
let _ = daemon.join();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue