Skip to content

Commit 135607d

Browse files
committed
isolated: rework pipe handling
Rework the pipe handling in the `isolated`, primarily in the Windows-specific codepaths, in an attempt to mitigate the file handle exhaustion issues that sometimes pop up on the CI. Specifically, avoid using `os.pipe()` and instead call WinAPI `CreatePipe()` via `ctypes`. This gives us file handles without wrapping them in file descriptors (which are always inheritable as per python/cpython#77046 and may or may not be related to our problems). For good measure, avoid setting `close_fds=False` when spawning subprocess on Windows; instead, pass the list of relevant file handles via `subprocess.STARTUPINFO`. In addition, in both codepaths we now set inheritable flag only on the file descriptors (or handles) that need to be passed to the child. Similarly, once the child is spawned, we immediately close the corresponding file descriptors (or handles) in the parent process.
1 parent 1d2b79c commit 135607d

File tree

2 files changed

+153
-49
lines changed

2 files changed

+153
-49
lines changed

PyInstaller/isolated/_parent.py

Lines changed: 144 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -14,65 +14,150 @@
1414
from marshal import loads, dumps
1515
from base64 import b64encode, b64decode
1616
import functools
17+
import subprocess
1718

1819
from PyInstaller.compat import __wrap_python
1920

21+
# WinAPI bindings for Windows-specific codepath
2022
if os.name == "nt":
21-
from msvcrt import get_osfhandle, open_osfhandle
22-
23-
def open(osf_handle, mode):
24-
# Convert system file handles to file descriptors before opening them.
25-
return os.fdopen(open_osfhandle(osf_handle, 0), mode)
26-
27-
def close(osf_handle):
28-
# Likewise when closing.
29-
return os.close(open_osfhandle(osf_handle, 0))
30-
31-
else:
32-
close = os.close
23+
import msvcrt
24+
import ctypes
25+
import ctypes.wintypes
26+
27+
# CreatePipe
28+
class SECURITY_ATTRIBUTES(ctypes.Structure):
29+
_fields_ = [
30+
("nLength", ctypes.wintypes.DWORD),
31+
("lpSecurityDescriptor", ctypes.wintypes.LPVOID),
32+
("bInheritHandle", ctypes.wintypes.BOOL),
33+
]
34+
35+
HANDLE_FLAG_INHERIT = 0x0001
36+
37+
LPSECURITY_ATTRIBUTES = ctypes.POINTER(SECURITY_ATTRIBUTES)
38+
39+
CreatePipe = ctypes.windll.kernel32.CreatePipe
40+
CreatePipe.argtypes = [
41+
ctypes.POINTER(ctypes.wintypes.HANDLE),
42+
ctypes.POINTER(ctypes.wintypes.HANDLE),
43+
LPSECURITY_ATTRIBUTES,
44+
ctypes.wintypes.DWORD,
45+
]
46+
CreatePipe.restype = ctypes.wintypes.BOOL
47+
48+
# CloseHandle
49+
CloseHandle = ctypes.windll.kernel32.CloseHandle
50+
CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
51+
CloseHandle.restype = ctypes.wintypes.BOOL
3352

3453
CHILD_PY = Path(__file__).with_name("_child.py")
3554

3655

37-
def pipe():
56+
def create_pipe(read_handle_inheritable, write_handle_inheritable):
3857
"""
3958
Create a one-way pipe for sending data to child processes.
4059
60+
Args:
61+
read_handle_inheritable:
62+
A boolean flag indicating whether the handle corresponding to the read end-point of the pipe should be
63+
marked as inheritable by subprocesses.
64+
write_handle_inheritable:
65+
A boolean flag indicating whether the handle corresponding to the write end-point of the pipe should be
66+
marked as inheritable by subprocesses.
67+
4168
Returns:
42-
A read/write pair of file descriptors (which are just integers) on posix or system file handle on Windows.
69+
A read/write pair of file descriptors (which are just integers) on posix or system file handles on Windows.
4370
4471
The pipe may be used either by this process or subprocesses of this process but not globally.
72+
"""
73+
return _create_pipe_impl(read_handle_inheritable, write_handle_inheritable)
74+
4575

76+
def close_pipe_endpoint(pipe_handle):
77+
"""
78+
Close the file descriptor (posix) or handle (Windows) belonging to a pipe.
4679
"""
47-
read, write = os.pipe()
48-
# The default behaviour of pipes is that they are process specific. i.e. they can only be used for this process to
49-
# talk to itself. Setting these means that child processes may also use these pipes.
50-
os.set_inheritable(read, True)
51-
os.set_inheritable(write, True)
80+
return _close_pipe_endpoint_impl(pipe_handle)
5281

53-
# On Windows, file descriptors are not shareable. They need to be converted to system file handles here then
54-
# converted back with open_osfhandle() by the child.
55-
if os.name == "nt":
56-
read, write = get_osfhandle(read), get_osfhandle(write)
5782

58-
return read, write
83+
if os.name == "nt":
84+
85+
def _create_pipe_impl(read_handle_inheritable, write_handle_inheritable):
86+
# Use WinAPI CreatePipe function to create the pipe. Python's os.pipe() does the same, but wraps the resulting
87+
# handles into inheritable file descriptors (https://github.com/python/cpython/issues/77046). Instead, we want
88+
# just handles, and will set the inheritable flag on corresponding handle ourselves.
89+
read_handle = ctypes.wintypes.HANDLE()
90+
write_handle = ctypes.wintypes.HANDLE()
91+
92+
# SECURITY_ATTRIBUTES with inherit handle set to True
93+
security_attributes = SECURITY_ATTRIBUTES()
94+
security_attributes.nLength = ctypes.sizeof(security_attributes)
95+
security_attributes.bInheritHandle = True
96+
security_attributes.lpSecurityDescriptor = None
97+
98+
# CreatePipe()
99+
succeeded = CreatePipe(
100+
ctypes.byref(read_handle), # hReadPipe
101+
ctypes.byref(write_handle), # hWritePipe
102+
ctypes.byref(security_attributes), # lpPipeAttributes
103+
0, # nSize
104+
)
105+
if not succeeded:
106+
raise ctypes.WinError()
107+
108+
# Set inheritable flags. Instead of binding and using SetHandleInformation WinAPI function, we can use
109+
# os.set_handle_inheritable().
110+
os.set_handle_inheritable(read_handle.value, read_handle_inheritable)
111+
os.set_handle_inheritable(write_handle.value, write_handle_inheritable)
112+
113+
return read_handle.value, write_handle.value
114+
115+
def _close_pipe_endpoint_impl(pipe_handle):
116+
succeeded = CloseHandle(pipe_handle)
117+
if not succeeded:
118+
raise ctypes.WinError()
119+
else:
120+
121+
def _create_pipe_impl(read_fd_inheritable, write_fd_inheritable):
122+
# Create pipe, using os.pipe()
123+
read_fd, write_fd = os.pipe()
124+
125+
# The default behaviour of pipes is that they are process specific. I.e., they can only be used by this
126+
# process to talk to itself. Setting inheritable flags means that child processes may also use these pipes.
127+
os.set_inheritable(read_fd, read_fd_inheritable)
128+
os.set_inheritable(write_fd, write_fd_inheritable)
129+
130+
return read_fd, write_fd
131+
132+
def _close_pipe_endpoint_impl(pipe_fd):
133+
os.close(pipe_fd)
59134

60135

61136
def child(read_from_parent: int, write_to_parent: int):
62137
"""
63138
Spawn a Python subprocess sending it the two file descriptors it needs to talk back to this parent process.
64139
"""
65-
from subprocess import Popen
140+
if os.name != 'nt':
141+
# Explicitly disabling close_fds is a requirement for making file descriptors inheritable by child processes.
142+
extra_kwargs = {
143+
"env": _subprocess_env(),
144+
"close_fds": False,
145+
}
146+
else:
147+
# On Windows, we can use subprocess.STARTUPINFO to explicitly pass the list of file handles to be inherited,
148+
# so we can avoid disabling close_fds
149+
extra_kwargs = {
150+
"env": _subprocess_env(),
151+
"close_fds": True,
152+
"startupinfo": subprocess.STARTUPINFO(lpAttributeList={"handle_list": [read_from_parent, write_to_parent]})
153+
}
66154

67155
# Run the _child.py script directly passing it the two file descriptors it needs to talk back to the parent.
68-
cmd, options = __wrap_python(
69-
[str(CHILD_PY), str(read_from_parent), str(write_to_parent)],
70-
# Explicitly disabling close_fds is a requirement for making file descriptors inheritable by child processes.
71-
dict(close_fds=False, env=_subprocess_env()),
72-
)
156+
cmd, options = __wrap_python([str(CHILD_PY), str(read_from_parent), str(write_to_parent)], extra_kwargs)
157+
73158
# I'm intentionally leaving stdout and stderr alone so that print() can still be used for emergency debugging and
74159
# unhandled errors in the child are still visible.
75-
return Popen(cmd, **options)
160+
return subprocess.Popen(cmd, **options)
76161

77162

78163
def _subprocess_env():
@@ -110,17 +195,34 @@ def __init__(self):
110195
self._child = None
111196

112197
def __enter__(self):
113-
# We need two pipes. One for the child to send data to the parent.
114-
self._read_from_child, self._write_to_parent = pipe()
115-
# And one for the parent to send data to the child.
116-
self._read_from_parent, self._write_to_child = pipe()
198+
# We need two pipes. One for the child to send data to the parent. The (write) end-point passed to the
199+
# child needs to be marked as inheritable.
200+
read_from_child, write_to_parent = create_pipe(False, True)
201+
# And one for the parent to send data to the child. The (read) end-point passed to the child needs to be
202+
# marked as inheritable.
203+
read_from_parent, write_to_child = create_pipe(True, False)
117204

118205
# Spawn a Python subprocess sending it the two file descriptors it needs to talk back to this parent process.
119-
self._child = child(self._read_from_parent, self._write_to_parent)
120-
121-
# Open file handles to talk to the child.
122-
self._write_handle = open(self._write_to_child, "wb")
123-
self._read_handle = open(self._read_from_child, "rb")
206+
self._child = child(read_from_parent, write_to_parent)
207+
208+
# Close the end-points that were inherited by the child.
209+
close_pipe_endpoint(read_from_parent)
210+
close_pipe_endpoint(write_to_parent)
211+
del read_from_parent
212+
del write_to_parent
213+
214+
# Open file handles to talk to the child. This should fully transfer ownership of the underlying file
215+
# descriptor to the opened handle; so when we close the latter, the former should be closed as well.
216+
if os.name == 'nt':
217+
# On Windows, we must first open file descriptor on top of the handle using _open_osfhandle (which
218+
# python wraps in msvcrt.open_osfhandle). According to MSDN, this transfers the ownership of the
219+
# underlying file handle to the file descriptors; i.e., they are both closed when the file descriptor
220+
# is closed).
221+
self._write_handle = os.fdopen(msvcrt.open_osfhandle(write_to_child, 0), "wb")
222+
self._read_handle = os.fdopen(msvcrt.open_osfhandle(read_from_child, 0), "rb")
223+
else:
224+
self._write_handle = os.fdopen(write_to_child, "wb")
225+
self._read_handle = os.fdopen(read_from_child, "rb")
124226

125227
return self
126228

@@ -130,14 +232,11 @@ def __exit__(self, exc_type, exc_val, exc_tb):
130232
self._write_handle.flush()
131233
self._child.wait()
132234

133-
# Then close all the pipes and handles. These steps were determined empirically to appease the corresponding
134-
# test: tests/unit/test_isolation.py::test_pipe_leakage
135-
# I don't really understand why they must be this way.
136-
close(self._read_from_parent)
137-
close(self._write_to_parent)
235+
# Close the handles. This should also close the underlying file descriptors.
138236
self._write_handle.close()
139237
self._read_handle.close()
140238
del self._read_handle, self._write_handle
239+
141240
self._child = None
142241

143242
def call(self, function, *args, **kwargs):

tests/unit/test_isolation.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,10 +185,15 @@ def test_pipe_leakage():
185185
child = isolated.Python()
186186
assert open_fds() == old
187187

188-
# Entering its context creates the child process and 4 handles for sending/receiving to/from it. Then on Windows,
189-
# opening the parent's ends of the two pipes creates another two handles and starting the subprocess adds another
190-
# two (although I don't know what for).
191-
EXPECTED_INCREASE_IN_FDS = (4 if os.name != "nt" else 8)
188+
# On POSIX systems, entering the context creates the child process and 4 handles for sending/receiving to/from it.
189+
# After creating the child process, we close the descriptors that were passed to the child, so the expected total
190+
# increase in the parent/main process is two file descriptors on POSIX systems.
191+
# On Windows, we monitor file handles; four are opened when both pipes are created. Additional two handles are
192+
# opened when the sub-process is spawned. Then we close the two pipe end-points that were inherited by the child,
193+
# which closes two handles. Finally, we open file descriptors on the remaining two pipe end-point handles, and
194+
# perform os.fdopen() on those FDs to obtained buffered python "file" object. This adds two additional file
195+
# handles, bringing us to the total of six.
196+
EXPECTED_INCREASE_IN_FDS = (2 if os.name != "nt" else 6)
192197

193198
with child:
194199
assert open_fds() == old + EXPECTED_INCREASE_IN_FDS

0 commit comments

Comments
 (0)