Skip to content

Commit a0ffaee

Browse files
authored
Merge pull request #332 from charliek/feature/plan-024-pty-exit
fix(engine): the reader publishes Exit, so trailing output can't be lost (#255)
2 parents bbe6b42 + d371824 commit a0ffaee

5 files changed

Lines changed: 485 additions & 108 deletions

File tree

crates/roost-engine/src/pty.rs

Lines changed: 190 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,29 @@ use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, PtySize};
2727
use tokio::sync::{broadcast, mpsc};
2828
use tracing::{debug, error, warn};
2929

30+
/// Depth of a tab's output fan-out. A consumer that falls this far
31+
/// behind gets `RecvError::Lagged` and the skipped bytes are gone for
32+
/// good — a *second*, independent way a tab's output can be truncated,
33+
/// unrelated to the exit ordering fixed in #255 (that one is about
34+
/// ordering, this one about capacity). Deliberately left alone:
35+
/// closing it means resizing or redesigning the channel, and the only
36+
/// production consumer (`session.rs`) forwards straight onto an
37+
/// unbounded mpsc, so it can only lag if the UI's drain stalls. When it
38+
/// does, `session.rs` reports it as `TabOutput::Error` rather than
39+
/// silently swallowing it.
3040
const PTY_OUTPUT_BROADCAST_CAPACITY: usize = 256;
3141
const PTY_INPUT_CHANNEL_CAPACITY: usize = 64;
3242
const PTY_OUTPUT_CHUNK_SIZE: usize = 4096;
3343
/// Grace period after SIGHUP before `close()` escalates to SIGKILL.
3444
/// Matches the Mac side's 20×10ms teardown window in
3545
/// `PtySupervisor.swift`.
3646
const KILL_GRACE: Duration = Duration::from_millis(200);
47+
/// How long each side of the exit handshake waits on the other before
48+
/// publishing `Exit` itself (#255). Long enough that the normal path —
49+
/// the reader hitting EOF within microseconds of the child being
50+
/// reaped — always wins; short enough that a tab whose reader never
51+
/// EOFs still reports its exit promptly.
52+
const EXIT_PUBLISH_GRACE: Duration = Duration::from_millis(250);
3753

3854
/// What a subscriber gets back from `PtySupervisor::subscribe`.
3955
#[derive(Debug, Clone)]
@@ -44,7 +60,12 @@ pub enum PtyOutputEvent {
4460
/// per-frame chunks are small and the broadcast clone is cheap
4561
/// enough at the workloads roost runs).
4662
Bytes(Vec<u8>),
47-
/// PTY child exited with this status.
63+
/// PTY child exited with this status. Published by the reader task
64+
/// after the last `Bytes` it read, so a consumer that stops here
65+
/// has the tab's complete output (#255). The one exception is the
66+
/// bounded fallback described on `PtySupervisor::spawn`: a reader
67+
/// that never reaches EOF gets `Exit` published out from under it
68+
/// on a deadline.
4869
Exit(i32),
4970
}
5071

