Description
Bug report
Bug description:
While working on GH-127049, I noticed that on non-Windows platforms Popen.poll
can return None
even for an exited child when Popen.poll
races against Popen.[poll | wait]
in another thread.
I think Popen.poll
is violating its docs when this happens, because it returns without checking the child.
Writing a reproducer for this is tricky, but the following hits the race condition more than half the time on my machine:
from __future__ import annotations
import os
import subprocess
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(1) as executor:
process = subprocess.Popen(
(
"python",
"-c",
"import time; time.sleep(1)",
),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
executor.submit(process.wait)
try:
os.waitid(os.P_PID, process.pid, os.WEXITED | os.WNOWAIT)
except ChildProcessError:
# P_PIDFD would avoid this ECHILD, but writing the reproducer this way for
# portability.
pass
assert process.poll() is not None
The culprit is https://github.com/python/cpython/blob/v3.14.0a1/Lib/subprocess.py#L1989-L1992, from d65ba51.
IIUC the reason that poll
can't block on _waitpid_lock.acquire
here is because poll
must be non-blocking API, but a wait
call can make a blocking call (waitpid
without WNOHANG
) while holding that lock.
IIUC this should be fixable with the two-step (1) wait-without-reaping and (2) hold a lock to atomically reap-without-waiting & set .returncode
approach described at #82811 (comment) and #86724 (comment) and used by Trio. That approach should also be able to fix case 1 of the thread-unsafety ignored in GH-20010, but not case 21. It could be a bit of a pain though2.
To be clear about impact, though, I have only seen this poll
retval bug happen while testing a fix for GH-127049. And in that situation, a very slight modification to said fix for GH-127049 can easily avoid this bug (as well as case 1 from GH-20010).
CPython versions tested on:
3.9, 3.10, 3.11, 3.12, 3.13, 3.14, CPython main branch
Operating systems tested on:
Linux
Footnotes
-
Tangent: Case 2 should be fixable on Linux >= 5.4 (via
pidfd_open
&P_PIDFD
). (It's possibly also fixable on BSDs & macOS, though I am not currently familiar there. What happens to an already-existing kevent for a PID after that PID has been freed? Does it still work, like a pidfd? It looks like Trio assumes that it does.) ↩ -
Does the "fail gracefully when
SIGCLD
is set to be ignored or waiting for child processes has otherwise been disabled for our process" case need different handling than the "an external caller reaped the PID and stole the return code from thePopen
" case? FromPopen
's perspective (assuming a platform without pidfd/kqueue), both of these cases look like anECHILD
(either from "WNOWAIT
waitid
(wait-without-reaping) orWNOHANG
wait[p]id
(reap-without-waiting)). Is it okay if we can't disambiguate between these two cases? ↩