Ask around about any monorepo tool and you will hear the same story: a
vite still holding port 5173 after the run was cancelled, a tsc -w
from last Tuesday, a CI job whose cancellation left a database container
running until the runner was reclaimed. A task runner spawns processes.
The least it owes you is that they die when it does.
vx has one teardown, and every way a run can end goes through it.
The teardown
- Every live child's process group receives the signal vx got:
SIGINTstaysSIGINT, so a Ctrl-C cleanup runs, andSIGTERMorSIGHUParrives asSIGTERM. - vx waits a grace period, two seconds by default,
VX_KILL_GRACE_MSto change it. - It re-reads its registries, because a child may have spawned during
the grace, and sends
SIGKILLto anything still alive. - It reaps, so nothing is left as a zombie. Then the run finishes the way any run does: the summary prints, every telemetry sink flushes and every plugin tears down, each within its bound. Only then does the process exit.
The exit code says what happened: 130 after SIGINT, 143 after
SIGTERM, 129 after SIGHUP, 1 when a foreground persistent task
ended the run with a non-zero code. A second Ctrl-C during the grace skips the rest of it and
escalates immediately, for the case where you already know the child
will not listen.
Every way a run ends
The teardown is not a signal handler bolted to the side. It is the
same function called from every exit path:
-
A signal.
SIGINT,SIGTERMorSIGHUPto thevxprocess, including a CI cancellation.SIGHUPis the one that matters most here and is easiest to forget: a task runs in its own session, so a closing terminal no longer reaches it — onlyvxhears the hang-up, and unlessvxpasses it on the tree outlives the window. -
An abort.
run()is also a library call, and it takes anAbortSignal. Abort it and the same teardown runs; tasks that never started are reported asaborted, notskipped, so a summary can tell the two apart. -
A readiness timeout. A persistent task whose
readyWhennever matched isSIGTERMed, thenSIGKILLed after the grace if it ignores that. -
A foreground persistent task exiting.
vx run devwith three servers up: when one exits, the other two are torn down, andvxexits 1 if that one failed. - The normal end of a run. Persistent tasks that gated other work are torn down when the graph completes.
-
vx watch. A Ctrl-C mid-cycle tears the cycle's children down first and returns only once they are gone, so a cancelled watch never orphans a task.
The tests are the claim
Every one of those paths has a test that spawns a real child, records
its pid to a marker file, ends the run the way that path ends it, and
asserts the child is dead within the window, with a zombie counting as
alive. The window is the shortest one that still fails without the fix,
because a timed wait in a test is a claim about time and a generous
window would hide a regression that merely got slower. "The task has
started" is a marker file, never a sleep.
That is also why the grace is a named constant rather than a literal:
SIGNAL_SHUTDOWN_GRACE_MS is the default, VX_KILL_GRACE_MS is the
env var the tests set to 200 ms so they prove the escalation without
waiting two seconds each, and a change to either is a visible diff.
One more refusal
A task can run vx itself. If it runs vx against the same
workspace, the inner run would build the same graph, claim the same
cache, and, on Ctrl-C, race the outer teardown for the same children.
vx exports the workspace it is running into the task's environment and
refuses a nested run on it with a clear error, instead of letting the
recursion look like it worked.
Originally published on the vx blog. vx is an MIT task runner and build cache for JS monorepos: github.com/vznjs/vx.
Written with AI assistance.
Top comments (0)