From 11a76f3e686e69d3abbd5989dab45bae23ba497a Mon Sep 17 00:00:00 2001 From: Jason G <41053218+gianghungtien@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:14:54 +0700 Subject: [PATCH] gh-154208: Fix Windows handle leak when a child process dies early On Windows, ``multiprocessing.reduction.DupHandle`` duplicated a handle in the parent process and relied on the receiving process to steal it with ``DUPLICATE_CLOSE_SOURCE``. When the child was terminated before it could unpickle its arguments, that duplicate was never claimed and leaked in the parent for the rest of its lifetime. Creating and immediately terminating a ``multiprocessing.pool.Pool`` leaked four handles (both ends of the input and output queues) per pool. When a child process is being spawned, duplicate the handle straight into it instead, so the operating system releases the handle if the child dies early. This is the same approach already used for the pipe that sends the child its parameters (bpo-33929) and for semaphores. Handles sent to an already running process still use the steal mechanism. --- Lib/multiprocessing/reduction.py | 14 ++++ Lib/test/_test_multiprocessing.py | 65 +++++++++++++++++++ ...-07-21-21-42-10.gh-issue-154208.Kq3vTd.rst | 5 ++ 3 files changed, 84 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-07-21-21-42-10.gh-issue-154208.Kq3vTd.rst diff --git a/Lib/multiprocessing/reduction.py b/Lib/multiprocessing/reduction.py index fcccd3eef86cc7..41de207ebb31d4 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 8f665b3a98a037..821b49d28537d1 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 00000000000000..3dd2414b4144ba --- /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.