Finding
In MeshRouter::send, self.delivery.track(id, dest) runs unconditionally before the unreachable path calls self.store_forward.store(...). If the destination is unreachable and store returns Error::QueueFull, the ? propagates the error and returns early — but the delivery tracker already holds a Queued record for id, even though no packet was queued anywhere. The caller receives an error and drops the PacketId, so the orphaned record stays Queued forever.
Evidence
crates/kerykeion/src/router.rs:136 — track runs first, unconditionally:
self.delivery.track(id, dest);
crates/kerykeion/src/router.rs:147 — the store that can fail with ? after the record already exists:
self.store_forward.store(
NodeNum(dest),
StoredMessage {
Why this matters
A full store-forward queue (e.g. a long-offline node, or a destination an adversary keeps saturated) makes every subsequent send() to that destination insert a phantom Queued entry into the delivery tracker. Callers that query delivery_status expecting None for an un-sent packet instead see Queued, corrupting delivery accounting. Because nothing resolves these records, the delivery table leaks memory proportional to the QueueFull rejection rate — an adversary-controllable rate under the threat model.
Desired correction
Move self.delivery.track(id, dest) to after the dispatch (outbound.enqueue / store_forward.store) succeeds, or add a self.delivery.remove(id) in the Err branch before returning. Track only once dispatch is confirmed.
Done when: a QueueFull return from send() leaves the delivery tracker unchanged — no entry exists for the rejected packet ID.
Finding
In
MeshRouter::send,self.delivery.track(id, dest)runs unconditionally before the unreachable path callsself.store_forward.store(...). If the destination is unreachable andstorereturnsError::QueueFull, the?propagates the error and returns early — but the delivery tracker already holds aQueuedrecord forid, even though no packet was queued anywhere. The caller receives an error and drops thePacketId, so the orphaned record staysQueuedforever.Evidence
crates/kerykeion/src/router.rs:136— track runs first, unconditionally:crates/kerykeion/src/router.rs:147— the store that can fail with?after the record already exists:Why this matters
A full store-forward queue (e.g. a long-offline node, or a destination an adversary keeps saturated) makes every subsequent
send()to that destination insert a phantomQueuedentry into the delivery tracker. Callers that querydelivery_statusexpectingNonefor an un-sent packet instead seeQueued, corrupting delivery accounting. Because nothing resolves these records, the delivery table leaks memory proportional to the QueueFull rejection rate — an adversary-controllable rate under the threat model.Desired correction
Move
self.delivery.track(id, dest)to after the dispatch (outbound.enqueue/store_forward.store) succeeds, or add aself.delivery.remove(id)in theErrbranch before returning. Track only once dispatch is confirmed.Done when: a
QueueFullreturn fromsend()leaves the delivery tracker unchanged — no entry exists for the rejected packet ID.