X Tutup
Skip to content

fix: close all memory stream ends in client transport cleanup#2266

Draft
maxisbey wants to merge 2 commits intomainfrom
max-153-stream-leak-fix
Draft

fix: close all memory stream ends in client transport cleanup#2266
maxisbey wants to merge 2 commits intomainfrom
max-153-stream-leak-fix

Conversation

@maxisbey
Copy link
Contributor

Summary

Client transports for SSE, WebSocket, and StreamableHTTP create 4 anyio memory stream ends (2 paired streams) but only closed 2 in their finally blocks. anyio memory stream ends are independent — closing the writer does not close the reader. Unclosed stream ends leak and emit ResourceWarning when garbage collected.

This caused flaky CI failures: a transport connection error (404, 403, ConnectError) in one test would leak streams, then GC in a later unrelated test would trigger ResourceWarning, which pytest's filterwarnings = ["error"] promotes to a test failure — in whatever test happened to be running when GC fired, not the test that actually leaked.

Fix

Follows the existing correct pattern in stdio.py (which closes all 4 ends on both early-fail and normal-exit paths):

File Before After
sse.py finally closed 2 of 4 finally closes all 4
streamable_http.py finally closed 2 of 4 — read_stream was never closed, even on happy path finally closes all 4
websocket.py No try/finally at all — if ws_connect() raised, all 4 leaked Wrapped entire body in try/finally that closes all 4

anyio's aclose() is idempotent, so double-closing (e.g. when reader/writer tasks already closed their end) is safe.

Tests

Added tests/client/test_transport_stream_cleanup.py with one regression test per transport. Each test triggers the error/exit path, then calls gc.collect() to force any leaked stream to emit ResourceWarning deterministically. All 3 tests fail on main with ResourceWarning: Unclosed <MemoryObjectReceiveStream> and pass with this fix.

CI Evidence of the Flakiness

AI Disclaimer

Client transports for SSE, WebSocket, and StreamableHTTP create 4 memory
stream ends (2 paired streams) but only closed 2 in their finally blocks.
anyio memory stream ends are independent — closing the writer does not
close the reader. The unclosed ends leak and emit ResourceWarning when
garbage collected.

This caused flaky test failures in CI: a transport connection error in
one test would leak streams, then GC in a later unrelated test would
trigger ResourceWarning, which pytest promotes to a test failure.

Fix follows the existing correct pattern in stdio.py:
- sse.py: close all 4 stream ends in the existing finally block
- streamable_http.py: close all 4 stream ends in the existing finally
  block (read_stream was previously never closed, even on happy path)
- websocket.py: add try/finally wrapping the entire body, closing all
  4 stream ends (previously had no cleanup at all — ws_connect failure
  leaked everything)

Regression tests force gc.collect() after the transport context exits
so leaked streams fail deterministically in the test that caused them.
The gc.collect() in these tests was picking up leaked PipeHandles from
flaky stdio tests (TestChildProcessCleanup) on the same xdist worker,
causing false failures on Windows CI.

Now uses a custom sys.unraisablehook that filters for MemoryObject
stream leaks specifically, ignoring unrelated resources leaked by other
tests. Also adds explicit del exc_info in the SSE test since the
traceback would otherwise keep leaked stream locals alive past
gc.collect().
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

X Tutup