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 intoAug 7, 2026
Conversation
…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>
|
Pushed two maintainer commits on top:
Independently verified while reviewing: the patch applies clean with |
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
approved these changes
Aug 7, 2026
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.
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::sendwrites responses with a blockingwrite_all, and no socket in theelectrs tree sets a timeout —
set_write_timeout/SO_SNDTIMEOappears nowhere,in
v0.11.1or inmaster. That write happens inhandle_peer_events, reachedfrom
handle_events, which is a plain sequentialforloop running inline onthe single
serve()loop — the same loop that callsrpc.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 responseand drops thatpeer. 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
:16and one on:17:indexing 58 blocksindexing 27 blocksindexing 8 blocksindexing 15 blocksDuring 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 syncedin 52 seconds,and wedged a minute later.
The fix
Set a 60s
SO_SNDTIMEOon each accepted socket inaccept_loop, and let theerror path that already exists drop just that peer.
The timeout bounds one
writesyscall, not a whole response, so a client thatkeeps 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_clonedups 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.1is the newest tag, andmastersets no sockettimeout either.
patches/README.mdrecords the retirement condition. TheDockerfile applies
patches/*.patchwith--fuzz=0, so a submodule bump thatmoves the context fails the build rather than applying somewhere subtly wrong.
Blast radius
accept_loopserveselectrum_rpc_addrandnothing else. bitcoind p2p is an outbound
TcpStream::connectinp2p.rs;bitcoind RPC and the Prometheus endpoint are separate. None are touched.
SO_SNDTIMEOaffects sends only, sorecv_loop's reads are unchanged.migrations: {}, upstreamstill 0.11.1 — nobody reindexes.
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.
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 43stsc --noEmitexit 0;prettierexit 0, no formatting drift35216c6; the patch is a build-time delta onlyNot 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