Skip to content

Commit 89fef36

Browse files
committed
feat: ensure kill confirmation
1 parent faecb84 commit 89fef36

3 files changed

Lines changed: 141 additions & 19 deletions

File tree

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,23 +81,25 @@ const list = tree.sync({ pid: 123, recursive: true })
8181
```
8282

8383
### kill(pid, opts?, callback?)
84-
Kills a process and optionally verifies it has exited.
84+
Kills a process and waits for it to exit. The returned promise resolves once the process is confirmed dead, or rejects on timeout.
8585

8686
```ts
8787
import { kill } from '@webpod/ps'
8888

89+
// Sends SIGTERM, polls until the process is gone (default timeout 30s)
8990
await kill(12345)
9091

9192
// With signal
9293
await kill(12345, 'SIGKILL')
9394

94-
// With options and verification callback
95-
await kill(12345, { signal: 'SIGKILL', timeout: 10 }, (err, pid) => {
95+
// With custom timeout (seconds) and polling interval (ms)
96+
await kill(12345, { signal: 'SIGKILL', timeout: 10, interval: 250 })
97+
98+
// With callback
99+
await kill(12345, (err, pid) => {
96100
// called when the process is confirmed dead or timeout is reached
97101
})
98102
```
99103

100-
When a `callback` is provided, `kill` polls the process list until the process disappears or the `timeout` (default 30s) is reached.
101-
102104
## License
103105
[MIT](./LICENSE)

src/main/ts/ps.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export type TPsLookupCallback = (err: any, processList?: TPsLookupEntry[]) => vo
6161
export type TPsKillOptions = {
6262
timeout?: number
6363
signal?: string | number | NodeJS.Signals
64+
/** Polling interval in ms between exit checks (default 200). */
65+
interval?: number
6466
}
6567

6668
export type TPsTreeOpts = {
@@ -170,7 +172,7 @@ export const kill = (pid: string | number, opts?: TPsNext | TPsKillOptions | TPs
170172
if (typeof opts === 'string' || typeof opts === 'number') return kill(pid, { signal: opts }, next)
171173

172174
const { promise, resolve, reject } = makeDeferred()
173-
const { timeout = 30, signal = 'SIGTERM' } = opts || {}
175+
const { timeout = 30, signal = 'SIGTERM', interval = 200 } = opts || {}
174176

175177
try {
176178
process.kill(+pid, signal)
@@ -180,17 +182,14 @@ export const kill = (pid: string | number, opts?: TPsNext | TPsKillOptions | TPs
180182
return promise
181183
}
182184

183-
if (!next) {
184-
resolve(pid)
185-
return promise
186-
}
187-
188185
let confirmCount = 0
189186
let timedOut = false
190187

191188
const timer = setTimeout(() => {
192189
timedOut = true
193-
next(new Error('Kill process timeout'))
190+
const err = new Error('Kill process timeout')
191+
reject(err)
192+
next?.(err)
194193
}, timeout * 1000)
195194

196195
const poll = () =>
@@ -199,21 +198,21 @@ export const kill = (pid: string | number, opts?: TPsNext | TPsKillOptions | TPs
199198
if (err) {
200199
clearTimeout(timer)
201200
reject(err)
202-
next(err, pid)
201+
next?.(err, pid)
203202
return
204203
}
205204
if (list.length > 0) {
206205
confirmCount = Math.max(confirmCount - 1, 0)
207-
poll()
206+
setTimeout(poll, interval)
208207
return
209208
}
210209
confirmCount++
211210
if (confirmCount >= 5) {
212211
clearTimeout(timer)
213212
resolve(pid)
214-
next(null, pid)
213+
next?.(null, pid)
215214
} else {
216-
poll()
215+
setTimeout(poll, interval)
217216
}
218217
})
219218

src/test/ts/ps.test.ts

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { describe, it, before, after } from 'node:test'
33
import process from 'node:process'
44
import { fork, execSync } from 'node:child_process'
55
import * as path from 'node:path'
6-
import { kill, lookup, lookupSync, tree, treeSync, removeWmicPrefix, normalizeOutput } from '../../main/ts/ps.ts'
6+
import { kill, lookup, lookupSync, tree, treeSync, removeWmicPrefix, normalizeOutput, filterProcessList } from '../../main/ts/ps.ts'
77
import { parse } from '@webpod/ingrid'
88

99
const __dirname = new URL('.', import.meta.url).pathname
@@ -40,6 +40,14 @@ describe('lookup()', () => {
4040
assert.equal(list.length, 1)
4141
assert.equal(list[0].pid, pid)
4242
})
43+
44+
if (process.platform !== 'win32') {
45+
it('supports custom psargs', async () => {
46+
const list = await lookup({ pid, psargs: '-eo pid,ppid,args' })
47+
assert.equal(list.length, 1)
48+
assert.equal(list[0].pid, pid)
49+
})
50+
}
4351
})
4452

4553
describe('lookupSync()', () => {
@@ -86,7 +94,6 @@ describe('tree()', () => {
8694
const childrenAll = await tree({ pid, recursive: true })
8795

8896
await Promise.all(list.map(p => kill(p.pid)))
89-
await kill(pid)
9097

9198
assert.equal(children.length, 1)
9299
assert.equal(childrenAll.length, 2)
@@ -113,7 +120,6 @@ describe('treeSync()', () => {
113120
const childrenAll = treeSync({ pid, recursive: true })
114121

115122
await Promise.all(list.map(p => kill(p.pid)))
116-
await kill(pid)
117123

118124
assert.equal(children.length, 1)
119125
assert.equal(childrenAll.length, 2)
@@ -170,6 +176,115 @@ describe('ps -eo vs ps -lx output comparison', { skip: process.platform === 'win
170176
})
171177
})
172178

179+
describe('kill() edge cases', () => {
180+
it('rejects when killing a non-existent pid', async () => {
181+
await assert.rejects(() => kill(999999), { code: 'ESRCH' })
182+
})
183+
184+
it('rejects with invalid signal', async () => {
185+
const pid = spawnChild()
186+
await assert.rejects(() => kill(pid, 'INVALID'))
187+
killSafe(pid)
188+
})
189+
190+
it('passes signal as string shorthand', async () => {
191+
const pid = spawnChild()
192+
await kill(pid, 'SIGKILL')
193+
assert.equal((await lookup({ pid })).length, 0)
194+
})
195+
196+
it('invokes callback on error for non-existent pid', async () => {
197+
let cbErr: any
198+
await kill(999999, (err) => { cbErr = err }).catch(() => {})
199+
assert.ok(cbErr)
200+
})
201+
})
202+
203+
describe('kill() timeout', { skip: process.platform === 'win32' }, () => {
204+
it('rejects on timeout when process stays alive', async () => {
205+
// Signal 0 checks existence but doesn't actually kill — process stays alive, so poll times out
206+
const pid = spawnChild()
207+
await assert.rejects(
208+
() => kill(pid, { signal: 0 as any, timeout: 1 }),
209+
(err: Error) => err.message.includes('timeout')
210+
)
211+
killSafe(pid)
212+
})
213+
})
214+
215+
describe('tree() edge cases', () => {
216+
it('accepts string pid', async () => {
217+
const pid = spawnChild()
218+
const children = await tree(String(pid))
219+
assert.ok(Array.isArray(children))
220+
killSafe(pid)
221+
})
222+
223+
it('treeSync accepts number pid', () => {
224+
const pid = spawnChild()
225+
const children = treeSync(pid)
226+
assert.ok(Array.isArray(children))
227+
killSafe(pid)
228+
})
229+
})
230+
231+
describe('filterProcessList()', () => {
232+
const list = [
233+
{ pid: '1', ppid: '0', command: '/usr/bin/node', arguments: ['server.js', '--port=3000'] },
234+
{ pid: '2', ppid: '1', command: '/usr/bin/python', arguments: ['app.py'] },
235+
{ pid: '3', ppid: '1', command: '/usr/bin/node', arguments: ['worker.js'] },
236+
]
237+
238+
it('filters by pid array', () => {
239+
assert.equal(filterProcessList(list, { pid: ['1', '3'] }).length, 2)
240+
})
241+
242+
it('filters by command regex', () => {
243+
assert.equal(filterProcessList(list, { command: 'node' }).length, 2)
244+
})
245+
246+
it('filters by arguments regex', () => {
247+
assert.equal(filterProcessList(list, { arguments: 'port' }).length, 1)
248+
})
249+
250+
it('filters by ppid', () => {
251+
assert.equal(filterProcessList(list, { ppid: 1 }).length, 2)
252+
})
253+
254+
it('returns all when no filters', () => {
255+
assert.equal(filterProcessList(list).length, 3)
256+
})
257+
})
258+
259+
describe('normalizeOutput()', () => {
260+
it('skips entries without pid', () => {
261+
const data = [{ COMMAND: ['node'] }] as any
262+
assert.equal(normalizeOutput(data).length, 0)
263+
})
264+
265+
it('skips entries without command', () => {
266+
const data = [{ PID: ['1'] }] as any
267+
assert.equal(normalizeOutput(data).length, 0)
268+
})
269+
270+
it('handles ARGS header (macOS)', () => {
271+
const data = [{ PID: ['1'], PPID: ['0'], ARGS: ['/usr/bin/node server.js'] }] as any
272+
const result = normalizeOutput(data)
273+
assert.equal(result.length, 1)
274+
assert.ok(result[0].command)
275+
})
276+
277+
it('handles quoted paths on Windows', () => {
278+
const data = [{
279+
ProcessId: ['1'],
280+
ParentProcessId: ['0'],
281+
CommandLine: ['"C:\\Program Files\\node.exe" server.js']
282+
}] as any
283+
const result = normalizeOutput(data)
284+
assert.equal(result.length, 1)
285+
})
286+
})
287+
173288
describe('removeWmicPrefix()', () => {
174289
it('extracts wmic output', () => {
175290
const input = `CommandLine
@@ -184,4 +299,10 @@ PS C:\\Users\\user>`
184299
const sliced = removeWmicPrefix(input).trim()
185300
assert.equal(sliced, input.slice(0, -'PS C:\\Users\\user>'.length - 1).trim())
186301
})
302+
303+
it('handles output without prompt suffix', () => {
304+
const input = `ParentProcessId ProcessId\n0 1`
305+
const result = removeWmicPrefix(input)
306+
assert.ok(result.includes('ProcessId'))
307+
})
187308
})

0 commit comments

Comments
 (0)