@@ -27,13 +27,29 @@ use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, PtySize};
2727use tokio:: sync:: { broadcast, mpsc} ;
2828use 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.
3040const PTY_OUTPUT_BROADCAST_CAPACITY : usize = 256 ;
3141const PTY_INPUT_CHANNEL_CAPACITY : usize = 64 ;
3242const 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`.
3646const 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+
804934fn pty_reader_loop (
805935 mut reader : Box < dyn Read + Send > ,
806936 output_tx : & broadcast:: Sender < PtyOutputEvent > ,
0 commit comments