Skip to content

Commit f0c6939

Browse files
authored
fix(streaming): interrupt blocked synchronous SSE reads (#37)
1 parent f9c270c commit f0c6939

5 files changed

Lines changed: 233 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ existing `0.1.0` release; earlier development prereleases are not listed.
1010
- Add automatic Thread SSE resumption for Forward and Managed, preserving the latest event cursor and request options across retryable disconnects; thread termination stops the subscription while idle events keep it open.
1111
- Add a typed `Last-Event-ID` parameter to Managed Thread streams.
1212

13+
### Fixed
14+
15+
- Closing a synchronous Session or Thread SSE subscription from another thread now wakes a blocked HTTP/1.x socket read, including HTTPS, instead of waiting for more data or a read timeout. Fully consumed connections remain reusable; HTTP/2 and transports without an exposed socket retain their own close behavior.
16+
1317
## [0.13.0]
1418

1519
### Added

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,12 @@ async with client.sessions.events.resumable_stream(session_id) as stream:
549549

550550
The helper retries until closed, cancelled, or it receives `session.status_terminated` / `session.deleted`. It uses jittered exponential backoff and the same HTTP retry classification as the client (in particular, `409` is not retried). It does not query event history, discard an invalid cursor, or deduplicate event IDs because multiple preview deltas may share one ID.
551551

552+
With the default synchronous HTTP/1.x transport, `close()` also wakes a body read
553+
blocked in another thread, including over HTTPS. HTTP/2 connections are shared,
554+
so their sockets are not interrupted; HTTP/2 and custom transports without an
555+
exposed socket retain their transport's close behavior. Async callers can cancel
556+
the consuming task or await `close()` to interrupt an active read.
557+
552558
Events are also readable after the fact through `client.sessions.events.list(session_id)`, which paginates like any other list method.
553559

554560
## Handling errors

‎docs/api/reference.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1409,6 +1409,46 @@ def last_event_id() -> str | None
14091409

14101410
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_streaming.py)
14111411

1412+
<a id="qca.common._streaming._InterruptibleSyncBody"></a>
1413+
1414+
## \_InterruptibleSyncBody
1415+
1416+
```python
1417+
class _InterruptibleSyncBody(httpx.SyncByteStream)
1418+
```
1419+
1420+
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_streaming.py)
1421+
1422+
<a id="qca.common._streaming._InterruptibleSyncBody.__init__"></a>
1423+
1424+
#### \_\_init\_\_
1425+
1426+
```python
1427+
def __init__(body: httpx.SyncByteStream, connection: socket.socket) -> None
1428+
```
1429+
1430+
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_streaming.py)
1431+
1432+
<a id="qca.common._streaming._InterruptibleSyncBody.__iter__"></a>
1433+
1434+
#### \_\_iter\_\_
1435+
1436+
```python
1437+
def __iter__() -> Iterator[bytes]
1438+
```
1439+
1440+
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_streaming.py)
1441+
1442+
<a id="qca.common._streaming._InterruptibleSyncBody.close"></a>
1443+
1444+
#### close
1445+
1446+
```python
1447+
def close() -> None
1448+
```
1449+
1450+
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/common/_streaming.py)
1451+
14121452
<a id="qca.common._streaming.Stream"></a>
14131453

14141454
## Stream

‎src/qca/common/_streaming.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
import json
4+
import socket
5+
import threading
46
from collections.abc import AsyncIterator, Iterator
57
from typing import Any, Generic, TypeVar
68

@@ -72,9 +74,52 @@ def _parse(self, frame: tuple[str | None, str]) -> T:
7274
return result
7375

7476

77+
class _InterruptibleSyncBody(httpx.SyncByteStream):
78+
def __init__(self, body: httpx.SyncByteStream, connection: socket.socket) -> None:
79+
self._body = body
80+
self._connection = connection
81+
self._lock = threading.Lock()
82+
self._reading = False
83+
self._closed = False
84+
85+
def __iter__(self) -> Iterator[bytes]:
86+
with self._lock:
87+
if self._closed:
88+
return
89+
self._reading = True
90+
try:
91+
yield from self._body
92+
finally:
93+
with self._lock:
94+
self._reading = False
95+
96+
def close(self) -> None:
97+
# Interrupt before releasing the response to the pool; a later shutdown
98+
# could otherwise affect a different request reusing the connection.
99+
with self._lock:
100+
if self._closed:
101+
return
102+
self._closed = True
103+
if self._reading:
104+
try:
105+
self._connection.shutdown(socket.SHUT_RDWR)
106+
except OSError:
107+
pass
108+
self._body.close()
109+
110+
75111
class Stream(BaseStream[T], Iterator[T]):
76112
def __init__(self, response: httpx.Response, cast_to: Any, *, strict: bool = False) -> None:
77113
super().__init__(response, cast_to, strict=strict)
114+
if not response.is_closed and response.extensions.get("http_version") in (b"HTTP/1.0", b"HTTP/1.1"):
115+
network_stream = response.extensions.get("network_stream")
116+
get_extra_info = getattr(network_stream, "get_extra_info", None)
117+
try:
118+
connection = get_extra_info("socket") if callable(get_extra_info) else None
119+
except (OSError, NotImplementedError):
120+
connection = None
121+
if isinstance(connection, socket.socket) and isinstance(response.stream, httpx.SyncByteStream):
122+
response.stream = _InterruptibleSyncBody(response.stream, connection)
78123
self._iterator = self._iter_events()
79124

