@@ -180,6 +180,150 @@ describe("pty attach --attach-stream-fd-v1", () => {
180180 expect ( packets . at ( - 1 ) ?. type ) . toBe ( MessageType . EXIT ) ;
181181 } , 15_000 ) ;
182182
183+ it ( "frames an intentional local detach before the shipped bin launcher closes fd 3" , async ( ) => {
184+ const root = fs . mkdtempSync ( path . join ( testRoot , "launcher-detach-" ) ) ;
185+ const name = `launcher-detach-${ process . pid } ` ;
186+ const launcherEnv : NodeJS . ProcessEnv = {
187+ ...process . env ,
188+ PTY_ROOT : root ,
189+ PTY_ROOT_LEGACY_SILENT : "1" ,
190+ } ;
191+ delete launcherEnv . PTY_SESSION ;
192+ delete launcherEnv . PTY_SERVER_CONFIG ;
193+ const created = spawnSync (
194+ nodeBin ,
195+ [ cliPath , "run" , "-d" , "--id" , name , "--" , "sh" , "-c" , "printf DETACH_READY; sleep 300" ] ,
196+ { env : launcherEnv , encoding : "utf8" } ,
197+ ) ;
198+ expect ( created . status , created . stderr ) . toBe ( 0 ) ;
199+
200+ const child = spawn (
201+ nodeBin ,
202+ [ binPath , "attach" , "--attach-stream-fd-v1" , "3" , name ] ,
203+ { env : launcherEnv , stdio : [ "pipe" , "pipe" , "pipe" , "pipe" ] } ,
204+ ) ;
205+ const stdout = collect ( child . stdout ) ;
206+ const stderr = collect ( child . stderr ) ;
207+ const streamChunks : Buffer [ ] = [ ] ;
208+ const reader = new PacketReader ( ) ;
209+ let requestedDetach = false ;
210+ ( child . stdio [ 3 ] as NodeJS . ReadableStream ) . on ( "data" , ( chunk ) => {
211+ const data = Buffer . from ( chunk ) ;
212+ streamChunks . push ( data ) ;
213+ for ( const packet of reader . feed ( data ) ) {
214+ if ( packet . type === MessageType . SCREEN && ! requestedDetach ) {
215+ requestedDetach = true ;
216+ child . stdin . write ( Buffer . from ( [ 0x1c ] ) ) ;
217+ }
218+ }
219+ } ) ;
220+
221+ const status = await new Promise < number | null > ( ( resolve , reject ) => {
222+ const timer = setTimeout ( ( ) => reject ( new Error ( "bin launcher detach timed out" ) ) , 10_000 ) ;
223+ child . once ( "exit" , ( code ) => {
224+ clearTimeout ( timer ) ;
225+ resolve ( code ) ;
226+ } ) ;
227+ } ) ;
228+ spawnSync ( nodeBin , [ cliPath , "kill" , name ] , { env : launcherEnv , encoding : "utf8" } ) ;
229+
230+ expect ( status ) . toBe ( 0 ) ;
231+ expect ( await stdout ) . toEqual ( Buffer . alloc ( 0 ) ) ;
232+ expect ( await stderr ) . toEqual ( Buffer . alloc ( 0 ) ) ;
233+ const packets = new PacketReader ( ) . feed ( Buffer . concat ( streamChunks ) ) ;
234+ expect ( packets [ 0 ] . type ) . toBe ( MessageType . GEOMETRY ) ;
235+ expect ( packets [ 1 ] . type ) . toBe ( MessageType . SCREEN ) ;
236+ expect ( packets [ 1 ] . payload . toString ( ) ) . toContain ( "DETACH_READY" ) ;
237+ expect ( packets . at ( - 1 ) ?. type ) . toBe ( MessageType . DETACH ) ;
238+ expect ( packets . some ( ( packet ) => packet . type === MessageType . EXIT ) ) . toBe ( false ) ;
239+ } , 15_000 ) ;
240+
241+ it ( "frames an intentional local detach before the initial daemon baseline" , async ( ) => {
242+ const { server, port } = await listen ( ) ;
243+ const attached = new Promise < void > ( ( resolve ) => {
244+ server . once ( "connection" , ( socket ) => socket . once ( "data" , ( ) => resolve ( ) ) ) ;
245+ } ) ;
246+ const child = spawn ( nodeBin , [ "--input-type=module" , "-e" , attachScript ( port ) ] , {
247+ stdio : [ "pipe" , "pipe" , "pipe" , "pipe" ] ,
248+ } ) ;
249+ const stdout = collect ( child . stdout ) ;
250+ const stderr = collect ( child . stderr ) ;
251+ const stream = collect ( child . stdio [ 3 ] as NodeJS . ReadableStream ) ;
252+ await attached ;
253+ child . stdin . write ( Buffer . from ( [ 0x1c ] ) ) ;
254+ const status = await new Promise < number | null > ( ( resolve , reject ) => {
255+ const timer = setTimeout ( ( ) => reject ( new Error ( "pre-baseline detach timed out" ) ) , 5_000 ) ;
256+ child . once ( "exit" , ( code ) => {
257+ clearTimeout ( timer ) ;
258+ resolve ( code ) ;
259+ } ) ;
260+ } ) ;
261+ server . close ( ) ;
262+
263+ expect ( status ) . toBe ( 0 ) ;
264+ expect ( await stdout ) . toEqual ( Buffer . alloc ( 0 ) ) ;
265+ expect ( await stderr ) . toEqual ( Buffer . alloc ( 0 ) ) ;
266+ expect ( new PacketReader ( ) . feed ( await stream ) . map ( ( packet ) => packet . type ) ) . toEqual ( [
267+ MessageType . DETACH ,
268+ ] ) ;
269+ } ) ;
270+
271+ it ( "does not frame DETACH when EXIT wins the pending detach-key window" , async ( ) => {
272+ const { server, port } = await listen ( ) ;
273+ let daemonSocket : net . Socket | undefined ;
274+ server . once ( "connection" , ( socket ) => {
275+ daemonSocket = socket ;
276+ socket . once ( "data" , ( ) => socket . write ( Buffer . concat ( [
277+ encodeGeometry ( 24 , 80 ) ,
278+ encodeScreen ( "exit wins" ) ,
279+ ] ) ) ) ;
280+ } ) ;
281+ const script = `
282+ import net from "node:net";
283+ import { attach } from ${ JSON . stringify ( clientUrl ) } ;
284+ const socket = net.createConnection({ host: "127.0.0.1", port: ${ port } });
285+ socket.once("connect", () => attach({
286+ name: "fixture",
287+ socket,
288+ attachStreamFdV1: 3,
289+ onExit: (code) => setTimeout(() => process.exit(code), 600),
290+ onDetach: () => process.exit(42),
291+ }));
292+ ` ;
293+ const child = spawn ( nodeBin , [ "--input-type=module" , "-e" , script ] , {
294+ stdio : [ "pipe" , "pipe" , "pipe" , "pipe" ] ,
295+ } ) ;
296+ const stdout = collect ( child . stdout ) ;
297+ const stderr = collect ( child . stderr ) ;
298+ const chunks : Buffer [ ] = [ ] ;
299+ const reader = new PacketReader ( ) ;
300+ ( child . stdio [ 3 ] as NodeJS . ReadableStream ) . on ( "data" , ( chunk ) => {
301+ const data = Buffer . from ( chunk ) ;
302+ chunks . push ( data ) ;
303+ if ( reader . feed ( data ) . some ( ( packet ) => packet . type === MessageType . SCREEN ) ) {
304+ child . stdin . write ( Buffer . from ( [ 0x1c ] ) ) ;
305+ setTimeout ( ( ) => daemonSocket ?. end ( encodeExit ( 0 ) ) , 50 ) ;
306+ }
307+ } ) ;
308+ const status = await new Promise < number | null > ( ( resolve , reject ) => {
309+ const timer = setTimeout ( ( ) => reject ( new Error ( "exit/detach race timed out" ) ) , 5_000 ) ;
310+ child . once ( "exit" , ( code ) => {
311+ clearTimeout ( timer ) ;
312+ resolve ( code ) ;
313+ } ) ;
314+ } ) ;
315+ server . close ( ) ;
316+
317+ expect ( status ) . toBe ( 0 ) ;
318+ expect ( await stdout ) . toEqual ( Buffer . alloc ( 0 ) ) ;
319+ expect ( await stderr ) . toEqual ( Buffer . alloc ( 0 ) ) ;
320+ expect ( new PacketReader ( ) . feed ( Buffer . concat ( chunks ) ) . map ( ( packet ) => packet . type ) ) . toEqual ( [
321+ MessageType . GEOMETRY ,
322+ MessageType . SCREEN ,
323+ MessageType . EXIT ,
324+ ] ) ;
325+ } ) ;
326+
183327 it ( "reframes fragmented and coalesced daemon packets in order without stdout output" , async ( ) => {
184328 const geometry = encodeGeometry ( 31 , 97 ) ;
185329 const screen = encodeScreen ( "\x1b[31mred\x1b[0m" ) ;
0 commit comments