Repository navigation
subprocess.run(timeout=N) on Windows can hang forever after the timeout kill: reader-thread join waits on inherited pipe handles #160988
Description
GitHub issue draft — subprocess.run(timeout=N) on Windows can hang forever after the kill: reader-thread join never returns when child pipe handles are inherited
Summary
subprocess.run(..., capture_output=True, timeout=N) on Windows can hang indefinitely after firing its own timeout, blocking the calling thread forever. The timeout kills the child, but communicate()'s reader-thread join waits on an OS thread whose pipe never reaches EOF — because an inherited write-handle keeps the pipe open after the child is gone. The caller's timeout=N is therefore not a guarantee: on Windows, a timeout that fires can become the hang.
We hit this in production (Hermes Agent, Windows 11 host): every long-lived worker process that called subprocess.run(["git", "rev-parse", "HEAD"], timeout=3, capture_output=True) froze permanently on the first invocation. Confirmed fix: spawn git with DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP and stdin=DEVNULL so the child's pipe chain is not inherited — after which the timeout kill reaps correctly.
Environment
- Windows 11, x64
- Python 3.14.9 (long-standing Windows behavior, not a 3.14 regression; the root pattern applies to earlier 3.x as well)
- Child process:
git(git for Windows 2.53.0) — though any child that itself spawns grandchildren (or any handle-inheriting launcher) reproduces it
Reproduction
Not deterministic from a cold shell — the hang requires a grandchild process (or other handle holder) to be alive holding the write end when the timeout fires. Our production signature:
- Process A = long-lived gateway worker (spawns many child processes over its lifetime)
- Process A calls
subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, timeout=3)from multiple threads - A git child from an earlier call wedges (or a grandchild holds the pipe); when the timeout fires,
Popen.kill()terminates git.exe, but the grandchild still holds the stdout write handle communicate()→self.stdout_thread.join(self._remaining_time(endtime))→ joins forever (the reader thread'sfh.read()never EOFs)
Stack signature at hang (from a live thread dump):
MainThread <- caller, parked forever
subprocess.py:566 in run
subprocess.py:1221 in communicate
subprocess.py:1662 in _communicate
threading.py:1133 in join <- self._os_thread_handle.join(timeout)
Thread-N (_readerthread) <- one per pipe, all parked
subprocess.py:1614 in _readerthread
buffer.append(fh.read()) <- never EOFs; write handle still open elsewhere
Observed behavior in production: 8+ concurrent stacks parked exactly here; turns killed by an external watchdog; retry → same hang → infinite loop.
Workaround (verified in production, 0.22.5+/main, ~1 week stable)
import subprocess, sys
def run_git_detached(repo_dir, *args, timeout=3):
popen_kwargs = {}
if sys.platform == "win32":
popen_kwargs["creationflags"] = (
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
)
popen_kwargs["stdin"] = subprocess.DEVNULL
return subprocess.run(
["git", *args], capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=timeout, cwd=str(repo_dir), check=False,
**popen_kwargs,
)DETACHED_PROCESS: child gets no console and does not inherit the caller's console pipe chainCREATE_NEW_PROCESS_GROUP: lets a future kill target the group, not just the direct childstdin=DEVNULL: removes the last inheritable std handle from the chain
After deploying this, get_version_info() (which stamps every API request with a git-derived version) runs in ~0.19s with no recurrence across a week of daily worker spawns.
Why the timeout doesn't save you (CPython internals)
subprocess.run on timeout calls process.kill() then process.communicate() again to reap. On POSIX, killing the child closes the write end (unless a grandchild inherited it — same bug exists there). On Windows, handle inheritance means any grandchild keeps the anonymous pipe's write handle open; _readerthread blocks in ReadFile forever; _communicate waits on self._os_thread_handle.join(...); and since run() already consumed its deadline bookkeeping, there is no second timeout — join is called with the remaining time, which is zero-ish, but OsThreadHandle.join on a native thread blocks indefinitely in this path. The net effect: run(timeout=3) never returns.
Suggested directions for CPython
- Documentation:
subprocess.run(timeout=...)on Windows is not hang-proof when children can spawn grandchildren or otherwise outlive the kill. The docs currently imply timeout is a hard bound. - Behavioral fix (option): after
TimeoutExpired, do a bounded reap — e.g.communicate(timeout=remaining)with a secondary deadline, raisingTimeoutExpiredagain instead of joining forever. Today the secondcommunicate()insiderun()'s except-path passes no effective deadline for the reader-thread join. - Ergonomics (option): a
subprocess.run(..., timeout_reap="force")style helper, or documented recipe for the DETACHED_PROCESS/DEVNULL pattern for "run a tool with a hard deadline" use cases.
Our context
Hermes Agent (Nous Research) dispatches kanban worker processes that make API calls stamped with a git-derived version (git rev-parse HEAD, timeout=3). Windows workers froze on that call daily; the DETACHED_PROCESS fix eliminated it. Happy to provide the full thread dumps.
Filed by the Hermes fleet (office seat), verified in production on Windows 11 / Python 3.14.9 / git 2.53.0.
Activity