From 6a0b68cacf35af2576cb73d5f71731a2ea050eaf Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Thu, 10 Sep 2026 09:21:40 +0200 Subject: [PATCH 1/5] Give the Windows IO backend the error messages its siblings have wait_readable / wait_writable raised a bare BusyResourceError, and notify_closing a bare ClosedResourceError. The epoll and kqueue backends describe both. _io_windows already passes a message for the lpOverlapped case, so it was inconsistent with itself too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011M5uTyCU4WcNTsPvGrErDo --- src/trio/_core/_io_windows.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/trio/_core/_io_windows.py b/src/trio/_core/_io_windows.py index 7b789c3dec..99f31aea0a 100644 --- a/src/trio/_core/_io_windows.py +++ b/src/trio/_core/_io_windows.py @@ -740,7 +740,9 @@ async def _afd_poll(self, sock: _HasFileNo | int, mode: str) -> None: waiters = AFDWaiters() self._afd_waiters[base_handle] = waiters if getattr(waiters, mode) is not None: - raise _core.BusyResourceError + raise _core.BusyResourceError( + "another task is already reading / writing this socket", + ) setattr(waiters, mode, _core.current_task()) # Could potentially raise if the handle is somehow invalid; that's OK, # we let it escape. @@ -822,7 +824,10 @@ def notify_closing(self, handle: Handle | int | _HasFileNo) -> None: handle = _get_base_socket(handle) waiters = self._afd_waiters.get(handle) if waiters is not None: - wake_all(waiters, _core.ClosedResourceError()) + wake_all( + waiters, + _core.ClosedResourceError("another task closed this socket"), + ) self._refresh_afd(handle) ################################################################ From 1e5ef5f009349425674b14d713e219bb62308222 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Thu, 10 Sep 2026 09:22:07 +0200 Subject: [PATCH 2/5] Add newsfragment for 3509 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011M5uTyCU4WcNTsPvGrErDo --- newsfragments/3509.bugfix.rst | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 newsfragments/3509.bugfix.rst diff --git a/newsfragments/3509.bugfix.rst b/newsfragments/3509.bugfix.rst new file mode 100644 index 0000000000..2966f4706f --- /dev/null +++ b/newsfragments/3509.bugfix.rst @@ -0,0 +1,4 @@ +On Windows, :func:`trio.lowlevel.wait_readable`, :func:`trio.lowlevel.wait_writable` +and :func:`trio.lowlevel.notify_closing` raised ``BusyResourceError`` and +``ClosedResourceError`` without any message. They now explain themselves, as they +already did on the epoll and kqueue backends. From 44216737b1fdb86cab8129294c7b9b92537acd4b Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Fri, 18 Sep 2026 20:32:49 +0200 Subject: [PATCH 3/5] Assert the io errors carry a message These tests only checked the exception type, which is why the empty Windows BusyResourceError and ClosedResourceError went unnoticed. The messages differ per backend, so the assertion is that there is one. Stripping the messages from the kqueue backend fails 12 of these tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011M5uTyCU4WcNTsPvGrErDo --- src/trio/_core/_tests/test_io.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/trio/_core/_tests/test_io.py b/src/trio/_core/_tests/test_io.py index 379daa025e..ac36e46ab0 100644 --- a/src/trio/_core/_tests/test_io.py +++ b/src/trio/_core/_tests/test_io.py @@ -170,8 +170,9 @@ async def test_double_read(socketpair: SocketPair, wait_readable: WaitSocket) -> async with _core.open_nursery() as nursery: nursery.start_soon(wait_readable, a) await wait_all_tasks_blocked() - with pytest.raises(_core.BusyResourceError): + with pytest.raises(_core.BusyResourceError) as busy: await wait_readable(a) + assert str(busy.value), "the error should say why the resource is busy" nursery.cancel_scope.cancel() @@ -184,8 +185,9 @@ async def test_double_write(socketpair: SocketPair, wait_writable: WaitSocket) - async with _core.open_nursery() as nursery: nursery.start_soon(wait_writable, a) await wait_all_tasks_blocked() - with pytest.raises(_core.BusyResourceError): + with pytest.raises(_core.BusyResourceError) as busy: await wait_writable(a) + assert str(busy.value), "the error should say why the resource is busy" nursery.cancel_scope.cancel() @@ -201,12 +203,14 @@ async def test_interrupted_by_close( a, _b = socketpair async def reader() -> None: - with pytest.raises(_core.ClosedResourceError): + with pytest.raises(_core.ClosedResourceError) as closed: await wait_readable(a) + assert str(closed.value), "the error should say why the resource is closed" async def writer() -> None: - with pytest.raises(_core.ClosedResourceError): + with pytest.raises(_core.ClosedResourceError) as closed: await wait_writable(a) + assert str(closed.value), "the error should say why the resource is closed" fill_socket(a) From 9efa80aef79fc22de5d24d4a27d48ba9e7e6d7c8 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Wed, 23 Sep 2026 10:09:03 +0200 Subject: [PATCH 4/5] Use pytest's match instead of asserting on the exception Same assertion, the idiom the reviewer asked for. Stripping the messages from the kqueue backend still fails 12 of these tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011M5uTyCU4WcNTsPvGrErDo --- src/trio/_core/_tests/test_io.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/trio/_core/_tests/test_io.py b/src/trio/_core/_tests/test_io.py index ac36e46ab0..63d3dea233 100644 --- a/src/trio/_core/_tests/test_io.py +++ b/src/trio/_core/_tests/test_io.py @@ -170,9 +170,9 @@ async def test_double_read(socketpair: SocketPair, wait_readable: WaitSocket) -> async with _core.open_nursery() as nursery: nursery.start_soon(wait_readable, a) await wait_all_tasks_blocked() - with pytest.raises(_core.BusyResourceError) as busy: + # The wording differs per backend, so assert only that there is one. + with pytest.raises(_core.BusyResourceError, match=r"\S"): await wait_readable(a) - assert str(busy.value), "the error should say why the resource is busy" nursery.cancel_scope.cancel() @@ -185,9 +185,9 @@ async def test_double_write(socketpair: SocketPair, wait_writable: WaitSocket) - async with _core.open_nursery() as nursery: nursery.start_soon(wait_writable, a) await wait_all_tasks_blocked() - with pytest.raises(_core.BusyResourceError) as busy: + # The wording differs per backend, so assert only that there is one. + with pytest.raises(_core.BusyResourceError, match=r"\S"): await wait_writable(a) - assert str(busy.value), "the error should say why the resource is busy" nursery.cancel_scope.cancel() @@ -203,14 +203,12 @@ async def test_interrupted_by_close( a, _b = socketpair async def reader() -> None: - with pytest.raises(_core.ClosedResourceError) as closed: + with pytest.raises(_core.ClosedResourceError, match=r"\S"): await wait_readable(a) - assert str(closed.value), "the error should say why the resource is closed" async def writer() -> None: - with pytest.raises(_core.ClosedResourceError) as closed: + with pytest.raises(_core.ClosedResourceError, match=r"\S"): await wait_writable(a) - assert str(closed.value), "the error should say why the resource is closed" fill_socket(a) From 02d0adff081c1f359ffb45170cfaa9a87369a2ba Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Fri, 30 Oct 2026 23:57:39 -0400 Subject: [PATCH 5/5] Match the actual messages instead of merely requiring one Taking the review point. `match=r"\S"` only asserted a message existed, so rewording one would not have been noticed. `ClosedResourceError` is the same sentence on all three backends up to the last word, so the test matches "another task closed this " directly. `BusyResourceError` has two unrelated texts: epoll and windows say "another task is already reading / writing this ...", kqueue says "attempt to register multiple listeners for same ident/filter pair". BUSY_MESSAGE matches either, so the assertion is that the message is one of the two real ones. Rewording kqueue's two messages while keeping them non-empty now fails 12 of the 29 tests in test_io.py. The same rewording passed all 29 under `r"\S"`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011M5uTyCU4WcNTsPvGrErDo --- src/trio/_core/_tests/test_io.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/trio/_core/_tests/test_io.py b/src/trio/_core/_tests/test_io.py index 63d3dea233..b71e8b616d 100644 --- a/src/trio/_core/_tests/test_io.py +++ b/src/trio/_core/_tests/test_io.py @@ -162,6 +162,12 @@ async def block_on_write() -> None: assert record == ["cancelled"] +# The two real BusyResourceError texts from the three backends: epoll and +# windows word it one way, kqueue another. Matching both asserts the message is +# one of them rather than merely non-empty. +BUSY_MESSAGE = r"already reading / writing this|multiple listeners for same ident" + + @read_socket_test async def test_double_read(socketpair: SocketPair, wait_readable: WaitSocket) -> None: a, _b = socketpair @@ -170,8 +176,8 @@ async def test_double_read(socketpair: SocketPair, wait_readable: WaitSocket) -> async with _core.open_nursery() as nursery: nursery.start_soon(wait_readable, a) await wait_all_tasks_blocked() - # The wording differs per backend, so assert only that there is one. - with pytest.raises(_core.BusyResourceError, match=r"\S"): + # epoll and windows say one thing here, kqueue another, so match both. + with pytest.raises(_core.BusyResourceError, match=BUSY_MESSAGE): await wait_readable(a) nursery.cancel_scope.cancel() @@ -185,8 +191,8 @@ async def test_double_write(socketpair: SocketPair, wait_writable: WaitSocket) - async with _core.open_nursery() as nursery: nursery.start_soon(wait_writable, a) await wait_all_tasks_blocked() - # The wording differs per backend, so assert only that there is one. - with pytest.raises(_core.BusyResourceError, match=r"\S"): + # epoll and windows say one thing here, kqueue another, so match both. + with pytest.raises(_core.BusyResourceError, match=BUSY_MESSAGE): await wait_writable(a) nursery.cancel_scope.cancel() @@ -203,11 +209,15 @@ async def test_interrupted_by_close( a, _b = socketpair async def reader() -> None: - with pytest.raises(_core.ClosedResourceError, match=r"\S"): + with pytest.raises( + _core.ClosedResourceError, match="another task closed this " + ): await wait_readable(a) async def writer() -> None: - with pytest.raises(_core.ClosedResourceError, match=r"\S"): + with pytest.raises( + _core.ClosedResourceError, match="another task closed this " + ): await wait_writable(a) fill_socket(a)