fix(kerykeion): bound the outbound pending queue - #438
Merged
Conversation
added 3 commits
August 21, 2026 01:14
max_inflight bounded only what awaits an ACK. The pending queue behind it had no bound at all, so anything enqueuing faster than the radio drains -- a chatty caller, or a store-and-forward backlog arriving at once -- grew memory without limit. This was #229's last unbounded-growth clause. enqueue now refuses past max_pending and hands the message back rather than dropping it, because the two callers want different things and neither can act on a message the queue has already discarded. send reports the refusal through the existing QueueFull error. The caller asked to send; a queue that silently swallowed the message would look exactly like one that accepted it. The store-and-forward drain is the case worth care. drain_for removes everything it returns, so enqueuing until the queue refused would have destroyed the remainder. It now takes only what fits and returns the rest to store-and-forward, so a saturated queue delays delivery instead of losing it. The fourteen test call sites go through a helper that asserts acceptance rather than discarding the new Result. Discarding at each site would have hidden a regression that makes the queue refuse everything -- which is exactly what the anti-vacuity test exists to catch. Refs #229
…onfig fixture Two call sites the first pass missed. The retry requeue is inside outbound.rs itself, so the grep that found the router's callers excluded it -- searching for callers while filtering out the defining file hides the ones that live beside the definition. Its contract is 'will it be retried', so a refused requeue now returns false rather than promising an attempt that will not happen. One OutboundConfig literal spells out every field instead of using struct update, so it needed the new one.
clippy::result_large_err: PendingMessage carries a whole packet, and an Err variant that large is paid for by every Result in the call chain rather than only on the refusal. Boxing moves the cost onto the path that is already the exception.
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.
Summary
#229's last unbounded-growth clause:
OutboundQueue::pendinghad no bound.max_inflightbounds only what awaits an ACK, which is easy to read as bounding the queue. The queuebehind it was unbounded, so anything enqueuing faster than the radio drains — a chatty caller, or a
store-and-forward backlog arriving at once — grew memory without limit.
The refused message is handed back
enqueuereturnsResult<(), PendingMessage>. Dropping it inside the queue would have been simplerand wrong: the two callers want different things, and neither can act on a message the queue has
already discarded.
sendreports the refusal through the existingQueueFullerror. The caller asked to send; aqueue that silently swallowed the message would look identical to one that accepted it.
The store-and-forward drain is the case that needed care.
drain_forremoves everything itreturns, so enqueuing until the queue refused would have destroyed the remainder — trading an
unbounded queue for silent message loss, which is a worse bug than the one being fixed. It now takes
only what fits and returns the rest to store-and-forward, so a saturated outbound queue delays
delivery rather than losing it. The mid-drain guard stays as a belt-and-braces
breakwith a warning.Tests
The bound refuses past its limit; the refused message comes back with its own packet id, which is what
makes the two caller behaviours possible; and a queue within its bound still accepts — the
anti-vacuity partner, without which both cases would pass against a queue that refuses everything.
The fourteen existing test call sites go through a helper that asserts acceptance rather than
discarding the new
Result.let _ =at each site would have silenced exactly the regression theanti-vacuity test exists to catch.
Scope
This closes the unbounded-growth clauses of #229. One clause remains:
crypto.rsswallowing per-keyinitialization errors and accepting any protobuf-decodable trial plaintext as channel attribution.
That one is a design question — trial decryption without an authentication tag cannot do better than
"it parsed" — and deserves its own change rather than an incidental patch.
Refs #229