Skip to content

subprocess.run(timeout=N) on Windows can hang forever after the timeout kill: reader-thread join waits on inherited pipe handles #160988

Closed as duplicate

Description

@tdevries60

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's fh.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 chain
  • CREATE_NEW_PROCESS_GROUP: lets a future kill target the group, not just the direct child
  • stdin=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

  1. 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.
  2. Behavioral fix (option): after TimeoutExpired, do a bounded reap — e.g. communicate(timeout=remaining) with a secondary deadline, raising TimeoutExpired again instead of joining forever. Today the second communicate() inside run()'s except-path passes no effective deadline for the reader-thread join.
  3. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    stdlibStandard Library Python modules in the Lib/ directorytopic-subprocessSubprocess issues.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions