aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-13 13:47:53 +0300
committerGitHub <noreply@github.com>2026-09-13 13:47:53 +0300
commitfc44f61a10ac04688b2fedfa670f38a463ca25ac (patch)
tree29ca9c5411a444c051a7f9ea3db456d113a080d6 /src/server
parentbafbc0949dd996cf7fd0848c8965e254348cc11e (diff)
fix: disconnect stalled terminal observers (#4039)
Diffstat (limited to 'src/server')
-rw-r--r--src/server/client_transport.rs129
-rw-r--r--src/server/headless/render.rs10
-rw-r--r--src/server/headless/tests/mod.rs47
3 files changed, 183 insertions, 3 deletions
diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs
index c57d0f86..da682c06 100644
--- a/src/server/client_transport.rs
+++ b/src/server/client_transport.rs
@@ -41,6 +41,9 @@ const MIN_CLIENT_ROWS: u16 = 1;
/// and cleanup overhead.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(4);
+#[cfg(unix)]
+const OBSERVER_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
+
/// Maximum input payload size (bytes) for a single `ClientMessage::Input`.
const MAX_INPUT_PAYLOAD: usize = 1024 * 1024; // 1 MB
const MAX_CLIENT_SHELL_DIMENSION: u16 = 4096;
@@ -940,7 +943,11 @@ fn client_writer_loop(
}
fn write_framed_bytes(stream: &mut LocalStream, data: &[u8]) -> bool {
- if let Err(err) = stream.write_all(data) {
+ #[cfg(unix)]
+ let result = crate::platform::write_client_stream(stream, data);
+ #[cfg(windows)]
+ let result = stream.write_all(data);
+ if let Err(err) = result {
debug!(err = %err, "client write failed, closing writer");
return false;
}
@@ -970,8 +977,14 @@ fn client_read_loop_with_endpoint_controls(
endpoint_control_writer: Option<&ClientControlWriter>,
) -> io::Result<()> {
while !should_quit.load(Ordering::Acquire) {
- let msg: ClientMessage = match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE)
- {
+ #[cfg(unix)]
+ let message = protocol::read_message(
+ &mut crate::platform::ClientStreamReader(&mut stream),
+ MAX_GRAPHICS_FRAME_SIZE,
+ );
+ #[cfg(windows)]
+ let message = protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE);
+ let msg: ClientMessage = match message {
Ok(msg) => msg,
Err(protocol::FramingError::UnexpectedEof) => {
// Client disconnected.
@@ -1027,6 +1040,13 @@ fn client_read_loop_with_endpoint_controls(
}
}
ClientMessage::ObserveTerminal { target } => {
+ #[cfg(unix)]
+ {
+ stream.set_send_timeout(Some(OBSERVER_WRITE_TIMEOUT))?;
+ // macOS Unix sockets can block even with per-send MSG_DONTWAIT.
+ // ClientStreamReader preserves blocking reads on the shared socket.
+ stream.set_nonblocking(true)?;
+ }
ServerEvent::ClientObserveTerminal { client_id, target }
}
ClientMessage::ControlTerminal { target, takeover } => {
@@ -1641,6 +1661,109 @@ mod tests {
));
}
+ #[cfg(unix)]
+ #[test]
+ fn observer_write_timeout_resets_when_sending_makes_progress() {
+ use std::io::Read as _;
+
+ let (mut client, mut server, _path) = local_stream_pair("slow-observer");
+ server
+ .set_send_timeout(Some(Duration::from_millis(100)))
+ .unwrap();
+ server.set_nonblocking(true).unwrap();
+ let worker = std::thread::spawn(move || {
+ assert!(write_framed_bytes(&mut server, &vec![b'x'; 1024 * 1024]));
+ });
+ client
+ .set_recv_timeout(Some(Duration::from_secs(3)))
+ .unwrap();
+ let mut received = 0;
+ let mut buffer = [0; 16 * 1024];
+ while received < 1024 * 1024 {
+ let count = client.read(&mut buffer).unwrap();
+ assert_ne!(count, 0, "observer disconnected while making progress");
+ received += count;
+ std::thread::sleep(Duration::from_millis(5));
+ }
+ worker.join().unwrap();
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn stalled_observer_timeout_releases_writer_and_reader() {
+ let (mut client, server, _path) = local_stream_pair("stalled-observer");
+ let writer_stream = server.try_clone().expect("clone writer stream");
+ let (writer, queue) = test_queue_writer();
+ let (events, event_rx) = mpsc::channel(8);
+ let reader_events = events.clone();
+ let (reader_done_tx, reader_done) = std::sync::mpsc::channel();
+ let reader = std::thread::spawn(move || {
+ let result = client_read_loop(
+ server,
+ 14,
+ &reader_events,
+ &Arc::new(AtomicBool::new(false)),
+ );
+ let _ = reader_done_tx.send(result);
+ });
+ protocol::write_message(
+ &mut client,
+ &ClientMessage::ObserveTerminal {
+ target: "w1:p1".into(),
+ },
+ )
+ .expect("observe request");
+ let mut event_rx = event_rx;
+ assert!(matches!(
+ event_rx.blocking_recv(),
+ Some(ServerEvent::ClientObserveTerminal { client_id: 14, .. })
+ ));
+ let LocalStream::UdSocket(socket) = &writer_stream;
+ assert_eq!(
+ socket.inner().write_timeout().unwrap(),
+ Some(Duration::from_secs(30))
+ );
+ assert_eq!(socket.inner().read_timeout().unwrap(), None);
+ let mut resize = Vec::new();
+ protocol::write_message(
+ &mut resize,
+ &ClientMessage::Resize {
+ cols: 80,
+ rows: 24,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ },
+ )
+ .unwrap();
+ client.write_all(&resize[..2]).unwrap();
+ std::thread::sleep(Duration::from_millis(20));
+ client.write_all(&resize[2..]).unwrap();
+ assert!(matches!(
+ event_rx.blocking_recv(),
+ Some(ServerEvent::ClientResize { client_id: 14, .. })
+ ));
+ writer_stream
+ .set_send_timeout(Some(Duration::from_millis(200)))
+ .unwrap();
+ let (writer_done_tx, writer_done) = std::sync::mpsc::channel();
+ let worker = std::thread::spawn(move || {
+ client_writer_loop(writer_stream, 14, queue, events);
+ let _ = writer_done_tx.send(());
+ });
+ writer.render.try_send(vec![0; 4 * 1024 * 1024]).unwrap();
+ writer_done
+ .recv_timeout(Duration::from_millis(350))
+ .expect("writer timed out");
+ reader_done
+ .recv_timeout(Duration::from_secs(2))
+ .expect("reader released")
+ .unwrap();
+ worker.join().unwrap();
+ reader.join().unwrap();
+ assert!(writer.control.send(vec![1]).is_err());
+ }
+
#[test]
fn clamp_terminal_size_zero_zero() {
assert_eq!(
diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs
index fc55417f..882a1615 100644
--- a/src/server/headless/render.rs
+++ b/src/server/headless/render.rs
@@ -462,6 +462,16 @@ impl HeadlessServer {
let mut broken_clients: Vec<u64> = Vec::new();
for (client_id, (cols, rows), cell_size, _is_foreground, mode) in render_targets {
+ #[cfg(unix)]
+ if matches!(mode, ClientConnectionMode::TerminalObserve { .. })
+ && self
+ .clients
+ .get(&client_id)
+ .is_some_and(|client| client.deferred_render() != DeferredRender::None)
+ {
+ // The writer-drained event schedules recovery, even if pane output stops.
+ continue;
+ }
let area = Rect::new(0, 0, cols, rows);
let shell_target = self.shell_target_for_client(client_id);
let shell_tab_id = self.shell_tab_id_for_client(client_id);
diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs
index 2c13dc8b..bb196be5 100644
--- a/src/server/headless/tests/mod.rs
+++ b/src/server/headless/tests/mod.rs
@@ -3509,6 +3509,53 @@ fn terminal_attach_disconnect_restores_client_shell_pane_size() {
rt.shutdown_timeout(Duration::from_millis(100));
}
+#[cfg(unix)]
+#[test]
+fn backpressured_observer_skips_runtime_access_and_recovers_without_new_output() {
+ with_terminal_session_test_server(|server, terminal_id, target, _| {
+ let (writer, control, frames) = test_client_writer();
+ server.handle_server_event(ServerEvent::ClientConnected {
+ client_id: 7,
+ cols: 80,
+ rows: 24,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ pixel_mouse: false,
+ writer,
+ });
+ server.handle_server_event(ServerEvent::ClientObserveTerminal {
+ client_id: 7,
+ target,
+ });
+ server.render_and_stream();
+ server.app.terminal_runtimes.insert(
+ terminal_id.clone(),
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"LATEST"),
+ );
+ server.render_and_stream();
+ assert_eq!(server.clients[&7].deferred_render(), DeferredRender::Full);
+
+ // An absent runtime makes any attempted rendering observable without timing a lock.
+ let runtime = server.app.terminal_runtimes.remove(&terminal_id).unwrap();
+ server.render_and_stream();
+ server.app.terminal_runtimes.insert(terminal_id, runtime);
+ assert!(
+ server.clients.contains_key(&7),
+ "backpressure must skip runtime access"
+ );
+ assert!(control.try_recv().is_err());
+
+ let _ = frames.recv().expect("previously accepted frame");
+ assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 7 }));
+ server.render_and_stream();
+ let ServerMessage::Terminal(frame) = read_server_message(frames.recv().unwrap()) else {
+ panic!("terminal update");
+ };
+ assert!(String::from_utf8_lossy(&frame.bytes).contains("LATEST"));
+ assert_eq!(server.clients[&7].deferred_render(), DeferredRender::None);
+ });
+}
+
#[test]
fn terminal_observe_allows_multiple_clients_without_attach_ownership() {
with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| {