Skip to content

eth/filters: decouple client notification delivery from event fan-out - #2335

Open
mukul3097 wants to merge 2 commits into
0xPolygon:developfrom
mukul3097:fix/ws-subscription-fanout-starvation
Open

eth/filters: decouple client notification delivery from event fan-out#2335
mukul3097 wants to merge 2 commits into
0xPolygon:developfrom
mukul3097:fix/ws-subscription-fanout-starvation

Conversation

@mukul3097

Copy link
Copy Markdown

Problem

The filter EventSystem fans events out to every installed subscription from a single eventLoop goroutine using blocking channel sends (eth/filters/filter_system.go, handleTxsEvent et al.), and the per-subscription goroutines in eth/filters/api.go deliver to clients with a synchronous notifier.Notify. The two together create a back-pressure chain from an untrusted RPC client into shared node state:

slow WS client ← notifier.Notify ← api.go subscription goroutine ← subscription channel ← shared eventLoop

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 shared eventLoop then 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 because eth_subscribe keeps 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), newPendingTransactions subscribers 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 / queueNotification in api.go): enqueueing never blocks, and a per-subscription goroutine drains the queue into notifier.Notify. A client that falls more than clientNotificationBuffer (512) notifications behind loses subsequent notifications for itself only.

Deliberate properties of this approach:

  • EventSystem semantics are untouched. In-process subscribers keep guaranteed, ordered, blocking delivery — all existing eth/filters tests pass unmodified. The isolation boundary sits exactly where the untrusted party (the RPC client) attaches.
  • The subscription goroutines now always drain their channels promptly, so the shared loop can never be held hostage by a connection.
  • Trade-off, stated explicitly: a genuinely-backlogged client silently misses notifications instead of freezing the node's subscription system for everyone (and previously, deadlocking healthy unsubscribes against the wedged loop). This matches the delivery semantics Polygon's Erigon already has for the same surface (rpc/rpchelper's chan_sub.Send drops 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 EventSystem layer (breaks the guaranteed-delivery contract that TestBlockSubscription and TestTransactionReceiptsSubscription correctly 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

  • New regression test TestSlowClientDoesNotStarveOtherSubscribers: a raw-pipe client subscribes and then stops reading; a healthy in-proc client must still receive all 200 events promptly. On current develop it fails with got 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.
  • Full eth/filters suite passes, including with -race (29/29).
  • Field verification: we canaried this fix on the affected production mainnet node. Delivery returned to parity with a healthy sibling (~1,300 notifications/15s each), and deliberately re-running the failure scenario — three concurrently wedged subscribers — left a healthy subscriber completely unaffected (~60 tx/s throughout). Multi-day soak shows no regressions.

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.

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).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@pratikspatil024
pratikspatil024 requested a lite review from Copilot August 11, 2026 04:47
@pratikspatil024

Copy link
Copy Markdown
Member

codegenie review

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

🧞 Codegenie Review

Reviewed all 9 hunks (4 deep, 5 normal), no skipped or failed hunks. Two verified issues remain after deduplication.

  1. Notification drop policy is unobservable to clients (eth/filters/api.go, eth/filters/bor_api.go). Four packets independently converged on the same delta: queueNotification uses a non-blocking send with an empty default, and notifyAsync discards notifier.Notify's error, so a subscriber more than clientNotificationBuffer (512) notifications behind gets a gapped logs/newHeads/newPendingTransactions/transactionReceipts/newDeposits stream with no error, no gap marker, no teardown, and no log/metric. The decoupling itself matches declared intent; the open question is the client-facing contract for overflow. Merged into one inline finding.

  2. New regression test in eth/filters/api_slow_client_test.go is both flaky and unable to fail pre-fix. Two verified sub-findings merged: (a) no barrier between EthSubscribe returning and EventSystem installation, so early txFeed.Send calls can be dropped and the exact-count loop then fails with a misleading starvation message; (b) the 5s deadline is created after the blocking send loop and healthy is buffered for all 200 events, so with the fix reverted the ~10s rpc write timeout tears down the stalled client and the test still passes.

