@@ -45,6 +45,9 @@ const DETAIL_COLS: u16 = 120;
4545/// poll cadence, not a fixed readiness sleep: every wait has a deadline and
4646/// returns the instant its condition holds.
4747const POLL_INTERVAL : Duration = Duration :: from_millis ( 20 ) ;
48+ /// Maximum time to let the PTY reader consume the child's final frame after the
49+ /// process exits. This is bounded so a misbehaving PTY cannot stall a scenario.
50+ const DRAIN_TIMEOUT : Duration = Duration :: from_millis ( 250 ) ;
4851
4952/// Default wall-clock budget for a single wait. Generous enough for a cold dash
5053/// start plus the embedded-daemon connect, while still turning a genuine hang
@@ -108,7 +111,7 @@ impl TuiSession {
108111 for ( key, value) in std:: env:: vars_os ( ) {
109112 cmd. env ( key, value) ;
110113 }
111- for ( key, value) in world. isolate_env ( ) {
114+ for ( key, value) in world. isolate_env ( ) . into_iter ( ) . chain ( world . pty_env ( ) ) {
112115 cmd. env ( key, value) ;
113116 }
114117 // Deterministic terminal type; the PTY ioctl size above is authoritative
@@ -157,12 +160,21 @@ impl TuiSession {
157160 /// already resolved escape sequences and styling into cells, so this is
158161 /// exactly what a user sees — color/attribute independent.
159162 pub fn screen_text ( & self ) -> String {
163+ self . screen_snapshot ( ) . 0
164+ }
165+
166+ fn screen_snapshot ( & self ) -> ( String , ( u16 , u16 ) ) {
160167 self . parser
161168 . lock ( )
162- . map ( |p| p. screen ( ) . contents ( ) )
169+ . map ( |p| ( p. screen ( ) . contents ( ) , p . screen ( ) . size ( ) ) )
163170 . unwrap_or_default ( )
164171 }
165172
173+ fn framed_screen ( & self ) -> String {
174+ let ( screen, ( rows, cols) ) = self . screen_snapshot ( ) ;
175+ format ! ( "--- last screen ({cols}x{rows}) ---\n {screen}\n --- end screen ---" )
176+ }
177+
166178 /// Resize both the real PTY and the emulated screen. The application receives
167179 /// the normal terminal resize event; assertions continue to inspect exactly
168180 /// what a user would see at the new geometry.
@@ -201,24 +213,41 @@ impl TuiSession {
201213 if self . screen_text ( ) . contains ( marker) {
202214 return Ok ( ( ) ) ;
203215 }
204- // If the process is gone, give the reader a beat to drain any final
205- // bytes, then check once more before declaring failure.
216+ // If the process is gone, let the reader drain the final frame for a
217+ // short bounded window. A single poll is not enough when a large frame
218+ // is still buffered behind the process exit notification.
206219 if let Ok ( Some ( status) ) = self . child . try_wait ( ) {
207220 self . finished = true ;
208- tokio:: time:: sleep ( POLL_INTERVAL ) . await ;
209- let screen = self . screen_text ( ) ;
210- if screen. contains ( marker) {
221+ self . record_once ( i32:: try_from ( status. exit_code ( ) ) . unwrap_or ( -1 ) ) ;
222+ let drain_deadline = Instant :: now ( ) + DRAIN_TIMEOUT ;
223+ while Instant :: now ( ) < drain_deadline {
224+ if self . screen_text ( ) . contains ( marker) {
225+ return Ok ( ( ) ) ;
226+ }
227+ if self
228+ . reader
229+ . as_ref ( )
230+ . is_some_and ( std:: thread:: JoinHandle :: is_finished)
231+ {
232+ break ;
233+ }
234+ tokio:: time:: sleep ( POLL_INTERVAL ) . await ;
235+ }
236+ // Final check after the drain window closes: the reader may have
237+ // committed the last frame between the loop's screen check and the
238+ // `is_finished`/deadline exit, so re-read before declaring failure.
239+ if self . screen_text ( ) . contains ( marker) {
211240 return Ok ( ( ) ) ;
212241 }
213242 return Err ( format ! (
214243 "process exited ({status:?}) before {marker:?} appeared.\n {}" ,
215- framed_screen( & screen )
244+ self . framed_screen( )
216245 ) ) ;
217246 }
218247 if Instant :: now ( ) >= deadline {
219248 return Err ( format ! (
220249 "timed out after {timeout:?} waiting for {marker:?}.\n {}" ,
221- framed_screen ( & self . screen_text ( ) )
250+ self . framed_screen ( )
222251 ) ) ;
223252 }
224253 tokio:: time:: sleep ( POLL_INTERVAL ) . await ;
@@ -250,7 +279,7 @@ impl TuiSession {
250279 } else {
251280 Err ( format ! (
252281 "TUI exited unsuccessfully ({status:?}).\n {}" ,
253- framed_screen ( & self . screen_text ( ) )
282+ self . framed_screen ( )
254283 ) )
255284 } ;
256285 }
@@ -260,7 +289,7 @@ impl TuiSession {
260289 if Instant :: now ( ) >= deadline {
261290 return Err ( format ! (
262291 "timed out after {timeout:?} waiting for the TUI to exit.\n {}" ,
263- framed_screen ( & self . screen_text ( ) )
292+ self . framed_screen ( )
264293 ) ) ;
265294 }
266295 tokio:: time:: sleep ( POLL_INTERVAL ) . await ;
@@ -288,8 +317,14 @@ impl Drop for TuiSession {
288317 // without an explicit quit (e.g. the consent-gate scenarios).
289318 if !self . finished {
290319 let _ = self . child . kill ( ) ;
291- let _ = self . child . wait ( ) ;
320+ let rc = self
321+ . child
322+ . wait ( )
323+ . ok ( )
324+ . and_then ( |status| i32:: try_from ( status. exit_code ( ) ) . ok ( ) )
325+ . unwrap_or ( -1 ) ;
292326 self . finished = true ;
327+ self . record_once ( rc) ;
293328 }
294329 self . reader_stop . store ( true , Ordering :: Relaxed ) ;
295330 if let Some ( handle) = self . reader . take ( ) {
@@ -327,8 +362,3 @@ fn spawn_reader(
327362 }
328363 } )
329364}
330-
331- /// Wrap a screen dump in delimiters so failure messages are easy to read.
332- fn framed_screen ( screen : & str ) -> String {
333- format ! ( "--- last screen ({COLS}x{ROWS}) ---\n {screen}\n --- end screen ---" )
334- }
0 commit comments