Windows autostart has no supervisor, so a registered Startup entry reports the service as running when nothing is
exit codes · case
Symptom
Section titled “Symptom”You port a background service from launchd or systemd to Windows. status says
loaded, stop says stopped, and both are lies. The dashboard is green while the
process is dead; the stop command returns success while the process keeps running.
On the POSIX side you never wrote this bug, because you asked the supervisor and the supervisor knew.
A Startup-folder entry has no run-state at all:
PS> $startup = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup"PS> Copy-Item app.cmd $startupPS> Test-Path "$startup\app.cmd"True # true forever, whether or not it ever ranA Scheduled Task does have state, and that is the subtler half — it reports the TASK HOST, not the thing the host launched:
PS> schtasks /Query /TN MyApp /FO LIST /V | Select-String "Status|Last Result"Status: ReadyLast Result: 0Ready means “not currently executing the action”; Last Result: 0 means the
action it launched returned success. Your detached server started, the launcher
returned 0, and the task went back to Ready. That reads identically whether the
server is serving or died ninety seconds later.
launchd and systemd are supervisors: they own the process, so “is it loaded” and “is it running” are the same question and they answer it authoritatively.
Windows autostart mechanisms are not supervisors. A Startup-folder .cmd is a
file Explorer executes once at logon and then forgets entirely.
A Scheduled Task is closer but still not one. It tracks its own execution — the
API exposes TASK_STATE_RUNNING and the CLI shows Status and Last Result — and
that state is about the task instance. The moment your action detaches a server
and returns, the task is done and its state stops describing your process. So the
question “is the task registered” has an answer, “is the task running” has an
answer, and “is my daemon alive” has none.
Neither owns your process, so registration and liveness are independent facts, and code ported from a supervisor platform reads the one it can get as the one it wants.
The tempting fallback makes it worse. Probing your own HTTP health endpoint is unattributable: if your process died and a foreign one bound the port, the health check answers 200 and you report healthy about someone else’s server.
Workaround
Section titled “Workaround”Derive liveness from a pidfile you own and verify, and keep it separate from registration:
registered = the Startup entry or Scheduled Task existsrunning = pidfile exists AND that pid is alive AND it is ours“It is ours” needs a real check — the pid must still be alive and its identity must match what you recorded (start time, launch fingerprint, or a token the process wrote), because pids are reused. Write the pidfile from inside the process, not from the launcher.
After starting, poll for ownership to appear rather than asserting success; the launcher returning 0 only means the launch was requested. Report the two states separately in your UI, since “registered but not running” is a real and diagnosable condition on Windows in a way it is not under a supervisor.
The corpus’s other exit-code cases are about a command’s own status being lost or faked. This one is about a status nobody is tracking at all: there is no process supervisor to ask, so any code that assumes one invents an answer.