# 0249 — A test run leaves no process behind

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0249-a-test-run-leaves-no-process-behind.md

---
# 0249 — A test run leaves no process behind

**Status:** Accepted
**Date:** 2026-10-06

Practice. Invariant P.

## Context

Tests of the process primitives start real processes: leaders, members of their groups, holders
that left the group with `setsid`. A test that registers them in a list after its first assertion
leaks them when that assertion fails or the test times out first, and a runner stopped by a signal
leaks everything it had started. The suite stays green; the processes live on with `PPID=1`,
for days, on a developer machine and a CI runner. Nothing looked for them.

Finding them from what they look like does not work. A command started by `runNativeCommand` leads
a session and a process group of its own, has an empty environment (`envPolicy: 'declared-only'`),
and keeps only the working directory it inherited, which says nothing about whose test it was. A
holder may call `setsid` itself. A probe confirmed it: a test cut off by its timeout in the middle
of a stop's grace left its leader and member running, and neither a session id nor the environment
named them.

## Decision

**A test owns what it starts, from before it can exist.** `tests/support/process-reaper.ts` takes a
registration at the moment the test could still fail: a leader's group through
`onLeaderStarted: trackGroupLeader`, a child the test spawns itself by pid (`trackProcess`), and a
member that publishes its pid in a file by the file's path (`trackPidFile`, read when the test
ends). `reapAfterEachTest()` kills all of it in `afterEach`, which runs after a failure and after a
timeout, and when the runner receives SIGINT, SIGTERM or SIGHUP.

**The run is checked by who the kernel says was orphaned, not by what the process looks like.**
`bun run test` and `bun run consumer-lane` of the core package run under `scripts/test-leak-gate.ts`. The gate sets
`PR_SET_CHILD_SUBREAPER` on itself, so the kernel reparents every process whose parent died to the
gate instead of to init, whatever its session, group, environment or working directory. When the
command has exited, a live child of the gate that is not the command was left behind by it. The gate
waits up to three seconds for a member a test just stopped to disappear, then names the survivors,
kills them and fails the run even when every test passed. A runner killed with SIGKILL runs no
`afterEach`; the gate still sees what it left.

Linux only. Where the kernel cannot do it the gate runs the command unchecked and prints that the
leak check was not measurable, never that it passed.

## Consequences

- A test that starts a process outside the registered shapes fails the run until it registers it;
  the gate names the process and its command line.
- The subreaper adopts orphans for the length of the run, so a test cannot observe that a process
  was reparented to init. None does.
- Reaping by group id is only sound for `group: 'own'`; a `group: 'caller'` leader is tracked by
  pid, because its group is the runner's.

## Verification

`scripts/test-leak-gate.test.ts` runs the real gate as a child: a clean command keeps its exit
code and prints nothing; a member left behind is named, killed, and fails a green command; a holder
that left its session with `setsid` is found; a member that ends inside the settle window is not
reported; a failing command keeps its own code. The first two controls are the ones that can fail:
an empty report from a gate that never saw a leak proves nothing.
