"""Unit tests for the streamed-read helpers. These exercise connection lifecycle (consume / context manager / explicit close / idle timeout / abandonment) without hitting a real sandbox, using a local chunked HTTP server. """ import asyncio import socket import threading import time from typing import Optional import httpx import pytest from e2b.sandbox.filesystem.filesystem import ( AsyncFileStreamReader, FileStreamReader, ) CHUNKS = [f"chunk{i}".encode() for i in range(5)] EXPECTED = b"".join(CHUNKS) def _start_chunked_server( stall_before: Optional[int] = None, stall_seconds: float = 0.0, ) -> int: """Start a one-shot HTTP server that replies with a chunked body. When ``stall_before`` is set, the server sleeps ``stall_seconds`` before sending that chunk index, so a reader with a shorter idle timeout times out. Returns the server's port. """ sock = socket.socket() sock.bind(("127.0.0.1", 0)) sock.listen(1) port = sock.getsockname()[1] def serve(): try: conn, _ = sock.accept() while b"\r\n\r\n" not in conn.recv(65536): pass conn.sendall( b"HTTP/1.1 200 OK\r\n" b"Content-Type: application/octet-stream\r\n" b"Transfer-Encoding: chunked\r\n\r\n" ) for idx, chunk in enumerate(CHUNKS): if stall_before is not None and idx == stall_before: time.sleep(stall_seconds) conn.sendall(f"{len(chunk):x}\r\n".encode() + chunk + b"\r\n") conn.sendall(b"0\r\n\r\n") conn.close() except OSError: pass finally: sock.close() threading.Thread(target=serve, daemon=True).start() return port def _active_connections(client) -> int: # Count connections that are still checked out (a leaked/in-use stream), # not the total pool size. A fully consumed stream returns its connection # to the pool, where it may linger as an idle keep-alive entry until the # server-side close is observed; that lingering idle connection is not a # leak. Asserting on total pool size makes this racy under load (the basis # of a CI flake); counting only non-idle connections is deterministic. return sum(1 for conn in client._transport._pool.connections if not conn.is_idle()) def _open_stream(client, port, read_timeout: Optional[float] = None): request = client.build_request( "GET", f"http://127.0.0.1:{port}/files", timeout=httpx.Timeout(5.0) ) if read_timeout is not None: # Mirror the SDK: the per-chunk `read` timeout bounds idle gaps. request.extensions["timeout"]["read"] = read_timeout return client.send(request, stream=True) def test_sync_full_consume_releases_connection(): with httpx.Client() as client: port = _start_chunked_server() reader = FileStreamReader(_open_stream(client, port)) assert b"".join(reader) == EXPECTED assert _active_connections(client) == 0 def test_sync_context_manager_releases_on_exit(): with httpx.Client() as client: port = _start_chunked_server() with FileStreamReader(_open_stream(client, port)) as reader: assert next(iter(reader)) == CHUNKS[0] # Exiting the context releases the connection even though the stream # was only partially consumed. assert _active_connections(client) == 0 def test_sync_close_is_idempotent(): with httpx.Client() as client: port = _start_chunked_server() reader = FileStreamReader(_open_stream(client, port)) reader.close() reader.close() assert _active_connections(client) == 0 def test_sync_idle_timeout_releases_connection(): with httpx.Client() as client: # The server stalls before the second chunk for longer than the # reader's idle (read) timeout. port = _start_chunked_server(stall_before=1, stall_seconds=0.5) reader = FileStreamReader(_open_stream(client, port, read_timeout=0.05)) it = iter(reader) assert next(it) # The stalled read trips the idle timeout, which propagates and # releases the connection. with pytest.raises(httpx.ReadTimeout): next(it) assert _active_connections(client) == 0 def test_sync_slow_consumer_does_not_trip_idle_timeout(): # The server sends every chunk promptly; the consumer then pauses far # longer than the read (idle) timeout between iterations. httpx's read # timeout only counts while it's waiting on the wire, so a slow consumer # must not trip it (parity with the JS wire-only idle timeout). with httpx.Client() as client: port = _start_chunked_server() reader = FileStreamReader(_open_stream(client, port, read_timeout=0.05)) out = [] for chunk in reader: out.append(chunk) time.sleep(0.2) assert b"".join(out) == EXPECTED def test_sync_abandoned_reader_is_reclaimed_on_client_close(): client = httpx.Client() port = _start_chunked_server() reader = FileStreamReader(_open_stream(client, port)) assert _active_connections(client) == 1 # The sync reader has no GC safety net: dropping it without closing keeps # the connection checked out (an idle timeout would reclaim a stalled one). del reader assert _active_connections(client) == 1 # Closing the client reclaims the abandoned connection. client.close() assert _active_connections(client) == 0 async def test_async_full_consume_releases_connection(): async with httpx.AsyncClient() as client: port = _start_chunked_server() request = client.build_request("GET", f"http://127.0.0.1:{port}/files") reader = AsyncFileStreamReader(await client.send(request, stream=True)) collected = b"".join([chunk async for chunk in reader]) assert collected == EXPECTED assert _active_connections(client) == 0 async def test_async_context_manager_releases_on_exit(): async with httpx.AsyncClient() as client: port = _start_chunked_server() request = client.build_request("GET", f"http://127.0.0.1:{port}/files") async with AsyncFileStreamReader( await client.send(request, stream=True) ) as reader: assert await reader.__anext__() == CHUNKS[0] assert _active_connections(client) == 0 async def test_async_aclose_is_idempotent(): async with httpx.AsyncClient() as client: port = _start_chunked_server() request = client.build_request("GET", f"http://127.0.0.1:{port}/files") reader = AsyncFileStreamReader(await client.send(request, stream=True)) await reader.aclose() await reader.aclose() assert _active_connections(client) == 0 async def test_async_idle_timeout_releases_connection(): async with httpx.AsyncClient() as client: port = _start_chunked_server(stall_before=1, stall_seconds=0.5) request = client.build_request( "GET", f"http://127.0.0.1:{port}/files", timeout=httpx.Timeout(5.0) ) request.extensions["timeout"]["read"] = 0.05 reader = AsyncFileStreamReader(await client.send(request, stream=True)) it = reader.__aiter__() assert await it.__anext__() # The stalled read trips the idle timeout, which propagates and # releases the connection. with pytest.raises(httpx.ReadTimeout): await it.__anext__() assert _active_connections(client) == 0 async def test_async_abandoned_reader_is_reclaimed_on_client_close(): client = httpx.AsyncClient() port = _start_chunked_server() request = client.build_request("GET", f"http://127.0.0.1:{port}/files") reader = AsyncFileStreamReader(await client.send(request, stream=True)) assert _active_connections(client) == 1 # The async reader has no GC safety net: dropping it without closing keeps # the connection checked out (releasing one requires awaiting aclose()). del reader await asyncio.sleep(0.05) assert _active_connections(client) == 1 # Closing the client reclaims the abandoned connection. await client.aclose() assert _active_connections(client) == 0 if __name__ == "__main__": raise SystemExit(pytest.main([__file__, "-v"]))