1- // Verifies that bin/pty forwards SIGTERM/SIGINT to the inner cli.js child.
2- // systemd's `KillMode=process` only signals the leader (the bin/pty shell
3- // shim) and lets children become orphans unless the leader propagates the
4- // signal. Without forwarding, the inner cli.js survives a unit `stop` and
5- // the next start fails because the orphan still holds whatever resource
6- // it owned (file watchers, sockets, etc.).
1+ // Verifies that bin/pty runs the CLI in the launcher's process. Keeping a
2+ // single process makes inherited descriptors and signal ownership identical
3+ // to a direct dist/cli.js invocation.
74
85import { describe , it , expect , afterEach } from "vitest" ;
96import * as fs from "node:fs" ;
107import * as os from "node:os" ;
118import * as path from "node:path" ;
129import { fileURLToPath } from "node:url" ;
13- import { spawn } from "node:child_process" ;
10+ import { execFileSync , spawn } from "node:child_process" ;
1411
1512const __dirname = path . dirname ( fileURLToPath ( import . meta. url ) ) ;
1613const wrapperPath = path . join ( __dirname , ".." , "bin" , "pty" ) ;
@@ -49,18 +46,16 @@ function isAlive(pid: number): boolean {
4946 try { process . kill ( pid , 0 ) ; return true ; } catch { return false ; }
5047}
5148
52- describe ( "bin/pty signal forwarding " , ( ) => {
53- it ( "propagates SIGTERM to the inner cli.js (events --all is long-lived) " , async ( ) => {
49+ describe ( "bin/pty process lifecycle " , ( ) => {
50+ it ( "runs a long-lived command as one process that exits on SIGTERM " , async ( ) => {
5451 const sessionDir = makeSessionDir ( ) ;
52+ const socketPath = path . join ( sessionDir , "remote.sock" ) ;
5553
56- // `events --all` is a long-lived command that registers a SIGINT
57- // handler and runs an EventFollower until the process is signalled.
58- // Same shape as the supervisor used to be: long-running, owns file
59- // watchers, must exit cleanly when the wrapper relays a signal.
60- const wrapper = spawn ( nodeBin , [ wrapperPath , "events" , "--all" ] , {
54+ const wrapper = spawn ( nodeBin , [ wrapperPath , "remote-serve" , "--socket" , socketPath ] , {
6155 env : { ...process . env , PTY_SESSION_DIR : sessionDir } ,
6256 stdio : [ "ignore" , "pipe" , "pipe" ] ,
6357 } ) ;
58+ trackedPids . push ( wrapper . pid ! ) ;
6459
6560 let stdout = "" ;
6661 let stderr = "" ;
@@ -71,36 +66,27 @@ describe("bin/pty signal forwarding", () => {
7166 let exitSignal : NodeJS . Signals | null = null ;
7267 wrapper . on ( "exit" , ( c , s ) => { exitCode = c ; exitSignal = s ; } ) ;
7368
74- // Wait long enough for the wrapper to fork the inner node cli.js.
75- // No specific marker; sleep briefly then look at the process tree.
76- await new Promise ( ( r ) => setTimeout ( r , 800 ) ) ;
69+ const started = await waitFor (
70+ ( ) => stdout . includes ( `pty remote-serve listening on ${ socketPath } ` ) ,
71+ 5000 ,
72+ ) ;
73+ expect ( started , `bin/pty did not become ready; stdout=${ stdout } stderr=${ stderr } ` ) . toBe ( true ) ;
7774
78- // Find the inner cli.js by walking the wrapper's child processes via
79- // /proc on Linux, or via `pgrep -P` everywhere. We use ps -o pid -p
80- // <wrapper-pid> first then list children with a tree walk fallback.
81- const psResult = ( ( ) => {
75+ const childPids = ( ( ) => {
8276 try {
83- const { execFileSync } = require ( "node:child_process" ) as typeof import ( "node:child_process" ) ;
8477 return execFileSync ( "pgrep" , [ "-P" , String ( wrapper . pid ) ] , { encoding : "utf-8" } ) . trim ( ) ;
8578 } catch {
8679 return "" ;
8780 }
8881 } ) ( ) ;
89- expect ( psResult , `pgrep failed to find a child of pid ${ wrapper . pid } ; stdout=${ stdout } stderr=${ stderr } ` ) . not . toBe ( "" ) ;
82+ expect ( childPids , `bin/pty unexpectedly spawned a child; stdout=${ stdout } stderr=${ stderr } ` ) . toBe ( "" ) ;
9083
91- const innerPid = parseInt ( psResult . split ( "\n" ) [ 0 ] ! . trim ( ) , 10 ) ;
92- trackedPids . push ( innerPid ) ;
93- expect ( innerPid ) . toBeGreaterThan ( 0 ) ;
94- expect ( innerPid ) . not . toBe ( wrapper . pid ) ;
95- expect ( isAlive ( innerPid ) ) . toBe ( true ) ;
96-
97- // The actual test: SIGTERM the wrapper, expect the inner cli.js to also die.
9884 wrapper . kill ( "SIGTERM" ) ;
9985
10086 const wrapperExited = await waitFor ( ( ) => exitCode !== null || exitSignal !== null , 5000 ) ;
10187 expect ( wrapperExited , "wrapper did not exit after SIGTERM" ) . toBe ( true ) ;
10288
103- const innerDied = await waitFor ( ( ) => ! isAlive ( innerPid ) , 5000 ) ;
104- expect ( innerDied , `inner cli.js (pid ${ innerPid } ) survived wrapper SIGTERM — signal not forwarded` ) . toBe ( true ) ;
89+ expect ( exitCode ) . toBe ( 0 ) ;
90+ expect ( exitSignal ) . toBeNull ( ) ;
10591 } , 20000 ) ;
10692} ) ;
0 commit comments