Skip to content

fix: enable WebSocket ping heartbeat to detect dead connections#6

Merged
Go1c merged 1 commit intomainfrom
fix/websocket-heartbeat
Apr 14, 2026
Merged

fix: enable WebSocket ping heartbeat to detect dead connections#6
Go1c merged 1 commit intomainfrom
fix/websocket-heartbeat

Conversation

@Go1c
Copy link
Copy Markdown
Owner

@Go1c Go1c commented Apr 14, 2026

Problem

heartbeat_interval: 30 was defined in config but never used. Both ping_interval and ping_timeout were set to None, disabling all keepalive. When the WebSocket connection silently died, the async for raw in self.ws receive loop hung indefinitely — no error, no reconnect, no incoming events.

Confirmed in logs: connection established at 08:23:28, last activity at 08:45:08, then complete silence. Files created on the server after that point were never received by the CLI.

Fix

Pass heartbeat_interval from config to ping_interval and ping_timeout on the WebSocket connection. The websockets library sends a ping every 30s and expects a pong within 30s. A missed pong raises ConnectionClosed, which exits _listen(), triggers the reconnect loop, and _on_reconnect runs an incremental sync to catch up on missed events.

Test plan

  • Start run, let it idle for >30s, confirm ping/pong in debug logs
  • Kill network briefly, confirm CLI detects disconnect and reconnects
  • Create a note on server while CLI is reconnecting, confirm it arrives after reconnect

Summary by Sourcery

Bug Fixes:

  • Use the configured heartbeat interval for WebSocket ping and timeout settings so dead connections are detected instead of hanging indefinitely.

heartbeat_interval was defined in config (default 30s) but never
used — ping_interval/ping_timeout were both None, so silently dead
connections were never detected and the receive loop hung indefinitely.

Now passes heartbeat_interval to websockets ping_interval and
ping_timeout. A missed pong raises ConnectionClosed, exits _listen(),
and triggers the reconnect loop which re-syncs any missed events.
@Go1c Go1c merged commit 5944642 into main Apr 14, 2026
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai bot commented Apr 14, 2026

Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

This PR enables WebSocket heartbeat-based keepalive by wiring the existing heartbeat_interval configuration into the websockets.connect call, so dead connections are detected and the existing reconnect logic can run.

Updated class diagram for WebSocket client heartbeat configuration

classDiagram
    class CLIClient {
        Config config
        WebSocket ws
        _connect() void
        _listen() void
        _on_reconnect() void
    }

    class Config {
        ClientConfig client
    }

    class ClientConfig {
        int heartbeat_interval
    }

    class WebSocket {
        connect(url, max_size, ping_interval, ping_timeout, close_timeout) WebSocket
    }

    CLIClient --> Config : uses
    Config --> ClientConfig : has
    CLIClient --> WebSocket : establishes

    %% Detail of _connect behavior
    class CLIClient_connect {
        _connect() void
    }

    CLIClient ..> CLIClient_connect : behavior

    CLIClient_connect : interval = config.client.heartbeat_interval
    CLIClient_connect : ws = websockets.connect(
    CLIClient_connect :   url,
    CLIClient_connect :   max_size=128MB,
    CLIClient_connect :   ping_interval=interval,
    CLIClient_connect :   ping_timeout=interval,
    CLIClient_connect :   close_timeout=10
    CLIClient_connect : )
Loading

File-Level Changes

Change Details Files
Enable WebSocket ping/pong heartbeat using configured heartbeat_interval to detect dead connections and trigger reconnects.
  • Read heartbeat_interval from the client configuration before establishing the WebSocket connection.
  • Pass the heartbeat interval as ping_interval to websockets.connect so the client sends periodic pings.
  • Pass the same heartbeat interval as ping_timeout so missing pongs cause the WebSocket to raise ConnectionClosed instead of hanging indefinitely.
fns_cli/client.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@Go1c Go1c deleted the fix/websocket-heartbeat branch April 14, 2026 08:58
Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • Consider handling edge cases for heartbeat_interval (e.g., None or very small/zero values) explicitly before passing it to websockets.connect, so misconfiguration doesn't lead to confusing runtime errors or tight ping loops.
  • You might want to allow configuring ping_interval and ping_timeout separately rather than tying both to heartbeat_interval, in case future tuning requires a shorter timeout than interval or vice versa.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider handling edge cases for `heartbeat_interval` (e.g., `None` or very small/zero values) explicitly before passing it to `websockets.connect`, so misconfiguration doesn't lead to confusing runtime errors or tight ping loops.
- You might want to allow configuring `ping_interval` and `ping_timeout` separately rather than tying both to `heartbeat_interval`, in case future tuning requires a shorter timeout than interval or vice versa.

## Individual Comments

### Comment 1
<location path="fns_cli/client.py" line_range="124" />
<code_context>
         )
         log.info("Connecting to %s", url)

+        interval = self.config.client.heartbeat_interval
         self.ws = await websockets.connect(
             url,
</code_context>
<issue_to_address>
**issue (bug_risk):** Validate/normalize `heartbeat_interval` before passing it to `websockets.connect`.

Because `ping_interval=None` previously disabled ping/pong, passing `self.config.client.heartbeat_interval` directly means invalid values (e.g. `0` or negative) could now cause `websockets.connect` to raise or act unexpectedly. Consider normalizing here (e.g., map `<= 0` to `None` or enforce a minimum positive value) so behavior is robust against misconfigured heartbeat values.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread fns_cli/client.py
)
log.info("Connecting to %s", url)

interval = self.config.client.heartbeat_interval
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Validate/normalize heartbeat_interval before passing it to websockets.connect.

Because ping_interval=None previously disabled ping/pong, passing self.config.client.heartbeat_interval directly means invalid values (e.g. 0 or negative) could now cause websockets.connect to raise or act unexpectedly. Consider normalizing here (e.g., map <= 0 to None or enforce a minimum positive value) so behavior is robust against misconfigured heartbeat values.

Go1c added a commit that referenced this pull request Apr 14, 2026
… timeout

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.
Go1c added a commit that referenced this pull request Apr 14, 2026
… timeout (#8)

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant