Skip to content

feat(rpc): gate WSS requests and bound active subscriptions - #3883

Open
infrmtcs wants to merge 1 commit into
mainfrom
dat/ws-rpc-admission-limits
Open

feat(rpc): gate WSS requests and bound active subscriptions#3883
infrmtcs wants to merge 1 commit into
mainfrom
dat/ws-rpc-admission-limits

Conversation

@infrmtcs

@infrmtcs infrmtcs commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Gate WebSocket RPC requests through the shared jsonrpc.Gate, same as HTTP
  • Add a per-request timeout for WebSocket handling (HandleReadWriter now takes requestTimeout)
  • Add Conn.Context() so subscriptions key off the connection, not the timed-out request context
  • Cap concurrent subscriptions process-wide via a shared semaphore (rpccore.DefaultMaxSubscriptions), returning ErrTooManySubscriptions when exceeded (v8/v9/v10)
  • Wire gate + timeout through node.go/http.go into the HTTP and WebSocket servers

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @infrmtcs's task in 7m 12s —— View job


Review complete

  • Gather context (diff vs origin/dat/ws-rpc-request-timeout, Gate, HTTP path)
  • Review jsonrpc/websocket.go gate integration
  • Review subscription limiter across v8/v9/v10
  • Review wiring in node/
  • Review tests
  • Post inline comments

Summary

The direction is right and the two mechanisms are wired in cleanly. I traced the acquire/release pairing carefully and it is correct — no leaks, no double-releases:

  • Subscription limiter: nothing returns early between TryAcquire and sub.wg.Go, all five Subscribe* entrypoints in each version funnel through subscribe, the release is deferred first inside the goroutine so it runs after unsubscribe, and Unsubscribe does subs.wg.Wait() — so the slot is provably free before the RPC returns true. The goroutine's ctx derives from the conn ctx, so a dropped connection also reclaims slots.
  • WS gate: Release is deferred correctly, and on rejection the un-read frame is still drained by the existing io.Copy(io.Discard, wsc.r) in ServeHTTP, so the connection survives. Sharing one gate across HTTP and WS (hoisted into node.New) is the right fix.

No blockers. Findings by severity:

Important

  1. rpc/handlers.go:48 — the subscription cap is a single process-wide semaphore, not per-connection as the PR description states. With maxConns = 2048 and DefaultMaxSubscriptions = 2048, one client on one connection can take every slot and lock out all other clients. That's easier to trigger than the exhaustion it's guarding against.
  2. jsonrpc/websocket.go:167-186 — a WS request can consume 2 × rpcRequestTimeout: acquireCtx gets its own deadline, then HandleReadWriter derives a fresh one from wsc.ctx. The connection's read loop is blocked that whole time. The HTTP path charges queue time against the request budget; the asymmetry looks unintentional.
  3. jsonrpc/websocket.go:175-181 — rejections are silent (HTTP has sampled logServerBusy), and ErrServerBusy vs context.DeadlineExceeded are conflated. Relatedly, makeHTTPGateMetrics publishes under subsystemHTTP with "HTTP RPC requests" help text, so rpc_http_*_requests now silently counts websocket traffic — existing dashboards/alerts go wrong.
  4. jsonrpc/websocket.go:24-33 — the busy response is always "id":null (the gate runs before the frame is parsed), so pipelining clients can't attribute it; a client that sent a notification also gets an unsolicited response. InternalError (-32603) is also a poor fit for load shedding.

Nit
5. rpc/rpccore/rpccore.go:23DefaultMaxSubscriptions is hardcoded while the sibling request gate is flag-configurable; new error code 101 needs documenting.
6. rpc/v10/subscriptions.go:57 — the limit is checked after resolveBlockRange has hit the DB (visible as Height().Times(3) in the new test); the 6-line acquire/release pair is triplicated across v8/v9/v10.
7. node/node.go:546 — gate and its collectors are now built even with both HTTP and WS disabled.
8. rpc/v10/subscriptions_test.go:1221 — only v10 / only SubscribeNewHeads is covered; the identical v8/v9 code has none. Also subID3/conn3 skips 2.

jsonrpc/server.go:550 is an unrelated but harmless paren cleanup.