@@ -179,6 +200,27 @@ impl PtySupervisor {
179200
/// into the child as `ROOST_SOCKET` so `roostctl` invoked from
180201
/// inside the tab dials the right UI.
181202
///
203+
/// Exit ordering (#255): the reader task publishes `Exit`, after
204+
/// the last `Bytes` it read. One producer makes "every byte, then
205+
/// the exit" structural instead of a race between the reader and
206+
/// the reap task — the shape that used to drop a shell's final
207+
/// output. The reap task hands the status over and then waits for
208+
/// the reader to finish, but only for `EXIT_PUBLISH_GRACE`: a
209+
/// reader can legitimately never reach EOF (a background
210+
/// descendant holding the slave fd keeps the master readable
211+
/// forever), and an unbounded wait would mean the tab never
212+
/// reports its exit. Past the deadline the reap task publishes
213+
/// `Exit` itself, and bytes may still follow it — the one
214+
/// documented exception to the ordering guarantee. Whichever side
215+
/// gets there first, `publish_exit_once` makes it exactly one
216+
/// `Exit`.
217+
///
218+
/// Session lifetime: the session is installed before the reap task
219+
/// starts, and the reap task removes it before it reports the exit
220+
/// on either channel. So a session always has a waiter that will
221+
/// take it back out, and by the time a consumer sees `Exit` (or
222+
/// `TabExited`) the tab is already gone from the map.
223+
///
182224
/// Errors:
183225
/// * [`PtyError::DuplicateTab`] — `tab_id` already has a live
184226
/// session. Caller must `close()` the prior session first.
@@ -293,10 +335,40 @@ impl PtySupervisor {
293335

294336
let master = pair.master;
295337

296-
// Reader: blocking read off the master fd, push to broadcast.
338+
// The two halves of the exit handshake (#255). `status_*`
339+
// carries the reaped status to the reader so it can publish
340+
// `Exit` after its final `Bytes`; `reader_alive_*` carries
341+
// nothing — the reap task's wait ends on the reader task
342+
// dropping its sender, which is precisely "the reader is
343+
// done". `exit_published` keeps the two sides to one `Exit`.
344+
let (status_tx, status_rx) = std::sync::mpsc::channel::<i32>();
345+
let (reader_alive_tx, reader_alive_rx) = std::sync::mpsc::channel::<()>();
346+
let exit_published = Arc::new(AtomicBool::new(false));
347+
348+
// Reader: blocking read off the master fd, push to broadcast,
349+
// then publish the exit.
297350
tokio::task::spawn_blocking({
298351
let output_tx = output_tx.clone();
299-
move || pty_reader_loop(reader_handle, &output_tx, tab_id)
352+
let exit_published = exit_published.clone();
353+
move || {
354+
let _reader_alive = reader_alive_tx;
355+
pty_reader_loop(reader_handle, &output_tx, tab_id);
356+
// EOF: everything the PTY produced is on the channel,
357+
// so `Exit` published from here can only follow it.
358+
// The status normally lands within microseconds (the
359+
// reap task's `waitpid` is already blocked when the
360+
// child dies); if it doesn't, the reap task publishes
361+
// once it does, still after this EOF.
362+
match status_rx.recv_timeout(EXIT_PUBLISH_GRACE) {
363+
Ok(status) => {
364+
publish_exit_once(&output_tx, &exit_published, status);
365+
}
366+
Err(_) => debug!(
367+
tab_id,
368+
"pty reader finished before the child was reaped; reap task publishes Exit"
369+
),
370+
}
371+
}
300372
});
301373

302374
// Writer + resizer: a single ordered loop over the unified
@@ -323,13 +395,59 @@ impl PtySupervisor {
323395
debug!(tab_id, "pty input loop ended");
324396
});
325397

326-
// Wait for the child to exit; forward the exit status onto
327-
// the output channel AND the lifecycle channel so both
328-
// per-tab consumers and the workspace converge.
329398
let output_tx_exit = output_tx.clone();
330399
let lifecycle_tx = self.lifecycle.clone();
331400
let sessions_for_reap = self.sessions.clone();
332401
let reaped_for_wait = reaped.clone();
402+
let exit_published_for_wait = exit_published.clone();
403+
404+
let session = Session {
405+
cmd_tx,
406+
output_tx,
407+
killer: Mutex::new(killer),
408+
pid,
409+
reaped,
410+
initial_rx: Some(initial_rx),
411+
};
412+
// Promote the slot from pending → sessions atomically, BEFORE
413+
// the reap task exists (see below).
414+
//
415+
// If `close(tab_id)` ran while we were building the PTY it
416+
// removed our entry from `pending` as a cancellation signal.
417+
// Detect that here and hand the session back instead of
418+
// installing it; the caller-visible teardown happens after the
419+
// reap task is started, so the child is still reaped.
420+
let cancelled = {
421+
let mut sessions = self.sessions.lock().unwrap();
422+
let mut pending = self.pending.lock().unwrap();
423+
if pending.remove(&tab_id) {
424+
sessions.insert(tab_id, session);
425+
None
426+
} else {
427+
Some(session)
428+
}
429+
};
430+
// Either branch consumed the pending entry (ours, or the one
431+
// `close()` already took), so the guard has nothing left to do.
432+
slot.armed = false;
433+
434+
// Wait for the child to exit; hand the status to the reader
435+
// task (which publishes it onto the output channel) and send
436+
// it on the lifecycle channel so both per-tab consumers and
437+
// the workspace converge.
438+
//
439+
// Started only now that the promotion has run, because this
440+
// task's identity-checked removal is the ONLY thing that ever
441+
// takes the session back out. Starting it earlier meant a
442+
// child that exited during the promotion window got reaped
443+
// first: the removal found no session and removed nothing,
444+
// then the promotion installed a session whose child was
445+
// already dead — `has()` kept answering yes and `write()` kept
446+
// accepting input for a PTY nobody was reading. Ordering it
447+
// after the insert makes "a reaped child leaves no session"
448+
// structural. It also means no `Exit` can be published before
449+
// the session is reachable: the reader only publishes once
450+
// this task hands it a status.
333451
tokio::task::spawn_blocking(move || {
334452
let status = match child.wait() {
335453
Ok(status) => status.exit_code() as i32,
@@ -339,66 +457,58 @@ impl PtySupervisor {
339457
}
340458
};
341459
// Mark reaped first so a concurrent `close()` SIGKILL
342-
// watchdog stands down, then publish exit and drop the
343-
// dead session so later writes get `NotFound` instead of
344-
// silently succeeding against a closed PTY.
460+
// watchdog stands down, then drop the dead session so
461+
// later writes get `NotFound` instead of silently
462+
// succeeding against a closed PTY — and only then tell
463+
// anyone the child exited. Removing ahead of both the
464+
// status handoff and `TabExited` means the tab is already
465+
// unreachable by the time either channel reports the exit,
466+
// so a consumer reacting to `Exit` can never find a live
467+
// session for a dead child.
345468
reaped_for_wait.store(true, Ordering::SeqCst);
346-
let _ = output_tx_exit.send(PtyOutputEvent::Exit(status));
469+
{
470+
// Only remove the session if THIS waiter still owns it.
471+
// `close()` frees the slot synchronously, so the same
472+
// tab_id can be re-spawned before a stale waiter fires;
473+
// matching the per-spawn `reaped` identity prevents
474+
// evicting a newer live session (#80). Scoped so the
475+
// deadline wait below never holds the sessions lock.
476+
let mut sessions = sessions_for_reap.lock().unwrap();
477+
let owns = sessions
478+
.get(&tab_id)
479+
.map(|s| Arc::ptr_eq(&s.reaped, &reaped_for_wait))
480+
.unwrap_or(false);
481+
if owns {
482+
sessions.remove(&tab_id);
483+
}
484+
}
485+
let _ = status_tx.send(status);
347486
let _ = lifecycle_tx.send(SupervisorEvent::TabExited { tab_id, status });
348-
// Only remove the session if THIS waiter still owns it.
349-
// `close()` frees the slot synchronously, so the same
350-
// tab_id can be re-spawned before a stale waiter fires;
351-
// matching the per-spawn `reaped` identity prevents
352-
// evicting a newer live session (#80).
353-
let mut sessions = sessions_for_reap.lock().unwrap();
354-
let owns = sessions
355-
.get(&tab_id)
356-
.map(|s| Arc::ptr_eq(&s.reaped, &reaped_for_wait))
357-
.unwrap_or(false);
358-
if owns {
359-
sessions.remove(&tab_id);
487+
// Backstop for a reader that never reaches EOF (#255).
488+
// Ends as soon as the reader task drops its sender —
489+
// by then it has published `Exit` and this is a no-op —
490+
// or on the deadline, when publishing here is the only
491+
// way the tab ever reports its exit.
492+
let _ = reader_alive_rx.recv_timeout(EXIT_PUBLISH_GRACE);
493+
if publish_exit_once(&output_tx_exit, &exit_published_for_wait, status) {
494+
debug!(
495+
tab_id,
496+
"pty reader had not finished; published Exit on the deadline path"
497+
);
360498
}
361499
});
362500

363-
let session = Session {
364-
cmd_tx,
365-
output_tx,
366-
killer: Mutex::new(killer),
367-
pid,
368-
reaped,
369-
initial_rx: Some(initial_rx),
370-
};
371-
// Promote the slot from pending → sessions atomically.
372-
// If `close(tab_id)` ran while we were building the PTY it
373-
// removed our entry from `pending` as a cancellation
374-
// signal. Detect that here, kill the freshly-spawned
375-
// child, and don't insert into `sessions`. The killer was
376-
// moved into the wait task already, so we reach for the
377-
// copy we stashed in `session` below — actually the
378-
// session struct already holds the killer, so we tear it
379-
// back down via `terminate_child` (SIGHUP→SIGKILL) and drop
380-
// `session` (which drops the input/resize channels, the
381-
// writer task exits, and the wait task reaps once the
382-
// signal lands).
383-
{
384-
let mut sessions = self.sessions.lock().unwrap();
385-
let mut pending = self.pending.lock().unwrap();
386-
if !pending.remove(&tab_id) {
387-
// Cancelled by close(). Kill the child rather than
388-
// returning a usable receiver.
389-
drop(pending);
390-
drop(sessions);
391-
terminate_child(&session.killer, session.pid, session.reaped.clone(), tab_id);
392-
drop(session);
393-
// SlotGuard is no longer needed — pending was
394-
// already cleared by close(); we already cleaned
395-
// up the child.
396-
slot.armed = false;
397-
return Err(PtyError::Cancelled(tab_id).into());
398-
}
399-
sessions.insert(tab_id, session);
501+
if let Some(session) = cancelled {
502+
// Cancelled by close(). Kill the child rather than
503+
// returning a usable receiver: `terminate_child` sends
504+
// SIGHUP (SIGKILL on the watchdog), and the reap task
505+
// started above reaps whatever the signal lands on.
506+
// Dropping `session` drops the input/resize channels, so
507+
// the writer task exits too.
508+
terminate_child(&session.killer, session.pid, session.reaped.clone(), tab_id);
509+
drop(session);
510+
return Err(PtyError::Cancelled(tab_id).into());
400511
}
401-
slot.armed = false;
402512

403513
Ok(early_rx)
404514
}
@@ -801,6 +911,26 @@ fn build_command(
801911
cmd
802912
}
803913

914+
/// Publish a tab's `Exit` event, at most once per spawn. Both the
915+
/// reader task (the normal path) and the reap task's deadline backstop
916+
/// call this; the compare-exchange decides which one gets to send, so
917+
/// a consumer never sees two exits for one child. Returns whether this
918+
/// call was the one that published.
919+
fn publish_exit_once(
920+
output_tx: &broadcast::Sender<PtyOutputEvent>,
921+
published: &AtomicBool,
922+
status: i32,
923+
) -> bool {
924+
if published
925+
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
926+
.is_err()
927+
{
928+
return false;
929+
}
930+
let _ = output_tx.send(PtyOutputEvent::Exit(status));
931+
true
932+
}
933+
804934
fn pty_reader_loop(
805935
mut reader: Box<dyn Read + Send>,
806936
output_tx: &broadcast::Sender<PtyOutputEvent>,

crates/roost-engine/src/session.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,34 @@ impl TabSession {
8989
break;
9090
}
9191
}
92+
// Stopping here is safe on the normal path: the
93+
// supervisor's reader task publishes `Exit` after
94+
// the last bytes it read, so nothing is left
95+
// behind (#255).
96+
//
97+
// The exception is `pty.rs`'s bounded deadline
98+
// fallback. A reader that never reaches EOF — a
99+
// background descendant holding the slave fd keeps
100+
// the master readable forever — has `Exit`
101+
// published out from under it after
102+
// `EXIT_PUBLISH_GRACE`, and the bytes it reads
103+
// afterwards are dropped by the `break` below.
104+
// That is the deliberate trade: a tab that never
105+
// reports its exit would never auto-close.
92106
Ok(PtyOutputEvent::Exit(status)) => {
93107
let _ = output_tx.send(TabOutput::Exit {
94108
status,
95109
reason: String::new(),
96110
});
97111
break;
98112
}
113+
// The other way a tab's output can be truncated,
114+
// independent of the #255 ordering fix: this drain
115+
// fell far enough behind that the broadcast
116+
// dropped `n` messages. Out of scope there —
117+
// fixing it means resizing or redesigning the
118+
// channel (see `PTY_OUTPUT_BROADCAST_CAPACITY`).
119+
// Surfaced rather than swallowed.
99120
Err(RecvError::Lagged(n)) => {
100121
let _ = output_tx.send(TabOutput::Error(format!(
101122
"broadcast lagged: dropped {n} message(s)"

crates/roost-engine/tests/pty_env_terminfo.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@ async fn inherited_terminfo_is_stripped_from_child_env() {
2424
.spawn(11, "/tmp", &["/usr/bin/env".into()], 80, 24, &socket)
2525
.expect("spawn");
2626

27-
// Same budget-bounded drain rationale as pty_smoke's
28-
// collect_until_closed: content assertions below are what prove the
29-
// capture wasn't truncated.
27+
// Same drain rationale as pty_smoke's collect_until_exit: `Exit`
28+
// arrives after the reader's last bytes (#255), and the content
29+
// assertions below are what prove the capture wasn't truncated.
3030
let deadline = Instant::now() + Duration::from_secs(5);
3131
let mut collected = Vec::new();
3232
let mut exit_status = None;
33-
while Instant::now() < deadline {
33+
while Instant::now() < deadline && exit_status.is_none() {
3434
match output.try_recv() {
3535
Ok(PtyOutputEvent::Bytes(bytes)) => collected.extend_from_slice(&bytes),
3636
Ok(PtyOutputEvent::Exit(status)) => exit_status = Some(status),

0 commit comments

Comments
 (0)