@@ -9,7 +9,8 @@ use russh::server::{Auth, ChannelOpenHandle, Handler, Msg, Session};
99use std:: time:: Duration ;
1010
1111struct ExecPeer {
12- echo : bool ,
12+ channel : Option < russh:: Channel < Msg > > ,
13+ echo_tx : Option < mpsc:: UnboundedSender < Vec < u8 > > > ,
1314 input : Arc < std:: sync:: Mutex < Vec < u8 > > > ,
1415 eof : Arc < AtomicBool > ,
1516 release_output : Arc < tokio:: sync:: Notify > ,
@@ -33,11 +34,12 @@ impl Handler for ExecPeer {
3334
3435 async fn channel_open_session (
3536 & mut self ,
36- _channel : russh:: Channel < Msg > ,
37+ channel : russh:: Channel < Msg > ,
3738 reply : ChannelOpenHandle ,
3839 _session : & mut Session ,
3940 ) -> Result < ( ) , Self :: Error > {
4041 reply. accept ( ) . await ;
42+ self . channel = Some ( channel) ;
4143 Ok ( ( ) )
4244 }
4345
@@ -48,7 +50,27 @@ impl Handler for ExecPeer {
4850 session : & mut Session ,
4951 ) -> Result < ( ) , Self :: Error > {
5052 session. channel_success ( channel) ?;
51- self . echo = data == b"duplex" ;
53+ if data == b"duplex" {
54+ let channel = self . channel . take ( ) . unwrap ( ) ;
55+ let release = self . release_output . clone ( ) ;
56+ let ( echo_tx, mut echo_rx) = mpsc:: unbounded_channel :: < Vec < u8 > > ( ) ;
57+ self . echo_tx = Some ( echo_tx) ;
58+ self . output_task = Some ( tokio:: spawn ( async move {
59+ let ( reader, writer) = channel. split ( ) ;
60+ // The handler receives stdin independently of output flow
61+ // control, as a real process with separate I/O pumps would.
62+ drop ( reader) ;
63+ while let Some ( data) = echo_rx. recv ( ) . await {
64+ writer. data_bytes ( data. clone ( ) ) . await . unwrap ( ) ;
65+ writer. extended_data_bytes ( 1 , data) . await . unwrap ( ) ;
66+ }
67+ release. notified ( ) . await ;
68+ writer. exit_status ( 7 ) . await . unwrap ( ) ;
69+ writer. close ( ) . await . unwrap ( ) ;
70+ } ) ) ;
71+ } else {
72+ self . channel . take ( ) ;
73+ }
5274 if data == b"early" {
5375 session. exit_status_request ( channel, 0 ) ?;
5476 session. close ( channel) ?;
@@ -61,14 +83,13 @@ impl Handler for ExecPeer {
6183
6284 async fn data (
6385 & mut self ,
64- channel : russh:: ChannelId ,
86+ _channel : russh:: ChannelId ,
6587 data : & [ u8 ] ,
66- session : & mut Session ,
88+ _session : & mut Session ,
6789 ) -> Result < ( ) , Self :: Error > {
6890 self . input . lock ( ) . unwrap ( ) . extend_from_slice ( data) ;
69- if self . echo {
70- session. data ( channel, data. to_vec ( ) ) ?;
71- session. extended_data ( channel, 1 , data. to_vec ( ) ) ?;
91+ if let Some ( tx) = & self . echo_tx {
92+ tx. send ( data. to_vec ( ) ) . unwrap ( ) ;
7293 }
7394 Ok ( ( ) )
7495 }
@@ -79,6 +100,10 @@ impl Handler for ExecPeer {
79100 session : & mut Session ,
80101 ) -> Result < ( ) , Self :: Error > {
81102 self . eof . store ( true , Ordering :: SeqCst ) ;
103+ self . echo_tx . take ( ) ;
104+ if self . output_task . is_some ( ) {
105+ return Ok ( ( ) ) ;
106+ }
82107 let release = self . release_output . clone ( ) ;
83108 let handle = session. handle ( ) ;
84109 self . output_task = Some ( tokio:: spawn ( async move {
@@ -135,7 +160,8 @@ impl Fixture {
135160 let eof = Arc :: new ( AtomicBool :: new ( false ) ) ;
136161 let release_output = Arc :: new ( tokio:: sync:: Notify :: new ( ) ) ;
137162 let handler = ExecPeer {
138- echo : false ,
163+ channel : None ,
164+ echo_tx : None ,
139165 input : input. clone ( ) ,
140166 eof : eof. clone ( ) ,
141167 release_output : release_output. clone ( ) ,
@@ -228,10 +254,12 @@ async fn interactive_exec_drains_stdout_and_stderr_after_input_eof() {
228254async fn interactive_exec_makes_progress_in_both_directions_before_eof ( ) {
229255 const CHUNKS : usize = 128 ;
230256 const CHUNK_SIZE : usize = 64 * 1024 ;
257+ const BATCH : usize = 4 ;
231258 let fixture = Fixture :: new ( ) . await ;
232259 let ( input_tx, input_rx) = mpsc:: channel ( 16 ) ;
233260 let ( output_tx, mut output_rx) = mpsc:: channel ( 2 ) ;
234261 let drained = tokio:: sync:: Notify :: new ( ) ;
262+ let progress = std:: cell:: Cell :: new ( ( 0 , 0 ) ) ;
235263 let exec = run_interactive_exec_with_russh (
236264 fixture. port ,
237265 "duplex" ,
@@ -243,24 +271,27 @@ async fn interactive_exec_makes_progress_in_both_directions_before_eof() {
243271 output_tx,
244272 ) ;
245273 let writer = async {
246- for _ in 0 .. CHUNKS {
274+ for chunk in 1 ..= CHUNKS {
247275 input_tx
248276 . send ( Ok ( ExecSandboxInput {
249277 payload : Some ( exec_sandbox_input:: Payload :: Stdin ( vec ! [ b'x' ; CHUNK_SIZE ] ) ) ,
250278 } ) )
251279 . await
252280 . unwrap ( ) ;
281+ if chunk % BATCH == 0 {
282+ drained. notified ( ) . await ;
283+ }
253284 }
254285 // Keep the request stream open until BOTH output streams have drained.
255- // The payload exceeds SSH windows and all bridge queues, so buffering
256- // the entire exchange cannot masquerade as concurrent progress.
257- drained. notified ( ) . await ;
286+ // Bound in-flight data to exercise sustained interactive traffic without
287+ // saturating both ends of the fixture's SSH transport simultaneously.
258288 drop ( input_tx) ;
259289 } ;
260290 let reader = async {
261291 ready ( & mut output_rx) . await ;
262292 let mut stdout = 0 ;
263293 let mut stderr = 0 ;
294+ let mut acknowledged = 0 ;
264295 while stdout < CHUNKS * CHUNK_SIZE || stderr < CHUNKS * CHUNK_SIZE {
265296 let bytes = match output_rx. recv ( ) . await . unwrap ( ) . unwrap ( ) . payload . unwrap ( ) {
266297 exec_sandbox_event:: Payload :: Stdout ( s) => {
@@ -271,26 +302,76 @@ async fn interactive_exec_makes_progress_in_both_directions_before_eof() {
271302 stderr += s. data . len ( ) ;
272303 s. data
273304 }
274- event => panic ! ( "unexpected event: {event:?}" ) ,
305+ event @ exec_sandbox_event:: Payload :: Exit ( _) => {
306+ panic ! ( "unexpected event: {event:?}" )
307+ }
275308 } ;
276309 assert ! ( bytes. iter( ) . all( |b| * b == b'x' ) ) ;
310+ progress. set ( ( stdout, stderr) ) ;
277311 assert ! ( !fixture. eof. load( Ordering :: SeqCst ) ) ;
312+ if stdout. min ( stderr) >= acknowledged + BATCH * CHUNK_SIZE {
313+ acknowledged += BATCH * CHUNK_SIZE ;
314+ drained. notify_one ( ) ;
315+ }
278316 }
279317 assert_eq ! ( stdout, CHUNKS * CHUNK_SIZE ) ;
280318 assert_eq ! ( stderr, CHUNKS * CHUNK_SIZE ) ;
281- drained. notify_one ( ) ;
282319 fixture. release_output . notify_one ( ) ;
283320 while output_rx. recv ( ) . await . is_some ( ) { }
284321 } ;
285322 let ( result, ( ) , ( ) ) = tokio:: time:: timeout ( Duration :: from_secs ( 30 ) , async {
286323 tokio:: join!( exec, writer, reader)
287324 } )
288325 . await
289- . expect ( "stdin and stdout/stderr must make progress without request EOF" ) ;
326+ . unwrap_or_else ( |_| {
327+ panic ! (
328+ "duplex stalled: input={}, output={:?}, eof={}" ,
329+ fixture. input. lock( ) . unwrap( ) . len( ) ,
330+ progress. get( ) ,
331+ fixture. eof. load( Ordering :: SeqCst )
332+ )
333+ } ) ;
290334 assert_eq ! ( result. unwrap( ) , 7 ) ;
291335 assert_eq ! ( fixture. input. lock( ) . unwrap( ) . len( ) , CHUNKS * CHUNK_SIZE ) ;
292336}
293337
338+ #[ tokio:: test]
339+ async fn interactive_exec_ready_resize_stream_does_not_starve_output ( ) {
340+ use futures:: StreamExt ;
341+ use std:: sync:: atomic:: AtomicUsize ;
342+
343+ // Finite to make a regression fail rather than wedge the runtime forever.
344+ // These frames have no SSH write await because this session has no PTY.
345+ const FRAMES : usize = 100_000 ;
346+ let fixture = Fixture :: new ( ) . await ;
347+ let consumed = AtomicUsize :: new ( 0 ) ;
348+ let input = futures:: stream:: repeat_with ( || {
349+ consumed. fetch_add ( 1 , Ordering :: SeqCst ) ;
350+ Ok ( ExecSandboxInput {
351+ payload : Some ( exec_sandbox_input:: Payload :: Resize (
352+ openshell_core:: proto:: ExecSandboxWindowResize :: default ( ) ,
353+ ) ) ,
354+ } )
355+ } )
356+ . take ( FRAMES ) ;
357+ let ( output_tx, mut output_rx) = mpsc:: channel ( 2 ) ;
358+ let exec =
359+ run_interactive_exec_with_russh ( fixture. port , "test" , input, false , false , 0 , 0 , output_tx) ;
360+ let reader = async {
361+ ready ( & mut output_rx) . await ;
362+ assert ! (
363+ consumed. load( Ordering :: SeqCst ) < FRAMES ,
364+ "output must be delivered before the continuously ready input ends"
365+ ) ;
366+ drop ( output_rx) ;
367+ } ;
368+ let ( result, ( ) ) =
369+ tokio:: time:: timeout ( Duration :: from_secs ( 5 ) , async { tokio:: join!( exec, reader) } )
370+ . await
371+ . unwrap ( ) ;
372+ assert_eq ! ( result. unwrap_err( ) . code( ) , tonic:: Code :: Cancelled ) ;
373+ }
374+
294375#[ tokio:: test]
295376async fn interactive_exec_input_error_is_not_graceful_eof ( ) {
296377 for message in [
0 commit comments