System Pulse lets an AI agent read a Windows machine’s health: crashes, hardware errors, memory, disk space. An agent app starts it the way it starts any tool server, and when the app is done, it stops it by ending the process it started.
That last step is where we found a problem. System Pulse ships as one executable, but on Windows that one file runs as two processes: a small launcher that unpacks the program, and the server itself. The agent app ends the one it started, the launcher. Before version 1.15.0, the server and the PowerShell sessions it uses to read the machine kept running after that, for as long as the app held its connection open.
On one machine we measured it: three processes, all three still alive fifteen seconds after the app had stopped the tool. A tool that reads your machine’s health shouldn’t leave its own processes behind.
The fix, and what it covers
Windows has a way to tie processes together: a job object. A job can be set to end every process in it when its last handle closes, and processes those members start stay in the same job.
Since 1.15.0, System Pulse puts the PowerShell sessions and the decoder it starts into one job like that, before PowerShell is given anything to run. When an agent app runs it the usual way, talking to it over its input and output, the server also watches the launcher the app started, and ends when the launcher does. If a forced stop leaves the unpacked program behind in the temporary folder, the next time an agent app starts it that way, it removes the leftovers, but never a copy that’s still running.
It’s worth saying exactly what this doesn’t cover:
- Processes started by the launcher and the updater are kept out of the job on purpose, because they’re meant to outlive the server.
- On Windows before 8, which can’t nest jobs, a process can be refused if System Pulse itself is running inside another job. That process runs as before, and the refusal is logged. If the job can’t be created at all, nothing is protected, and that’s logged too.
- Some work is done for PowerShell by Windows’ own services, such as answering a system query. Those processes are Windows’, not ours. That’s how those services work in general; we haven’t observed it here.
Closing a loophole in the check
The first version of the test watched whether PowerShell was still busy after the server was killed. That can pass with no job at all, if PowerShell happens to finish early.
So the check now suspends each PowerShell process before killing the server. A suspended process can’t read the end of its input and finish normally, so the early-finish loophole is gone: the check is about the job, not about timing.
On the same machine, the fixed build passed all three checks (the launcher stopped, the server killed, the leftover folder swept), with nothing left running. 1.14.0 failed all three.
Three failures our own runs missed
The check runs on a fresh Windows machine in the cloud, alongside the full test suite, on every change to the main branch. Together they exposed three failures before main went green, each one missed by our local runs on a warm, long-running machine:
- A cold machine. On a freshly started runner, the first health reading timed out, so there was no PowerShell to test. The check now retries until it gets one (up to three times) and still fails if it never does.
- A pipe that wasn’t handed down. The check failed on Windows. We guessed a slow start, reproduced a similar failure on Linux, fixed that and pushed it, and Windows failed the same way again. The real cause depended on a platform difference: with the launch settings we use, a grandchild process inherits the open pipe on Linux but not on Windows. On Windows the check could therefore read “everything stopped” a moment before it was true, a race that can’t occur on Linux. The fault was in the check, not the product. The lesson we kept: before claiming a fix, name the platform difference the cause depends on, and test under that platform’s rules.
- Midnight. One run crossed midnight UTC between setting up its test data and reading it, so two age calculations came out a day older than expected.
Where the check stands today
The check runs on every change to the main branch. A release’s update-channel signature is made only after every check passes on its exact commit, and the full suite and this check both passed on 1.15.0’s commit. The release file itself is built from that commit on our own machine, so the check ran on the cloud’s build of the same code, not on the release file itself. After signing, the release file gets its own quick test, started the way an agent app starts it, and nothing is uploaded unless it passes.
What another lab’s reviewer found
The code that decides what to clean up had its own tests, and they caught every deliberate bug we planted in it: 16 of 16. A reviewer model from another lab still found two design flaws those tests couldn’t: a folder could be deleted on its name alone, and a folder whose owner couldn’t be determined was treated as abandoned. Both could have deleted something that wasn’t ours. Tests check the code against its design. An independent reviewer can question the design itself.