Skip to content

fix(ws): detect and clean up dead WebSocket connections via heartbeat - #3697

Open
aithal007 wants to merge 3 commits into
fosrl:mainfrom
aithal007:feat/ws-heartbeat-detection
Open

aithal007 wants to merge 3 commits into
fosrl:mainfrom
aithal007:feat/ws-heartbeat-detection

Conversation

@aithal007

Copy link
Copy Markdown

Community Contribution License Agreement

By creating this pull request, I grant the project maintainers an unlimited,
perpetual license to use, modify, and redistribute these contributions under any terms they
choose, including both the AGPLv3 and the Fossorial Commercial license terms. I
represent that I have the right to grant this license for all contributed content.

AI Disclosure

Claude Code (Anthropic) was used throughout this PR: to trace the gap, write the implementation and tests, and build/run the benchmarks and simulations below. I directed the investigation and verified the results myself, including catching a real flaw in the first draft of the integration test (see "How this was verified" below) before it shipped.

Description

Summary

server/routers/ws/ws.ts tracks every connected Newt/Olm client in an in-memory connectedClients map, keyed by client ID. The server only removes an entry when it receives a WebSocket close event - and it has no way to generate that event itself. There's no server-initiated liveness check anywhere in the file, and the underlying sockets have no TCP keepalive configured either (checked: keepAlive is set for the Postgres pool, Redis, and an AI-gateway HTTP client, but never for these WebSocket connections).

So when a connection dies without a clean close - WiFi drops, a laptop sleeps, a process is killed, a NAT/firewall silently stops forwarding packets - nothing ever tells the server. The stale entry sits in connectedClients forever: hasActiveConnections() keeps reporting true, sendToClient()/broadcastToAllExcept() keep "succeeding" into a socket that goes nowhere, and the connection count only ever grows.

Why this helps

This adds the heartbeat pattern the ws library's own documentation recommends for exactly this problem: ping every tracked connection on an interval, and terminate any connection that didn't answer the previous ping. A pong handler marks a connection alive again the moment a response comes back, so nothing is disconnected for being slow - only for being silent across a full interval. This bounds detection of a truly dead connection to 1-2 heartbeat cycles instead of never happening on its own, keeping connectedClients (and anything that reasons about it) accurate.

What changed

  • server/routers/ws/heartbeat.ts (new) - sweepConnection()/sweepAllConnections(). Deliberately takes a minimal duck-typed { isAlive, ping(), terminate() } shape rather than importing ws's WebSocket type or anything from @server/db, so the logic is unit-testable without a live socket or the database.
  • server/routers/ws/ws.ts - a 30s setInterval sweeping every tracked connection via sweepAllConnections(); ws.isAlive = true and a pong handler added in setupConnection(); the interval is cleared in the existing cleanup() function on graceful shutdown. sweepConnection's terminate() call fires the existing close handler, so cleanup reuses the removeClient() path that already exists - no new cleanup code needed.
  • server/routers/ws/types.ts - added isAlive?: boolean to AuthenticatedWebSocket.
  • server/routers/ws/heartbeat.test.ts (new) - 12 assertions against fake connections: fresh connections survive their first sweep, responsive connections survive indefinitely, unresponsive ones are terminated on exactly their 2nd missed cycle (not 1st, not 3rd+).
  • server/routers/ws/heartbeat.integration.test.ts (new) - see below.

Scope note: server/private/routers/ws/ws.ts is explicitly proprietary (Fossorial Commercial License, not AGPL) and is intentionally not touched here.

How this was verified (and a mistake I caught before shipping)

A unit test against fake objects proves the sweep logic is correct, but not that it behaves correctly against a real socket. So I added a second test, heartbeat.integration.test.ts, that spins up an actual ws.WebSocketServer and connects a real WebSocket client over a real TCP socket on localhost, then drives the actual sweepAllConnections() this PR ships against it.

