@@ -263,16 +263,27 @@ fn hmac_sha256(key: &[u8], data: &[u8]) -> String {
263263 hex:: encode ( result)
264264}
265265
266+ /// Per-channel state for tracking PTY resources and I/O senders.
267+ ///
268+ /// Each SSH channel gets its own PTY master (if a PTY was requested) and input
269+ /// sender. This allows `window_change_request` to resize the correct PTY when
270+ /// multiple channels are open simultaneously (e.g. parallel shells, shell +
271+ /// sftp, etc.).
272+ #[ derive( Default ) ]
273+ struct ChannelState {
274+ input_sender : Option < mpsc:: Sender < Vec < u8 > > > ,
275+ pty_master : Option < std:: fs:: File > ,
276+ pty_request : Option < PtyRequest > ,
277+ }
278+
266279struct SshHandler {
267280 policy : SandboxPolicy ,
268281 workdir : Option < String > ,
269282 netns_fd : Option < RawFd > ,
270283 proxy_url : Option < String > ,
271284 ca_file_paths : Option < Arc < ( PathBuf , PathBuf ) > > ,
272285 provider_env : HashMap < String , String > ,
273- input_sender : Option < mpsc:: Sender < Vec < u8 > > > ,
274- pty_master : Option < std:: fs:: File > ,
275- pty_request : Option < PtyRequest > ,
286+ channels : HashMap < ChannelId , ChannelState > ,
276287}
277288
278289impl SshHandler {
@@ -291,9 +302,7 @@ impl SshHandler {
291302 proxy_url,
292303 ca_file_paths,
293304 provider_env,
294- input_sender : None ,
295- pty_master : None ,
296- pty_request : None ,
305+ channels : HashMap :: new ( ) ,
297306 }
298307 }
299308}
@@ -315,12 +324,27 @@ impl russh::server::Handler for SshHandler {
315324
316325 async fn channel_open_session (
317326 & mut self ,
318- _channel : russh:: Channel < russh:: server:: Msg > ,
327+ channel : russh:: Channel < russh:: server:: Msg > ,
319328 _session : & mut Session ,
320329 ) -> Result < bool , Self :: Error > {
330+ self . channels . insert ( channel. id ( ) , ChannelState :: default ( ) ) ;
321331 Ok ( true )
322332 }
323333
334+ /// Clean up per-channel state when the channel is closed.
335+ ///
336+ /// This is the final cleanup and subsumes `channel_eof` — if `channel_close`
337+ /// fires without a preceding `channel_eof`, all resources (pty_master File,
338+ /// input_sender) are dropped here.
339+ async fn channel_close (
340+ & mut self ,
341+ channel : ChannelId ,
342+ _session : & mut Session ,
343+ ) -> Result < ( ) , Self :: Error > {
344+ self . channels . remove ( & channel) ;
345+ Ok ( ( ) )
346+ }
347+
324348 async fn channel_open_direct_tcpip (
325349 & mut self ,
326350 channel : russh:: Channel < russh:: server:: Msg > ,
@@ -388,7 +412,11 @@ impl russh::server::Handler for SshHandler {
388412 _modes : & [ ( russh:: Pty , u32 ) ] ,
389413 session : & mut Session ,
390414 ) -> Result < ( ) , Self :: Error > {
391- self . pty_request = Some ( PtyRequest {
415+ let state = self
416+ . channels
417+ . get_mut ( & channel)
418+ . ok_or_else ( || anyhow:: anyhow!( "pty_request on unknown channel {channel:?}" ) ) ?;
419+ state. pty_request = Some ( PtyRequest {
392420 term : term. to_string ( ) ,
393421 col_width,
394422 row_height,
@@ -401,21 +429,27 @@ impl russh::server::Handler for SshHandler {
401429
402430 async fn window_change_request (
403431 & mut self ,
404- _channel : ChannelId ,
432+ channel : ChannelId ,
405433 col_width : u32 ,
406434 row_height : u32 ,
407435 pixel_width : u32 ,
408436 pixel_height : u32 ,
409437 _session : & mut Session ,
410438 ) -> Result < ( ) , Self :: Error > {
411- if let Some ( master) = self . pty_master . as_ref ( ) {
439+ let Some ( state) = self . channels . get ( & channel) else {
440+ warn ! ( "window_change_request on unknown channel {channel:?}" ) ;
441+ return Ok ( ( ) ) ;
442+ } ;
443+ if let Some ( master) = state. pty_master . as_ref ( ) {
412444 let winsize = Winsize {
413445 ws_row : to_u16 ( row_height. max ( 1 ) ) ,
414446 ws_col : to_u16 ( col_width. max ( 1 ) ) ,
415447 ws_xpixel : to_u16 ( pixel_width) ,
416448 ws_ypixel : to_u16 ( pixel_height) ,
417449 } ;
418- let _ = unsafe_pty:: set_winsize ( master. as_raw_fd ( ) , winsize) ;
450+ if let Err ( e) = unsafe_pty:: set_winsize ( master. as_raw_fd ( ) , winsize) {
451+ warn ! ( "failed to resize PTY for channel {channel:?}: {e}" ) ;
452+ }
419453 }
420454 Ok ( ( ) )
421455 }
@@ -474,7 +508,10 @@ impl russh::server::Handler for SshHandler {
474508 self . ca_file_paths . clone ( ) ,
475509 & self . provider_env ,
476510 ) ?;
477- self . input_sender = Some ( input_sender) ;
511+ let state = self . channels . get_mut ( & channel) . ok_or_else ( || {
512+ anyhow:: anyhow!( "subsystem_request on unknown channel {channel:?}" )
513+ } ) ?;
514+ state. input_sender = Some ( input_sender) ;
478515 } else {
479516 warn ! ( subsystem = name, "unsupported subsystem requested" ) ;
480517 session. channel_failure ( channel) ?;
@@ -499,26 +536,34 @@ impl russh::server::Handler for SshHandler {
499536
500537 async fn data (
501538 & mut self ,
502- _channel : ChannelId ,
539+ channel : ChannelId ,
503540 data : & [ u8 ] ,
504541 _session : & mut Session ,
505542 ) -> Result < ( ) , Self :: Error > {
506- if let Some ( sender) = self . input_sender . as_ref ( ) {
543+ let Some ( state) = self . channels . get ( & channel) else {
544+ warn ! ( "data on unknown channel {channel:?}" ) ;
545+ return Ok ( ( ) ) ;
546+ } ;
547+ if let Some ( sender) = state. input_sender . as_ref ( ) {
507548 let _ = sender. send ( data. to_vec ( ) ) ;
508549 }
509550 Ok ( ( ) )
510551 }
511552
512553 async fn channel_eof (
513554 & mut self ,
514- _channel : ChannelId ,
555+ channel : ChannelId ,
515556 _session : & mut Session ,
516557 ) -> Result < ( ) , Self :: Error > {
517558 // Drop the input sender so the stdin writer thread sees a
518559 // disconnected channel and closes the child's stdin pipe. This
519560 // is essential for commands like `cat | tar xf -` which need
520561 // stdin EOF to know the input stream is complete.
521- self . input_sender . take ( ) ;
562+ if let Some ( state) = self . channels . get_mut ( & channel) {
563+ state. input_sender . take ( ) ;
564+ } else {
565+ warn ! ( "channel_eof on unknown channel {channel:?}" ) ;
566+ }
522567 Ok ( ( ) )
523568 }
524569}
@@ -530,7 +575,11 @@ impl SshHandler {
530575 handle : Handle ,
531576 command : Option < String > ,
532577 ) -> anyhow:: Result < ( ) > {
533- if let Some ( pty) = self . pty_request . take ( ) {
578+ let state = self
579+ . channels
580+ . get_mut ( & channel)
581+ . ok_or_else ( || anyhow:: anyhow!( "start_shell on unknown channel {channel:?}" ) ) ?;
582+ if let Some ( pty) = state. pty_request . take ( ) {
534583 // PTY was requested — allocate a real PTY (interactive shell or
535584 // exec that explicitly asked for a terminal).
536585 let ( pty_master, input_sender) = spawn_pty_shell (
@@ -545,8 +594,8 @@ impl SshHandler {
545594 self . ca_file_paths . clone ( ) ,
546595 & self . provider_env ,
547596 ) ?;
548- self . pty_master = Some ( pty_master) ;
549- self . input_sender = Some ( input_sender) ;
597+ state . pty_master = Some ( pty_master) ;
598+ state . input_sender = Some ( input_sender) ;
550599 } else {
551600 // No PTY requested — use plain pipes so stdout/stderr are
552601 // separate and output has clean LF line endings. This is the
@@ -562,7 +611,7 @@ impl SshHandler {
562611 self . ca_file_paths . clone ( ) ,
563612 & self . provider_env ,
564613 ) ?;
565- self . input_sender = Some ( input_sender) ;
614+ state . input_sender = Some ( input_sender) ;
566615 }
567616 Ok ( ( ) )
568617 }
@@ -999,7 +1048,7 @@ mod unsafe_pty {
9991048
10001049 #[ allow( unsafe_code) ]
10011050 pub fn set_winsize ( fd : RawFd , winsize : Winsize ) -> std:: io:: Result < ( ) > {
1002- let rc = unsafe { libc:: ioctl ( fd, libc:: TIOCSWINSZ , winsize) } ;
1051+ let rc = unsafe { libc:: ioctl ( fd, libc:: TIOCSWINSZ , & winsize) } ;
10031052 if rc != 0 {
10041053 return Err ( std:: io:: Error :: last_os_error ( ) ) ;
10051054 }
@@ -1404,4 +1453,111 @@ mod tests {
14041453 assert ! ( !is_loopback_host( "not-an-ip" ) ) ;
14051454 assert ! ( !is_loopback_host( "[]" ) ) ;
14061455 }
1456+
1457+ // -----------------------------------------------------------------------
1458+ // Per-channel PTY state tests (#543)
1459+ // -----------------------------------------------------------------------
1460+
1461+ #[ test]
1462+ fn set_winsize_applies_to_correct_pty ( ) {
1463+ // Verify that set_winsize applies to a specific PTY master FD,
1464+ // which is the mechanism that per-channel tracking relies on.
1465+ // With the old single-pty_master design, a window_change_request
1466+ // for channel N would resize whatever PTY was stored last —
1467+ // potentially belonging to a different channel.
1468+ let pty_a = openpty ( None , None ) . expect ( "openpty a" ) ;
1469+ let pty_b = openpty ( None , None ) . expect ( "openpty b" ) ;
1470+ let master_a = std:: fs:: File :: from ( pty_a. master ) ;
1471+ let master_b = std:: fs:: File :: from ( pty_b. master ) ;
1472+ let fd_a = master_a. as_raw_fd ( ) ;
1473+ let fd_b = master_b. as_raw_fd ( ) ;
1474+ assert_ne ! ( fd_a, fd_b, "two PTYs must have distinct FDs" ) ;
1475+
1476+ // Close the slave ends to avoid leaking FDs in the test.
1477+ drop ( std:: fs:: File :: from ( pty_a. slave ) ) ;
1478+ drop ( std:: fs:: File :: from ( pty_b. slave ) ) ;
1479+
1480+ // Resize only PTY B.
1481+ let winsize_b = Winsize {
1482+ ws_row : 50 ,
1483+ ws_col : 120 ,
1484+ ws_xpixel : 0 ,
1485+ ws_ypixel : 0 ,
1486+ } ;
1487+ unsafe_pty:: set_winsize ( fd_b, winsize_b) . expect ( "set_winsize on PTY B" ) ;
1488+
1489+ // Resize PTY A to a different size.
1490+ let winsize_a = Winsize {
1491+ ws_row : 24 ,
1492+ ws_col : 80 ,
1493+ ws_xpixel : 0 ,
1494+ ws_ypixel : 0 ,
1495+ } ;
1496+ unsafe_pty:: set_winsize ( fd_a, winsize_a) . expect ( "set_winsize on PTY A" ) ;
1497+
1498+ // Read back sizes via ioctl to verify independence.
1499+ let mut actual_a: libc:: winsize = unsafe { std:: mem:: zeroed ( ) } ;
1500+ let mut actual_b: libc:: winsize = unsafe { std:: mem:: zeroed ( ) } ;
1501+ #[ allow( unsafe_code) ]
1502+ unsafe {
1503+ libc:: ioctl ( fd_a, libc:: TIOCGWINSZ , & mut actual_a) ;
1504+ libc:: ioctl ( fd_b, libc:: TIOCGWINSZ , & mut actual_b) ;
1505+ }
1506+
1507+ assert_eq ! ( actual_a. ws_row, 24 , "PTY A should be 24 rows" ) ;
1508+ assert_eq ! ( actual_a. ws_col, 80 , "PTY A should be 80 cols" ) ;
1509+ assert_eq ! ( actual_b. ws_row, 50 , "PTY B should be 50 rows" ) ;
1510+ assert_eq ! ( actual_b. ws_col, 120 , "PTY B should be 120 cols" ) ;
1511+ }
1512+
1513+ #[ test]
1514+ fn channel_state_independent_input_senders ( ) {
1515+ // Verify that each channel gets its own input sender so that
1516+ // data() and channel_eof() affect only the targeted channel.
1517+ let ( tx_a, rx_a) = mpsc:: channel :: < Vec < u8 > > ( ) ;
1518+ let ( tx_b, rx_b) = mpsc:: channel :: < Vec < u8 > > ( ) ;
1519+
1520+ let mut state_a = ChannelState {
1521+ input_sender : Some ( tx_a) ,
1522+ ..Default :: default ( )
1523+ } ;
1524+ let state_b = ChannelState {
1525+ input_sender : Some ( tx_b) ,
1526+ ..Default :: default ( )
1527+ } ;
1528+
1529+ // Send data to channel A only.
1530+ state_a
1531+ . input_sender
1532+ . as_ref ( )
1533+ . unwrap ( )
1534+ . send ( b"hello-a" . to_vec ( ) )
1535+ . unwrap ( ) ;
1536+ // Send data to channel B only.
1537+ state_b
1538+ . input_sender
1539+ . as_ref ( )
1540+ . unwrap ( )
1541+ . send ( b"hello-b" . to_vec ( ) )
1542+ . unwrap ( ) ;
1543+
1544+ assert_eq ! ( rx_a. recv( ) . unwrap( ) , b"hello-a" ) ;
1545+ assert_eq ! ( rx_b. recv( ) . unwrap( ) , b"hello-b" ) ;
1546+
1547+ // EOF on channel A (drop sender) should not affect channel B.
1548+ state_a. input_sender . take ( ) ;
1549+ assert ! (
1550+ rx_a. recv( ) . is_err( ) ,
1551+ "channel A sender dropped, recv should fail"
1552+ ) ;
1553+
1554+ // Channel B should still be functional.
1555+ state_b
1556+ . input_sender
1557+ . as_ref ( )
1558+ . unwrap ( )
1559+ . send ( b"still-alive" . to_vec ( ) )
1560+ . unwrap ( ) ;
1561+ assert_eq ! ( rx_b. recv( ) . unwrap( ) , b"still-alive" ) ;
1562+ }
14071563}
0 commit comments