Tuning tman for Vitest

JavaScript / TypeScript

Watch mode never exits; the thread pool is where memory goes.

capstart atwhat it answers
stall10mhang backstop — no output and no CPU/IO activity for this long
max-time15mwall-clock budget — the only cap that bounds a run that is working but will never finish
max-mem4096RSS ceiling across the process tree
max-parallel2slots per name-and-directory bucket

These are starting points derived from how Vitest behaves, not measurements of your machine. Every one of them should be replaced by a number you took from your own runs — the last section on this page is how.

.tman.kdl
alias "test" {
    command "npx"
    args "vitest" "run"
    max-time "15m"
    max-mem 4096
}

Vitest streams results as they land, so output-based stall detection sees it constantly and the backstop rarely has to reason about anything. The two things worth configuring are the mode and the memory ceiling.

Always `vitest run`, never bare `vitest`

Bare vitest enters watch mode and stays resident. Under an agent that is a durable problem: the agent gets no exit code, moves on, and leaves a file watcher and a worker pool alive for the rest of the session. The alias should pin run so the mode cannot be inherited from a config file or a habit.

Never put a wall-clock cap on vitest --watch. It is designed not to exit, so max-time guarantees a kill and an exit 124 that means nothing. Use --name instead: the dedup lock is what you actually want, because it stops an agent starting a second copy on top of the first.

Where the memory goes

The default pool forks worker threads, and a suite that mounts a DOM per test — jsdom or happy-dom — accumulates across a run when teardown is incomplete. A max-mem of 4 GB is high enough that an ordinary suite never sees it and low enough that a leak is caught before the machine starts swapping. On Linux the ceiling is summed across the whole process tree, so it covers the workers, not just the parent.

If you hit 126 repeatedly, cap the pool before you raise the ceiling: --pool=threads --poolOptions.threads.maxThreads=4 bounds the multiplier that turns a small per-worker leak into an OOM.

Sizing the stall backstop

Vitest's quietest stretch is the first transform of a cold dependency graph — a large monorepo can sit for a minute or two before the first test result appears. 10m leaves an order of magnitude of headroom over that while still catching a wedged worker in a coffee break rather than a working day.

Turn the starting point into a real number

Everything above is derived from how this tool behaves, not from your machine. The numbers that matter come from your own runs, and getting them takes one command:

shell
tman status --json | jq '.runs[] | {name, exit, duration_ms}'

Set max-time from the slowest successful run you are willing to wait for, with real headroom — a cap that fires on healthy work teaches everyone to raise the cap, and after a few rounds of that it bounds nothing. Set stall from the longest stretch the command legitimately produces no output and no CPU, then double it.

Exit 124, 125, and 126 are findings, not flakes. 124 is a wall-clock timeout, 125 a stall, 126 a resource cull. Widening the cap that fired is the right response only after you know why it fired — the first two times a suite trips max-mem, the interesting question is what is holding the memory.

Frequently asked

What tman caps should I use for Vitest?

Start with max-time 15m, stall 10m, max-mem 4096, max-parallel 2, and pin `vitest run` in the alias so watch mode can never be inherited. Adjust max-time from your own slowest successful run.

Why does my Vitest run get killed with exit 126?

126 is a resource cull — the process tree crossed max-mem or sustained max-cpu. With Vitest that usually means a worker pool accumulating DOM instances across tests. Cap the pool size before raising the ceiling; a ceiling raised to fit a leak stops being a ceiling.

Sources