diff --git a/Lib/multiprocessing/reduction.py b/Lib/multiprocessing/reduction.py index fcccd3eef86cc7e..41de207ebb31d47 100644 --- a/Lib/multiprocessing/reduction.py +++ b/Lib/multiprocessing/reduction.py @@ -105,6 +105,20 @@ class DupHandle(object): '''Picklable wrapper for a handle.''' def __init__(self, handle, access, pid=None): if pid is None: + popen = context.get_spawning_popen() + if popen is not None: + # gh-154208: The child process is already running, so + # duplicate the handle straight into it. Stealing the + # handle (below) would leak the duplicate held by this + # process if the child died before it could steal it. + # This mirrors the fix made for the pipe used to send + # the child its parameters -- see bpo-33929. + self._handle = _winapi.DuplicateHandle( + _winapi.GetCurrentProcess(), + handle, popen.sentinel, access, False, 0) + self._access = access + self._pid = popen.pid + return # We just duplicate the handle in the current process and # let the receiving process steal the handle. pid = os.getpid() diff --git a/Lib/test/_test_multiprocessing.py b/Lib/test/_test_multiprocessing.py index 8f665b3a98a0371..821b49d28537d1e 100644 --- a/Lib/test/_test_multiprocessing.py +++ b/Lib/test/_test_multiprocessing.py @@ -5733,6 +5733,71 @@ def test_neg_timeout(self): a.close() b.close() +# +# Issue 154208: handles duplicated for a child process must not leak in the +# parent if the child dies before it can make use of them +# + +@unittest.skipUnless(WIN32, "skipped on non-Windows platforms") +class TestWindowsHandleLeak(unittest.TestCase): + + @staticmethod + def handle_count(): + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) + kernel32.GetCurrentProcess.argtypes = () + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + kernel32.GetProcessHandleCount.argtypes = (wintypes.HANDLE, + wintypes.LPDWORD) + kernel32.GetProcessHandleCount.restype = wintypes.BOOL + + # Pseudo-handle: it must not be closed + hproc = kernel32.GetCurrentProcess() + count = wintypes.DWORD() + if not kernel32.GetProcessHandleCount(hproc, ctypes.byref(count)): + raise ctypes.WinError(ctypes.get_last_error()) + return count.value + + @staticmethod + def _sleep(conn): + time.sleep(support.LONG_TIMEOUT) + + def spawn_and_terminate(self): + # Kill the child before it gets a chance to unpickle its arguments: + # the handles duplicated for it are then never claimed. + reader, writer = multiprocessing.Pipe(duplex=False) + try: + proc = multiprocessing.Process(target=self._sleep, args=(reader,)) + proc.start() + try: + proc.terminate() + proc.join() + finally: + proc.close() + finally: + reader.close() + writer.close() + + def test_no_handle_leak_if_child_is_terminated(self): + # Warm up: the first iterations import modules and populate caches, + # which opens handles that are unrelated to this test. + for _ in range(3): + self.spawn_and_terminate() + gc.collect() + + before = self.handle_count() + for _ in range(10): + self.spawn_and_terminate() + gc.collect() + leaked = self.handle_count() - before + + # Before the fix, each iteration leaked the handle duplicated for + # the child, so the count grew without bound. + self.assertLessEqual(leaked, 4) + + # # Issue 14151: Test invalid family on invalid environment # diff --git a/Misc/NEWS.d/next/Library/2026-07-21-21-42-10.gh-issue-154208.Kq3vTd.rst b/Misc/NEWS.d/next/Library/2026-07-21-21-42-10.gh-issue-154208.Kq3vTd.rst new file mode 100644 index 000000000000000..3dd2414b4144bac --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-21-21-42-10.gh-issue-154208.Kq3vTd.rst @@ -0,0 +1,5 @@ +On Windows, :mod:`multiprocessing` no longer leaks a handle in the parent +process for each handle passed to a child process which is terminated before +it can steal them. Handles are now duplicated directly into the child +process, so that they are released by the operating system if the child dies +early.