Skip to content

Commit e1d4d8a

Browse files
Noam Lewisclaude
authored andcommitted
fix: prevent slow client from blocking server main loop on Windows named pipes
Add per-client background writer threads (ClientDataWriter) with bounded channels so render_and_broadcast() never blocks. Use try_lock in StreamWrapper::try_read() to avoid mutex contention with writer threads. Simplify tests to exercise the production code path instead of using test-specific drain thread workarounds. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2ab12fd commit e1d4d8a

3 files changed

Lines changed: 248 additions & 179 deletions

File tree

crates/fresh-editor/src/server/editor_server.rs

Lines changed: 95 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use std::io;
99
use std::path::PathBuf;
1010
use std::sync::atomic::{AtomicBool, Ordering};
11+
use std::sync::mpsc;
1112
use std::sync::Arc;
1213
use std::time::{Duration, Instant};
1314

@@ -22,7 +23,7 @@ use crate::server::capture_backend::{
2223
terminal_setup_sequences, terminal_teardown_sequences, CaptureBackend,
2324
};
2425
use crate::server::input_parser::InputParser;
25-
use crate::server::ipc::{ServerConnection, ServerListener, SocketPaths};
26+
use crate::server::ipc::{ServerConnection, ServerListener, SocketPaths, StreamWrapper};
2627
use crate::server::protocol::{
2728
ClientControl, ServerControl, ServerHello, TermSize, VersionMismatch, PROTOCOL_VERSION,
2829
};
@@ -60,9 +61,67 @@ pub struct EditorServer {
6061
last_input_client: Option<usize>,
6162
}
6263

