@@ -263,16 +263,26 @@ 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+ struct ChannelState {
273+ input_sender : Option < mpsc:: Sender < Vec < u8 > > > ,
274+ pty_master : Option < std:: fs:: File > ,
275+ pty_request : Option < PtyRequest > ,
276+ }
277+
266278struct SshHandler {
267279 policy : SandboxPolicy ,
268280 workdir : Option < String > ,
269281 netns_fd : Option < RawFd > ,
270282 proxy_url : Option < String > ,
271283 ca_file_paths : Option < Arc < ( PathBuf , PathBuf ) > > ,
272284 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 > ,
285+ channels : HashMap < ChannelId , ChannelState > ,
276286}
277287
278288impl SshHandler {
@@ -291,9 +301,7 @@ impl SshHandler {
291301 proxy_url,
292302 ca_file_paths,
293303 provider_env,
294- input_sender : None ,
295- pty_master : None ,
296- pty_request : None ,
304+ channels : HashMap :: new ( ) ,
297305 }
298306 }
299307}
@@ -388,7 +396,15 @@ impl russh::server::Handler for SshHandler {
388396 _modes : & [ ( russh:: Pty , u32 ) ] ,
389397 session : & mut Session ,
390398 ) -> Result < ( ) , Self :: Error > {
391- self . pty_request = Some ( PtyRequest {
399+ let state = self
400+ . channels
401+ . entry ( channel)
402+ . or_insert_with ( || ChannelState {
403+ input_sender : None ,
404+ pty_master : None ,
405+ pty_request : None ,
406+ } ) ;
407+ state. pty_request = Some ( PtyRequest {
392408 term : term. to_string ( ) ,
393409 col_width,
394410 row_height,
@@ -401,21 +417,25 @@ impl russh::server::Handler for SshHandler {
401417
402418 async fn window_change_request (
403419 & mut self ,
404- _channel : ChannelId ,
420+ channel : ChannelId ,
405421 col_width : u32 ,
406422 row_height : u32 ,
407423 pixel_width : u32 ,
408424 pixel_height : u32 ,
409425 _session : & mut Session ,
410426 ) -> Result < ( ) , Self :: Error > {
411- if let Some ( master) = self . pty_master . as_ref ( ) {
412- let winsize = Winsize {
413- ws_row : to_u16 ( row_height. max ( 1 ) ) ,
414- ws_col : to_u16 ( col_width. max ( 1 ) ) ,
415- ws_xpixel : to_u16 ( pixel_width) ,
416- ws_ypixel : to_u16 ( pixel_height) ,
417- } ;
418- let _ = unsafe_pty:: set_winsize ( master. as_raw_fd ( ) , winsize) ;
427+ if let Some ( state) = self . channels . get ( & channel) {
428+ if let Some ( master) = state. pty_master . as_ref ( ) {
429+ let winsize = Winsize {
430+ ws_row : to_u16 ( row_height. max ( 1 ) ) ,
431+ ws_col : to_u16 ( col_width. max ( 1 ) ) ,
432+ ws_xpixel : to_u16 ( pixel_width) ,
433+ ws_ypixel : to_u16 ( pixel_height) ,
434+ } ;
435+ if let Err ( e) = unsafe_pty:: set_winsize ( master. as_raw_fd ( ) , winsize) {
436+ warn ! ( "failed to resize PTY for channel {channel:?}: {e}" ) ;
437+ }
438+ }
419439 }
420440 Ok ( ( ) )
421441 }
@@ -474,7 +494,15 @@ impl russh::server::Handler for SshHandler {
474494 self . ca_file_paths . clone ( ) ,
475495 & self . provider_env ,
476496 ) ?;
477- self . input_sender = Some ( input_sender) ;
497+ let state = self
498+ . channels
499+ . entry ( channel)
500+ . or_insert_with ( || ChannelState {
501+ input_sender : None ,
502+ pty_master : None ,
503+ pty_request : None ,
504+ } ) ;
505+ state. input_sender = Some ( input_sender) ;
478506 } else {
479507 warn ! ( subsystem = name, "unsupported subsystem requested" ) ;
480508 session. channel_failure ( channel) ?;
@@ -499,26 +527,30 @@ impl russh::server::Handler for SshHandler {
499527
500528 async fn data (
501529 & mut self ,
502- _channel : ChannelId ,
530+ channel : ChannelId ,
503531 data : & [ u8 ] ,
504532 _session : & mut Session ,
505533 ) -> Result < ( ) , Self :: Error > {
506- if let Some ( sender) = self . input_sender . as_ref ( ) {
507- let _ = sender. send ( data. to_vec ( ) ) ;
534+ if let Some ( state) = self . channels . get ( & channel) {
535+ if let Some ( sender) = state. input_sender . as_ref ( ) {
536+ let _ = sender. send ( data. to_vec ( ) ) ;
537+ }
508538 }
509539 Ok ( ( ) )
510540 }
511541
512542 async fn channel_eof (
513543 & mut self ,
514- _channel : ChannelId ,
544+ channel : ChannelId ,
515545 _session : & mut Session ,
516546 ) -> Result < ( ) , Self :: Error > {
517547 // Drop the input sender so the stdin writer thread sees a
518548 // disconnected channel and closes the child's stdin pipe. This
519549 // is essential for commands like `cat | tar xf -` which need
520550 // stdin EOF to know the input stream is complete.
521- self . input_sender . take ( ) ;
551+ if let Some ( state) = self . channels . get_mut ( & channel) {
552+ state. input_sender . take ( ) ;
553+ }
522554 Ok ( ( ) )
523555 }
524556}
@@ -530,7 +562,15 @@ impl SshHandler {
530562 handle : Handle ,
531563 command : Option < String > ,
532564 ) -> anyhow:: Result < ( ) > {
533- if let Some ( pty) = self . pty_request . take ( ) {
565+ let state = self
566+ . channels
567+ . entry ( channel)
568+ . or_insert_with ( || ChannelState {
569+ input_sender : None ,
570+ pty_master : None ,
571+ pty_request : None ,
572+ } ) ;
573+ if let Some ( pty) = state. pty_request . take ( ) {
534574 // PTY was requested — allocate a real PTY (interactive shell or
535575 // exec that explicitly asked for a terminal).
536576 let ( pty_master, input_sender) = spawn_pty_shell (
@@ -545,8 +585,8 @@ impl SshHandler {
545585 self . ca_file_paths . clone ( ) ,
546586 & self . provider_env ,
547587 ) ?;
548- self . pty_master = Some ( pty_master) ;
549- self . input_sender = Some ( input_sender) ;
588+ state . pty_master = Some ( pty_master) ;
589+ state . input_sender = Some ( input_sender) ;
550590 } else {
551591 // No PTY requested — use plain pipes so stdout/stderr are
552592 // separate and output has clean LF line endings. This is the
@@ -562,7 +602,7 @@ impl SshHandler {
562602 self . ca_file_paths . clone ( ) ,
563603 & self . provider_env ,
564604 ) ?;
565- self . input_sender = Some ( input_sender) ;
605+ state . input_sender = Some ( input_sender) ;
566606 }
567607 Ok ( ( ) )
568608 }
@@ -999,7 +1039,7 @@ mod unsafe_pty {
9991039
10001040 #[ allow( unsafe_code) ]
10011041 pub fn set_winsize ( fd : RawFd , winsize : Winsize ) -> std:: io:: Result < ( ) > {
1002- let rc = unsafe { libc:: ioctl ( fd, libc:: TIOCSWINSZ , winsize) } ;
1042+ let rc = unsafe { libc:: ioctl ( fd, libc:: TIOCSWINSZ , & winsize) } ;
10031043 if rc != 0 {
10041044 return Err ( std:: io:: Error :: last_os_error ( ) ) ;
10051045 }
@@ -1404,4 +1444,115 @@ mod tests {
14041444 assert ! ( !is_loopback_host( "not-an-ip" ) ) ;
14051445 assert ! ( !is_loopback_host( "[]" ) ) ;
14061446 }
1447+
1448+ // -----------------------------------------------------------------------
1449+ // Per-channel PTY state tests (#543)
1450+ // -----------------------------------------------------------------------
1451+
1452+ #[ test]
1453+ fn set_winsize_applies_to_correct_pty ( ) {
1454+ // Verify that set_winsize applies to a specific PTY master FD,
1455+ // which is the mechanism that per-channel tracking relies on.
1456+ // With the old single-pty_master design, a window_change_request
1457+ // for channel N would resize whatever PTY was stored last —
1458+ // potentially belonging to a different channel.
1459+ let pty_a = openpty ( None , None ) . expect ( "openpty a" ) ;
1460+ let pty_b = openpty ( None , None ) . expect ( "openpty b" ) ;
1461+ let master_a = std:: fs:: File :: from ( pty_a. master ) ;
1462+ let master_b = std:: fs:: File :: from ( pty_b. master ) ;
1463+ let fd_a = master_a. as_raw_fd ( ) ;
1464+ let fd_b = master_b. as_raw_fd ( ) ;
1465+ assert_ne ! ( fd_a, fd_b, "two PTYs must have distinct FDs" ) ;
1466+
1467+ // Close the slave ends to avoid leaking FDs in the test.
1468+ drop ( std:: fs:: File :: from ( pty_a. slave ) ) ;
1469+ drop ( std:: fs:: File :: from ( pty_b. slave ) ) ;
1470+
1471+ // Resize only PTY B.
1472+ let winsize_b = Winsize {
1473+ ws_row : 50 ,
1474+ ws_col : 120 ,
1475+ ws_xpixel : 0 ,
1476+ ws_ypixel : 0 ,
1477+ } ;
1478+ unsafe_pty:: set_winsize ( fd_b, winsize_b) . expect ( "set_winsize on PTY B" ) ;
1479+
1480+ // Resize PTY A to a different size.
1481+ let winsize_a = Winsize {
1482+ ws_row : 24 ,
1483+ ws_col : 80 ,
1484+ ws_xpixel : 0 ,
1485+ ws_ypixel : 0 ,
1486+ } ;
1487+ unsafe_pty:: set_winsize ( fd_a, winsize_a) . expect ( "set_winsize on PTY A" ) ;
1488+
1489+ // Read back sizes via ioctl to verify independence.
1490+ let mut actual_a: libc:: winsize = unsafe { std:: mem:: zeroed ( ) } ;
1491+ let mut actual_b: libc:: winsize = unsafe { std:: mem:: zeroed ( ) } ;
1492+ #[ allow( unsafe_code) ]
1493+ unsafe {
1494+ libc:: ioctl ( fd_a, libc:: TIOCGWINSZ , & mut actual_a) ;
1495+ libc:: ioctl ( fd_b, libc:: TIOCGWINSZ , & mut actual_b) ;
1496+ }
1497+
1498+ assert_eq ! ( actual_a. ws_row, 24 , "PTY A should be 24 rows" ) ;
1499+ assert_eq ! ( actual_a. ws_col, 80 , "PTY A should be 80 cols" ) ;
1500+ assert_eq ! ( actual_b. ws_row, 50 , "PTY B should be 50 rows" ) ;
1501+ assert_eq ! ( actual_b. ws_col, 120 , "PTY B should be 120 cols" ) ;
1502+ }
1503+
1504+ #[ test]
1505+ fn channel_state_independent_input_senders ( ) {
1506+ // Verify that each channel gets its own input sender so that
1507+ // data() and channel_eof() affect only the targeted channel.
1508+ // Uses the ChannelState struct directly without needing ChannelId
1509+ // constructors.
1510+ let ( tx_a, rx_a) = mpsc:: channel :: < Vec < u8 > > ( ) ;
1511+ let ( tx_b, rx_b) = mpsc:: channel :: < Vec < u8 > > ( ) ;
1512+
1513+ let mut state_a = ChannelState {
1514+ input_sender : Some ( tx_a) ,
1515+ pty_master : None ,
1516+ pty_request : None ,
1517+ } ;
1518+ let state_b = ChannelState {
1519+ input_sender : Some ( tx_b) ,
1520+ pty_master : None ,
1521+ pty_request : None ,
1522+ } ;
1523+
1524+ // Send data to channel A only.
1525+ state_a
1526+ . input_sender
1527+ . as_ref ( )
1528+ . unwrap ( )
1529+ . send ( b"hello-a" . to_vec ( ) )
1530+ . unwrap ( ) ;
1531+ // Send data to channel B only.
1532+ state_b
1533+ . input_sender
1534+ . as_ref ( )
1535+ . unwrap ( )
1536+ . send ( b"hello-b" . to_vec ( ) )
1537+ . unwrap ( ) ;
1538+
1539+ assert_eq ! ( rx_a. recv( ) . unwrap( ) , b"hello-a" ) ;
1540+ assert_eq ! ( rx_b. recv( ) . unwrap( ) , b"hello-b" ) ;
1541+
1542+ // EOF on channel A (drop sender) should not affect channel B.
1543+ state_a. input_sender . take ( ) ;
1544+ assert ! (
1545+ rx_a. recv( ) . is_err( ) ,
1546+ "channel A sender dropped, recv should fail"
1547+ ) ;
1548+
1549+ // Channel B should still be functional.
1550+ state_b
1551+ . input_sender
1552+ . as_ref ( )
1553+ . unwrap ( )
1554+ . send ( b"still-alive" . to_vec ( ) )
1555+ . unwrap ( ) ;
1556+ assert_eq ! ( rx_b. recv( ) . unwrap( ) , b"still-alive" ) ;
1557+ }
14071558}
0 commit comments