evmrpc: extend admission control to the WebSocket plane - #3818
Conversation
|
The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ 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
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
PR SummaryMedium Risk Overview 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 2× the configured value).
Config, Reviewed by Cursor Bugbot for commit 0f8d6a5. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
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.SetWSAdmissionTimeoutandrpc.WSAdmissionReasonBudgetWaitTimeoutdo not exist anywhere in this repo, so they must come from the sei go-ethereum fork — but this PR leavesgo.modpinned atgithub.com/sei-protocol/go-ethereum v1.15.7-sei-18with 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 incrementsevmrpc_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:33still inlines the samemaxBody <= 0 → defaultMaxRequestBodyBytesfallback that was just extracted intoeffectiveMaxRequestBodyBytes. 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_bytesWS configuration (the case flagged inline), nor WS oversize-frame rejection and itsplane="ws", reason="oversize"metric label. Both are the behaviors the config docs newly promise. effectiveMaxRequestBodyBytes(max int64)shadows themaxbuiltin;maxBytes/configuredreads better even though no enabled linter flags it.rpcResponse.Result,.Error, and.JSONRPCare 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.
| readLimit = math.MaxInt | ||
| } | ||
| srv.SetReadLimits(readLimit) | ||
| srv.SetWSConcurrentRequestBytes(config.maxConcurrentRequestBytes) |
There was a problem hiding this comment.
[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.
| srv.SetBatchLimits(config.batchItemLimit, config.batchResponseSizeLimit) | ||
| srv.SetReadLimits(config.readLimit) | ||
| readLimit := effectiveMaxRequestBodyBytes(config.readLimit) | ||
| if readLimit > math.MaxInt { |
There was a problem hiding this comment.
[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.
| } | ||
| srv.SetReadLimits(readLimit) | ||
| srv.SetWSConcurrentRequestBytes(config.maxConcurrentRequestBytes) | ||
| srv.SetWSAdmissionEventHook(func(reason string) { |
There was a problem hiding this comment.
[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.
|
|
||
| wsConfig := WsConfig{Origins: strings.Split(config.WSOrigins, ",")} | ||
| wsConfig.readLimit = DefaultWebsocketMaxMessageSize | ||
| wsConfig.readLimit = config.MaxRequestBodyBytes |
There was a problem hiding this comment.
[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.
| ) | ||
| } | ||
|
|
||
| func recordWSAdmissionRejected(ctx context.Context, reason string) { |
There was a problem hiding this comment.
[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.
| require.NoError(t, srv.EnableWS([]rpc.API{ | ||
| {Namespace: "test", Service: wsAdmissionTestService{}}, | ||
| }, wsConf)) | ||
| require.Equal(t, defaultMaxRequestBodyBytes, effectiveMaxRequestBodyBytes(wsConf.readLimit)) |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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.
|
|
||
| _ = conn.SetReadDeadline(time.Now().Add(time.Second)) | ||
| msgType, data, err := conn.ReadMessage() | ||
| require.IsType(t, websocket.TextMessage, msgType) |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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).
@seidroid the upgrade of version to latest sei go-ethereum fork has been merged in this PR: Here are the changes in the latest version of sei go-ethereum fork: For example, |
|
Claude finished @amir-deris's task in 3m 29s —— View job Re: fork APIs — you're right, that blocker is withdrawnThanks for the links. Confirming and correcting my review.
1. "Dependency not bumped" — withdrawn, my errorThe bump landed separately in #3804 and is already in this branch's history ( CI on 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:
3. Budget normalization — narrowed, and I need to correct part of my own claimCorrection: 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:
Except it can't be used as evidence either way, because of the unasserted-fields finding: the test only checks One-line change makes this decisive — add to require.Nil(t, firstResp.Error)
require.NotEmpty(t, firstResp.Result)
require.Nil(t, secondResp.Error)If that holds, a frame of exactly Two questions only you can answer cheaply from the fork:
Still-standing non-blocking itemsUnaffected by the above: the duplicated Nothing committed — this was an answer, not a change request. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 / Cursorcheck 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_bytesis actually rejected/closed by the read loop. That is half the headline feature (readLimitwiring); the added tests only exercise the concurrent-byte budget and the 0→default normalization helper. A single test writing areadLimit+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
planelabel to the pre-existingevmrpc_requests_rejected_totalcounter changes the series shape. Prometheus queries that only group byreasonkeep 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.
| // 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) { |
There was a problem hiding this comment.
[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.
| // 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) |
There was a problem hiding this comment.
[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.
|
|
||
| wsConfig := WsConfig{Origins: strings.Split(config.WSOrigins, ",")} | ||
| wsConfig.readLimit = DefaultWebsocketMaxMessageSize | ||
| wsConfig.readLimit = config.MaxRequestBodyBytes |
There was a problem hiding this comment.
[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.
| ) | ||
| } | ||
|
|
||
| func recordWSAdmissionRejected(ctx context.Context, reason string) { |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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).
| require.NoError(t, srv.EnableWS([]rpc.API{ | ||
| {Namespace: "test", Service: wsAdmissionTestService{}}, | ||
| }, wsConf)) | ||
| require.Equal(t, defaultMaxRequestBodyBytes, effectiveMaxRequestBodyBytes(wsConf.readLimit)) |
There was a problem hiding this comment.
[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.
|
|
||
| _ = conn.SetReadDeadline(time.Now().Add(time.Second)) | ||
| msgType, data, err := conn.ReadMessage() | ||
| require.Equal(t, websocket.TextMessage, msgType) |
There was a problem hiding this comment.
[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.
| srv.SetBatchLimits(config.batchItemLimit, config.batchResponseSizeLimit) | ||
| srv.SetReadLimits(config.readLimit) | ||
| readLimit := effectiveMaxRequestBodyBytes(config.readLimit) | ||
| if readLimit > math.MaxInt { |
There was a problem hiding this comment.
[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.
Superseded: latest AI review found no blocking issues.
|
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 |
There was a problem hiding this comment.
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.
TestWSAdmissionHookBudgetWaitTimeoutonly 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 MiBnow 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 nogo.modbump —go.mod:281still pinssei-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.gonow has bothreadLimit(WS, fed fromMaxRequestBodyBytes) andmaxRequestBodyBytes(HTTP, fed from the same config field) in the same embeddedRPCEndpointConfig. Two fields carrying one config value invites divergence; consider collapsing tomaxRequestBodyBytesfor both planes, or at least updating thereadLimitfield comment (rpcstack.go:66) to say it ismax_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.
|
|
||
| wsConfig := WsConfig{Origins: strings.Split(config.WSOrigins, ",")} | ||
| wsConfig.readLimit = DefaultWebsocketMaxMessageSize | ||
| wsConfig.readLimit = config.MaxRequestBodyBytes |
There was a problem hiding this comment.
[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:
- Clients currently sending 5–10 MiB WS frames (large batch requests, big
eth_callpayloads, 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. - 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.
| // 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) |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
| // 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. |
There was a problem hiding this comment.
[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.
| ) | ||
| } | ||
|
|
||
| func recordWSAdmissionRejected(ctx context.Context, reason string) { |
There was a problem hiding this comment.
[suggestion] Two things here:
reasonnow carries disjoint value sets per plane — HTTP emitsoversize/busyfrom the local constants, while WS passes the fork's string through verbatim (rpc.WSAdmissionReasonBudgetWaitTimeout). Queryingevmrpc_requests_rejected_totalbyreasonalone becomes plane-dependent, and any future fork-side reason value silently appears as a new label value. Consider mapping fork reasons onto the existingrejectReason*constants (budget exhaustion is conceptuallybusy), or documenting the full union of values next to them.- The doc comment removed from
recordRequestRejectedcarried 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;recordWSAdmissionRejectedcurrently 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.
| srv.SetBatchLimits(config.batchItemLimit, config.batchResponseSizeLimit) | ||
| srv.SetReadLimits(config.readLimit) | ||
| readLimit := effectiveMaxRequestBodyBytes(config.readLimit) | ||
| if readLimit > math.MaxInt { |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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.
| require.NoError(t, srv.EnableWS([]rpc.API{ | ||
| {Namespace: "test", Service: wsAdmissionTestService{}}, | ||
| }, wsConf)) | ||
| require.Equal(t, defaultMaxRequestBodyBytes, effectiveMaxRequestBodyBytes(wsConf.readLimit)) |
There was a problem hiding this comment.
[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.
| _, err := io.WriteString(p2, payload) | ||
| require.NoError(t, err) | ||
|
|
||
| deadline := time.Now().Add(waitTimeout + 300*time.Millisecond) |
There was a problem hiding this comment.
[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.
|
|
||
| _ = conn.SetReadDeadline(time.Now().Add(time.Second)) | ||
| msgType, data, err := conn.ReadMessage() | ||
| require.Equal(t, websocket.TextMessage, msgType) |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.mdis empty), so this review merges only Codex's finding with my own. Codex's single High finding is confirmed (see the inline blocker onrpcstack.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, bigeth_callpayloads) 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
DefaultWebsocketMaxMessageSizeis an API break for any external importer ofevmrpc(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 MiBnow 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_totalseries changes its series identity. Queries grouping only byreasonkeep 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_bytesshould be rejected by the read loop. A test writing areadLimit+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 →wsConfigmapping (readLimit/maxConcurrentRequestBytes/wsAdmissionTimeout), which is the only production path where these knobs reach the WS plane. An assertion onhttpServer.WsConfigafter construction would lock the wiring down. - Nit:
effectiveMaxRequestBodyBytes(max int64)shadows the predeclaredmaxbuiltin;maxBodymatches the existing naming innewRequestSizeLimiter. - Nit:
rpcResponse.Resultand.JSONRPCare 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.
| // 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) |
There was a problem hiding this comment.
[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.
| srv.SetBatchLimits(config.batchItemLimit, config.batchResponseSizeLimit) | ||
| srv.SetReadLimits(config.readLimit) | ||
| readLimit := effectiveMaxRequestBodyBytes(config.readLimit) | ||
| if readLimit > math.MaxInt { |
There was a problem hiding this comment.
[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.
| errorClassKey = "error_class" | ||
| jsonrpcCodeKey = "jsonrpc_code" | ||
| rejectReasonKey = "reason" | ||
| protocolKey = "protocol" |
There was a problem hiding this comment.
[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:102 → rpc_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.
| ) | ||
| } | ||
|
|
||
| func recordWSAdmissionRejected(ctx context.Context, reason string) { |
There was a problem hiding this comment.
[suggestion] Two things here:
- The
reasonlabel 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 isbudget_wait_timeout/frame_admission_timeout(and only oncontext.DeadlineExceeded— seefireAdmissionEventOnBudgetTimeoutin the fork). Neither WS value is listed in that const block, and WS never emitsoversize, which the newmax_request_body_bytesdoc comment implies it does. Worth documenting the per-protocol reason sets next to the constants. - The old
recordRequestRejecteddoc comment explaining why there is noendpointdimension (rejection happens pre-decode) was dropped. Consider keeping one comment above the pair covering both theprotocolvalues and that rationale.
| readJSON(t, conn, &secondResp) | ||
|
|
||
| require.Equal(t, json.Number("1"), firstResp.ID) | ||
| require.Equal(t, json.Number("2"), secondResp.ID) |
There was a problem hiding this comment.
[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.
| require.NoError(t, srv.EnableWS([]rpc.API{ | ||
| {Namespace: "test", Service: wsAdmissionTestService{}}, | ||
| }, wsConf)) | ||
| require.Equal(t, defaultMaxRequestBodyBytes, effectiveMaxRequestBodyBytes(wsConf.readLimit)) |
There was a problem hiding this comment.
[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.
| conn := dialWSTestServer(t, srv) | ||
| defer conn.Close() | ||
|
|
||
| require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(payload))) |
There was a problem hiding this comment.
[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).
| 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. |
There was a problem hiding this comment.
[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.
| } | ||
|
|
||
| // 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. |
There was a problem hiding this comment.
[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.
|
|
||
| wsConfig := WsConfig{Origins: strings.Split(config.WSOrigins, ",")} | ||
| wsConfig.readLimit = DefaultWebsocketMaxMessageSize | ||
| wsConfig.readLimit = config.MaxRequestBodyBytes |
There was a problem hiding this comment.
[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.
|
@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? |
|
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 fixes1. 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 Worth noting the secondary effect: since 2. WS oversize frames invisible on the counter (PLT-857). Resolved, no code change needed here. 3. Silent disconnect on budget-wait timeout. Resolved. New Also confirmed while reading the fork: Cursor's "WS timeout zero not normalized" (Low) is a non-issue. What #83 does not fix
Relatedly, Then, everything already flagged that lives entirely in this repo:
Already closed on this branch, for completeness: the Short answerLand 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: Nothing committed — this was an answer, not a change request. |
There was a problem hiding this comment.
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
RPCEndpointConfignow carries bothreadLimitandmaxRequestBodyBytes, both fed fromconfig.MaxRequestBodyBytes(WS reads the former inEnableWS, HTTP reads the latter inEnableRPC). 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}toevmrpc_requests_rejected_total, while the siblingrpc_rate_limit_rejected_totalusesplane. 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 addsprotocol. 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.mdis 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. DefaultWebsocketMaxMessageSizewas an exported constant in packageevmrpcand 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.
| // 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"` |
There was a problem hiding this comment.
[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:
TestManifestNamesEveryField—CheckManifestCoversEveryFieldrequires every field onConfigto be named by someevmKeysrow'sPath/AlsoWritesor exempted at the call site.WSAdmissionTimeoutis neither (the only exemptions areTraceAllowedTracers,TraceBakeTracers,MaxOpenConnections).TestDefaultsMatchTheRecordedValues—evmrpc/config/testdata/evm.goldenhas noWSAdmissionTimeoutline; it currently ends atMaxOpenConnections = 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.)
|
|
||
| wsConfig := WsConfig{Origins: strings.Split(config.WSOrigins, ",")} | ||
| wsConfig.readLimit = DefaultWebsocketMaxMessageSize | ||
| wsConfig.readLimit = config.MaxRequestBodyBytes |
There was a problem hiding this comment.
[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."
| // 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 |
There was a problem hiding this comment.
[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:
- 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.
- 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) |
There was a problem hiding this comment.
[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.
| require.NoError(t, srv.EnableWS([]rpc.API{ | ||
| {Namespace: "test", Service: wsAdmissionTestService{}}, | ||
| }, wsConf)) | ||
| require.Equal(t, defaultMaxRequestBodyBytes, effectiveMaxRequestBodyBytes(wsConf.readLimit)) |
There was a problem hiding this comment.
[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.
| srv.SetBatchLimits(config.batchItemLimit, config.batchResponseSizeLimit) | ||
| srv.SetReadLimits(config.readLimit) | ||
| readLimit := effectiveMaxRequestBodyBytes(config.readLimit) | ||
| if readLimit > math.MaxInt { |
There was a problem hiding this comment.
[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.
| ) | ||
| } | ||
|
|
||
| func recordWSAdmissionRejected(ctx context.Context, reason string) { |
There was a problem hiding this comment.
[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 likerpc.WSAdmissionReasonBudgetWaitTimeout. A singleevmrpc_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 ontooversize/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.
| // newRequestSizeLimiter’s max(budget, maxBody) rule on the HTTP protocol. | ||
| srv.SetWSConcurrentRequestBytes(config.maxConcurrentRequestBytes) | ||
| srv.SetWSAdmissionTimeout(config.wsAdmissionTimeout) | ||
| srv.SetWSAdmissionEventHook(func(reason string) { |
There was a problem hiding this comment.
[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).
| // 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) { |
There was a problem hiding this comment.
[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.
| } | ||
|
|
||
| // 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. |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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).
❌ 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.
There was a problem hiding this comment.
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.mdis 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 againstevmrpc/config/testdata/evm.goldenandevmKeys. RPCEndpointConfigstill carries bothreadLimit(read byEnableWS) andmaxRequestBodyBytes(read byEnableRPC), andevmrpc/server.go:341/ the HTTP path now feed both from the sameconfig.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+300msEventuallybudgets) and run under-racein CI. The serialization assertions use lower bounds and are safe, but the-32005timeout assertions could flake on a loaded runner; consider widening theEventuallywindows (the lower bounds are what carry the signal). TestEffectiveMaxRequestBodyBytes(ws_admission_test.go:26) overlaps the assertion just added torequest_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
NewEVMWebSocketServerconfig →wsConfigmapping (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 theEnableWS-level tests can't. - 4 suggestion(s)/nit(s) flagged inline on specific lines.
| // 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"` |
There was a problem hiding this comment.
[blocker] Adding this key without recording it in the [evm] characterization suite fails two tests:
TestManifestNamesEveryField—CheckManifestCoversEveryFieldrequires every resolved field to be named by a row'sPath(or exempted);evmKeysinevmrpc/config/config_fuzz_test.gohas noWSAdmissionTimeoutrow.TestDefaultsMatchTheRecordedValues—evmrpc/config/testdata/evm.goldengoes straight fromMaxConcurrentRequestBytes = int64(134217728)toMaxOpenConnections = int(2000), so the dumped defaults won't match onceWSAdmissionTimeout = 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.
| 1, | ||
| metric.WithAttributes( | ||
| attribute.String("plane", plane), | ||
| attribute.String("protocol", protocol), |
There was a problem hiding this comment.
[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.
| // 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) { |
There was a problem hiding this comment.
[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.
| srv.SetReadLimits(config.readLimit) | ||
| readLimit := effectiveMaxRequestBodyBytes(config.readLimit) | ||
| srv.SetReadLimits(readLimit) | ||
| // maxConcurrentRequestBytes is passed through raw; rpc.Server.recomputeWSConcurrentBudget |
There was a problem hiding this comment.
[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 { |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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 plane→protocol 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.
There was a problem hiding this comment.
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.mdis 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
EnableWSitself installs the admission event hook — both hook tests (TestWSOversizeFrameFiresAdmissionHook,TestWSAdmissionHookBudgetWaitTimeout) construct a rawrpc.Serverand callSetWSAdmissionEventHookdirectly, so a regression that dropped thesrv.SetWSAdmissionEventHook(...)line inrpcstack.gowould 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 useprotocol, and it doesn't mention the newws_admission_timeoutconfig key or theratelimiterlabel 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 ingo.mod) exportsSetWSConcurrentRequestBytes/SetWSAdmissionTimeout/SetWSAdmissionEventHook/WSAdmissionReason*and implements the documented semantics (0 disables the budget, non-positive timeout falls back to 30s,recomputeWSConcurrentBudgetraises the budget toreadLimit). The comments inrpcstack.goand the config docs assert all four; CI's build is the check. - 6 suggestion(s)/nit(s) flagged inline on specific lines.
|
|
||
| wsConfig := WsConfig{Origins: strings.Split(config.WSOrigins, ",")} | ||
| wsConfig.readLimit = DefaultWebsocketMaxMessageSize | ||
| wsConfig.readLimit = config.MaxRequestBodyBytes |
There was a problem hiding this comment.
[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)) |
There was a problem hiding this comment.
[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.
| func recordRequestRejected(ctx context.Context, reason string) { | ||
| metrics.requestRejectedCount.Add(ctx, 1, | ||
| metric.WithAttributes( | ||
| attribute.String(protocolKey, protocolHTTP), |
There was a problem hiding this comment.
[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.
| case rpc.WSAdmissionReasonBudgetWaitTimeout, rpc.WSAdmissionReasonFrameAdmissionTimeout: | ||
| return rejectReasonBusy | ||
| default: | ||
| return reason |
There was a problem hiding this comment.
[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.
| 1, | ||
| metric.WithAttributes( | ||
| attribute.String("plane", plane), | ||
| attribute.String("protocol", protocol), |
There was a problem hiding this comment.
[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.
| srv := rpc.NewServer() | ||
| srv.SetBatchLimits(config.batchItemLimit, config.batchResponseSizeLimit) | ||
| srv.SetReadLimits(config.readLimit) | ||
| readLimit := effectiveMaxRequestBodyBytes(config.readLimit) |
There was a problem hiding this comment.
[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.
Superseded: latest AI review found no blocking issues.
There was a problem hiding this comment.
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.

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_bytesnow bounds WS frame size in addition to the HTTP body size (viasrv.SetReadLimits), replacing the removedDefaultWebsocketMaxMessageSizeconstant.max_concurrent_request_bytesnow 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.evmrpc_requests_rejected_total, now labeled with a newplane(http/ws) dimension instead of being HTTP-only.effectiveMaxRequestBodyByteshelper so the "0 means use the 5 MiB default" rule is shared between the HTTP limiter and the WS read-limit wiring.Testing performed to validate your change
evmrpc/ws_admission_test.gocoveringeffectiveMaxRequestBodyBytesand WS concurrent-request-byte budget enforcement/blocking behavior end-to-end over a real websocket connection.evmrpc/request_limiter_test.gofor the new shared helper.go test ./evmrpc/...