eth/filters: decouple client notification delivery from event fan-out - #2335
Open
mukul3097 wants to merge 1 commit into
Open
eth/filters: decouple client notification delivery from event fan-out#2335mukul3097 wants to merge 1 commit into
mukul3097 wants to merge 1 commit into
Conversation
The filter EventSystem fans events out to every installed subscription from a single eventLoop goroutine using blocking channel sends, and the per-subscription goroutines in the RPC API deliver to clients with a synchronous notifier.Notify. A WebSocket client that stops reading (or reads very slowly, never tripping the write deadline) therefore back-pressures through its subscription channel into the shared loop: one stalled client freezes newPendingTransactions, newHeads, logs, receipts and state-sync delivery for every other subscriber on the node, while eth_subscribe keeps returning valid IDs because installs interleave with the blocked sends. Observed in production on Polygon mainnet: a single stalled subscriber reduced newPendingTransactions delivery for all other clients from ~900 to ~5 notifications per 15s for days; node restarts did not help because the client reconnected immediately. Insert a bounded queue between each subscription's event feed and the client write: enqueueing never blocks, and a per-subscription goroutine drains the queue into notifier.Notify. A client that falls more than clientNotificationBuffer notifications behind loses subsequent notifications for itself only; in-process EventSystem delivery semantics are unchanged. The regression test stalls a raw-pipe client after subscribing and asserts a healthy client still receives all events promptly; without this change it stalls after exactly buffer-size events (129 of 200).
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.
Problem
The filter
EventSystemfans events out to every installed subscription from a singleeventLoopgoroutine using blocking channel sends (eth/filters/filter_system.go,handleTxsEventet al.), and the per-subscription goroutines ineth/filters/api.godeliver to clients with a synchronousnotifier.Notify. The two together create a back-pressure chain from an untrusted RPC client into shared node state:A WebSocket client that stops reading its connection — or reads just slowly enough never to trip the RPC write deadline — blocks its subscription goroutine in
Notify, its subscription channel fills, and the sharedeventLoopthen blocks on that one subscriber. From that moment every subscription on the node starves:newPendingTransactions,newHeads,logs, transaction receipts, and state-sync deposits (they all share the loop, so the starvation crosses subscription types). The failure is deceptive becauseeth_subscribekeeps returning valid subscription IDs — the install channel interleaves between blocked sends — while delivery trickles at the pace of the slowest client.Per the repo's own threat-model framing this is an RPC-user-triggerable DoS on a public endpoint: any single WS client can, accidentally or deliberately, suppress subscription delivery for all other clients of the node.
Production impact
We operate large Polygon PoS RPC infrastructure. On a mainnet full node (bor v2.9.0, 200 peers, at chain tip, txpool ingesting ~64 tx/s throughout),
newPendingTransactionssubscribers received 2–13 hashes per 15s instead of ~900 for several days. Restarting bor did not help — the offending client auto-reconnected through the load balancer and re-wedged the fresh process; the node recovered only when an LB restart severed all client sessions. We reported the symptom ("progressive mempool starvation on subscribe") through the operator channel in April without a reproduction; this PR includes the reproduction that was missing.Fix
Insert a bounded queue between each subscription's event feed and the client write (
notifyAsync/queueNotificationinapi.go): enqueueing never blocks, and a per-subscription goroutine drains the queue intonotifier.Notify. A client that falls more thanclientNotificationBuffer(512) notifications behind loses subsequent notifications for itself only.Deliberate properties of this approach:
EventSystemsemantics are untouched. In-process subscribers keep guaranteed, ordered, blocking delivery — all existingeth/filterstests pass unmodified. The isolation boundary sits exactly where the untrusted party (the RPC client) attaches.rpc/rpchelper'schan_sub.Senddrops on overflow), so the two clients become consistent under a slow consumer.Alternatives considered: per-send timeouts in the fan-out loop (retains head-of-line blocking for the timeout duration, multiplied across subscribers); dropping at the
EventSystemlayer (breaks the guaranteed-delivery contract thatTestBlockSubscriptionandTestTransactionReceiptsSubscriptioncorrectly encode for in-process consumers — rejected after trying it); relying on the RPC write deadline (already insufficient in practice — a trickling client never trips it).Testing
TestSlowClientDoesNotStarveOtherSubscribers: a raw-pipe client subscribes and then stops reading; a healthy in-proc client must still receive all 200 events promptly. On currentdevelopit fails withgot 129 of 200 events— exactly the stalled subscriber's channel buffer (128) plus one in-flight before the shared loop froze. With this change it passes in ~1s.eth/filterssuite passes, including with-race(29/29).Happy to adjust details (queue size, a metrics counter for dropped notifications, drop-oldest vs drop-newest) if maintainers prefer — the property we need is that one slow client cannot affect other subscribers.