use super::protocol::Call;
#[cfg(unix)]
const CONNECT_WAIT: std::time::Duration = std::time::Duration::from_millis(200);
#[cfg(unix)]
const IO_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(250);
pub(crate) trait Sink: Send {
fn deliver(&mut self, call: &Call) -> bool;
}
#[cfg(unix)]
pub(crate) use unix::SocketSink;
#[cfg(unix)]
mod unix {
use super::{Call, Sink, CONNECT_WAIT, IO_TIMEOUT};
use std::io::{ErrorKind, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, TryRecvError};
use std::sync::Arc;
pub(super) fn connect_socket(path: &Path) -> std::io::Result<std::os::unix::net::UnixStream> {
std::os::unix::net::UnixStream::connect(path)
}
pub(super) type Connector =
Arc<dyn Fn(&Path) -> std::io::Result<std::os::unix::net::UnixStream> + Send + Sync>;
pub(crate) struct SocketSink {
path: PathBuf,
connect: Connector,
conn: Option<std::os::unix::net::UnixStream>,
pending: Option<Receiver<std::io::Result<std::os::unix::net::UnixStream>>>,
next_id: u64,
connect_wait: std::time::Duration,
}
impl SocketSink {
pub(crate) fn new(path: PathBuf) -> Self {
Self::with_connector(path, Arc::new(connect_socket))
}
pub(super) fn with_connector(path: PathBuf, connect: Connector) -> Self {
Self {
path,
connect,
conn: None,
pending: None,
next_id: 0,
connect_wait: CONNECT_WAIT,
}
}
#[cfg(test)]
pub(super) fn with_wait(mut self, wait: std::time::Duration) -> Self {
self.connect_wait = wait;
self
}
fn connection(&mut self) -> Option<&mut std::os::unix::net::UnixStream> {
if self.conn.is_none() {
self.reconnect();
}
self.conn.as_mut()
}
fn reconnect(&mut self) {
if let Some(rx) = self.pending.take() {
match rx.try_recv() {
Ok(Ok(stream)) => self.conn = Some(stream),
Ok(Err(_)) | Err(TryRecvError::Disconnected) => {}
Err(TryRecvError::Empty) => self.pending = Some(rx),
}
return;
}
if !self.path.exists() {
return;
}
let (tx, rx) = mpsc::channel();
let connect = Arc::clone(&self.connect);
let path = self.path.clone();
if std::thread::Builder::new()
.name("herdr-connect".into())
.spawn(move || {
let _ = tx.send(connect(&path));
})
.is_err()
{
return;
}
match rx.recv_timeout(self.connect_wait) {
Ok(Ok(stream)) => self.conn = Some(stream),
Ok(Err(_)) | Err(RecvTimeoutError::Disconnected) => {}
Err(RecvTimeoutError::Timeout) => self.pending = Some(rx),
}
}
fn drain_responses(stream: &mut std::os::unix::net::UnixStream) -> bool {
if stream.set_nonblocking(true).is_err() {
return false;
}
let mut buf = [0u8; 1024];
let alive = loop {
match stream.read(&mut buf) {
Ok(0) => break false, Ok(_) => continue,
Err(e) if e.kind() == ErrorKind::WouldBlock => break true,
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
Err(_) => break false,
}
};
alive && stream.set_nonblocking(false).is_ok()
}
}
impl Sink for SocketSink {
fn deliver(&mut self, call: &Call) -> bool {
let Some(line) = call.encode(self.next_id) else {
return false;
};
self.next_id = self.next_id.wrapping_add(1);
let Some(stream) = self.connection() else {
return false;
};
let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
let wrote = stream
.write_all(&line)
.and_then(|()| stream.flush())
.is_ok();
let alive = wrote && Self::drain_responses(stream);
if !alive {
self.conn = None;
}
alive
}
}
}
pub(crate) mod cli {
use super::{Call, Sink};
use std::path::PathBuf;
use std::process::Command;
pub(crate) struct CliSink {
bin: PathBuf,
}
impl CliSink {
pub(crate) fn new(bin: PathBuf) -> Self {
Self { bin }
}
fn argv(call: &Call) -> Option<Vec<String>> {
let p = &call.params;
let pane = p.get("pane_id")?.as_str()?.to_string();
let mut v = vec!["pane".to_string()];
match call.method {
"pane.report_agent" => {
v.push("report-agent".into());
v.push(pane);
v.push("--source".into());
v.push(p.get("source")?.as_str()?.to_string());
v.push("--agent".into());
v.push(p.get("agent")?.as_str()?.to_string());
v.push("--state".into());
v.push(p.get("state")?.as_str()?.to_string());
if let Some(m) = p.get("message").and_then(|m| m.as_str()) {
v.push("--message".into());
v.push(m.to_string());
}
}
"pane.report_agent_session" => {
v.push("report-agent-session".into());
v.push(pane);
v.push("--source".into());
v.push(p.get("source")?.as_str()?.to_string());
v.push("--agent".into());
v.push(p.get("agent")?.as_str()?.to_string());
if let Some(id) = p.get("agent_session_id").and_then(|s| s.as_str()) {
v.push("--agent-session-id".into());
v.push(id.to_string());
}
if let Some(path) = p.get("agent_session_path").and_then(|s| s.as_str()) {
v.push("--agent-session-path".into());
v.push(path.to_string());
}
}
"pane.release_agent" => {
v.push("release-agent".into());
v.push(pane);
v.push("--source".into());
v.push(p.get("source")?.as_str()?.to_string());
v.push("--agent".into());
v.push(p.get("agent")?.as_str()?.to_string());
}
_ => return None,
}
Some(v)
}
}
impl Sink for CliSink {
fn deliver(&mut self, call: &Call) -> bool {
let Some(argv) = Self::argv(call) else {
return false;
};
Command::new(&self.bin)
.args(&argv)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::super::super::protocol::{release_agent, report_agent, PaneAgentState};
use super::*;
#[test]
fn report_agent_maps_to_the_typed_cli_verb() {
let call = report_agent("w1:p2", PaneAgentState::Working, Some("read_file"));
let argv = CliSink::argv(&call).unwrap();
assert_eq!(
argv,
vec![
"pane",
"report-agent",
"w1:p2",
"--source",
"custom:newt",
"--agent",
"newt",
"--state",
"working",
"--message",
"read_file",
]
);
}
#[test]
fn release_agent_maps_to_the_typed_cli_verb_with_reported_identity() {
let call = release_agent("w1:p2");
let argv = CliSink::argv(&call).unwrap();
assert_eq!(
argv,
vec![
"pane",
"release-agent",
"w1:p2",
"--source",
"custom:newt",
"--agent",
"newt",
]
);
}
#[test]
fn a_method_with_no_cli_verb_fails_open() {
let call = super::super::super::protocol::report_metadata_title("w1:p2", "title");
assert!(CliSink::argv(&call).is_none());
}
#[test]
fn a_missing_binary_delivers_false_not_panic() {
let mut sink = CliSink::new(PathBuf::from("/no/such/herdr-binary"));
let call = release_agent("w1:p2");
assert!(!sink.deliver(&call));
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::unix::{Connector, SocketSink};
use super::*;
use crate::herdr::protocol::{report_agent, PaneAgentState};
use std::io::{BufRead, BufReader};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"newt-herdr-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn working() -> Call {
report_agent("w1:p2", PaneAgentState::Working, None)
}
fn deliver_eventually(sink: &mut SocketSink, call: &Call) -> bool {
(0..50).any(|i| {
if i > 0 {
std::thread::sleep(Duration::from_millis(20));
}
sink.deliver(call)
})
}
#[serial_test::serial(real_fs)]
#[test]
fn a_missing_socket_fails_fast() {
let dir = scratch("missing");
let attempts = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&attempts);
let connect: Connector = Arc::new(move |p: &std::path::Path| {
counter.fetch_add(1, Ordering::SeqCst);
super::unix::connect_socket(p)
});
let mut sink = SocketSink::with_connector(dir.join("nope.sock"), connect);
assert!(!sink.deliver(&working()));
assert!(!sink.deliver(&working()));
assert_eq!(
attempts.load(Ordering::SeqCst),
0,
"an absent socket must not cost a connect attempt"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[serial_test::serial(real_fs)]
#[test]
fn a_path_that_is_not_a_socket_fails() {
let dir = scratch("notasocket");
let path = dir.join("api.sock");
std::fs::write(&path, b"not a socket").unwrap();
let mut sink = SocketSink::new(path);
assert!(!sink.deliver(&working()));
let _ = std::fs::remove_dir_all(&dir);
}
#[serial_test::serial(real_fs)]
#[test]
fn calls_are_framed_over_one_reused_connection() {
let dir = scratch("roundtrip");
let path = dir.join("api.sock");
let listener = UnixListener::bind(&path).unwrap();
let server = std::thread::spawn(move || {
let (conn, _) = listener.accept().unwrap();
let mut reader = BufReader::new(conn);
let mut lines = Vec::new();
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => break, Ok(_) => lines.push(line),
}
}
lines
});
let mut sink = SocketSink::new(path);
assert!(deliver_eventually(
&mut sink,
&report_agent("w1:p2", PaneAgentState::Working, Some("read_file"))
));
assert!(
sink.deliver(&report_agent("w1:p2", PaneAgentState::Idle, None)),
"a warm connection must be reused, not reconnected"
);
drop(sink);
let lines = server.join().unwrap();
assert_eq!(lines.len(), 2, "both calls arrive on the same connection");
let first: serde_json::Value = serde_json::from_str(lines[0].trim()).unwrap();
assert_eq!(first["method"], "pane.report_agent");
assert_eq!(first["params"]["state"], "working");
assert_eq!(first["params"]["message"], "read_file");
let second: serde_json::Value = serde_json::from_str(lines[1].trim()).unwrap();
assert_eq!(second["params"]["state"], "idle");
assert_ne!(first["id"], second["id"], "ids are per-call");
let _ = std::fs::remove_dir_all(&dir);
}
#[serial_test::serial(real_fs)]
#[test]
fn a_silent_server_does_not_stall_delivery() {
let dir = scratch("silent");
let path = dir.join("api.sock");
let listener = UnixListener::bind(&path).unwrap();
let (hangup_tx, hangup_rx) = std::sync::mpsc::channel::<()>();
let server = std::thread::spawn(move || {
let (conn, _) = listener.accept().unwrap();
let _ = hangup_rx.recv();
drop(conn);
});
let mut sink = SocketSink::new(path);
assert!(deliver_eventually(&mut sink, &working()));
assert!(sink.deliver(&working()));
hangup_tx.send(()).unwrap();
server.join().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[serial_test::serial(real_fs)]
#[test]
fn a_closed_peer_fails_and_retires_the_connection() {
let dir = scratch("closed");
let path = dir.join("api.sock");
let listener = UnixListener::bind(&path).unwrap();
let server = std::thread::spawn(move || {
let (conn, _) = listener.accept().unwrap();
drop(conn); drop(listener); });
let mut sink = SocketSink::new(path);
let _ = deliver_eventually(&mut sink, &working());
server.join().unwrap();
assert!(
!sink.deliver(&working()),
"a hung-up peer must not be reported as delivered"
);
assert!(
!sink.deliver(&working()),
"and the dead connection must have been retired, not reused"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[serial_test::serial(real_fs)]
#[test]
fn a_hanging_connect_is_abandoned_and_never_duplicated() {
let dir = scratch("hang");
let path = dir.join("api.sock");
let _listener = UnixListener::bind(&path).unwrap();
let attempts = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&attempts);
let release = Arc::new(std::sync::Mutex::new(()));
let held = release.lock().unwrap();
let gate = Arc::clone(&release);
let (entered_tx, entered_rx) = std::sync::mpsc::channel::<()>();
let peers: Arc<std::sync::Mutex<Vec<UnixStream>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let peer_sink = Arc::clone(&peers);
let connect: Connector = Arc::new(move |_p: &std::path::Path| {
counter.fetch_add(1, Ordering::SeqCst);
let _ = entered_tx.send(()); let _wait = gate.lock().unwrap(); let (near, far) = UnixStream::pair()?;
peer_sink.lock().unwrap().push(far);
Ok(near)
});
let mut sink =
SocketSink::with_connector(path, connect).with_wait(Duration::from_millis(1));
assert!(!sink.deliver(&working()));
newt_core::test_guard::recv_guarded(&entered_rx, "herdr-connect thread entering connect()");
for _ in 0..5 {
assert!(!sink.deliver(&working()));
}
assert_eq!(
attempts.load(Ordering::SeqCst),
1,
"exactly one connect attempt stays outstanding"
);
drop(held); let deadline = Instant::now() + newt_core::test_guard::HANG_GUARD;
let collected = loop {
if sink.deliver(&working()) {
break true;
}
if Instant::now() >= deadline {
break false;
}
std::thread::yield_now();
};
assert!(collected, "the abandoned attempt is collected, not leaked");
let _ = std::fs::remove_dir_all(&dir);
}
}