Ctrl-C leaves nothing running
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
Section titled “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
Section titled “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
Section titled “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
Section titled “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.