Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 72 additions & 43 deletions packages/python-sdk/tests/test_file_stream_reader.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
"""Unit tests for the streamed-read helpers.

These exercise the readers' own lifecycle (consume / context manager /
explicit close / idle timeout) against a local chunked HTTP server. They
assert on the reader's contract — the underlying response is closed — rather
than on connection-pool internals, which are private to the transport and
absent on the pyqwest transports the SDK ships.
explicit close / read error / abandonment / idle timeout) against a local
chunked HTTP server. They assert on the reader's contract — the underlying
response is closed, or deliberately left open — rather than on
connection-pool internals, which are private to the transport and absent on
the pyqwest transports the SDK ships.
"""

import asyncio
import gc
import socket
import threading
import time
Expand All @@ -24,15 +27,32 @@
EXPECTED = b"".join(CHUNKS)


def _read_request_head(conn) -> None:
"""Read up to the end of the request head.

Accumulates across reads, since the head can arrive split across TCP
segments, and stops on a closed peer rather than spinning on empty reads.
"""
buffered = b""
while b"\r\n\r\n" not in buffered:
received = conn.recv(65536)
if not received:
return
buffered += received


def _start_chunked_server(
stall_before: Optional[int] = None,
stall_seconds: float = 0.0,
truncate_before: Optional[int] = None,
) -> int:
"""Start a one-shot HTTP server that replies with a chunked body.

When ``stall_before`` is not None, 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.
times out. When ``truncate_before`` is not None, the server drops the
connection at that chunk index without the terminating zero-length chunk,
so a mid-body read raises a protocol error. Returns the server's port.
"""
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
Expand All @@ -42,49 +62,20 @@ def _start_chunked_server(
def serve():
try:
conn, _ = sock.accept()
while b"\r\n\r\n" not in conn.recv(65536):
pass
_read_request_head(conn)
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 idx == truncate_before:
break
if 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 _start_truncating_server() -> int:
"""One-shot server that sends the head and one chunk, then drops the
connection without the terminating zero-length chunk, so a mid-body read
raises a protocol error."""
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"
)
chunk = CHUNKS[0]
conn.sendall(f"{len(chunk):x}\r\n".encode() + chunk + b"\r\n")
else:
conn.sendall(b"0\r\n\r\n")
conn.close()
except OSError:
pass
Expand All @@ -102,7 +93,7 @@ def _open_stream(client, port):
return client.send(request, stream=True)


def test_sync_full_consume_releases_connection():
def test_sync_full_consume_releases_response():
with httpx.Client() as client:
port = _start_chunked_server()
response = _open_stream(client, port)
Expand Down Expand Up @@ -135,18 +126,37 @@ def test_sync_close_is_idempotent():

def test_sync_read_error_releases_response():
with httpx.Client() as client:
port = _start_truncating_server()
port = _start_chunked_server(truncate_before=1)
response = _open_stream(client, port)
reader = FileStreamReader(response)
it = iter(reader)
assert next(it) == CHUNKS[0]
# A mid-body error propagates and the reader releases the response.
with pytest.raises(httpx.RemoteProtocolError):
# Asserted on the `httpx.HTTPError` base rather than the concrete
# class, because which error a truncated body surfaces as is the
# transport's business, not part of the reader's contract.
with pytest.raises(httpx.HTTPError):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small things about this file that came out of the mutation run.

On the widening: I checked what a truncated body actually surfaces as here, and it is deterministically httpx.RemoteProtocolError ("peer closed connection without sending complete message body (incomplete chunked read)"). The stated reason for relaxing the assertion — which error a truncation surfaces as is the transport's business — does not apply to this test as written, because _open_stream builds a bare httpx.Client(), so the error class is httpx's own and not the shipped pyqwest transport's. The relaxation is therefore paying specificity for portability the test cannot have: httpx.HTTPError is the base of ReadTimeout, ConnectTimeout, ConnectError and PoolTimeout too, so a regression that turned a truncation into a hang-then-timeout would still satisfy it. Keeping RemoteProtocolError (or asserting on httpx.TransportError if a slightly wider net is wanted) costs nothing today.

