# wmic is gone and your Node process tree is silently wrong on Windows 11

> Source: <https://dev.to/eliseomdq/wmic-is-gone-and-your-node-process-tree-is-silently-wrong-on-windows-11-3e8c>
> Published: 2026-08-23 22:59:00+00:00

The last post I wrote here ended on a line I had to go and check: killing a shell does not kill what the shell started. This is what I found underneath it, and one of the things is a dependency a lot of people have without knowing.

Numbers below come from Windows 11 Home, build 26200, in August 2026. Run the commands yourself, they take a minute. I work on NestMux, which is where I hit this, but nothing here is specific to it.

A terminal pane running a coding agent reported 77 MB of memory. That is roughly the PowerShell host and nothing else. The agent inside it, and the dev server the agent had started, were not being counted.

So the process tree was empty. Every pane looked like an idle shell.

```
Get-Command wmic
Get-Command : The term 'wmic' is not recognized as the name of a cmdlet...
```

Microsoft deprecated WMIC in Windows 10 21H1 and started removing it from the image in Windows 11 22H2. On a current install it is simply not there. This is documented and it was announced, and it is still going to break things for years, because `wmic`

is buried inside libraries rather than in the code people wrote.

`pidtree`

is the standard way to get a process tree from Node. It backs `pkill`

-style behavior in a lot of tooling and it has millions of downloads a week. On Windows, version 0.6.0 shells out to `wmic`

.

``` js
npm i pidtree@0.6.0
node -e "require('pidtree')(process.pid,{root:true}).then(console.log).catch(e=>console.log('FAIL:',e.message))"
FAIL: spawn wmic ENOENT
```

Version 1.0.0 fixed it. It still tries `wmic`

first, and falls back to PowerShell `Get-CimInstance`

when that fails:

``` js
npm i pidtree@1.0.0
node -e "const m=require('pidtree');(m.default||m)(process.pid,{root:true}).then(console.log)"
OK [ 30520, 36000, 29244 ]
```

So check what you actually resolve, not what your `package.json`

says. A caret range of `^0.6.0`

will never reach 1.0.0. That was our case: the range said `^0.6.0`

, the lockfile said `0.6.0`

, and every call on Windows was throwing `ENOENT`

into a `Promise.allSettled`

that treated the rejection as "no children" and moved on. It was not an error anyone saw. It was a tree of size one.

That is the part worth repeating. This failure does not look like a failure. It looks like a process with no children.

If you cannot upgrade, the replacement is `Get-CimInstance Win32_Process`

. The instinct is to query per PID with a filter. Do not:

```
# one snapshot of everything
Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId
# 396 processes in 200 ms

# five filtered queries
1..5 | ForEach-Object { Get-CimInstance Win32_Process -Filter "ParentProcessId=$PID" }
# 517 ms
```

One full snapshot of 396 processes costs less than five filtered queries. The cost is in starting the CIM call, not in the rows. Take one snapshot per polling cycle, build a `parent -> children[]`

map from it, and walk the map in memory for every PID you care about. If you are polling several panes every few seconds, this is the difference between a background cost you can ignore and one you cannot.

Now the part from the previous post, with the actual output.

Start a grandchild. Root `cmd`

spawns a `cmd`

, which spawns `timeout`

:

``` php
$g = Start-Process cmd -PassThru -WindowStyle Hidden -ArgumentList '/c','cmd /c timeout /t 90 /nobreak'
@(Get-CimInstance Win32_Process -Filter "Name='timeout.exe'").Count
# 1
```

Kill the root and only the root:

```
Stop-Process -Id $g.Id -Force
@(Get-CimInstance Win32_Process -Filter "Name='timeout.exe'").Count
# 1
```

Still alive. Windows has no process groups in the POSIX sense and no automatic reparenting cleanup. `Stop-Process`

, and `process.kill(pid)`

from Node, both terminate exactly one process. The descendants keep running, holding their file handles and their ports, with a parent PID that now points at a dead process.

`taskkill /F /T`

walks it properly:

```
taskkill /F /T /PID $g.Id
SUCCESS: the process with PID 29828 (child process of PID 34112) has been terminated.
SUCCESS: the process with PID 39928 (child process of PID 37072) has been terminated.
SUCCESS: the process with PID 34112 (child process of PID 37072) has been terminated.
SUCCESS: the process with PID 37072 (child process of PID 39620) has been terminated.
```

Deepest first, then up. That ordering is the whole point: it resolves the tree while the links are still intact.

Which gives you the trap. If you kill the root first and then go looking for the descendants, the links you needed are gone. The orphans are still there, they are still holding whatever they were holding, and nothing connects them to the thing you were trying to clean up. Resolve the tree first, or hand the root to `taskkill /F /T`

and let it do both.

One hazard while you are in here: Windows reuses PIDs, and aggressively. A PID you cached thirty seconds ago and are about to force-kill may now be something else. If you keep a PID map across polling cycles, validate it against a fresh snapshot before you kill anything.

The uncomfortable case. A coding agent starts a dev server in the background. On Windows that server frequently ends up with no usable ancestry back to the pane: the intermediate process has exited, the parent PID is stale or recycled, and the binary path is just `node.exe`

. The command line does not carry the worktree it belongs to either.

What is left is the process's current directory. If the server's cwd is inside a given worktree, it belongs to that worktree.

There is no Node API for reading another process's cwd on Windows. On Linux it is `/proc/<pid>/cwd`

, on macOS it is `lsof`

, and on Windows you go through the kernel:

``` js
const koffi = require('koffi')
const ntdll = koffi.load('ntdll.dll')
const NtQueryInformationProcess = ntdll.func(
  'long __stdcall NtQueryInformationProcess(void*, int, _Out_ uint8_t*, uint32_t, _Out_ uint32_t*)'
)
// OpenProcess with PROCESS_QUERY_INFORMATION | PROCESS_VM_READ,
// then NtQueryInformationProcess to get PROCESS_BASIC_INFORMATION,
// then ReadProcessMemory to walk:
//   PEB + 0x20 -> ProcessParameters -> CurrentDirectory (UNICODE_STRING)
```

The offsets are x64 Windows 10 and 11. They are not a public contract, which is the honest caveat here: this is a documented-enough structure that tooling has relied on it for twenty years, and it is still something Microsoft can move.

Two things you will hit immediately. Elevated processes and processes from another user reject `OpenProcess`

with access denied, and you get nothing back for them, so whatever feature you built on this silently does not apply to Docker Desktop and to services. Log those once per PID rather than per poll, otherwise a five second polling loop buries the console.

`pidtree`

below 1.0.0. If you do, your Windows process trees are size one and nothing told you.`process.kill`

and `Stop-Process`

are not tree kills. `taskkill /F /T`

is.If you maintain something that manages child processes on Windows and you have a cleaner answer than the PEB walk, I would like to hear it. That is the part of this I am least happy with.
