374 lines
13 KiB
Python
374 lines
13 KiB
Python
"""Regression tests for the relay WS transport hardening fix.
|
|
|
|
Coatue incident 2026-08-18: WAN latency / event-loop stalls tripped the
|
|
websockets library's default 20s pong deadline, closing customer-gateway
|
|
sockets with `1011 keepalive ping timeout`. On top of the spurious close,
|
|
every in-flight outbound then hung for the full _outbound_timeout_s (~30s)
|
|
because only disconnect() failed pending futures — an unexpected socket drop
|
|
left them stranded — and sends issued while the reconnect supervisor was
|
|
backing off registered futures no reader could ever resolve.
|
|
|
|
Three hardening changes under test:
|
|
1. _read_loop fails all in-flight _pending futures on ANY exit path with
|
|
the dict shape callers expect ({"success": False, ...}).
|
|
2. _request_response fails fast while the reconnect supervisor is
|
|
mid-redial (live supervisor task = the redial window).
|
|
3. connect() passes explicit WAN-friendly keepalive tuning
|
|
(ping_interval=30, ping_timeout=60) to websockets.connect().
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
import gateway.relay.ws_transport as ws_transport_mod
|
|
from gateway.relay.ws_transport import WebSocketRelayTransport, WEBSOCKETS_AVAILABLE
|
|
|
|
pytestmark = pytest.mark.skipif(not WEBSOCKETS_AVAILABLE, reason="websockets not installed")
|
|
|
|
if WEBSOCKETS_AVAILABLE:
|
|
from websockets.exceptions import ConnectionClosedError
|
|
|
|
|
|
class _DroppingWS:
|
|
"""Fake socket: accepts sends, then the read loop dies mid-iteration —
|
|
the shape of an unexpected close (e.g. 1011 keepalive ping timeout)."""
|
|
|
|
def __init__(self, close_code: int | None = None):
|
|
self.sent: list[str] = []
|
|
# Reader blocks here until the test releases it, so the outbound
|
|
# future is registered BEFORE the "socket" drops.
|
|
self.drop = asyncio.Event()
|
|
self._close_code = close_code
|
|
|
|
async def send(self, data):
|
|
self.sent.append(data)
|
|
|
|
def __aiter__(self):
|
|
return self
|
|
|
|
async def __anext__(self):
|
|
await self.drop.wait()
|
|
if self._close_code is not None:
|
|
from websockets.frames import Close
|
|
|
|
raise ConnectionClosedError(Close(self._close_code, ""), None)
|
|
raise ConnectionClosedError(None, None)
|
|
|
|
async def close(self):
|
|
pass
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_loop_exit_fails_pending_futures_promptly():
|
|
"""When the socket drops unexpectedly, in-flight _request_response callers
|
|
must get {"success": False, ...} promptly — not block ~30s on a future
|
|
only the (now dead) reader could have resolved."""
|
|
t = WebSocketRelayTransport("ws://unused", "discord", "bot1", outbound_timeout_s=30.0)
|
|
fake = _DroppingWS()
|
|
t._ws = fake
|
|
t._reader = asyncio.create_task(t._read_loop())
|
|
|
|
send_task = asyncio.create_task(t.send_outbound({"op": "send_message", "text": "hi"}))
|
|
# Let the outbound frame go out and its future register in _pending.
|
|
for _ in range(50):
|
|
if t._pending:
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
assert t._pending, "outbound future never registered"
|
|
|
|
# Drop the socket: the read loop exits on ConnectionClosedError.
|
|
fake.drop.set()
|
|
|
|
result = await asyncio.wait_for(send_task, timeout=2.0)
|
|
assert result == {"success": False, "error": "relay transport connection lost"}
|
|
assert t._pending == {}
|
|
await t._reader
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_during_redial_window_fails_fast():
|
|
"""While the reconnect supervisor is backing off after a drop, a send must
|
|
return an error dict immediately (no RuntimeError, no 30s timeout on an
|
|
unresolvable future). Drives the REAL sequence — reader exit arms the
|
|
supervisor and clears _ws — rather than hand-crafting a stale-_ws state
|
|
the transport can no longer reach."""
|
|
t = WebSocketRelayTransport(
|
|
"ws://unused",
|
|
"discord",
|
|
"bot1",
|
|
reconnect=True,
|
|
reconnect_backoff_s=60.0, # park the supervisor in backoff
|
|
outbound_timeout_s=30.0,
|
|
)
|
|
fake = _DroppingWS()
|
|
t._ws = fake
|
|
await _run_reader_to_exit(t, fake)
|
|
supervisor = t._supervisor
|
|
try:
|
|
assert supervisor is not None and not supervisor.done(), (
|
|
"reader exit must arm the reconnect supervisor"
|
|
)
|
|
result = await asyncio.wait_for(
|
|
t.send_outbound({"op": "send_message", "text": "hi"}), timeout=1.0
|
|
)
|
|
assert result["success"] is False
|
|
assert t._pending == {}
|
|
finally:
|
|
if supervisor is not None:
|
|
supervisor.cancel()
|
|
try:
|
|
await supervisor
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_allowed_once_redial_installs_fresh_socket(monkeypatch):
|
|
"""The moment _dial_and_start() installs a fresh socket and its reader,
|
|
the transport is genuinely usable — even though the supervisor task has
|
|
not finished unwinding (it is still awaiting the hello sends). A send in
|
|
that window must be ACCEPTED, not rejected as 'reconnecting': gating
|
|
sends on supervisor state rejected real traffic on a live socket."""
|
|
|
|
class _LiveWS:
|
|
def __init__(self):
|
|
self.sent: list[str] = []
|
|
self.hello_seen = asyncio.Event()
|
|
self.release = asyncio.Event()
|
|
|
|
async def send(self, data):
|
|
self.sent.append(data)
|
|
if '"hello"' in data:
|
|
# Inside _dial_and_start, AFTER _ws and the reader are
|
|
# installed. Park here to hold the window open.
|
|
self.hello_seen.set()
|
|
await self.release.wait()
|
|
|
|
def __aiter__(self):
|
|
return self
|
|
|
|
async def __anext__(self):
|
|
await asyncio.sleep(3600)
|
|
|
|
async def close(self):
|
|
pass
|
|
|
|
live = _LiveWS()
|
|
|
|
async def _fake_connect(url, **kwargs):
|
|
return live
|
|
|
|
monkeypatch.setattr(ws_transport_mod.websockets, "connect", _fake_connect)
|
|
|
|
t = WebSocketRelayTransport(
|
|
"ws://unused",
|
|
"discord",
|
|
"bot1",
|
|
reconnect=True,
|
|
reconnect_backoff_s=0.01,
|
|
outbound_timeout_s=5.0,
|
|
)
|
|
# Arm the supervisor exactly as the reader's fall-through does.
|
|
t._supervisor = asyncio.create_task(t._reconnect_loop())
|
|
await asyncio.wait_for(live.hello_seen.wait(), timeout=2.0)
|
|
try:
|
|
assert t._ws is live and not t._supervisor.done()
|
|
|
|
send_task = asyncio.create_task(
|
|
t.send_outbound({"op": "send_message", "text": "hi"})
|
|
)
|
|
# The send must reach the live socket (registered + frame written),
|
|
# not fail fast: wait for the outbound frame to land.
|
|
for _ in range(100):
|
|
if any('"outbound"' in s for s in live.sent):
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
assert any('"outbound"' in s for s in live.sent), (
|
|
"send was rejected during the post-dial window despite a live "
|
|
"socket and running reader"
|
|
)
|
|
|
|
# Resolve it via the reader path shape: answer directly.
|
|
rid = next(iter(t._pending))
|
|
t._pending[rid].set_result({"success": True})
|
|
assert (await asyncio.wait_for(send_task, timeout=2.0)) == {"success": True}
|
|
finally:
|
|
live.release.set()
|
|
await asyncio.wait_for(t._supervisor, timeout=2.0)
|
|
if t._reader is not None:
|
|
t._reader.cancel()
|
|
try:
|
|
await t._reader
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_passes_wan_keepalive_tuning(monkeypatch):
|
|
"""connect() must pass ping_interval=30 / ping_timeout=60 explicitly —
|
|
the library defaults (20/20) caused spurious 1011 keepalive closes over
|
|
WAN paths (Coatue 2026-08-18). Both call sites (with/without auth
|
|
headers) are exercised."""
|
|
captured: list[dict] = []
|
|
|
|
class _IdleWS:
|
|
async def send(self, data):
|
|
pass
|
|
|
|
def __aiter__(self):
|
|
return self
|
|
|
|
async def __anext__(self):
|
|
await asyncio.sleep(3600)
|
|
|
|
async def close(self):
|
|
pass
|
|
|
|
async def _fake_connect(url, **kwargs):
|
|
captured.append(kwargs)
|
|
return _IdleWS()
|
|
|
|
monkeypatch.setattr(ws_transport_mod.websockets, "connect", _fake_connect)
|
|
|
|
# Site 1: no upgrade secret -> the headerless connect() call.
|
|
t = WebSocketRelayTransport("ws://unused", "discord", "bot1")
|
|
await t.connect()
|
|
await t.disconnect(budget_s=0)
|
|
|
|
# Site 2: secret + gateway_id -> the additional_headers connect() call.
|
|
t2 = WebSocketRelayTransport(
|
|
"ws://unused", "discord", "bot1", gateway_id="gw-1", upgrade_secret="s3cret"
|
|
)
|
|
await t2.connect()
|
|
await t2.disconnect(budget_s=0)
|
|
|
|
assert len(captured) == 2
|
|
no_header_kwargs, header_kwargs = captured
|
|
assert "additional_headers" not in no_header_kwargs
|
|
assert "additional_headers" in header_kwargs
|
|
for kwargs in captured:
|
|
assert kwargs.get("ping_interval") == 30
|
|
assert kwargs.get("ping_timeout") == 60
|
|
|
|
|
|
async def _run_reader_to_exit(t: WebSocketRelayTransport, fake: _DroppingWS) -> None:
|
|
"""Start the reader on ``fake``, drop the socket, and wait for the reader
|
|
to fully unwind — the state every post-drop assertion depends on."""
|
|
t._reader = asyncio.create_task(t._read_loop())
|
|
await asyncio.sleep(0)
|
|
fake.drop.set()
|
|
await t._reader
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_loop_without_socket_still_fails_pending():
|
|
"""If the reader is ever scheduled with no socket (lifecycle bug), it must
|
|
still settle in-flight waiters on its way out — the old `assert` escaped
|
|
before the fail-pending cleanup and left them to the full 30s timeout."""
|
|
t = WebSocketRelayTransport("ws://unused", "discord", "bot1", outbound_timeout_s=30.0)
|
|
loop = asyncio.get_running_loop()
|
|
fut: asyncio.Future = loop.create_future()
|
|
t._pending["rid"] = fut
|
|
t._ws = None
|
|
|
|
await t._read_loop() # must not raise
|
|
|
|
assert fut.done()
|
|
assert fut.result() == {"success": False, "error": "relay transport connection lost"}
|
|
assert t._pending == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_after_terminal_4401_revocation_fails_fast():
|
|
"""A terminal 4401 revocation deliberately arms NO reconnect supervisor,
|
|
so the reader's exit is the LAST liveness transition this transport will
|
|
ever make. If _ws still points at the dead socket afterwards, the
|
|
revocation path's own fatal-error notification send wedges for the full
|
|
_outbound_timeout_s. The reader must leave _ws cleared so the
|
|
not-connected guard answers instantly."""
|
|
t = WebSocketRelayTransport(
|
|
"ws://unused", "discord", "bot1", reconnect=True, outbound_timeout_s=30.0
|
|
)
|
|
fake = _DroppingWS(close_code=4401)
|
|
t._ws = fake
|
|
t._handshake_succeeded = True # prior handshake -> 4401 is a revocation
|
|
await _run_reader_to_exit(t, fake)
|
|
|
|
assert t._auth_revoked is True
|
|
assert t._supervisor is None # revocation must not re-dial
|
|
assert t._ws is None, "dead socket handle must not survive the reader"
|
|
|
|
result = await asyncio.wait_for(
|
|
t.send_outbound({"op": "send_message", "text": "hi"}), timeout=2.0
|
|
)
|
|
assert result["success"] is False
|
|
assert t._pending == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_after_drop_with_reconnect_disabled_fails_fast():
|
|
"""reconnect=False transports never arm a supervisor either — the same
|
|
stranded-_ws wedge as the revocation path, reachable by configuration."""
|
|
t = WebSocketRelayTransport(
|
|
"ws://unused", "discord", "bot1", reconnect=False, outbound_timeout_s=30.0
|
|
)
|
|
fake = _DroppingWS()
|
|
t._ws = fake
|
|
await _run_reader_to_exit(t, fake)
|
|
|
|
assert t._ws is None, "dead socket handle must not survive the reader"
|
|
|
|
result = await asyncio.wait_for(
|
|
t.send_outbound({"op": "send_message", "text": "hi"}), timeout=2.0
|
|
)
|
|
assert result["success"] is False
|
|
assert t._pending == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_raising_socket_returns_error_dict():
|
|
"""The socket can die BETWEEN the `_ws is None` liveness guard and the
|
|
actual write (the reader's finally hasn't cleared the handle yet). The
|
|
write then raises ConnectionClosed — but send_outbound's contract is a
|
|
result dict, and RelayAdapter.send consumes it with no try. The raise
|
|
must be converted to {"success": False, ...}, with no future left in
|
|
_pending."""
|
|
|
|
class _RaisingWS:
|
|
"""Send raises (already dead); the reader hasn't noticed yet."""
|
|
|
|
def __init__(self):
|
|
self.reader_release = asyncio.Event()
|
|
|
|
async def send(self, data):
|
|
raise ConnectionClosedError(None, None)
|
|
|
|
def __aiter__(self):
|
|
return self
|
|
|
|
async def __anext__(self):
|
|
await self.reader_release.wait()
|
|
raise ConnectionClosedError(None, None)
|
|
|
|
async def close(self):
|
|
pass
|
|
|
|
t = WebSocketRelayTransport("ws://unused", "discord", "bot1", outbound_timeout_s=5.0)
|
|
fake = _RaisingWS()
|
|
t._ws = fake
|
|
t._reader = asyncio.create_task(t._read_loop())
|
|
await asyncio.sleep(0)
|
|
|
|
result = await asyncio.wait_for(
|
|
t.send_outbound({"op": "send_message", "text": "hi"}), timeout=2.0
|
|
)
|
|
assert result["success"] is False
|
|
assert "relay send failed" in result["error"]
|
|
assert t._pending == {}
|
|
|
|
fake.reader_release.set()
|
|
await t._reader
|