Not verified: go build ./jsonrpc/... ./rpc/... ./node/... passes, but I could not run the test suite — go test needs approval in this sandbox, so the two new tests are reviewed by reading only.
· branch dat/ws-rpc-admission-limits

Comment thread rpc/handlers.go
handlerv8 := rpcv8.New(bcReader, syncReader, virtualMachine, logger)
handlerv9 := rpcv9.New(bcReader, syncReader, virtualMachine, logger)
handlerv10 := rpcv10.New(bcReader, syncReader, virtualMachine, logger)
subscriptionLimiter := semaphore.NewWeighted(rpccore.DefaultMaxSubscriptions)

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.

important — the limit is process-wide, not per-connection

The PR description says "Bound the number of active subscriptions per connection", but this is a single semaphore.Weighted(2048) shared by all three handlers and, transitively, by every websocket connection in the process. There is no per-connection accounting anywhere in subscribe().

Combined with jsonrpc/websocket.go:21 (maxConns = 2048), a single client that opens one connection and issues 2048 starknet_subscribe* calls exhausts the global pool, and every other client — including already-connected ones — gets ErrTooManySubscriptions on their next subscribe. That's cheaper to trigger than the resource exhaustion this is meant to prevent.

Suggest tracking a per-connection counter (keyed on the jsonrpc.Conn, which subscribe already receives, and which has Equal for identity) and keeping the global cap as a second, larger backstop.

Fix this →

Comment thread jsonrpc/websocket.go
Comment on lines +167 to +186
func (ws *Websocket) handleMessage(wsc *websocketConn) error {
if ws.gate != nil {
acquireCtx := wsc.ctx
if ws.requestTimeout > 0 {
var cancel context.CancelFunc
acquireCtx, cancel = context.WithTimeout(acquireCtx, ws.requestTimeout)
defer cancel()
}
if err := ws.gate.Acquire(acquireCtx); err != nil {
if errors.Is(err, context.Canceled) {
return err
}
_, writeErr := wsc.Write(serverBusyResponse)
return writeErr
}
defer ws.gate.Release()
}

return ws.rpc.HandleReadWriter(wsc.ctx, ws.requestTimeout, wsc)
}

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.

important — a websocket request can now consume 2 × rpcRequestTimeout

acquireCtx gets its own requestTimeout deadline (line 172), and then HandleReadWriter derives a fresh requestTimeout from wsc.ctx (jsonrpc/server.go:322-323), not from acquireCtx. So a request that waits requestTimeout-ε in the gate queue still gets a full requestTimeout to execute — worst case double the configured budget, during which the connection's read loop (ServeHTTP line 132) is blocked and no other message on that connection makes progress.

The HTTP path doesn't have this: jsonrpc/http.go:80-105 builds one ctx with the timeout and uses it for both the gate acquire and the handler, so queue time is charged against the request budget.

Suggest building the deadline once here and threading it into HandleReadWriter, e.g. give HandleReadWriter the already-deadlined ctx and pass 0 for requestTimeout:

func (ws *Websocket) handleMessage(wsc *websocketConn) error {
	ctx := wsc.ctx
	if ws.requestTimeout > 0 {
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeout(ctx, ws.requestTimeout)
		defer cancel()
	}
	if ws.gate != nil {
		if err := ws.gate.Acquire(ctx); err != nil { ... }
		defer ws.gate.Release()
	}
	return ws.rpc.HandleReadWriter(ctx, 0, wsc)
}

Careful with that shape though: HandleReadWriter passes connCtx into connection.ctx, which subscription writes use after the initial response, so handing it a deadlined ctx would break long-lived subscriptions. Either keep the two ctxs separate but subtract the queue wait, or pass the deadline explicitly. Worth a comment either way, since the current asymmetry with HTTP looks unintentional.

Comment thread jsonrpc/websocket.go
Comment on lines +175 to +181
if err := ws.gate.Acquire(acquireCtx); err != nil {
if errors.Is(err, context.Canceled) {
return err
}
_, writeErr := wsc.Write(serverBusyResponse)
return writeErr
}

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.

important — rejections here are silent, and DeadlineExceeded is reported as "server busy"

