feat: 连接寿命到点前主动重连(服务端约 1 小时会无预警断开)

实测 QQ 服务端在一个连接建立约 1 小时后会自己断开。此前只能等它断:
要么被看门狗判成静默超时、要么等读写失败,都算"异常断开",观感与恢复
路径都不理想。现改为临到期前主动收尾重连——是正常断开 + RESUME 续传,
期间消息由网关补发。

实现:receive_helper 记录寿命截止点(_SERVER_LIFETIME - _RECONNECT_MARGIN,
默认 60min - 10min = 50min),收帧超时取"静默阈值"与"寿命余量"两个截止里
的先到者——静默过久仍抛 TimeoutError 交给看门狗,寿命到点则 return 正常
结束本轮连接,run() 见存活超过 _STABLE_SECONDS 便立刻重连(沿用 b37a53f
的"健康断开零等待"路径),RESUME 带上 session_id 续传。

_connect 的存活秒数天然 >= 50min > _STABLE_SECONDS,主动重连必被判定为
健康断开,无需额外标志位。

测试:新增 test_proactive_reconnect_before_server_lifetime(真实 ws 端到端,
把寿命阈值调到 0.5s 让这一刻立即发生);已验证把寿命阈值关掉后该测试会
超时失败,不是空转到通过。另给 tests/test_reconnect.py 补 CHANGELOG 条目
(b37a53f 漏记),并同步 connecter 模块 docstring / ARCHITECTURE / GUIDE。

90 测试全绿,ruff check/format + pyright 干净。
This commit is contained in:
snowy-yang committed 2026-09-21 18:28:53 +08:00
1 parent b37a53f6e0
commit 13a1d971be
6 files changed
+100 -23

No files matched your search

+1 -3
View File
@@ -64,9 +64,7 @@ async def test_failed_reconnect_backs_off_and_logs_disconnect(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""连不上时记断开并按退避等待,退避逐次翻倍。"""
sleeps, logs = await _run_capture(
monkeypatch, [3600.0, TimeoutError(), 0.0, 0.0]
)
sleeps, logs = await _run_capture(monkeypatch, [3600.0, TimeoutError(), 0.0, 0.0])
# 健康断开 → 无 sleep;失败后依次 1s、2s、4s
assert sleeps == [1, 2, 4]
+71 -12
View File
@@ -25,11 +25,15 @@ class FakeGateway:
def __init__(self) -> None:
self.identifies: list[dict] = []
self.heartbeats = 0
self.resumes: list[dict] = []
self.connections = 0
self.runner: web.AppRunner | None = None
self.port = 0
# 测试用同步点
self.ready_sent = asyncio.Event()
self.got_dispatch = asyncio.Event()
self.second_connection = asyncio.Event()
self.resume_received = asyncio.Event()
async def start(self) -> None:
app = web.Application()
@@ -56,6 +60,9 @@ class FakeGateway:
async def _ws(self, request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse()
await ws.prepare(request)
self.connections += 1
if self.connections >= 2:
self.second_connection.set()
await ws.send_json({"op": 10, "d": {"heartbeat_interval": 300}})
s = 0
@@ -87,29 +94,49 @@ class FakeGateway:
"d": {"content": "ping", "group_openid": "G1"},
}
)
elif op == 6: # RESUME
self.resumes.append(payload)
self.resume_received.set()
elif op == 1: # HEARTBEAT
self.heartbeats += 1
await ws.send_json({"op": 11})
return ws
def make_config(gw: FakeGateway) -> Config:
return Config(
app_id="app",
app_secret="secret",
base_url=f"http://127.0.0.1:{gw.port}",
timeout=None, # type: ignore[arg-type]
connecter="websocket",
intents_file="no-such-intents.toml", # 测试环境无配置,intents=0
webhook_host="127.0.0.1",
webhook_port=0,
webhook_path="/x",
)
def build_connecter(
config: Config,
) -> tuple[ClientSession, EventEmitter, WebsocketConnecter]:
http = ClientSession(timeout=config.timeout)
token = AccessToken(config, http)
session = Session()
emitter = EventEmitter(EventQueue())
protocol = WebsocketProtocol(config, session, token)
connecter = WebsocketConnecter(
config, http, token, session, emitter.queue, protocol
)
return http, emitter, connecter
@pytest.mark.asyncio
async def test_websocket_end_to_end():
gw = FakeGateway()
await gw.start()
try:
config = Config(
app_id="app",
app_secret="secret",
base_url=f"http://127.0.0.1:{gw.port}",
timeout=None, # type: ignore[arg-type]
connecter="websocket",
intents_file="no-such-intents.toml", # 测试环境无配置,intents=0
webhook_host="127.0.0.1",
webhook_port=0,
webhook_path="/x",
)
config = make_config(gw)
http = ClientSession(timeout=config.timeout)
token = AccessToken(config, http)
queue = EventQueue()
@@ -165,3 +192,35 @@ async def test_websocket_end_to_end():
await http.close()
finally:
await gw.stop()
@pytest.mark.asyncio
async def test_proactive_reconnect_before_server_lifetime():
"""服务端约 1 小时后会无预警掐断连接:应在临到期前主动重连。
把寿命阈值压到极短让这一刻在测试里立刻发生。第二次连接必须是 RESUME
——说明是正常收尾后按会话续传(服务端补发期间消息),而不是被掐断后
从头 IDENTIFY。
"""
gw = FakeGateway()
await gw.start()
try:
config = make_config(gw)
http, _emitter, connecter = build_connecter(config)
# 0.5s 寿命 - 0.3s 余量 = 连接存活约 0.2s 后主动重连;"稳定"阈值一并调小,
# 让这次主动重连被判定为正常收尾(立即重连,不退避)
connecter._SERVER_LIFETIME = 0.5
connecter._RECONNECT_MARGIN = 0.3
connecter._STABLE_SECONDS = 0.1
connect_task = asyncio.create_task(connecter.run())
try:
await asyncio.wait_for(gw.resume_received.wait(), timeout=10)
assert gw.connections >= 2 # 确实重连过
assert len(gw.identifies) == 1 # 只 IDENTIFY 过一次
assert gw.resumes[0]["d"]["session_id"] == "S1" # 续传同一会话
finally:
connect_task.cancel()
await asyncio.gather(connect_task, return_exceptions=True)
await http.close()
finally:
await gw.stop()