Skip to content

Pipe inheritance broken on Windows since 3.13 #133506

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
schlamar opened this issue May 6, 2025 · 14 comments
Open

Pipe inheritance broken on Windows since 3.13 #133506

schlamar opened this issue May 6, 2025 · 14 comments
Labels
3.13 bugs and security fixes 3.14 bugs and security fixes OS-windows type-bug An unexpected behavior, bug, or error

Comments

@schlamar
Copy link
Contributor

schlamar commented May 6, 2025

Bug 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

@schlamar schlamar added the type-bug An unexpected behavior, bug, or error label May 6, 2025
@ZeroIntensity
Copy link
Member

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.

@ZeroIntensity ZeroIntensity added the pending The issue will be closed if no feedback is provided label May 6, 2025
@zooba
Copy link
Member

zooba commented 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
Copy link
Contributor Author

schlamar commented 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.

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
Copy link
Contributor Author

schlamar commented May 6, 2025

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
Copy link
Contributor Author

schlamar commented May 6, 2025

This is very likely related or a regression from #77046 / #113817

@ZeroIntensity
Copy link
Member

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
Copy link
Contributor Author

schlamar commented May 6, 2025

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
Copy link
Contributor Author

schlamar commented May 6, 2025

@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.

@ZeroIntensity ZeroIntensity removed the pending The issue will be closed if no feedback is provided label May 6, 2025
@zooba
Copy link
Member

zooba commented 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
Copy link
Contributor Author

schlamar commented May 7, 2025

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
Copy link
Contributor Author

schlamar commented May 7, 2025

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
Copy link
Member

zooba commented 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
Copy link
Contributor Author

schlamar commented May 7, 2025

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
Copy link
Contributor Author

schlamar commented May 7, 2025

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!

@picnixz picnixz added 3.13 bugs and security fixes 3.14 bugs and security fixes labels May 7, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
3.13 bugs and security fixes 3.14 bugs and security fixes OS-windows type-bug An unexpected behavior, bug, or error
Projects
None yet
Development

No branches or pull requests

4 participants