From 37904e4229c3c0a8295b918a0c90cff16f837884 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 7 May 2026 14:59:08 -0700 Subject: [PATCH] gh-149388: Make PipeHandle.close idempotent Clear _handle before calling CloseHandle so a stale handle (closed by another code path) does not leak OSError into _ProactorBasePipeTransport._call_connection_lost. --- Lib/asyncio/windows_utils.py | 4 +-- Lib/test/test_asyncio/test_windows_utils.py | 28 +++++++++++++++++++ ...-05-07-21-58-17.gh-issue-149388.DDBPeA.rst | 7 +++++ 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-05-07-21-58-17.gh-issue-149388.DDBPeA.rst diff --git a/Lib/asyncio/windows_utils.py b/Lib/asyncio/windows_utils.py index acd49441131b04..e1506ecb44741c 100644 --- a/Lib/asyncio/windows_utils.py +++ b/Lib/asyncio/windows_utils.py @@ -111,8 +111,8 @@ def fileno(self): def close(self, *, CloseHandle=_winapi.CloseHandle): if self._handle is not None: - CloseHandle(self._handle) - self._handle = None + handle, self._handle = self._handle, None + CloseHandle(handle) def __del__(self, _warn=warnings.warn): if self._handle is not None: diff --git a/Lib/test/test_asyncio/test_windows_utils.py b/Lib/test/test_asyncio/test_windows_utils.py index f9ee2f4f68150a..5c177037ab01c2 100644 --- a/Lib/test/test_asyncio/test_windows_utils.py +++ b/Lib/test/test_asyncio/test_windows_utils.py @@ -77,6 +77,34 @@ def test_pipe_handle(self): else: raise RuntimeError('expected ERROR_INVALID_HANDLE') + def test_pipe_handle_close_after_external_close(self): + # gh-149388: PipeHandle.close() must clear ``_handle`` before calling + # CloseHandle so that a handle already closed by another code path + # does not leak an OSError into the caller (and into + # _ProactorBasePipeTransport._call_connection_lost on the proactor + # loop, where the error is not caught). + h1, h2 = windows_utils.pipe(overlapped=(False, False)) + try: + p = windows_utils.PipeHandle(h1) + # Simulate an external close of the underlying handle (e.g. + # a finalizer race or a concurrent close on the same object). + _winapi.CloseHandle(p.handle) + # The OSError from CloseHandle may still surface to the caller, + # but ``_handle`` must be cleared first so that __del__ and any + # subsequent close() are silent no-ops. + try: + p.close() + except OSError: + pass + self.assertIsNone(p.handle) + # Second close is a no-op. + p.close() + # __del__ through GC is a no-op too — no unraisable warning. + del p + support.gc_collect() + finally: + _winapi.CloseHandle(h2) + class PopenTests(unittest.TestCase): diff --git a/Misc/NEWS.d/next/Library/2026-05-07-21-58-17.gh-issue-149388.DDBPeA.rst b/Misc/NEWS.d/next/Library/2026-05-07-21-58-17.gh-issue-149388.DDBPeA.rst new file mode 100644 index 00000000000000..71d4731ff1d9aa --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-05-07-21-58-17.gh-issue-149388.DDBPeA.rst @@ -0,0 +1,7 @@ +Make :meth:`!asyncio.windows_utils.PipeHandle.close` idempotent by clearing +``_handle`` before calling :c:func:`!CloseHandle`. Previously, if the +underlying Win32 handle had already been closed by another code path, the +``OSError`` raised by :c:func:`!CloseHandle` would escape through +:meth:`!_ProactorBasePipeTransport._call_connection_lost` on Windows +proactor loops and be reported as an unhandled exception to the event +loop's exception handler.