Separately, and not something this PR introduced: test_sync_full_consume_releases_response above does not distinguish the reader's release from httpx's. Narrowing FileStreamReader.__next__'s except BaseException to except httpx.HTTPError — which removes the StopIterationclose() path entirely — leaves all 11 tests in this file green, because iter_bytes() closes the response itself when the stream ends. The error path is genuinely pinned (deleting self.close() from the handler fails test_sync_read_error_releases_response); the normal-end path is redundant with httpx and asserted by nobody. Worth a line in that test if the reader is meant to own the release regardless of what httpx does.

next(it)
assert response.is_closed


async def test_async_full_consume_releases_connection():
def test_sync_abandoned_reader_leaves_the_response_open():
with httpx.Client() as client:
port = _start_chunked_server()
response = _open_stream(client, port)
reader = FileStreamReader(response)
assert next(iter(reader)) == CHUNKS[0]

# The reader documents that it has no garbage-collection safety net,
# so dropping it half-consumed must leave the response — and the
# pooled connection behind it — open. Callers have to consume it
# fully, use the context manager, or call `close()`.
del reader
gc.collect()
assert not response.is_closed


async def test_async_full_consume_releases_response():
async with httpx.AsyncClient() as client:
port = _start_chunked_server()
request = client.build_request("GET", f"http://127.0.0.1:{port}/files")
Expand Down Expand Up @@ -179,6 +189,25 @@ async def test_async_aclose_is_idempotent():
assert response.is_closed


async def test_async_abandoned_reader_leaves_the_response_open():
async with httpx.AsyncClient() as client:
port = _start_chunked_server()
request = client.build_request("GET", f"http://127.0.0.1:{port}/files")
response = await client.send(request, stream=True)
reader = AsyncFileStreamReader(response)
assert await reader.__anext__() == CHUNKS[0]

# Same contract as the sync reader, and for a stronger reason:
# releasing an async response requires awaiting `aclose()`, which a
# finalizer cannot do. Sleeping gives the event loop a chance to run
# async-generator finalization, so this asserts the response really
# stays open rather than just outracing the loop.
del reader
gc.collect()
await asyncio.sleep(0.05)
assert not response.is_closed


async def test_async_reader_explicit_idle_timeout_bounds_each_read():
# The per-call idle bound is enforced with wait_for around each read, so
# it works on the regular transport (no transport-level read timeout).
Expand Down
97 changes: 95 additions & 2 deletions packages/python-sdk/tests/test_volume_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,20 @@ async def get_transports():
CHUNK = b"x" * 1024


def _read_request_head(conn) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this helper is byte-identical to the one added at test_file_stream_reader.py:30. The description's stated reason for fixing the loop at all is that #1690 copied it into a second helper — and the fix lands as a third and fourth copy rather than one shared one.

Sharing is already set up: pytest.ini sets pythonpath = tests with the comment "Makes shared test helpers (e.g. envd_frame_server) importable under importlib mode", and this file already imports transport_caches. A tests/http_server.py (or an addition to an existing helper module) would leave one place for the next person to fix.

"""Read up to the end of the request head.

Accumulates across reads, since the head can arrive split across TCP
segments, and stops on a closed peer rather than spinning on empty reads.
"""
buffered = b""
while b"\r\n\r\n" not in buffered:
received = conn.recv(65536)
if not received:
return
buffered += received