Two things:

  1. No log on rejection. The HTTP path calls h.logServerBusy() (jsonrpc/http.go:60-66), which emits a sampled warning with running/queued/rejected. The websocket path drops the request with no log at all, so an operator debugging "clients randomly get -32603" has nothing to go on. Consider giving Websocket the same log.Sampled logger and calling it here.

  2. The two failure modes are conflated. Gate.Acquire returns either ErrServerBusy (queue full — a genuine overload) or context.DeadlineExceeded (this request queued longer than requestTimeout — the server is busy but this was a timeout). Both fall through to serverBusyResponse. HTTP distinguishes them (http.go:94-102). Worth at least an explicit errors.Is(err, ErrServerBusy) branch so the two are separable in logs/metrics rather than relying on "not context.Canceled" as the catch-all.

Also note the metrics side: makeHTTPGateMetrics (node/metrics.go:110-135) registers these under subsystemHTTP with help text "Number of HTTP RPC requests…". Now that the same gate serves websockets, rpc_http_active_requests / rpc_http_rejected_requests silently include websocket traffic. Either rename to a version-neutral subsystem or update the help strings, otherwise existing dashboards/alerts become wrong after this lands.

Comment thread jsonrpc/websocket.go
Comment on lines +24 to +33
var serverBusyResponse = func() []byte {
b, err := json.Marshal(&response{
Version: "2.0",
Error: &Error{Code: InternalError, Message: ErrServerBusy.Error()},
})
if err != nil {
panic(err)
}
return b
}()

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.

important — the busy response is always "id":null, so pipelining clients can't correlate it

Because the gate is checked before the frame is parsed (HandleReadWriter is what reads wsc.r), this canned response carries no request id. Websocket clients routinely have several requests in flight on one connection and match responses by id; an id:null error is unattributable, so a client typically either drops it or fails the wrong request. The HTTP path doesn't have this problem — it answers with a 503 at the transport level, which is unambiguous.

Related: a client that sent a notification (no id) now receives an unsolicited response frame, which is a JSON-RPC violation.

Options: read the frame into a bounded buffer and decode just the id before gating (cost: one extra copy bounded by ReadLimit), or close the connection with a websocket close code instead of a fake response. If the current behaviour is a deliberate trade-off, please note it in a comment on serverBusyResponse.

Separately, InternalError (-32603) is a poor fit semantically — nothing failed internally, the server is shedding load. A dedicated code (or ErrServerBusy mapped to one) would let clients implement backoff instead of treating it as a bug.

Comment thread rpc/rpccore/rpccore.go
Comment on lines +22 to +23

DefaultMaxSubscriptions int64 = 2048

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.

nit — hardcoded, unlike the sibling request gate

rpc-max-concurrent-requests / rpc-max-request-queue are both configurable flags (cmd/juno/juno.go:110,176), but the subscription cap is a compile-time constant with no way to tune or disable it. Node operators with different memory budgets can't adjust it, and there's no way to turn it off if it turns out to be too tight. Worth a rpc-max-subscriptions flag for symmetry — the plumbing already supports it since WithSubscriptionLimiter takes the semaphore.

Also 2048 deserves the same "arbitrary default, revisit after monitoring" note that maxConns carries in jsonrpc/websocket.go:21, and ErrTooManySubscriptions (code 101) is a new Juno-specific error that clients will hit — please add it to the RPC error docs.

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.

Valid

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

In fact, if we make them configurable, it should be a pair of flags: 1 for connections and 1 for subscriptions.

Comment thread rpc/v10/subscriptions.go
Comment on lines +57 to +59
if h.subscriptionLimiter != nil && !h.subscriptionLimiter.TryAcquire(1) {
return "", rpccore.ErrTooManySubscriptions
}

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.

nit — the limit is checked after the expensive work, and the pair is triplicated

The acquire/release pairing itself is correct: there is no early return between TryAcquire (line 57) and sub.wg.Go (line 80), the release is deferred first inside the goroutine so it runs after unsubscribe, and Unsubscribe does subs.wg.Wait() (line 230), so the slot is provably free before starknet_unsubscribe returns true. No leak, no double-release.

