Skip to content

fix(kerykeion): bound the outbound pending queue - #438

Merged
forkwright merged 3 commits into
mainfrom
fix/229-bound-outbound-queue
Aug 21, 2026
Merged

fix(kerykeion): bound the outbound pending queue#438
forkwright merged 3 commits into
mainfrom
fix/229-bound-outbound-queue

Conversation

@forkwright

Copy link
Copy Markdown
Owner

Summary

#229's last unbounded-growth clause: OutboundQueue::pending had no bound.

max_inflight bounds only what awaits an ACK, which is easy to read as bounding the queue. The queue
behind 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

enqueue returns Result<(), PendingMessage>. Dropping it inside the queue would have been simpler
and wrong: 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 identical to one that accepted it.

The store-and-forward drain is the case that needed care. drain_for removes everything it
returns, 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 break with 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 the
anti-vacuity test exists to catch.

Scope

This closes the unbounded-growth clauses of #229. One clause remains: crypto.rs swallowing per-key
initialization 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

forkwright 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.
@forkwright
forkwright merged commit 35a5d74 into main Aug 21, 2026
11 checks passed
@forkwright
forkwright deleted the fix/229-bound-outbound-queue branch August 21, 2026 06:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant