@take-out/scripts 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@take-out/scripts",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "type": "module",
5
5
  "main": "./src/run.ts",
6
6
  "sideEffects": false,
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@clack/prompts": "^0.8.2",
32
- "@take-out/helpers": "0.1.10",
32
+ "@take-out/helpers": "0.1.12",
33
33
  "picocolors": "^1.1.1"
34
34
  },
35
35
  "peerDependencies": {
@@ -1,5 +1,3 @@
1
- import { spawn } from 'node:child_process'
2
-
3
1
  import {
4
2
  addProcessHandler,
5
3
  setExitCleanupState,
@@ -7,8 +5,6 @@ import {
7
5
  type ProcessType,
8
6
  } from './run'
9
7
 
10
- import type { ChildProcess } from 'node:child_process'
11
-
12
8
  type ExitCallback = (info: { signal: NodeJS.Signals | string }) => void | Promise<void>
13
9
 
14
10
  interface HandleProcessExitReturn {
@@ -17,96 +13,39 @@ interface HandleProcessExitReturn {
17
13
  exit: (code?: number) => Promise<void>
18
14
  }
19
15
 
20
- async function getChildPids(parentPid: number): Promise<number[]> {
21
- try {
22
- const proc = spawn('pgrep', ['-P', parentPid.toString()], {
23
- stdio: ['ignore', 'pipe', 'pipe'],
24
- })
25
-
26
- return new Promise((resolve) => {
27
- let output = ''
28
- proc.stdout?.on('data', (data) => {
29
- output += data.toString()
30
- })
31
-
32
- proc.on('close', () => {
33
- const childPids = output.trim().split('\n').filter(Boolean).map(Number)
34
- resolve(childPids)
35
- })
36
- })
37
- } catch (error) {
38
- console.error(`Error getting child PIDs for ${parentPid}: ${error}`)
39
- return []
40
- }
41
- }
42
-
43
- async function getAllDescendantPids(parentPid: number): Promise<number[]> {
44
- const childPids = await getChildPids(parentPid)
45
- const descendantPids = [...childPids]
46
-
47
- for (const childPid of childPids) {
48
- const grandchildren = await getAllDescendantPids(childPid)
49
- descendantPids.push(...grandchildren)
50
- }
51
-
52
- return descendantPids
53
- }
54
-
55
- async function killProcessTree(
16
+ // kill an entire process group (works because children are spawned with detached: true,
17
+ // which makes them process group leaders). negative pid = kill the whole group.
18
+ // this is synchronous, no pgrep needed, no races.
19
+ function killProcessGroup(
56
20
  pid: number,
57
21
  signal: NodeJS.Signals = 'SIGTERM',
58
22
  forceful: boolean = false
59
- ): Promise<void> {
23
+ ): void {
24
+ // kill the process group (negative pid)
60
25
  try {
61
- const descendants = await getAllDescendantPids(pid)
62
-
63
- // first send the requested signal
64
- for (const descendantPid of descendants.reverse()) {
65
- try {
66
- process.kill(descendantPid, signal)
67
- } catch (_) {
68
- // process may already be gone
69
- }
26
+ process.kill(-pid, signal)
27
+ } catch (_) {
28
+ // group may already be gone, try the individual process
29
+ try {
30
+ process.kill(pid, signal)
31
+ } catch (_) {
32
+ // process already gone
70
33
  }
34
+ }
71
35
 
72
- if (pid && !Number.isNaN(pid)) {
36
+ if (forceful && signal !== 'SIGKILL') {
37
+ // schedule a SIGKILL followup
38
+ setTimeout(() => {
73
39
  try {
74
- process.kill(pid, signal)
75
- } catch (_) {
76
- // process may already be gone
77
- }
78
- }
79
-
80
- // if forceful, wait briefly then send SIGKILL
81
- if (forceful && signal !== 'SIGKILL') {
82
- await new Promise((resolve) => setTimeout(resolve, 100))
83
-
84
- // send SIGKILL to any still-alive processes
85
- for (const descendantPid of descendants.reverse()) {
86
- try {
87
- process.kill(descendantPid, 'SIGKILL')
88
- } catch (_) {
89
- // process may already be gone
90
- }
91
- }
92
-
93
- if (pid && !Number.isNaN(pid)) {
94
- try {
95
- process.kill(pid, 'SIGKILL')
96
- } catch (_) {
97
- // process may already be gone
98
- }
99
- }
100
- }
101
- } catch (error) {
102
- console.error(`Error killing process tree for ${pid}: ${error}`)
40
+ process.kill(-pid, 'SIGKILL')
41
+ } catch (_) {}
42
+ try {
43
+ process.kill(pid, 'SIGKILL')
44
+ } catch (_) {}
45
+ }, 100)
103
46
  }
104
47
  }
105
48
 
106
- function isChildProcess(proc: ProcessType): proc is ChildProcess {
107
- return 'killed' in proc && typeof (proc as any).on === 'function'
108
- }
109
-
110
49
  let isHandling = false
111
50
 
112
51
  export function handleProcessExit({
@@ -120,26 +59,27 @@ export function handleProcessExit({
120
59
 
121
60
  isHandling = true
122
61
  const processes: ProcessType[] = []
123
- const allChildPids = new Set<number>()
124
- let cleaning = false
62
+ let cleanupPromise: Promise<void> | null = null
125
63
 
126
- const cleanup = async (signal: NodeJS.Signals | string = 'SIGTERM') => {
127
- if (cleaning) return
128
- cleaning = true
64
+ const cleanup = (signal: NodeJS.Signals | string = 'SIGTERM'): Promise<void> => {
65
+ // return existing cleanup promise if already running, so process.exit
66
+ // override waits for the real cleanup instead of exiting early
67
+ if (cleanupPromise) return cleanupPromise
129
68
 
130
- // notify run.ts that we're in cleanup state
69
+ cleanupPromise = doCleanup(signal)
70
+ return cleanupPromise
71
+ }
72
+
73
+ const doCleanup = async (signal: NodeJS.Signals | string) => {
131
74
  setExitCleanupState(true)
132
75
 
133
- // suppress console output during cleanup for cleaner exit
134
76
  if (signal === 'SIGINT') {
135
77
  const noop = () => {}
136
78
  console.log = noop
137
79
  console.info = noop
138
80
  console.warn = noop
139
- // keep console.error for critical errors only
140
81
  }
141
82
 
142
- // wrap entire cleanup in a timeout to prevent hanging
143
83
  if (onExit) {
144
84
  try {
145
85
  await onExit({ signal })
@@ -148,68 +88,35 @@ export function handleProcessExit({
148
88
  }
149
89
  }
150
90
 
151
- // skip cleanup if no processes to clean up
152
91
  if (processes.length === 0) {
153
92
  return
154
93
  }
155
94
 
156
- // for SIGINT (Ctrl+C), be more aggressive with cleanup
95
+ // kill process groups synchronously - no pgrep, no races
96
+ // detached: true makes each child a process group leader,
97
+ // so kill(-pid) gets the entire group in one syscall
157
98
  const isInterrupt = signal === 'SIGINT'
158
99
  const killSignal = isInterrupt ? 'SIGTERM' : (signal as NodeJS.Signals)
159
100
 
160
- await Promise.all(
161
- processes.map(async (proc) => {
162
- if (proc.pid) {
163
- try {
164
- await killProcessTree(proc.pid, killSignal, isInterrupt)
165
- } catch (error) {
166
- console.error(
167
- `Error during graceful shutdown of process ${proc.pid}: ${error}`
168
- )
169
- }
170
- }
171
- })
172
- )
101
+ for (const proc of processes) {
102
+ if (proc.pid) {
103
+ killProcessGroup(proc.pid, killSignal, isInterrupt)
104
+ }
105
+ }
173
106
 
174
- // shorter wait for SIGINT
175
- await new Promise((res) => setTimeout(res, isInterrupt ? 50 : 200))
107
+ // brief wait for graceful shutdown
108
+ await new Promise((res) => setTimeout(res, isInterrupt ? 80 : 200))
176
109
 
177
- // force kill any remaining processes
110
+ // force kill any remaining
178
111
  for (const proc of processes) {
179
- try {
180
- if (!proc.exitCode && proc.pid) {
181
- await killProcessTree(proc.pid, 'SIGKILL', true)
182
- }
183
- } catch (err) {
184
- // process already gone
112
+ if (proc.pid && !proc.exitCode) {
113
+ killProcessGroup(proc.pid, 'SIGKILL')
185
114
  }
186
115
  }
187
116
  }
188
117
 
189
118
  const addChildProcess = (proc: ProcessType) => {
190
119
  processes.push(proc)
191
-
192
- // track pid if available
193
- const pid = (proc as any).pid
194
- if (pid) {
195
- allChildPids.add(pid)
196
-
197
- // for child processes, capture descendants once after a brief delay
198
- if (isChildProcess(proc)) {
199
- setTimeout(async () => {
200
- if (proc.pid) {
201
- try {
202
- const childPids = await getChildPids(proc.pid)
203
- for (const childPid of childPids) {
204
- allChildPids.add(childPid)
205
- }
206
- } catch {
207
- // ignore errors in background polling
208
- }
209
- }
210
- }, 300)
211
- }
212
- }
213
120
  }
214
121
 
215
122
  addProcessHandler(addChildProcess)
@@ -231,7 +138,9 @@ export function handleProcessExit({
231
138
  })
232
139
  }
233
140
 
234
- // intercept process.exit to ensure cleanup
141
+ // intercept process.exit to ensure cleanup completes before exiting.
142
+ // if cleanup is already running, this awaits the SAME promise instead of
143
+ // early-returning and calling originalExit prematurely.
235
144
  const originalExit = process.exit
236
145
  process.exit = ((code?: number) => {
237
146
  cleanup('SIGTERM').then(() => {