@@ -210,20 +210,52 @@ describe("pty attach --attach-stream-fd-v1", () => {
210210 expect ( result . stderr . toString ( ) ) . toMatch ( / d a e m o n d o e s n o t s u p p o r t a t t a c h s t r e a m v 1 / i) ;
211211 } ) ;
212212
213+ for ( const premature of [
214+ encodePacket ( MessageType . DATA , Buffer . from ( "too early" ) ) ,
215+ encodeExit ( 0 ) ,
216+ ] ) {
217+ const type = new PacketReader ( ) . feed ( premature ) [ 0 ] . type === MessageType . DATA ? "DATA" : "EXIT" ;
218+ it ( `rejects a partial daemon that sends ${ type } before the initial SCREEN` , async ( ) => {
219+ const result = await runAgainstFakeDaemon ( ( socket ) => {
220+ socket . write ( Buffer . concat ( [ encodeGeometry ( 24 , 80 ) , premature ] ) ) ;
221+ } ) ;
222+
223+ expect ( result . status ) . toBe ( 1 ) ;
224+ expect ( result . stdout ) . toEqual ( Buffer . alloc ( 0 ) ) ;
225+ expect ( result . stderr . toString ( ) ) . toMatch ( new RegExp ( `expected SCREEN before ${ type } ` , "i" ) ) ;
226+ expect ( new PacketReader ( ) . feed ( result . stream ) . map ( ( packet ) => packet . type ) ) . toEqual ( [
227+ MessageType . GEOMETRY ,
228+ ] ) ;
229+ } ) ;
230+ }
231+
213232 it ( "fails when the connection closes without a framed EXIT event" , async ( ) => {
214233 const result = await runAgainstFakeDaemon ( ( socket ) => {
215234 socket . end ( Buffer . concat ( [ encodeGeometry ( 24 , 80 ) , encodeScreen ( "truncated" ) ] ) ) ;
216235 } ) ;
217236
218237 expect ( result . status ) . toBe ( 1 ) ;
219238 expect ( result . stdout ) . toEqual ( Buffer . alloc ( 0 ) ) ;
220- expect ( result . stderr . toString ( ) ) . toMatch ( / m a c h i n e s t r e a m e n d e d b e f o r e a n E X I T e v e n t / i) ;
239+ expect ( result . stderr . toString ( ) ) . toMatch ( / m a c h i n e s t r e a m t r u n c a t e d b e f o r e E X I T : c o n n e c t i o n c l o s e d / i) ;
221240 expect ( new PacketReader ( ) . feed ( result . stream ) . map ( ( packet ) => packet . type ) ) . toEqual ( [
222241 MessageType . GEOMETRY ,
223242 MessageType . SCREEN ,
224243 ] ) ;
225244 } ) ;
226245
246+ it ( "diagnoses a transport reset as a truncated stream, not a missing session" , async ( ) => {
247+ const result = await runAgainstFakeDaemon ( ( socket ) => {
248+ socket . write ( Buffer . concat ( [ encodeGeometry ( 24 , 80 ) , encodeScreen ( "partial" ) ] ) , ( ) => {
249+ socket . resetAndDestroy ( ) ;
250+ } ) ;
251+ } ) ;
252+
253+ expect ( result . status ) . toBe ( 1 ) ;
254+ expect ( result . stdout ) . toEqual ( Buffer . alloc ( 0 ) ) ;
255+ expect ( result . stderr . toString ( ) ) . toMatch ( / m a c h i n e s t r e a m t r u n c a t e d b e f o r e E X I T / i) ;
256+ expect ( result . stderr . toString ( ) ) . not . toMatch ( / s e s s i o n .* n o t f o u n d / i) ;
257+ } ) ;
258+
227259 it ( "fails instead of hanging when the inherited stream breaks" , async ( ) => {
228260 const { server, port } = await listen ( ) ;
229261 let streamReader : { destroy ( ) : void } | undefined ;
@@ -324,4 +356,61 @@ describe("pty attach --attach-stream-fd-v1", () => {
324356 ] ) ;
325357 expect ( decodeSize ( packets [ 3 ] . payload ) ) . toEqual ( { rows : 21 , cols : 71 } ) ;
326358 } ) ;
359+
360+ it ( "requires a fresh SCREEN after GEOMETRY on reconnect" , async ( ) => {
361+ const { server, port } = await listen ( ) ;
362+ let connection = 0 ;
363+ server . on ( "connection" , ( socket ) => {
364+ const current = ++ connection ;
365+ socket . once ( "data" , ( ) => {
366+ if ( current === 1 ) {
367+ socket . end ( Buffer . concat ( [
368+ encodeGeometry ( 20 , 70 ) ,
369+ encodeScreen ( "first" ) ,
370+ encodePacket ( MessageType . DATA , Buffer . from ( "before reconnect" ) ) ,
371+ ] ) ) ;
372+ } else {
373+ socket . write ( Buffer . concat ( [
374+ encodeGeometry ( 21 , 71 ) ,
375+ encodePacket ( MessageType . DATA , Buffer . from ( "too early" ) ) ,
376+ ] ) ) ;
377+ }
378+ } ) ;
379+ } ) ;
380+ const script = `
381+ import net from "node:net";
382+ import { attach } from ${ JSON . stringify ( clientUrl ) } ;
383+ const dial = () => new Promise((resolve, reject) => {
384+ const socket = net.createConnection({ host: "127.0.0.1", port: ${ port } });
385+ socket.once("connect", () => resolve(socket));
386+ socket.once("error", reject);
387+ });
388+ const socket = await dial();
389+ attach({
390+ name: "fixture",
391+ socket,
392+ attachStreamFdV1: 3,
393+ reconnect: dial,
394+ onExit: (code) => process.exit(code),
395+ });
396+ ` ;
397+ const child = spawn ( nodeBin , [ "--input-type=module" , "-e" , script ] , {
398+ stdio : [ "pipe" , "pipe" , "pipe" , "pipe" ] ,
399+ } ) ;
400+ const stdout = collect ( child . stdout ) ;
401+ const stderr = collect ( child . stderr ) ;
402+ const stream = collect ( child . stdio [ 3 ] as NodeJS . ReadableStream ) ;
403+ const status = await new Promise < number | null > ( ( resolve ) => child . once ( "exit" , resolve ) ) ;
404+ server . close ( ) ;
405+
406+ expect ( status ) . toBe ( 1 ) ;
407+ expect ( await stdout ) . toEqual ( Buffer . alloc ( 0 ) ) ;
408+ expect ( ( await stderr ) . toString ( ) ) . toMatch ( / e x p e c t e d S C R E E N b e f o r e D A T A / i) ;
409+ expect ( new PacketReader ( ) . feed ( await stream ) . map ( ( packet ) => packet . type ) ) . toEqual ( [
410+ MessageType . GEOMETRY ,
411+ MessageType . SCREEN ,
412+ MessageType . DATA ,
413+ MessageType . GEOMETRY ,
414+ ] ) ;
415+ } ) ;
327416} ) ;
0 commit comments