Compare commits

...
Author SHA1 Message Date
Elliot Slusky 410562409d fix: make websocket bridge race cancellation-safe 2026-08-10 11:04:12 -07:00
Ari 9498adc7c4 fix: close ws_bridge send loop on client disconnect
Previously the ws_bridge send loop blocked forever on queue.get(),
never learning the client left. When the server service stopped,
uvicorn waited for open WebSocket tasks until systemd SIGKILLed
after TimeoutStopSec=90s. Now each iteration races recv+send; a
completed receive means the client disconnected => break the loop.
2026-08-10 11:04:12 -07:00
github-actions[bot] ebf370595d chore: update clone traffic data [skip ci] 2026-08-10 07:26:44 +00:00
4 changed files with 137 additions and 7 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "183,817",
"message": "185,599",
"color": "green",
"namedLogo": "git"
}
+5 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 183817,
"last_updated": "2026-08-09T06:51:49Z",
"total_clones": 185599,
"last_updated": "2026-08-10T07:26:44Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -135,6 +135,8 @@
"2026-08-04": 708,
"2026-08-05": 647,
"2026-08-06": 604,
"2026-08-07": 624
"2026-08-07": 624,
"2026-08-08": 706,
"2026-08-09": 1076
}
}
+30 -3
View File
@@ -79,14 +79,41 @@ def create_ws_router(event_bus: EventBus) -> Any:
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
loop = asyncio.get_running_loop()
clients[websocket] = (queue, loop)
recv: asyncio.Task | None = None
payload: asyncio.Task | None = None
disconnected = False
try:
recv = asyncio.create_task(websocket.receive())
payload = asyncio.create_task(queue.get())
while True:
payload = await queue.get()
await websocket.send_json(payload)
done, _ = await asyncio.wait(
{recv, payload}, return_when=asyncio.FIRST_COMPLETED
)
if recv in done:
# Starlette surfaces a disconnect message only when the app
# reads from the socket. Without this receive, the handler
# can stay parked on queue.get() after the client leaves.
message = await recv
if message.get("type") == "websocket.disconnect":
disconnected = True
break
recv = asyncio.create_task(websocket.receive())
if payload in done:
await websocket.send_json(payload.result())
payload = asyncio.create_task(queue.get())
except WebSocketDisconnect:
pass
disconnected = True
finally:
clients.pop(websocket, None)
pending = [task for task in (recv, payload) if task is not None]
for task in pending:
task.cancel()
cleanup = asyncio.gather(*pending, return_exceptions=True)
try:
await asyncio.shield(cleanup)
except asyncio.CancelledError:
if not disconnected:
raise
return router
+101
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import asyncio
import time
from types import SimpleNamespace
import pytest
@@ -60,3 +62,102 @@ class TestWSBridge:
time.sleep(0.05) # Let call_soon_threadsafe deliver to queue
data = ws.receive_json()
assert data["data"]["agent_id"] == "agent-A"
def test_client_disconnect_stops_handler(self, event_bus):
async def exercise():
from openjarvis.server.ws_bridge import create_ws_router
class FakeWebSocket:
app = SimpleNamespace(state=SimpleNamespace(api_key=""))
query_params = {}
headers = {}
async def accept(self):
pass
async def receive(self):
return {"type": "websocket.disconnect"}
endpoint = create_ws_router(event_bus).routes[0].endpoint
await asyncio.wait_for(endpoint(FakeWebSocket()), timeout=1)
asyncio.run(exercise())
def test_simultaneous_client_message_does_not_drop_event(self, event_bus):
async def exercise():
from openjarvis.server.ws_bridge import create_ws_router
class FakeWebSocket:
def __init__(self):
self.app = SimpleNamespace(state=SimpleNamespace(api_key=""))
self.query_params = {}
self.headers = {}
self.sent = []
self.receive_count = 0
self.disconnect = asyncio.Event()
async def accept(self):
pass
async def receive(self):
self.receive_count += 1
if self.receive_count == 1:
event_bus.publish(
EventType.AGENT_TICK_START, {"agent_id": "not-dropped"}
)
return {"type": "websocket.receive", "text": "client message"}
await self.disconnect.wait()
return {"type": "websocket.disconnect"}
async def send_json(self, payload):
self.sent.append(payload)
self.disconnect.set()
websocket = FakeWebSocket()
endpoint = create_ws_router(event_bus).routes[0].endpoint
await asyncio.wait_for(endpoint(websocket), timeout=1)
assert websocket.sent[0]["data"]["agent_id"] == "not-dropped"
asyncio.run(exercise())
def test_cancelling_handler_cleans_up_child_tasks(self, event_bus):
async def exercise():
from openjarvis.server.ws_bridge import create_ws_router
class FakeWebSocket:
def __init__(self):
self.app = SimpleNamespace(state=SimpleNamespace(api_key=""))
self.query_params = {}
self.headers = {}
self.receiving = asyncio.Event()
self.receive_cancelled = asyncio.Event()
async def accept(self):
pass
async def receive(self):
self.receiving.set()
try:
await asyncio.Event().wait()
finally:
self.receive_cancelled.set()
websocket = FakeWebSocket()
endpoint = create_ws_router(event_bus).routes[0].endpoint
handler = asyncio.create_task(endpoint(websocket))
await websocket.receiving.wait()
handler.cancel()
with pytest.raises(asyncio.CancelledError):
await handler
assert websocket.receive_cancelled.is_set()
assert not [
task
for task in asyncio.all_tasks()
if task is not asyncio.current_task() and not task.done()
]
asyncio.run(exercise())