def _start_volume_file_server(
chunk_delays: List[float], ttfb_delay: float = 0.0
) -> str:
Expand All @@ -183,8 +197,7 @@ def _start_volume_file_server(
def serve():
try:
conn, _ = sock.accept()
while b"\r\n\r\n" not in conn.recv(65536):
pass
_read_request_head(conn)
time.sleep(ttfb_delay)
conn.sendall(
b"HTTP/1.1 200 OK\r\n"
Expand Down Expand Up @@ -244,6 +257,38 @@ def test_sync_stream_stall_raises_read_timeout(short_read_timeout):
assert received == [CHUNK]


# A body far past the ~500 KiB the socket and reqwest buffers hold between
# them, so a stall a quarter of the way in provably lands mid-transfer with the
# server still pushing, rather than after the whole body was read ahead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirming @chatgpt-codex-connector's P2 with measurements — and the mechanism turns out to be different from what both this comment and the PR description say.

I reproduced the fixture verbatim (the time.sleep(delay) call matters: even at 0.0 it yields the GIL) and recorded server progress at the instant the consumer stalls.

On an idle VM the tests are not vacuous: the server was at 512–586 of 2048 chunks with body_complete=False in 10/10 runs (5 sync, 5 async). But note 512 of 2048 — that is exactly the consumer's own read position, i.e. essentially zero read-ahead. What holds the invariant is that the server thread, yielding the GIL on every time.sleep(0), is no faster than the consumer. It is a scheduling coincidence, not buffer capacity.

The capacity claim is off by more than an order of magnitude, in the direction that removes the headroom. With the GIL yield removed, the client absorbed an entire 2 MiB body and an entire 8 MiB body before the consumer reached the quarter mark; on a 32 MiB body the server was 13 MiB ahead at the stall. So ~500 KiB is not a ceiling this 2 MiB body clears — the buffers here hold at least 8 MiB, four times the whole body. (The ~526 KiB you measured is presumably read-ahead against a consumer that has already stopped reading; before the stall the consumer is reading at full speed, which is what lets the server race ahead.)

Under CI conditions the property does break. python_sdk_tests.yml runs the suite with --numprocesses=4; with that plus CPU oversubscription, 3 of 24 runs had the stall land after the entire body, terminating chunk included. Those runs still pass — they just stop covering slow-consumer behavior. That matters more than it looks, because the wall-clock mutation this test was validated against (_get_request_timeout(None, None) returning 0.3) is also caught by the pre-existing test_{sync,async}_stream_survives_transfers_longer_than_read_timeout. The unique coverage here is the consumer-pace case, which needs a socket read still outstanding after the stall — precisely what the degenerate runs remove.

Verified alternative: pace the server rather than trying to out-size the buffers, and assert the property. The fixture already takes per-chunk delays, so [0.005] * 400 puts a hard 2s floor under the transfer, and a 0.9s stall at the quarter mark cannot land past it:

SLOW_CONSUMER_CHUNKS = 400
SLOW_CONSUMER_PACE = 0.005  # every gap far under the idle bound
SLOW_CONSUMER_STALL_AT = SLOW_CONSUMER_CHUNKS // 4

api_url, server = _start_volume_file_server([SLOW_CONSUMER_PACE] * SLOW_CONSUMER_CHUNKS)
...
    if not stalled and received >= len(CHUNK) * SLOW_CONSUMER_STALL_AT:
        stalled = True
        # The stall has to land while the server is still pushing, or the case
        # degenerates into re-reading an already-buffered body.
        assert not server.body_complete
        time.sleep(short_read_timeout * 3)

Measured: 100/400 chunks at the stall in every run, both flavors, 9/9 green including 4 runs under the same load that produced the degenerate runs above; both fail under the wall-clock mutation; and the body drops from 2 MiB to 400 KiB. It needs the server helper to hand back a small progress object next to the URL — tests/envd_frame_server.py's SharedPoolServer.drop_when is the in-repo precedent for a test server the test synchronizes with rather than races.

Non-blocking on correctness, but the whole point of the PR is coverage that can't pass vacuously, and this is the one test here that currently can.

SLOW_CONSUMER_CHUNKS = 2048
SLOW_CONSUMER_STALL_AT = SLOW_CONSUMER_CHUNKS // 4
Comment on lines +263 to +264

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Synchronize the stall with an active transfer

On systems where TCP autotuning and the transport buffers can hold this 2 MiB body, or where the server simply wins the race, the terminating chunk may already have been written before the consumer reaches the fixed 512 KiB threshold. Both positive tests would then sleep over an already-completed transfer and pass without exercising slow-consumer behavior; the separate sync deadline test cannot prove that either positive connection—especially the async one—was still active. Gate the pause on server-side progress/backpressure instead of assuming a fixed buffer capacity so these tests actually cover the intended path.

AGENTS.md reference: AGENTS.md:L5-L5

Useful? React with 👍 / 👎.



def test_sync_stream_survives_a_consumer_slower_than_read_timeout(short_read_timeout):
# The idle read timeout is wire-only: the server sends every chunk
# promptly and the consumer then pauses for far longer than the bound,
# which must not abort the stream (parity with the JS SDK's
# "wrapStreamWithConnectionCleanup does not abort a slow consumer").
# This guards the bound staying an idle one — putting a wall-clock deadline
# back on the streaming path would fail here while every stall test above
# kept passing.
api_url = _start_volume_file_server([0.0] * SLOW_CONSUMER_CHUNKS)
volume = Volume(volume_id="v1", name="test", token="vol-token")

stream = volume.read_file("file.bin", format="stream", api_url=api_url)
received = 0
stalled = False
for chunk in stream:
received += len(chunk)
# A threshold rather than an exact count: the transport is free to
# coalesce or split the chunks it hands back.
if not stalled and received >= len(CHUNK) * SLOW_CONSUMER_STALL_AT:
stalled = True
time.sleep(short_read_timeout * 3)
assert stalled
assert received == len(CHUNK) * SLOW_CONSUMER_CHUNKS


def test_async_stream_survives_transfers_longer_than_read_timeout(short_read_timeout):
api_url = _start_volume_file_server([0.15] * 4)
volume = AsyncVolume(volume_id="v1", name="test", token="vol-token")
Expand All @@ -255,6 +300,54 @@ async def run():
assert asyncio.run(run()) == CHUNK * 4


def test_async_stream_survives_a_consumer_slower_than_read_timeout(short_read_timeout):
# Async counterpart of the sync slow-consumer case above, against the
# async streaming transport.
api_url = _start_volume_file_server([0.0] * SLOW_CONSUMER_CHUNKS)
volume = AsyncVolume(volume_id="v1", name="test", token="vol-token")

async def run():
stream = await volume.read_file("file.bin", format="stream", api_url=api_url)
received = 0
stalled = False
async for chunk in stream:
received += len(chunk)
if not stalled and received >= len(CHUNK) * SLOW_CONSUMER_STALL_AT:
stalled = True
await asyncio.sleep(short_read_timeout * 3)
return stalled, received

assert asyncio.run(run()) == (True, len(CHUNK) * SLOW_CONSUMER_CHUNKS)


def test_sync_stream_explicit_request_timeout_is_a_total_transfer_deadline(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the A/B counterpart exists only for sync, but the two flavors reach the deadline through different code — the adapter's async path re-applies the remaining deadline around each chunk read (AsyncIteratorByteStream), while the sync path relies on reqwest keeping the deadline attached to the in-flight request. So "a regression that turned the default idle bound into a wall-clock one would look indistinguishable from correct behavior" is only ruled out for one of the two mechanisms, while every other streaming case in this file is paired sync/async.

Smaller point in the same test: request_timeout=1.0 and time.sleep(2.0) are the two numbers that make it valid (the stall has to exceed the deadline), and unlike the SLOW_CONSUMER_* constants above they're unnamed, so the relationship between them is only in prose.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is the sole guard on forwarding an explicit request_timeout (dropping it fails this test and nothing else). But the complementary half — that the default streamed read carries no whole-request deadline — has no guard at all, and it is the half the code comment above stream_timeout cares about.

The surviving mutant is a one-liner, in both volume_sync.py and volume_async.py:

# stream_timeout = VolumeConnectionConfig._get_request_timeout(
#     None, opts.get("request_timeout")
# )
stream_timeout = timeout          # FILE_TIMEOUT-based, i.e. 3600 s

timeout is computed ten lines earlier in the same function and is unused on the streaming branch, so collapsing the two is the natural "cleanup" someone will eventually make. All 21 tests in this file still pass, because 3600 s is longer than every stall any of them can produce — and the SDK is then back to exactly what the comment warns about, a whole-request deadline that kills long downloads, just with a big constant.

A deadline-value assertion closes it without any timing, covers both flavours (which also settles the sync-only concern on my sibling's thread below, since it asserts at the call boundary rather than through the adapter's two different enforcement paths), and runs in 0.5 s:

@pytest.fixture
def recorded_stream_timeouts(monkeypatch):
    seen = []
    sync_stream, async_stream = httpx.Client.stream, httpx.AsyncClient.stream

    def sync_spy(self, *args, **kwargs):
        seen.append(kwargs.get("timeout"))
        return sync_stream(self, *args, **kwargs)

    def async_spy(self, *args, **kwargs):
        seen.append(kwargs.get("timeout"))
        return async_stream(self, *args, **kwargs)

    monkeypatch.setattr(httpx.Client, "stream", sync_spy)
    monkeypatch.setattr(httpx.AsyncClient, "stream", async_spy)
    return seen


def test_sync_streamed_read_carries_no_deadline_by_default(recorded_stream_timeouts):
    api_url = _start_volume_file_server([0.0])
    volume = Volume(volume_id="v1", name="test", token="vol-token")

    stream = volume.read_file("file.bin", format="stream", api_url=api_url)
    assert b"".join(stream) == CHUNK
    # A whole-request deadline here would cap total transfer time, which is
    # what FILE_TIMEOUT does on the non-streamed path.
    assert recorded_stream_timeouts == [None]


def test_sync_streamed_read_forwards_an_explicit_request_timeout(recorded_stream_timeouts):
    api_url = _start_volume_file_server([0.0])
    volume = Volume(volume_id="v1", name="test", token="vol-token")

    stream = volume.read_file(
        "file.bin", format="stream", request_timeout=12.0, api_url=api_url
    )
    assert b"".join(stream) == CHUNK
    assert recorded_stream_timeouts == [12.0]

I ran these plus their two AsyncVolume counterparts: 4 passed in 0.47 s on this head; under the stream_timeout = timeout mutant the two default-path tests fail with assert [3600.0] == [None] in both flavours; under a stream_timeout = None mutant the two explicit-path tests fail with assert [None] == [12.0]. They complement the timing test above rather than replace it — this one pins the value, that one pins that the value is really enforced end to end.

short_read_timeout,
):
# The counterpart that makes the two tests above meaningful: same fixture,
# same body, same stall — the only difference is the explicit
# `request_timeout`, which is documented as a whole-transfer deadline and
# so *does* cut off a slow consumer. Without this A/B, a regression that
# turned the default idle bound into a wall-clock one would look
# indistinguishable from correct behavior. The stall is longer than the
# total deadline, and the tests above establish that a slow consumer never
# trips the idle bound, so the timeout here can only be the deadline.
api_url = _start_volume_file_server([0.0] * SLOW_CONSUMER_CHUNKS)
volume = Volume(volume_id="v1", name="test", token="vol-token")

stream = volume.read_file(
"file.bin", format="stream", request_timeout=1.0, api_url=api_url
)
received = 0
stalled = False
with pytest.raises(httpx.TimeoutException):
for chunk in stream:
received += len(chunk)
if not stalled and received >= len(CHUNK) * SLOW_CONSUMER_STALL_AT:
stalled = True
time.sleep(2.0)
assert stalled


def test_async_stream_stall_raises_read_timeout(short_read_timeout):
api_url = _start_volume_file_server([0.0, 5.0])
volume = AsyncVolume(volume_id="v1", name="test", token="vol-token")
Expand Down
Loading