Summary
handler.handleBatch sizes its WaitGroup with wg.Add(len(msgs)) but spawns only len(calls) goroutines (one per actual call). Whenever a batch contains any message that handleResponses filters out of calls, the counter can never reach zero: wg.Wait() blocks forever and the batch's call-processing goroutine hangs. That goroutine holds callWG (via startCallProc's defer h.callWG.Done()), so handler.close's h.callWG.Wait() hangs with it, leaking the goroutine(s) plus the connection for every such request. sync.WaitGroup.Wait ignores context cancellation, so it survives client disconnect — an unauthenticated remote DoS on exposed RPC endpoints.
Pre-existing; not introduced by #22338. Filing to track the fix (promised as a follow-up during that review) and its backports.
Root cause
// rpc/handler.go, handleBatch
wg := sync.WaitGroup{}
wg.Add(len(msgs)) // counts ALL messages
for i := range calls { // but only len(calls) goroutines are spawned
boundedConcurrency <- struct{}{}
go func(i int) {
defer func() { wg.Done(); <-boundedConcurrency }()
...
}(i)
}
wg.Wait() // never returns when len(calls) < len(msgs)
handleResponses drops two message categories from calls:
- Response-shaped messages —
msg.isResponse() (has result/error, no method).
- Subscription notifications — a notification whose method ends in
_subscription (notificationMethodSuffix), which is routed to handleSubscriptionResult and continued.
Any batch mixing at least one real call with at least one message of either category gives len(calls) < len(msgs) → permanent hang.
Reproduction
A single unauthenticated HTTP request is enough:
[{"jsonrpc":"2.0","id":1,"method":"eth_chainId"},{"jsonrpc":"2.0","id":2,"result":"0x1"}]
len(msgs) == 2, len(calls) == 1 (the second entry is response-shaped and dropped) → wg.Add(2) against a single wg.Done() → wg.Wait() never returns. The connection's call goroutine and the connection itself leak; repeating the request exhausts server resources.
Impact
Affected branches
Present identically on every maintained line (same wg.Add(len(msgs)) / for i := range calls shape), so each needs its own backport:
| Branch |
Location |
main |
rpc/handler.go:210 |
release/3.5 |
rpc/handler.go:207 |
release/3.4 |
rpc/handler.go:206 |
release/3.3 |
rpc/handler.go:186 |
Suggested fix
Count the goroutines actually spawned, not the incoming messages:
Please land it with a regression test (TDD): send a mixed batch (a real call plus a response-shaped message, and a real call plus a _subscription notification) and assert the request returns instead of hanging.
Provenance
Flagged during review of #22338 by @yperbasis ("Point 2"), and independently by Copilot as a low-confidence note — it is real. @lupin012 indicated it would be handled in a separate PR; this issue gives that follow-up a home and a place to track backports across release lines. The gzip-race cherry-pick to release/3.5 that surfaced this is #22383.
Summary
handler.handleBatchsizes itsWaitGroupwithwg.Add(len(msgs))but spawns onlylen(calls)goroutines (one per actual call). Whenever a batch contains any message thathandleResponsesfilters out ofcalls, the counter can never reach zero:wg.Wait()blocks forever and the batch's call-processing goroutine hangs. That goroutine holdscallWG(viastartCallProc'sdefer h.callWG.Done()), sohandler.close'sh.callWG.Wait()hangs with it, leaking the goroutine(s) plus the connection for every such request.sync.WaitGroup.Waitignores context cancellation, so it survives client disconnect — an unauthenticated remote DoS on exposed RPC endpoints.Pre-existing; not introduced by #22338. Filing to track the fix (promised as a follow-up during that review) and its backports.
Root cause
handleResponsesdrops two message categories fromcalls:msg.isResponse()(hasresult/error, nomethod)._subscription(notificationMethodSuffix), which is routed tohandleSubscriptionResultandcontinued.Any batch mixing at least one real call with at least one message of either category gives
len(calls) < len(msgs)→ permanent hang.Reproduction
A single unauthenticated HTTP request is enough:
len(msgs) == 2,len(calls) == 1(the second entry is response-shaped and dropped) →wg.Add(2)against a singlewg.Done()→wg.Wait()never returns. The connection's call goroutine and the connection itself leak; repeating the request exhausts server resources.Impact
handler.close(which waits oncallWG), and holds the connection open indefinitely. Survives client disconnect —WaitGroup.Waitis not context-aware.Affected branches
Present identically on every maintained line (same
wg.Add(len(msgs))/for i := range callsshape), so each needs its own backport:mainrpc/handler.go:210release/3.5rpc/handler.go:207release/3.4rpc/handler.go:206release/3.3rpc/handler.go:186Suggested fix
Count the goroutines actually spawned, not the incoming messages:
Please land it with a regression test (TDD): send a mixed batch (a real call plus a response-shaped message, and a real call plus a
_subscriptionnotification) and assert the request returns instead of hanging.Provenance
Flagged during review of #22338 by @yperbasis ("Point 2"), and independently by Copilot as a low-confidence note — it is real. @lupin012 indicated it would be handled in a separate PR; this issue gives that follow-up a home and a place to track backports across release lines. The gzip-race cherry-pick to
release/3.5that surfaced this is #22383.