11use std:: io:: { BufRead , BufReader , Read , Write } ;
22use std:: path:: PathBuf ;
33use std:: process:: { Child , Command , ExitStatus , Stdio } ;
4- use std:: sync:: mpsc;
4+ use std:: sync:: { Arc , Mutex , mpsc} ;
55use std:: time:: { Duration , Instant , SystemTime , UNIX_EPOCH } ;
66
77use base64:: { Engine as _, engine:: general_purpose:: STANDARD } ;
@@ -28,7 +28,16 @@ impl TestDirectory {
2828
2929impl Drop for TestDirectory {
3030 fn drop ( & mut self ) {
31- std:: fs:: remove_dir_all ( & self . 0 ) . unwrap ( ) ;
31+ // Windows may briefly retain the copied fixture executable while
32+ // ConPTY exits. Never double-panic during a timeout's stack unwind.
33+ for _ in 0 ..20 {
34+ match std:: fs:: remove_dir_all ( & self . 0 ) {
35+ Ok ( ( ) ) => return ,
36+ Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: NotFound => return ,
37+ Err ( _) => std:: thread:: sleep ( Duration :: from_millis ( 50 ) ) ,
38+ }
39+ }
40+ let _ = writeln ! ( std:: io:: stderr( ) , "fixture cleanup failed: {:?}" , self . 0 ) ;
3241 }
3342}
3443
@@ -52,6 +61,15 @@ impl CoreChild {
5261
5362impl Drop for CoreChild {
5463 fn drop ( & mut self ) {
64+ // Closing input lets the core kill/reap its own PTY child and close
65+ // ConPTY before the executable's directory is removed.
66+ self . 0 . stdin . take ( ) ;
67+ for _ in 0 ..100 {
68+ if matches ! ( self . 0 . try_wait( ) , Ok ( Some ( _) ) ) {
69+ return ;
70+ }
71+ std:: thread:: sleep ( Duration :: from_millis ( 10 ) ) ;
72+ }
5573 let _ = self . 0 . kill ( ) ;
5674 let _ = self . 0 . wait ( ) ;
5775 }
@@ -106,6 +124,10 @@ fn child_fixture() {
106124 std:: io:: stdout ( ) . flush ( ) . unwrap ( ) ;
107125 std:: process:: exit ( 23 ) ;
108126 }
127+ // The protocol's ready frame means the PTY exists, not that its child has
128+ // finished console initialization. On ConPTY a cursor query can precede it.
129+ writeln ! ( std:: io:: stdout( ) . lock( ) , "fixture-ready" ) . unwrap ( ) ;
130+ std:: io:: stdout ( ) . flush ( ) . unwrap ( ) ;
109131 let stdin = std:: io:: stdin ( ) ;
110132 for line in stdin. lock ( ) . lines ( ) {
111133 // Input follows resize, so ConPTY cannot wrap the long path at its
@@ -119,6 +141,73 @@ fn child_fixture() {
119141 }
120142}
121143
144+ #[ derive( Default ) ]
145+ struct TerminalOutput {
146+ bytes : Vec < u8 > ,
147+ answered_cursor_queries : usize ,
148+ }
149+
150+ impl TerminalOutput {
151+ fn push ( & mut self , bytes : & [ u8 ] ) -> usize {
152+ self . bytes . extend_from_slice ( bytes) ;
153+ // portable-pty uses PSEUDOCONSOLE_INHERIT_CURSOR. A real terminal
154+ // answers CSI 6 n; ignoring it can deadlock ResizePseudoConsole.
155+ // Count over the accumulated bytes to handle split output frames.
156+ let queries = self
157+ . bytes
158+ . windows ( 4 )
159+ . filter ( |part| * part == b"\x1b [6n" )
160+ . count ( ) ;
161+ let pending = queries - self . answered_cursor_queries ;
162+ self . answered_cursor_queries = queries;
163+ pending
164+ }
165+
166+ fn contains ( & self , text : & str ) -> bool {
167+ self . bytes
168+ . windows ( text. len ( ) )
169+ . any ( |part| part == text. as_bytes ( ) )
170+ }
171+ }
172+
173+ fn write_request ( input : & mut impl Write , request : Value ) {
174+ writeln ! ( input, "{request}" ) . unwrap ( ) ;
175+ input. flush ( ) . unwrap ( ) ;
176+ }
177+
178+ fn receive_frame (
179+ receiver : & mpsc:: Receiver < Result < Value , String > > ,
180+ deadline : Instant ,
181+ phase : & str ,
182+ output : & TerminalOutput ,
183+ diagnostics : & Mutex < Vec < u8 > > ,
184+ ) -> Value {
185+ match receiver. recv_timeout ( deadline. saturating_duration_since ( Instant :: now ( ) ) ) {
186+ Ok ( Ok ( frame) ) => frame,
187+ failure => {
188+ let message = format ! (
189+ "PTY {phase} failed: {failure:?}; output={:?}; stderr={:?}" ,
190+ String :: from_utf8_lossy( & output. bytes) ,
191+ String :: from_utf8_lossy( & diagnostics. lock( ) . unwrap( ) ) ,
192+ ) ;
193+ // Bypass libtest capture so timeout diagnostics survive even if
194+ // another Windows cleanup failure aborts the harness.
195+ let _ = writeln ! ( std:: io:: stderr( ) . lock( ) , "{message}" ) ;
196+ panic ! ( "{message}" ) ;
197+ }
198+ }
199+ }
200+
201+ #[ test]
202+ fn terminal_answers_cursor_queries_split_across_output_frames_once ( ) {
203+ let mut terminal = TerminalOutput :: default ( ) ;
204+ assert_eq ! ( terminal. push( b"\x1b [" ) , 0 ) ;
205+ assert_eq ! ( terminal. push( b"6nfixture-ready" ) , 1 ) ;
206+ assert_eq ! ( terminal. push( b"\r \n " ) , 0 ) ;
207+ assert_eq ! ( terminal. push( b"\x1b [6n" ) , 1 ) ;
208+ assert ! ( terminal. contains( "fixture-ready" ) ) ;
209+ }
210+
122211#[ test]
123212fn proxy_preserves_project_cwd_binary_stdin_and_child_exit_code ( ) {
124213 let directory = TestDirectory :: new ( "proxy" ) ;
@@ -153,37 +242,81 @@ fn pty_starts_in_project_directory_and_supports_resize_input_and_shutdown() {
153242 let directory = TestDirectory :: new ( "pty" ) ;
154243 let mut core = spawn_fixture ( "pty" , & directory) ;
155244 let stdout = core. 0 . stdout . take ( ) . unwrap ( ) ;
245+ let mut stderr = core. 0 . stderr . take ( ) . unwrap ( ) ;
246+ let diagnostics = Arc :: new ( Mutex :: new ( Vec :: new ( ) ) ) ;
247+ let stderr_capture = Arc :: clone ( & diagnostics) ;
248+ let stderr_reader = std:: thread:: spawn ( move || {
249+ let mut buffer = [ 0_u8 ; 4096 ] ;
250+ while let Ok ( count) = stderr. read ( & mut buffer) {
251+ if count == 0 {
252+ break ;
253+ }
254+ stderr_capture
255+ . lock ( )
256+ . unwrap ( )
257+ . extend_from_slice ( & buffer[ ..count] ) ;
258+ }
259+ } ) ;
156260 let ( sender, receiver) = mpsc:: channel ( ) ;
157261 let reader = std:: thread:: spawn ( move || {
158262 for line in BufReader :: new ( stdout) . lines ( ) {
159- let frame = serde_json:: from_str :: < Value > ( & line. unwrap ( ) ) . unwrap ( ) ;
263+ let frame = line. map_err ( |error| error. to_string ( ) ) . and_then ( |line| {
264+ serde_json:: from_str :: < Value > ( & line) . map_err ( |error| format ! ( "{error}: {line:?}" ) )
265+ } ) ;
160266 if sender. send ( frame) . is_err ( ) {
161267 break ;
162268 }
163269 }
164270 } ) ;
165- let first = receiver
166- . recv_timeout ( TIMEOUT )
167- . expect ( "PTY did not become ready" ) ;
271+ let mut output = TerminalOutput :: default ( ) ;
272+ let first = receive_frame (
273+ & receiver,
274+ Instant :: now ( ) + TIMEOUT ,
275+ "host readiness" ,
276+ & output,
277+ & diagnostics,
278+ ) ;
168279 assert_eq ! ( first, json!( { "protocolVersion" : 1 , "kind" : "ready" } ) ) ;
169280 let mut stdin = core. 0 . stdin . take ( ) . unwrap ( ) ;
281+ // Answer terminal queries before resize: ResizePseudoConsole may block
282+ // while ConPTY waits for the cursor reply on its input pipe.
283+ let deadline = Instant :: now ( ) + TIMEOUT ;
284+ while !output. contains ( "fixture-ready" ) {
285+ let frame = receive_frame (
286+ & receiver,
287+ deadline,
288+ "child readiness" ,
289+ & output,
290+ & diagnostics,
291+ ) ;
292+ assert_eq ! ( frame[ "kind" ] , "output" , "unexpected frame: {frame}" ) ;
293+ let bytes = STANDARD . decode ( frame[ "data" ] . as_str ( ) . unwrap ( ) ) . unwrap ( ) ;
294+ for _ in 0 ..output. push ( & bytes) {
295+ write_request (
296+ & mut stdin,
297+ json ! ( { "protocolVersion" : 1 , "method" : "pty.write" , "data" : STANDARD . encode( b"\x1b [1;1R" ) } ) ,
298+ ) ;
299+ }
300+ }
170301 for request in [
171302 json ! ( { "protocolVersion" : 1 , "method" : "pty.resize" , "cols" : 1000 , "rows" : 30 } ) ,
172303 json ! ( { "protocolVersion" : 1 , "method" : "pty.write" , "data" : STANDARD . encode( b"native-pty-token\r " ) } ) ,
173304 ] {
174- writeln ! ( stdin, "{ request}" ) . unwrap ( ) ;
305+ write_request ( & mut stdin, request) ;
175306 }
176- let mut output = Vec :: new ( ) ;
177307 let deadline = Instant :: now ( ) + TIMEOUT ;
178308 loop {
179- let frame = receiver
180- . recv_timeout ( deadline. saturating_duration_since ( Instant :: now ( ) ) )
181- . expect ( "PTY did not echo input" ) ;
309+ let frame = receive_frame ( & receiver, deadline, "input echo" , & output, & diagnostics) ;
182310 assert_eq ! ( frame[ "kind" ] , "output" , "unexpected frame: {frame}" ) ;
183- output. extend ( STANDARD . decode ( frame[ "data" ] . as_str ( ) . unwrap ( ) ) . unwrap ( ) ) ;
184- let text = String :: from_utf8_lossy ( & output) ;
185- if text. contains ( & expected_cwd ( & directory) )
186- && text. contains ( "fixture-input=native-pty-token" )
311+ let bytes = STANDARD . decode ( frame[ "data" ] . as_str ( ) . unwrap ( ) ) . unwrap ( ) ;
312+ for _ in 0 ..output. push ( & bytes) {
313+ write_request (
314+ & mut stdin,
315+ json ! ( { "protocolVersion" : 1 , "method" : "pty.write" , "data" : STANDARD . encode( b"\x1b [1;1R" ) } ) ,
316+ ) ;
317+ }
318+ if output. contains ( & expected_cwd ( & directory) )
319+ && output. contains ( "fixture-input=native-pty-token" )
187320 {
188321 break ;
189322 }
@@ -197,5 +330,11 @@ fn pty_starts_in_project_directory_and_supports_resize_input_and_shutdown() {
197330 drop ( stdin) ;
198331 assert ! ( core. wait( ) . success( ) ) ;
199332 reader. join ( ) . unwrap ( ) ;
200- assert ! ( receiver. try_iter( ) . any( |frame| frame[ "kind" ] == "exit" ) ) ;
333+ stderr_reader. join ( ) . unwrap ( ) ;
334+ assert ! (
335+ receiver
336+ . try_iter( )
337+ . any( |frame| frame. unwrap( ) [ "kind" ] == "exit" )
338+ ) ;
339+ assert ! ( diagnostics. lock( ) . unwrap( ) . is_empty( ) ) ;
201340}
0 commit comments