Commit afcb0eb

Anton Golub <antongolub@antongolub.com>
2025-07-14 15:08:59
feat: call `taskkill` for process killing on windows (#1269) tag: 8.7.1
1 parent 3b9aaeb
build/core.cjs
@@ -1010,8 +1010,18 @@ function cd(dir) {
 }
 function kill(_0) {
   return __async(this, arguments, function* (pid, signal = $.killSignal) {
-    const children = yield import_vendor_core2.ps.tree({ pid, recursive: true });
-    for (const p of children) {
+    if (import_node_process2.default.platform === "win32" && (yield new Promise((resolve) => {
+      (0, import_vendor_core2.exec)({
+        cmd: `taskkill /pid ${pid} /t /f`,
+        on: {
+          end({ error }) {
+            resolve(!error);
+          }
+        }
+      });
+    })))
+      return;
+    for (const p of yield import_vendor_core2.ps.tree({ pid, recursive: true })) {
       try {
         import_node_process2.default.kill(+p.pid, signal);
       } catch (e) {
docs/configuration.md
@@ -12,10 +12,26 @@ Or use a CLI argument: `--shell=/bin/bash`
 
 ## `$.spawn`
 
-Specifies a `spawn` api. Defaults to `require('child_process').spawn`.
+Specifies a `spawn` api. Defaults to native `child_process.spawn`.
 
 To override a sync API implementation, set `$.spawnSync` correspondingly.
 
+## `$.kill`
+Specifies a `kill` function. The default implements _half-graceful shutdown_ via `ps.tree()`. You can override with more sophisticated logic.
+
+```js
+import treekill from 'tree-kill'
+
+$.kill = (pid, signal = 'SIGTERM') => {
+  return new Promise((resolve, reject) => {
+    treekill(pid, signal, (err) => {
+      if (err) reject(err)
+      else resolve()
+    })
+  })
+}
+```
+
 ## `$.prefix`
 
 Specifies the command that will be prefixed to all commands run.
@@ -160,6 +176,7 @@ $.defaults = {
   spawn:          childProcess.spawn,
   spawnSync:      childProcess.spawnSync,
   log:            $.log,
+  kill:           $.kill,
   killSignal:     'SIGTERM',
   timeoutSignal:  'SIGTERM',
   delimiter:      /\r?\n/,
src/core.ts
@@ -64,6 +64,7 @@ import {
   bufArrJoin,
 } from './util.ts'
 import { log } from './log.ts'
+import * as child_process from 'node:child_process'
 
 export { default as path } from 'node:path'
 export * as os from 'node:os'
@@ -928,8 +929,15 @@ export function cd(dir: string | ProcessOutput) {
 }
 
 export async function kill(pid: number, signal = $.killSignal) {
-  const children = await ps.tree({ pid, recursive: true })
-  for (const p of children) {
+  if (
+    process.platform === 'win32' &&
+    (await new Promise((resolve) => {
+      child_process.exec(`taskkill /pid ${pid} /t /f`, (err) => resolve(!err))
+    }))
+  )
+    return
+
+  for (const p of await ps.tree({ pid, recursive: true })) {
     try {
       process.kill(+p.pid, signal)
     } catch (e) {}
test/smoke/win32.test.js
@@ -72,6 +72,20 @@ _describe('win32', () => {
     assert.equal(root.pid, process.pid)
   })
 
+  test('kill works', async () => {
+    const p = $({ nothrow: true })`sleep 100`
+    const { pid } = p
+    const found = await ps.lookup({ pid })
+    console.log('found:', found)
+    assert.equal(found.length, 1)
+    assert.equal(found[0].pid, pid)
+
+    await p.kill()
+    const killed = await ps.lookup({ pid })
+    console.log('killed:', killed)
+    assert.equal(killed.length, 0)
+  })
+
   test('abort controller works', async () => {
     const ac = new AbortController()
     const { signal } = ac
@@ -94,6 +108,7 @@ _describe('win32', () => {
 
     const o = await p
     assert.equal(o.signal, 'SIGTERM')
+    assert.throws(() => p.abort(), /Too late to abort the process/)
     assert.throws(() => p.kill(), /Too late to kill the process/)
   })
 })
test/core.test.js
@@ -1178,15 +1178,16 @@ describe('core', () => {
 
     describe('timeout()', () => {
       test('expiration works', async () => {
+        await $`sleep 1`.timeout(1000)
         let exitCode, signal
         try {
-          await $`sleep 1`.timeout(999)
+          await $`sleep 1`.timeout(200)
         } catch (p) {
           exitCode = p.exitCode
           signal = p.signal
         }
-        assert.equal(exitCode, undefined)
-        assert.equal(signal, undefined)
+        assert.equal(exitCode, null)
+        assert.equal(signal, 'SIGTERM')
       })
 
       test('accepts a signal opt', async () => {
.size-limit.json
@@ -15,7 +15,7 @@
       "README.md",
       "LICENSE"
     ],
-    "limit": "122.90 kB",
+    "limit": "123.15 kB",
     "brotli": false,
     "gzip": false
   },
@@ -29,7 +29,7 @@
       "build/globals.js",
       "build/deno.js"
     ],
-    "limit": "813.40 kB",
+    "limit": "813.70 kB",
     "brotli": false,
     "gzip": false
   },
@@ -62,7 +62,7 @@
       "README.md",
       "LICENSE"
     ],
-    "limit": "869.50 kB",
+    "limit": "869.70 kB",
     "brotli": false,
     "gzip": false
   }