GeezerVison is showing github.com — Pipe inheritance broken on Windows since 3.13 · Issue #133506 · python/cpython
Pipe inheritance broken on Windows since 3.13 #133506DescriptionBug report
Bug description:
Passing an inheritable pipe file descriptor to a child process is somehow broken on Windows since Python 3.13. Opening the file descriptor in the child process crashes with [WinError 6] the handle is invalid.
> py -3.12-64 .\main.py
INFO:root:S: Starting
INFO:root:Duration for 10000 requests: 0.37447249999968335 s
INFO:root:S: EOF Quitting
> py -3.13-64 .\main.py
INFO:root:S: Starting
Traceback (most recent call last):
File "D:\temp\test-pipe-main\sub.py", line 31, in <module>
main()
~~~~^^
File "D:\temp\test-pipe-main\sub.py", line 14, in main
reply_write_fobj = os.fdopen(reply_write_fd, "wb", buffering=0)
File "<frozen os>", line 1068, in fdopen
OSError: [WinError 6] Das Handle ist ungültig
Minimal example
import logging
import os
import pickle
import sys
import time
def main():
logging.basicConfig(level=logging.INFO)
reply_read_fd, reply_write_fd = os.pipe()
request_read_fd, request_write_fd = os.pipe()
child_fds = [reply_write_fd, request_read_fd]
for fd in child_fds:
os.set_inheritable(fd, True)
reply_read_fobj = os.fdopen(reply_read_fd, "rb", buffering=0)
request_write_fobj = os.fdopen(request_write_fd, "wb", buffering=0)
child_pid = os.spawnl(
os.P_NOWAIT,
sys.executable,
os.path.basename(sys.executable),
"sub.py",
*(str(fd) for fd in child_fds),
)
for fd in child_fds:
os.close(fd)
number_requests = 10000
start = time.perf_counter()
for i in range(number_requests):
request = f"test{i}"
pickle.dump(request, request_write_fobj)
logging.debug(f"C: Sent {request}")
reply = pickle.load(reply_read_fobj)
logging.debug(f"C: Got {reply}")
duration = time.perf_counter() - start
logging.info(f"Duration for {number_requests} requests: {duration} s")
request_write_fobj.close()
os.waitpid(child_pid, 0)
time.sleep(1)
if __name__ == "__main__":
main()
import logging
import os
import pickle
import sys
def main():
logging.basicConfig(level=logging.INFO)
reply_write_fd = int(sys.argv[1])
request_read_fd = int(sys.argv[2])
logging.info("S: Starting")
reply_write_fobj = os.fdopen(reply_write_fd, "wb", buffering=0)
request_read_fobj = os.fdopen(request_read_fd, "rb", buffering=0)
logging.debug("S: Sent event")
while True:
try:
request: str = pickle.load(request_read_fobj)
except EOFError:
logging.info("S: EOF Quitting")
return
logging.debug(f"S: Handle {request}")
reply = "test"
pickle.dump(reply, reply_write_fobj)
logging.debug("S: Sent event")
if __name__ == "__main__":
main()
CPython versions tested on:
3.12, 3.13
Operating systems tested on:
Windows ActivityZeroIntensity commented on May 6, 2025 You close the file descriptors before waiting on the child, so the child process might not have opened them by the time they're closed. It's a race condition. It wasn't correct on prior versions, but I think you just got lucky that it didn't break before. In 3.13, something (probably startup) is slightly slower, which revealed the race.
I would just wait until you've received a message before closing the pipes in the parent. zooba commented on May 6, 2025 There are also a few ways that Python may launch that don't properly handle the CRT's emulation of file descriptors (they aren't real things on Windows, so they don't always match POSIX behaviour perfectly). spawnl is also emulated.
The multiprocessing module is the platform independent way to do this.
That said, if there's actually a bug causing the change on 3.13, we should fix it. If @ZeroIntensity's analysis is correct then maybe it's worth a doc update (if we can choose a good place to put some notes), but ultimately, low-level POSIX emulation on Windows is going to require testing and tweaking to work well. schlamar commented on May 6, 2025 ContributorAuthor More actions
You close the file descriptors before waiting on the child, so the child process might not have opened them by the time they're closed. It's a race condition. It wasn't correct on prior versions, but I think you just got lucky that it didn't break before. In 3.13, something (probably startup) is slightly slower, which revealed the race.
I would just wait until you've received a message before closing the pipes in the parent.
No, that is how this should be done. Handles are duplicated, so the parent handles should be closed after child process is spawned. Here is a similar Microsoft example doing exactly the same. https://learn.microsoft.com/en-us/windows/win32/procthread/creating-a-child-process-with-redirected-input-and-output
Plus, there is the same behavior if I don't close the child file descriptors in the parent process. schlamar commented on May 6, 2025 ContributorAuthor More actions
The multiprocessing module is the platform independent way to do this.
I need multiples pipes from a 64bit to a 32bit process. So the multiprocessing module doesn't cover my use case at all. schlamar commented on May 6, 2025 ContributorAuthor More actions This is very likely related or a regression from #77046 / #113817 ZeroIntensity commented on May 6, 2025
No, that is how this should be done. Handles are duplicated, so the parent handles should be closed after child process is spawned.
Hm, odd. I don't think it works the same on POSIX. schlamar commented on May 6, 2025 ContributorAuthor More actions It works as expected on Python 3.12 and 3.13 if I pass handles to child process by wrapping it around msvcrt.get_osfhandle and msvcrt.open_osfhandle
child_handles = [msvcrt.get_osfhandle(fd) for fd in child_fds]
child_pid = os.spawnl(
os.P_NOWAIT,
sys.executable,
os.path.basename(sys.executable),
"sub.py",
*(str(handle) for handle in child_handles),
)
reply_write_handle = int(sys.argv[1])
reply_write_fd = msvcrt.open_osfhandle(reply_write_handle, os.O_NOINHERIT)
request_read_handle = int(sys.argv[2])
request_read_fd = msvcrt.open_osfhandle(
request_read_handle, os.O_RDONLY | os.O_NOINHERIT
)schlamar commented on May 6, 2025 ContributorAuthor More actions @vstinner asked for a use case in #77046 (comment), so I elaborate a little bit: Pipes in combination with pickle is a really elegant and simple pattern for inter process communication. I am aware that the above example could probably be solved with STDOUT / STDIN as pipes and subprocess. However in my specific case I need a third pipe for out of band event messaging. zooba commented on May 6, 2025
Handles are duplicated, so the parent handles should be closed after child process is spawned. Here is a similar Microsoft example doing exactly the same. https://learn.microsoft.com/en-us/windows/win32/procthread/creating-a-child-process-with-redirected-input-and-output
Again, the problem as I stated is that you aren't dealing with HANDLEs directly here, but you're dealing with the CRT's file descriptor emulation. So the only directly relevant example would be using the Microsoft C Runtime's APIs exclusively, and not touching any native APIs (like the one you showed).
Skim-reading the discussion at #113817 (I don't have time to reabsorb it all right now) it seems we were aware that pipes inside file descriptors were broken when it comes to spawn, and changing the inheritance behaviour really just moves the point of failure (to hopefully not involve a crash). Someone is going to have to dig deeper to find a full resolution (which probably involves reporting bugs to Microsoft and then waiting for new OS releases to include the fix). schlamar commented on May 7, 2025 ContributorAuthor More actions
Again, the problem as I stated is that you aren't dealing with HANDLEs directly here, but you're dealing with the CRT's file descriptor emulation. So the only directly relevant example would be using the Microsoft C Runtime's APIs exclusively, and not touching any native APIs (like the one you showed).
I have just a rough idea what you mean with that. Is there some documentation of these specifics regarding Python API vs. CRT? schlamar commented on May 7, 2025 ContributorAuthor More actions I found an interesting comment in #113817 (comment)
Maybe in Python 3.12, it's possible to inherit some file descriptors on Windows using os.spawnl(), but for me, it's more an accident than a deliberate choice, and the code is not portable.
So I guess to correct solution would be to use subprocess with handle_list
child_handles = [msvcrt.get_osfhandle(fd) for fd in child_fds]
for child_handle in child_handles:
os.set_handle_inheritable(child_handle, True)
command = [sys.executable, "sub.py"] + [str(handle) for handle in child_handles]
startup_info = subprocess.STARTUPINFO(
lpAttributeList={"handle_list": child_handles}
)
process = subprocess.Popen(command, startupinfo=startup_info)
zooba commented on May 7, 2025
So I guess to correct solution would be to use subprocess with handle_list
This is going to work better, yeah. You should be able to open_osfhandle on the other side to get a file descriptor back, but sharing the HANDLE value directly is going to be working with the proper OS APIs. However, do be aware that if this goes via the py.exe redirector or a venv's python.exe (also a redirector) that there's one more step involved that may break it (which would either be our fault, or a platform limitation, but if you notice a difference with a venv then do let us know).
Is there some documentation of these specifics regarding Python API vs. CRT?
Unfortunately not. Our documentation is a specification, so if we explain something in docs, we then have to work hard to preserve it, but the main guarantee Python makes is "provides access to your platform's C runtime". So if we specify what the platform's C runtime has to do, and then the platform changes it, now we're wrong. So we tend not to document a lot of this stuff. (And a lot of the documentation that's written is written by people who don't consider this, and so they do document things either incorrectly, too specifically, or just for a single OS without mentioning that they only meant it for a single OS.)
But if you go searching for something like "msvcrt spawn inherit pipes" then you'll find more generic information about the runtime (though in this case I just found the equivalent bug to this one but for Go...). There doesn't seem to be a lot out there about this one, perhaps because nobody writes this kind of code and expects it to be cross-plat? schlamar commented on May 7, 2025 ContributorAuthor More actions
You should be able to open_osfhandle on the other side to get a file descriptor back
Yes, this works with spawn (see #133506 (comment)) and with subprocess. schlamar commented on May 7, 2025 ContributorAuthor More actions
However, do be aware that if this goes via the py.exe redirector
This seems to work in my case. Haven't tried a venv.
Thanks for your thorough explanation!
MetadataMetadataDevelopmentNo branches or pull requestsIssue actions
|