# Strays

> This skill should be used when things start failing for no clear reason — when the user says "everything is timing out", "the machine got slow", "all my commands are failing", "why is this hanging", "builds keep timing out", "the dev server is already running", "port already in use", or when several unrelated commands fail in a row during a long session. Also use before writing code that spawns subprocesses, so the spawn cannot leak. Finds processes that outlived whatever started them, and prevents creating them.

- Skill: `lkc-studio/strays` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add lkc-studio/strays`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lkc-studio/strays/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: lkc-studio (https://skillmd.com/u/lkc-studio)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lkc-studio/strays

---


# Strays: processes that outlived what started them

## Read this first

> **When several unrelated things start failing at once, the cause is almost
> never several unrelated bugs. It is one shared resource.**

CPU, memory, disk, file descriptors, ports, a lock. Check the shared resource
*before* debugging any individual failure.

This matters more for an agent than for a person, because the instinctive
response to a timeout — retry it — spawns another process and makes the real
problem worse. Two retries of a hung test suite is two more permanent CPU
burners.

## The symptom

Failures that arrive together and make no sense individually:

- commands that worked five minutes ago now time out
- tests fail with no code change, differently each run
- builds hang partway with no error
- `port already in use` when nothing should be listening
- background tasks report failure with empty output

The tell is **breadth**. One flaky test is a flaky test. Six unrelated things
failing in ten minutes is a machine problem.

## Step 1: look at load before looking at code

```bash
scripts/strays.py
```

The first line is the one that matters:

```
  load 69.90 / 4 cores -- OVERLOADED -- almost everything will fail or hang
```

Load is the number of processes wanting CPU. Compare it against core count, not
against zero:

| load ÷ cores | Meaning |
| --- | --- |
| under 0.7 | healthy |
| 0.7 – 1.5 | busy, normal under a build |
| 1.5 – 4 | saturated; unrelated commands start timing out |
| over 4 | overloaded; assume every timeout is a symptom, not a bug |

If the ratio is above ~1.5, **stop debugging the failure**. It is downstream.

## Step 2: find what is holding the machine

`strays.py` flags a process when it is either a tool that should have finished,
or an unrecognised process that is both orphaned and burning CPU:

```
  6 stray process(es), 316% CPU between them:

    pid 111503    51.8%   2h35m  python -m pytest -q -x
                 why: 52% CPU for 2h35m; orphaned (parent exited)
```

Being orphaned is not by itself suspicious — daemons are reparented to init by
design. A `pytest` that has been running for two and a half hours is.

It also lists dev-server ports still held, which is where `address already in
use` comes from.

## Step 3: clean up, having looked

```bash
scripts/strays.py --kill
```

It reports first and kills nothing without that flag, because the judgement is
not automatable: **a long compile is not a stray.** Read the list. `SIGTERM`
goes first, then `SIGKILL` to whatever ignored it, and always to the whole
process group.

It never touches itself, its own ancestors, or `systemd`, `sshd`, `docker`,
`tmux`, `claude` — killing a parent of the current session would end the session.

Load falls slowly; it is an average. Judge recovery by the process list, not the
number.

## Step 4: fix the source, or it comes back

Cleaning up is not the fix. Find which spawn leaked and repair it, or the same
processes return on the next run.

The overwhelmingly common cause is **killing a shell instead of a process tree**:

```python
# Leaks. shell=True runs /bin/sh -c "pytest ..."; the timeout kills the shell
# and orphans pytest, which keeps running -- forever, if the code it is testing
# happens to loop.
subprocess.run(cmd, shell=True, timeout=30)
```

```python
# Correct: own process group, and kill the group on timeout.
proc = subprocess.Popen(cmd, shell=True, start_new_session=True)
try:
    proc.wait(timeout=30)
except subprocess.TimeoutExpired:
    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
    proc.wait(timeout=10)
```

`references/spawning.md` has the equivalent for Node, Go, Rust and bash, plus
cleanup on interrupt and the double-fork case.

## Rules for spawning anything

1. **Every spawn gets a timeout.** No exceptions — a hang with no timeout is a
   leak waiting for the parent to die.
2. **Every timeout kills the group, not the process.** `start_new_session=True`
   plus `killpg`. Killing the direct child is the bug, not the fix.
3. **Reap what was killed.** `wait()` afterwards, or it lingers as a zombie.
4. **Clean up on the way out too** — `try/finally`, or a signal handler. Ctrl-C
   is the most common way a run ends, and the least tested.
5. **Prefer a list argv over `shell=True`.** No shell means no intermediate
   process to lose track of.
6. **Long-running servers get a recorded PID and an explicit stop**, not a hope
   that something will tidy up later.

## Before ending a long session

Run `scripts/strays.py` once. A session that started dev servers, watchers or
test runs has probably left something behind, and the next session inherits a
slower machine with no idea why.

## Resources

- **`scripts/strays.py`** — reports load against core count, finds abandoned
  processes with reasons, lists held ports. `--kill` to clean up, `--json` for
  scripting. Refuses to touch itself, its ancestors, or system processes.
- **`references/spawning.md`** — leak-proof spawning in Python, Node, Go, Rust
  and bash; process groups explained; interrupt handling; container and CI notes.

