From 22fb777a071df7d2701b8aab08c5bf746dfdb4de Mon Sep 17 00:00:00 2001 From: Go1c <1c@live.cn> Date: Tue, 14 Apr 2026 17:51:54 +0800 Subject: [PATCH] fix: replace WebSocket ping with inactivity watchdog; extend NoteSync timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #6 used ping_interval=30 but the server does not respond to WebSocket ping frames — confirmed by real-environment test: connection dropped every 30s with "keepalive ping timeout". Reverted to ping_interval=None. Instead, an asyncio background task (_inactivity_watchdog) tracks the last received message time and closes the connection if no data arrives for 2 × heartbeat_interval (60s default), triggering the reconnect loop. _last_received_at is updated on every text or binary frame received. Also extended the NoteSync timeout in _initial_sync from 60s to 300s. The server sends NoteSyncEnd immediately but may delay actual note delivery by over a minute on full syncs — the 60s window was too short and caused NoteSync to abort before any notes were written. Verified with real-server pull: 24 notes (including Test-456/123.md, 456.md, 789.md) fully synced, no ping disconnects, no echo push-backs. --- fns_cli/client.py | 39 +++++++++++++++++++++++++++++++-------- fns_cli/sync_engine.py | 2 +- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/fns_cli/client.py b/fns_cli/client.py index ac39a9b..7d1eb65 100644 --- a/fns_cli/client.py +++ b/fns_cli/client.py @@ -4,6 +4,7 @@ import asyncio import logging +import time from typing import Any, Callable, Coroutine import websockets @@ -36,6 +37,7 @@ def __init__(self, config: AppConfig) -> None: self._on_reconnect: Callable[[], Coroutine] | None = None self._msg_queue: list[str | bytes] = [] self._ready_event = asyncio.Event() + self._last_received_at: float = 0.0 def on_reconnect(self, handler: Callable[[], Coroutine]) -> None: self._on_reconnect = handler @@ -121,14 +123,14 @@ async def _connect(self) -> None: ) log.info("Connecting to %s", url) - interval = self.config.client.heartbeat_interval self.ws = await websockets.connect( url, max_size=128 * 1024 * 1024, - ping_interval=interval, - ping_timeout=interval, + ping_interval=None, + ping_timeout=None, close_timeout=10, ) + self._last_received_at = time.monotonic() log.info("WebSocket connected, sending auth") auth_raw = f"{ACTION_AUTHORIZATION}{SEPARATOR}{self.config.server.token}" @@ -136,13 +138,33 @@ async def _connect(self) -> None: async def _listen(self) -> None: assert self.ws is not None - async for raw in self.ws: - if isinstance(raw, bytes): - await self._handle_binary(raw) - else: - await self._handle_text(raw) + watchdog = asyncio.create_task(self._inactivity_watchdog()) + try: + async for raw in self.ws: + if isinstance(raw, bytes): + await self._handle_binary(raw) + else: + await self._handle_text(raw) + finally: + watchdog.cancel() + + async def _inactivity_watchdog(self) -> None: + """Close the connection if no message is received for 2 × heartbeat_interval.""" + interval = self.config.client.heartbeat_interval + deadline = interval * 2 + while True: + await asyncio.sleep(interval) + idle = time.monotonic() - self._last_received_at + if idle >= deadline: + log.warning( + "No data received for %.0fs — closing for reconnect", idle + ) + if self.ws: + await self.ws.close() + return async def _handle_text(self, raw: str) -> None: + self._last_received_at = time.monotonic() msg = decode_message(raw) log.debug("← %s | %s", msg.action, str(msg.data)[:200]) @@ -160,6 +182,7 @@ async def _handle_text(self, raw: str) -> None: log.debug("Unhandled action: %s", msg.action) async def _handle_binary(self, raw: bytes) -> None: + self._last_received_at = time.monotonic() if self._binary_handler and len(raw) > 42 and raw[:2] == b"00": try: sid, idx, data = parse_binary_chunk(raw[2:]) diff --git a/fns_cli/sync_engine.py b/fns_cli/sync_engine.py index 8511013..4ad4ef7 100644 --- a/fns_cli/sync_engine.py +++ b/fns_cli/sync_engine.py @@ -208,7 +208,7 @@ async def push(self) -> None: async def _initial_sync(self) -> None: if self.config.sync.sync_notes: await self.note_sync.request_sync() - await self._wait_note_sync(timeout=60) + await self._wait_note_sync(timeout=300) if self.config.sync.sync_files or self.config.sync.sync_config: await self.file_sync.request_sync()