Skip to content

Commit a4f5f8c

Browse files
chore(rpc): close slow WebSocket clients by default (defense-in-depth) (#147)
* chore(rpc): close slow WebSocket clients by default Sets CometBFT's experimental_close_on_slow_client=true via initCometBFTConfig so any WebSocket subscriber that cannot keep up with its subscription buffer is forcibly disconnected by the server. This is defense-in-depth against the failure mode observed on lumera-devnet-1 val3, where a client-side socket leak in sdk-go (fixed in LumeraProtocol/sdk-go#17) accumulated ~5,000 ESTABLISHED WS connections, saturated the RPC listen backlog (Recv-Q=4097/4096), and made external port :26687 unresponsive — while the chain itself kept producing blocks. The sdk-go fix stops our own client from leaking. This change protects mainnet validators from any third-party client (indexer, relayer, custom tooling) exhibiting the same pattern. Subscription caps (max_subscription_clients=100, max_subscriptions_per_client=5) are left at CometBFT defaults; the new test locks them in as a guard against accidental relaxation that would re-open the saturation surface. Trade-off: well-behaved clients on high-volume subscriptions that briefly stall longer than experimental_subscription_buffer_size events will be disconnected and must reconnect. Reconnect handling is already required for operational reasons (validator restart, network blips); this is not a new client-side requirement. * test(system): suppress expected node stop exits
1 parent 26ab53f commit a4f5f8c

3 files changed

Lines changed: 66 additions & 7 deletions

File tree

cmd/lumera/cmd/config.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,38 @@ func initCometBFTConfig() *cmtcfg.Config {
7272
// cfg.P2P.MaxNumInboundPeers = 100
7373
// cfg.P2P.MaxNumOutboundPeers = 40
7474

75+
// RPC hardening — defense-in-depth against misbehaving WebSocket clients.
76+
//
77+
// Default CometBFT behaviour keeps a WebSocket connection open even when the
78+
// client is not draining its subscription buffer, relying on the OS / peer to
79+
// eventually close the socket. In practice this can take hours, and a buggy
80+
// client (or one with a goroutine/socket leak — see sdk-go PR #17) can
81+
// accumulate thousands of dormant subscriptions on `:26657` and saturate the
82+
// listen backlog of a validator's RPC.
83+
//
84+
// Observed on lumera-devnet-1 val3: ~5,000 ESTABLISHED WS connections, listen
85+
// queue full (Recv-Q=4097/4096), external RPC port unresponsive — while the
86+
// chain itself kept producing blocks. Root cause was a client-side leak in
87+
// sdk-go's wait-tx subscriber (fixed in sdk-go PR #17), but the server had no
88+
// circuit breaker: it accepted, kept, and buffered for every slow subscriber
89+
// indefinitely.
90+
//
91+
// Setting CloseOnSlowClient=true tells CometBFT to forcibly close any WS
92+
// subscriber that cannot keep up with its subscription buffer. This is the
93+
// upstream-recommended defense against the failure mode and protects mainnet
94+
// validators from any third-party client (indexer, relayer, custom tooling)
95+
// that exhibits the same pattern — sdk-go-based or otherwise.
96+
//
97+
// Trade-off: well-behaved clients that subscribe to a high-volume query and
98+
// briefly stall (network blip, GC pause >subscription_buffer_size events)
99+
// will be disconnected and must reconnect. They already had to handle
100+
// reconnect for normal operational reasons (validator restart, network), so
101+
// this is not a new client-side requirement.
102+
//
103+
// Subscription caps are left at CometBFT defaults (100 clients × 5 subs each),
104+
// which are appropriate; no live evidence justifies changing them.
105+
cfg.RPC.CloseOnSlowClient = true
106+
75107
return cfg
76108
}
77109

cmd/lumera/cmd/config_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,30 @@ func TestInitAppConfigEVMDefaults(t *testing.T) {
5353
require.True(t, evmMempoolCfg.IsValid(), "Lumera.EVMMempool field not found")
5454
require.False(t, evmMempoolCfg.FieldByName("BroadcastDebug").Bool(), "broadcast debug must be disabled by default")
5555
}
56+
57+
// TestInitCometBFTConfigRPCHardening locks in the RPC defense-in-depth
58+
// overrides applied in initCometBFTConfig. These protect lumerad's RPC
59+
// (port :26657) from misbehaving WebSocket clients accumulating dormant
60+
// subscriptions (see sdk-go PR #17 / lumera-devnet-1 val3 incident, where
61+
// ~5,000 ESTABLISHED conns saturated the listen backlog while the chain
62+
// itself stayed healthy). Changing these defaults should require an
63+
// explicit decision recorded against this test.
64+
func TestInitCometBFTConfigRPCHardening(t *testing.T) {
65+
t.Parallel()
66+
67+
cfg := initCometBFTConfig()
68+
69+
require.NotNil(t, cfg, "initCometBFTConfig must return a config")
70+
require.NotNil(t, cfg.RPC, "RPC config must not be nil")
71+
72+
require.True(t, cfg.RPC.CloseOnSlowClient,
73+
"experimental_close_on_slow_client must default to true to prevent slow WS clients from holding subscription buffers indefinitely")
74+
75+
// Subscription caps are upstream CometBFT defaults — we intentionally do
76+
// not narrow them; this assertion guards against accidentally loosening
77+
// them, which would re-open the saturation attack surface.
78+
require.Equal(t, 100, cfg.RPC.MaxSubscriptionClients,
79+
"max_subscription_clients must stay at the upstream default; relaxing it re-opens the val3-style saturation surface")
80+
require.Equal(t, 5, cfg.RPC.MaxSubscriptionsPerClient,
81+
"max_subscriptions_per_client must stay at the upstream default")
82+
}

tests/systemtests/system.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ type SystemUnderTest struct {
6868
verbose bool
6969
ChainStarted bool
7070
projectName string
71-
dirty bool // requires full reset when marked dirty
72-
stopping bool // true while StopChain is running (suppresses exit errors)
71+
dirty bool // requires full reset when marked dirty
72+
stopping atomic.Bool // true while StopChain is running (suppresses expected exit errors)
7373

7474
pidsLock mtxSync.RWMutex
7575
pids map[int]struct{}
@@ -328,7 +328,7 @@ func (s *SystemUnderTest) StopChain() {
328328
if !s.ChainStarted {
329329
return
330330
}
331-
s.stopping = true
331+
s.stopping.Store(true)
332332

333333
// Pre-cleanup: unsubscribe from events while nodes are still alive
334334
for _, c := range s.cleanupPreFn {
@@ -374,7 +374,7 @@ func (s *SystemUnderTest) StopChain() {
374374
s.cleanupPostFn = nil
375375

376376
s.ChainStarted = false
377-
s.stopping = false
377+
s.stopping.Store(false)
378378
}
379379

380380
func (s *SystemUnderTest) withEachPid(cb func(p *os.Process)) {
@@ -636,7 +636,7 @@ func (s *SystemUnderTest) startNodesAsync(t *testing.T, xargs ...string) {
636636
go func(pid int, cmd *exec.Cmd, nodeIndex int) {
637637
defer wg.Done()
638638
err := cmd.Wait() // blocks until shutdown
639-
if err != nil {
639+
if err != nil && !s.stopping.Load() {
640640
s.PrintBuffer()
641641
errChan <- fmt.Errorf("node %d exited unexpectedly: %w", nodeIndex, err)
642642
} else {
@@ -653,13 +653,13 @@ func (s *SystemUnderTest) startNodesAsync(t *testing.T, xargs ...string) {
653653
// Wait in background and close the channel when all nodes are done
654654
go func() {
655655
wg.Wait()
656+
close(errChan)
656657

657658
for err := range errChan {
658-
if err != nil && !s.stopping {
659+
if err != nil {
659660
t.Errorf("%v", err)
660661
}
661662
}
662-
close(errChan)
663663
}()
664664
}
665665

0 commit comments

Comments
 (0)