cloudagent-daemon 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,439 @@
1
+ """Server-sent events consumer for the cloudagent daemon.
2
+
3
+ This is the SSE side of Sub-plan 1j (SSE+POST transport replacing the
4
+ WebSocket). The daemon GETs ``/v1/daemon/stream`` with a Bearer token and
5
+ ``Last-Event-ID`` set to the highest ``seq`` it has durably processed.
6
+ The server replies with ``text/event-stream``; each ``event:``/``id:``/
7
+ ``data:`` block is a server→daemon frame.
8
+
9
+ Reconnect strategy
10
+ ------------------
11
+ The HTTP+SSE world is intentionally less precise than WebSocket — proxies
12
+ may close idle streams, NAT may cull connections, etc. We:
13
+
14
+ * Reconnect on any non-auth error with exponential backoff (1→2→4→8→16→30s
15
+ cap), reset to 1s on a successful event.
16
+ * Always re-send ``Last-Event-ID`` on reconnect so the server replays
17
+ unacked frames from its catchup buffer (per SSE spec, this is the
18
+ client's responsibility — the server only sees the header).
19
+ * Treat ``401``/``403`` as terminal (``AuthFailed``) — re-trying with the
20
+ same key wastes server time and can trigger abuse mitigations.
21
+
22
+ Out of scope:
23
+ * POSTing daemon→server frames — that's ``http_outbound.HTTPOutbound``.
24
+
25
+ Stale-stream watchdogs (1k #64 passive + Wave C #2 active)
26
+ ----------------------------------------------------------
27
+ Clash TUN / NAT middleware silently swallows long-idle SSE streams: the
28
+ TCP socket stays open from our side but no bytes ever arrive again. Two
29
+ layers of defence:
30
+
31
+ * **Passive (1k #64)** — httpx's ``read`` timeout. The server sends
32
+ ``:hb`` comment lines every 30s when idle (see
33
+ ``backend/.../daemon_sse.py``), which counts as bytes at the socket
34
+ layer (``httpx-sse`` filters comment lines out only at the parse
35
+ layer). If no bytes arrive within ``READ_TIMEOUT_SECONDS`` (60s = 2×
36
+ server heartbeat), httpx raises ``ReadTimeout`` and we reconnect with
37
+ ``Last-Event-ID``. Covers "TCP socket dead, no bytes flowing".
38
+
39
+ * **Active (Wave C #2)** — :data:`ACTIVE_LIVENESS_TIMEOUT_SECONDS`
40
+ watchdog **on the parsed-event stream**. ``httpx-sse`` filters
41
+ ``:hb`` comments before yielding to user code, so even on a fully
42
+ healthy stream we may go minutes between user-visible events. The
43
+ passive timeout (TCP bytes) can hide a pathological case where the
44
+ underlying httpx-sse buffer is starved of real events forever. If we
45
+ see no real frame for ``ACTIVE_LIVENESS_TIMEOUT_SECONDS`` we close
46
+ the stream from our side and reconnect — the next ``Last-Event-ID``
47
+ GET re-arms the catchup buffer and the worst-case stuck-frame window
48
+ is bounded by the watchdog. Default is intentionally LONGER than the
49
+ passive timeout so an idle backend doesn't reconnect every minute.
50
+ """
51
+ from __future__ import annotations
52
+
53
+ import asyncio
54
+ import json
55
+ import logging
56
+ import ssl
57
+ import time
58
+ from collections.abc import Awaitable, Callable
59
+
60
+ import httpx
61
+ from httpx_sse import aconnect_sse
62
+
63
+ logger = logging.getLogger(__name__)
64
+
65
+ # Reconnect backoff curve: 1, 2, 4, 8, 16, 30, 30, … (capped at 30s).
66
+ _BACKOFF_INITIAL_SECONDS = 1.0
67
+ _BACKOFF_CAP_SECONDS = 30.0
68
+
69
+ # Passive stale-stream watchdog (1k #64). Server emits a ``:hb`` comment
70
+ # line every 30s when no real frame flows; httpx counts each ``:hb`` as
71
+ # bytes at the socket layer (httpx-sse filters them out only at parse
72
+ # layer). 60s = tolerate one missed heartbeat before httpx raises
73
+ # ``ReadTimeout`` and we reconnect via ``Last-Event-ID``. Tests
74
+ # monkeypatch this down to a few seconds.
75
+ READ_TIMEOUT_SECONDS = 60.0
76
+
77
+ # Active liveness watchdog (Wave C #2). httpx-sse swallows ``:hb``
78
+ # comment lines before yielding to user code, so even on a healthy
79
+ # stream we may go minutes between user-visible events. If no parsed
80
+ # event arrives within this window, force-close the SSE response and
81
+ # trigger a fresh ``Last-Event-ID`` GET. Default is 180s = 6× server
82
+ # heartbeat — wide enough that a genuinely idle backend doesn't
83
+ # thrash, tight enough to cap stuck-event windows at 3 min in
84
+ # worst-case pathological httpx-sse buffer starvation. Tests
85
+ # monkeypatch this to a few seconds.
86
+ ACTIVE_LIVENESS_TIMEOUT_SECONDS = 180.0
87
+
88
+
89
+ class AuthFailed(Exception):
90
+ """Raised on terminal 401/403 — caller should not retry."""
91
+
92
+ def __init__(self, status_code: int) -> None:
93
+ self.status_code = status_code
94
+ super().__init__(f"SSE auth failed: HTTP {status_code}")
95
+
96
+
97
+ class SSEClient:
98
+ """Async SSE consumer with auto-reconnect via ``Last-Event-ID``.
99
+
100
+ Usage::
101
+
102
+ client = SSEClient(server_url, api_key)
103
+ await client.run_forever(on_frame)
104
+
105
+ ``on_frame`` is awaited for each parsed JSON event (heartbeat comment
106
+ lines and non-JSON event data are silently dropped).
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ server_url: str,
112
+ api_key: str,
113
+ *,
114
+ insecure: bool = False,
115
+ last_event_id: str = "0",
116
+ ) -> None:
117
+ self._server_url = server_url.rstrip("/")
118
+ self._api_key = api_key
119
+ self._insecure = insecure
120
+ self._last_event_id = last_event_id
121
+ self._stop_event = asyncio.Event()
122
+ self._reconnects = 0
123
+ # Wave C #2 — active liveness watchdog timestamp. Reset on
124
+ # every successfully-parsed event (and on connection open).
125
+ # Compared against ``ACTIVE_LIVENESS_TIMEOUT_SECONDS`` inside
126
+ # ``_consume_sse_until_stop`` to force a reconnect when the
127
+ # event stream stalls below the passive read-timeout layer.
128
+ self._last_event_at: float = time.monotonic()
129
+ # Diagnostic counters surfaced by /metrics + doctor (Wave C #3).
130
+ self._active_watchdog_fires = 0
131
+ # SSL context only used for https:// URLs; httpx ignores ``verify``
132
+ # values that aren't bool/path/SSLContext, so we map insecure→False.
133
+ self._verify: ssl.SSLContext | bool = not insecure
134
+
135
+ @property
136
+ def last_event_id(self) -> str:
137
+ return self._last_event_id
138
+
139
+ @property
140
+ def reconnect_count(self) -> int:
141
+ return self._reconnects
142
+
143
+ @property
144
+ def last_event_age_seconds(self) -> float:
145
+ """Seconds since the last parsed SSE event (Wave C #2 / #3).
146
+
147
+ Used by ``cloudagent-daemon doctor`` and the ``/metrics`` endpoint
148
+ to surface stream liveness without having to plumb the timestamp
149
+ through callbacks.
150
+ """
151
+ return max(0.0, time.monotonic() - self._last_event_at)
152
+
153
+ @property
154
+ def active_watchdog_fires(self) -> int:
155
+ """How many times the active liveness watchdog has forced a
156
+ reconnect (cumulative since process start)."""
157
+ return self._active_watchdog_fires
158
+
159
+ def stop(self) -> None:
160
+ """Signal ``run_forever`` to exit at the next checkpoint."""
161
+ self._stop_event.set()
162
+
163
+ async def run_forever(
164
+ self,
165
+ on_frame: Callable[[dict], Awaitable[None]],
166
+ ) -> None:
167
+ """Connect, stream, reconnect on errors. Returns on ``stop()``.
168
+
169
+ Raises:
170
+ AuthFailed: 401/403 on the SSE GET — auth is dead, do not retry.
171
+ asyncio.CancelledError: propagated unchanged for cooperative
172
+ shutdown (so signal handlers / ``task.cancel()`` work).
173
+ """
174
+ backoff = _BACKOFF_INITIAL_SECONDS
175
+ url = f"{self._server_url}/v1/daemon/stream"
176
+
177
+ # One client survives the entire run_forever call; httpx pools the
178
+ # underlying TCP connection so each reconnect is cheap.
179
+ # Read READ_TIMEOUT_SECONDS at call-time (not import-time) so tests
180
+ # can monkeypatch the module constant before invoking run_forever.
181
+ import cloudagent_daemon.sse_client as _self_module # local re-import
182
+ read_timeout = _self_module.READ_TIMEOUT_SECONDS
183
+ async with httpx.AsyncClient(
184
+ verify=self._verify,
185
+ # ``read=READ_TIMEOUT_SECONDS`` is the stale-stream watchdog —
186
+ # server sends ``:hb`` comments every 30s, so 60s of no inbound
187
+ # bytes means Clash TUN / NAT silently dropped the stream
188
+ # → httpx.ReadTimeout → reconnect with Last-Event-ID (1k #64).
189
+ timeout=httpx.Timeout(
190
+ timeout=None, connect=30.0, read=read_timeout
191
+ ),
192
+ # No proxy override — daemon CLI's _bypass_system_proxy_by_default
193
+ # already cleared the env vars before we got here. Honour any
194
+ # explicit env var set by ``CLOUDAGENT_USE_SYSTEM_PROXY=1`` users.
195
+ trust_env=True,
196
+ # 2026-05-15 — Alibaba vipserver fronts the pre/daily/prod
197
+ # intranet ingresses (``pre-agentinfra.alibabacloud.com`` →
198
+ # vipserver TLS terminate → SLB → nginx-ingress). Vipserver
199
+ # buffers HTTP/1.1 chunked-transfer bodies, so the SSE long-
200
+ # poll yields zero bytes until the upstream closes — the
201
+ # daemon's stale-stream watchdog then fires every 60s and the
202
+ # stream looks broken even though the server is happily
203
+ # publishing frames into Redis. Negotiating HTTP/2 dodges
204
+ # the chunked-transfer path entirely (HTTP/2 frames the body
205
+ # natively). Cleartext HTTP and servers without h2 just
206
+ # transparently fall back to 1.1 (httpx ALPN).
207
+ http2=True,
208
+ ) as client:
209
+ while not self._stop_event.is_set():
210
+ try:
211
+ headers = {
212
+ "Authorization": f"Bearer {self._api_key}",
213
+ "Accept": "text/event-stream",
214
+ "Last-Event-ID": self._last_event_id,
215
+ }
216
+ async with aconnect_sse(
217
+ client, "GET", url, headers=headers
218
+ ) as event_source:
219
+ # Surface auth failures BEFORE entering the
220
+ # iteration loop — httpx-sse defers the status
221
+ # check to ``response.raise_for_status()``.
222
+ try:
223
+ event_source.response.raise_for_status()
224
+ except httpx.HTTPStatusError as exc:
225
+ await _handle_status_error(exc)
226
+ # _handle_status_error either raises AuthFailed
227
+ # or returns; non-auth status falls through to
228
+ # the backoff path below.
229
+ raise
230
+ # Iterate manually so we can cooperatively check
231
+ # stop_event between event arrivals — if the stream
232
+ # only emits heartbeat comments (which httpx-sse
233
+ # filters out before yielding), a plain ``async for``
234
+ # would block forever ignoring stop_event.
235
+ sse_iter = event_source.aiter_sse()
236
+ backoff = await _consume_sse_until_stop(
237
+ sse_iter,
238
+ on_frame,
239
+ self,
240
+ backoff,
241
+ )
242
+ except AuthFailed:
243
+ raise
244
+ except httpx.HTTPStatusError as exc:
245
+ # Already handled above for 401/403; non-auth status
246
+ # codes (5xx, 502, etc.) are transient — back off.
247
+ logger.warning(
248
+ "sse: HTTP %d, reconnecting in %.1fs",
249
+ exc.response.status_code,
250
+ min(backoff, _BACKOFF_CAP_SECONDS),
251
+ )
252
+ except asyncio.CancelledError:
253
+ raise
254
+ except httpx.ReadTimeout:
255
+ # 1k #64 — stale-stream watchdog fired. Clash TUN / NAT
256
+ # silently dropped the stream; the OS-level read sat
257
+ # idle past READ_TIMEOUT_SECONDS without even a ``:hb``
258
+ # comment line arriving. Reconnect via Last-Event-ID.
259
+ if self._stop_event.is_set():
260
+ break
261
+ logger.warning(
262
+ "sse: stream read timeout (%.0fs idle) — "
263
+ "reconnecting via Last-Event-ID=%s in %.1fs",
264
+ read_timeout,
265
+ self._last_event_id,
266
+ min(backoff, _BACKOFF_CAP_SECONDS),
267
+ )
268
+ except (httpx.HTTPError, OSError) as exc:
269
+ if self._stop_event.is_set():
270
+ break
271
+ logger.warning(
272
+ "sse: stream broken (%s), reconnecting in %.1fs",
273
+ type(exc).__name__,
274
+ min(backoff, _BACKOFF_CAP_SECONDS),
275
+ )
276
+ except Exception: # noqa: BLE001
277
+ # 1k78 — catch-all so silent failures stop being
278
+ # silent. Previously an unexpected exception type
279
+ # (e.g. RuntimeError from httpx-sse internals or
280
+ # ``on_frame`` raising) would propagate out of
281
+ # run_forever without hitting any logger — making
282
+ # post-mortem debugging impossible. Now we log
283
+ # with full traceback and re-raise so the
284
+ # supervisor in main_loop sees the task die.
285
+ if self._stop_event.is_set():
286
+ raise
287
+ logger.exception(
288
+ "sse: unexpected error in run_forever (will "
289
+ "propagate to supervisor for daemon restart)"
290
+ )
291
+ raise
292
+
293
+ if self._stop_event.is_set():
294
+ break
295
+
296
+ self._reconnects += 1
297
+ # Wave C #3 — surface reconnects to the default metrics
298
+ # registry. Imported lazily so this module stays cheap
299
+ # to import in tests that don't exercise metrics.
300
+ try:
301
+ from cloudagent_daemon.metrics import get_default as _md
302
+ await _md().inc_counter(
303
+ "cloudagent_daemon_sse_reconnects_total"
304
+ )
305
+ except Exception: # noqa: BLE001
306
+ pass
307
+ await asyncio.sleep(min(backoff, _BACKOFF_CAP_SECONDS))
308
+ backoff = min(backoff * 2.0, _BACKOFF_CAP_SECONDS)
309
+
310
+ # 1k78 — if we exit the loop via stop_event normally, log
311
+ # that explicitly. Pre-1k78 a normal return looked identical
312
+ # to a silent failure in the main_loop supervisor.
313
+ logger.info(
314
+ "sse: run_forever exited normally (stop_event=%s reconnects=%d)",
315
+ self._stop_event.is_set(),
316
+ self._reconnects,
317
+ )
318
+
319
+
320
+ async def _handle_status_error(exc: httpx.HTTPStatusError) -> None:
321
+ """Raise AuthFailed for terminal codes; fall through for transient."""
322
+ code = exc.response.status_code
323
+ if code in (401, 403):
324
+ logger.error("sse: auth failed (%d), aborting", code)
325
+ raise AuthFailed(code) from exc
326
+ # Non-auth status — let the caller handle backoff.
327
+
328
+
329
+ async def _consume_sse_until_stop(
330
+ sse_iter,
331
+ on_frame: Callable[[dict], Awaitable[None]],
332
+ sse_client: "SSEClient",
333
+ backoff: float,
334
+ ) -> float:
335
+ """Drive ``sse_iter`` while honouring ``sse_client._stop_event``.
336
+
337
+ Returns the (possibly reset) backoff so the outer reconnect loop sees
338
+ the latest value after a successful event delivery.
339
+
340
+ Wave C #2 — also gates each ``__anext__`` call on the active
341
+ liveness watchdog: if no parsed event arrives in
342
+ :data:`ACTIVE_LIVENESS_TIMEOUT_SECONDS` (re-read from the module
343
+ each iteration so tests can monkeypatch), we exit so the outer
344
+ loop reconnects via ``Last-Event-ID``. Resetting ``_last_event_at``
345
+ on connection open + each successfully-parsed event keeps an idle
346
+ backend from thrashing.
347
+ """
348
+ # Reset the watchdog clock when we (re)enter the consume loop —
349
+ # otherwise a previous run's last_event_at could still be stale
350
+ # at the moment we connect.
351
+ sse_client._last_event_at = time.monotonic()
352
+ while True:
353
+ if sse_client._stop_event.is_set():
354
+ break
355
+
356
+ # Re-read each iter so tests can monkeypatch the module-level
357
+ # constant between runs without reconstructing the client.
358
+ import cloudagent_daemon.sse_client as _self_module
359
+ active_timeout = _self_module.ACTIVE_LIVENESS_TIMEOUT_SECONDS
360
+ elapsed = time.monotonic() - sse_client._last_event_at
361
+ remaining = active_timeout - elapsed
362
+ if remaining <= 0:
363
+ # Edge case: clock jumped (sleep / hibernate) past the
364
+ # threshold while we were between iterations. Treat
365
+ # exactly like a watchdog fire.
366
+ sse_client._active_watchdog_fires += 1
367
+ try:
368
+ from cloudagent_daemon.metrics import get_default as _md
369
+ await _md().inc_counter(
370
+ "cloudagent_daemon_sse_active_watchdog_fires_total"
371
+ )
372
+ except Exception: # noqa: BLE001
373
+ pass
374
+ logger.warning(
375
+ "sse: active watchdog — no event in %.0fs (>= %.0fs), "
376
+ "reconnecting via Last-Event-ID=%s",
377
+ elapsed, active_timeout, sse_client._last_event_id,
378
+ )
379
+ return backoff
380
+
381
+ next_task = asyncio.create_task(_anext(sse_iter))
382
+ stop_task = asyncio.create_task(sse_client._stop_event.wait())
383
+ watchdog_task = asyncio.create_task(asyncio.sleep(remaining))
384
+ try:
385
+ done, _pending = await asyncio.wait(
386
+ {next_task, stop_task, watchdog_task},
387
+ return_when=asyncio.FIRST_COMPLETED,
388
+ )
389
+ finally:
390
+ for t in (next_task, stop_task, watchdog_task):
391
+ if not t.done():
392
+ t.cancel()
393
+ if stop_task in done:
394
+ return backoff
395
+ if watchdog_task in done and next_task not in done:
396
+ # Active watchdog won the race — the parsed-event stream
397
+ # has been silent past the threshold. Exit so the outer
398
+ # loop reconnects; ``Last-Event-ID`` re-arms catchup.
399
+ sse_client._active_watchdog_fires += 1
400
+ try:
401
+ from cloudagent_daemon.metrics import get_default as _md
402
+ await _md().inc_counter(
403
+ "cloudagent_daemon_sse_active_watchdog_fires_total"
404
+ )
405
+ except Exception: # noqa: BLE001
406
+ pass
407
+ elapsed = time.monotonic() - sse_client._last_event_at
408
+ logger.warning(
409
+ "sse: active watchdog — no event in %.0fs (>= %.0fs), "
410
+ "reconnecting via Last-Event-ID=%s",
411
+ elapsed, active_timeout, sse_client._last_event_id,
412
+ )
413
+ return backoff
414
+ # next_task done — propagate exceptions, otherwise consume.
415
+ try:
416
+ sse_event = next_task.result()
417
+ except StopAsyncIteration:
418
+ return backoff
419
+ sse_client._last_event_at = time.monotonic()
420
+ if sse_event.id:
421
+ sse_client._last_event_id = sse_event.id
422
+ if not sse_event.data:
423
+ continue
424
+ try:
425
+ frame = json.loads(sse_event.data)
426
+ except json.JSONDecodeError:
427
+ logger.warning(
428
+ "sse: non-JSON event data %r",
429
+ sse_event.data[:120],
430
+ )
431
+ continue
432
+ backoff = _BACKOFF_INITIAL_SECONDS
433
+ await on_frame(frame)
434
+ return backoff
435
+
436
+
437
+ async def _anext(aiter):
438
+ """Polyfill for ``anext`` on a generic async iterator."""
439
+ return await aiter.__anext__()
@@ -0,0 +1,105 @@
1
+ """Transport abstraction for daemon→server frames.
2
+
3
+ Sub-plan 1j replaces the WebSocket with an SSE stream (server→daemon)
4
+ plus per-frame-type POST endpoints (daemon→server). Outbound senders
5
+ (``OutboundQueue``, ``ClaudeRunner``) shouldn't care which wire is used —
6
+ they call ``transport.send_frame(frame_dict)`` and let the implementation
7
+ route.
8
+
9
+ Two concrete transports today:
10
+ * ``HTTPTransport`` — routes each frame ``type`` to the matching POST
11
+ endpoint. Used in production with the SSE stream.
12
+ * ``WSTransport`` — wraps a connected ``WSClient`` so the legacy
13
+ ``--transport ws`` path still works through the same OutboundQueue.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from typing import TYPE_CHECKING, Protocol
19
+
20
+ if TYPE_CHECKING:
21
+ from .http_outbound import HTTPOutbound
22
+ from .ws_client import WSClient
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class Transport(Protocol):
28
+ """Sends daemon→server frames.
29
+
30
+ Implementations decide whether the frame goes over WS as JSON or
31
+ over HTTP as a POST to a per-type endpoint.
32
+ """
33
+
34
+ async def send_frame(self, frame: dict) -> None: ...
35
+
36
+ async def close(self) -> None: ...
37
+
38
+
39
+ # Frame ``type`` → POST path. ``None`` means the SSE path doesn't have a
40
+ # corresponding POST (the WS-only frame is silently dropped). We list all
41
+ # the daemon→server frame types from spec §4.2 explicitly so future frame
42
+ # types fail loudly (logged) rather than getting silently sent to ``None``.
43
+ _HTTP_PATHS: dict[str, str | None] = {
44
+ "delivery_ack": "/v1/daemon/delivery-ack",
45
+ "agent_status": "/v1/daemon/agent-status",
46
+ "agent_session_start": "/v1/daemon/agent-session-start",
47
+ "tool_call": "/v1/daemon/tool-call",
48
+ # SSE provides server-side ``:hb`` heartbeat; the daemon doesn't need
49
+ # to pong (and the server has no /pong endpoint). The legacy WS still
50
+ # uses pong via WSTransport.
51
+ "pong": None,
52
+ # Resume happens implicitly via the ``Last-Event-ID`` header on the
53
+ # SSE GET; no POST equivalent.
54
+ "resume": None,
55
+ # Ready is sent ONCE during /v1/daemon/ready (handled by main_loop
56
+ # before the OutboundQueue is wired up); no need to re-send.
57
+ "ready": None,
58
+ }
59
+
60
+
61
+ class HTTPTransport:
62
+ """Transport implementation that routes by frame type to POST endpoints."""
63
+
64
+ def __init__(self, http: HTTPOutbound) -> None:
65
+ self._http = http
66
+
67
+ async def send_frame(self, frame: dict) -> None:
68
+ ftype = frame.get("type")
69
+ if ftype not in _HTTP_PATHS:
70
+ logger.warning(
71
+ "http_transport: unknown frame type %r — dropping", ftype
72
+ )
73
+ return
74
+ path = _HTTP_PATHS[ftype]
75
+ if path is None:
76
+ # Intentionally not POSTed — see _HTTP_PATHS comments above.
77
+ logger.debug(
78
+ "http_transport: frame type %r has no POST path — skipping",
79
+ ftype,
80
+ )
81
+ return
82
+ payload = frame.get("payload") or {}
83
+ # tool_call and delivery_ack include the request_id / message_id
84
+ # inside payload already; the backend POST schemas mirror those
85
+ # fields, so passing the inner payload as the JSON body is enough.
86
+ await self._http.post(path, payload)
87
+
88
+ async def close(self) -> None:
89
+ await self._http.close()
90
+
91
+
92
+ class WSTransport:
93
+ """Transport wrapping a connected ``WSClient`` for the legacy path."""
94
+
95
+ def __init__(self, ws: "WSClient") -> None:
96
+ self._ws = ws
97
+
98
+ async def send_frame(self, frame: dict) -> None:
99
+ await self._ws.send(frame)
100
+
101
+ async def close(self) -> None:
102
+ # WSClient is owned (entered/exited) by ``run_forever`` in the
103
+ # legacy path; closing here would race with the supervisor. Keep
104
+ # this a no-op so the supervisor stays in charge of the lifecycle.
105
+ return