Container-green, host-red: a detached child holds the test's tempdir
Problem
A factory/CI gate runs a hook's self-test inside a Linux container and reports green. The
hook is then wired into settings.json (and its self-test into the session receiver
checks) on a Windows host. On that host the same suite exits 1 part-way through: the first
block of checks prints ok, then a PermissionError [WinError 32] traceback from
tempfile.TemporaryDirectory.__exit__, and every later check never runs. The receiver
now rejects the next session packet on a "trusted check" that never reached its second
case.
Observed 2026-08-29 in a private steering repository (board-drift sweep hooks): 19/19 gate checks
green in-container; on the host 15/57, then abort.
Context / Trigger Conditions
- Windows host; Python 3.12/3.13; suite uses
tempfile.TemporaryDirectory() per test.
- The code under test launches a long-lived detached child:
subprocess.Popen([...], start_new_session=True) with no cwd= argument.
- Some test runs the parent with
subprocess.run(..., cwd=td) where td is the tempdir.
- Symptom order: early
ok lines → WinError 32 ... being used by another process: 'C:\\Users\\...\\Temp\\tmpXXXX' naming the directory, not a file → suite aborts.
- Red herring: the first tempdir you suspect (the one whose test polls for an output
file) is not the holder. Adding
ignore_cleanup_errors=True there changes nothing.
Solution
Find the holder by the frame, not the message. The traceback's in test_... frame
names the test whose tempdir is locked. Look for cwd=td in that test.
Fix the product, not the test: the detached launcher pins its child's cwd to the
project root (or another directory that outlives the test):
root = os.environ.get("CLAUDE_PROJECT_DIR")
if not root or not os.path.isdir(root):
root = os.path.dirname(os.path.dirname(_HERE))
subprocess.Popen([sys.executable, _DETACHED], cwd=root,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
An inherited cwd is a latent defect in production too: the child keeps whatever
directory the hook was launched from locked for its lifetime.
Publish outputs atomically in the child: write <path>.tmp, then os.replace(tmp, path). A poller that keys on path.exists() then never sees a half-written or
still-open file, which is the second Windows-only lock.
Compare path parts, not joined suffixes: Path(p).parts[-3:] == (".claude", "state", "x.json") instead of p.endswith(".claude/state/x.json").
On the test whose child may still be exiting at block end, TemporaryDirectory( ignore_cleanup_errors=True) is a legitimate belt-and-braces, but it is not the fix.
Verification
Run the suite on the host: exit 0 and the full check count (here all green (57 checks)).
Before the fix: exit 1, the early block only, WinError 32 naming a temp directory.
Confirm with grep -n "cwd=td" that the located test is the one in the traceback frame.
Example
Three defects survived a 19-check container gate and were found only by running the
self-test on the host before merging: inherited cwd (abort), non-atomic cache write
(race), slash-joined path assertion (backslashes). Fixed forward on the factory branch
(945c684), re-run 57/57, then merged. Rule adopted in the project: a PR that wires a
hook into settings.json or a receiver check into session-boundary.json gets its
self-test run on the host before merge — the gate is not the host.
Notes
- Windows refuses
rmdir on a directory that is any process's cwd; Linux does not, which
is why the container never sees it.
start_new_session=True is a POSIX setsid; on Windows it is ignored, so "detached" is
weaker there too — the child survives parent exit only because Windows does not kill
children by default. Measure, do not assume.
- See also:
windows-claude-code-env (Problem 9, CRLF; cp1252 console encoding) for the
other Windows-only classes that pass a Linux gate.
References
- Python
tempfile.TemporaryDirectory(ignore_cleanup_errors=...) — added in 3.10; it
swallows the error, it does not release the holder.
- Python
subprocess.Popen — cwd and start_new_session semantics per platform.
1---2name: detached-child3description: Use when a self-test passes in a Linux container and fails on Windows with WinError 32 at TemporaryDirectory cleanup. A detached child inherited the tempdir as its cwd; fix the product, not the test.4---56# Container-green, host-red: a detached child holds the test's tempdir78## Problem910A factory/CI gate runs a hook's self-test inside a Linux container and reports green. The11hook is then wired into `settings.json` (and its self-test into the session receiver12checks) on a Windows host. On that host the same suite exits 1 part-way through: the first13block of checks prints `ok`, then a `PermissionError [WinError 32]` traceback from14`tempfile.TemporaryDirectory.__exit__`, and every later check never runs. The receiver15now rejects the next session packet on a "trusted check" that never reached its second16case.1718Observed 2026-08-29 in a private steering repository (board-drift sweep hooks): 19/19 gate checks19green in-container; on the host 15/57, then abort.2021## Context / Trigger Conditions2223- Windows host; Python 3.12/3.13; suite uses `tempfile.TemporaryDirectory()` per test.24- The code under test launches a long-lived detached child: `subprocess.Popen([...],25 start_new_session=True)` with no `cwd=` argument.26- Some test runs the parent with `subprocess.run(..., cwd=td)` where `td` is the tempdir.27- Symptom order: early `ok` lines → `WinError 32 ... being used by another process:28 'C:\\Users\\...\\Temp\\tmpXXXX'` naming the **directory**, not a file → suite aborts.29- Red herring: the first tempdir you suspect (the one whose test polls for an output30 file) is not the holder. Adding `ignore_cleanup_errors=True` there changes nothing.3132## Solution33341. **Find the holder by the frame, not the message.** The traceback's `in test_...` frame35 names the test whose tempdir is locked. Look for `cwd=td` in that test.362. **Fix the product, not the test:** the detached launcher pins its child's cwd to the37 project root (or another directory that outlives the test):3839 ```python40 root = os.environ.get("CLAUDE_PROJECT_DIR")41 if not root or not os.path.isdir(root):42 root = os.path.dirname(os.path.dirname(_HERE))43 subprocess.Popen([sys.executable, _DETACHED], cwd=root,44 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,45 start_new_session=True)46 ```4748 An inherited cwd is a latent defect in production too: the child keeps whatever49 directory the hook was launched from locked for its lifetime.503. **Publish outputs atomically** in the child: write `<path>.tmp`, then `os.replace(tmp,51 path)`. A poller that keys on `path.exists()` then never sees a half-written or52 still-open file, which is the second Windows-only lock.534. **Compare path parts, not joined suffixes**: `Path(p).parts[-3:] == (".claude",54 "state", "x.json")` instead of `p.endswith(".claude/state/x.json")`.555. On the test whose child may still be exiting at block end, `TemporaryDirectory(56 ignore_cleanup_errors=True)` is a legitimate belt-and-braces, but it is not the fix.5758## Verification5960Run the suite on the host: exit 0 and the full check count (here `all green (57 checks)`).61Before the fix: exit 1, the early block only, `WinError 32` naming a temp **directory**.62Confirm with `grep -n "cwd=td"` that the located test is the one in the traceback frame.6364## Example6566Three defects survived a 19-check container gate and were found only by running the67self-test on the host before merging: inherited cwd (abort), non-atomic cache write68(race), slash-joined path assertion (backslashes). Fixed forward on the factory branch69(`945c684`), re-run 57/57, then merged. Rule adopted in the project: a PR that wires a70hook into `settings.json` or a receiver check into `session-boundary.json` gets its71self-test run on the host before merge — the gate is not the host.7273## Notes7475- Windows refuses `rmdir` on a directory that is any process's cwd; Linux does not, which76 is why the container never sees it.77- `start_new_session=True` is a POSIX `setsid`; on Windows it is ignored, so "detached" is78 weaker there too — the child survives parent exit only because Windows does not kill79 children by default. Measure, do not assume.80- See also: `windows-claude-code-env` (Problem 9, CRLF; cp1252 console encoding) for the81 other Windows-only classes that pass a Linux gate.8283## References8485- Python `tempfile.TemporaryDirectory(ignore_cleanup_errors=...)` — added in 3.10; it86 swallows the error, it does not release the holder.87- Python `subprocess.Popen` — `cwd` and `start_new_session` semantics per platform.