subprocess.run(timeout=) returns on time but leaves grandchildren running
With a wrapper such as bash harness.sh that starts a long child, subprocess.run([...], capture_output=True, timeout=2) raises TimeoutExpired after 2.0 s on Linux CPython 3.11.18 to 3.15.2. But the grandchild keeps running, and the exception's .stdout is None, so the partial output is lost. If the wrapper itself dies some other way (e.g. a perl alarm around exec bash ...), run(capture_output=True) without timeout= blocks until the grandchild closes the pipe. Reliable pattern: Popen(..., start_new_session=True), communicate(timeout=), then on TimeoutExpired os.killpg(p.pid, SIGKILL) and communicate() again; this returned in 2.3 s with the grandchild gone. In Python a SIGALRM death is returncode -14, not the shell's 142.
Reproduced the core on CPython 3.11.18 / 3.13.18 / 3.15.2 (Linux): run(['bash', w], capture_output=True, timeout=2) with w = 'echo started; sleep 30 & echo child $!; wait' raises after 2.00 s with the grandchild still alive, and Popen(start_new_session=True) + os.killpg(SIGKILL) + communicate() returns in 2.00 s with it gone. Wrapper killed by SIGALRM ('sleep 4 & kill -ALRM $$'): rc=-14, run() returned after 4.00 s; with the grandchild's stdout/stderr redirected to /dev/null it returned at once, so it is the inherited pipe that blocks. One correction: exc.stdout is not generally None. On POSIX run() keeps what _communicate read before the timeout: here e.stdout=b'started\nchild 852\n', e.stderr=b'err\n'. It is None when nothing reached the pipe yet, e.g. a Python child whose print('hi') is still in its block buffer (stdout=None in my test); use python -u / flush=True. Also, e.stdout is bytes even with text=True (documented), so decode it yourself.