From f91745a0956b725e29e375a89bc5d67f23a330ed Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Sun, 19 Jul 2026 14:49:15 +0200 Subject: [PATCH 1/3] gh-154137: Check for Windows handle leaks in regrtest Add handle_count() to test.support.os_helper. --- Lib/test/libregrtest/refleak.py | 12 +++++-- Lib/test/support/os_helper.py | 32 +++++++++++++++++++ Lib/test/test_regrtest.py | 28 ++++++++++++++++ Lib/test/test_support.py | 25 +++++++++++++++ ...-07-19-15-59-41.gh-issue-154137.N689bE.rst | 1 + 5 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Tests/2026-07-19-15-59-41.gh-issue-154137.N689bE.rst diff --git a/Lib/test/libregrtest/refleak.py b/Lib/test/libregrtest/refleak.py index e7da17e500ead9..a07afa5fe665a9 100644 --- a/Lib/test/libregrtest/refleak.py +++ b/Lib/test/libregrtest/refleak.py @@ -118,12 +118,15 @@ def get_pooled_int(value): rc_deltas = [0] * repcount alloc_deltas = [0] * repcount fd_deltas = [0] * repcount + handle_deltas = [0] * repcount getallocatedblocks = sys.getallocatedblocks gettotalrefcount = sys.gettotalrefcount getunicodeinternedsize = sys.getunicodeinternedsize fd_count = os_helper.fd_count + handle_count = os_helper.handle_count # initialize variables to make pyflakes quiet rc_before = alloc_before = fd_before = interned_immortal_before = 0 + handle_before = 0 if not quiet: print("beginning", repcount, "repetitions. Showing number of leaks " @@ -160,14 +163,17 @@ def get_pooled_int(value): alloc_after = getallocatedblocks() - interned_immortal_after rc_after = gettotalrefcount() fd_after = fd_count() + handle_after = handle_count() rc_deltas[i] = get_pooled_int(rc_after - rc_before) alloc_deltas[i] = get_pooled_int(alloc_after - alloc_before) fd_deltas[i] = get_pooled_int(fd_after - fd_before) + handle_deltas[i] = get_pooled_int(handle_after - handle_before) if not quiet: # use max, not sum, so total_leaks is one of the pooled ints - total_leaks = max(rc_deltas[i], alloc_deltas[i], fd_deltas[i]) + total_leaks = max(rc_deltas[i], alloc_deltas[i], + fd_deltas[i], handle_deltas[i]) if total_leaks <= 0: symbol = '.' elif total_leaks < 10: @@ -185,6 +191,7 @@ def get_pooled_int(value): alloc_before = alloc_after rc_before = rc_after fd_before = fd_after + handle_before = handle_after interned_immortal_before = interned_immortal_after restore_support_xml(xml_filename) @@ -215,7 +222,8 @@ def check_fd_deltas(deltas): for deltas, item_name, checker in [ (rc_deltas, 'references', check_rc_deltas), (alloc_deltas, 'memory blocks', check_rc_deltas), - (fd_deltas, 'file descriptors', check_fd_deltas) + (fd_deltas, 'file descriptors', check_fd_deltas), + (handle_deltas, 'handles', check_rc_deltas), ]: # ignore warmup runs deltas = deltas[warmups:] diff --git a/Lib/test/support/os_helper.py b/Lib/test/support/os_helper.py index 2c45fe2369ec36..ecd7868edf7e07 100644 --- a/Lib/test/support/os_helper.py +++ b/Lib/test/support/os_helper.py @@ -830,3 +830,35 @@ def subst_drive(path): DDD_REMOVE_DEFINITION | DDD_EXACT_MATCH_ON_REMOVE, drive, path): raise ctypes.WinError(ctypes.get_last_error()) + + +try: + if support.MS_WINDOWS: + import ctypes.util + kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) + else: + raise AttributeError +except (ImportError, AttributeError): + def handle_count(): + return 0 +else: + @ctypes.util.wrap_dll_function(kernel32) + def GetCurrentProcess() -> ctypes.wintypes.HANDLE: + pass + + @ctypes.util.wrap_dll_function(kernel32) + def GetProcessHandleCount(khProcess: ctypes.wintypes.HANDLE, + pdwHandleCount: ctypes.wintypes.LPDWORD) -> ctypes.wintypes.BOOL: + pass + + del kernel32 + + def handle_count(): + # Pseudo-handle that doesn't need to be closed + hproc = GetCurrentProcess() + + handle_count = ctypes.wintypes.DWORD() + if not GetProcessHandleCount(hproc, ctypes.byref(handle_count)): + raise ctypes.WinError() + + return handle_count.value diff --git a/Lib/test/test_regrtest.py b/Lib/test/test_regrtest.py index 6d30d267cd5ad2..52561910b32f8a 100644 --- a/Lib/test/test_regrtest.py +++ b/Lib/test/test_regrtest.py @@ -1382,6 +1382,34 @@ def test_leak(self): """) self.check_leak(code, 'file descriptors') + @unittest.skipUnless(support.Py_DEBUG, 'need a debug build') + def test_huntrleaks_handle_leak(self): + # test --huntrleaks for Windows handle leak + code = textwrap.dedent(""" + import unittest + import _winapi + + handle = None + + class HandleLeakTest(unittest.TestCase): + def test_leak(self): + global handle + if handle is None: + handle = _winapi.CreateFile( + __file__, _winapi.GENERIC_READ, + 0, _winapi.NULL, + _winapi.OPEN_EXISTING, + 0, _winapi.NULL) + else: + hproc = _winapi.GetCurrentProcess() + copy = _winapi.DuplicateHandle( + hproc, handle, + hproc, 0, False, + _winapi.DUPLICATE_SAME_ACCESS) + # bug! the new handle is never closed + """) + self.check_leak(code, 'handles') + def test_list_tests(self): # test --list-tests tests = [self.create_test() for i in range(5)] diff --git a/Lib/test/test_support.py b/Lib/test/test_support.py index d556f96bc532ed..0bb6cf1ed94c88 100644 --- a/Lib/test/test_support.py +++ b/Lib/test/test_support.py @@ -26,6 +26,10 @@ from test.support import socket_helper from test.support import warnings_helper +if support.MS_WINDOWS: + import _winapi + + TESTFN = os_helper.TESTFN @@ -623,6 +627,27 @@ def test_fd_count(self): os.close(fd) self.assertEqual(more - start, 1) + @unittest.skipUnless(support.MS_WINDOWS, "test specific to Windows") + def test_handle_count(self): + handle = _winapi.CreateFile( + __file__, _winapi.GENERIC_READ, + 0, _winapi.NULL, + _winapi.OPEN_EXISTING, + 0, _winapi.NULL) + self.addCleanup(_winapi.CloseHandle, handle) + + start = os_helper.handle_count() + hproc = _winapi.GetCurrentProcess() + copy = _winapi.DuplicateHandle( + hproc, handle, + hproc, 0, False, + _winapi.DUPLICATE_SAME_ACCESS) + try: + more = os_helper.handle_count() + finally: + _winapi.CloseHandle(copy) + self.assertEqual(more - start, 1) + def check_print_warning(self, msg, expected): stderr = io.StringIO() with support.swap_attr(support.print_warning, 'orig_stderr', stderr): diff --git a/Misc/NEWS.d/next/Tests/2026-07-19-15-59-41.gh-issue-154137.N689bE.rst b/Misc/NEWS.d/next/Tests/2026-07-19-15-59-41.gh-issue-154137.N689bE.rst new file mode 100644 index 00000000000000..39809e44f72185 --- /dev/null +++ b/Misc/NEWS.d/next/Tests/2026-07-19-15-59-41.gh-issue-154137.N689bE.rst @@ -0,0 +1 @@ +Check for Windows handle leaks in regrtest. Patch by Victor Stinner. From 37c29f36103016e1b3771a104deb2236a9f6032b Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Sun, 19 Jul 2026 16:28:45 +0200 Subject: [PATCH 2/3] Skip test_regrtest test on non-Windows platforms --- Lib/test/test_regrtest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/test_regrtest.py b/Lib/test/test_regrtest.py index 52561910b32f8a..a8687ef2162aab 100644 --- a/Lib/test/test_regrtest.py +++ b/Lib/test/test_regrtest.py @@ -1383,6 +1383,7 @@ def test_leak(self): self.check_leak(code, 'file descriptors') @unittest.skipUnless(support.Py_DEBUG, 'need a debug build') + @unittest.skipUnless(support.MS_WINDOWS, 'test specific to Windows') def test_huntrleaks_handle_leak(self): # test --huntrleaks for Windows handle leak code = textwrap.dedent(""" From e6200f09406beced41ea99dc4c0229a49db904b1 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Sun, 19 Jul 2026 23:35:18 +0200 Subject: [PATCH 3/3] Fix os_helper.subst_drive() Reuse os_helper.handle_count() in test_os.test_windows. --- Lib/test/support/os_helper.py | 56 +++++++++++++++++++------------- Lib/test/test_os/test_windows.py | 24 ++------------ 2 files changed, 35 insertions(+), 45 deletions(-) diff --git a/Lib/test/support/os_helper.py b/Lib/test/support/os_helper.py index ecd7868edf7e07..7c28a339832a67 100644 --- a/Lib/test/support/os_helper.py +++ b/Lib/test/support/os_helper.py @@ -797,51 +797,61 @@ def __exit__(self, *ignore_exc): try: if support.MS_WINDOWS: - import ctypes + import ctypes.util kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) - - ERROR_FILE_NOT_FOUND = 2 - DDD_REMOVE_DEFINITION = 2 - DDD_EXACT_MATCH_ON_REMOVE = 4 - DDD_NO_BROADCAST_SYSTEM = 8 else: raise AttributeError except (ImportError, AttributeError): def subst_drive(path): raise unittest.SkipTest('ctypes or kernel32 is not available') + + def handle_count(): + return 0 else: + ERROR_FILE_NOT_FOUND = 2 + DDD_REMOVE_DEFINITION = 2 + DDD_EXACT_MATCH_ON_REMOVE = 4 + DDD_NO_BROADCAST_SYSTEM = 8 + + + @ctypes.util.wrap_dll_function(kernel32) + def DefineDosDeviceW( + dwFlags: ctypes.wintypes.DWORD, + lpDeviceName: ctypes.c_wchar_p, + lpTargetPath: ctypes.c_wchar_p, + ) -> ctypes.wintypes.BOOL: + pass + + @ctypes.util.wrap_dll_function(kernel32) + def QueryDosDeviceW( + lpDeviceName: ctypes.c_wchar_p, + lpTargetPath: ctypes.c_wchar_p, + ucchMax: ctypes.wintypes.DWORD, + ) -> ctypes.wintypes.DWORD: + pass + @contextlib.contextmanager def subst_drive(path): """Temporarily yield a substitute drive for a given path.""" for c in reversed(string.ascii_uppercase): drive = f'{c}:' - if (not kernel32.QueryDosDeviceW(drive, None, 0) and + if (not QueryDosDeviceW(drive, None, 0) and ctypes.get_last_error() == ERROR_FILE_NOT_FOUND): break else: raise unittest.SkipTest('no available logical drive') - if not kernel32.DefineDosDeviceW( - DDD_NO_BROADCAST_SYSTEM, drive, path): + + if not DefineDosDeviceW(DDD_NO_BROADCAST_SYSTEM, drive, path): raise ctypes.WinError(ctypes.get_last_error()) + try: yield drive finally: - if not kernel32.DefineDosDeviceW( - DDD_REMOVE_DEFINITION | DDD_EXACT_MATCH_ON_REMOVE, - drive, path): + flags = DDD_REMOVE_DEFINITION | DDD_EXACT_MATCH_ON_REMOVE + if not DefineDosDeviceW(flags, drive, path): raise ctypes.WinError(ctypes.get_last_error()) -try: - if support.MS_WINDOWS: - import ctypes.util - kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) - else: - raise AttributeError -except (ImportError, AttributeError): - def handle_count(): - return 0 -else: @ctypes.util.wrap_dll_function(kernel32) def GetCurrentProcess() -> ctypes.wintypes.HANDLE: pass @@ -859,6 +869,6 @@ def handle_count(): handle_count = ctypes.wintypes.DWORD() if not GetProcessHandleCount(hproc, ctypes.byref(handle_count)): - raise ctypes.WinError() + raise ctypes.WinError(ctypes.get_last_error()) return handle_count.value diff --git a/Lib/test/test_os/test_windows.py b/Lib/test/test_os/test_windows.py index f1c6283f60d35e..72896acfe79b8e 100644 --- a/Lib/test/test_os/test_windows.py +++ b/Lib/test/test_os/test_windows.py @@ -453,25 +453,8 @@ def test_unlink_removes_junction(self): class Win32NtTests(unittest.TestCase): def test_getfinalpathname_handles(self): nt = import_helper.import_module('nt') - ctypes = import_helper.import_module('ctypes') - # Ruff false positive -- it thinks we're redefining `ctypes` here - import ctypes.wintypes # noqa: F811 - kernel = ctypes.WinDLL('Kernel32.dll', use_last_error=True) - kernel.GetCurrentProcess.restype = ctypes.wintypes.HANDLE - - kernel.GetProcessHandleCount.restype = ctypes.wintypes.BOOL - kernel.GetProcessHandleCount.argtypes = (ctypes.wintypes.HANDLE, - ctypes.wintypes.LPDWORD) - - # This is a pseudo-handle that doesn't need to be closed - hproc = kernel.GetCurrentProcess() - - handle_count = ctypes.wintypes.DWORD() - ok = kernel.GetProcessHandleCount(hproc, ctypes.byref(handle_count)) - self.assertEqual(1, ok) - - before_count = handle_count.value + before_count = os_helper.handle_count() # The first two test the error path, __file__ tests the success path filenames = [ @@ -493,10 +476,7 @@ def test_getfinalpathname_handles(self): except Exception: pass - ok = kernel.GetProcessHandleCount(hproc, ctypes.byref(handle_count)) - self.assertEqual(1, ok) - - handle_delta = handle_count.value - before_count + handle_delta = os_helper.handle_count() - before_count self.assertEqual(0, handle_delta)