Skip to content

Commit 4f12d69

Browse files
committed
Add --shm orphan sweep to tractor-reap
Since `tractor.ipc._mp_bs.disable_mantracker()` turns off `mp.resource_tracker` entirely (see the conc-anal doc `subint_forkserver_mp_shared_memory_issue.md`), a hard-crashing actor can leave `/dev/shm/<key>` segments that nothing else GCs. New `tractor-reap` phase 2 sweeps them. Deats, - `tractor/_testing/_reap.py`: add `find_orphaned_shm()` + `reap_shm()` helpers. Match criteria: regular file under `/dev/shm`, owned by current uid, AND no live proc has it open (mmap'd or fd-held). In-use enumeration via `psutil.Process.memory_maps()` + `.open_files()` — xplatform, kernel-canonical (same answer `lsof` would give), no reliance on tractor-specific shm-key naming. - `_ensure_shm_supported()` guard: helpers raise `NotImplementedError` outside Linux/FreeBSD bc macOS POSIX shm has no fs-visible path (`shm_open` only) and Windows is a different story. - `scripts/tractor-reap`: new `--shm` (run after process reap) and `--shm-only` (skip process phase) flags. `-n` dry-runs both phases. Exit code is `1` if either phase had survivors/errors. - `pyproject.toml` + `uv.lock`: add `psutil>=7.0.0` to the `testing` dep group; lazy-imported in `_reap.py` so the process-reap path stays import-clean without it. Also, - doc `--shm` in `.claude/skills/run-tests/SKILL.md` (new section 10c) — covers match criteria + the preservation guarantee for unrelated apps. - flip mitigation status in `subint_forkserver_mp_shared_memory_issue.md` from "could extend `tractor-reap`" to "implemented", with a note that callers should still UUID-pin shm keys to avoid cross-session collisions. Verified locally vs 81 in-use segments held by `piker`, `lttng-ust-*`, `aja-shm-*` — all preserved; only the genuinely-orphaned tractor segments got unlinked. (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code
1 parent aa3e230 commit 4f12d69

6 files changed

Lines changed: 388 additions & 47 deletions

File tree

.claude/skills/run-tests/SKILL.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,3 +585,41 @@ to force-reap under a still-live supervisor.
585585
active in another terminal. It's safe (won't touch
586586
that session's live children in orphan-mode) but can
587587
race if the target session is mid-teardown.
588+
589+
### c) `--shm` / `--shm-only`: orphan-segment sweep
590+
591+
Because `tractor.ipc._mp_bs.disable_mantracker()`
592+
turns off `mp.resource_tracker` (see
593+
`ai/conc-anal/subint_forkserver_mp_shared_memory_issue.md`),
594+
a hard-crashing actor can leave `/dev/shm/<key>`
595+
segments behind that nothing else GCs.
596+
597+
```sh
598+
# process reap THEN shm sweep
599+
scripts/tractor-reap --shm
600+
601+
# shm sweep only (skip process phase)
602+
scripts/tractor-reap --shm-only
603+
604+
# dry-run: list candidates, don't unlink
605+
scripts/tractor-reap --shm -n
606+
```
607+
608+
**Match criteria** (very conservative — this is a
609+
shared-system path, can't be wrong):
610+
- segment is a regular file under `/dev/shm`,
611+
- owned by the **current uid** (`stat.st_uid`),
612+
- AND **no live process holds it open**
613+
enumerated by walking every readable
614+
`/proc/<pid>/maps` (post-mmap mappings) AND
615+
`/proc/<pid>/fd/*` (pre-mmap shm-opened fds).
616+
617+
The "nobody has it open" check is the
618+
kernel-canonical "is this leaked?" test — same
619+
answer `lsof /dev/shm/<key>` would give. No
620+
reliance on tractor-specific naming, so it works
621+
for any tractor app. Critically, it WILL NOT touch
622+
segments held by other apps you have running
623+
(e.g. `piker`, `lttng-ust-*`, `aja-shm-*`
624+
verified locally with 81 in-use segments correctly
625+
preserved).

ai/conc-anal/subint_forkserver_mp_shared_memory_issue.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,14 +132,20 @@ segment (legitimate race in shared-key setups).
132132

133133
- **Crash-leaked segments.** If an actor segfaults
134134
or is `SIGKILL`'d before its lifetime stack runs,
135-
`/dev/shm/<key>` will leak. Mitigations:
136-
- `tractor-reap` (the new
137-
`scripts/tractor-reap` CLI) doesn't yet sweep
138-
`/dev/shm` — could extend it.
139-
- Higher-level apps using shm should pin a UUID
140-
into the key (the `'shml_<uuid>'` pattern in
141-
`test_child_attaches_alot`) so leaks are
142-
distinct per session and easy to GC out-of-band.
135+
`/dev/shm/<key>` will leak. Mitigation:
136+
`scripts/tractor-reap --shm` walks `/dev/shm`,
137+
filters to segments owned by the current uid that
138+
no live process is mapping or holding open (via
139+
`/proc/*/maps` + `/proc/*/fd/*`), and unlinks
140+
them. The "nobody-has-it-open" filter is
141+
kernel-canonical so it never touches in-flight
142+
segments held by sibling apps (verified locally
143+
against 81 piker/lttng/aja-held segments — all
144+
preserved).
145+
- Higher-level apps using shm should still pin a
146+
UUID into the key (the `'shml_<uuid>'` pattern
147+
in `test_child_attaches_alot`) so concurrent
148+
sessions don't collide on the same key.
143149
- **Cross-actor unlink races.** Two actors holding
144150
the same shm key racing on `unlink()` — handled
145151
by the `FileNotFoundError` swallow.

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ testing = [
8484
# known-hanging `subint`-backend audit tests; see
8585
# `ai/conc-anal/subint_*_issue.md`).
8686
"pytest-timeout>=2.3",
87+
# used by `tractor._testing._reap` for the
88+
# `tractor-reap` zombie-subactor + leaked-shm
89+
# cleanup utility (xplatform `Process.memory_maps`,
90+
# `Process.open_files`).
91+
"psutil>=7.0.0",
8792
]
8893
repl = [
8994
"pyperclip>=1.9.0",

scripts/tractor-reap

Lines changed: 87 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,26 @@
44
#
55
# SPDX-License-Identifier: AGPL-3.0-or-later
66
'''
7-
`tractor-reap` — SC-polite zombie-subactor reaper.
7+
`tractor-reap` — SC-polite zombie-subactor reaper +
8+
optional `/dev/shm/` orphan-segment sweep.
89
9-
Finds `tractor` subactor processes left alive after a
10-
`pytest` (or any tractor-app) run that failed to fully
11-
cancel its actor tree, then sends SIGINT with a bounded
12-
grace window before escalating to SIGKILL.
10+
Two cleanup phases (run in order when both are enabled):
1311
14-
Detection modes (auto-selected):
12+
1. **process reap** — finds `tractor` subactor processes
13+
left alive after a `pytest` (or any tractor-app) run
14+
that failed to fully cancel its actor tree, then sends
15+
SIGINT with a bounded grace window before escalating
16+
to SIGKILL.
17+
18+
2. **shm sweep** (`--shm` / `--shm-only`) — unlinks
19+
`/dev/shm/<file>` entries owned by the current uid
20+
that no live process has open (mmap'd or fd-held).
21+
Needed because `tractor` disables
22+
`mp.resource_tracker` (see `tractor.ipc._mp_bs`), so a
23+
hard-crashing actor leaves leaked segments that
24+
nothing else GCs.
25+
26+
Process-reap detection modes (auto-selected):
1527
1628
--parent <pid> : descendant-mode — kill procs whose
1729
PPid == <pid>. Use when a parent
@@ -29,14 +41,21 @@ Detection modes (auto-selected):
2941
3042
Usage:
3143
32-
# after a pytest run crashed/was Ctrl+C'd
44+
# process reap only (default)
3345
scripts/tractor-reap
3446
47+
# process reap + shm sweep
48+
scripts/tractor-reap --shm
49+
50+
# only the shm sweep, skip process reap
51+
scripts/tractor-reap --shm-only
52+
3553
# from inside a still-live supervisor
3654
scripts/tractor-reap --parent 12345
3755
38-
# dry-run: list what would be reaped, don't signal
56+
# dry-run: list what would be reaped, don't act
3957
scripts/tractor-reap -n
58+
scripts/tractor-reap --shm -n
4059
4160
'''
4261
import argparse
@@ -83,7 +102,21 @@ def main() -> int:
83102
parser.add_argument(
84103
'--dry-run', '-n',
85104
action='store_true',
86-
help='list matched pids but do not signal',
105+
help='list matched pids/paths but do not signal/unlink',
106+
)
107+
parser.add_argument(
108+
'--shm',
109+
action='store_true',
110+
help=(
111+
'after process reap, also unlink orphaned '
112+
'/dev/shm segments owned by the current user '
113+
'that no live process is mapping or holding open'
114+
),
115+
)
116+
parser.add_argument(
117+
'--shm-only',
118+
action='store_true',
119+
help='skip process reap; only do the shm sweep',
87120
)
88121
args = parser.parse_args()
89122

@@ -95,29 +128,54 @@ def main() -> int:
95128
from tractor._testing._reap import (
96129
find_descendants,
97130
find_orphans,
131+
find_orphaned_shm,
98132
reap,
133+
reap_shm,
99134
)
100135

101-
if args.parent is not None:
102-
pids: list[int] = find_descendants(args.parent)
103-
mode: str = f'descendants of PPid={args.parent}'
104-
else:
105-
pids = find_orphans(repo)
106-
mode = f'orphans (PPid=1, cwd={repo})'
107-
108-
if not pids:
109-
print(f'[tractor-reap] no {mode} to reap')
110-
return 0
111-
112-
if args.dry_run:
113-
print(f'[tractor-reap] dry-run — {mode}:\n {pids}')
114-
return 0
115-
116-
signalled, survivors = reap(pids, grace=args.grace)
117-
# exit 0 if everyone exited cleanly, else 1 to signal
118-
# escalation happened — makes the command useful in
119-
# CI health-checks and `||`-chaining.
120-
return 0 if not survivors else 1
136+
rc: int = 0
137+
138+
# --- phase 1: process reap (skipped under --shm-only) ---
139+
if not args.shm_only:
140+
if args.parent is not None:
141+
pids: list[int] = find_descendants(args.parent)
142+
mode: str = f'descendants of PPid={args.parent}'
143+
else:
144+
pids = find_orphans(repo)
145+
mode = f'orphans (PPid=1, cwd={repo})'
146+
147+
if not pids:
148+
print(f'[tractor-reap] no {mode} to reap')
149+
elif args.dry_run:
150+
print(
151+
f'[tractor-reap] dry-run — {mode}:\n {pids}'
152+
)
153+
else:
154+
_, survivors = reap(pids, grace=args.grace)
155+
if survivors:
156+
rc = 1
157+
158+
# --- phase 2: shm sweep (opt-in) ---
159+
if args.shm or args.shm_only:
160+
leaked: list[str] = find_orphaned_shm()
161+
if not leaked:
162+
print(
163+
'[tractor-reap] no orphaned /dev/shm '
164+
'segments to sweep'
165+
)
166+
elif args.dry_run:
167+
print(
168+
f'[tractor-reap] dry-run — {len(leaked)} '
169+
f'orphaned shm segment(s):\n {leaked}'
170+
)
171+
else:
172+
_, errors = reap_shm(leaked)
173+
if errors:
174+
rc = 1
175+
176+
# exit 0 if everything cleaned cleanly, else 1 — useful
177+
# for CI health-check chaining.
178+
return rc
121179

122180

123181
if __name__ == '__main__':

0 commit comments

Comments
 (0)