@@ -73,7 +73,8 @@ class FakeShellStream extends EventEmitter {
7373 if ( echo ) this . emit ( 'data' , Buffer . from ( echo ) ) ;
7474 // Emit any scripted stdout.
7575 if ( script . stdout ) this . emit ( 'data' , Buffer . from ( script . stdout ) ) ;
76- if ( script . stderr ) this . stderr . emit ( 'data' , Buffer . from ( script . stderr ) ) ;
76+ // PTY folds fd2 into fd1 -> scripted stderr arrives on `data`, not `.stderr`.
77+ if ( script . stderr ) this . emit ( 'data' , Buffer . from ( script . stderr ) ) ;
7778 // Emit the sentinel line last -- unless the script wants to misbehave.
7879 if ( ! script . skipMarker ) {
7980 this . emit ( 'data' , Buffer . from ( `${ marker } ${ script . exit ?? 0 } \n` ) ) ;
@@ -312,25 +313,26 @@ await test('runCommand: timeout cancels and rejects', async () => {
312313 await sess . close ( ) ;
313314} ) ;
314315
315- await test ( 'stream: stderr is folded into the pty stream, not decoded separately' , async ( ) => {
316- // client.shell() merges stderr into `data`. The session must NOT wire a
317- // separate stderr decoder -- a multibyte char straddling a stdout/stderr
318- // boundary would otherwise splice across two decoders and corrupt.
316+ await test ( 'stream: stderr folds into data; one decoder stitches split chars' , async ( ) => {
317+ // client.shell() gives a PTY -- fd2 multiplexes onto the same master as fd1,
318+ // so stderr arrives interleaved on `data`. SSHSessionV2 wires ONE decoder on
319+ // `data` and no separate `.stderr` listener; a multibyte char split across
320+ // chunks (stdout/stderr interleaved) must still decode intact, none lost.
319321 const stream = new FakeShellStream ( { scriptFor : ( ) => ( { stdout : '' , exit : 0 , skipMarker : true } ) } ) ;
320322 const sess = new SSHSessionV2 ( { id : 'sess_dec' , server : 's' , shell : 'bash' , stream } ) ;
323+ // fix removed the dead `.stderr` listener -> nothing wires it
324+ assert . strictEqual ( stream . stderr . listenerCount ( 'data' ) , 0 , 'no separate stderr listener' ) ;
321325 const p = sess . _waitForMarker ( { timeoutMs : 1000 } ) ;
322326
323- // A 3-byte UTF-8 char ('e' with acute, U+00E9 is 2 bytes; use U+20AC euro = 3 bytes).
324- const euro = Buffer . from ( '€' , 'utf8' ) ; // [0xE2,0x82,0xAC]
325- // Deliver the char split across two `data` events -- single decoder must
326- // stitch it. Anything emitted on stream.stderr must be ignored entirely.
327- stream . stderr . emit ( 'data' , Buffer . from ( 'this stderr must be ignored' ) ) ;
327+ const euro = Buffer . from ( '€' , 'utf8' ) ; // [0xE2,0x82,0xAC] -- 3 bytes
328+ // PTY interleaving: stderr text, then a stdout char split mid-byte across events.
329+ stream . emit ( 'data' , Buffer . from ( 'ERR-folded ' ) ) ;
328330 stream . emit ( 'data' , euro . slice ( 0 , 1 ) ) ;
329331 stream . emit ( 'data' , Buffer . concat ( [ euro . slice ( 1 ) , Buffer . from ( `done\n${ sess . marker } 0\n` ) ] ) ) ;
330332
331333 const r = await p ;
332- assert ( r . raw . includes ( '€' ) , 'split multibyte char decoded intact on the data stream ' ) ;
333- assert ( ! r . raw . includes ( 'stderr must be ignored ' ) , 'separate stderr stream is not buffered ' ) ;
334+ assert ( r . raw . includes ( '€' ) , 'split multibyte char decoded intact across data chunks ' ) ;
335+ assert ( r . raw . includes ( 'ERR-folded ' ) , 'stderr folded into data is captured, not lost ' ) ;
334336 await sess . close ( ) ;
335337} ) ;
336338
0 commit comments