fix: keep windows api connections open

refs #1279
This commit is contained in:
Ogulcan Celik
2026-07-12 15:00:05 +03:00
parent 4ca6cac445
commit d64cf40ca3
3 changed files with 224 additions and 29 deletions
+1
View File
@@ -8,6 +8,7 @@
### Fixed
- Native Windows servers now detach from the terminal console that launched them, so closing WezTerm, Windows Terminal, or another host terminal no longer stops persistent pane processes. (#1329)
- Windows API clients now remain connected while waiting for initial named-pipe request bytes, so `status server`, `api snapshot`, and other socket commands no longer intermittently fail with BrokenPipe. (#1279)
- `herdr --remote` now installs remote helper binaries without routing the binary stream through a multiline `/bin/sh -c` command, fixing installs for non-POSIX login shells such as xonsh. (#1203, thanks @nhumrich)
## [0.7.3] - 2026-07-08
+165 -23
View File
@@ -1,4 +1,4 @@
use std::io::{self, Read, Write};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -18,7 +18,8 @@ use crate::api::wait::{wait_for_event, wait_for_output};
use crate::api::{request_changes_ui, socket_path, ApiRequestMessage, ApiRequestSender, EventHub};
use crate::ipc::{
bind_local_listener, is_connection_closed_error, local_stream_peer_closed,
remove_socket_file_if_owned, socket_file_identity, LocalStream, SocketFileIdentity,
poll_local_stream_read, remove_socket_file_if_owned, set_local_stream_polling,
socket_file_identity, LocalStream, LocalStreamRead, SocketFileIdentity,
};
const SOCKET_PERMISSION_MODE: u32 = 0o600;
@@ -405,48 +406,189 @@ fn api_response_outcome(response: &str) -> &'static str {
}
fn read_initial_request_line(stream: &mut LocalStream) -> std::io::Result<Option<String>> {
stream.set_nonblocking(true)?;
let deadline = Instant::now() + INITIAL_REQUEST_TIMEOUT;
read_initial_request_line_with_timeout(stream, INITIAL_REQUEST_TIMEOUT)
}
fn read_initial_request_line_with_timeout(
stream: &mut LocalStream,
timeout: Duration,
) -> std::io::Result<Option<String>> {
read_initial_request_line_with_limits(stream, timeout, MAX_INITIAL_REQUEST_BYTES)
}
fn read_initial_request_line_with_limits(
stream: &mut LocalStream,
timeout: Duration,
max_bytes: usize,
) -> std::io::Result<Option<String>> {
set_local_stream_polling(stream, true)?;
let deadline = Instant::now() + timeout;
let mut bytes = Vec::new();
let mut byte = [0u8; 1];
loop {
match stream.read(&mut byte) {
Ok(0) => {
stream.set_nonblocking(false)?;
return Ok(None);
}
Ok(_) => {
let result = loop {
let read = match poll_local_stream_read(stream, &mut byte) {
Ok(read) => read,
Err(err) => break Err(err),
};
match read {
LocalStreamRead::Closed => break Ok(None),
LocalStreamRead::Data => {
bytes.push(byte[0]);
if byte[0] == b'\n' {
stream.set_nonblocking(false)?;
return String::from_utf8(bytes)
break String::from_utf8(bytes)
.map(Some)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err));
}
if bytes.len() > MAX_INITIAL_REQUEST_BYTES {
stream.set_nonblocking(false)?;
return Err(io::Error::new(
if bytes.len() > max_bytes {
break Err(io::Error::new(
io::ErrorKind::InvalidData,
"api request line is too large",
));
}
}
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
LocalStreamRead::Pending => {
if Instant::now() >= deadline {
stream.set_nonblocking(false)?;
return Err(io::Error::new(
break Err(io::Error::new(
io::ErrorKind::TimedOut,
"timed out reading api request",
));
}
std::thread::sleep(CONNECTION_POLL_INTERVAL);
}
Err(err) => {
stream.set_nonblocking(false)?;
return Err(err);
}
}
};
set_local_stream_polling(stream, false)?;
result
}
#[cfg(all(test, windows))]
mod windows_tests {
use super::*;
use interprocess::local_socket::traits::Listener as _;
use std::io::{BufRead, BufReader};
use std::sync::mpsc::{self, Receiver};
fn local_stream_pair(name: &str) -> (LocalStream, LocalStream, PathBuf) {
let path = std::env::temp_dir().join(format!(
"herdr-api-{name}-{}-{}.sock",
std::process::id(),
Instant::now().elapsed().as_nanos()
));
let listener = crate::ipc::bind_local_listener(&path).unwrap();
let client = crate::ipc::connect_local_stream(&path).unwrap();
let server = listener.accept().unwrap();
(client, server, path)
}
fn spawn_connection(
server: LocalStream,
) -> (Receiver<std::io::Result<()>>, std::thread::JoinHandle<()>) {
let (done_tx, done_rx) = mpsc::channel();
let thread = std::thread::spawn(move || {
let (api_tx, _api_rx) = tokio::sync::mpsc::unbounded_channel();
let result = handle_connection(
server,
&api_tx,
&EventHub::default(),
&Arc::new(AtomicBool::new(true)),
None,
);
done_tx.send(result).unwrap();
});
(done_rx, thread)
}
#[test]
fn windows_delayed_partial_initial_request_returns_pong() {
let (mut client, server, path) = local_stream_pair("delayed-request");
let (done_rx, server_thread) = spawn_connection(server);
std::thread::sleep(Duration::from_millis(300));
assert!(
done_rx.try_recv().is_err(),
"idle connected client must not be treated as closed"
);
client
.write_all(br#"{"id":"delayed","method":"ping","params":{}}"#)
.unwrap();
client.flush().unwrap();
std::thread::sleep(Duration::from_millis(150));
assert!(
done_rx.try_recv().is_err(),
"partial request must wait for its newline"
);
client.write_all(b"\n").unwrap();
client.flush().unwrap();
let mut response = String::new();
BufReader::new(&mut client)
.read_line(&mut response)
.unwrap();
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
assert_eq!(response["id"], "delayed");
assert_eq!(response["result"]["type"], "pong");
done_rx
.recv_timeout(Duration::from_secs(2))
.unwrap()
.unwrap();
server_thread.join().unwrap();
let _ = std::fs::remove_file(path);
}
#[test]
fn windows_disconnected_initial_request_returns_promptly() {
let (client, server, path) = local_stream_pair("disconnected-request");
let (done_rx, server_thread) = spawn_connection(server);
drop(client);
done_rx
.recv_timeout(Duration::from_secs(2))
.expect("disconnected connection handler must finish promptly")
.unwrap();
server_thread.join().unwrap();
let _ = std::fs::remove_file(path);
}
#[test]
fn windows_idle_initial_request_honors_timeout() {
let (_client, mut server, path) = local_stream_pair("request-timeout");
let err = read_initial_request_line_with_timeout(&mut server, Duration::from_millis(50))
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::TimedOut);
let _ = std::fs::remove_file(path);
}
#[test]
fn windows_initial_request_enforces_size_limit() {
let (mut client, mut server, path) = local_stream_pair("request-size-limit");
client.write_all(b"12345").unwrap();
client.flush().unwrap();
let err = read_initial_request_line_with_limits(&mut server, Duration::from_secs(1), 4)
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
assert_eq!(err.to_string(), "api request line is too large");
let _ = std::fs::remove_file(path);
}
#[test]
fn windows_initial_request_rejects_invalid_utf8() {
let (mut client, mut server, path) = local_stream_pair("request-invalid-utf8");
client.write_all(&[0xff, b'\n']).unwrap();
client.flush().unwrap();
let err = read_initial_request_line_with_timeout(&mut server, Duration::from_secs(1))
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
let _ = std::fs::remove_file(path);
}
}
+58 -6
View File
@@ -1,7 +1,5 @@
use std::fs;
use std::io;
#[cfg(unix)]
use std::io::Read;
use std::io::{self, Read};
#[cfg(unix)]
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::Path;
@@ -12,6 +10,12 @@ use interprocess::local_socket::traits::Stream as _;
pub(crate) type LocalListener = interprocess::local_socket::Listener;
pub(crate) type LocalStream = interprocess::local_socket::Stream;
pub(crate) enum LocalStreamRead {
Data,
Pending,
Closed,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct SocketFileIdentity {
#[cfg(unix)]
@@ -108,6 +112,48 @@ pub(crate) fn local_stream_peer_closed(stream: &mut LocalStream) -> io::Result<b
probe_stream_closed(stream)
}
pub(crate) fn set_local_stream_polling(stream: &mut LocalStream, enabled: bool) -> io::Result<()> {
#[cfg(unix)]
{
stream.set_nonblocking(enabled)
}
#[cfg(windows)]
{
let _ = (stream, enabled);
Ok(())
}
}
pub(crate) fn poll_local_stream_read(
stream: &mut LocalStream,
buf: &mut [u8],
) -> io::Result<LocalStreamRead> {
#[cfg(unix)]
{
match stream.read(buf) {
Ok(0) => Ok(LocalStreamRead::Closed),
Ok(_) => Ok(LocalStreamRead::Data),
Err(err) if err.kind() == io::ErrorKind::WouldBlock => Ok(LocalStreamRead::Pending),
Err(err) => Err(err),
}
}
#[cfg(windows)]
{
match windows_named_pipe_available(stream)? {
None => Ok(LocalStreamRead::Closed),
Some(0) => Ok(LocalStreamRead::Pending),
Some(_) => match stream.read(buf) {
Ok(0) => Ok(LocalStreamRead::Closed),
Ok(_) => Ok(LocalStreamRead::Data),
Err(err) if is_connection_closed_error(&err) => Ok(LocalStreamRead::Closed),
Err(err) => Err(err),
},
}
}
}
#[cfg(unix)]
fn probe_stream_closed(stream: &mut LocalStream) -> io::Result<bool> {
stream.set_nonblocking(true)?;
@@ -132,26 +178,32 @@ fn probe_stream_closed(stream: &mut LocalStream) -> io::Result<bool> {
#[cfg(windows)]
fn probe_stream_closed(stream: &mut LocalStream) -> io::Result<bool> {
Ok(windows_named_pipe_available(stream)?.is_none())
}
#[cfg(windows)]
fn windows_named_pipe_available(stream: &mut LocalStream) -> io::Result<Option<u32>> {
use std::os::windows::io::{AsHandle, AsRawHandle};
let LocalStream::NamedPipe(pipe) = stream;
let mut available = 0;
let ok = unsafe {
windows_sys::Win32::System::Pipes::PeekNamedPipe(
pipe.as_handle().as_raw_handle(),
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut available,
std::ptr::null_mut(),
)
};
if ok != 0 {
return Ok(false);
return Ok(Some(available));
}
let err = io::Error::last_os_error();
if is_connection_closed_error(&err) || windows_named_pipe_closed_error(&err) {
return Ok(true);
return Ok(None);
}
Err(err)
}