fix(p2p): make the inbound accept rate configurable and raise its default - #3899
fix(p2p): make the inbound accept rate configurable and raise its default#3899bdchatham wants to merge 14 commits into
Conversation
…ault The router paces its accept loop with a rate limiter whose limit has never been reachable from config: getRouterConfig/createRouter sets MaxDialRate from p2p dial-interval but leaves MaxAcceptRate unset, so every node falls through to the compiled-in default of rate.Every(time.Second) — one inbound connection per second. That is too low for a public-facing node. The kernel keeps completing handshakes into the listen backlog while the loop drains it at 1/s, so once the backlog is more than a few entries deep an arriving peer waits longer than handshake-timeout and never completes. The node stops acquiring inbound peers while continuing to serve its established ones, so it reports healthy. The 1/s value was not chosen for inbound behaviour: #2539 introduced the limiter at 2/s, and #2799 — whose subject and body are about making *dialing* less aggressive — halved it to 1/s while exposing only the dial knob to config. Add a p2p accept-interval key mirroring dial-interval, wire it through to MaxAcceptRate, and default it to 10ms. Concurrency is already bounded separately by MaxConcurrentAccepts (set from MaxConnections), and per-source abuse by MaxIncomingConnectionAttempts, so the global rate limiter is a backstop rather than the binding constraint. rate.Every returns rate.Inf for a non-positive interval, so accept-interval = 0 disables the limiter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).
|
PR SummaryMedium Risk Overview
Reviewed by Cursor Bugbot for commit 8f5a204. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Small, well-motivated change that closes a real defect: MaxAcceptRate was unreachable from config and every node ran the compiled-in 1 accept/s, which cannot drain the accept backlog on a public listener. The wiring is correct and I verified the safe-fallback claims (absent key preserves the default; config.toml is not rewritten over a hand-added key) — remaining findings are doc-comment accuracy, a test assertion looser than its stated intent, and a per-IP concurrency gap now exposed by the higher rate.
Findings: 0 blocking | 9 non-blocking | 4 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 merges only Claude's and Codex's findings. - Nothing replaces the global accept limiter as flood control, and that trade-off deserves an explicit follow-up. The limiter is constructed as
rate.NewLimiter(maxAcceptRate(), maxAccepts())(sei-tendermint/internal/p2p/router.go:190), so a burst ofmaxAccepts(=maxInbound, ~80 by default) was always permitted; what 1/s prevented was sustaining it. Holding 80 handshake slots full against a 10shandshake-timeoutneeds ~8 accepts/s, which 1/s denied and 100/s comfortably allows.connTrackeris a per-IP rate limit (100 per 100ms ⇒ ~1000/s) applied afterAcceptOrClose, and there is no per-IP concurrency cap, so one source can now keep every handshake slot occupied. Raising the default is still the right call — 1/s breaks legitimate inbound — but the residual gap (per-IP concurrent-handshake cap) is worth filing rather than leaving implicit. - No test covers the wiring itself. The PR's own diagnosis is that a 1/s production cap survived because every
RouterOptionsconstruction site exceptnode/setup.gosubstitutesrate.Inf. The fix adds a config-level test but leavescreateRouter'scfg.P2P.AcceptInterval → MaxAcceptRatemapping unasserted, so the same class of regression (dropping the field, or wiring the wrong config key) stays invisible. Anode/setup_test.goassertion on the constructedRouterOptionswould close it cheaply; the author acknowledges this as deferred, which seems reasonable, but it is the specific gap that produced this bug. - Verified, no action needed (recording so a later reviewer need not re-derive it): omitting
accept-intervalfrom config.toml does not silently yield0/unlimited.sei-cosmos/server/util.go:255starts fromtmcfg.DefaultConfig()androotViper.Unmarshal(conf)leaves absent keys untouched, andWriteConfigFileonly runs when config.toml does not already exist (sei-cosmos/server/util.go:257-273), so a hand-added key also survives restarts. - PR description is internally inconsistent on
max-connections: the Change section saysMaxConcurrentAcceptsis "set fromMaxConnections", while Notes for review says max-connections "feeds only theMaxInbound/MaxOutboundcomputation."sei-tendermint/node/setup.go:510shows the former is closer — it is set tomaxInbound, itself derived from max-connections. Worth correcting so the follow-up note isn't acted on from a wrong premise. - 4 suggestion(s)/nit(s) flagged inline on specific lines.
…pping (#47) WriteConfigToDir regenerates config.toml wholesale from the legacy structs, so any config.toml key with no corresponding field on legacyP2P is silently dropped on the next write rather than preserved. sei-chain is adding a p2p accept-interval key to make the router's inbound accept rate configurable (sei-protocol/sei-chain#3899); without a matching field here, seictl and sei-k8s-controller would strip that key from any node they write, silently reverting the node to the compiled-in 1 accept/s. Add AcceptInterval alongside DialInterval across all five sites the contract requires: the unified P2PConfig (accept_interval), legacyP2P (accept-interval), baseDefaults, toLegacyTendermint and fromLegacy. The default matches the one sei-chain#3899 sets, so a node written by this library and a node using seid's own defaults agree. Verified the round-trip assertion discriminates rather than merely passing: removing the toLegacyTendermint mapping fails it with "got 0s, want 25ms", which is the silent-drop failure mode this guards. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3899 +/- ##
==========================================
- Coverage 59.48% 58.47% -1.02%
==========================================
Files 2325 2229 -96
Lines 198647 188038 -10609
==========================================
- Hits 118160 109950 -8210
+ Misses 69258 67700 -1558
+ Partials 11229 10388 -841
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
accept-interval follows dial-interval in staying out of the generated template, which leaves "deliberately not rendered" and "not readable at all" indistinguishable from the test suite's point of view. The statesync knobs already have both halves of this convention; the p2p pacing knobs had neither. Add the matching pair: * checkConfig asserts dial-interval and accept-interval are absent from the rendered template, alongside the existing hiddenStateSyncElems block. * p2p_compat_test.go mirrors statesync_compat_test.go: one test proves both keys still parse out of an existing config.toml, one proves a template- shaped file without them still yields the defaults. The second case guards both directions. A zeroed AcceptInterval means rate.Every(0) == rate.Inf and disables accept pacing outright, while an oversized one throttles the accept loop below the rate at which peers arrive; neither is visible in the rendered config. Verified both guards discriminate: typoing the mapstructure tag fails the parse test with "expected: 20ms, actual: 10ms" (the operator's value silently ignored), and adding accept-interval to the template fails TestEnsureRoot with "config file was not expected to contain accept-interval". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Added the golden/compat test coverage this key was missing. The statesync knobs already establish both halves of this convention; the p2p pacing knobs had neither, so this pins Absent from the template — // The p2p pacing knobs are likewise expert-only and stay out of the
// generated template, while still being parsed from existing config files.
var hiddenP2PElems = []string{"dial-interval", "accept-interval"}Still parseable — new
Both guards were checked to actually discriminate rather than merely pass:
The first is the one worth having — a wrong tag doesn't error, it silently ignores the operator's value and falls back to the default, which is the same class of silent-drop failure that made the sei-config companion necessary. Companion sei-protocol/sei-config#47 has merged, so the key now survives a |
… the wiring Doc comment (2 nits): * MaxConnections was the wrong knob. Concurrency is bounded by RouterOptions.MaxConcurrentAccepts, which setup derives from max-connections minus the outbound reservation; name that, since the derivation is the load-bearing part of why a 100/s global rate is safe. * "A value <= 0 means unlimited" overstated what is reachable — ValidateBasic rejects negatives, so 0 is the only value that gets there. Render accept-interval in the generated template. The sibling dial-interval is unrendered and I had matched it, but the reviewer is right that the argument cuts the other way here: this PR exists because an unreachable, undocumented accept rate went unnoticed on mainnet listeners, and the template is where an operator looks. Matching the sibling would perpetuate the condition being fixed. dial-interval stays hidden and keeps its parse-side coverage. Pin the default exactly. require.Greater(limit, 50.0) pinned a band, so 10ms -> 19ms was invisible. Assert the exact value, and keep the band, whose failure message carries the reason the value was chosen. Test the wiring itself. This was the reviewer's sharpest point: the PR's own diagnosis is that a 1/s production rate survived because every RouterOptions construction site except node/setup.go substitutes rate.Inf, yet the fix left the cfg.P2P.AcceptInterval -> MaxAcceptRate mapping unasserted. Extract the budget/pacing derivation into p2pRouterOptions and assert it directly: dropping the field fails with "expected: 100, actual: -1" against a sentinel, rather than silently falling back to the package default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All four inline findings addressed in "No test covers the wiring itself." This was the sharpest finding — the PR diagnosed that a 1/s production rate survived because every Extracted the budget/pacing derivation out of
Verified it discriminates: deleting the Per-IP concurrent-handshake cap. Confirmed and not addressed here — agreed it wants its own change rather than being folded in. Your analysis matches what a security review of this reached independently: the slot is taken in Cursor's empty second-opinion pass — noted; no action available on my side. Also corrected the PR description's |
There was a problem hiding this comment.
Sound, well-scoped fix: accept-interval is added to P2PConfig with a default, validation, template rendering, and wiring into MaxAcceptRate, and the p2pRouterOptions extraction makes the derivation testable without changing other behavior. No blockers; findings are test-quality nits, a stale PR description, and a couple of consistency/structural suggestions.
Findings: 0 blocking | 10 non-blocking | 4 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- PR description is stale relative to the diff and should be updated before merge. It states
accept-intervalis "not rendered into the generated template — same treatment as its sibling", but the diff does render it intoml.goandtoml_test.goasserts it is present (onlydial-intervalstays hidden). The Tests section also lists only two tests while the diff addsp2p_compat_test.go(2 tests), thetoml_test.goassertions, andTestP2PRouterOptions_PacingAndBudgetWiring. Rendering it is the better call — just make the description match, since it is what a reader relies on for release notes. - Structural: per
AGENTS.md("guard at the choke point, never at each caller"), the durable correction for the diagnosed failure mode — "anyRouterOptionsfield left unset silently falls back to a package default rather than failing" — is a guard insidep2p(e.g. requiringMaxAcceptRate/MaxDialRateinRouterOptions.Validate(), or dropping the silent.Or()fallbacks for pacing), not a test innodethat enumerates fields by hand. The new test is a convention the next field-adder can forget; a validation in the single construction path every router goes through is an invariant they cannot. Worth a follow-up even if out of scope here. - Validation asymmetry:
ValidateBasicnow rejects a negativeaccept-intervalbut still accepts a negativedial-interval, which also becomesrate.Inf(unbounded dialing) viarate.Every. Adding the sibling check would make the pair consistent and is a one-liner alongside this change. - Security framing worth stating explicitly: raising the global limiter 100x makes it non-binding, so accept-slot exhaustion now rests entirely on
MaxConcurrentAccepts. Inrouter.go:193-214the semaphore slot is taken beforeconnTracker.AddConn(the per-IP check) and held for the whole handshake (handshake-timeout= 10s), so time-to-fill all 80 slots drops from ~80s to <1s. Steady-state exposure is unchanged — an attacker saturates the slots under either rate — so this is not a regression, but the PR's argument that the per-IP limiter andMaxConcurrentAcceptsmake the global one "just a backstop" holds only because of that steady-state equivalence, not because the per-IP limiter constrains slot exhaustion. - Agreeing with the author's own follow-up note: the gap that let a 1/s mainnet accept cap go unnoticed is that
testonly.go,router_test.go, andgiga_router_validator_test.goall substituterate.Inf, so no test exercises the real accept rate at the router level.TestP2PRouterOptions_PacingAndBudgetWiringcloses the wiring half but not the behavioral half. Worth filing rather than leaving in the PR body. - Codex reported no material findings. The Cursor review file (
cursor-review.md) is empty, so that pass produced no output and contributed nothing to this synthesis. - 4 suggestion(s)/nit(s) flagged inline on specific lines.
…he tests
Structural, and the one that matters. The reviewer cited AGENTS.md "Guard at the
choke point, never at each caller" against the previous round's fix: wiring
MaxAcceptRate in node setup corrects one caller, while the hazard is the package
default every embedder falls through to. Raise the default in maxAcceptRate()
itself, from rate.Every(time.Second) to rate.Every(10*time.Millisecond), so no
construction path can inherit an accept rate too low to drain the backlog. The
config key and the wiring stay; they now tune a safe default rather than rescue
a dangerous one.
Test quality:
* config_test.go's band (> 50/s, != Inf) was unreachable once the exact 10ms pin
above it passed, so it read as coverage while never able to fire. Dropped it
and moved the "why 10ms" into the doc comment, per AGENTS.md "the step name
carries the what, the doc comment carries the why".
* setup_test.go's budget case set MaxConnections = 100, which is already the
default, so it could not distinguish "derives from max-connections" from
"hardcoded to the default". Now table-driven over 50 (-> 30/30/20) and 30
(-> 15/15/15), the latter reaching the min(20, (maxConns+1)/2) branch nothing
else covers. Verified it discriminates: wiring MaxConcurrentAccepts to
maxConns instead of maxInbound fails with "expected: 30, actual: 50".
* Dropped my own "fell through to the package default" assertion, which the
default change made unreachable for the same reason as the band above.
* Removed a no-op string(types.NodeID("0000...")) round trip.
Docs and symmetry:
* The template comment said "set too high a value", which reads backwards for an
interval. Now states the direction explicitly.
* ValidateBasic rejected a negative accept-interval but not dial-interval, which
also becomes rate.Inf via rate.Every. Added the sibling check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 2 addressed in The structural finding — taken, and it changed the shape of the fix. You cited Applying it changed the fix. Wiring I did not take the stronger form you offered (requiring the pacing fields in Validation asymmetry — added; Security framing — agreed and worth having in the record: raising the global limiter makes it non-binding, so slot exhaustion now rests entirely on Behavioral half of the test gap ( PR description is now synced to the diff — the "not rendered" claim and the Tests section were both stale. |
There was a problem hiding this comment.
Well-motivated fix: accept-interval is wired through to RouterOptions.MaxAcceptRate, the 1/s package default is raised to 100/s, and the derivation is extracted into a testable p2pRouterOptions with reasonable coverage of the config default, the template rendering, and the wiring. No correctness bugs found; the notes are about doc comments that misstate what max-incoming-connection-attempts actually bounds, stale comments in the new test file, and one uncovered validation branch.
Findings: 0 blocking | 9 non-blocking | 4 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Cursor's second-opinion pass produced no output (
cursor-review.mdis empty); Codex reported no material issues. Only this pass contributed findings. - Security judgement worth making explicit: with the global accept limiter no longer the binding constraint, the effective per-source brake is
max-incoming-connection-attempts= 100 concurrent per IP, which is above the default inbound pool (maxInbound= 80 formax-connections= 100). A single IP can therefore occupy the whole inbound handshake semaphore in well under a second, where at 1/s it took ~80s. Still strictly better than a node that accepts nothing, but consider tightening that default in the same change or stating that the risk is accepted. - The new 100/s default remains unexercised end-to-end:
testonly.go,router_test.goandgiga_router_validator_test.goall substituterate.Inf, which is exactly why the 1/s production value went unnoticed. The PR description defers the router-level test; worth filing it as a tracked follow-up rather than leaving it only in the PR body. - The failure mode this fixes is silent (node stays at head, reports healthy, acquires no inbound peers). Consider logging the effective accept rate and
MaxConcurrentAcceptsonce at router start, so a pathological operator-setaccept-intervalis diagnosable from logs instead of only from peer counts. ValidateBasicnow rejects a negativedial-interval, which makes a previously-accepted (if nonsensical) existing config fatal at startup. Almost certainly harmless, but it is an unadvertised behavior change beyond the accept-interval work.- 4 suggestion(s)/nit(s) flagged inline on specific lines.
…stale framing The doc comment's claim about max-incoming-connection-attempts was wrong, and it had propagated into the operator-facing template and the PR description. connTracker.AddConn (internal/p2p/conn_tracker.go:33) compares against a count that RemoveConn decrements, so `max` is 100 *concurrent* connections per source IP, not an attempt rate; the IncomingConnectionWindow check applies only when that count is currently zero, making it a re-connect delay rather than a rate. That changes the argument, not just the wording: 100 concurrent per IP exceeds the default inbound pool of 80, so this limiter does not bound a single source below the pool and cannot be the backstop the comment implied. Both the config doc comment and the template now say what it actually does. Also: * dial-interval's new negative check had no coverage — the previous round added the guard and claimed the test. Added "DialInterval" to fieldsToTest; verified removing the guard now fails TestP2PConfigValidateBasic. * p2p_compat_test.go still described both pacing knobs as hidden, which the previous round falsified by rendering accept-interval. Renamed TestHiddenP2PKnobsStillParseFromExistingConfig -> TestP2PPacingKnobsParseFromExistingConfig and narrowed the comment to dial-interval, the only key still absent from the template. * Reframed TestFreshP2PConfigKeepsDefaultPacing as TestP2PConfigPredatingPacingKnobsKeepsDefaults. It never mirrored a fresh template; what it actually pins is a config.toml written before these keys existed — the case every already-deployed node is in, since seid does not rewrite an existing config.toml. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 3 in The The consequence is what matters: 100 concurrent per IP exceeds the default inbound pool of 80, so that limiter does not bound a single source below the pool and cannot be the backstop I described. Corrected in the doc comment, the template, and the PR description, which carried "~1000/s from a single address" — a claim that was load-bearing for the safety argument and simply wrong. It also sharpens the follow-up I have been deferring: a single IP can occupy the entire inbound pool under the current defaults, independent of anything this PR changes. That strengthens the case for per-IP concurrency accounting, which I am routing separately as a security item rather than a public issue. The other three were all self-inflicted staleness from round 2 — I rendered One reframing worth flagging beyond the literal fix: |
There was a problem hiding this comment.
Well-scoped change: accept-interval is added to P2PConfig, rendered in the template, wired into RouterOptions, and the package default is raised from 1/s to 100/s, with the cfg → RouterOptions derivation extracted into a testable p2pRouterOptions and covered by new tests. No correctness bugs found; the notable issues are that the new ValidateBasic checks are never reached in production (confirmed) and that the higher accept rate interacts with a pre-existing per-IP cap gap.
Findings: 0 blocking | 7 non-blocking | 2 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Security/capacity note (not blocking, but worth a follow-up): raising the accept rate from 1/s to 100/s while
max-incoming-connection-attemptsdefaults to 100 concurrent connections per source IP — above the 80-slot default inbound pool (max-connections100 minus 20 outbound) — means a single source can now re-saturate theMaxConcurrentAcceptssemaphore in well under a second instead of over ~80s. The burst (rate.NewLimiter(maxAcceptRate, maxAccepts)inrouter.go:190) already allowed initial saturation, so this widens sustained-pressure surface rather than creating it, and the PR documents the gap in both the struct and template comments. Consider a follow-up bringing the per-IP cap belowmaxInboundso the accept pipeline can't be monopolized by one source. - Test gap the author already calls out: every router-level harness (
testonly.go,router_test.go,giga_router_validator_test.go) pinsMaxAcceptRate: rate.Inf, so nothing exercises the real accept rate end-to-end — which is precisely why a 1/s cap survived on a mainnet listener.TestP2PRouterOptions_PacingAndBudgetWiringcloses the wiring half; the router-behaviour half is still uncovered. Worth landing the dedicated test rather than leaving it as a note. - The Cursor second-opinion pass produced no output (
cursor-review.mdis empty), so this review reflects only my own pass merged with Codex's single finding. - No prompt-injection attempts or suspicious directives found in the PR title, body, or diff.
testutil/configtestdoes not coversei-tendermint/config, so the newAcceptIntervalfield needs no characterization row — noting it explicitly since AGENTS.md flags added keys as a place the completeness check can be silent.- 2 suggestion(s)/nit(s) flagged inline on specific lines.
P2PConfig.ValidateBasic has never run in production. Config.ValidateBasic (config.go:146) routes BaseConfig, RPC, Mempool, StateSync, Consensus, Instrumentation and SelfRemediation, but not P2P — and both production entry points go through it, commands.ParseConfig and seid's interceptConfigs. Before this commit the only callers of P2PConfig.ValidateBasic in the whole tree were the tests added by this PR, which is what made the coverage look real. So the negative-interval guards the previous round added were dead where it counts: accept-interval = "-1s" started successfully and rate.Every turned it into rate.Inf, disabling the accept limiter outright. The same was true of the pre-existing send-rate, recv-rate, flush-throttle-timeout and max-packet-msg-payload-size checks, for their whole life. Route the section, and assert the routing rather than the section's own checks — the latter passed the entire time nothing called them, which is the failure mode worth guarding. Verified: removing the new routing line fails TestConfigValidateBasicRoutesP2P. Confirmed no config in the tree relied on the gap; config, cmd, internal/p2p and the root module all build and pass. This is the AGENTS.md choke-point rule again, one level up from where the last round applied it: a guard that every path must pass through, rather than a section that happens to own its checks. Also cross-referenced the two copies of the 10ms accept default. config cannot import internal/p2p, so they are deliberate copies; each doc comment now names the other, since only the config-side one is pinned by a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 4 in The So when I added the negative-interval guards, ran the deletion check, watched The gap predates this PR and covers The new test asserts the routing, not the section, because asserting the section is precisely what passed while nothing called it. Blast radius checked rather than assumed: no config in the tree relied on the gap — That is Duplicated default — cross-referenced both ways. I looked at a real drift test in |
There was a problem hiding this comment.
Sound, well-tested fix: accept-interval is properly threaded from config to RouterOptions, the extraction of p2pRouterOptions makes the derivation testable, and the compat/template tests cover the paths that matter. No blockers; the notes below are about an undisclosed ValidateBasic behavior change bundled in, a duplicated default with no drift guard, and the security/operator framing of the 100× default increase.
Findings: 0 blocking | 8 non-blocking | 3 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Security framing of the default change: the accept limiter is the only global (non-per-IP) pacing on pre-authentication inbound handshakes. Going 1/s -> 100/s raises the sustainable churn rate of unauthenticated handshake attempts 100x.
max-incoming-connection-attemptscaps concurrent connections per source (100), not rate, so it does not compensate; onlyMaxConcurrentAccepts(80 by default) bounds in-flight work, and that ceiling is unchanged. This looks like the right availability/exposure tradeoff, and per-handshake cost is small, but the PR argues the default purely from the drain-the-backlog side — worth stating the DoS side explicitly so the choice is recorded as deliberate. - PR-narrative nit (doesn't affect the fix): the limiter's burst is
maxAccepts=maxInbound= 80 by default, so even at 1/s a cold listener could accept 80 connections immediately. The failure mode the PR describes is therefore steady-state churn / reconnect storms after the burst drains, not initial pool fill. Adjusting the problem statement would make the diagnosis reproducible for the next reader. - The author-acknowledged gap is the one worth closing in this PR rather than deferring: every router harness pins
MaxAcceptRate: rate.Inf, which is precisely why a 1/s mainnet default went unnoticed.internal/p2p/transport_test.go:43(TestRouter_MaxConcurrentAccepts) is a close template for a router-level test that exercises a finite accept rate. The config- and setup-level tests added here pin the wiring but still leave the router's own behaviour under a real rate unexercised. - Cursor's second-opinion file (
cursor-review.md) is empty — that pass produced no output. Codex reported no material issues, which matches my reading. - Nit (
sei-tendermint/config/toml_test.go):hiddenP2PElemsis a one-element slice whileaccept-intervalgets a bespokeif. It mirrorshiddenStateSyncElemsso it's defensible, but a matchingexpectedP2PElemsslice would make the pair read symmetrically. - 3 suggestion(s)/nit(s) flagged inline on specific lines.
|
Tree read succeeded (merge ref 1. BlockingNone. The change is a self-contained P2P accept-pacing knob: it adds
2. SecurityNone found. I checked the classes that apply: the accept rate is node-local P2P plumbing, not consensus-observed, so the wall-clock/rate behaviour raises no determinism concern across validators. The 100× accept-rate increase (1/s → 100/s) is bounded downstream by the concurrent-handshake semaphore ( 3. Non-blockingNone. 4. SummaryI read the full 470-line diff and the changed files under the PR merge tree ( {"decision": "approve",
"summary": "Self-contained P2P accept-pacing knob (AcceptInterval, default 10ms) wired through a new pacingRate/p2pRouterOptions with correct negative-interval refusal, 0-as-disable, ValidateBasic routing, and thorough tests. No blocking, security, or non-blocking findings.",
"findings": []}seidroid xreview · decision |
There was a problem hiding this comment.
Solid, well-tested fix: accept-interval is correctly threaded config → RouterOptions.MaxAcceptRate, the package default is raised in lockstep, and the extracted p2pRouterOptions makes the derivation directly testable. No correctness bugs found; remaining notes are a DoS-surface question the PR itself raises, a widened ValidateBasic behavior change, and some godoc/comment style deviations from AGENTS.md.
Findings: 0 blocking | 9 non-blocking | 5 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- The Cursor second-opinion pass produced no output (
cursor-review.mdis empty). Codex's two findings are both incorporated below. - No test exercises a finite
MaxAcceptRateend-to-end at the router level —testonly.go:296,router_test.go:273andgiga_router_validator_test.go:112all pinrate.Inf. The PR correctly identifies that substitution as the reason a 1/s mainnet cap went unnoticed; that follow-up is worth doing soon rather than later, since the accept path is now the only pacing knob operators can get wrong. - Codex's testify point:
sei-tendermint/AGENTS.md:8asks forlibs/utils/requireovertestify/require. The new tests use testify, matching the surrounding packages (configandnodeare entirely testify today). Matching the package is the defensible call here; noting it for a maintainer decision only. - Per
AGENTS.md("Document the subject, not the system"), the newAcceptIntervalgodoc (config.go:730-738) and the template comment (toml.go:349-356) each spend most of their length describing other knobs (MaxConcurrentAccepts,max-incoming-connection-attempts, kernel backlog behaviour). The operator-facing template comment arguably earns it; the struct godoc could shrink to what the field is plus the 0-disables-it escape hatch. - 5 suggestion(s)/nit(s) flagged inline on specific lines.
monty-sei
left a comment
There was a problem hiding this comment.
✅ Approving this one, the diagnosis lines up exactly with something I ran into last week while verifying the new seed defaults, so it was great to see a fix already in flight!
One thing worth checking before this goes in, the seed profile sets max-connections to 1000, so maxInbound and MaxConcurrentAccepts both derive to 980, and going from 1 accept per second to 100 puts two orders of magnitude more sustained traffic through exactly the path that unbounded read sits on. The burst is unchanged (the limiter is already built with burst = maxAccepts) so it isn't a new hole, it just widens the existing one a fair bit.
Two smaller things, neither blocking:
accept-interval has no upper bound: validation only rejects negatives, so setting it to 1s quietly puts you back in the exact state this PR fixes, and by your own reasoning there's no signal at all when that happens. A warning above some threshold would be nice to have.
Config.ValidateBasic now routing [p2p]: this newly enforces four checks that have never actually run in production. I had a look and the blast radius is small (they're all negative only, and config unmarshals onto DefaultConfig so missing keys keep their defaults), but it does change startup behaviour so it's probably worth a line in the release notes?
… constant The previous round left the 10ms default in two places and a comment asking a future editor to keep them equal. Reviewer is right that a comment is a convention, not an invariant. The suggested fix was a drift test in node/setup_test.go, on the reasoning that config does not import internal/p2p. It does — config/autobahn.go:10 — so the dependency already runs config -> p2p, and the stronger fix is available: export DefaultAcceptInterval from internal/p2p and have DefaultP2PConfig() set accept-interval from it. One value with two references cannot drift, so no test is needed to detect drift that can no longer happen. (A drift test was also not reachable as suggested: maxAcceptRate is unexported, so neither node nor an external p2p_test package can call it.) TestP2PConfigAcceptInterval now transitively pins both sides; verified by moving the constant to 19ms, which fails it with "expected: 10ms, actual: 19ms". No behavior change: the value is the same on both sides as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The main merge added a Godoc section: godocs say what a thing is, not why it came to be or how it works inside, and never record design history. Several comments in this PR predate that and violate it. * DefaultAcceptInterval explained why it is exported and how the accept backlog fails. Now states what it is. * MaxAcceptRate's field comment toured the surrounding system. Now names its default. * p2pRouterOptions recorded why it was split out of createRouter and what the bug had been. Now states what it returns. * AcceptInterval's field comment carried the same system tour. The operator-facing rationale it held already lives in the config.toml template, which is where an operator reads it. Rewritten rather than trimmed, per the section's own rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 5 pushed ( Duplicated default — collapsed rather than tested. One premise in the suggestion was off, and correcting it opened a better fix: The suggested drift test also would not have compiled —
Two things worth flagging beyond the review, since The new There is a tension between two Confirmed no golden update is needed. No Branch is |
There was a problem hiding this comment.
Well-scoped fix that makes a previously unreachable p2p accept-rate limit configurable and raises a default that was accidentally low; the wiring, the backwards-compat path, and the p2pRouterOptions extraction all check out, and the PR body's own analysis of connTracker and the boot path is accurate. No blockers — findings are a godoc/comment-hygiene cluster (including "this PR" phrasing that goes stale on merge), a validation gap that lets a large accept-interval silently reproduce the very outage being fixed, an untested 0-disables-the-limiter path, and a pre-existing connTracker.lastConnect leak whose growth ceiling this change raises 100×.
Findings: 0 blocking | 11 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 merges only Claude's findings with Codex's single godoc nit. - The PR body's release-note warning looks unreachable, worth confirming before it ships as one.
AddCommandscallscommands.ParseConfigduring root-command construction (cmd/seid/cmd/root.go:158→sei-cosmos/server/util.go:342), which runs strictly before anyPersistentPreRunE.ParseConfigreads the global viper singleton, and nothing populates that withconfig.tomlat construction time —interceptConfigsreads intoserverCtx.Viper, andsei-tendermint/libs/cli/setup.go:85(the only global-viper reader) is itself a pre-run hook. SoParseConfigshould see bareDefaultConfig()and always validate clean; the described panic-on-negative-send-ratecan't fire from an operator's file. Good news for the risk section — but it also means the flip side of the PR's framing is the accurate one: routing[p2p]is correct hygiene, yet operator-supplied[p2p]values still aren't validated anywhere on theseidboot path, so calling those four checks "load-bearing" now overstates it. Worth settling, since this paragraph is what release notes will be written from. - Layering:
sei-tendermint/confignow importssei-tendermint/internal/p2pfor a single duration constant, which permanently forbidsinternal/p2pfrom importingconfig. No cycle today (I checkedinternal/p2p's import closure), and it matches the existingmempoolcfgprecedent in the same file, so this is fine as-is — just noting the constraint it locks in. Sharing the constant is still better than the duplicated10 * time.Secondliteral thatDialInterval/maxDialRate()carry. - Verified and correct, for the record: the
p2pRouterOptionsextraction is behaviour-preserving (theExternalAddressbranch and everything after still mutate the returned struct in the same order);interceptConfigsdoes start fromDefaultConfig()beforeUnmarshal, so an existingconfig.tomlwithout the key inherits the default exactly asTestP2PConfigPredatingPacingKnobsKeepsDefaultsasserts; themax-incoming-connection-attemptsanalysis matchesconnTracker.AddConn;p2p_compat_test.gomirrors the establishedstatesync_compat_test.goshape, and no test in the package callst.Parallel, so the global-viper mutation is safe. Adding the template key needs notestutil/configtestrow — that suite coversapp.toml/AppOptsreads, not Tendermint[p2p].scripts/confixgoldens are static fixtures and the twodocker/*/config/config.tomlfiles carry only positive values in the newly-live fields, so nothing breaks there. - The named follow-up is the right one and still open: with every router harness substituting
rate.Inf, nothing exercises the production accept rate end-to-end, so the actual failure mode — accept loop not draining the kernel backlog — remains unexercised. The wiring test proves the value reachesRouterOptions, not that the value works. - 6 suggestion(s)/nit(s) flagged inline on specific lines.
… sentinel pompon0: the routeroptions default should not reference where it is set or claim to be kept in sync — that property is fragile, and the defaults are moving to sei-tendermint/config once the RouterOptions fields become required. Reverted the exported DefaultAcceptInterval and the config-side reference; both sides carry a plain literal again and neither comment mentions the other. This undoes the previous round's coupling, which pointed from internal/p2p at config, the inverse of the real dependency. pompon0: an artificial sentinel is bad practice. The wiring test now compares utils.Some(rate.Every(...)) against the Option directly, and the budget cases likewise compare utils.Some(want) rather than unwrapping with .Or(-1). amir-deris: dropped the "This PR's own diagnosis" preamble; the comment now states what the test holds in the present tense. Also from review: * Added a subtest for accept-interval = 0. The template promises it disables the limiter, and that promise was only pinned in config_test as rate.Every(0) == rate.Inf — a property of golang.org/x/time/rate rather than of this wiring passing the value through. * Trimmed the template comment from eight lines to four. Three of them described max-incoming-connection-attempts' relationship to the inbound pool, which is a cross-key numeric claim nothing checks and would go stale silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Well-scoped fix: accept-interval is correctly plumbed from config into RouterOptions, the extraction of p2pRouterOptions is behavior-preserving, and the new tests follow existing package conventions (statesync_compat_test.go, hiddenStateSyncElems). No blockers found; a handful of suggestions around the untested package default, negative-value handling, and the template asymmetry.
Findings: 0 blocking | 10 non-blocking | 5 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Verified the PR's claims independently:
sei-cosmos/server/util.go:262does only validate on the config-creation branch, andutil.go:342panics oncommands.ParseConfigerror. Worth noting the blast radius is even narrower than the PR states — thatParseConfigcall unmarshals the global viper singleton at command-tree construction time, which is not populated with the node'sconfig.toml(seid's boot path uses the per-commandrootViper). So a deployed node with a negative[p2p]value most likely never reaches that panic. The release-note call-out is still right to make. - Security/robustness trade-off worth stating explicitly in the PR body: the accept limiter's burst is
maxAcceptsand the semaphore (maxInbound, 80 by default) is acquired beforelimiter.Wait, so raising the rate 1/s→100/s drops the time for a single source to saturate all 80 concurrent handshake slots from ~80s to <1s. Under a slow-loris the semaphore then becomes the binding constraint (~8 slots/s freed at a 10shandshake-timeout), so the ceiling is unchanged and the change is defensible — but the PR's own observation thatconnTracker's per-IP concurrent cap (100) exceeds the inbound pool (80) means nothing bounds one source below the pool. Worth a tracked follow-up rather than leaving it as a prose aside. scripts/confix/confix.go:163(CheckValid) is a third caller ofConfig.ValidateBasicnot covered by the PR's trace. It unmarshals into a zero-valuedconfig.Config, and all six[p2p]checks are< 0, so zero values pass and the tool is unaffected unless a migrated file carries an explicit negative — same class as the other paths, but worth listing for completeness.- Cursor's second-opinion pass (
cursor-review.md) is empty — that review produced no output. Codex reported no material findings, which matches my read. - I could not execute the test suite in this environment (
go test/go vetwere not permitted), so the new tests were reviewed statically. The reflection-basedfieldsToTestaddition is sound:DialInterval/AcceptIntervalaretime.Duration, soSetInt(-1)works, and the reset-to-0 between iterations keeps each error attributable to the intended field. - 5 suggestion(s)/nit(s) flagged inline on specific lines.
…ults The negative-interval check added earlier does not fire on the path an operator's typo actually travels. interceptConfigs validates only when it creates config.toml, so an already-deployed node never reaches ValidateBasic, and rate.Every maps every non-positive interval to rate.Inf — accept-interval = "-1s" in an existing file silently disables accept pacing, which is the failure this PR exists to fix. Clamp it where every router passes instead: pacingRate() falls back to the config default for a negative interval, applied to both accept and dial. A configured 0 still disables the limiter, since that is documented. ValidateBasic keeps the negative check as the early, friendly error on the paths that do reach it. Doc comment and template now state both behaviours. Also from review: * Added routeroptions_test.go pinning maxAcceptRate/maxDialRate's package fallbacks. Every harness sets rate.Inf and every production path now sets the value explicitly, so the fallbacks were unexercised and the doc comment was their only record. * Trimmed the test doc comments in config_test.go and p2p_compat_test.go to what each test pins. They were narratives of the bug, which AGENTS.md rules out and the PR body already carries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
The change is well-motivated and correctly wired: accept-interval reaches RouterOptions through the single production construction site, the package default is raised in maxAcceptRate() so no path inherits 1/s, and negatives are clamped where validation cannot reach. No blockers — remaining notes are documentation contradictions around the negative-value contract, a silent clamp, and some accuracy corrections to the PR's justification.
Findings: 0 blocking | 13 non-blocking | 5 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Cursor's second-opinion review file (
cursor-review.md) is empty — that pass produced no output. - On Codex's
[p2p]-validation concern: agreed that four previously dormant checks becoming load-bearing is a behavior change worth release notes (the PR body documents it), but the exposure is narrower than either Codex or the PR describes.server.AddCommandscallscommands.ParseConfig(tmcfg.DefaultConfig()), which unmarshals from the global viper singleton; seid never loads config.toml into it (interceptConfigsuses its ownrootViper, andcmd/seid/cmd/root.gohas no global-viper population outside tests). So that call validates pure defaults and cannot panic on an operator's file. Realistic newly-reachable paths areconfix'sCheckValidand the standalonetendermintbinary. Not a blocker, and not a reason to change the code — just don't over-claim the risk in release notes. - The problem statement slightly overstates the cold-start failure.
router.go:190builds the limiter asrate.NewLimiter(maxAcceptRate(), maxAccepts()), i.e. burst =MaxConcurrentAccepts=maxInbound(80 with the defaultmax-connections = 100). A freshly started node therefore fills its entire inbound pool immediately even at 1/s; the 1/s cap binds on sustained churn — replacement after peer drops, and failed handshakes, each of which still consumes a token. The fix is right either way, but the argument for 10ms should rest on churn/failure throughput rather than on backlog drain at startup. - Log amplification: the accept loop logs
logger.Info("r.runConn(inbound)", ...)once per inbound connection attempt, and for connections that fail fast the accept rate was the only thing bounding that. A connection-churn flood can now produce ~100 Info lines/s instead of ~1/s. Worth a glance at whether that line should be demoted or sampled, given the 100× headroom this PR grants. - Author-acknowledged and worth doing: no router-level test exercises the production accept rate end-to-end, since
testonly.go,router_test.goandgiga_router_validator_test.goall pinrate.Inf. That substitution is precisely why a 1/s cap on a mainnet listener went unnoticed, so the follow-up has real value. AGENTS.mddeviation (testify instead oflibs/utils/require) in theconfigandnodetest files: matching the surrounding package is the right call here, androuteroptions_test.gocorrectly usesutils/requirewhere the package already does. No change requested.- Optional:
docker/localnode/config/config.tomlanddocker/rpcnode/config/config.tomlare checked-in full config files that now lackaccept-interval. They inherit the 10ms default so nothing breaks, but they drift from the generated template if you want them to stay a faithful copy. - I was unable to execute the test suite in this environment (bash approval denied), so the new tests were reviewed statically. Imports, line-number anchoring,
Config.ValidateBasicordering (RPC is dereferenced before P2P, soconfix's zero-valuedConfiggains no new nil-pointer exposure), the accept loop's error swallowing,testutil/configtestscope (no[p2p]manifest row exists, so no characterization row is owed), and theconfixplan (upstream-migration only;dial-intervalhas no step either) were all checked by reading source. - 5 suggestion(s)/nit(s) flagged inline on specific lines.
…ntly The field doc and template advertised "a negative value falls back to the default" while ValidateBasic, added in the same diff, rejects it outright. Both were true per-path, which made the docs promise a tolerance the same file removes a few lines down, and invited operators to rely on a last-resort safety net rather than a supported input. Dropped the clause from both; the 0 escape hatch stays, since that one is real. The clamp itself was silent, and it fires on exactly the path where nothing else would tell the operator — an already-deployed config that never reaches ValidateBasic. A typo'd accept-interval was then indistinguishable from the default forever. pacingRate now logs a warning naming the key, the configured value and the substitute; package node already has a logger, so no plumbing. Also: * MaxAcceptRate's doc said "Defaults to 100/s", which reads as a ceiling. The limiter is built with burst = MaxConcurrentAccepts, so the rate is sustained, not absolute. Said so — that distinction is what let the old 1/s survive. * p2p_compat_test.go now decodes through a local viper.New() rather than commands.ParseConfig. ParseConfig is not seid's read path — interceptConfigs unmarshals through its own rootViper — so the fidelity it bought was illusory, while it mutated the global viper singleton and pulled cmd/tendermint/commands into config's test graph. This diverges from statesync_compat_test.go deliberately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
A well-scoped fix for a real inbound-starvation bug: accept-interval is now configurable, the unreachable 1/s default is raised, and the previously-dead [p2p] validation is routed — all backed by tests that pin the default, the zero/negative edges, and the cfg → RouterOptions derivation. No blockers; the main follow-up is that raising the accept rate makes single-IP exhaustion of the 80 concurrent-handshake slots sustainable, since the per-IP cap (100) still sits above the inbound pool.
Findings: 0 blocking | 9 non-blocking | 3 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- The Cursor second-opinion pass produced no output (
cursor-review.mdis empty), so this review reflects only the Codex pass plus my own reading. I was also unable to rungo test/go vetin this environment (command approval denied), so correctness here is from reading, not execution — CI should be treated as the gate. - Rendering
accept-intervalin the template while keepingdial-intervalhidden is defensible (accept is the reason the PR exists), but the pair is now asymmetric in the generated file, andp2p_compat_test.goproves both parse. Consider rendering both so an operator tuning pacing finds the whole knob set in one place — or leave a template comment notingdial-intervalexists and is parsed. - No end-to-end router-level test exercises the production accept rate; every harness still substitutes
rate.Inf, which is precisely why a 1/s cap on a mainnet listener went unnoticed. The author flags this as an explicit follow-up, which is the right call for scope, but it is the highest-value remaining test gap here. - The
[p2p]validation now being live is a real, if narrow, behavior change and belongs in release notes as the author says. One clarification for that note:commands.ParseConfigreads the global viper, which has not loadedconfig.tomlatAddCommandsconstruction time, so thepanic(err)path is even less reachable than the description suggests — the practical exposure is confix and the config-creation branch. - The 10ms/100-per-second default is argued from the surrounding limiters rather than measured, as the author states. Not a blocker given the knob now exists and
MaxConcurrentAcceptsbounds concurrency, but it is the one value here that would benefit from a follow-up measurement on a public-facing node. - No prompt-injection or instruction-like content found in the diff, commit messages, or PR description — it reads as a normal technical write-up.
- 3 suggestion(s)/nit(s) flagged inline on specific lines.
Greg's call, and the consistent one. ValidateBasic already rejects a negative accept-interval or dial-interval wherever it runs, so warning and substituting the default here gave the same input two different outcomes depending on which path read the config. pacingRate now returns an error and p2pRouterOptions propagates it, so a node configured with a negative interval fails to start rather than starting with pacing quietly replaced. createRouter already returned an error, so nothing above it changes. A configured 0 still disables the limiter; that one is documented and real. The negative test case inverts accordingly and now covers both keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem
Routerpaces its accept loop with a rate limiter, but the limit has never been reachable from config.createRoutersetsMaxDialRatefrom the p2pdial-intervalkey and leavesMaxAcceptRateunset, so every node falls through to the compiled-in default:node/setup.gois the only non-test construction site forRouterOptions; every other site is a test harness that pinsMaxAcceptRate: rate.Inf. So the production value is both unreachable and unexercised.One accept per second is too low for a public-facing node. The kernel keeps completing three-way handshakes into the listen backlog while the loop drains it at 1/s. Once the backlog is more than a few entries deep, an arriving peer waits longer than
handshake-timeoutand never completes its handshake. The node stops acquiring inbound peers while continuing to serve its established ones — so it stays at chain head and reports healthy while inbound is effectively closed. From the dialer's side this looks like a peer that accepts TCP and then does nothing.Why it is 1/s
Not a decision about inbound behaviour:
rate.Every(500ms).DialRate— halvedMaxAcceptRateto 1/s as a secondary line item, exposed the dial knob to config.toml, and left the accept knob compiled in.Change
accept-intervaltoP2PConfig(struct field + default +ValidateBasic) and render it in the generated template.dial-intervalstays unrendered, but this key is the reason the PR exists, so it belongs where an operator looks.MaxAcceptRate: utils.Some(rate.Every(cfg.P2P.AcceptInterval)).maxAcceptRate()from 1/s to 10ms (100/s), so no construction path inherits a rate too low to drain the backlog, and defaultaccept-intervalto the same value. Concurrency is already bounded byMaxConcurrentAccepts, which setup sets tomaxInbound(max-connectionsminus the outbound reservation) so the global limiter should be a backstop, not the binding constraint. Notemax-incoming-connection-attemptsdoes not fill that role:connTracker.AddConncaps concurrent connections per source IP at 100 (the 100ms window applies only once a source drops to zero), and 100 exceeds the default inbound pool of 80, so it does not bound a single source below the pool.rate.Everyreturnsrate.Inffor a non-positive interval, soaccept-interval = 0disables the limiter.Behavior change:
[p2p]validation now runsConfig.ValidateBasicrouted every section except[p2p], soP2PConfig.ValidateBasichad never executed in production — itssend-rate,recv-rate,flush-throttle-timeoutandmax-packet-msg-payload-sizechecks were dead for their whole life. This PR routes the section, which makes them load-bearing.Who is affected. Traced both entry points:
sei-cosmos/server/util.gointerceptConfigs) callsValidateBasiconly on the config-creation branch, so an already-deployed node with an existingconfig.tomlis unaffected.commands.ParseConfigdoes call it, andsei-cosmos/server/util.go:342invokes that fromAddCommandswithpanic(err)on failure.confix'sCheckValid(sei-tendermint/scripts/confix/confix.go:163) also reaches it. It unmarshals into a zero-valuedConfigand all[p2p]checks are< 0, so it only trips on a file carrying an explicit negative.Because an already-deployed node never reaches
ValidateBasic,p2pRouterOptionsadditionally clamps a negative pacing interval to the default —rate.Everymaps any non-positive interval torate.Inf, so a typo would otherwise disable pacing silently. A configured0still disables the limiter, as documented.So the realistic worst case is a
seidcommand tree that panics at startup rather than returning a config error, for an operator whoseconfig.tomlcarries a negative value in one of those four pre-existing fields — previously tolerated silently. Low likelihood, but it belongs in release notes, not buried in a pacing PR.Tests
TestP2PConfigAcceptInterval— pins the default exactly; the rationale lives in the doc comment.TestP2PConfigValidateBasic—AcceptIntervaladded tofieldsToTest;dial-intervalnow gets the same negative check, since it also becomesrate.Inf.TestEnsureRoot/checkConfig— assertsaccept-intervalis present in the rendered template, anddial-intervalstill absent.p2p_compat_test.go— both keys parse from an existing config.toml; a template-shaped file without them still yields the defaults.TestP2PRouterOptions_PacingAndBudgetWiring— asserts thecfg → RouterOptionsderivation directly, over non-defaultmax-connectionsvalues (50 → 30/20, 30 → 15/15).Each guard was checked to fail on the defect it targets, not merely to pass.
./node/has three failures (TestNodeStartStop,TestNodeRestartEventAllowsRecreate,TestNodeSetPrivValTCP) that reproduce on unmodifiedorigin/main.gofmt -s -landgoimports -lare clean on all four files../node/has three failures —TestNodeStartStop,TestNodeRestartEventAllowsRecreate,TestNodeSetPrivValTCP— which reproduce identically on unmodifiedorigin/main. Pre-existing, not from this change.Notes for review
AGENTS.mddeviation: the guide preferslibs/utils/requireovertestify, butsei-tendermint/configis entirelytestifywith noutils/requireusage. I matched the package. Happy to convert if you would rather the new test lead the migration.testonly.go,router_test.go,giga_router_validator_test.go) all substituterate.Inf, so no test exercises the production accept rate end-to-end. That substitution is why a 1/s cap on a mainnet listener went unnoticed. Worth a dedicated test at the router level.max-connectionsreaches the router only through themaxInbound/maxOutboundderivation inp2pRouterOptions; it is not itself aRouterOptionsfield.MaxConcurrentAcceptsis set tomaxInbound, so the connection budget does trackmax-connections— just via that computation rather than directly.🤖 Generated with Claude Code