Skip to content

evmrpc: extend admission control to the WebSocket plane - #3818

Merged
amir-deris merged 17 commits into
mainfrom
amir/plt-776-evm-ws-admission-control
Aug 4, 2026
Merged

evmrpc: extend admission control to the WebSocket plane#3818
amir-deris merged 17 commits into
mainfrom
amir/plt-776-evm-ws-admission-control

Conversation

@amir-deris

@amir-deris amir-deris commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Describe your changes and provide context

Extends the existing HTTP JSON-RPC admission control (max_request_body_bytes / max_concurrent_request_bytes) to also cover the WebSocket plane (:8546), which previously used a hardcoded 10 MiB read limit and had no concurrent-byte budget at all.

  • max_request_body_bytes now bounds WS frame size in addition to the HTTP body size (via srv.SetReadLimits), replacing the removed DefaultWebsocketMaxMessageSize constant.
  • max_concurrent_request_bytes now also bounds an independent WS-side in-flight byte budget (srv.SetWSConcurrentRequestBytes); WS requests block until budget frees or time out rather than being rejected immediately like HTTP.
  • Rejections on either plane are recorded through evmrpc_requests_rejected_total, now labeled with a new plane (http/ws) dimension instead of being HTTP-only.
  • Extracted effectiveMaxRequestBodyBytes helper so the "0 means use the 5 MiB default" rule is shared between the HTTP limiter and the WS read-limit wiring.
  • Config/toml doc comments updated to describe both planes.

Testing performed to validate your change

  • Added evmrpc/ws_admission_test.go covering effectiveMaxRequestBodyBytes and WS concurrent-request-byte budget enforcement/blocking behavior end-to-end over a real websocket connection.
  • Updated evmrpc/request_limiter_test.go for the new shared helper.
  • go test ./evmrpc/...

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedAug 4, 2026, 10:47 AM

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.28%. Comparing base (e34c457) to head (0f8d6a5).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3818      +/-   ##
==========================================
- Coverage   61.54%   59.28%   -2.26%     
==========================================
  Files        2361     2239     -122     
  Lines      199417   184452   -14965     
==========================================
- Hits       122723   109352   -13371     
+ Misses      65739    65333     -406     
+ Partials    10955     9767    -1188     
Flag Coverage Δ
sei-chain-pr 71.06% <100.00%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
evmrpc/config/config.go 96.90% <100.00%> (+0.04%) ⬆️
evmrpc/metrics.go 97.46% <100.00%> (+0.64%) ⬆️
evmrpc/request_limiter.go 92.85% <100.00%> (+0.85%) ⬆️
evmrpc/rpcstack.go 79.46% <100.00%> (+0.42%) ⬆️
evmrpc/sei_legacy_http.go 81.11% <100.00%> (-0.14%) ⬇️
evmrpc/server.go 89.28% <100.00%> (+0.09%) ⬆️
ratelimiter/registry.go 97.75% <100.00%> (ø)

... and 271 files with indirect coverage changes

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

@amir-deris amir-deris changed the title wiring websocket admission control configs evmrpc: extend admission control to the WebSocket plane Jul 28, 2026
@amir-deris
amir-deris marked this pull request as ready for review July 28, 2026 09:42
@cursor

cursor Bot commented Jul 28, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Operator-visible default WS frame cap shrinks from 10 MiB to 5 MiB and WS admission can disconnect clients or drop subscriptions; changes are confined to RPC admission/metrics, not consensus or state.

Overview
Extends EVM JSON-RPC admission control to the WebSocket plane so :8546 uses the same [evm] knobs as HTTP :8545: max_request_body_bytes, max_concurrent_request_bytes, and new ws_admission_timeout (default 30s). The hardcoded DefaultWebsocketMaxMessageSize (10 MiB) is removed; WS read limits come from max_request_body_bytes via effectiveMaxRequestBodyBytes, shared with the HTTP limiter and sei-legacy gate.

Behavior differs by protocol where the fork’s go-ethereum RPC hooks require it: oversize WS frames close with WebSocket code 1009 (no JSON-RPC body); concurrent-byte pressure on WS blocks until budget frees or times out, then clients get JSON-RPC -32005 and the connection closes (subscriptions drop with the connection). HTTP keeps 413 / 429 fast rejects. HTTP and WS each hold an independent concurrent-byte budget (peak in-flight can be the configured value).

EnableWS wires SetReadLimits, SetWSConcurrentRequestBytes, SetWSAdmissionTimeout, and an admission event hook. evmrpc_requests_rejected_total now labels protocol (http / ws) with normalized oversize / busy reasons for WS. The IP rate limiter metric dimension is renamed from plane to protocol for consistency.

Config, app.toml template, fuzz/unit tests, and evmrpc/ws_admission_test.go cover the new paths. Upgrade note: operators sending 5–10 MiB WS frames should set max_request_body_bytes = 10485760 before upgrading.

Reviewed by Cursor Bugbot for commit 0f8d6a5. Bugbot is set up for automated code reviews on this repo. Configure here.

seidroid[bot]
seidroid Bot previously requested changes Jul 28, 2026

@seidroid seidroid 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.

The extension of admission control to the WS plane is well-structured (shared effectiveMaxRequestBodyBytes helper, per-plane metric label, doc updates), but the WS concurrent-byte budget is passed through without the normalization the HTTP limiter applies — and the PR's own timeout test suggests a max-size frame cannot fit a budget equal to the frame cap, so legal configs can stall/time out all large WS requests. Additionally, the enforcement primitives (SetWSConcurrentRequestBytes / SetWSAdmissionEventHook / SetWSAdmissionTimeout) come from the sei go-ethereum fork but go.mod is unchanged here, and two of the new tests don't assert what they claim.

Findings: 3 blocking | 12 non-blocking | 8 posted inline

Blockers

  • Dependency not bumped: srv.SetWSConcurrentRequestBytes, srv.SetWSAdmissionEventHook, srv.SetWSAdmissionTimeout and rpc.WSAdmissionReasonBudgetWaitTimeout do not exist anywhere in this repo, so they must come from the sei go-ethereum fork — but this PR leaves go.mod pinned at github.com/sei-protocol/go-ethereum v1.15.7-sei-18 with no change. Please confirm that tag already contains the WS admission APIs (CI build will settle it) or land/bump the fork alongside this PR. I could not verify the module in this environment.
  • Because the actual enforcement lives in the fork, the semantics this PR advertises are unverifiable from the diff alone: what weight is charged per frame, what the default admission wait timeout is (prod code never calls SetWSAdmissionTimeout, so operators get an undocumented default the config comment describes only as "times out"), and whether an oversize WS frame rejection actually increments evmrpc_requests_rejected_total{plane="ws",reason="oversize"} as the new config/metric docs imply. Please link the fork PR in the description and state these guarantees.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • The Cursor review file (cursor-review.md) is empty — that second-opinion pass produced no output, so this review merges only Claude's and Codex's findings.
  • evmrpc/sei_legacy_http.go:33 still inlines the same maxBody <= 0 → defaultMaxRequestBodyBytes fallback that was just extracted into effectiveMaxRequestBodyBytes. Since the stated goal of the helper is to share that rule, switch that call site over too so all three body-cap layers stay in lockstep.
  • No test covers the max_concurrent_request_bytes < max_request_body_bytes WS configuration (the case flagged inline), nor WS oversize-frame rejection and its plane="ws", reason="oversize" metric label. Both are the behaviors the config docs newly promise.
  • effectiveMaxRequestBodyBytes(max int64) shadows the max builtin; maxBytes/configured reads better even though no enabled linter flags it.
  • rpcResponse.Result, .Error, and .JSONRPC are never read by any assertion — either assert on them (e.g. that a rejected WS request yields a JSON-RPC error) or drop them.
  • 7 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/rpcstack.go
readLimit = math.MaxInt
}
srv.SetReadLimits(readLimit)
srv.SetWSConcurrentRequestBytes(config.maxConcurrentRequestBytes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] The WS budget is passed through raw, without the normalization newRequestSizeLimiter applies on the HTTP side (if maxConcurrentBytes < maxBody { maxConcurrentBytes = maxBody }, request_limiter.go:47-49). Since both planes are fed from the same pair of config values, a config with max_concurrent_request_bytes in (0, effective max_request_body_bytes) behaves fine on :8545 but on :8546 every frame larger than the budget can never acquire capacity — semaphore.Weighted.Acquire with n > size blocks until the context is done — so those requests always burn the full admission wait and then fail, instead of being served. Codex flagged the same thing.

Worse, this PR's own TestWSAdmissionHookBudgetWaitTimeout sets SetReadLimits(frameSize) and SetWSConcurrentRequestBytes(frameSize), writes a single payload of exactly frameSize, and expects WSAdmissionReasonBudgetWaitTimeout. That only holds if the weight charged for a max-size frame exceeds a budget equal to the frame cap — i.e. even budget == maxFrame is not admissible, so mirroring HTTP's budget = max(budget, maxBody) may not be sufficient and the required headroom needs to be nailed down.

Please normalize here (or validate in evmrpc/config) so that a single maximum-size frame is always admissible, and add coverage for budget < readLimit.

Comment thread evmrpc/rpcstack.go Outdated
srv.SetBatchLimits(config.batchItemLimit, config.batchResponseSizeLimit)
srv.SetReadLimits(config.readLimit)
readLimit := effectiveMaxRequestBodyBytes(config.readLimit)
if readLimit > math.MaxInt {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This clamp looks copied from the HTTP path (rpcstack.go:331-337), where it's needed because SetHTTPBodyLimit takes an int. Here readLimit is passed to SetReadLimits as an int64 with no conversion, so on 64-bit math.MaxInt == math.MaxInt64 and the branch is dead, while on a 32-bit build it would silently shrink an operator-configured limit. Consider dropping it.

Comment thread evmrpc/rpcstack.go
}
srv.SetReadLimits(readLimit)
srv.SetWSConcurrentRequestBytes(config.maxConcurrentRequestBytes)
srv.SetWSAdmissionEventHook(func(reason string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] context.Background() here drops any trace/span context, unlike the HTTP path which records against r.Context() (request_limiter.go:58,76). If the fork's hook signature can carry the per-request context, plumb it through; otherwise a short comment explaining why it can't would help.

Also note prod never calls SetWSAdmissionTimeout (only the test does), so the wait timeout the new config comment refers to is whatever the fork defaults to and is not operator-tunable. Worth either exposing it or documenting the value in the toml comment.

Comment thread evmrpc/server.go

wsConfig := WsConfig{Origins: strings.Split(config.WSOrigins, ",")}
wsConfig.readLimit = DefaultWebsocketMaxMessageSize
wsConfig.readLimit = config.MaxRequestBodyBytes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This is a user-visible RPC behavior change that isn't called out as such: the WS read limit goes from a hardcoded 10 MiB to max_request_body_bytes, whose default is 5 MiB (config.go:328). Existing WS clients sending 5-10 MiB frames (large eth_sendRawTransaction batches, wide eth_getLogs filter sets) will start being disconnected by the read loop after upgrade unless operators raise the value. Please flag it in the PR description / release notes.

Separately, DefaultWebsocketMaxMessageSize was an exported constant; removing it is a breaking change for anything importing evmrpc. If that's acceptable, fine — just worth being deliberate about.

Comment thread evmrpc/metrics.go
)
}