My first attempt at "kill the connection" used client._socket.destroy(), which felt like the obvious way to simulate a dropped connection. Testing it by hand first (see comments in the test file) showed it does not simulate the bug this PR fixes: on localhost, destroy() sends an immediate TCP RST, which the server's existing close handler already receives and reacts to (close code 1006) - that path was never broken. The actual bug only happens when nothing reaches the server at all: no RST, no FIN, no error. I reproduced that correctly with client._socket.pause(), which stops the client from processing incoming frames without tearing down the TCP connection - confirmed by hand this produces zero close, error, or pong events on the server, a genuine silent black hole. Only the heartbeat sweep can detect that; the integration test proves it does, within exactly 2 cycles, while also proving a live, responsive connection is never mistakenly dropped across repeated sweeps.

Impact & metrics

Before vs. after (simulated: 200 connections, 30% go silent without a clean close):

connectedClients.size
Before (no heartbeat) stuck at 200, indefinitely
After - cycle 1 (pinged, marked unresponsive) 200
After - cycle 2 (terminated & removed) 140

Matches the math exactly (60 dead / 200 = 30%). Detection is bounded to 2 heartbeat cycles - ~60-90s at the 30s interval used here - instead of unbounded.

Overhead of the sweep itself (runs once per 30s interval, not per-request; each row measured in its own fresh process with 3,000 warm-up iterations, to avoid an earlier tier's JIT warm-up making a later one look artificially fast):

Connections Cost per sweep
50 0.20 µs
500 0.6-0.8 µs
5,000 (10-100x Pangolin's realistic self-hosted scale) 5.7-8.7 µs

Negligible at any plausible scale, and it's a periodic background sweep, not something that runs per-message or per-request.

Memory overhead. Measured directly (heap delta, forced GC, 5,000 simulated connections, 3 repeated runs) rather than estimated: the isAlive boolean plus the pong listener closure setupConnection() registers together cost ~137 bytes/connection, consistently. The listener closure - not the boolean - is the dominant cost. At 1,000 connections that's ~137 KB; at 5,000, ~0.65 MB. Trivial at any scale Pangolin realistically runs at.

Graceful shutdown. The heartbeat's setInterval handle is cleared in the existing cleanup() function, which is wired to both SIGTERM and SIGINT via server/cleanup.ts. It's cleared before the existing socket-termination loop runs, specifically to close a race: without that ordering, the interval could fire mid-shutdown and call .ping()/.terminate() on a socket that's already being torn down. (Note: server/cleanup.ts calls process.exit(0) explicitly once cleanup finishes, so an uncleared timer wouldn't have blocked process exit either way here - the real reason to clear it first is that shutdown ordering, not event-loop lifetime.)

How to test?

  1. npx tsx server/routers/ws/heartbeat.test.ts - unit tests against fake connections.
  2. npx tsx server/routers/ws/heartbeat.integration.test.ts - integration test against a real ws server/client pair; simulates a silently dropped connection and a healthy one, asserts both are handled correctly.
  3. npx tsc --noEmit and npx eslint . --ext .js,.jsx,.ts,.tsx - both clean.
  4. Manually: connect a Newt/Olm client, then simulate a silent drop (e.g. suspend the client process, or block its outbound traffic at the firewall without closing the socket) and confirm the server's log shows the connection being cleaned up (Client disconnected...) within ~60s instead of the entry lingering.

Parikshith and others added 2 commits September 4, 2026 15:19
connectedClients tracks Newt/Olm WebSocket connections with no
server-initiated liveness check and no TCP keepalive configured on the
sockets. A connection that dies without a clean close (network drop,
sleep, killed process, silent NAT/firewall drop) stays in
connectedClients indefinitely - sendToClient/broadcastToAllExcept keep
"succeeding" into a socket that goes nowhere, and hasActiveConnections()
never notices.

Adds the standard ws-library heartbeat pattern: ping every tracked
connection every 30s, terminate any connection that didn't answer the
previous ping. Bounds detection of a dead connection to 1-2 heartbeat
intervals instead of never happening on its own. The sweep logic lives
in a dependency-free heartbeat.ts so it's unit-testable against fake
connections without a live socket or the database.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
heartbeat.test.ts only exercises sweepConnection/sweepAllConnections
against plain fake objects. This adds a real ws.WebSocketServer +
real WebSocket client integration test that drives the actual shipped
sweepAllConnections() against genuine TCP sockets.

Verified by hand first: the obvious way to "kill" a connection in a
test, client._socket.destroy(), does NOT simulate the bug this PR
fixes - on localhost it sends an immediate TCP RST that the server's
pre-existing `close` handler already reacts to (code 1006). The actual
bug is a connection that goes silent with no close/error/RST ever
reaching the server (WiFi vanishing, laptop sleep, a NAT/firewall
black-holing packets). That's simulated correctly here with
client._socket.pause(), confirmed by hand to produce no close, error,
or pong on the server - a true black hole that only the heartbeat
sweep can detect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 4, 2026 11:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The heartbeat sweep currently lacks resilience against exceptions during ping()/terminate(), which can break the periodic sweep or crash the process if an error occurs.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a heartbeat-based liveness sweep for WebSocket connections to ensure silently-dead sockets are detected and cleaned up, keeping connectedClients accurate and preventing unbounded growth from connections that never emit a close event.

Changes:

  • Introduces a heartbeat sweep module (sweepConnection / sweepAllConnections) and integrates it into the WS router on a 30s interval.
  • Adds isAlive tracking + pong handling to mark connections responsive between sweeps.
  • Adds unit + integration tests validating termination behavior on real ws sockets and fake connections.
File summaries
File Description
server/routers/ws/ws.ts Starts a periodic sweep over tracked sockets; marks connections alive on pong; clears interval on shutdown.
server/routers/ws/types.ts Extends AuthenticatedWebSocket with an optional isAlive flag.
server/routers/ws/heartbeat.ts Implements the minimal, testable heartbeat sweep logic.
server/routers/ws/heartbeat.test.ts Unit tests for sweep logic using fake connections.
server/routers/ws/heartbeat.integration.test.ts Integration test proving the sweep works against real ws server/client sockets.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +26 to +33
export function sweepConnection(connection: HeartbeatConnection): void {
if (connection.isAlive === false) {
connection.terminate();
return;
}
connection.isAlive = false;
connection.ping();
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — fixed in 09b58fa.

For the exception safety: I verified against ws's own source that ping() throws synchronously when readyState is CONNECTING. Since this runs from a bare setInterval and this app has a global uncaughtException handler that calls process.exit(1), an uncaught throw here would've crashed the whole server for every connected user, not just missed detecting one bad connection. sweepConnection() now catches that and falls back to terminating the connection as dead, with a nested fallback in case terminate() itself ever fails too.

Comment thread server/routers/ws/ws.ts
Comment on lines +67 to +69
heartbeatTimer = setInterval(() => {
sweepAllConnections(Array.from(connectedClients.values()).flat());
}, HEARTBEAT_INTERVAL_MS);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 09b58fa — switched to exactly this: connectedClients.forEach((clients) => sweepAllConnections(clients)) instead of building the flattened array copy every tick. That's also why the autofix shows as outdated now — the code already matches your suggestion.

Addresses two Copilot review findings on fosrl#3697:

- sweepConnection() called ping()/terminate() with no error handling.
  Verified against ws's own source: ping() throws synchronously when
  readyState is CONNECTING. This runs from a bare setInterval with no
  surrounding try/catch, and this app's global uncaughtException
  handler (server/logger.ts) calls process.exit(1) - so one bad
  connection could crash the entire server for every connected user,
  not just fail to detect itself. Now caught and treated as dead.

- The heartbeat interval rebuilt a flattened array of every tracked
  connection on every tick (Array.from(...).flat()). sweepAllConnections
  already accepts any iterable, so sweeping each client array directly
  removes that allocation entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

2 participants