Skip to content

fix: bound client writes so a wedged peer cannot stall the server; 0.11.1:17 → 0.11.1:18 - #82

Merged
MattDHill merged 5 commits into
Start9-Community:masterfrom
stupleb:fix/electrum-client-write-timeout
Aug 7, 2026
Merged

fix: bound client writes so a wedged peer cannot stall the server; 0.11.1:17 → 0.11.1:18#82
MattDHill merged 5 commits into
Start9-Community:masterfrom
stupleb:fix/electrum-client-write-timeout

Conversation

@stupleb

@stupleb stupleb commented Aug 6, 2026

Copy link
Copy Markdown

One Electrum client that stops draining its socket freezes the whole server —
no wallet served, no block indexed — until the kernel gives up on it. Observed
in the field from 19 minutes to 8h39m, on two unrelated servers.

The defect

Peer::send writes responses with a blocking write_all, and no socket in the
electrs tree sets a timeout — set_write_timeout/SO_SNDTIMEO appears nowhere,
in v0.11.1 or in master. That write happens in handle_peer_events, reached
from handle_events, which is a plain sequential for loop running inline on
the single serve() loop — the same loop that calls rpc.sync().

So a client whose receive window closes and stays closed — gone without a FIN,
suspended, or behind a stalled proxy — holds that loop for as long as the kernel
keeps retransmitting. Indexing stops with it, and the process is too wedged to
answer SIGTERM, so a stop falls through to SIGKILL after the grace period.

It self-heals only when the kernel finally errors the socket, at which point the
existing path logs disconnecting due to failed to send response and drops that
peer. That signature is the fingerprint: every long stall ends in a burst of
those, immediately followed by a catch-up batch of every block missed.

Field evidence

Two unrelated servers, different architectures, one on :16 and one on :17:

Server Stall Recovery
x86_64 8h 39m 17 peers dropped, then indexing 58 blocks
x86_64 3h 32m 12 peers dropped, then indexing 27 blocks
x86_64 1h 55m 5 peers dropped, then indexing 8 blocks
x86_64 1h 43m 3 peers dropped, then indexing 15 blocks
aarch64 19m 22s ended by a manual restart; SIGTERM → SIGKILL at 60s

During the 3h32m stall bitcoind accepted 28 blocks and electrs indexed none —
not one log line for the whole window. Short dips (under ~40 min) carry no
disconnect burst; those are the ordinary indexing blips :17's retry addressed.

Both users read the frozen server as a stuck resync. One reinstalled to fix it,
destroying a 62 GB index and triggering a 23-hour rebuild that was never needed.
The other restarted by hand and reported that Electrs "doesn't come back on its
own" after a power cut — it had come back, reached Fully synced in 52 seconds,
and wedged a minute later.

The fix

Set a 60s SO_SNDTIMEO on each accepted socket in accept_loop, and let the
error path that already exists drop just that peer.

The timeout bounds one write syscall, not a whole response, so a client that
keeps draining resets it on every partial write — a slow link cannot trip it
however large the response. What trips it is a receive window that stays shut
for the full minute. try_clone dups the fd and the option lives on the socket,
so setting it at accept also covers the clone handed to Peer.

