Skip to content

Commit d8c7de2

Browse files
committed
Drain stdout to EOF so _ProactorReadPipeTransport closes on Windows
One remaining leak on Windows CI: _ProactorReadPipeTransport (stdout) was never closed, triggering ResourceWarning in a later test. Root cause: anyio's StreamReaderWrapper.aclose() only calls set_exception(ClosedResourceError()) on the Python-level StreamReader — it never touches the underlying transport. On Windows, _ProactorReadPipeTransport starts with _paused=True and only detects pipe EOF when someone reads. The EOF-detection path is what calls transport.close(): _eof_received() → self.close() → _sock = None. Without a read, the transport lives until __del__. Production stdio.py avoids this because its stdout_reader task continuously reads stdout, so EOF is detected naturally. These tests never read stdout, so the transport stays paused with an orphaned overlapped read until GC. Fix: drain stdout with a single receive() call in _terminate_and_reap. The process is already dead, so the read immediately gets EOF (EndOfStream on clean close, BrokenResourceError on abrupt RST, ClosedResourceError on second call when already aclose()'d — all caught by contextlib.suppress). Note: the agent's suggested fix (stack.enter_async_context(proc) for Process.__aexit__) wouldn't help — __aexit__ calls aclose() which has the same stdout.aclose() issue. The drain is what actually closes the transport. Github-Issue: #1775
1 parent a070356 commit d8c7de2

File tree

1 file changed

+26
-19
lines changed

1 file changed

+26
-19
lines changed

tests/client/test_stdio.py

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import sys
44
import textwrap
55
import time
6-
from contextlib import AsyncExitStack
6+
from contextlib import AsyncExitStack, suppress
77

88
import anyio
99
import anyio.abc
@@ -308,26 +308,31 @@ async def _assert_stream_closed(stream: anyio.abc.SocketStream) -> None:
308308

309309

310310
async def _terminate_and_reap(proc: anyio.abc.Process | FallbackProcess) -> None:
311-
"""Terminate the process tree, then reap and close pipe transports.
311+
"""Terminate the process tree, reap, and tear down pipe transports.
312312
313313
``_terminate_process_tree`` kills the OS process group / Job Object but does
314-
not call ``process.wait()`` or close stdin/stdout pipe wrappers. On Windows,
315-
the resulting ``_WindowsSubprocessTransport`` / ``PipeHandle`` objects are
316-
then GC'd after the test's ``filterwarnings`` scope has exited, triggering
317-
``PytestUnraisableExceptionWarning`` in whatever test happens to run next.
318-
319-
The SDK's production path (``stdio.py``) avoids this via ``async with process:``
320-
which wraps termination in a context manager whose ``__aexit__`` handles
321-
reaping + stream closure. These tests call ``_terminate_process_tree``
322-
directly, so they must do the same cleanup explicitly.
323-
324-
Idempotent: the ``returncode`` guard skips termination if the process has
325-
already been reaped (calling ``_terminate_process_tree`` on a reaped POSIX
326-
process hits the fallback path in ``terminate_posix_process_tree`` and emits
327-
spurious WARNING/ERROR logs, visible because ``log_cli = true``); ``wait()``
328-
and stream ``aclose()`` are no-ops on subsequent calls. The tests call this
329-
explicitly as the action under test, and ``AsyncExitStack`` calls it again
330-
on exit as a safety net for early failures. Bounded by ``move_on_after``.
314+
not call ``process.wait()`` or clean up the asyncio pipe transports. On
315+
Windows those transports leak and emit ``ResourceWarning`` when GC'd in a
316+
later test, causing ``PytestUnraisableExceptionWarning`` knock-on failures.
317+
318+
Production ``stdio.py`` avoids this via its ``stdout_reader`` task which
319+
reads stdout to EOF (triggering ``_ProactorReadPipeTransport._eof_received``
320+
→ ``close()``) plus ``async with process:`` which waits and closes stdin.
321+
These tests call ``_terminate_process_tree`` directly, so they replicate
322+
both parts here: ``wait()`` + close stdin + drain stdout to EOF.
323+
324+
The stdout drain is the non-obvious part: anyio's ``StreamReaderWrapper.aclose()``
325+
only marks the Python-level reader closed — it never touches the underlying
326+
``_ProactorReadPipeTransport``. That transport starts paused and only detects
327+
pipe EOF when someone reads, so without a drain it lives until ``__del__``.
328+
329+
Idempotent: the ``returncode`` guard skips termination if already reaped
330+
(avoids spurious WARNING/ERROR logs from ``terminate_posix_process_tree``'s
331+
fallback path, visible because ``log_cli = true``); ``wait()`` and stream
332+
``aclose()`` no-op on subsequent calls; the drain raises ``ClosedResourceError``
333+
on the second call, caught by the suppress. The tests call this explicitly
334+
as the action under test and ``AsyncExitStack`` calls it again on exit as a
335+
safety net. Bounded by ``move_on_after`` to prevent hangs.
331336
"""
332337
with anyio.move_on_after(5.0):
333338
if getattr(proc, "returncode", None) is None:
@@ -336,6 +341,8 @@ async def _terminate_and_reap(proc: anyio.abc.Process | FallbackProcess) -> None
336341
assert proc.stdin is not None
337342
assert proc.stdout is not None
338343
await proc.stdin.aclose()
344+
with suppress(anyio.EndOfStream, anyio.BrokenResourceError, anyio.ClosedResourceError):
345+
await proc.stdout.receive(65536)
339346
await proc.stdout.aclose()
340347

341348

0 commit comments

Comments
 (0)