Repository navigation
fix: heartbeat liveness on the client, socket cleanup on the server - #27
Merged
Merged
Conversation
The control loop read with :infinity timeout, so a connection that died
without a FIN/RST reaching the client (NAT/conntrack flush, silent host
loss) looked perfectly healthy and blocked recv forever. Neither the eof
nor the error clause that trigger reconnect/3 could ever fire, making
--retry a no-op in exactly the scenario it exists for; observed live on
2026-08-31 with client sidecars stuck for hours until a pod restart.
Bound reads by a heartbeat timeout (Application env
:heartbeat_timeout_ms, default 5000ms — the server heartbeats every
500ms, so ten missed beats). On timeout the socket is closed (no FIN is
coming) and the loop reconnects with --retry, or returns
{:error, :heartbeat_timeout} without it.
Data-plane connections are untouched: they are short-lived and
heartbeatless by design.
Co-Authored-By: Claude <noreply@anthropic.com>
handle_connection/4 returned :ok on its failure paths — eof, handshake
read timeout, decode errors, unexpected messages, refused Hellos —
without closing the socket, leaving cleanup to port GC. Under a storm of
doomed connections (clients retrying against a server that dropped their
tunnel) that piled sockets up in CLOSE-WAIT and leaked FDs: 3844 open on
2026-08-31, after a day of repeated {:error, :emfile} from accept, with
peers stuck holding unread hellos in Recv-Q.
Guarantee :gen_tcp.close(socket) on every exit path (normal return,
raise, exit) via a single cleanup point in an after block, and remove
the now-redundant inline closes on the auth-failure paths. The Accept
path's pipe_bidirectional/4 already closes the control socket; closing
again is a no-op. handle_accept_connection/2 gets the same guarantee.
Tests assert the peer sees a FIN (recv -> {:error, :closed}) where it
previously timed out against a socket abandoned to GC, plus regression
guards for the healthy session (port assignment, heartbeats, listener
release) and the end-to-end Accept pipe.
Co-Authored-By: Claude <noreply@anthropic.com>
The accept loop matched {:ok, socket} = accept_conn(...), so a single
failed accept — {:error, :emfile} while the process sat at its FD limit,
which the same server logged repeatedly from 00:07 to 18:41 on
2026-08-31 — crashed the whole server process. Log, wait a second and
retry instead, so the server rides out the window.
Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two production bugs found during the 2026-08-31 incident, both fixes protocol-coupled and included here. No wire-protocol changes — the frame layer (
Spore.Shared.Delimited) is untouched, so Rust bore interop is preserved.Root cause
1. Client:
--retrywas a no-op for half-open connectionsSpore.Client.loop/4read the control connection withDelimited.recv(d), i.e. a:infinitytimeout. The control plane only ever receives "Heartbeat" frames (every 500ms). When the connection dies without a FIN/RST reaching the client — NAT/conntrack flush, silent host loss — the socket looks perfectly healthy,recvblocks forever, and neither the:eofnor the error clause that triggersreconnect/3can ever fire.Fix: bound every control-loop read by a heartbeat timeout — application env
:heartbeat_timeout_ms, default5000ms (the server beats every 500 ms, so the default tolerates ten missed beats). On read timeout the socket is closed (no FIN is coming) and the loop goes throughreconnect/3when retry is enabled, or returns{:error, :heartbeat_timeout}when it is not. Data-plane connections are untouched (short-lived, heartbeatless by design).2. Server: control sockets leaked to GC on handler failure paths
Spore.Server.handle_connection/4returned:okon its failure paths — eof, handshake read timeout, decode error, unexpected message, refused Hello — without closing the socket, leaving cleanup to port GC. Under a storm of doomed connections (clients retrying against a server that had just dropped their tunnels) this piles sockets up in CLOSE-WAIT and leaks FDs.Fix: a single cleanup point —
:gen_tcp.close(socket)in anafterblock — guarantees the close on every exit path (normal return, raise,exit/1). Redundant inline closes on the auth-failure paths were removed.handle_accept_connection/2gets the same guarantee. Closing again afterShared.pipe_bidirectionalalready closed the socket is a verified no-op.3. Hardening: accept loop survives EMFILE
{:ok, socket} = accept_conn(...)meant one{:error, :emfile}crashed the whole accept loop. Now it logs, waits 1 s and retries.Evidence summary (production, v0.2.8, 2026-08-31)
"removed stale connection"x2 →"client control connection lost"for both tunnels in the same second (correlated client-side NAT/conntrack flush) → server kept accepting on :7835 but"new client on port"never fired again;ss -tnshowed the peers stuck in CLOSE-WAIT with Recv-Q 1 (unread hellos); process FD count 3844 after a day of repeated{:error, :emfile}from accept.--retryon): control TCP stayed ESTABLISHED (zombie half-open against a server that had moved on), no reconnect attempts, no logs for hours; only a pod restart recovered them.What changed
lib/spore/client.ex— heartbeat-bounded reads in the control loop,:heartbeat_timeout_msenv (default 5000), close + reconnect/return on timeout.lib/spore/server.ex— guaranteed control-socket close on every handler exit path; accept-loop retry on transient accept errors.Test plan
TDD: the new tests were written first and verified red against the unfixed code (
{:error, :timeout}from recv = leaked socket; no reconnect / no return on the client side), then green after the fixes. All use real:gen_tcpsockets over loopback — no transport mocks.test/spore/client_heartbeat_test.exs— fake server answers Hello then goes silent (the half-open state): with retry, the client reconnects (a fresh handshake is observed); without retry,listen/1returns{:error, :heartbeat_timeout}instead of hanging.test/spore/server_test.exs— garbage first message / total silence / refused out-of-range Hello / a run of unknown messages: the peer must see a FIN (recv→{:error, :closed}) instead of timing out against a GC-abandoned socket. Regression guards: healthy session still gets a tunnel port, heartbeats and listener release; end-to-end Accept pipe still forwards and tears down.mix test: 138 passed (132 → 138).mix format --check-formatted: clean. Wire protocol unchanged (no new frames;Delimiteduntouched).🤖 Generated with Claude Code