Carried as a build-time patch rather than a submodule bump because there is no
upstream fix to bump to: v0.11.1 is the newest tag, and master sets no socket
timeout either. patches/README.md records the retirement condition. The
Dockerfile applies patches/*.patch with --fuzz=0, so a submodule bump that
moves the context fails the build rather than applying somewhere subtly wrong.

Blast radius

  • Only Electrum client sockets. accept_loop serves electrum_rpc_addr and
    nothing else. bitcoind p2p is an outbound TcpStream::connect in p2p.rs;
    bitcoind RPC and the Prometheus endpoint are separate. None are touched.
  • SO_SNDTIMEO affects sends only, so recv_loop's reads are unchanged.
  • No index impact. No RocksDB or schema change, migrations: {}, upstream
    still 0.11.1 — nobody reindexes.
  • Partial-write-then-disconnect is not a new failure mode. It is the existing
    path, which fired 42 times in one user's log; this reaches it sooner. A
    truncated line carries no newline, so clients discard rather than misparse it.
  • On a server with no stalling client the timeout never fires and behaviour
    is identical to :17.

The one new outcome: a client that is alive but has stopped reading for a full
minute now gets disconnected where before it did not. It reconnects — Electrum
clients handle this routinely. The trade is that client losing its own
connection instead of freezing the server for everyone.

Verification

  • docker build --target builder — exit 0, Finished release profile in 16m 43s
  • compiled source in the image confirmed to carry the change
  • tsc --noEmit exit 0; prettier exit 0, no formatting drift
  • submodule left pristine at 35216c6; the patch is a build-time delta only

Not verified: that this resolves the stall on a live server — a wedged client
can't be manufactured locally. Worth confirming on one of the affected boxes
before release. The 60s value is a judgement call, not a measured one: ~19×
shorter than the shortest observed stall, and it takes a full minute of zero
bytes accepted to trip.

Follow-up

Every electrs user has this, not just StartOS. Worth an upstream issue at
romanz/electrs with the same evidence.

🤖 Generated with Claude Code

stupleb and others added 3 commits August 6, 2026 19:55
…11.1:17 → 0.11.1:18

`Peer::send` writes responses with a blocking `write_all`, and no socket in
the electrs tree sets a timeout — `SO_SNDTIMEO` appears nowhere, in v0.11.1
or in master. That write runs in `handle_peer_events`, reached from
`handle_events`, a plain sequential loop inline on the single `serve()`
loop — the same loop that calls `rpc.sync()`.

So one client whose receive window closes and stays closed — gone without a
FIN, suspended, or behind a stalled proxy — holds that loop for as long as
the kernel keeps retransmitting. No wallet is served and no block is indexed
until it gives up, and the process is too wedged to answer SIGTERM, so a stop
falls through to SIGKILL after the grace period. It self-heals only when the
kernel errors the socket, at which point the existing path logs
`disconnecting due to failed to send response` and drops that peer — every
long stall ends in a burst of those, immediately followed by a catch-up batch
of every block missed.

Seen on two unrelated servers, one on :16 and one on :17: 8h39m, 3h32m,
1h55m and 1h43m on x86_64, 19m22s on aarch64. Through the 3h32m stall
bitcoind accepted 28 blocks and electrs indexed none, logging nothing at all.
Both users read the frozen server as a stuck resync; one reinstalled to clear
it, destroying a 62 GB index and triggering a 23-hour rebuild that was never
needed.

Set a 60s `SO_SNDTIMEO` on each accepted socket and let the existing error
path drop just that peer. It bounds one `write` syscall rather than a whole
response, so a client that keeps draining resets it on every partial write
and a slow link cannot trip it however large the response; what trips it is a
receive window shut for the full minute. `try_clone` dups the fd and the
option lives on the socket, so setting it at accept covers the clone handed
to `Peer`.

Carried as a build-time patch because there is nothing to bump to: v0.11.1 is
the newest upstream tag and master sets no socket timeout either.
`patches/README.md` records the retirement condition, and the Dockerfile
applies patches with `--fuzz=0` so a submodule bump that moves the context
fails the build instead of applying somewhere subtly wrong.

Only Electrum client sockets are affected — `accept_loop` serves
`electrum_rpc_addr` and nothing else, while bitcoind p2p, bitcoind RPC and
the Prometheus endpoint are separate. No RocksDB or schema change, so nobody
reindexes, and on a server with no stalling client the timeout never fires.
The one new outcome is that a client alive but not reading for a full minute
is now disconnected and reconnects, instead of freezing the server for
everyone.

Also bounds the duration `instructions.md` promises for the "not responding"
message, which 0.11.1:17 introduced without one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A task's replay key defaults to [package-id]:[action-id], so each time
bitcoind renamed its config action ('config' → 'other-config' →
'autoconfig'), the next electrs release wrote a new key and abandoned
the old one, which stays in the database still demanding whatever it
last asked for. The stale keys are harmless today only because they
demand the same value the live one does (prune: 0); change what electrs
asks of bitcoind and they become mutually exclusive, ping-ponging the
user between tasks that cannot all be satisfied — exactly what a stale
key did to datum-gateway until it was cleared by hand over SSH.

clearTask is a no-op for a key that is not present, so the migration
ships unconditionally; identifying which servers ever ran an affected
build is neither needed nor possible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…atch record

- UPDATING.md gains the carried-patches step in 'Applying the bump':
  check each patch's retire condition, expect --fuzz=0 to fail the
  build on changed context, never loosen the fuzz.
- patches/README.md no longer claims a live client cannot trip the
  write timeout — one whose application stops reading long enough to
  fill its receive window can, and is disconnected by design (matching
  the patch's own comment and the PR description).
- 'context has moved' → 'context has changed' in the four places that
  describe --fuzz=0: exact context that merely relocated still applies
  at an offset, which is fine; changed context is what fails.
- TODO.md records the upstream report to file at romanz/electrs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@helix-nine

Copy link
Copy Markdown

Pushed two maintainer commits on top:

  • 132e05d folds in a cleanup that was parked for the next release: a migration clearing the task replay keys abandoned when bitcoind renamed its config action (bitcoind:config, bitcoind:other-config). They're harmless today only because they demand the same prune: 0 the live bitcoind:autoconfig key does; a stale key of exactly this kind fought datum-gateway's live one and stopped the service. clearTask is a no-op where a key is absent, so it ships unconditionally. One correction to the description this implies: migrations is no longer {} — the "no index impact, nobody reindexes" conclusion is unchanged.
  • 7735f15 — doc corrections: UPDATING.md now carries the patch-revalidation step in "Applying the bump"; patches/README.md no longer claims a live client cannot trip the write timeout (one whose application stops reading long enough to fill its receive window can, and is disconnected by design — matching the patch's own comment and the description here); and "context has moved" → "context has changed" in the --fuzz=0 prose, since relocated-but-intact context still applies at an offset — changed context is what fails.

Independently verified while reviewing: the patch applies clean with patch -p1 --fuzz=0 against pristine 35216c6, upstream v0.11.1 and master both confirmed timeout-free, blast-radius claims check out, and 0.11.1:18 is free on the reference registry. The earlier red CI run was a runner-acquisition infrastructure failure (zero steps executed) — superseded by this push.

SO_SNDTIMEO is a per-write-syscall deadline and write_all loops, so a
syscall that transfers even one byte returns a partial count and
restarts the clock. Against a peer that never reads, measured on
loopback with a 3s stand-in: partial writes of 2.6 MB and 95 KB (the
peer's receive buffer auto-tuning upward) preceded the first zero-byte
period, for 9.1s total — 3x the timeout. The README implied the bound
was the timeout itself; at 60s it is a couple of minutes, which is the
number to compare against the 19m-8h39m it replaces.

The same harness demonstrates the claim that a draining client is never
dropped: 64 MB accepted over 28s, timeout never fired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The upstream report is filed (romanz/electrs#1326), so TODO.md's 'report
this upstream' item is done. Replace it with the retirement it creates:
what has to be true to drop the patch, and every file that references it
so a future bump doesn't leave dangling prose behind.

patches/README.md's retire condition now names the issue to watch, and
#745 as the same defect reported in 2022.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MattDHill
MattDHill merged commit ffb9385 into Start9-Community:master Aug 7, 2026
3 checks passed
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.

3 participants