80125
def __next__(self) -> T:
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import threading
2+
from concurrent.futures import ThreadPoolExecutor, TimeoutError
3+
from contextlib import contextmanager
4+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5+
from types import SimpleNamespace
6+
7+
import httpx
8+
import pytest
9+
10+
from qca import Forward, Managed
11+
from qca.common._streaming import Stream
12+
13+
14+
@contextmanager
15+
def local_server(*, hanging):
16+
release = threading.Event()
17+
requests = []
18+
19+
class Handler(BaseHTTPRequestHandler):
20+
protocol_version = "HTTP/1.1"
21+
22+
def do_GET(self):
23+
requests.append((self.path, self.client_address))
24+
sse = self.path.endswith("/stream")
25+
body = b'data: {"type":"agent.message"}\n\n' if sse else b'{"data":[],"has_more":false}'
26+
self.send_response(200)
27+
self.send_header("Content-Type", "text/event-stream" if sse else "application/json")
28+
if sse and hanging:
29+
self.send_header("Transfer-Encoding", "chunked")
30+
self.end_headers()
31+
self.wfile.flush()
32+
release.wait(timeout=10)
33+
self.close_connection = True
34+
else:
35+
self.send_header("Content-Length", str(len(body)))
36+
self.end_headers()
37+
self.wfile.write(body)
38+
self.wfile.flush()
39+
40+
def log_message(self, *args):
41+
pass
42+
43+
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
44+
thread = threading.Thread(target=server.serve_forever, daemon=True)
45+
thread.start()
46+
try:
47+
yield f"http://127.0.0.1:{server.server_port}", requests, release
48+
finally:
49+
release.set()
50+
server.shutdown()
51+
server.server_close()
52+
thread.join(timeout=2)
53+
assert not thread.is_alive()
54+
55+
56+
@pytest.mark.parametrize("mode", ["forward", "managed"])
57+
@pytest.mark.parametrize("scope", ["session", "thread"])
58+
def test_sync_close_wakes_real_http_read_without_reconnecting(mode, scope):
59+
reading = threading.Event()
60+
61+
def trace(event, info):
62+
if event == "http11.receive_response_body.started":
63+
reading.set()
64+
65+
def request_hook(request):
66+
request.extensions["trace"] = trace
67+
68+
with local_server(hanging=True) as (base_url, requests, release):
69+
http_client = httpx.Client(trust_env=False, event_hooks={"request": [request_hook]})
70+
client = (Forward if mode == "forward" else Managed)(
71+
pat="test", base_url=base_url, http_client=http_client, timeout=3, max_retries=0
72+
)
73+
stream = (
74+
client.sessions.events.resumable_stream("session")
75+
if scope == "session"
76+
else client.sessions.threads.events.resumable_stream("thread", session_id="session")
77+
)
78+
executor = ThreadPoolExecutor(max_workers=1)
79+
pending = executor.submit(next, stream)
80+
try:
81+
assert reading.wait(timeout=2)
82+
with pytest.raises(TimeoutError):
83+
pending.result(timeout=0.1)
84+
stream.close()
85+
stream.close()
86+
with pytest.raises(StopIteration):
87+
pending.result(timeout=1)
88+
# A cancelled SSE connection must not poison or close the shared client.
89+
assert client.models.list().data == []
90+
assert len([path for path, _ in requests if path.endswith("/stream")]) == 1
91+
finally:
92+
release.set()
93+
stream.close()
94+
executor.shutdown(wait=True)
95+
client.close()
96+
97+
98+
def test_fully_consumed_stream_can_reuse_connection_after_repeated_close():
99+
with local_server(hanging=False) as (base_url, requests, _):
100+
with httpx.Client(base_url=base_url, trust_env=False) as client:
101+
response = client.send(client.build_request("GET", "/stream"), stream=True)
102+
stream = Stream(response, dict)
103+
assert list(stream) == [{"type": "agent.message"}]
104+
stream.close()
105+
assert client.get("/models").status_code == 200
106+
stream.close()
107+
assert client.get("/models").status_code == 200
108+
assert len({address for _, address in requests}) == 1
109+
110+
111+
@pytest.mark.parametrize("version", [b"HTTP/2", None])
112+
def test_close_does_not_inspect_shared_or_unknown_protocol_socket(version):
113+
def get_extra_info(name):
114+
pytest.fail("must not access a shared or unknown protocol socket")
115+
116+
response = httpx.Response(
117+
200,
118+
stream=httpx.ByteStream(b""),
119+
extensions={"http_version": version, "network_stream": SimpleNamespace(get_extra_info=get_extra_info)},
120+
)
121+
Stream(response, dict).close()
122+
assert response.is_closed
123+
124+
125+
@pytest.mark.parametrize("unsupported", [None, OSError, NotImplementedError])
126+
def test_custom_transport_without_socket_still_closes(unsupported):
127+
def get_extra_info(name):
128+
if unsupported:
129+
raise unsupported("socket metadata is unavailable")
130+
return None
131+
132+
response = httpx.Response(
133+
200,
134+
stream=httpx.ByteStream(b""),
135+
extensions={"http_version": b"HTTP/1.1", "network_stream": SimpleNamespace(get_extra_info=get_extra_info)},
136+
)
137+
Stream(response, dict).close()
138+
assert response.is_closed

0 commit comments

Comments
 (0)