Two smaller things:

  1. Load is shed too late. All five Subscribe* entrypoints resolve the block range before calling subscribe, so a rejected subscription has already paid bcReader.Height() and — for historical subscriptions — the header/commitment/state-update reads. Your own test makes this visible: Height() is expected .Times(3) for two successful subscriptions. Checking the limiter at the top of the Subscribe* methods (or hoisting it into a tiny helper called first) would reject before touching the DB, which is the point of a limiter.

  2. Identical 6 lines now live in v8/subscriptions.go:121,138, v9/subscriptions.go:109,131 and here. The three subscribe bodies were already near-duplicates, so this follows precedent, but it means a future fix (e.g. the per-connection accounting discussed on rpc/handlers.go) has to be applied three times and can silently diverge. Worth a shared helper in rpccore if that's cheap here.

Comment thread node/node.go
Comment on lines +546 to +552
var rpcGate *jsonrpc.Gate
if cfg.RPCMaxConcurrentRequests > 0 {
rpcGate = jsonrpc.NewGate(cfg.RPCMaxConcurrentRequests, uint64(cfg.RPCMaxRequestQueue))
if cfg.Metrics {
makeHTTPGateMetrics(rpcGate)
}
}

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.

nit — hoisting the gate here so HTTP and WS share it is the right call, but it now runs unconditionally: with --http=false --ws=false the node still builds a Gate and (with metrics on) registers three prometheus collectors for a gate nobody acquires. Harmless, but if cfg.HTTP || cfg.Websocket around it would keep /metrics honest.

Comment on lines +1221 to +1262
func TestSubscribeNewHeadsRespectsLimit(t *testing.T) {
logger := log.NewNopZapLogger()
client := feeder.NewTestClient(t, &networks.Sepolia)
block1, commitments1, stateUpdate1 := GetTestBlockWithCommitments(t, client, 56377)
adaptedHeader := AdaptBlockHeader(block1.Header, commitments1, stateUpdate1.StateDiff)

mockCtrl := gomock.NewController(t)
t.Cleanup(mockCtrl.Finish)
mockChain := mocks.NewMockReader(mockCtrl)

handler := New(mockChain, nil, nil, logger).
WithSubscriptionLimiter(semaphore.NewWeighted(1))

mockChain.EXPECT().Height().Return(block1.Number, nil).Times(3)
mockChain.EXPECT().BlockHeaderByNumber(block1.Number).Return(block1.Header, nil).Times(2)
mockChain.EXPECT().BlockCommitmentsByNumber(block1.Number).Return(commitments1, nil).Times(2)
mockChain.EXPECT().StateUpdateByNumber(block1.Number).Return(stateUpdate1, nil).Times(2)

blockIDLatest := BlockIDLatest()

subID1, conn1 := createTestNewHeadsWebsocket(t, handler, (*SubscriptionBlockID)(&blockIDLatest))
assertNextHead(t, conn1, subID1, &adaptedHeader)

serverConn, clientConn := net.Pipe()
t.Cleanup(func() {
require.NoError(t, serverConn.Close())
require.NoError(t, clientConn.Close())
})
rejConn := &fakeConn{Conn: clientConn, w: serverConn}
rejCtx := context.WithValue(t.Context(), jsonrpc.ConnKey{}, rejConn)
id, rpcErr := handler.SubscribeNewHeads(rejCtx, nil)
assert.Zero(t, id)
assert.Equal(t, rpccore.ErrTooManySubscriptions, rpcErr)

unsubCtx := context.WithValue(t.Context(), jsonrpc.ConnKey{}, conn1)
ok, rpcErr := handler.Unsubscribe(unsubCtx, string(subID1))
require.Nil(t, rpcErr)
require.True(t, ok)

subID3, conn3 := createTestNewHeadsWebsocket(t, handler, (*SubscriptionBlockID)(&blockIDLatest))
assertNextHead(t, conn3, subID3, &adaptedHeader)
}

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.

nit — good test, two gaps

Solid: it exercises reject-at-limit and slot-reclaimed-after-unsubscribe, and it correctly relies on Unsubscribe's wg.Wait() rather than a sleep, so it isn't flaky.

  • The identical limiter code in rpc/v8/subscriptions.go:121 and rpc/v9/subscriptions.go:109 has no coverage. Since it's copy-pasted three times, that's exactly where a divergence would go unnoticed.
  • Only SubscribeNewHeads is covered. SubscribeEvents / SubscribeTransactionStatus / SubscribeNewTransactions / SubscribeNewTransactionReceipts all funnel through the same subscribe, so a small table-driven test over the entrypoints would be cheap insurance that none of them grows an early return between TryAcquire and sub.wg.Go.

