Skip to content

Commit 4588867

Browse files
Zawwarsami16claude
andcommitted
phase 1.5: auto-reconnect on publisher + connect (production gap fix)
both publish() and connect() runners died permanently on WebSocket drop — hub restart, network blip, transient error all left the publisher dead. hand-test in last batch surfaced this; now fixed. implementation: - ZhubConnection gains _stop_event field (ZhubPublication already had one) - publish().runner: outer reconnect loop with exponential backoff (1s → 2x → 60s max). _serve_one_session opens WS, registers (with pub.api_key from prior session if set so key pinning re-attaches the same name), serves until disconnect. AuthError = terminal (no retry on register_failed); any other exception = reconnect with backoff. - connect().runner: same shape — _serve_one_session + outer loop. - on registered envelope, only update pub.api_key if the new value is non-empty (so a transient empty re-register response can't clear it). tests: - tests/test_reconnect.py: spin up uvicorn hub thread, register publisher, set should_exit on the server (proper shutdown), restart hub on same port + same db_path, send a chat through a new client, assert response arrives — only succeeds if publisher auto-re-registered. result: 33/33 pytest passing. publisher now survives: - hub restart (re-registers via key pinning) - transient network blip (reconnects with backoff) - clean reconnect if hub was down momentarily Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d367561 commit 4588867

2 files changed

Lines changed: 199 additions & 9 deletions

File tree

tests/test_reconnect.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Auto-reconnect after hub WS drops or hub restarts.
2+
3+
Today (pre-1.5) the publish/connect runners die permanently on disconnect.
4+
This test asserts they re-register automatically when the hub comes back.
5+
"""
6+
7+
import asyncio
8+
import socket
9+
import threading
10+
import time
11+
12+
import pytest
13+
14+
try:
15+
import fastapi # noqa
16+
import uvicorn # noqa
17+
SERVER_AVAILABLE = True
18+
except ImportError:
19+
SERVER_AVAILABLE = False
20+
21+
if SERVER_AVAILABLE:
22+
from zhub.server import create_app
23+
from zhub import publish
24+
25+
26+
def _free_port() -> int:
27+
with socket.socket() as s:
28+
s.bind(("", 0))
29+
return s.getsockname()[1]
30+
31+
32+
class _HubThread:
33+
"""Owns a uvicorn server in a daemon thread. Allows clean stop()."""
34+
35+
def __init__(self, port: int, db_path: str):
36+
self.port = port
37+
self.db_path = db_path
38+
self.server: uvicorn.Server | None = None
39+
self.thread: threading.Thread | None = None
40+
41+
def start(self):
42+
config = uvicorn.Config(
43+
create_app(db_path=self.db_path), host="127.0.0.1", port=self.port,
44+
log_level="warning",
45+
)
46+
self.server = uvicorn.Server(config)
47+
48+
def _run():
49+
asyncio.run(self.server.serve())
50+
51+
self.thread = threading.Thread(target=_run, daemon=True)
52+
self.thread.start()
53+
# wait for port
54+
for _ in range(50):
55+
try:
56+
with socket.create_connection(("127.0.0.1", self.port), timeout=0.1):
57+
return
58+
except OSError:
59+
time.sleep(0.1)
60+
61+
def stop(self):
62+
if self.server:
63+
self.server.should_exit = True
64+
if self.thread:
65+
self.thread.join(timeout=5)
66+
67+
68+
@pytest.mark.asyncio
69+
async def test_publisher_auto_reregisters_after_hub_restart():
70+
"""Publisher's WS dies when hub stops. After hub comes back on the same
71+
port + db, publisher reconnects + re-registers via key pinning."""
72+
if not SERVER_AVAILABLE:
73+
pytest.skip("fastapi/uvicorn not installed")
74+
75+
import tempfile, os
76+
db_fd, db_path = tempfile.mkstemp(suffix=".db")
77+
os.close(db_fd)
78+
try:
79+
port = _free_port()
80+
hub = _HubThread(port, db_path)
81+
hub.start()
82+
83+
# First registration — fresh hub, no prior key.
84+
pub = publish(
85+
name="reconn",
86+
description="reconnect test",
87+
chat_handler=lambda m, o: "alive",
88+
hub_url=f"ws://127.0.0.1:{port}",
89+
)
90+
for _ in range(50):
91+
if pub.api_key:
92+
break
93+
await asyncio.sleep(0.05)
94+
first_key = pub.api_key
95+
assert first_key
96+
97+
# Stop the hub — publisher's WS will drop.
98+
hub.stop()
99+
await asyncio.sleep(0.3)
100+
101+
# Restart the hub on the same port + same db.
102+
hub2 = _HubThread(port, db_path)
103+
hub2.start()
104+
105+
# Wait long enough for the reconnect backoff to fire (initial 1s + jitter).
106+
for _ in range(50):
107+
if pub.api_key == first_key:
108+
# api_key still set — but now actually serving via hub2?
109+
# try a chat to confirm reconnection is alive
110+
pass
111+
await asyncio.sleep(0.2)
112+
113+
# Verify by sending a chat through the new hub — only succeeds if
114+
# publisher has reconnected and re-registered.
115+
from zhub import connect
116+
client = connect(
117+
ai_name="reconn", api_key=first_key,
118+
hub_url=f"ws://127.0.0.1:{port}",
119+
capabilities={},
120+
)
121+
await asyncio.sleep(0.5)
122+
123+
try:
124+
resp = await asyncio.wait_for(
125+
client.chat(messages=[{"role": "user", "content": "ping"}]),
126+
timeout=10.0,
127+
)
128+
assert resp.get("text") == "alive", \
129+
f"reconnected publisher didn't serve chat: {resp!r}"
130+
finally:
131+
hub2.stop()
132+
finally:
133+
try:
134+
os.unlink(db_path)
135+
except OSError:
136+
pass

zhub/client.py

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,18 +149,22 @@ def publish(
149149
pub._pending: dict[str, asyncio.Future] = {} # type: ignore[attr-defined]
150150
pub._ws = None # type: ignore[attr-defined]
151151

152-
async def runner() -> None:
153-
url = _to_ws_url(hub_url, "/ws/publish")
154-
log.info("publisher connecting to %s", url)
152+
async def _serve_one_session(url: str) -> None:
153+
"""Open one WebSocket session, register, serve until disconnect.
154+
On disconnect, returns normally (the outer loop reconnects)."""
155155
async with websockets.connect(url, max_size=10_000_000) as ws:
156156
pub._ws = ws # type: ignore[attr-defined]
157157
manifest_dict = manifest.to_dict()
158158
if private_key:
159159
from .signing import sign_manifest as _sign
160160
manifest_dict = _sign(manifest_dict, private_key)
161161
register_env = register_publisher(manifest_dict, name)
162-
if api_key:
163-
register_env.payload["api_key"] = api_key
162+
# Use the api_key from the prior registration if we already have
163+
# one (re-register on the same name via key pinning); fall back
164+
# to the operator-supplied api_key on first connect.
165+
register_key = pub.api_key or api_key
166+
if register_key:
167+
register_env.payload["api_key"] = register_key
164168
await ws.send(register_env.to_json())
165169

166170
async for raw in ws:
@@ -169,7 +173,11 @@ async def runner() -> None:
169173
if env.type == "registered":
170174
pub.name = env.payload.get("name", name)
171175
pub.base_url = env.payload.get("base_url", "")
172-
pub.api_key = env.payload.get("api_key", "")
176+
# Hub returns the same key on re-registration; only set
177+
# if non-empty so a transient empty value doesn't clear it.
178+
new_key = env.payload.get("api_key", "")
179+
if new_key:
180+
pub.api_key = new_key
173181
log.info("publisher registered as %s with key %s",
174182
pub.name, pub.api_key[:10] + "…")
175183

@@ -196,6 +204,32 @@ async def runner() -> None:
196204

197205
elif env.type == "error":
198206
log.warning("hub error: %s", env.payload)
207+
# Auth/registration failures are terminal — don't loop.
208+
if env.payload.get("code") == "register_failed":
209+
raise AuthError(env.payload.get("message", "register failed"))
210+
211+
async def runner() -> None:
212+
url = _to_ws_url(hub_url, "/ws/publish")
213+
backoff = 1.0
214+
while not pub._stop_event.is_set():
215+
try:
216+
log.info("publisher connecting to %s", url)
217+
await _serve_one_session(url)
218+
# Clean disconnect — reset backoff before retrying
219+
backoff = 1.0
220+
except AuthError:
221+
# Terminal — stop the loop
222+
log.error("publisher registration failed — stopping reconnect loop")
223+
return
224+
except Exception as e:
225+
log.warning("publisher session ended: %s — reconnecting in %.1fs", e, backoff)
226+
if pub._stop_event.is_set():
227+
return
228+
try:
229+
await asyncio.wait_for(pub._stop_event.wait(), timeout=backoff)
230+
except asyncio.TimeoutError:
231+
pass
232+
backoff = min(backoff * 2.0, 60.0)
199233

200234
pub._task = asyncio.ensure_future(runner())
201235
return pub
@@ -278,6 +312,7 @@ class ZhubConnection:
278312
_ws: Any = None
279313
_pending: dict[str, asyncio.Future] = field(default_factory=dict)
280314
_streams: dict[str, asyncio.Queue] = field(default_factory=dict)
315+
_stop_event: asyncio.Event = field(default_factory=asyncio.Event)
281316

282317
async def chat(self, messages: list[dict[str, Any]],
283318
model: str = "default", temperature: float = 0.4,
@@ -349,9 +384,7 @@ def connect(
349384
capabilities=handlers,
350385
)
351386

352-
async def runner() -> None:
353-
url = _to_ws_url(hub_url, "/ws/connect")
354-
log.info("client connecting to %s for AI %s", url, ai_name)
387+
async def _serve_one_session(url: str) -> None:
355388
async with websockets.connect(url, max_size=10_000_000) as ws:
356389
conn._ws = ws
357390
await ws.send(register_connection(ai_name, api_key, cm.to_dict()).to_json())
@@ -384,6 +417,27 @@ async def runner() -> None:
384417
if env.payload.get("code") == "register_failed":
385418
raise AuthError(env.payload.get("message", "register failed"))
386419

420+
async def runner() -> None:
421+
url = _to_ws_url(hub_url, "/ws/connect")
422+
backoff = 1.0
423+
while not conn._stop_event.is_set():
424+
try:
425+
log.info("client connecting to %s for AI %s", url, ai_name)
426+
await _serve_one_session(url)
427+
backoff = 1.0
428+
except AuthError:
429+
log.error("client registration failed — stopping reconnect loop")
430+
return
431+
except Exception as e:
432+
log.warning("client session ended: %s — reconnecting in %.1fs", e, backoff)
433+
if conn._stop_event.is_set():
434+
return
435+
try:
436+
await asyncio.wait_for(conn._stop_event.wait(), timeout=backoff)
437+
except asyncio.TimeoutError:
438+
pass
439+
backoff = min(backoff * 2.0, 60.0)
440+
387441
conn._task = asyncio.ensure_future(runner())
388442
return conn
389443

0 commit comments

Comments
 (0)