64+
/// Buffered writer for sending data to a client without blocking the server loop.
65+
///
66+
/// Spawns a background thread that receives data via a bounded channel and
67+
/// writes it to the client's data pipe. If the channel fills up (client is
68+
/// too slow to read), frames are dropped. If the pipe breaks, the `pipe_broken`
69+
/// flag is set so the main loop can disconnect the client.
70+
struct ClientDataWriter {
71+
sender: mpsc::SyncSender<Vec<u8>>,
72+
pipe_broken: Arc<AtomicBool>,
73+
}
74+
75+
impl ClientDataWriter {
76+
/// Create a new writer that spawns a background thread to write to the data stream.
77+
fn new(data: StreamWrapper, client_id: u64) -> Self {
78+
// 16 frames of buffer (~270ms at 60fps before dropping frames)
79+
let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(16);
80+
let pipe_broken = Arc::new(AtomicBool::new(false));
81+
let pipe_broken_clone = pipe_broken.clone();
82+
83+
std::thread::Builder::new()
84+
.name(format!("client-{}-writer", client_id))
85+
.spawn(move || {
86+
while let Ok(buf) = rx.recv() {
87+
if let Err(e) = data.write_all(&buf) {
88+
tracing::debug!("Client {} writer pipe error: {}", client_id, e);
89+
pipe_broken_clone.store(true, Ordering::Relaxed);
90+
break;
91+
}
92+
if let Err(e) = data.flush() {
93+
tracing::debug!("Client {} writer flush error: {}", client_id, e);
94+
pipe_broken_clone.store(true, Ordering::Relaxed);
95+
break;
96+
}
97+
}
98+
tracing::debug!("Client {} writer thread exiting", client_id);
99+
})
100+
.expect("Failed to spawn client writer thread");
101+
102+
Self {
103+
sender: tx,
104+
pipe_broken,
105+
}
106+
}
107+
108+
/// Try to send data without blocking. Returns false if the channel is full
109+
/// (client too slow) or the writer thread has exited.
110+
fn try_write(&self, data: &[u8]) -> bool {
111+
self.sender.try_send(data.to_vec()).is_ok()
112+
}
113+
114+
/// Check if the writer thread detected a broken pipe.
115+
fn is_broken(&self) -> bool {
116+
self.pipe_broken.load(Ordering::Relaxed)
117+
}
118+
}
119+
63120
/// A connected client with its own input parser
64121
struct ConnectedClient {
65122
conn: ServerConnection,
123+
/// Background writer for non-blocking data output
124+
data_writer: ClientDataWriter,
66125
term_size: TermSize,
67126
env: std::collections::HashMap<String, Option<String>>,
68127
id: u64,
@@ -214,7 +273,7 @@ impl EditorServer {
214273
tracing::info!("Client {} requested detach", self.clients[idx].id);
215274
let client = self.clients.remove(idx);
216275
let teardown = terminal_teardown_sequences();
217-
let _ = client.conn.write_data(&teardown);
276+
let _ = client.data_writer.try_write(&teardown);
218277
let quit_msg = serde_json::to_string(&ServerControl::Quit {
219278
reason: "Detached".to_string(),
220279
})
@@ -406,8 +465,12 @@ impl EditorServer {
406465
hello.term()
407466
);
408467

468+
// Create background writer for non-blocking render output
469+
let data_writer = ClientDataWriter::new(conn.data.clone(), client_id);
470+
409471
Ok(ConnectedClient {
410472
conn,
473+
data_writer,
411474
term_size: hello.term_size,
412475
env: hello.env,
413476
id: client_id,
@@ -585,12 +648,24 @@ impl EditorServer {
585648
}
586649
}
587650

651+
// Check for clients with broken write pipes
652+
for (idx, client) in self.clients.iter().enumerate() {
653+
if client.data_writer.is_broken() && !disconnected.contains(&idx) {
654+
tracing::info!("Client {} write pipe broken, disconnecting", client.id);
655+
disconnected.push(idx);
656+
}
657+
}
658+
659+
// Deduplicate and sort for safe reverse removal
660+
disconnected.sort_unstable();
661+
disconnected.dedup();
662+
588663
// Remove disconnected clients
589664
for idx in disconnected.into_iter().rev() {
590665
let client = self.clients.remove(idx);
591-
// Send teardown sequences
666+
// Best-effort teardown via the non-blocking writer
592667
let teardown = terminal_teardown_sequences();
593-
let _ = client.conn.write_data(&teardown);
668+
let _ = client.data_writer.try_write(&teardown);
594669
tracing::info!("Client {} disconnected", client.id);
595670
// Invalidate input source if that client disconnected
596671
if input_source_client == Some(idx) {
@@ -684,21 +759,22 @@ impl EditorServer {
684759
return Ok(());
685760
}
686761

687-
// Broadcast to all clients (pending sequences first, then rendered output)
762+
// Broadcast to all clients via non-blocking writer threads
688763
for client in &mut self.clients {
689-
if !pending_sequences.is_empty() {
690-
if let Err(e) = client.conn.write_data(&pending_sequences) {
691-
tracing::warn!(
692-
"Failed to send pending sequences to client {}: {}",
693-
client.id,
694-
e
695-
);
696-
}
697-
}
698-
if !output.is_empty() {
699-
if let Err(e) = client.conn.write_data(&output) {
700-
tracing::warn!("Failed to send to client {}: {}", client.id, e);
701-
}
764+
// Combine pending sequences and output into a single frame
765+
let frame = if !pending_sequences.is_empty() && !output.is_empty() {
766+
let mut combined = Vec::with_capacity(pending_sequences.len() + output.len());
767+
combined.extend_from_slice(&pending_sequences);
768+
combined.extend_from_slice(&output);
769+
combined
770+
} else if !pending_sequences.is_empty() {
771+
pending_sequences.clone()
772+
} else {
773+
output.clone()
774+
};
775+
776+
if !frame.is_empty() && !client.data_writer.try_write(&frame) {
777+
tracing::warn!("Client {} output buffer full, dropping frame", client.id);
702778
}
703779
// Clear full render flag after sending
704780
client.needs_full_render = false;
@@ -711,7 +787,7 @@ impl EditorServer {
711787
fn disconnect_all_clients(&mut self, reason: &str) -> io::Result<()> {
712788
let teardown = terminal_teardown_sequences();
713789
for client in &mut self.clients {
714-
let _ = client.conn.write_data(&teardown);
790+
let _ = client.data_writer.try_write(&teardown);
715791
let quit_msg = serde_json::to_string(&ServerControl::Quit {
716792
reason: reason.to_string(),
717793
})

crates/fresh-editor/src/server/ipc/mod.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -300,12 +300,20 @@ impl StreamWrapper {
300300
Write::flush(&mut *guard)
301301
}
302302

303-
/// Try to read without blocking (returns WouldBlock if no data)
303+
/// Try to read without blocking (returns WouldBlock if no data or if mutex is contended)
304304
pub fn try_read(&self, buf: &mut [u8]) -> io::Result<usize> {
305-
let mut guard = self
306-
.0
307-
.lock()
308-
.map_err(|_| io::Error::new(io::ErrorKind::Other, "mutex poisoned"))?;
305+
let mut guard = match self.0.try_lock() {
306+
Ok(g) => g,
307+
Err(std::sync::TryLockError::WouldBlock) => {
308+
return Err(io::Error::new(
309+
io::ErrorKind::WouldBlock,
310+
"stream busy (mutex contended)",
311+
));
312+
}
313+
Err(std::sync::TryLockError::Poisoned(_)) => {
314+
return Err(io::Error::new(io::ErrorKind::Other, "mutex poisoned"));
315+
}
316+
};
309317

310318
platform::try_read_nonblocking(&mut *guard, buf)
311319
}

0 commit comments

Comments
 (0)