fix(relay): cap TopicAnnounce + per-signer slots (SEC-V-06)#453
Merged
intendednull merged 1 commit intoApr 28, 2026
Merged
Conversation
Three layered caps on the TopicAnnounce listener: - MAX_TOPICS_PER_ANNOUNCE = 64 — drop announce before any per-topic work runs. Prior code iterated unbounded; a 256 KB envelope of 2-byte topics meant ~128 000 blake3 hashes per message (CPU amplification). - MAX_TOPICS_PER_SIGNER = 100 with per-signer LRU eviction. Stops one peer from monopolising the global slot table (MAX_TOPICS = 10 000) and starving legitimate clients. Hand-rolled VecDeque + HashMap refcount: insert appends, eviction pops front, refcount==0 triggers network.unsubscribe so the global slot is actually freed. - WARN_RATE_LIMIT = 60s. Replaces the once-per-session warned_full flag so operators see ongoing pressure without log spam. Applied to per-message-cap, global-cap, and per-signer-eviction warns. Refactored listener body around a pure AnnounceState struct that returns TopicActions describing what to do on the network — keeps the state machine unit-testable without driving MemNetwork at 10 000-topic scale. Tests added (relay-tier, lowest covering behaviour): - topic_announce_listener_rejects_oversized_announce — integration test: 65-topic announce dropped, sentinel announce still works. - announce_state_per_signer_lru_evicts_oldest — fills 100 topics for one signer, the 101st evicts t0 and subscribes to the new one. - announce_state_per_signer_lru_does_not_starve_other_signers — A fills its quota, B can still subscribe. - announce_state_repeat_announce_promotes_lru_no_resubscribe — LRU touch on re-announce, no network call. - announce_state_shared_topic_refcount_keeps_subscription — refcount keeps subscription alive when one signer evicts. - announce_state_rejects_at_global_cap — fills 10 000 slots across multiple signers, fresh signer's new topic rejected. - should_emit_warn_rate_limits_to_one_per_window — directly exercises the rate-limit helper. - topic_announce_listener_enforces_max_topics_cap removed (its single- announce-of-10001 setup is now blocked by the per-message cap; the global-cap behaviour is exercised at the unit tier instead). Tradeoff considered: enforcing the per-message cap in willow-common's unpack_wire would protect every consumer, but bincode does not expose inline length caps without a custom Visitor and the relay is the only production consumer; defense-in-depth at the wire layer is a follow-up if we add more consumers. Per-message cap lives in the relay listener where the load-bearing work happens. Refs #235
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
what
Three caps on
topic_announce_listener(SEC-V-06).MAX_TOPICS_PER_ANNOUNCE = 64— drop announce before per-topic loop. Big msg = no work.MAX_TOPICS_PER_SIGNER = 100+ LRU evict. One peer no monopolise global slot table.WARN_RATE_LIMIT = 60s— replace once-per-sessionwarned_full. Operator see ongoing wall, no log spam.why
Old listener iterate
topics: Vec<String>unbounded. 256 KB envelope of 2-byte topics → ~128 000 blake3 hashes per msg. CPU amplification. Plus topics never removed once added, so one peer fill 10 000 slot table = starve everyone.how
AnnounceStatestruct:HashMap<String, usize>refcount + per-signerVecDeque<String>LRU.process_topicreturnsTopicActions { subscribe, unsubscribe, rejected_global, evicted_for_signer }. Caller drive network I/O. No new dep.network.unsubscribe. Otherwise eviction only frees per-signer slot, global table still drains.VecDeque. n ≤ 100 so O(n) probe trivial. Re-announce promote (remove + push_back).should_emit_warn(last, now, interval). Three sites use it: per-msg-cap, global-cap, per-signer-evict.tradeoffs
Per-msg cap could live in
willow-common::unpack_wireto protect every consumer. Rejected: bincode no inline length cap without custom Visitor, relay is only production consumer today. Wire-side defense-in-depth = follow-up when more consumers added. Cap at relay listener, where load-bearing work happen.tests (relay-tier, lowest covering behaviour)
topic_announce_listener_rejects_oversized_announce— 65-topic announce drop, sentinel still workannounce_state_per_signer_lru_evicts_oldest— 101st evict t0, subscribe newannounce_state_per_signer_lru_does_not_starve_other_signers— A fill quota, B still subscribeannounce_state_repeat_announce_promotes_lru_no_resubscribe— LRU touch, no network callannounce_state_shared_topic_refcount_keeps_subscription— refcount keep sub aliveannounce_state_rejects_at_global_cap— fill 10 000 across signers, outsider rejectedshould_emit_warn_rate_limits_to_one_per_window— direct test of helperRemoved
topic_announce_listener_enforces_max_topics_cap— its single-announce-of-10001 now blocked by per-msg cap. Global-cap behaviour exercised at unit tier instead. State-machine logic only → state-tier per CLAUDE.md decision tree.verify
Local — sub-PR base ≠ main, CI no fire. Local gate green:
cargo fmt --checkcleancargo clippy --workspace --all-targets -- -D warningscleancargo test --workspaceall pass (62/26/45/18/5/13/12/31/227/35/16/73/28/13 across crates, no failures)cargo check --target wasm32-unknown-unknown -p willow-identity ... -p willow-webcleanRefs #235
Generated by Claude Code