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.
- cloudagent_daemon/__init__.py +1 -0
- cloudagent_daemon/__main__.py +4 -0
- cloudagent_daemon/claude_runner.py +1068 -0
- cloudagent_daemon/cli.py +833 -0
- cloudagent_daemon/config.py +197 -0
- cloudagent_daemon/doctor.py +534 -0
- cloudagent_daemon/frame_dispatch.py +216 -0
- cloudagent_daemon/http_outbound.py +80 -0
- cloudagent_daemon/jsonl_logging.py +98 -0
- cloudagent_daemon/login.py +34 -0
- cloudagent_daemon/main_loop.py +891 -0
- cloudagent_daemon/mcp_bridge.py +441 -0
- cloudagent_daemon/metrics.py +315 -0
- cloudagent_daemon/metrics_server.py +149 -0
- cloudagent_daemon/outbound_queue.py +289 -0
- cloudagent_daemon/paths.py +204 -0
- cloudagent_daemon/protocol.py +141 -0
- cloudagent_daemon/runtime_detect.py +135 -0
- cloudagent_daemon/service.py +355 -0
- cloudagent_daemon/spawn_outbox.py +443 -0
- cloudagent_daemon/spawn_queue.py +200 -0
- cloudagent_daemon/sse_client.py +439 -0
- cloudagent_daemon/transport.py +105 -0
- cloudagent_daemon/unix_socket.py +278 -0
- cloudagent_daemon/workspace.py +441 -0
- cloudagent_daemon/ws_client.py +431 -0
- cloudagent_daemon-0.2.1.dist-info/METADATA +322 -0
- cloudagent_daemon-0.2.1.dist-info/RECORD +31 -0
- cloudagent_daemon-0.2.1.dist-info/WHEEL +4 -0
- cloudagent_daemon-0.2.1.dist-info/entry_points.txt +2 -0
- cloudagent_daemon-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""Async WebSocket client for the cloudagent daemon.
|
|
2
|
+
|
|
3
|
+
Spec: docs/superpowers/specs/2026-05-08-local-daemon-runtime-design.md §4.1
|
|
4
|
+
|
|
5
|
+
Single responsibility: own the WS transport. Connect → hello → ready
|
|
6
|
+
handshake, run a background reader that feeds an inbound queue, auto-reply
|
|
7
|
+
to server pings, and watchdog the connection (>40s of silence ⇒ close).
|
|
8
|
+
|
|
9
|
+
Also exposes ``run_forever`` — a reconnect-on-disconnect supervisor with
|
|
10
|
+
exponential backoff (1→2→4→8→16→30s cap) that sends ``resume {last_seq}``
|
|
11
|
+
after every reconnect so the server can replay unacked frames (Task 5).
|
|
12
|
+
|
|
13
|
+
Out of scope (handled in later tasks):
|
|
14
|
+
- Frame dispatch by type (Task 6)
|
|
15
|
+
- Outbound queue + delivery ack (Task 7)
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import platform
|
|
23
|
+
import socket
|
|
24
|
+
import ssl
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import Awaitable, Callable
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from typing import Any
|
|
29
|
+
from uuid import uuid4
|
|
30
|
+
|
|
31
|
+
from websockets.asyncio.client import ClientConnection, connect
|
|
32
|
+
from websockets.exceptions import ConnectionClosed, InvalidStatus
|
|
33
|
+
|
|
34
|
+
from .runtime_detect import DetectedRuntime, detect_runtimes
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# Spec §4.1: server pings every 30s. We treat 40s of silence as "missed
|
|
39
|
+
# two pings" and close. Watchdog fires every 5s.
|
|
40
|
+
HEARTBEAT_GRACE_SECONDS = 40.0
|
|
41
|
+
WATCHDOG_INTERVAL_SECONDS = 5.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ws_url(server_url: str) -> str:
|
|
45
|
+
"""Map ``http(s)://host`` → ``ws(s)://host/v1/daemon/ws``."""
|
|
46
|
+
base = server_url.rstrip("/")
|
|
47
|
+
if base.startswith("https://"):
|
|
48
|
+
base = "wss://" + base[len("https://"):]
|
|
49
|
+
elif base.startswith("http://"):
|
|
50
|
+
base = "ws://" + base[len("http://"):]
|
|
51
|
+
return f"{base}/v1/daemon/ws"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _new_frame_id() -> str:
|
|
55
|
+
return f"frm_{uuid4().hex[:16]}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _now_iso() -> str:
|
|
59
|
+
return datetime.now(UTC).isoformat()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class WSClient:
|
|
63
|
+
"""Asyncio WebSocket client for ``/v1/daemon/ws``.
|
|
64
|
+
|
|
65
|
+
Use as ``async with WSClient(server_url, api_key) as client:``. The
|
|
66
|
+
handshake (hello + ready) runs inside ``__aenter__``; on exit we
|
|
67
|
+
cancel the reader/watchdog tasks and close the WS.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
server_url: str,
|
|
73
|
+
api_key: str,
|
|
74
|
+
*,
|
|
75
|
+
daemon_ver: str | None = None,
|
|
76
|
+
detect_runtimes_fn: Callable[[], list[DetectedRuntime]] | None = None,
|
|
77
|
+
insecure: bool = False,
|
|
78
|
+
workspace_root: str | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
# Default the version to the package's __version__ so the WS hello
|
|
81
|
+
# frame stays in sync with the SSE/POST ready POST without
|
|
82
|
+
# callers having to remember to pass it.
|
|
83
|
+
if daemon_ver is None:
|
|
84
|
+
from cloudagent_daemon import __version__ as _v
|
|
85
|
+
daemon_ver = _v
|
|
86
|
+
self._server_url = server_url
|
|
87
|
+
self._api_key = api_key
|
|
88
|
+
self._daemon_ver = daemon_ver
|
|
89
|
+
# Task #113 (2026-05-12) — agent workspace root, surfaced on
|
|
90
|
+
# the legacy WS ``ready`` frame so the legacy path mirrors the
|
|
91
|
+
# SSE+POST ``/v1/daemon/ready`` shape. ``None`` ⇒ omit the
|
|
92
|
+
# field (backend preserves any previously-reported value).
|
|
93
|
+
self._workspace_root = workspace_root
|
|
94
|
+
# DI seam — tests pass a stub returning a known list so the ready
|
|
95
|
+
# frame is deterministic without monkeypatching the module. Defaults
|
|
96
|
+
# to scanning $PATH on the real machine.
|
|
97
|
+
self._detect_runtimes_fn = detect_runtimes_fn or detect_runtimes
|
|
98
|
+
# TLS verification policy. Default = strict (verify cert + hostname).
|
|
99
|
+
# When ``insecure=True`` we build an unverified SSL context — required
|
|
100
|
+
# for self-signed dev/staging environments (e.g. fr cluster pre-Wave-7
|
|
101
|
+
# where nginx-ingress serves the bundled fake cert). Only applies to
|
|
102
|
+
# wss:// URLs; for ws:// the ssl context is unused.
|
|
103
|
+
self._insecure = insecure
|
|
104
|
+
if insecure:
|
|
105
|
+
self._ssl_context: ssl.SSLContext | None = ssl.create_default_context()
|
|
106
|
+
self._ssl_context.check_hostname = False
|
|
107
|
+
self._ssl_context.verify_mode = ssl.CERT_NONE
|
|
108
|
+
else:
|
|
109
|
+
# Let websockets build the default verified context per scheme.
|
|
110
|
+
self._ssl_context = None
|
|
111
|
+
self._ws: ClientConnection | None = None
|
|
112
|
+
self._inbound: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
113
|
+
self._reader_task: asyncio.Task[None] | None = None
|
|
114
|
+
self._watchdog_task: asyncio.Task[None] | None = None
|
|
115
|
+
self._host_id: str = ""
|
|
116
|
+
self._bound_agents: list[dict[str, Any]] = []
|
|
117
|
+
self._last_inbound_at: float = 0.0
|
|
118
|
+
self._closed = False
|
|
119
|
+
# Set when the reader loop has exited (server dropped, etc.) so
|
|
120
|
+
# next_frame() can surface the disconnect instead of hanging.
|
|
121
|
+
self._disconnect_event: asyncio.Event = asyncio.Event()
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def host_id(self) -> str:
|
|
125
|
+
return self._host_id
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def bound_agents(self) -> list[dict[str, Any]]:
|
|
129
|
+
return list(self._bound_agents)
|
|
130
|
+
|
|
131
|
+
def _build_ready_frame(
|
|
132
|
+
self, runtimes_payload: list[dict[str, Any]]
|
|
133
|
+
) -> dict[str, Any]:
|
|
134
|
+
"""Build the ``ready`` frame body sent during handshake.
|
|
135
|
+
|
|
136
|
+
Pulled out as a helper so tests can verify the payload shape
|
|
137
|
+
(notably Task #113's ``workspace_root`` field) without driving
|
|
138
|
+
the full websocket handshake.
|
|
139
|
+
"""
|
|
140
|
+
ready_payload: dict[str, Any] = {
|
|
141
|
+
"daemon_ver": self._daemon_ver,
|
|
142
|
+
"runtimes": runtimes_payload,
|
|
143
|
+
"hostname": socket.gethostname(),
|
|
144
|
+
"os": platform.system().lower(),
|
|
145
|
+
}
|
|
146
|
+
# Task #113 — only emit when set; omission preserves backend
|
|
147
|
+
# state (idempotent re-ready).
|
|
148
|
+
if self._workspace_root is not None:
|
|
149
|
+
ready_payload["workspace_root"] = self._workspace_root
|
|
150
|
+
return {
|
|
151
|
+
"v": 1,
|
|
152
|
+
"type": "ready",
|
|
153
|
+
"id": _new_frame_id(),
|
|
154
|
+
"ts": _now_iso(),
|
|
155
|
+
"payload": ready_payload,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async def __aenter__(self) -> WSClient:
|
|
159
|
+
# ``additional_headers`` is the websockets v15 spelling (renamed
|
|
160
|
+
# from ``extra_headers``). ``ping_interval=None`` disables the
|
|
161
|
+
# library's auto-ping; we implement application-level heartbeat
|
|
162
|
+
# ourselves per spec §4.1.
|
|
163
|
+
ws_url = _ws_url(self._server_url)
|
|
164
|
+
# Only pass ssl= for wss://. For plain ws:// websockets ignores it
|
|
165
|
+
# but is fussy about None vs default in some library versions.
|
|
166
|
+
connect_kwargs: dict[str, Any] = {
|
|
167
|
+
"additional_headers": {"Authorization": f"Bearer {self._api_key}"},
|
|
168
|
+
"ping_interval": None,
|
|
169
|
+
}
|
|
170
|
+
if ws_url.startswith("wss://") and self._ssl_context is not None:
|
|
171
|
+
connect_kwargs["ssl"] = self._ssl_context
|
|
172
|
+
self._ws = await connect(ws_url, **connect_kwargs)
|
|
173
|
+
|
|
174
|
+
# Hello is the first inbound frame from the server.
|
|
175
|
+
try:
|
|
176
|
+
raw = await self._ws.recv()
|
|
177
|
+
except ConnectionClosed as exc:
|
|
178
|
+
await self._ws.close()
|
|
179
|
+
raise ConnectionError(
|
|
180
|
+
f"server closed before hello: code={exc.rcvd}"
|
|
181
|
+
) from exc
|
|
182
|
+
hello = json.loads(raw)
|
|
183
|
+
if hello.get("type") != "hello":
|
|
184
|
+
await self._ws.close()
|
|
185
|
+
raise ConnectionError(
|
|
186
|
+
f"expected hello frame, got {hello.get('type')!r}"
|
|
187
|
+
)
|
|
188
|
+
payload = hello.get("payload", {}) or {}
|
|
189
|
+
self._host_id = payload.get("host_id", "")
|
|
190
|
+
self._bound_agents = list(payload.get("agents", []) or [])
|
|
191
|
+
self._last_inbound_at = time.monotonic()
|
|
192
|
+
|
|
193
|
+
# Detect locally installed AI coding runtimes (Task 6). Failures
|
|
194
|
+
# in detection (e.g. a probe binary hangs unexpectedly) must not
|
|
195
|
+
# break the handshake — degrade to empty list so the daemon still
|
|
196
|
+
# comes online and the user can fix runtime config out-of-band.
|
|
197
|
+
try:
|
|
198
|
+
detected = self._detect_runtimes_fn()
|
|
199
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
200
|
+
log.warning("runtime detection failed (%s); reporting empty list", exc)
|
|
201
|
+
detected = []
|
|
202
|
+
runtimes_payload = [r.to_payload() for r in detected]
|
|
203
|
+
|
|
204
|
+
ready_frame = self._build_ready_frame(runtimes_payload)
|
|
205
|
+
await self._ws.send(json.dumps(ready_frame))
|
|
206
|
+
|
|
207
|
+
self._reader_task = asyncio.create_task(self._reader_loop())
|
|
208
|
+
self._watchdog_task = asyncio.create_task(self._watchdog_loop())
|
|
209
|
+
return self
|
|
210
|
+
|
|
211
|
+
async def __aexit__(self, *_exc: Any) -> None:
|
|
212
|
+
self._closed = True
|
|
213
|
+
for task in (self._reader_task, self._watchdog_task):
|
|
214
|
+
if task is not None and not task.done():
|
|
215
|
+
task.cancel()
|
|
216
|
+
try:
|
|
217
|
+
await task
|
|
218
|
+
except (asyncio.CancelledError, Exception):
|
|
219
|
+
pass
|
|
220
|
+
if self._ws is not None:
|
|
221
|
+
try:
|
|
222
|
+
await self._ws.close()
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
async def next_frame(self, timeout: float | None = None) -> dict[str, Any]:
|
|
227
|
+
"""Wait for the next non-control frame. Pings never surface here.
|
|
228
|
+
|
|
229
|
+
Raises ``ConnectionClosed`` if the underlying reader has already
|
|
230
|
+
exited (server dropped the connection) — otherwise the call would
|
|
231
|
+
hang forever on an empty queue. Callers like ``run_forever``
|
|
232
|
+
rely on this to trigger a reconnect.
|
|
233
|
+
"""
|
|
234
|
+
get_task = asyncio.create_task(self._inbound.get())
|
|
235
|
+
disc_task = asyncio.create_task(self._disconnect_event.wait())
|
|
236
|
+
try:
|
|
237
|
+
done, _ = await asyncio.wait(
|
|
238
|
+
{get_task, disc_task},
|
|
239
|
+
timeout=timeout,
|
|
240
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
241
|
+
)
|
|
242
|
+
if not done:
|
|
243
|
+
raise asyncio.TimeoutError
|
|
244
|
+
if get_task in done:
|
|
245
|
+
return get_task.result()
|
|
246
|
+
# Reader exited before a frame arrived → connection dead.
|
|
247
|
+
raise ConnectionClosed(rcvd=None, sent=None)
|
|
248
|
+
finally:
|
|
249
|
+
for t in (get_task, disc_task):
|
|
250
|
+
if not t.done():
|
|
251
|
+
t.cancel()
|
|
252
|
+
|
|
253
|
+
async def send(self, frame: dict[str, Any]) -> None:
|
|
254
|
+
if self._ws is None:
|
|
255
|
+
raise RuntimeError("WSClient not connected")
|
|
256
|
+
await self._ws.send(json.dumps(frame))
|
|
257
|
+
|
|
258
|
+
async def send_resume(self, last_seq: int) -> None:
|
|
259
|
+
"""Send a ``resume`` frame so the server can replay unacked frames.
|
|
260
|
+
|
|
261
|
+
Spec §4.1: after every (re)connect, the daemon tells the server
|
|
262
|
+
the highest seq it has durably processed; the server then replays
|
|
263
|
+
any frames with ``seq > last_seq`` from its catchup buffer.
|
|
264
|
+
"""
|
|
265
|
+
await self.send({
|
|
266
|
+
"v": 1,
|
|
267
|
+
"type": "resume",
|
|
268
|
+
"id": _new_frame_id(),
|
|
269
|
+
"ts": _now_iso(),
|
|
270
|
+
"payload": {"last_seq": last_seq},
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
async def _reader_loop(self) -> None:
|
|
274
|
+
assert self._ws is not None
|
|
275
|
+
try:
|
|
276
|
+
async for raw in self._ws:
|
|
277
|
+
self._last_inbound_at = time.monotonic()
|
|
278
|
+
try:
|
|
279
|
+
frame = json.loads(raw)
|
|
280
|
+
except (TypeError, ValueError):
|
|
281
|
+
# Malformed frame — drop and continue. Phase 1c will
|
|
282
|
+
# add a bye/disconnect path for protocol errors.
|
|
283
|
+
continue
|
|
284
|
+
if frame.get("type") == "ping":
|
|
285
|
+
# Inline pong reply, do NOT enqueue. Spec §4.1.
|
|
286
|
+
try:
|
|
287
|
+
await self._ws.send(json.dumps({
|
|
288
|
+
"v": 1,
|
|
289
|
+
"type": "pong",
|
|
290
|
+
"id": _new_frame_id(),
|
|
291
|
+
"ts": _now_iso(),
|
|
292
|
+
"payload": {},
|
|
293
|
+
}))
|
|
294
|
+
except Exception:
|
|
295
|
+
return
|
|
296
|
+
continue
|
|
297
|
+
await self._inbound.put(frame)
|
|
298
|
+
except ConnectionClosed:
|
|
299
|
+
return
|
|
300
|
+
finally:
|
|
301
|
+
# Wake any next_frame() caller waiting on an empty queue.
|
|
302
|
+
self._disconnect_event.set()
|
|
303
|
+
|
|
304
|
+
async def _watchdog_loop(self) -> None:
|
|
305
|
+
"""Close the WS if no inbound traffic for HEARTBEAT_GRACE_SECONDS."""
|
|
306
|
+
while True:
|
|
307
|
+
await asyncio.sleep(WATCHDOG_INTERVAL_SECONDS)
|
|
308
|
+
if self._closed or self._ws is None:
|
|
309
|
+
return
|
|
310
|
+
if (
|
|
311
|
+
time.monotonic() - self._last_inbound_at
|
|
312
|
+
> HEARTBEAT_GRACE_SECONDS
|
|
313
|
+
):
|
|
314
|
+
try:
|
|
315
|
+
await self._ws.close()
|
|
316
|
+
finally:
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
# Reconnect supervisor (Task 5)
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
# Backoff cap from the spec — the longest delay we ever sleep between
|
|
325
|
+
# reconnect attempts. The series is 1, 2, 4, 8, 16, 30, 30, 30, …
|
|
326
|
+
_BACKOFF_INITIAL_SECONDS = 1.0
|
|
327
|
+
_BACKOFF_CAP_SECONDS = 30.0
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class AuthFailed(Exception):
|
|
331
|
+
"""Raised when the server rejects the WS upgrade with 401/403.
|
|
332
|
+
|
|
333
|
+
Auth failures are terminal — re-trying with the same key is futile
|
|
334
|
+
and could trigger server-side abuse mitigations. ``run_forever``
|
|
335
|
+
surfaces this instead of looping.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
def __init__(self, status_code: int, message: str = "") -> None:
|
|
339
|
+
self.status_code = status_code
|
|
340
|
+
super().__init__(message or f"auth rejected (HTTP {status_code})")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
async def run_forever(
|
|
344
|
+
client_factory: Callable[[], WSClient],
|
|
345
|
+
on_frame: Callable[[dict[str, Any]], Awaitable[None]],
|
|
346
|
+
state_loader: Callable[[], int],
|
|
347
|
+
*,
|
|
348
|
+
on_connect: Callable[[WSClient], Awaitable[None]] | None = None,
|
|
349
|
+
_backoff_factor: float = 1.0,
|
|
350
|
+
_sleep: Callable[[float], Awaitable[None]] | None = None,
|
|
351
|
+
) -> None:
|
|
352
|
+
"""Run the WS client forever, reconnecting with exponential backoff.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
client_factory: Builds a fresh ``WSClient`` per attempt — the
|
|
356
|
+
client is single-use because ``__aexit__`` cancels its
|
|
357
|
+
background tasks.
|
|
358
|
+
on_frame: Coroutine called for every business frame surfaced by
|
|
359
|
+
``WSClient.next_frame``. Frame dispatch logic lives here
|
|
360
|
+
(Task 6 wires it up to the assign/deliver/tool_call handlers).
|
|
361
|
+
state_loader: Returns the current ``last_seq`` to send in the
|
|
362
|
+
``resume`` frame after each (re)connect. Called fresh each
|
|
363
|
+
iteration so it always reflects the latest persisted state.
|
|
364
|
+
on_connect: Optional coroutine called once per successful
|
|
365
|
+
(re)connect, after the WS handshake completes and *before*
|
|
366
|
+
the resume frame is sent. Used by ``main_loop.run_daemon``
|
|
367
|
+
to rebind ``OutboundQueue.ws`` to the freshly minted client
|
|
368
|
+
so subsequent outbound sends target the live socket.
|
|
369
|
+
_backoff_factor: Multiplier applied to all sleep durations. Real
|
|
370
|
+
usage leaves this at 1.0 (delays in real seconds); tests
|
|
371
|
+
pass a small value (e.g. 0.01) to keep the suite fast.
|
|
372
|
+
_sleep: Optional sleep override — defaults to ``asyncio.sleep``.
|
|
373
|
+
Test-only hook used to record/replace backoff sleeps without
|
|
374
|
+
patching the global ``asyncio.sleep`` (which would also break
|
|
375
|
+
unrelated waits inside the same test).
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
AuthFailed: Server returned HTTP 401/403 — auth is dead, do not
|
|
379
|
+
retry. Caller should re-prompt the user for their machine
|
|
380
|
+
key (or just exit).
|
|
381
|
+
asyncio.CancelledError: Propagated unchanged so cooperative
|
|
382
|
+
shutdown (signal handlers, ``task.cancel()``) works.
|
|
383
|
+
"""
|
|
384
|
+
sleep = _sleep if _sleep is not None else asyncio.sleep
|
|
385
|
+
backoff = _BACKOFF_INITIAL_SECONDS
|
|
386
|
+
while True:
|
|
387
|
+
try:
|
|
388
|
+
client = client_factory()
|
|
389
|
+
async with client:
|
|
390
|
+
# Connection succeeded → reset backoff so the next drop
|
|
391
|
+
# starts the curve over at 1s.
|
|
392
|
+
backoff = _BACKOFF_INITIAL_SECONDS
|
|
393
|
+
|
|
394
|
+
# Notify caller (e.g. main_loop) so it can rebind any
|
|
395
|
+
# client-derived state (OutboundQueue.ws). Runs BEFORE
|
|
396
|
+
# the resume frame so the resume itself uses the new
|
|
397
|
+
# client; runs AFTER aenter so .send is usable.
|
|
398
|
+
if on_connect is not None:
|
|
399
|
+
await on_connect(client)
|
|
400
|
+
|
|
401
|
+
# Send resume BEFORE entering the receive loop so the
|
|
402
|
+
# server replays unacked frames first; on_frame then
|
|
403
|
+
# picks them up in order. last_seq=0 still gets a
|
|
404
|
+
# resume frame — the server treats that as "send me
|
|
405
|
+
# everything in the buffer".
|
|
406
|
+
last_seq = state_loader()
|
|
407
|
+
await client.send_resume(last_seq)
|
|
408
|
+
|
|
409
|
+
while True:
|
|
410
|
+
frame = await client.next_frame()
|
|
411
|
+
await on_frame(frame)
|
|
412
|
+
except asyncio.CancelledError:
|
|
413
|
+
# Cooperative shutdown — bubble up so the caller (signal
|
|
414
|
+
# handler, supervisor task) sees a clean exit.
|
|
415
|
+
raise
|
|
416
|
+
except InvalidStatus as exc:
|
|
417
|
+
code = exc.response.status_code
|
|
418
|
+
if code in (401, 403):
|
|
419
|
+
# Auth is dead. Re-trying won't help and risks lockout.
|
|
420
|
+
log.error("ws auth rejected (HTTP %d) — not retrying", code)
|
|
421
|
+
raise AuthFailed(code) from exc
|
|
422
|
+
# Any other handshake-level rejection (5xx, 426, …) is
|
|
423
|
+
# plausibly transient — back off and retry.
|
|
424
|
+
log.warning("ws handshake rejected (HTTP %d), backing off", code)
|
|
425
|
+
except (ConnectionClosed, OSError, asyncio.TimeoutError) as exc:
|
|
426
|
+
log.warning("ws disconnected (%s), backing off %.1fs",
|
|
427
|
+
type(exc).__name__, backoff)
|
|
428
|
+
|
|
429
|
+
# Sleep with the current backoff, then double (capped at 30s).
|
|
430
|
+
await sleep(min(backoff, _BACKOFF_CAP_SECONDS) * _backoff_factor)
|
|
431
|
+
backoff = min(backoff * 2, _BACKOFF_CAP_SECONDS)
|