Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Attached clients no longer get detached at random under heavy session output, which showed up on macOS. Frames reach the client over a relay socket whose writes are bounded at five seconds, and three consecutive timeouts are read as "this client is wedged" and answered with a forced detach. Nothing was wedged: macOS gives a unix socket an 8 KiB buffer where Linux gives ~208 KiB, small enough that a single frame can fill it, so a burst from a chatty session outran the emulator's drain and the server disowned a perfectly healthy terminal. Both ends now ask for a 512 KiB socket buffer, the server buffers a whole frame before writing and the client drains in 64 KiB reads, a write-timeout reported as `ETIMEDOUT` (as BSDs do) is recognized as slowness rather than going straight to a detach, and the stall budget is a minute instead of fifteen seconds — a client that has actually gone away is still caught immediately by EOF on the relay.

- The planning agent no longer re-litigates the thread's scope root on every turn. The root is chosen once, when the thread is created, and nothing in the UI moves it afterwards — so an agent that opens each reply by judging whether the directory suits the work is spending the turn on the one thing the engineer cannot act on, and has already read. The system prompt now states that the root is fixed and off the table, and a thread with any history is told that what it has already established stands and should not be restated.

- The planning pane keeps your message on screen while the backend answers it. The turn task owns the thread and only writes it on completion, so between pressing Enter and the reply landing the text existed nowhere the pane drew: cleared from the input, not yet in the transcript, with only the thinking spinner where it had been. It read as though the pane had swallowed the message, or errored. The in-flight message is now shown as a normal `you` turn until the real one is loaded back from disk — and it is dropped, not duplicated, when the reply arrives or the draft is returned to the input after a failure.
Expand Down
27 changes: 23 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ async fn run_server() -> anyhow::Result<()> {
// one frame per write-timeout forever and reject new
// reattach attempts as "already attached".
relay_write_failures += 1;
if headless || relay_write_failures < 3 {
if headless || relay_write_failures < RELAY_STALL_LIMIT {
continue;
}
} else if headless {
Expand Down Expand Up @@ -559,11 +559,26 @@ fn send_restore_sequences(writer_handle: &Arc<Mutex<WriterBox>>, kitty: bool) {
let _ = w.flush();
}

/// Consecutive 5-second relay write timeouts before the server gives up on an
/// attached client and drops to headless. A client that has actually gone away
/// is caught much sooner by the relay reader hitting EOF, so this only governs
/// the rare *stalled but connected* case — being patient here costs nothing and
/// avoids yanking the terminal away from someone whose emulator merely fell
/// behind a heavy output burst.
const RELAY_STALL_LIMIT: u32 = 12;

fn is_transient_terminal_error(error: &std::io::Error) -> bool {
matches!(
error.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
) || error.raw_os_error() == Some(libc::EAGAIN)
// A `set_write_timeout` expiry surfaces as EAGAIN on Linux but can come
// back as ETIMEDOUT on macOS/BSD; both mean "slow client", not "dead".
std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::Interrupted
| std::io::ErrorKind::TimedOut
) || matches!(
error.raw_os_error(),
Some(libc::EAGAIN) | Some(libc::ETIMEDOUT)
)
}

fn handle_event(app: &mut App, event: AppEvent) {
Expand Down Expand Up @@ -1552,6 +1567,7 @@ async fn do_reattach(
return None;
};
let _ = std_stream.set_nonblocking(false); // writer clone must be blocking
reattach::widen_socket_buffers(&std_stream);
let Ok(writer_clone) = std_stream.try_clone() else {
return None;
};
Expand All @@ -1565,7 +1581,10 @@ async fn do_reattach(
};

// Point the ratatui backend at the relay socket.
*writer_handle.lock().unwrap() = Box::new(std::io::BufWriter::new(writer_clone));
// 64 KiB so a whole frame usually reaches the kernel in one write syscall
// rather than eight 8 KiB chunks, each of which can block separately.
*writer_handle.lock().unwrap() =
Box::new(std::io::BufWriter::with_capacity(64 * 1024, writer_clone));

// Re-issue terminal init sequences to the relay client: enter alternate
// screen and re-enable mouse capture so the relay terminal is ready.
Expand Down
33 changes: 32 additions & 1 deletion src/reattach.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
use tokio::sync::mpsc;

/// Bytes of kernel socket buffer we ask for on both ends of the relay.
///
/// The default unix-socket buffer is generous on Linux (~208 KiB) but tiny on
/// macOS/BSD (`net.local.stream.{send,recv}space`, 8 KiB). A single ratatui
/// frame for a wide terminal can exceed that on its own, so a burst of PTY
/// output would fill the pipe faster than a slow terminal emulator drains it,
/// stall the server's bounded relay writes, and trip the "client is wedged"
/// escalation into an involuntary detach. Widening the buffer keeps bursts in
/// the kernel where they belong.
const RELAY_SOCK_BUF: libc::c_int = 512 * 1024;

/// Best-effort widening of a socket's send/receive buffers. Failure is fine:
/// the kernel clamps to `kern.ipc.maxsockbuf` and we simply keep the default.
pub fn widen_socket_buffers<F: std::os::unix::io::AsRawFd>(sock: &F) {
let fd = sock.as_raw_fd();
for opt in [libc::SO_SNDBUF, libc::SO_RCVBUF] {
unsafe {
libc::setsockopt(
fd,
libc::SOL_SOCKET,
opt,
&RELAY_SOCK_BUF as *const _ as *const libc::c_void,
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
);
}
}
}

// ── SwappableWriter ────────────────────────────────────────────────────────
// Allows the ratatui terminal backend to be redirected from stdout to a
// relay socket at runtime, without reconstructing the Terminal object.
Expand Down Expand Up @@ -368,6 +396,7 @@ pub async fn run_relay_client(id: &str) -> anyhow::Result<()> {
e
)
})?;
widen_socket_buffers(&stream);
let (read_half, mut write_half) = stream.into_split();

// Handshake: tell the server our terminal dimensions.
Expand Down Expand Up @@ -423,7 +452,9 @@ pub async fn run_relay_client(id: &str) -> anyhow::Result<()> {
let done_tx2 = done_tx.clone();
tokio::spawn(async move {
let mut reader = read_half;
let mut buf = vec![0u8; 4096];
// Drain in large gulps: the faster this side empties the socket, the
// less the server's bounded writes stall behind a slow emulator.
let mut buf = vec![0u8; 64 * 1024];
let mut stdout = tokio::io::stdout();
loop {
match reader.read(&mut buf).await {
Expand Down
Loading