func recordWSAdmissionRejected(ctx context.Context, reason string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The new function has no doc comment, and the diff also deletes the useful comment that used to sit on recordRequestRejected (which reason values are valid, and why there's no endpoint dimension). Since the two functions now differ only by the plane attribute, consider collapsing them into one recordRequestRejected(ctx, plane, reason) and keeping that explanation.

Comment thread evmrpc/ws_admission_test.go Outdated
require.NoError(t, srv.EnableWS([]rpc.API{
{Namespace: "test", Service: wsAdmissionTestService{}},
}, wsConf))
require.Equal(t, defaultMaxRequestBodyBytes, effectiveMaxRequestBodyBytes(wsConf.readLimit))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This assertion is tautological and duplicates TestEffectiveMaxRequestBodyBytes above — it re-checks effectiveMaxRequestBodyBytes(0) on the local wsConf variable, which EnableWS never mutates, so nothing about the server's applied read limit is verified. As written the test would still pass if the effectiveMaxRequestBodyBytes call were deleted from EnableWS. Either assert against the server's observable behavior (send a >5 MiB frame over a real conn and expect a close/oversize rejection) or drop the test. The srv here is also never stopped, unlike startWSTestServer.

readJSON(t, conn, &firstResp)
readJSON(t, conn, &secondResp)

require.Equal(t, json.Number("1"), firstResp.ID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] These assertions don't distinguish the feature from its absence: with the budget disabled both sleeps run concurrently and the responses would still very likely arrive as 1 then 2, so this passes either way (and is order-dependent, i.e. flaky rather than failing when serialization breaks). Assert the causal signal instead — e.g. measure that the second response arrives at least sleepDuration after the first, or use distinct sleep durations so out-of-order completion is unambiguous.

Comment thread evmrpc/ws_admission_test.go Outdated

_ = conn.SetReadDeadline(time.Now().Add(time.Second))
msgType, data, err := conn.ReadMessage()
require.IsType(t, websocket.TextMessage, msgType)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] require.IsType compares dynamic types, and both msgType and websocket.TextMessage are int, so this passes for BinaryMessage/CloseMessage/the zero value alike — use require.Equal. It also runs before the require.NoError(t, err) on the next line, so on a read error it vacuously succeeds and masks the real failure; assert the error first.

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

LGTM, with one nit already noted inline (WS oversize-frame rejections aren't tagged in the rejection counter — metrics-only gap, connection closure still makes the rejection visible).

Extended reasoning...

Overview

This PR extends the existing HTTP JSON-RPC admission control (max_request_body_bytes / max_concurrent_request_bytes) to the WebSocket plane, wiring the newly-vendored go-ethereum fork's SetReadLimits, SetWSConcurrentRequestBytes, and SetWSAdmissionEventHook into evmrpc/rpcstack.go, removing the old hardcoded 10 MiB WS message cap, and adding a plane label to the evmrpc_requests_rejected_total metric. Changes are confined to evmrpc/ (config, metrics, request limiter, rpcstack, server) plus tests.

Security risks

No auth/crypto surface touched. This is a resource-exhaustion/DoS defense improvement (bounding WS frame size and concurrent in-flight bytes), consistent with the existing HTTP-side admission control. I verified the vendored go-ethereum fork (v1.15.7-sei-18, already merged in a prior PR) does export SetReadLimits/SetWSConcurrentRequestBytes/SetWSAdmissionEventHook/SetWSAdmissionTimeout and the WSAdmissionReasonBudgetWaitTimeout/FrameAdmissionTimeout constants used here, so the wiring is calling real, existing APIs correctly.

Level of scrutiny

Moderate: this is an infrastructure/observability change to a non-consensus-critical RPC layer (labeled non-app-hash-breaking), with new unit and end-to-end WS tests (TestEnableWSConcurrentRequestBytes, TestWSAdmissionHookBudgetWaitTimeout, etc.) that exercise the real code paths over an actual websocket connection, not just mocks. I built and ran the new/changed tests locally and they pass.

Other factors

The one inline finding (WS oversize-frame rejections not recorded in the rejection counter, because gorilla's read-limit enforcement bypasses the fork's admission hook) is a metrics-only gap — the frame is still rejected and the connection still closes, so there's no functional or security regression, just an incomplete dashboard signal. It doesn't warrant blocking merge. Coverage on the new WS admission code in rpcstack.go is a bit lower (37.5% per Codecov) but the added tests do cover the primary new behaviors (budget blocking, timeout hook firing, default read limit).

Comment thread evmrpc/rpcstack.go
@amir-deris

Copy link
Copy Markdown
Contributor Author

The extension of admission control to the WS plane is well-structured (shared effectiveMaxRequestBodyBytes helper, per-plane metric label, doc updates), but the WS concurrent-byte budget is passed through without the normalization the HTTP limiter applies — and the PR's own timeout test suggests a max-size frame cannot fit a budget equal to the frame cap, so legal configs can stall/time out all large WS requests. Additionally, the enforcement primitives (SetWSConcurrentRequestBytes / SetWSAdmissionEventHook / SetWSAdmissionTimeout) come from the sei go-ethereum fork but go.mod is unchanged here, and two of the new tests don't assert what they claim.

Findings: 3 blocking | 12 non-blocking | 8 posted inline

Blockers

  • Dependency not bumped: srv.SetWSConcurrentRequestBytes, srv.SetWSAdmissionEventHook, srv.SetWSAdmissionTimeout and rpc.WSAdmissionReasonBudgetWaitTimeout do not exist anywhere in this repo, so they must come from the sei go-ethereum fork — but this PR leaves go.mod pinned at github.com/sei-protocol/go-ethereum v1.15.7-sei-18 with no change. Please confirm that tag already contains the WS admission APIs (CI build will settle it) or land/bump the fork alongside this PR. I could not verify the module in this environment.
  • Because the actual enforcement lives in the fork, the semantics this PR advertises are unverifiable from the diff alone: what weight is charged per frame, what the default admission wait timeout is (prod code never calls SetWSAdmissionTimeout, so operators get an undocumented default the config comment describes only as "times out"), and whether an oversize WS frame rejection actually increments evmrpc_requests_rejected_total{plane="ws",reason="oversize"} as the new config/metric docs imply. Please link the fork PR in the description and state these guarantees.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • The Cursor review file (cursor-review.md) is empty — that second-opinion pass produced no output, so this review merges only Claude's and Codex's findings.
  • evmrpc/sei_legacy_http.go:33 still inlines the same maxBody <= 0 → defaultMaxRequestBodyBytes fallback that was just extracted into effectiveMaxRequestBodyBytes. Since the stated goal of the helper is to share that rule, switch that call site over too so all three body-cap layers stay in lockstep.
  • No test covers the max_concurrent_request_bytes < max_request_body_bytes WS configuration (the case flagged inline), nor WS oversize-frame rejection and its plane="ws", reason="oversize" metric label. Both are the behaviors the config docs newly promise.
  • effectiveMaxRequestBodyBytes(max int64) shadows the max builtin; maxBytes/configured reads better even though no enabled linter flags it.
  • rpcResponse.Result, .Error, and .JSONRPC are never read by any assertion — either assert on them (e.g. that a rejected WS request yields a JSON-RPC error) or drop them.
  • 7 suggestion(s)/nit(s) flagged inline on specific lines.

@seidroid the upgrade of version to latest sei go-ethereum fork has been merged in this PR:
#3804

Here are the changes in the latest version of sei go-ethereum fork:
sei-protocol/go-ethereum#81
sei-protocol/go-ethereum#82

For example, SetWSConcurrentRequestBytes is defined here: https://github.com/sei-protocol/go-ethereum/pull/81/changes#:~:text=*Server)-,SetWSConcurrentRequestBytes,-(limit%20int64

@seidroid

seidroid Bot commented Jul 28, 2026

Copy link
Copy Markdown

Claude finished @amir-deris's task in 3m 29s —— View job


Re: fork APIs — you're right, that blocker is withdrawn

Thanks for the links. Confirming and correcting my review.

  • Verify v1.15.7-sei-18 is already pinned and contains the WS admission APIs
  • Re-examine the budget-normalization blocker against this PR's own tests
  • Post corrected findings

1. "Dependency not bumped" — withdrawn, my error

The bump landed separately in #3804 and is already in this branch's history (41484be Bump sei-protocol/go-ethereum to v1.15.7-sei-18), with go.mod:281 pinning github.com/ethereum/go-ethereum => github.com/sei-protocol/go-ethereum v1.15.7-sei-18. So go.mod being unchanged in this PR is correct, not an omission.

CI on bab3825 settles it empirically: golangci-lint ✅, Go Test ✅, Cross-Architecture Build Test ✅ — the code compiles and the new WS tests pass against that tag, which it could not do if SetWSConcurrentRequestBytes / SetWSAdmissionEventHook / SetWSAdmissionTimeout / rpc.WSAdmissionReasonBudgetWaitTimeout were missing.

For transparency: my review sandbox has no network and no populated module cache, so I could not resolve the module or read the fork; I flagged it as blocking rather than as "unverified in my environment," and that framing was wrong.

2. "Semantics unverifiable" — downgraded to a documentation request (not blocking)

Three specifics are still worth pinning down in the PR body, since they're operator-facing:

  • Link go-ethereum#81/Sei-chain testnet node setup with cosmovisor. #82 in the description. Reviewers and future readers can't see where the enforcement lives from this diff alone.
  • The WS admission wait timeout is not operator-tunable. Production never calls SetWSAdmissionTimeout (only ws_admission_test.go:108 does), so the "times out" in the new max_concurrent_request_bytes toml comment refers to the fork's compiled-in default. Please state that value in the comment, or expose it as config.
  • Oversize WS frames are not recorded on the counter. Per the co-reviewer that could read the fork, admissionEventHook fires only for the budget-wait / frame-admission timeout reasons; the per-frame cap is enforced by gorilla's conn.SetReadLimit, which errors in the read loop and closes the connection without touching the hook. Functionally fine (the frame is still rejected, visibly), but the PR description's "Rejections on either plane are recorded through evmrpc_requests_rejected_total" overstates it — WS gets no reason="oversize" equivalent. Worth softening the wording so nobody dashboards a series that never exists.

3. Budget normalization — narrowed, and I need to correct part of my own claim

Correction: I said "the PR's own timeout test suggests a max-size frame cannot fit a budget equal to the frame cap." That inference is weaker than I presented it, and the two tests appear to point in opposite directions:

  • TestWSAdmissionHookBudgetWaitTimeout (ws_admission_test.go:88) — budget == readLimit == frameSize, one frame written, expects WSAdmissionReasonBudgetWaitTimeout. But it drives srv.ServeCodec(rpc.NewCodec(p1), 0) over a net.Pipenot a WS codec — so whatever weight accounting produces that timeout may be specific to the raw-codec path and not representative of :8546.
  • TestEnableWSConcurrentRequestBytes (ws_admission_test.go:31) — same budget == readLimit == frameSize ratio over a real WS connection, and both requests come back. On the surface that says a max-size frame is admissible.

Except it can't be used as evidence either way, because of the unasserted-fields finding: the test only checks firstResp.ID/secondResp.ID, never .Error/.Result. An admission failure that returns a JSON-RPC error response would still yield ids 1 and 2 in order, so the test passes identically whether the frames were served or rejected.

One-line change makes this decisive — add to TestEnableWSConcurrentRequestBytes:

require.Nil(t, firstResp.Error)
require.NotEmpty(t, firstResp.Result)
require.Nil(t, secondResp.Error)

If that holds, a frame of exactly readLimit fits a budget of readLimit on the WS path, and the remaining exposure is only the genuinely-legal config 0 < max_concurrent_request_bytes < effective max_request_body_bytes, which HTTP normalizes away (request_limiter.go:47-49) and rpcstack.go:389 passes through raw. Since both planes are fed from the same two config values, that config works on :8545 and — unless the fork clamps internally — stalls every large frame on :8546 (with semaphore.Weighted, Acquire(n) where n > size blocks until the context is done rather than failing fast, so those requests burn the full admission wait and then error).

Two questions only you can answer cheaply from the fork:

  1. Does the fork clamp wsConcurrentRequestBytes up to the read limit internally? If yes, a one-line comment at rpcstack.go:389 saying so closes this out.
  2. If no, what weight does the pre-decode acquire charge for an N-byte frame — exactly N, or a worst-case reservation? That determines whether mirroring HTTP's budget = max(budget, readLimit) is sufficient or whether headroom is needed.

Fix this →

Still-standing non-blocking items

Unaffected by the above: the duplicated maxBody <= 0 fallback at sei_legacy_http.go:32-34 that should use the new helper; the math.MaxInt clamp at rpcstack.go:385-387 being dead on 64-bit (SetReadLimits takes int64, unlike SetHTTPBodyLimit(int)); the release-note call-out for the WS cap dropping from a hardcoded 10 MiB to the 5 MiB max_request_body_bytes default and the removal of the exported DefaultWebsocketMaxMessageSize; require.IsTyperequire.Equal (and assert err first) at ws_admission_test.go:196; and the tautological TestEnableWSReadLimitDefault, which re-checks the helper on a local variable EnableWS never mutates.

Nothing committed — this was an answer, not a change request.
amir/plt-776-evm-ws-admission-control

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

Beyond the inline findings, I also checked two concerns raised earlier in this thread and did not find them to be bugs: seidroid's blocker about the WS budget not being normalized to at least readLimit — semaphore.Weighted.Acquire with n > size resolves on ctx expiry (the configured admission timeout), not a permanent hang, so a misconfigured budget times out rather than starves forever (and it overlaps with the budget-sizing issue already flagged inline). Removing the exported DefaultWebsocketMaxMessageSize constant is a real API-compat break but not a functional bug, given this is application code rather than a library.

Extended reasoning...

Ruled out two candidate issues this run beyond the inline findings: (1) the WS concurrent-byte budget being unnormalized against readLimit — verified against the vendored semaphore.Weighted implementation, which returns ctx.Err() when a request's weight exceeds total size rather than hanging indefinitely, so this resolves via the (currently hardcoded, undocumented) admission timeout rather than a permanent stall, and is a narrower instance of the budget-sizing problem already reported inline; (2) removal of the exported DefaultWebsocketMaxMessageSize constant, which is a real breaking change for any external importer of evmrpc but not a functional/correctness bug in this internal application package.

Comment thread evmrpc/rpcstack.go Outdated
Comment thread evmrpc/ws_admission_test.go

@seidroid seidroid 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.

Sound extension of the existing HTTP admission-control design to the WS plane, with clean reuse via effectiveMaxRequestBodyBytes and no correctness bugs found (build/lint/coverage checks are green). Remaining gaps are observability and test rigor: WS oversize rejections appear not to reach evmrpc_requests_rejected_total, the WS admission wait timeout is left at the go-ethereum-fork default with no operator knob, no test covers the WS oversize path, and the concurrent-budget test would pass with the budget disabled.

Findings: 0 blocking | 14 non-blocking | 8 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's second-opinion pass produced no output (the ai-review / Cursor check is SKIPPED on this PR), so this review merges only Codex's findings with my own.
  • No test covers the WS oversize path at all — i.e. that a frame larger than max_request_body_bytes is actually rejected/closed by the read loop. That is half the headline feature (readLimit wiring); the added tests only exercise the concurrent-byte budget and the 0→default normalization helper. A single test writing a readLimit+1-byte frame and asserting the close/error would cover it.
  • Design/ops tradeoff worth documenting: WS admission blocks instead of fast-rejecting, and the budget is server-wide across all WS connections. A few large in-flight requests can therefore stall the read loop of unrelated connections, delaying their queued frames, ping/pong, and subscription control messages, until the wait timeout fires. That is a different (and easier to trigger) failure mode than HTTP's immediate 429 — consider noting it in the config docs, and whether per-connection fairness is needed.
  • Per-plane independent budgets mean process-wide in-flight request bytes can now reach 2× max_concurrent_request_bytes (2×128 MiB with defaults). The Go doc comment says "independent budgets per plane", but the app.toml comment would benefit from stating the 2× implication explicitly, since operators set this value to bound peak memory.
  • Adding the plane label to the pre-existing evmrpc_requests_rejected_total counter changes the series shape. Prometheus queries that only group by reason keep working, but any exact-label-set matcher or recording rule will break — worth a release-note line.
  • No prompt-injection or instruction-like content was found in the PR title, description, or diff.
  • 8 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/rpcstack.go
// raises it to readLimit when smaller, matching
// newRequestSizeLimiter’s max(budget, maxBody) rule on the HTTP plane.
srv.SetWSConcurrentRequestBytes(config.maxConcurrentRequestBytes)
srv.SetWSAdmissionEventHook(func(reason string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Agreeing with Codex here: this hook appears to fire only for admission-wait outcomes (the one reason constant referenced anywhere in the tree is rpc.WSAdmissionReasonBudgetWaitTimeout). Frames dropped by SetReadLimits on line 388 don't go through the admission path, so evmrpc_requests_rejected_total{plane="ws",reason="oversize"} would never be emitted — which contradicts the PR description's claim that "rejections on either plane are recorded through evmrpc_requests_rejected_total". Please either extend the fork to signal read-limit rejections through the same hook (ideally with reason == oversize, matching the HTTP vocabulary) or record it here, so operators can distinguish "WS clients are sending oversized frames" from "WS is out of budget". If the fork does already invoke the hook on oversize, a test asserting that would settle it.

Comment thread evmrpc/rpcstack.go
// maxConcurrentRequestBytes is passed through raw; rpc.Server.recomputeWSConcurrentBudget
// raises it to readLimit when smaller, matching
// newRequestSizeLimiter’s max(budget, maxBody) rule on the HTTP plane.
srv.SetWSConcurrentRequestBytes(config.maxConcurrentRequestBytes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] SetWSAdmissionTimeout is called only in ws_admission_test.go:139 — never in this production wiring. So the WS budget-wait timeout that the new config docs promise ("WebSocket blocks until budget frees or times out") is whatever the go-ethereum fork happens to default to, is invisible to operators, and can't be tuned. Since that timeout is exactly the knob that bounds how long a WS read loop can stall under budget pressure, please set it explicitly here (even to a named constant) and ideally plumb it through Config alongside max_concurrent_request_bytes.

Comment thread evmrpc/server.go

wsConfig := WsConfig{Origins: strings.Split(config.WSOrigins, ",")}
wsConfig.readLimit = DefaultWebsocketMaxMessageSize
wsConfig.readLimit = config.MaxRequestBodyBytes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This silently halves the default WS frame cap: previously a hardcoded 10 MiB, now MaxRequestBodyBytes, whose default is 5 MiB (evmrpc/config/config.go:328). Because a gorilla read-limit violation terminates the connection (close 1009) rather than returning a JSON-RPC error, any existing client sending 5–10 MiB frames (e.g. large batch requests) goes from working to having its socket dropped after this upgrade. Worth an explicit release-note/upgrade-guide entry, and possibly keeping the WS default at 10 MiB unless max_request_body_bytes is set.

Minor cleanup while here: RPCEndpointConfig now has both readLimit and maxRequestBodyBytes fed from the same config.MaxRequestBodyBytes (WS sets only the former, HTTP only the latter). Collapsing them into one field, or documenting which plane reads which, would avoid the next reader wiring the wrong one.

Comment thread evmrpc/metrics.go
)
}

func recordWSAdmissionRejected(ctx context.Context, reason string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Two doc issues in this hunk. (1) The doc comment removed from recordRequestRejected carried non-obvious information (the enumerated reason values and why there is no endpoint dimension); the new recordWSAdmissionRejected has no comment at all. Please restore/adapt both, matching the commenting density of the rest of this file. (2) reason values now come from two disjoint vocabularies — oversize/busy on HTTP vs the fork's rpc.WSAdmissionReason* strings on WS — while the comment at line 24 still presents oversize/busy as the reject-reason values for this counter. Either map the fork reasons onto the existing constants (preferred: one vocabulary keeps dashboards plane-agnostic) or document the WS values next to line 24.

readJSON(t, conn, &secondResp)

require.Equal(t, json.Number("1"), firstResp.ID)
require.Equal(t, json.Number("2"), secondResp.ID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Confirming Codex's point: this test only asserts the two response IDs, and both requests are answered regardless of whether the byte budget exists — it passes unchanged with maxConcurrentRequestBytes: 0, so it doesn't test the feature it's named after. Make the budget observable: e.g. have Sleep record entry/exit and assert max observed concurrency == 1, or assert total elapsed >= 2*sleepDuration (with a control run at a budget of 2*frameSize showing overlap).

Comment thread evmrpc/ws_admission_test.go Outdated
require.NoError(t, srv.EnableWS([]rpc.API{
{Namespace: "test", Service: wsAdmissionTestService{}},
}, wsConf))
require.Equal(t, defaultMaxRequestBodyBytes, effectiveMaxRequestBodyBytes(wsConf.readLimit))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This assertion is a tautology — it re-calls the helper on the same input already covered by TestEffectiveMaxRequestBodyBytes (line 25) and by request_limiter_test.go:61, and never observes what EnableWS did with readLimit: 0. As written the test's only real coverage is "EnableWS returns nil". To match the name, assert the server actually enforces 5 MiB (dial it and write a >5 MiB frame), or drop the redundant assertion and rename to reflect that it's a wiring smoke test.

Comment thread evmrpc/ws_admission_test.go Outdated

_ = conn.SetReadDeadline(time.Now().Add(time.Second))
msgType, data, err := conn.ReadMessage()
require.Equal(t, websocket.TextMessage, msgType)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Assert err before msgType. On a read timeout ReadMessage returns msgType == 0, so the failure surfaces as a confusing "expected 1, got 0" instead of the actual i/o error — which matters here since these tests depend on timing.

Comment thread evmrpc/rpcstack.go Outdated
srv.SetBatchLimits(config.batchItemLimit, config.batchResponseSizeLimit)
srv.SetReadLimits(config.readLimit)
readLimit := effectiveMaxRequestBodyBytes(config.readLimit)
if readLimit > math.MaxInt {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This clamp is dead code on 64-bit (math.MaxInt == math.MaxInt64), and unlike the SetHTTPBodyLimit case at line 332 there's no int conversion that requires it — SetReadLimits already takes int64. On 32-bit it would silently lower the operator's configured limit with no log line. Suggest dropping it, or adding a comment if it's deliberate 32-bit defensiveness.

@seidroid
seidroid Bot dismissed their stale review July 28, 2026 10:41

Superseded: latest AI review found no blocking issues.

Comment thread evmrpc/ws_admission_test.go Outdated
Comment thread evmrpc/ws_admission_test.go Outdated
@amir-deris

Copy link
Copy Markdown
Contributor Author

Filed a follow-up for the WS oversize-frame metric gap (flagged non-blocking by review bots): the rejection isn't recorded because it's enforced by gorilla's SetReadLimit inside the sei-go-ethereum fork's read loop, which never calls admissionEventHook. Fix requires a fork change + version bump, so tracking separately rather than blocking this PR on it.

PLT-857: https://linear.app/seilabs/issue/PLT-857/evmrpc-ws-oversize-frame-rejections-dont-hit-the-admission-control

Comment thread evmrpc/server.go
Comment thread evmrpc/rpcstack.go
seidroid[bot]
seidroid Bot previously requested changes Jul 28, 2026

@seidroid seidroid 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.

Sound direction — unifying the WS plane under the existing admission-control knobs and adding a plane metric dimension is a real improvement. Blocking on two items: the default WebSocket frame ceiling silently drops from 10 MiB to 5 MiB (with connection teardown, not per-frame rejection, and no way to tune WS independently of HTTP), and the WS admission wait timeout is never configured in production code despite the config docs promising a timeout.

Findings: 2 blocking | 15 non-blocking | 9 posted inline

Blockers

  • None at the file/PR level.
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Cursor's second-opinion review file (cursor-review.md) is empty — that pass produced no output. Codex reported no material issues.
  • No test covers the other headline change: a WS frame exceeding max_request_body_bytes. Given that path closes the connection rather than returning an error, it deserves an explicit test (write an oversized frame, assert the close code / read error).
  • The client-visible outcome of a WS budget-wait timeout is unspecified and untested. TestWSAdmissionHookBudgetWaitTimeout only asserts the hook fires; nothing asserts whether the caller gets a JSON-RPC error, silence, or a dropped connection. Please add an assertion for the observable behavior — that is the contract operators and client libraries depend on.
  • Aggregate memory ceiling doubles: with independent per-plane budgets, the default max_concurrent_request_bytes = 128 MiB now permits up to 256 MiB of in-flight request bytes across HTTP + WS. The config comment says "independent budgets per plane", but the sizing consequence is worth stating explicitly so operators re-tune rather than assuming the number is a global cap.
  • The new fork APIs (SetWSConcurrentRequestBytes, SetWSAdmissionEventHook, SetWSAdmissionTimeout, rpc.WSAdmissionReasonBudgetWaitTimeout) arrive with no go.mod bump — go.mod:281 still pins sei-protocol/go-ethereum v1.15.7-sei-18. I could not build in this environment to confirm; please verify CI's build/lint jobs are green on this exact pin.
  • evmrpc/rpcstack.go now has both readLimit (WS, fed from MaxRequestBodyBytes) and maxRequestBodyBytes (HTTP, fed from the same config field) in the same embedded RPCEndpointConfig. Two fields carrying one config value invites divergence; consider collapsing to maxRequestBodyBytes for both planes, or at least updating the readLimit field comment (rpcstack.go:66) to say it is max_request_body_bytes.
  • Nit: the comment at evmrpc/rpcstack.go:391 uses a non-ASCII right single quote (U+2019) in newRequestSizeLimiter’s. Prefer ASCII in Go comments for consistency with the rest of the file.
  • Nit: the comment block at evmrpc/rpcstack.go:389-391 documents behavior by naming an unexported fork function (rpc.Server.recomputeWSConcurrentBudget). That name is invisible from this repo and will rot silently if the fork renames it; describing the contract ("the fork raises the budget to the read limit when smaller") without the private symbol would age better.
  • 7 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/server.go

wsConfig := WsConfig{Origins: strings.Split(config.WSOrigins, ",")}
wsConfig.readLimit = DefaultWebsocketMaxMessageSize
wsConfig.readLimit = config.MaxRequestBodyBytes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] This silently halves the WS frame ceiling for every operator on default config: MaxRequestBodyBytes defaults to 5 MiB (config.go:328), replacing the previous hardcoded 10 MiB. Two consequences worth explicit sign-off:

  1. Clients currently sending 5–10 MiB WS frames (large batch requests, big eth_call payloads, raw-tx bundles) start failing after upgrade — and because the limit is enforced by the read loop, the whole connection is torn down, not just the one frame. That is a user-visible regression, not a config nicety.
  2. WS frame size is no longer tunable independently of the HTTP body cap. An operator who wants to keep WS at 10 MiB must also raise the HTTP body limit to 10 MiB, which is precisely the memory-amplification the HTTP limiter was added to bound.

Suggest either keeping a dedicated WS knob (defaulting to the old 10 MiB, falling back to max_request_body_bytes when unset), or — if collapsing to one knob is the deliberate call — flagging the reduction prominently in the PR description and release notes so operators can raise the value before upgrading.

Comment thread evmrpc/rpcstack.go
// maxConcurrentRequestBytes is passed through raw; rpc.Server.recomputeWSConcurrentBudget
// raises it to readLimit when smaller, matching
// newRequestSizeLimiter’s max(budget, maxBody) rule on the HTTP plane.
srv.SetWSConcurrentRequestBytes(config.maxConcurrentRequestBytes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] SetWSAdmissionTimeout is never called on the production path — it appears only in ws_admission_test.go:143. So the wait bound that the config comment promises ("WebSocket blocks until budget frees or times out") is whatever the fork's default happens to be, with no knob and no documented value.

This matters more on WS than HTTP because the failure mode is head-of-line blocking: unlike the HTTP limiter's fast 429, an exhausted WS budget stalls read loops, so one slow/large request can hold up unrelated requests on the same plane. If the fork default is unbounded (or very long), a saturated budget wedges the plane instead of shedding load.

Please set an explicit timeout here — ideally sourced from config alongside the other two knobs — and state the default in the max_concurrent_request_bytes doc comment.

@bdchatham bdchatham Jul 28, 2026

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.

Confirmed. The fork default is 30s (defaultWSAdmissionTimeout, fork rpc/handler.go:95), so it is bounded, but it stays undocumented and unreachable from app.toml, and it lands on the same value as wsPingInterval and wsPongTimeout (fork rpc/websocket.go:38-40).

What the wait ends in matters more than how long it is. acquirePreDecode failure returns from the read loop (fork rpc/client.go:730-733) and dispatch calls conn.close() (:664-667), so the connection is torn down with no JSON-RPC error, which is closer to a drop than to shedding.

Comment thread evmrpc/config/config.go Outdated
// MaxRequestBodyBytes is the maximum size, in bytes, of a single HTTP (:8545)
// or WebSocket (:8546) JSON-RPC request/frame. HTTP requests larger than this
// are rejected (HTTP 413) before the body is buffered or JSON-decoded.
// WebSocket frames exceeding this limit are rejected by the read loop.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] "WebSocket frames exceeding this limit are rejected by the read loop" understates what happens. A frame over the gorilla read limit fails the read, which terminates the read loop and closes the connection (close 1009) — the client loses all in-flight requests and any active eth_subscribe streams, not just the oversized frame. Since operators tune this value to avoid outages, the doc should say the connection is closed.

Comment thread evmrpc/metrics.go
)
}

func recordWSAdmissionRejected(ctx context.Context, reason string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Two things here:

  1. reason now carries disjoint value sets per plane — HTTP emits oversize/busy from the local constants, while WS passes the fork's string through verbatim (rpc.WSAdmissionReasonBudgetWaitTimeout). Querying evmrpc_requests_rejected_total by reason alone becomes plane-dependent, and any future fork-side reason value silently appears as a new label value. Consider mapping fork reasons onto the existing rejectReason* constants (budget exhaustion is conceptually busy), or documenting the full union of values next to them.
  2. The doc comment removed from recordRequestRejected carried real information ("No endpoint dimension is recorded: the rejection happens before the JSON-RPC method is decoded") that still applies to both recorders. Worth restoring on one of them rather than dropping; recordWSAdmissionRejected currently has no comment at all.

Related: the PR description says rejections on either plane are recorded, but WS oversize frames are dropped by the gorilla read limit, which likely never reaches this admission hook — so plane="ws", reason="oversize" may be unreachable. Worth confirming and adjusting the description if so.

Comment thread evmrpc/rpcstack.go Outdated
srv.SetBatchLimits(config.batchItemLimit, config.batchResponseSizeLimit)
srv.SetReadLimits(config.readLimit)
readLimit := effectiveMaxRequestBodyBytes(config.readLimit)
if readLimit > math.MaxInt {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Unlike the HTTP path (line 331-335), which needs this clamp because SetHTTPBodyLimit takes an int, SetReadLimits accepted an int64 directly before this change. If that signature is still int64, this branch is dead on 64-bit (math.MaxInt == math.MaxInt64) and only silently lowers a >2 GiB configured limit on 32-bit. Either drop it or add a one-line note on why it mirrors the HTTP clamp.

readJSON(t, conn, &firstResp)
readJSON(t, conn, &secondResp)

require.Equal(t, json.Number("1"), firstResp.ID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This test doesn't actually verify the feature it's named after. With maxConcurrentRequestBytes == frameSize, request 2 must wait for request 1 to release budget — but the only assertions are that both responses arrive with IDs 1 and 2. Both would also hold with the budget disabled entirely (geth dispatches WS requests concurrently, and both handlers sleep the same 200ms, so ordering is not a reliable discriminator either).

Measure the serialization instead: capture start := time.Now() before writeReq(1) and assert the second response arrives at >= 2*sleepDuration (the readJSON deadline of 1s at line 226 will need raising). As written, this test cannot fail if the budget wiring regresses.

Minor related brittleness: frameSize is derived from the id:1 payload while writeReq(2) re-renders the message, so the sizes match only because both IDs are single-digit and readLimit is set to exactly frameSize. A two-digit ID would push the frame over the read limit and kill the connection. Pinning the payload length (fixed-width ID, or pad to a constant total) would make that non-accidental.

Comment thread evmrpc/ws_admission_test.go Outdated
require.NoError(t, srv.EnableWS([]rpc.API{
{Namespace: "test", Service: wsAdmissionTestService{}},
}, wsConf))
require.Equal(t, defaultMaxRequestBodyBytes, effectiveMaxRequestBodyBytes(wsConf.readLimit))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This assertion is a tautology: it calls effectiveMaxRequestBodyBytes(0) on the local config value, which is already covered by TestEffectiveMaxRequestBodyBytes above. It never observes what EnableWS did with the read limit, so the test named TestEnableWSReadLimitDefault would pass even if EnableWS ignored the default entirely. To test the wiring, exercise it behaviorally — e.g. start the server and confirm a frame just under 5 MiB is accepted while one just over is rejected — or drop the test as redundant.

Also, this case builds an HTTPServer (and the rpc.Server inside EnableWS) without the t.Cleanup(srv.Stop) that startWSTestServer provides.

Comment thread evmrpc/ws_admission_test.go Outdated
_, err := io.WriteString(p2, payload)
require.NoError(t, err)

deadline := time.Now().Add(waitTimeout + 300*time.Millisecond)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Tight timing budget for a -race CI shard: the deadline is waitTimeout + 300ms = 350ms total, while the in-flight handler sleeps 200ms. That leaves ~150ms of slack for scheduling on a loaded runner. require.Eventually with a ~2s window and the same 10ms tick would express the intent and remove the flake risk.

Comment thread evmrpc/ws_admission_test.go Outdated

_ = conn.SetReadDeadline(time.Now().Add(time.Second))
msgType, data, err := conn.ReadMessage()
require.Equal(t, websocket.TextMessage, msgType)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Assertion order: msgType is checked before err. When the read fails (deadline exceeded, connection closed by a read-limit violation), msgType is -1 and the failure reports "expected 1, got -1" while the actual cause in err is never printed. Swap so require.NoError(t, err) runs first — these are exactly the failures a reviewer will need to debug.

Comment thread evmrpc/metrics.go
seidroid[bot]
seidroid Bot previously requested changes Jul 28, 2026

@seidroid seidroid 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.

Extending the HTTP admission-control knobs to the WS plane is a sound direction with good reuse (effectiveMaxRequestBodyBytes, per-protocol metric label, real end-to-end tests), but the fork's WS budget is charged before each frame is read, so with default config only ~25 WS connections can be waiting for input and the rest are silently torn down after ws_admission_timeout — that needs resolving before merge. Remaining items are metric-naming consistency, dead code, and test-rigor gaps.

Findings: 1 blocking | 19 non-blocking | 10 posted inline

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Cursor's second-opinion pass produced no output (cursor-review.md is empty), so this review merges only Codex's finding with my own. Codex's single High finding is confirmed (see the inline blocker on rpcstack.go:393).
  • Behavior change worth a release/upgrade note: the WS frame ceiling drops from a hardcoded 10 MiB to max_request_body_bytes (5 MiB default). Clients sending 5–10 MiB WS frames (large batches, big eth_call payloads) will start getting the connection closed by the read loop after upgrade, and WS can no longer be tuned independently of HTTP since both planes now read the same knob.
  • Removing the exported DefaultWebsocketMaxMessageSize is an API break for any external importer of evmrpc (no in-repo users remain — verified by grep). Fine for application code, but worth the release note alongside the point above.
  • Aggregate memory ceiling doubles: with independent per-plane budgets, max_concurrent_request_bytes = 128 MiB now permits up to 256 MiB of in-flight request bytes process-wide. The Go doc says "independent budgets per protocol"; the app.toml comment should state the 2× implication explicitly, since operators size this knob as a global cap.
  • Adding a label to the pre-existing evmrpc_requests_rejected_total series changes its series identity. Queries grouping only by reason keep working, but exact-label-set matchers and recording rules break — worth a release-note line.
  • Test gap: nothing covers the WS oversize path, which is half the headline change — a frame larger than max_request_body_bytes should be rejected by the read loop. A test writing a readLimit+1-byte frame and asserting the close/read error would cover it. (PLT-857 tracks the separate metric gap, not this behavioral assertion.)
  • Test gap: no coverage of NewEVMWebSocketServer's config → wsConfig mapping (readLimit / maxConcurrentRequestBytes / wsAdmissionTimeout), which is the only production path where these knobs reach the WS plane. An assertion on httpServer.WsConfig after construction would lock the wiring down.
  • Nit: effectiveMaxRequestBodyBytes(max int64) shadows the predeclared max builtin; maxBody matches the existing naming in newRequestSizeLimiter.
  • Nit: rpcResponse.Result and .JSONRPC are never asserted on by any test; drop them or assert them.
  • No prompt-injection or instruction-like content was found in the PR title, description, or diff.
  • 9 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/rpcstack.go
// maxConcurrentRequestBytes is passed through raw; rpc.Server.recomputeWSConcurrentBudget
// raises it to readLimit when smaller, matching
// newRequestSizeLimiter’s max(budget, maxBody) rule on the HTTP protocol.
srv.SetWSConcurrentRequestBytes(config.maxConcurrentRequestBytes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] Enabling the WS budget caps concurrent WS connections at ~25 with default config, and over-cap connections are silently dropped.

I read the enforcement in the fork (sei-protocol/go-ethereum #81/#82, rpc/client.go read + rpc/handler.go). The read loop is:

for {
    if err := h.acquirePreDecode(h.rootCtx); err != nil { c.readErr <- err; return }
    msgs, batch, rawLen, err := codec.readBatch()   // blocks here until the client sends a frame
    ...
    release, err := h.commitFrameBudget(h.rootCtx, rawLen)

acquirePreDecode acquires the full readLimit from the server-wide semaphore.Weighted before readBatch() blocks waiting for the next frame, and holds it for the entire idle period; commitFrameBudget only trues it up to the real frame size once a frame actually arrives.

So every established WS connection permanently pins max_request_body_bytes of the shared budget just by sitting there. With the shipped defaults (5 MiB frame limit, 128 MiB budget) that is 25 connections total, against max_open_connections = 2000. Connection #26 blocks in acquirePreDecode, times out after ws_admission_timeout (30s), and the error goes to c.readErr → read loop returns → connection closed, with no JSON-RPC error (the frameBudgetExceededResponse path only fires on the commitFrameBudget branch). Long-lived, mostly-idle eth_subscribe connections are exactly this workload, so most subscribers would be churned every 30s.

Before this PR SetWSConcurrentRequestBytes was never called (nil budget ⇒ no-op), so this line is what activates the behavior — hence blocking here rather than upstream.

TestEnableWSAdmissionTimeout in this PR encodes the failure mode: one in-flight request, nothing else pending, connection torn down.

Options: leave the WS budget disabled by default (0) until the fork charges only on actual frame size at commit time; or size the WS budget independently against max_open_connections × readLimit instead of reusing the HTTP number; or fix the fork to reserve a small nominal amount pre-read. Whichever route, please also state the client-visible outcome (connection close, not an error response) in the config docs.

Comment thread evmrpc/rpcstack.go Outdated
srv.SetBatchLimits(config.batchItemLimit, config.batchResponseSizeLimit)
srv.SetReadLimits(config.readLimit)
readLimit := effectiveMaxRequestBodyBytes(config.readLimit)
if readLimit > math.MaxInt {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This clamp is dead code: the fork's signature is func (s *Server) SetReadLimits(limit int64), and on 64-bit platforms math.MaxInt == math.MaxInt64, so the branch is unreachable. On a 32-bit build it would silently shrink a legitimately-configured limit for no reason, since the fork stores it as int64 throughout. Suggest dropping lines 386-388.

Comment thread evmrpc/metrics.go
errorClassKey = "error_class"
jsonrpcCodeKey = "jsonrpc_code"
rejectReasonKey = "reason"
protocolKey = "protocol"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Label-name inconsistency: this introduces protocol="http"|"ws", while the sibling metric in this repo already uses plane for the same concept (ratelimiter/registry.go:102rpc_rate_limit_rejected_total{plane}). The PR title/description and the ratelimiter doc tweak in this diff also both say "plane". Pick one name for the dimension across evmrpc metrics so dashboards can join on it.

Comment thread evmrpc/metrics.go
)
}

func recordWSAdmissionRejected(ctx context.Context, reason string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Two things here:

  1. The reason label now carries two disjoint vocabularies. HTTP emits the constants documented at lines 24-26 (oversize, busy); WS emits whatever the fork's hook passes, which is budget_wait_timeout / frame_admission_timeout (and only on context.DeadlineExceeded — see fireAdmissionEventOnBudgetTimeout in the fork). Neither WS value is listed in that const block, and WS never emits oversize, which the new max_request_body_bytes doc comment implies it does. Worth documenting the per-protocol reason sets next to the constants.
  2. The old recordRequestRejected doc comment explaining why there is no endpoint dimension (rejection happens pre-decode) was dropped. Consider keeping one comment above the pair covering both the protocol values and that rationale.

readJSON(t, conn, &secondResp)

require.Equal(t, json.Number("1"), firstResp.ID)
require.Equal(t, json.Number("2"), secondResp.ID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This test asserts nothing about the budget — two requests come back in ID order, which is also true with maxConcurrentRequestBytes: 0 (budget disabled). Set the budget to 0 and it still passes, so it does not guard the feature it is named for. To make it meaningful, assert the serialization the budget forces, e.g. record wall time and require the second response to arrive at least ~2×sleepDuration after the write, or drive the budget-exhausted path and assert the observable outcome.

Comment thread evmrpc/ws_admission_test.go Outdated
require.NoError(t, srv.EnableWS([]rpc.API{
{Namespace: "test", Service: wsAdmissionTestService{}},
}, wsConf))
require.Equal(t, defaultMaxRequestBodyBytes, effectiveMaxRequestBodyBytes(wsConf.readLimit))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This assertion is a tautology on the local wsConf value and duplicates TestEffectiveMaxRequestBodyBytes — it never observes what EnableWS actually handed to SetReadLimits. Either assert the effective limit behaviorally (send a frame just over defaultMaxRequestBodyBytes and expect the read-loop close) or drop the test; as written the name over-promises.

Comment thread evmrpc/ws_admission_test.go Outdated
conn := dialWSTestServer(t, srv)
defer conn.Close()

require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(payload)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Worth making explicit in a comment what this test proves: the client sent exactly one request and never a second frame, yet the connection is torn down — i.e. the budget is reserved speculatively before the next read. Also consider asserting on the close code / error rather than accepting any ReadMessage error, so the test can't pass for an unrelated failure (e.g. a dial-time or write-side error).

Comment thread evmrpc/rpcstack.go Outdated
srv.SetReadLimits(readLimit)
// maxConcurrentRequestBytes is passed through raw; rpc.Server.recomputeWSConcurrentBudget
// raises it to readLimit when smaller, matching
// newRequestSizeLimiter’s max(budget, maxBody) rule on the HTTP protocol.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Two nits in this comment block: it contains a non-ASCII right single quote (U+2019) in newRequestSizeLimiter’s where surrounding comments are ASCII, and it names an unexported fork symbol (rpc.Server.recomputeWSConcurrentBudget) that is invisible from this repo and will rot silently if the fork renames it. The contract itself ("the fork raises the budget to the read limit when it is smaller") is accurate — just state it without the private symbol.

Comment thread ratelimiter/registry.go
}

// Allow reports whether the request from ip should be allowed for the given plane.
// Allow reports whether the request from ip should be allowed for the given protocol.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This drive-by rename makes the file internally inconsistent: the next line still documents rpc_rate_limit_rejected_total{plane}, the parameter is plane, and line 102 emits attribute.String("plane", plane). Either revert this line or rename the label/param too — but note the emitted label name is a breaking metric change, so reverting the comment is the cheaper fix. See also the naming comment on evmrpc/metrics.go:21.

Comment thread evmrpc/server.go

wsConfig := WsConfig{Origins: strings.Split(config.WSOrigins, ",")}
wsConfig.readLimit = DefaultWebsocketMaxMessageSize
wsConfig.readLimit = config.MaxRequestBodyBytes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] RPCEndpointConfig now carries both readLimit (WS) and maxRequestBodyBytes (HTTP), fed from the same config.MaxRequestBodyBytes. Two fields holding one config value invite divergence. Consider collapsing to maxRequestBodyBytes for both planes, or at minimum give readLimit (rpcstack.go:67) a comment saying it is max_request_body_bytes applied to WS frames — it is the only field in that struct without one.

@amir-deris

Copy link
Copy Markdown
Contributor Author

@seidroid once the upcoming changes in sei go-ethereum fork in this pr lands sei-protocol/go-ethereum#83, and we bump the go.mod version here, would that resolve the outstanding issues for this pr?

@seidroid

seidroid Bot commented Jul 29, 2026

Copy link
Copy Markdown

Claude finished @amir-deris's task in 4m 10s —— View job


Re: would go-ethereum#83 + a go.mod bump close this out?

Partly — it resolves the three biggest open items, but ~8 repo-side items remain, and one test in this PR will break on the bump.


What #83 fixes

1. Idle connections pinning budget → the ~25-connection cap and force-disconnects. Resolved. This was the standing blocker (and the one @bdchatham reproduced at 24-vs-25 idle sockets). #83 removes acquirePreDecode from Client.read's shared loop and adds a dedicated websocketCodec.readBatch that calls conn.NextReader() first, then acquires. Budget is held only while a frame is actually being decoded/served, not while blocked waiting for one. Covered fork-side by TestWSIdleConnectionsDoNotHoldBudget, TestWSManyIdleSubscriptions, and TestWSBudgetAcquiredOnlyOnFrame.

Worth noting the secondary effect: since commitFrameBudget trues the readLimit reservation down to rawLen right after decode, in-flight handlers are charged their actual frame size. So the "budget / max_request_body_bytes = hidden plane-wide concurrency cap" concern collapses to a transient decode-window constraint, and WS becomes size-weighted like HTTP. That also retires my 11:51 blocker.

2. WS oversize frames invisible on the counter (PLT-857). Resolved, no code change needed here. WSAdmissionReasonOversizeFrame = "oversize_frame" now fires through the same hook EnableWS already registers — including messages that only cross the limit partway through a run of continuation frames. rpcstack.go:395 forwards whatever reason it's handed, so evmrpc_requests_rejected_total{protocol="ws",reason="oversize_frame"} starts appearing for free. PLT-857 can close on the bump.

3. Silent disconnect on budget-wait timeout. Resolved. New errcodeBudgetWaitTimeout (-32005) is written to the peer before the close, which answers @bdchatham's "what the wait ends in matters more than how long it is." Caveat: it's still error-then-close, not per-request shedding — the connection and its subscriptions still go away. That makes the doc item below smaller but doesn't remove it.

Also confirmed while reading the fork: Cursor's "WS timeout zero not normalized" (Low) is a non-issue. newHandler runs the value through wsAdmissionTimeoutOrDefault (0/negative → 30s), unit-tested as TestWSAdmissionTimeoutOrDefault. An explicit ws_admission_timeout = 0 in app.toml behaves exactly as this PR's doc comment says. No change needed.


What #83 does not fix

⚠️ First, a regression the bump introduces in this PR's own tests.

TestEnableWSAdmissionTimeout (evmrpc/ws_admission_test.go:119-157) writes one frame with budget == readLimit == frameSize and asserts the connection closes. That only worked because of the pre-NextReader lookahead reservation #83 deletes. After the bump: frame 1 is served normally, the loop blocks in NextReader() holding nothing, and nothing ever closes the socket. The loop at line 151 calls conn.ReadMessage() with no read deadline set — it returns the id-1 response with err == nil, iterates, and then blocks forever. The 2s deadline check is never re-reached, so this hangs until the package timeout rather than failing cleanly. It needs rewriting to the new semantics: two frames back-to-back under a short ws_admission_timeout, then assert the -32005 response, then the close.

Relatedly, TestWSAdmissionHookBudgetWaitTimeout (line 159) will keep passing — but only because it drives rpc.NewCodec over net.Pipe, and plain jsonCodec.readBatch is now the one path that still acquires before a blocking read. Post-#83 it asserts behavior the WS plane no longer has. The fork re-pointed its own copy of that test at a real WS server for exactly this reason; this one should follow.

Then, everything already flagged that lives entirely in this repo:

# Item Where
1 WS frame default drops 10 MiB → 5 MiB — untouched by #83. Still needs a WS-specific knob, a raised default, or an explicit release note. Human-confirmed at 14:05:41. evmrpc/server.go:341
2 Removed exported DefaultWebsocketMaxMessageSize — release-note item evmrpc/server.go
3 Reason vocabulary — #83 makes this worse, not better. WS now emits three reasons (budget_wait_timeout, frame_admission_timeout, oversize_frame), none of them the documented oversize/busy. So reason="oversize" still never appears for WS despite what the new max_request_body_bytes comment implies. Map (oversize_frameoversize, *_timeoutbusy) or document the WS set. evmrpc/metrics.go:24-26,181
4 protocol vs plane label split; registry.go:89 is now internally inconsistent (comment says "protocol", the next line and the emitted attribute both say plane) evmrpc/metrics.go:21, ratelimiter/registry.go:89
5 Config docs need a #83-aware rewrite: "rejected by the read loop" → connection closed with 1009; budget-wait → -32005 then close; plus the 2× aggregate-memory note evmrpc/config/config.go:256,263 + toml template
6 Dead math.MaxInt clamp (SetReadLimits takes int64) evmrpc/rpcstack.go:386-388
7 Comment has a non-ASCII and names the unexported fork symbol recomputeWSConcurrentBudget evmrpc/rpcstack.go:390-392
8 Test rigor: TestEnableWSConcurrentRequestBytes still asserts only IDs (@bdchatham measured 21/40 passing with the budget disabled — that's a flake, not a signal; elapsed time discriminates at 403ms vs 201ms). TestEnableWSReadLimitDefault still tautological. No oversize-frame test — and #83 finally gives it something concrete to assert. No multi-connection test. No NewEVMWebSocketServer config→wsConfig mapping test. evmrpc/ws_admission_test.go
9 Nits: effectiveMaxRequestBodyBytes(max int64) shadows the builtin; readLimit and maxRequestBodyBytes both carry MaxRequestBodyBytes in RPCEndpointConfig request_limiter.go:14, rpcstack.go:67

Already closed on this branch, for completeness: the sei_legacy_http.go duplicated fallback (3a568d5), SetWSAdmissionTimeout not wired in production (1e025cf), and the go.mod-bump blocker (withdrawn earlier).


Short answer

Land the bump and #1 (the 10 MiB → 5 MiB default) is the only remaining item I'd call blocking — everything else on the list is docs, labels, or tests. But the bump is not a no-op for this PR: TestEnableWSAdmissionTimeout must be rewritten in the same commit or CI will hang, and the config/metrics docs describe pre-#83 behavior on both the oversize and budget-wait paths, so they need updating because of the bump rather than being fixed by it.

Fix the test + docs →

Nothing committed — this was an answer, not a change request.
amir/plt-776-evm-ws-admission-control

Comment thread evmrpc/server.go
seidroid[bot]
seidroid Bot previously requested changes Aug 4, 2026

@seidroid seidroid 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.

Solid extension of admission control to the WS plane, but it lands with a guaranteed CI failure: the new evm.ws_admission_timeout key is missing from the evmKeys manifest and evm.golden, which testutil/configtest/AGENTS.md explicitly requires. Two behavioral changes also need explicit sign-off/documentation — the WS frame limit default drops from 10 MiB to 5 MiB, and budget exhaustion now tears down WS connections rather than throttling them.

Findings: 3 blocking | 12 non-blocking | 10 posted inline

Blockers

  • None at the file/PR level.
  • 3 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • RPCEndpointConfig now carries both readLimit and maxRequestBodyBytes, both fed from config.MaxRequestBodyBytes (WS reads the former in EnableWS, HTTP reads the latter in EnableRPC). Two fields holding the same operator value on the same struct invites drift; consider collapsing them now that the semantics are unified.
  • Label vocabulary is inconsistent across the two admission metrics: this PR adds protocol={http,ws} to evmrpc_requests_rejected_total, while the sibling rpc_rate_limit_rejected_total uses plane. Picking one term would make cross-metric dashboard queries uniform.
  • The PR description says the rejection counter gains "a new plane (http/ws) dimension", but the code adds protocol. Worth fixing the description so it matches what operators will actually see, since it is the artifact people search for when wiring alerts.
  • The Cursor second-opinion pass produced no output (cursor-review.md is empty), so this review reflects only the Codex pass plus my own. Codex's single P1 (missing configtest manifest/golden rows) is included above and I confirmed it independently.
  • DefaultWebsocketMaxMessageSize was an exported constant in package evmrpc and is removed outright. No in-repo references remain, but if anything out-of-tree imports it the removal is a compile break.
  • 7 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/config/config.go
// WSAdmissionTimeout bounds how long a WebSocket connection waits for
// concurrent-byte budget to free before the next frame is read or committed.
// Zero or negative values use the go-ethereum default (30s).
WSAdmissionTimeout time.Duration `mapstructure:"ws_admission_timeout"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] WSAdmissionTimeout is a new resolved field and evm.ws_admission_timeout is a new read site, but neither is recorded in the configuration characterization suite. Two tests will fail:

  • TestManifestNamesEveryFieldCheckManifestCoversEveryField requires every field on Config to be named by some evmKeys row's Path/AlsoWrites or exempted at the call site. WSAdmissionTimeout is neither (the only exemptions are TraceAllowedTracers, TraceBakeTracers, MaxOpenConnections).
  • TestDefaultsMatchTheRecordedValuesevmrpc/config/testdata/evm.golden has no WSAdmissionTimeout line; it currently ends at MaxOpenConnections = int(2000).

testutil/configtest/AGENTS.md makes recording this mandatory rather than optional. Please add the row next to the other two limit keys:

{Key: "evm.ws_admission_timeout", Path: "WSAdmissionTimeout", Cast: configtest.CastDuration, Checked: true},

and regenerate evm.golden so the 30s default lands in the diff. The for i := range len(evmKeys) loop in FuzzReadConfig will pick up the nil/malformed seeds automatically once the row exists; a fuzzing.KindString, "30s" seed matching the other duration keys would be a nice addition.

(Also raised by the Codex pass.)

Comment thread evmrpc/server.go

wsConfig := WsConfig{Origins: strings.Split(config.WSOrigins, ",")}
wsConfig.readLimit = DefaultWebsocketMaxMessageSize
wsConfig.readLimit = config.MaxRequestBodyBytes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] This silently halves the default WS frame limit: the old wsConfig.readLimit = DefaultWebsocketMaxMessageSize was 10 MiB, and config.MaxRequestBodyBytes defaults to 5 MiB. Any existing WS client sending a frame in the 5–10 MiB range (large eth_call payloads, big batch requests) starts getting its connection killed by the read loop on an operator's unchanged app.toml.

Unifying the two limits under one key is a reasonable goal, but the default reduction is a user-visible regression on a public endpoint and neither the PR description ("replacing the removed DefaultWebsocketMaxMessageSize constant") nor the toml comments mention that the effective WS ceiling moves. Either raise DefaultConfig.MaxRequestBodyBytes so the WS plane keeps 10 MiB, or call the reduction out explicitly in the PR description and release notes so operators know to raise max_request_body_bytes before upgrading. Note the PR is labeled non-app-hash-breaking, which does not convey "RPC limit halved."

Comment thread evmrpc/config/config.go Outdated
// WebSocket JSON-RPC request bodies admitted for processing concurrently
// (independent budgets per protocol). HTTP uses Content-Length weighting and
// rejects over-budget requests fast (HTTP 429). WebSocket blocks until
// budget frees or WSAdmissionTimeout elapses. Set to 0 to disable the limit

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] This comment (and the matching toml text) says WS "blocks until budget frees or WSAdmissionTimeout elapses", but omits what happens when it does elapse. TestEnableWSAdmissionTimeout pins the actual behavior: the connection is closed and the in-flight response is never delivered. That test writes a single frame with maxConcurrentRequestBytes == readLimit and asserts conn.ReadMessage() errors — i.e. the client gets a dead socket, not a JSON-RPC error.

Two things need addressing:

  1. Document the teardown. An operator reading "blocks until budget frees or ws_admission_timeout elapses" will reasonably expect throttling, not connection loss. Contrast with the HTTP plane, which returns 429 and keeps the connection.
  2. Consider the cross-connection blast radius. The WS byte budget is server-wide, so one client holding 128 MiB of in-flight frames now causes unrelated WS connections to be dropped after 30s, taking their pending responses with them. Before this PR the WS plane had no budget and no such coupling. Subscribers (eth_subscribe) are hit hardest since they must re-establish and re-subscribe. If a JSON-RPC error response (as HTTP does) is available in the fork instead of a close, that would be a much gentler failure mode; if not, please at least document the teardown and confirm 30s is the right default here.

readJSON(t, conn, &secondResp)

require.Equal(t, json.Number("1"), firstResp.ID)
require.Equal(t, json.Number("2"), secondResp.ID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] These assertions don't exercise the feature under test. With the concurrent-byte budget disabled entirely, both requests would still complete and still return ids 1 and 2 — nothing here requires serialization, so the test passes whether or not SetWSConcurrentRequestBytes had any effect.

Assert the observable consequence of the budget instead, e.g. measure elapsed time around the two readJSON calls and require it to be at least 2*sleepDuration (serialized) rather than ~sleepDuration (concurrent). That also makes the id-ordering assertion meaningful — without the budget the two 200ms sleeps run concurrently and response ordering is a race.

Separately, readJSON's hardcoded 1s read deadline gives the second response only ~600ms of headroom past the two sleeps; under -race on loaded CI that is tight enough to flake. Consider deriving the deadline from sleepDuration.

Comment thread evmrpc/ws_admission_test.go Outdated
require.NoError(t, srv.EnableWS([]rpc.API{
{Namespace: "test", Service: wsAdmissionTestService{}},
}, wsConf))
require.Equal(t, defaultMaxRequestBodyBytes, effectiveMaxRequestBodyBytes(wsConf.readLimit))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This test's name promises it pins the WS read-limit default, but the only assertion is effectiveMaxRequestBodyBytes(wsConf.readLimit) == defaultMaxRequestBodyBytes — a pure-helper identity already covered by TestEffectiveMaxRequestBodyBytes at line 25. Nothing checks what EnableWS actually configured on the rpc.Server; the EnableWS call above could be deleted and the test would still pass.

To make it earn its name, drive it end to end: start the server with readLimit: 0, send a frame just over 5 MiB, and assert the connection is closed/rejected while a frame just under it succeeds.

Comment thread evmrpc/rpcstack.go Outdated
srv.SetBatchLimits(config.batchItemLimit, config.batchResponseSizeLimit)
srv.SetReadLimits(config.readLimit)
readLimit := effectiveMaxRequestBodyBytes(config.readLimit)
if readLimit > math.MaxInt {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This clamp looks copied from the HTTP path at line 334, but the two situations differ: there the clamp is load-bearing because the value is narrowed by int(bodyLimit) for SetHTTPBodyLimit. Here readLimit stays int64 and is passed to SetReadLimits without conversion, so on any 64-bit platform math.MaxInt == math.MaxInt64 and the branch is unreachable — while on 32-bit it silently lowers a configured limit for no reason. Suggest dropping it.

Comment thread evmrpc/metrics.go
)
}

func recordWSAdmissionRejected(ctx context.Context, reason string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] reason here is whatever string the go-ethereum fork hands the hook, which introduces two problems on a metric whose reason dimension was previously a closed set:

  • Mixed vocabularies. The HTTP side emits the locally-declared oversize/busy (lines 25-26), while WS emits fork constants like rpc.WSAdmissionReasonBudgetWaitTimeout. A single evmrpc_requests_rejected_total{reason=...} query now returns values from two unrelated namespaces, and the WS values are documented nowhere in this repo. Either map the fork reasons onto oversize/busy, or declare the WS reason values as consts here alongside the existing ones with a comment.
  • Unbounded label cardinality. Nothing validates the string before it becomes an OTel attribute, so a fork change that includes any dynamic detail in the reason becomes a metric cardinality problem in production. An allowlist switch with an "other" fallback would bound it.

Comment thread evmrpc/rpcstack.go
// newRequestSizeLimiter’s max(budget, maxBody) rule on the HTTP protocol.
srv.SetWSConcurrentRequestBytes(config.maxConcurrentRequestBytes)
srv.SetWSAdmissionTimeout(config.wsAdmissionTimeout)
srv.SetWSAdmissionEventHook(func(reason string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The fork API is named SetWSAdmissionEventHook (generic: event), but this handler unconditionally records every invocation as a rejection into evmrpc_requests_rejected_total. If the fork also fires the hook for non-rejection events — admitted, entered-wait, budget-released — the counter over-reports rejections, which is exactly the metric an operator would alert on. Please confirm the fork fires it only on rejection, and if it fires for other events, filter by reason here (and add a brief comment recording the full set of reasons the hook can emit, since that contract lives outside this repo).

Comment thread evmrpc/metrics.go
// admission control. reason is one of rejectReasonOversize / rejectReasonBusy.
// No endpoint dimension is recorded: the rejection happens before the JSON-RPC
// method is decoded, so it is not yet known.
func recordRequestRejected(ctx context.Context, reason string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The doc comment removed here explained something non-obvious that is still true and still worth keeping: that no endpoint dimension is recorded because rejection happens before the JSON-RPC method is decoded. Suggest restoring it (updated for the new protocol label) and giving recordWSAdmissionRejected a one-liner too, since it's the only place the WS reason values enter the metric.

Comment thread ratelimiter/registry.go
}

// Allow reports whether the request from ip should be allowed for the given plane.
// Allow reports whether the request from ip should be allowed for the given protocol.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This rename leaves the comment inconsistent with everything around it: the next line still says rpc_rate_limit_rejected_total{plane}, the parameter is still plane, and the emitted attribute on line 102 is still "plane". The change is also unrelated to this PR's scope (the new protocol label lives on a different metric in evmrpc). Suggest reverting this line, or renaming the parameter and label consistently in a separate PR if the vocabulary switch is intended.

Comment thread evmrpc/ws_admission_test.go Outdated
Comment thread ratelimiter/registry.go Outdated

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d023273. Configure here.

Comment thread evmrpc/config/config.go
seidroid[bot]
seidroid Bot previously requested changes Aug 4, 2026

@seidroid seidroid 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.

Extending the HTTP admission knobs to the WS plane is well-structured (shared effectiveMaxRequestBodyBytes, per-protocol metric label, real end-to-end WS tests, documented upgrade note for the 10 MiB → 5 MiB WS frame default). One blocker: the new evm.ws_admission_timeout key is not recorded in the [evm] configuration characterization suite, so TestManifestNamesEveryField and TestDefaultsMatchTheRecordedValues will fail.

Findings: 1 blocking | 9 non-blocking | 5 posted inline

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • The Cursor second-opinion pass produced no output (cursor-review.md is empty), so this review merges only the Codex pass and my own findings. Codex's one High (missing configtest manifest/golden rows) is included and I confirmed it independently against evmrpc/config/testdata/evm.golden and evmKeys.
  • RPCEndpointConfig still carries both readLimit (read by EnableWS) and maxRequestBodyBytes (read by EnableRPC), and evmrpc/server.go:341 / the HTTP path now feed both from the same config.MaxRequestBodyBytes. Now that the semantics are unified, collapsing them to one field would remove the drift risk.
  • The new WS tests rely on fairly tight wall-clock windows (50 ms admission timeout, 100 ms read deadlines, waitTimeout+300ms Eventually budgets) and run under -race in CI. The serialization assertions use lower bounds and are safe, but the -32005 timeout assertions could flake on a loaded runner; consider widening the Eventually windows (the lower bounds are what carry the signal).
  • TestEffectiveMaxRequestBodyBytes (ws_admission_test.go:26) overlaps the assertion just added to request_limiter_test.go's "zero maxBody uses default cap" subtest. Keep one of the two — the helper's unit test belongs next to the helper.
  • No test covers the NewEVMWebSocketServer config → wsConfig mapping (readLimit/maxConcurrentRequestBytes/wsAdmissionTimeout), which is the only production wiring of the three new knobs; a small table test there would catch a future mis-wire that the EnableWS-level tests can't.
  • 4 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/config/config.go
// When the wait expires the peer receives JSON-RPC error -32005
// ("timed out waiting for concurrent request-byte budget") and the connection
// is closed. Zero or negative values use the go-ethereum default (30s).
WSAdmissionTimeout time.Duration `mapstructure:"ws_admission_timeout"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] Adding this key without recording it in the [evm] characterization suite fails two tests:

  • TestManifestNamesEveryFieldCheckManifestCoversEveryField requires every resolved field to be named by a row's Path (or exempted); evmKeys in evmrpc/config/config_fuzz_test.go has no WSAdmissionTimeout row.
  • TestDefaultsMatchTheRecordedValuesevmrpc/config/testdata/evm.golden goes straight from MaxConcurrentRequestBytes = int64(134217728) to MaxOpenConnections = int(2000), so the dumped defaults won't match once WSAdmissionTimeout = time.Duration(30s) appears.

Per testutil/configtest/AGENTS.md, add the row as written ({Key: "evm.ws_admission_timeout", Path: "WSAdmissionTimeout", Cast: configtest.CastDuration, Checked: true, Why: ...}), keep the per-row nil/malformed fuzz seeds reachable, and regenerate the golden with go test ./evmrpc/config/ -run TestDefaultsMatchTheRecordedValues -update so the new 30s default lands as a reviewable diff.

Comment thread ratelimiter/registry.go
1,
metric.WithAttributes(
attribute.String("plane", plane),
attribute.String("protocol", protocol),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This renames an operator-visible label on an existing, unrelated metric: rpc_rate_limit_rejected_total{plane} becomes {protocol}. The rename is defensible for vocabulary consistency with the new evmrpc_requests_rejected_total{protocol}, but it is silent — the CHANGELOG entry only mentions the label added to the rejection counter. Either keep plane here (out of this PR's scope) or add a one-line telemetry-migration note to the CHANGELOG.

Mitigating: Registry.Allow has no in-repo production caller today (only ratelimiter/registry_test.go), so the blast radius is likely nil — which is why I'd treat Codex's Medium as non-blocking rather than a blocker.

Comment thread evmrpc/metrics.go
// admission control. reason is one of rejectReasonOversize / rejectReasonBusy.
// No endpoint dimension is recorded: the rejection happens before the JSON-RPC
// method is decoded, so it is not yet known.
func recordRequestRejected(ctx context.Context, reason string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The doc comment on recordRequestRejected was dropped with no replacement — it documented the oversize/busy reason vocabulary and, usefully, why there is no endpoint dimension (the rejection happens pre-decode, so the method isn't known yet). That rationale is not recoverable from the code. Looks accidental; worth restoring, updated for the new protocol="http" attribute.

Comment thread evmrpc/rpcstack.go
srv.SetReadLimits(config.readLimit)
readLimit := effectiveMaxRequestBodyBytes(config.readLimit)
srv.SetReadLimits(readLimit)
// maxConcurrentRequestBytes is passed through raw; rpc.Server.recomputeWSConcurrentBudget

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This comment names an unexported fork internal (rpc.Server.recomputeWSConcurrentBudget) to justify passing the budget through raw. The invariant is the right thing to record, but pinned to a symbol this repo can't see or compile against, so it rots silently if the fork renames it. Prefer stating it behaviorally — "the RPC server raises a positive budget below the read limit up to the read limit, so a maximum-size frame is always admissible (covered by TestEnableWSConcurrentBudgetBelowReadLimitAdmitsMaxFrame)" — plus a link to the fork PR.

func TestEnableWSConcurrentBudgetBelowReadLimitAdmitsMaxFrame(t *testing.T) {
const pad = 48

makeMsg := func(id int) string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This local makeMsg is makeSleepMsg(id, 0, pad) — same format string, same padding. Reuse the shared helper so the frame-size arithmetic these tests depend on stays defined in one place.

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

Beyond the config-characterization finding above, I also checked back on two things I flagged in an earlier pass: the TestEnableWSAdmissionTimeout hang (now rewritten to send two frames and poll for the timeout response — passes reliably) and the planeprotocol metric-label inconsistency in ratelimiter/registry.go (now fully consistent across the doc comment, parameter, and emitted attribute). Both are resolved, no further action needed on those.

Extended reasoning...

This is a follow-up note rather than a fresh review: the only new issue this run is the WSAdmissionTimeout config-characterization gap already attached as an inline comment (confirmed by running go test ./evmrpc/config/..., which fails on both TestDefaultsMatchTheRecordedValues and TestManifestNamesEveryField). I additionally re-verified two items from my prior review round that are now fixed in the current tree: TestEnableWSAdmissionTimeout no longer hangs (confirmed by running it directly — it now sends two frames and polls for the -32005 error response instead of blocking on an unbounded ReadMessage), and the plane/protocol metric label naming in ratelimiter/registry.go is now consistent end-to-end. Recording this so neither is re-litigated in a future pass.

Comment thread evmrpc/config/config.go

@seidroid seidroid 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.

Solid, well-documented extension of the HTTP admission-control knobs to the WebSocket plane: config plumbing is complete (KeySpec row, golden, template, defaults all updated per testutil/configtest/AGENTS.md), and the new tests exercise real websocket connections. No correctness blockers found; the notes below are about an intentional-but-user-visible default reduction, an unrelated metric-label rename, timing margins in the new tests, and a lost doc comment.

Findings: 0 blocking | 12 non-blocking | 6 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's second-opinion pass produced no output (cursor-review.md is empty), so this review reflects only the Claude and Codex passes.
  • Per-protocol independent budgets mean process-wide peak in-flight request bytes double (256 MiB at the default). This is clearly documented in both the struct comment and app.toml, but consider whether a single shared budget (or a WS budget derived as a fraction of the HTTP one) would be a safer default than 2x the previous ceiling.
  • On budget-wait timeout the WS connection is closed, which drops that connection's active subscriptions. Since subscriptions are the dominant :8546 use case, a transient overload caused by an unrelated frame now tears down long-lived subscribers where previously nothing on the WS plane could. Worth confirming (fork-side) that returning -32005 for the offending frame without closing the connection isn't the better contract.
  • No test asserts that EnableWS itself installs the admission event hook — both hook tests (TestWSOversizeFrameFiresAdmissionHook, TestWSAdmissionHookBudgetWaitTimeout) construct a raw rpc.Server and call SetWSAdmissionEventHook directly, so a regression that dropped the srv.SetWSAdmissionEventHook(...) line in rpcstack.go would leave all tests green.
  • PR description is stale relative to the code: it says the new label is plane (http/ws) while the code and CHANGELOG use protocol, and it doesn't mention the new ws_admission_timeout config key or the ratelimiter label rename. Worth syncing since the CHANGELOG entry is derived from it.
  • I could not compile in this environment to confirm the pinned fork (github.com/sei-protocol/go-ethereum v1.15.7-sei-19, unchanged in go.mod) exports SetWSConcurrentRequestBytes / SetWSAdmissionTimeout / SetWSAdmissionEventHook / WSAdmissionReason* and implements the documented semantics (0 disables the budget, non-positive timeout falls back to 30s, recomputeWSConcurrentBudget raises the budget to readLimit). The comments in rpcstack.go and the config docs assert all four; CI's build is the check.
  • 6 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/server.go

wsConfig := WsConfig{Origins: strings.Split(config.WSOrigins, ",")}
wsConfig.readLimit = DefaultWebsocketMaxMessageSize
wsConfig.readLimit = config.MaxRequestBodyBytes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This silently drops the WS frame cap from 10 MiB to 5 MiB for every operator running default config, and the only remedy documented in the CHANGELOG (max_request_body_bytes = 10485760) also doubles the HTTP body limit — i.e. restoring WS behavior weakens the HTTP protection this same knob was added for. The failure mode is also unfriendly: a 6 MiB frame gets close code 1009 with no JSON-RPC error, so clients see a bare disconnect rather than a diagnosable error.

Coupling both planes to one knob is defensible and you've documented it well, but consider either (a) a dedicated ws_max_frame_bytes defaulting to 10 MiB, or (b) defaulting the WS read limit to max(10 MiB, max_request_body_bytes) so the upgrade is non-breaking for WS while still being tightenable. If the tightening is deliberate, it's worth confirming with whoever owns the public WS endpoints that no current client sends 5–10 MiB frames.

func readJSON(t *testing.T, conn *websocket.Conn, dest *rpcResponse) {
t.Helper()

_ = conn.SetReadDeadline(time.Now().Add(time.Second))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] A fixed 1s read deadline is thin for TestEnableWSConcurrentRequestBytes, where the second response is expected at ~400 ms by construction (two serialized 200 ms sleeps) — under -race with CI shards running in parallel that's only ~2.5x headroom, and a miss fails as require.NoError(err) on an i/o timeout rather than as an admission-control failure.

The lower-bound duration assertions in these tests are safe under slowness (a slow machine only makes them more likely to pass), so widening the deadlines costs nothing: consider 5s here, and similarly widening the require.Eventually windows at lines 215 and 279 (waitTimeout+300ms) to a couple of seconds. The tick interval already keeps the tests fast in the common case.

Comment thread evmrpc/metrics.go
func recordRequestRejected(ctx context.Context, reason string) {
metrics.requestRejectedCount.Add(ctx, 1,
metric.WithAttributes(
attribute.String(protocolKey, protocolHTTP),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The doc comment on recordRequestRejected was dropped in this change. It carried two things not obvious from the body — that reason is one of rejectReasonOversize / rejectReasonBusy, and why there is no endpoint dimension (the rejection happens pre-decode, so the JSON-RPC method isn't known yet). Both still hold; worth restoring and extending with the new protocol="http" label rather than losing the rationale.

Comment thread evmrpc/metrics.go
case rpc.WSAdmissionReasonBudgetWaitTimeout, rpc.WSAdmissionReasonFrameAdmissionTimeout:
return rejectReasonBusy
default:
return reason

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The default branch forwards an unrecognized fork reason verbatim into the reason metric label. Today the fork's reasons are a small closed set of constants, so cardinality is bounded — but this makes evmrpc_requests_rejected_total's label space depend on a string another module controls, and a future fork reason that interpolates any per-connection detail would blow up the series count. Mapping unknown values to a fixed "other" (optionally logging the raw reason once) keeps the metric bounded by construction while still surfacing that something new is firing.

Comment thread ratelimiter/registry.go
1,
metric.WithAttributes(
attribute.String("plane", plane),
attribute.String("protocol", protocol),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Codex flags this as breaking existing dashboards for rpc_rate_limit_rejected_total{plane=...}. That overstates it: ratelimiter has no importers anywhere in the tree (Registry.Allow is only called from its own package's tests), so this counter is never emitted by a running node and there is nothing downstream to break — now is in fact the cheapest possible time to rename it.

The real nit is scope: this is an unrelated package touched for naming consistency, and unlike evmrpc_requests_rejected_total the rename isn't mentioned in the CHANGELOG. Either split it out or add a one-liner to the changelog entry so the label vocabulary change is recorded in one place.

Comment thread evmrpc/rpcstack.go
srv := rpc.NewServer()
srv.SetBatchLimits(config.batchItemLimit, config.batchResponseSizeLimit)
srv.SetReadLimits(config.readLimit)
readLimit := effectiveMaxRequestBodyBytes(config.readLimit)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] RPCEndpointConfig now carries both readLimit and maxRequestBodyBytes, and after this change both are fed from the same config.MaxRequestBodyBytes — but the HTTP path reads only maxRequestBodyBytes (leaving readLimit zero) and the WS path reads only readLimit (leaving maxRequestBodyBytes zero). Two same-meaning fields where each path silently ignores the other is an easy trap for the next caller, especially since readLimit == 0 and maxRequestBodyBytes == 0 now mean the same thing. Consider collapsing to maxRequestBodyBytes and dropping readLimit, or adding a comment on the struct saying which plane consumes which.

@seidroid
seidroid Bot dismissed stale reviews from themself August 4, 2026 10:54

Superseded: latest AI review found no blocking issues.

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

No new bugs found this run. The blockers raised in earlier rounds (config characterization suite missing WSAdmissionTimeout, and TestEnableWSAdmissionTimeout hanging against the sei-19 go-ethereum fork) have been fixed in the latest commits, and the WS frame-size default change is now called out in the CHANGELOG upgrade guide. Given this still touches production JSON-RPC/WebSocket admission control with a user-visible default change and a cross-connection blast-radius consideration (one client's in-flight budget can now cause unrelated WS connections to be dropped), I'd like a human to take a final look before merge.

Extended reasoning...

Overview

This PR extends existing HTTP JSON-RPC admission control (max request body size, max concurrent request bytes) to the WebSocket plane on :8546, which previously had only a hardcoded 10 MiB frame cap and no concurrency budget. It touches evmrpc/config/config.go (new WSAdmissionTimeout field), evmrpc/rpcstack.go (wires the new go-ethereum fork APIs SetReadLimits/SetWSConcurrentRequestBytes/SetWSAdmissionTimeout/SetWSAdmissionEventHook), evmrpc/metrics.go (new protocol-labeled rejection metric), evmrpc/server.go, and ratelimiter/registry.go (label rename), plus a large new test file (evmrpc/ws_admission_test.go).

Security risks

No injection/auth/crypto surface. The main risk is availability/DoS-adjacent: the new WS-side concurrent-byte budget is process-wide, so under this PR one client sending large in-flight frames can now cause unrelated WS connections (including eth_subscribe subscribers) to be dropped after the admission timeout, a coupling that did not exist before this PR. This was raised in review and is a behavior/design tradeoff rather than a bug, but it changes production failure characteristics on a public endpoint.

Level of scrutiny

This warrants more than mechanical review: it changes a default effective limit on a public-facing endpoint (WS frame cap moves from a hardcoded 10 MiB to the shared max_request_body_bytes default of 5 MiB unless operators override it), and it depends on exact semantics of a companion go-ethereum fork PR (sei-19) for its admission/timeout behavior. Multiple blocker-level issues were found and fixed over several review rounds (config characterization suite omission, a test that would hang in CI against the pinned fork version), which itself is a signal of real complexity in this area.

Other factors

No bugs were found by the bug hunting system in this run. Cross-checking the previously reported blockers against the current tree: the evmrpc/config/config_fuzz_test.go manifest and testdata/evm.golden now include WSAdmissionTimeout (fixed in the 'Updated config tests' commit), and TestEnableWSAdmissionTimeout now drives two back-to-back frames with a bounded read deadline instead of the single-frame/unbounded-read pattern that would hang under the pinned fork (fixed in 'Fixed ws_admission_tests'). The WS frame-size default reduction and the timeout teardown behavior are now documented in the CHANGELOG and config/toml comments. Remaining open review comments are nit-level (a dropped doc comment, a comment referencing an unexported fork-internal symbol, minor test duplication) and not blocking on their own.

@amir-deris
amir-deris added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 1e8ccd0 Aug 4, 2026
77 of 78 checks passed
@amir-deris
amir-deris deleted the amir/plt-776-evm-ws-admission-control branch August 4, 2026 11:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants