-
-
Notifications
You must be signed in to change notification settings - Fork 33.7k
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 NoneThe 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
SIGCLDis 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 "WNOWAITwaitid(wait-without-reaping) orWNOHANGwait[p]id(reap-without-waiting)). Is it okay if we can't disambiguate between these two cases? ↩