Open follow-ups for the author (not filed as findings): whether the NewPendingTransactions doc comment was intentionally detached by inserting clientNotificationBuffer/notifyAsync between comment and function; whether any existing eth/filters test encodes a full-delivery/ordering contract for newHeads/logs that the drop policy would violate.

Coverage

Reviewed 9/9 hunks.
Coverage levels: deep 4, normal 5, light 0, skip 0.

⚠️ Findings

🔵 Medium: Async notify queue silently drops subscription events for slow clients (contract change)

File: eth/filters/bor_api.go:85
Confidence: medium

Every eth_subscribe stream in this package now discards payloads once the client falls more than clientNotificationBuffer (512) notifications behind, and nothing observable happens when it does — no error, no gap marker, no subscription teardown, no log or metric.

// eth/filters/api.go
const clientNotificationBuffer = 512

func notifyAsync(notifier *rpc.Notifier, id rpc.ID, stop <-chan struct{}) chan<- any {
	queue := make(chan any, clientNotificationBuffer)
	go func() {
 for {
 select {
 case v := <-queue:
 _ = notifier.Notify(id, v) // error discarded
 case <-stop:
 return
 }
 }
	}()
	return queue
}

func queueNotification(queue chan<- any, v any) {
	select {
	case queue <- v:
	default: // payload dropped, caller cannot tell
	}
}

All five subscription paths were converted to this lossy send: NewPendingTransactions (eth/filters/api.go:280, :282), NewHeads (:350), Logs (:388), TransactionReceipts (:469), and NewDeposits (eth/filters/bor_api.go:85).

// before
notifier.Notify(rpcSub.ID, h)
// after
queueNotification(queue, h)

Impact: the delivery contract changes. Previously rpc.Notifier.Notify wrote synchronously to the connection, so a subscriber either received every matching event or the write failed and rpcSub.Err() fired, which the client could observe and act on. Now a slow client keeps an apparently healthy subscription while silently receiving an incomplete stream — it sees head N then head N+k with no indication that N+1..N+k-1 were skipped. Consumers that assume completeness (log indexers, reorg/head tracking, confirmation tracking, bor bridge/state-sync consumers of newDeposits) will derive wrong state with no trigger to resubscribe or backfill. Because notifyAsync also drops Notify's error, a genuinely failed write is equally invisible.

The code comments state the intent (client delivery must never back-pressure into it; Once a client falls this far behind, further notifications are dropped for that client only), and decoupling the shared eventLoop from client back-pressure is clearly desirable. What is not settled by the PR description is the client-facing overflow contract: whether callers should be disconnected so they can resubscribe, or should keep a truncated stream. Please confirm the intended semantics with the RPC spec/callers.

Suggested fix: make the overflow observable. Either terminate the affected subscription so rpcSub.Err() fires, or at minimum emit a log/metric so gapped streams are diagnosable.

func queueNotification(queue chan<- any, v any) bool {
	select {
	case queue <- v:
 return true
	default:
 return false
	}
}

// at each call site:
if !queueNotification(queue, h) {
	droppedNotificationsMeter.Mark(1)
	return // client must resubscribe rather than silently miss data
}

If the lossy behavior is deliberate and should stay, document it in the eth_subscribe RPC docs so clients know the stream is not gap-free.

Suggested test: install a logs/newHeads/newDeposits subscription with a notifier whose writes are stalled, push more than clientNotificationBuffer events, then assert the intended overflow contract (subscription torn down with an error, or drop surfaced via metric/log) rather than payloads vanishing unobserved. No existing test in eth/filters covers this boundary:

grep -rn 'queueNotification\|notifyAsync\|clientNotificationBuffer' eth/filters/*_test.go
# no matches

🔵 Medium: New starvation regression test can drop events: no barrier between EthSubscribe returning and the EventSystem subscription being installed

File: eth/filters/api_slow_client_test.go:92
Confidence: medium

TestSlowClientDoesNotStarveOtherSubscribers has two defects that together make it both flaky and unable to detect a regression of the fix it guards.

1. No barrier between EthSubscribe returning and the EventSystem subscription being installed.

sub, err := client.EthSubscribe(context.Background(), healthy, "newPendingTransactions")
if err != nil {
	t.Fatal(err)
}
defer sub.Unsubscribe()

for i := 0; i < events; i++ {
	tx := types.NewTransaction(uint64(i), common.HexToAddress("0xb794f5ea0ba39494ce83a213fffba74279579268"), new(big.Int), 0, new(big.Int), nil)
	backend.txFeed.Send(core.NewTxsEvent{Txs: []*types.Transaction{tx}})
}

FilterAPI.NewPendingTransactions installs the subscription inside a background goroutine after returning rpcSub, and installation only completes when es.subscribe round-trips through the eventLoop (eth/filters/api.go:251-292, eth/filters/filter_system.go:428-441). EthSubscribe returning therefore establishes no happens-before with installation. Any of the 200 txFeed.Send calls processed before installation is never fanned out, and because the receive loop demands exactly events deliveries, one missed event blocks for the full 5s and fails with healthy subscriber starved by stalled client — a misleading, non-deterministic failure that falsely accuses the code under test. Pre-existing tests in the same package (eth/filters/filter_system_test.go:303, :335, :395, :740) insert time.Sleep for exactly this reason, and this test crosses an additional RPC boundary with no barrier at all.

2. The timeout window starts after the stall has already resolved, so the test passes pre-fix.

for i := 0; i < events; i++ {
	// ... backend.txFeed.Send(...) <- this is where pre-fix back-pressure manifests; untimed
}

received := 0
timeout := time.After(5 * time.Second)

for received < events {
	select {
	case <-healthy:
 received++
	case <-timeout:
 t.Fatalf("healthy subscriber starved by stalled client: got %d of %d events", received, events)
	}
}

With queueNotification(queue, tx.Hash()) reverted to _ = notifier.Notify(rpcSub.ID, tx.Hash()), the stalled net.Pipe subscriber blocks the shared eventLoop and thus blocks txFeed.Send inside the send loop — which carries no time assertion. That block is bounded at ~10s by rpc's write deadline, after which the stalled connection is closed and its subscription removed:

// rpc/json.go
defaultWriteTimeout = 10 * time.Second // used if context has no deadline

func (c *jsonCodec) writeJSON(ctx context.Context, v interface{}, isErrorResponse bool) error {
	deadline, ok := ctx.Deadline()
	if !ok {
 deadline = time.Now().Add(defaultWriteTimeout)
	}
	c.conn.SetWriteDeadline(deadline)

Notifier.send passes context.Background(), so the default applies. The remaining events then flow into healthy, which is created as make(chan common.Hash, events) and buffers all 200. Only afterwards is timeout created, and the loop drains the buffer instantly. The assertion passes with and without the fix.

Impact: no production behavior is affected, but this is the only regression test accompanying a behavior-changing fix. Today it can fail spuriously on loaded CI (burning ~5s); tomorrow a revert or refactor that reintroduces blocking delivery would ship green, and the starvation bug described in the PR body could return undetected.

Suggested fix: add an installation barrier, then assert latency during fan-out rather than after it.

// Barrier: publish warm-up txs until one is observed, proving installation.
warm := time.NewTicker(20 * time.Millisecond)
defer warm.Stop()
installed := time.After(5 * time.Second)
for ready := false; !ready; {
	select {
	case <-warm.C:
 backend.txFeed.Send(core.NewTxsEvent{Txs: []*types.Transaction{warmupTx}})
	case <-healthy:
 ready = true
	case <-installed:
 t.Fatal("subscription never installed")
	}
}

done := make(chan struct{})
go func() {
	defer close(done)
	for i := 0; i < events; i++ {
 backend.txFeed.Send(core.NewTxsEvent{Txs: []*types.Transaction{newTx(i)}})
	}
}()

received := 0
deadline := time.After(3 * time.Second) // well under rpc defaultWriteTimeout (10s)
for received < events {
	select {
	case <-healthy:
 received++
	case err := <-sub.Err():
 t.Fatalf("healthy subscription failed: %v", err)
	case <-deadline:
 t.Fatalf("healthy subscriber starved by stalled client: got %d of %d events", received, events)
	}
}

select {
case <-done:
case <-time.After(2 * time.Second):
	t.Fatal("txFeed.Send blocked by stalled client")
}

Suggested test: validate the guard by temporarily reverting queueNotification(queue, tx.Hash()) to _ = notifier.Notify(rpcSub.ID, tx.Hash()) in FilterAPI.NewPendingTransactions and confirming the test fails. If it still passes, it does not protect the fix.

go test ./eth/filters/ -run TestSlowClientDoesNotStarveOtherSubscribers -race -count=20

Stats

  • 🤖 Model: anthropic claude-opus-5 high
  • 🧞 Codegenie: v0.5.5 (58f82a9b2c)
  • Elapsed time: 5m 39s
  • Git: 0xPolygon/bor from develop to fix/ws-subscription-fanout-starvation (dd654b2ef1)
  • Posting: 2 inline
  • Review completeness: complete.
  • Usage: model calls 51, tokens 989755, cost $4.8859.
  • Effective caps: tokens 8000000.
  • Local context pressure: 3 tool-budget rejections, 5 degraded tool results, 1 degraded hunk.

View Workflow Job

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses an RPC-client-triggerable head-of-line blocking issue in eth/filters subscriptions by decoupling client notification delivery from the shared EventSystem fan-out loop, ensuring one slow/stalled subscription client cannot starve other subscribers.

Changes:

  • Add a per-subscription bounded async notification queue (notifyAsync / queueNotification) to prevent client back-pressure from blocking the shared event fan-out loop.
  • Update RPC subscription endpoints (NewPendingTransactions, NewHeads, Logs, TransactionReceipts, and Bor NewDeposits) to enqueue notifications instead of calling notifier.Notify inline.
  • Add a regression test that reproduces the slow-client starvation scenario and asserts other subscribers remain unaffected.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
eth/filters/api.go Introduces async notification queueing and switches multiple subscription paths to non-blocking enqueue.
eth/filters/bor_api.go Applies async notification queueing to Bor deposits subscription delivery.
eth/filters/api_slow_client_test.go Adds regression test covering slow/stalled client behavior vs. healthy subscribers.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread eth/filters/api.go
Comment on lines 386 to 389
case logs := <-matchedLogs:
for _, log := range logs {
notifier.Notify(rpcSub.ID, &log)
queueNotification(queue, &log)
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧞 Codegenie Review

Reviewed all 9 hunks (4 deep, 5 normal), no skipped or failed hunks. Two verified issues remain after deduplication.

  1. Notification drop policy is unobservable to clients (eth/filters/api.go, eth/filters/bor_api.go). Four packets independently converged on the same delta: queueNotification uses a non-blocking send with an empty default, and notifyAsync discards notifier.Notify's error, so a subscriber more than clientNotificationBuffer (512) notifications behind gets a gapped logs/newHeads/newPendingTransactions/transactionReceipts/newDeposits stream with no error, no gap marker, no teardown, and no log/metric. The decoupling itself matches declared intent; the open question is the client-facing contract for overflow. Merged into one inline finding.

  2. New regression test in eth/filters/api_slow_client_test.go is both flaky and unable to fail pre-fix. Two verified sub-findings merged: (a) no barrier between EthSubscribe returning and EventSystem installation, so early txFeed.Send calls can be dropped and the exact-count loop then fails with a misleading starvation message; (b) the 5s deadline is created after the blocking send loop and healthy is buffered for all 200 events, so with the fix reverted the ~10s rpc write timeout tears down the stalled client and the test still passes.

Open follow-ups for the author (not filed as findings): whether the NewPendingTransactions doc comment was intentionally detached by inserting clientNotificationBuffer/notifyAsync between comment and function; whether any existing eth/filters test encodes a full-delivery/ordering contract for newHeads/logs that the drop policy would violate.

Reviewed 9/9 hunks.
Coverage levels: deep 4, normal 5, light 0, skip 0.

— codegenie v0.5.5 (58f82a9b2c) · View Workflow Job

Comment thread eth/filters/bor_api.go Outdated
if h != nil && (crit.ID == h.ID || crit.Contract == h.Contract ||
(crit.ID == 0 && crit.Contract == common.Address{})) {
notifier.Notify(rpcSub.ID, h)
queueNotification(queue, h)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every eth_subscribe stream in this package now discards payloads once the client falls more than clientNotificationBuffer (512) notifications behind, and nothing observable happens when it does — no error, no gap marker, no subscription teardown, no log or metric.

// eth/filters/api.go
const clientNotificationBuffer = 512

func notifyAsync(notifier *rpc.Notifier, id rpc.ID, stop <-chan struct{}) chan<- any {
	queue := make(chan any, clientNotificationBuffer)
	go func() {
 for {
 select {
 case v := <-queue:
 _ = notifier.Notify(id, v) // error discarded
 case <-stop:
 return
 }
 }
	}()
	return queue
}

func queueNotification(queue chan<- any, v any) {
	select {
	case queue <- v:
	default: // payload dropped, caller cannot tell
	}
}

All five subscription paths were converted to this lossy send: NewPendingTransactions (eth/filters/api.go:280, :282), NewHeads (:350), Logs (:388), TransactionReceipts (:469), and NewDeposits (eth/filters/bor_api.go:85).

// before
notifier.Notify(rpcSub.ID, h)
// after
queueNotification(queue, h)

Impact: the delivery contract changes. Previously rpc.Notifier.Notify wrote synchronously to the connection, so a subscriber either received every matching event or the write failed and rpcSub.Err() fired, which the client could observe and act on. Now a slow client keeps an apparently healthy subscription while silently receiving an incomplete stream — it sees head N then head N+k with no indication that N+1..N+k-1 were skipped. Consumers that assume completeness (log indexers, reorg/head tracking, confirmation tracking, bor bridge/state-sync consumers of newDeposits) will derive wrong state with no trigger to resubscribe or backfill. Because notifyAsync also drops Notify's error, a genuinely failed write is equally invisible.

The code comments state the intent (client delivery must never back-pressure into it; Once a client falls this far behind, further notifications are dropped for that client only), and decoupling the shared eventLoop from client back-pressure is clearly desirable. What is not settled by the PR description is the client-facing overflow contract: whether callers should be disconnected so they can resubscribe, or should keep a truncated stream. Please confirm the intended semantics with the RPC spec/callers.

Suggested fix: make the overflow observable. Either terminate the affected subscription so rpcSub.Err() fires, or at minimum emit a log/metric so gapped streams are diagnosable.

func queueNotification(queue chan<- any, v any) bool {
	select {
	case queue <- v:
 return true
	default:
 return false
	}
}

// at each call site:
if !queueNotification(queue, h) {
	droppedNotificationsMeter.Mark(1)
	return // client must resubscribe rather than silently miss data
}

If the lossy behavior is deliberate and should stay, document it in the eth_subscribe RPC docs so clients know the stream is not gap-free.

Suggested test: install a logs/newHeads/newDeposits subscription with a notifier whose writes are stalled, push more than clientNotificationBuffer events, then assert the intended overflow contract (subscription torn down with an error, or drop surfaced via metric/log) rather than payloads vanishing unobserved. No existing test in eth/filters covers this boundary:

grep -rn 'queueNotification\|notifyAsync\|clientNotificationBuffer' eth/filters/*_test.go
# no matches

Comment on lines +86 to +92
sub, err := client.EthSubscribe(context.Background(), healthy, "newPendingTransactions")
if err != nil {
t.Fatal(err)
}
defer sub.Unsubscribe()

for i := 0; i < events; i++ {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestSlowClientDoesNotStarveOtherSubscribers has two defects that together make it both flaky and unable to detect a regression of the fix it guards.

1. No barrier between EthSubscribe returning and the EventSystem subscription being installed.

sub, err := client.EthSubscribe(context.Background(), healthy, "newPendingTransactions")
if err != nil {
	t.Fatal(err)
}
defer sub.Unsubscribe()

for i := 0; i < events; i++ {
	tx := types.NewTransaction(uint64(i), common.HexToAddress("0xb794f5ea0ba39494ce83a213fffba74279579268"), new(big.Int), 0, new(big.Int), nil)
	backend.txFeed.Send(core.NewTxsEvent{Txs: []*types.Transaction{tx}})
}

FilterAPI.NewPendingTransactions installs the subscription inside a background goroutine after returning rpcSub, and installation only completes when es.subscribe round-trips through the eventLoop (eth/filters/api.go:251-292, eth/filters/filter_system.go:428-441). EthSubscribe returning therefore establishes no happens-before with installation. Any of the 200 txFeed.Send calls processed before installation is never fanned out, and because the receive loop demands exactly events deliveries, one missed event blocks for the full 5s and fails with healthy subscriber starved by stalled client — a misleading, non-deterministic failure that falsely accuses the code under test. Pre-existing tests in the same package (eth/filters/filter_system_test.go:303, :335, :395, :740) insert time.Sleep for exactly this reason, and this test crosses an additional RPC boundary with no barrier at all.

2. The timeout window starts after the stall has already resolved, so the test passes pre-fix.

for i := 0; i < events; i++ {
	// ... backend.txFeed.Send(...) <- this is where pre-fix back-pressure manifests; untimed
}

received := 0
timeout := time.After(5 * time.Second)

for received < events {
	select {
	case <-healthy:
 received++
	case <-timeout:
 t.Fatalf("healthy subscriber starved by stalled client: got %d of %d events", received, events)
	}
}

With queueNotification(queue, tx.Hash()) reverted to _ = notifier.Notify(rpcSub.ID, tx.Hash()), the stalled net.Pipe subscriber blocks the shared eventLoop and thus blocks txFeed.Send inside the send loop — which carries no time assertion. That block is bounded at ~10s by rpc's write deadline, after which the stalled connection is closed and its subscription removed:

// rpc/json.go
defaultWriteTimeout = 10 * time.Second // used if context has no deadline

func (c *jsonCodec) writeJSON(ctx context.Context, v interface{}, isErrorResponse bool) error {
	deadline, ok := ctx.Deadline()
	if !ok {
 deadline = time.Now().Add(defaultWriteTimeout)
	}
	c.conn.SetWriteDeadline(deadline)

Notifier.send passes context.Background(), so the default applies. The remaining events then flow into healthy, which is created as make(chan common.Hash, events) and buffers all 200. Only afterwards is timeout created, and the loop drains the buffer instantly. The assertion passes with and without the fix.

Impact: no production behavior is affected, but this is the only regression test accompanying a behavior-changing fix. Today it can fail spuriously on loaded CI (burning ~5s); tomorrow a revert or refactor that reintroduces blocking delivery would ship green, and the starvation bug described in the PR body could return undetected.

Suggested fix: add an installation barrier, then assert latency during fan-out rather than after it.

// Barrier: publish warm-up txs until one is observed, proving installation.
warm := time.NewTicker(20 * time.Millisecond)
defer warm.Stop()
installed := time.After(5 * time.Second)
for ready := false; !ready; {
	select {
	case <-warm.C:
 backend.txFeed.Send(core.NewTxsEvent{Txs: []*types.Transaction{warmupTx}})
	case <-healthy:
 ready = true
	case <-installed:
 t.Fatal("subscription never installed")
	}
}

done := make(chan struct{})
go func() {
	defer close(done)
	for i := 0; i < events; i++ {
 backend.txFeed.Send(core.NewTxsEvent{Txs: []*types.Transaction{newTx(i)}})
	}
}()

received := 0
deadline := time.After(3 * time.Second) // well under rpc defaultWriteTimeout (10s)
for received < events {
	select {
	case <-healthy:
 received++
	case err := <-sub.Err():
 t.Fatalf("healthy subscription failed: %v", err)
	case <-deadline:
 t.Fatalf("healthy subscriber starved by stalled client: got %d of %d events", received, events)
	}
}

select {
case <-done:
case <-time.After(2 * time.Second):
	t.Fatal("txFeed.Send blocked by stalled client")
}

Suggested test: validate the guard by temporarily reverting queueNotification(queue, tx.Hash()) to _ = notifier.Notify(rpcSub.ID, tx.Hash()) in FilterAPI.NewPendingTransactions and confirming the test fails. If it still passes, it does not protect the fix.

go test ./eth/filters/ -run TestSlowClientDoesNotStarveOtherSubscribers -race -count=20

The first version of this change bounded per-client notification delivery
but dropped payloads silently once a client fell clientNotificationBuffer
behind: no error, no gap marker, no teardown, no log or metric. That
changed the delivery contract, since a subscriber previously either
received every matching event or saw its write fail through rpcSub.Err().
A consumer that assumes completeness (log indexers, head and reorg
tracking, state-sync consumers) would derive wrong state with nothing to
trigger a resubscribe or backfill.

Overflow now drops the subscription rather than the payload. The queue
and its drain goroutine move into a clientNotifier that closes a failed
channel on either a full queue or a Notify error, which each subscription
loop selects on and returns from, unsubscribing from the EventSystem.
Notify errors are no longer discarded, and drops increment
rpc/subscription/dropped and log the reason, so a slow consumer is
distinguishable from a node that stopped producing events.

Also harden the regression test: EthSubscribe returns before the handler
installs the subscription in the EventSystem, so events sent in that
window were dropped by the feed and could fail the exact-count assertion
for the wrong reason. It now waits for installation first. Adds a unit
test for the overflow policy itself.
@mukul3097

Copy link
Copy Markdown
Author

Pushed a commit addressing the overflow contract.

Unobservable drops. Fixed. Overflow now drops the subscription rather than the payload: the queue and its drain goroutine live in a clientNotifier that closes a failed channel on either a full queue or a Notify error. Each subscription loop selects on it and returns, unsubscribing from the EventSystem. Notify errors are no longer discarded, and drops increment rpc/subscription/dropped and log the reason, so a slow consumer is distinguishable from a node that stopped producing events.

Note the current rpc.Notifier API has no way to hand a server-side error to a subscriber, so the observable surface is metric + log + teardown. Happy to extend rpc instead if the preference is for the client to see an explicit error.

Test install race. Real, fixed. The test now waits for the subscription to be installed in the EventSystem before sending the burst, instead of relying on the time.Sleep other tests in this package use.

"Test cannot fail pre-fix". This one does not reproduce. With the fix reverted and the test otherwise unchanged, it fails deterministically 3/3:

--- FAIL: TestSlowClientDoesNotStarveOtherSubscribers
    api_slow_client_test.go:117: healthy subscriber starved by stalled client: got 128 of 200 events

The healthy subscriber starves well inside the 5s deadline, before the ~10s RPC write timeout can tear down the stalled client. With the fix applied it passes 20/20 under -race. Also added a unit test covering the overflow policy itself.

&log in the Logs subscription. This line is unchanged from developnotifier.Notify(rpcSub.ID, &log) over []*types.Log predates this PR, and only the call target changed here. With go 1.26.3 loop variables are per-iteration, so there is no aliasing across enqueued pointers.

One request: the workflow runs on this fork PR need approval, so only the Socket Security checks have executed. The unit test, lint, Diffguard and Sonar gates have not run against these commits yet.

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