Also subID3/conn3 skips 2 — presumably left over from an earlier draft.

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.12500% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.12%. Comparing base (3d37ce1) to head (4b82a5c).
⚠️ Report is 42 commits behind head on main.

Files with missing lines Patch % Lines
jsonrpc/websocket.go 65.38% 5 Missing and 4 partials ⚠️
node/node.go 42.85% 3 Missing and 1 partial ⚠️
jsonrpc/server.go 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3883      +/-   ##
==========================================
- Coverage   75.29%   75.12%   -0.18%     
==========================================
  Files         446      446              
  Lines       40136    40187      +51     
==========================================
- Hits        30220    30190      -30     
- Misses       7825     7900      +75     
- Partials     2091     2097       +6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@infrmtcs
infrmtcs force-pushed the dat/ws-rpc-request-timeout branch from e25b0e8 to 650ead8 Compare July 29, 2026 08:08
@infrmtcs
infrmtcs force-pushed the dat/ws-rpc-admission-limits branch from d266b82 to 0401efe Compare July 30, 2026 06:26
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @infrmtcs's task in 5m 20s —— View job


Review complete

  • Gather context (diff vs origin/dat/ws-rpc-request-timeout)
  • Re-check jsonrpc/websocket.go gate integration
  • Re-check subscription limiter across v8/v9/v10
  • Re-check wiring in node/
  • Review tests
  • Post findings

This push was a rebase, not a content change. HEAD moved d266b820401efe, but every blob in git diff origin/dat/ws-rpc-request-timeout...HEAD is byte-identical to what I reviewed on 2026-07-29. So my earlier findings all still stand unchanged — I won't repeat them inline. Two new things below.

New findings

Nit — jsonrpc/websocket_test.go:347 (inline): require.Eventually(gate.Running() == 0) doesn't prove the slot is reclaimed. Gate.Release() drains g.sem before decreaseActiveReq(), and admission is decided on activeRequests, not len(sem). If Eventually samples in that window the follow-up id:3 request gets serverBusyResponse and the test fails. Nanosecond-wide and there's a round trip after it, so it will essentially never fire — but && gate.Queued() == 0 closes it for free.

Nit — cmd/juno/juno.go:264-266: the flag help text is now wrong in the same way the metrics help text is (raised previously on jsonrpc/websocket.go:181):

rpcMaxConcurrentRequestsUsage = "Maximum concurrent HTTP RPC requests; 0 disables the limit."
rpcMaxRequestQueueUsage       = "Maximum number of HTTP RPC requests to queue after ..."

After this PR both flags govern websocket traffic too. Worth fixing in the same pass as the subsystemHTTP help strings so --help and /metrics tell the same story. (File isn't in the diff, hence no inline comment.)

Re-confirmed from the previous pass

Also re-verified against the flag defaults this time (defaultRPCMaxConcurrentRequests = defaultRPCMaxQueuedRequests = 256000, cmd/juno/juno.go:176-177), which changes the practical severity of one item:

# Where Status
1 rpc/handlers.go:48 — subscription cap is process-wide, not per-connection still stands; one connection can take all 2048 slots
2 jsonrpc/websocket.go:167-186 — a WS request can consume 2 × rpcRequestTimeout still stands. Queue-starvation angle is low risk at the default gate size (256k), but the double budget applies at any size and the read loop is blocked for it
3 jsonrpc/websocket.go:175-181 — silent rejection; ErrServerBusy vs DeadlineExceeded conflated; rpc_http_* counts WS still stands
4 jsonrpc/websocket.go:24-33 — busy response is always "id":null; InternalError for load shedding still stands
5 rpc/rpccore/rpccore.go:23DefaultMaxSubscriptions hardcoded; code 101 undocumented acked by @rodrodros / @infrmtcs — agreed a subscriptions flag alongside the connections flag is the right shape
6 rpc/v10/subscriptions.go:57 — limiter checked after resolveBlockRange hits the DB; 6 lines triplicated still stands
7 node/node.go:546 — gate + collectors built even with HTTP and WS both off still stands
8 rpc/v10/subscriptions_test.go:1221 — only v10 / only SubscribeNewHeads; subID3 skips 2 still stands

Acquire/release pairing re-traced and still correct — no leaks, no double-releases, and the WithGate(nil) path when RPCMaxConcurrentRequests == 0 is handled on both the HTTP and WS sides. No blockers.

Not verified: go build ./jsonrpc/... ./rpc/... ./node/... passes. go test still requires approval in this sandbox, so both new tests — including the flake window above — are reviewed by reading only.
· branch dat/ws-rpc-admission-limits ·

Comment thread jsonrpc/websocket_test.go
close(release)
_, _, err = connA.Read(t.Context())
require.NoError(t, err)
require.Eventually(t, func() bool { return gate.Running() == 0 }, time.Second, 5*time.Millisecond)

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.

nit — Running() == 0 is not sufficient to know the slot is fully reclaimed

Gate.Release() (jsonrpc/gate.go) does:

func (g *Gate) Release() {
	<-g.sem              // Running() drops to 0 here
	g.decreaseActiveReq() // activeRequests drops to 0 only here
}

Running() is len(g.sem), but admission is decided by activeRequests vs maxRequests. So there is a window where Running() == 0 while activeRequests is still 1. If Eventually samples inside that window, the follow-up id:3 request on connB hits increaseActiveReq() == 2 > maxRequests == 1 and gets serverBusyResponse instead of {"result":"hi"} — the test fails.

It's a nanosecond-wide window and there's a websocket round trip after it, so it will almost never fire in CI, but it's a real ordering gap rather than a theoretical one. Cheap fix — assert both counters are drained:

Suggested change
require.Eventually(t, func() bool { return gate.Running() == 0 }, time.Second, 5*time.Millisecond)
require.Eventually(t, func() bool { return gate.Running() == 0 && gate.Queued() == 0 },
time.Second, 5*time.Millisecond)

Fix this →

@infrmtcs
infrmtcs force-pushed the dat/ws-rpc-request-timeout branch from 650ead8 to ab4786a Compare July 31, 2026 04:15
Base automatically changed from dat/ws-rpc-request-timeout to main July 31, 2026 15:21
@infrmtcs
infrmtcs force-pushed the dat/ws-rpc-admission-limits branch from 0401efe to 4b82a5c Compare August 3, 2026 07:08
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @infrmtcs's task in 6m 31s —— View job


Review complete

  • Diff new HEAD against origin/main
  • Identify what actually changed in this push
  • Review the new v8 / v9 limiter tests
  • Re-check the standing findings
  • Build / vet
  • Post findings

What changed in this push

The base branch dat/ws-rpc-request-timeout landed in main, so the PR is now a single commit against main. Every non-test file in git diff origin/main...HEAD is byte-identical to what I reviewed on 2026-07-29 / 07-30. The only new content is two tests, TestSubscribeNewHeadsRespectsLimit in rpc/v8/subscriptions_test.go:553 and rpc/v9/subscriptions_test.go:1039 — which addresses the first half of my earlier finding #8.

The new tests

They're good, and deterministic — I traced the ordering rather than assuming it:

  • The slot is provably held when the second subscribe runs: TryAcquire happens synchronously inside subscribe() before it returns, so subscription 2's rejection is not a race.
  • Slot reclamation before the third subscribe is also deterministic. conc.WaitGroup.Go defers wg.Done() outermost, and Release(1) is deferred first inside the closure so it runs last within f() — i.e. strictly before Unsubscribe's wg.Wait() returns. No sleeps, no Eventually.
  • Draining one frame from client1 before Unsubscribe is necessary and correctly explained in the comment: net.Pipe is unbuffered, so wg.Wait() would otherwise block on the in-flight sendHeader.
  • Mock counts check out: v8 HeadsHeader().Times(3) (empty-header path sends one frame, no BlockHeaderByNumber), v9 Height().Times(3) + BlockHeaderByNumber(0).Times(2) (startBlock == latestBlock == 0 → one iteration each). AdaptBlockHeader(&core.Header{}) is nil-safe, so the goroutine can't panic into wg.Wait().
  • Cleanup ordering is safe: t.Cleanup is LIFO, so mockCtrl.Finish runs last, and neither surviving goroutine makes a mock call after cancellation.

One nit posted inline on the new v9 test: the Times(3) counts now codify the late-shedding behaviour in a third place, and the single-Read drain is coupled to height == 0 in a way that hangs rather than fails if that ever changes.

Standing findings — all still open

I re-verified each against the current tree rather than trusting the earlier pass. None have been addressed; I have not re-posted them inline.

# Where Severity Status
1 rpc/handlers.go:48 — cap is one process-wide semaphore, not per-connection; one client can take all 2048 slots important open
2 jsonrpc/websocket.go:186HandleReadWriter(wsc.ctx, ws.requestTimeout, …) re-derives the deadline from wsc.ctx, not acquireCtx, so a request can consume 2 × rpcRequestTimeout with the read loop blocked important open
3 jsonrpc/websocket.go:175-181 — rejection is silent (HTTP has sampled logServerBusy); ErrServerBusy and DeadlineExceeded are conflated. makeHTTPGateMetrics (node/metrics.go:110-135) still publishes under subsystemHTTP with "HTTP RPC requests" help text, so rpc_http_*_requests now counts WS traffic important open
4 jsonrpc/websocket.go:24-33 — busy response is always "id":null; notifications get an unsolicited response; InternalError (-32603) is a poor fit for load shedding important open
5 rpc/rpccore/rpccore.go:23DefaultMaxSubscriptions hardcoded while the sibling gate is flag-configurable; error code 101 undocumented nit open, acked by @rodrodros / @infrmtcs
6 rpc/v10/subscriptions.go:57 — limiter checked after resolveBlockRange hits the DB; 6 lines triplicated nit open
7 node/node.go:546 — gate + 3 prometheus collectors built even with --http=false --ws=false nit open
8 test coverage nit partly fixed — v8/v9 now covered; still only SubscribeNewHeads, and v10's subID3/conn3 still skips 2
jsonrpc/websocket_test.go:347Eventually(Running() == 0) samples a window where activeRequests is still 1 nit open; && gate.Queued() == 0 closes it
cmd/juno/juno.go:264-266 — flag help still says "HTTP RPC requests" for flags that now govern WS too nit open

No blockers. The acquire/release pairing on both mechanisms is correct — no leaks, no double-releases — and the WithGate(nil) path when RPCMaxConcurrentRequests == 0 is handled on both transports.

Verification: go vet ./rpc/v8/... ./rpc/v9/... ./rpc/v10/... ./jsonrpc/... is clean, which type-checks the new test files. go test still requires approval in this sandbox, so the two new tests are reviewed by reading only — the hang scenario in the inline note is reasoned from the code, not observed.
· branch dat/ws-rpc-admission-limits

Comment on lines +1049 to +1051
// Every subscribe resolves the range (Height); only the two that acquire a
// slot go on to send one historical header (BlockHeaderByNumber).
mockChain.EXPECT().Height().Return(uint64(0), nil).Times(3)

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.

nit — these expectations now pin the "shed load after the DB read" behaviour in a third place

Height().Times(3) for two successful subscribes is the assertion that the rejected subscribe still paid resolveBlockRange before the limiter looked at it — same thing Height().Times(3) records in rpc/v10/subscriptions_test.go:1233 and HeadsHeader().Times(3) in rpc/v8/subscriptions_test.go:562. That's the late-shedding issue from my earlier note on rpc/v10/subscriptions.go. It's fine as a description of today's behaviour, but if the limiter check moves ahead of resolveBlockRange these three exact counts all have to be edited in lockstep, and the comments above them go stale. A short // note: limiter runs after resolveBlockRange, hence 3 not 2 would make that intentional rather than incidental.

Two smaller things while here:

  • The Times(2) on BlockHeaderByNumber and the single client1.Read are coupled to latestBlock == startBlock == 0. If the mocked height ever becomes non-zero, sendHistoricalHeaders writes more than one frame and Unsubscribe's wg.Wait() blocks on the unbuffered pipe — the test then hangs to the package timeout rather than failing. Your comment on line 1058 already spots the mechanism; draining in a loop until the goroutine parks (or using a buffered writer instead of net.Pipe) would make it fail loudly instead.
  • This body is now duplicated verbatim in v8 and v9 (and near-verbatim in v10). Same concern as the triplicated subscribe bodies: a divergence in one of the three won't be noticed.

Not a blocker — I could not execute go test in this sandbox, so the hang scenario is reasoned from the code, not observed.

@rodrodros

Copy link
Copy Markdown
Contributor

Stopped, it looks like it requires a bigger refactor all around with http.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants