Skip to content

fix(protocol): a new confirmation probe supersedes the last - #515

Merged
bahdotsh merged 17 commits into
mainfrom
fix/confirmation-probe-supersede
Oct 6, 2026
Merged

bahdotsh merged 17 commits into
mainfrom
fix/confirmation-probe-supersede

Conversation

@mizanxali

@mizanxali mizanxali commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

An unconfirmed session is probed every five seconds, and each probe entered the outbox with its own retry ladder on top of the unanswered ones before it. Only a relay verdict (unreachable or relay_pushed) backed that cadence off, and a mesh-only carrier never produces one. An account with ten such contacts stacked about two probes a second, filled the 500-entry outbox, and capacity eviction then failed the user's own messages with Outbox capacity exceeded. Seen on an Android and iPhone device test, offline over BLE.

A peer now holds at most one probe in the outbox. Each new probe supersedes the last, quietly and without counting a delivery failure against the carrier, and the cadence is unchanged, so a lost probe is replaced on the next scan.

  • One sender owns the probe. The supersede and the next due time live in send_session_confirmation_probe (protocol/session.rs), which both the periodic scan and the Welcome fast path (on_transport_send_confirmed) call. A fast-path probe keeps a due time a relay verdict has already backed off.
  • One teardown. forget_outbound_message (protocol/send.rs) takes a message out of the retry queue, the ACK tracker and the outbox together. retire_undeliverable_message now calls it and then records the carrier failure; the supersede calls it without one.
  • No orphaned probe. A probe is withdrawn when its session confirms or is torn down (clear_confirmation_recovery_tracking), when its peer leaves the pending set some other way (the scan's prune), and when the send fails after the probe entered the outbox (a full ACK tracker), where no id comes back to track.
  • Not persisted. Probes are kept out of protocol-state storage (is_confirmation_probe), which removes a secure-storage write per probe and a delete per supersede under the protocol lock. Probes an older build persisted are dropped at restore, inside the existing delete budget.
  • Late verdicts still count. A relay verdict finds its peer through the outbox entry, which the supersede removes, so each peer's last superseded probe id is kept (confirmation_probe_superseded). A verdict on it records the reachability fact and backs the schedule off, once per probe. This covers a verdict up to two probe intervals late.

Type of change

  • fix: bug fix

Checklist

  • cargo fmt --all -- --check passes
  • cargo clippy --workspace -- -D warnings passes
  • cargo test --workspace passes (locally)
  • RUSTDOCFLAGS="-D warnings" cargo doc passes for offline-protocol
  • cargo-deny is satisfied (no new dependencies)
  • Commits follow Conventional Commits (<type>(<scope>): <subject>)
  • Docs / CHANGELOG.md updated where relevant
  • No new unsafe in core crates
  • UniFFI UDL unchanged; no binding regeneration needed

Tests

  • an_unanswered_confirmation_probe_supersedes_the_last_one interleaves scan and fast-path probes against a peer that never confirms, then asserts only the latest probe is in the outbox and none of the superseded ones is in the retry queue or ACK tracker. It also checks that no message_failed fires and no carrier failure is recorded, and covers each case above: a deferred probe, confirmation, a session deleted underneath the engine, a full ACK tracker, a late recipient_unreachable and relay_pushed verdict (escalating once, recording the fact), and a fast-path probe keeping a backed-off due time.
  • a_persisted_confirmation_probe_is_not_restored covers the restore drop and the persist guard.
  • Each assertion was checked to fail with its fix removed.

Breaking changes

None. No API, wire, UDL or config change. Probe cadence is unchanged; only the number of probes waiting in the outbox per peer drops to one.

Notes for reviewers

  • The supersede runs before the send, so a new probe can never evict a real message at outbox capacity to make room beside the one it replaces. If that send then fails, the peer has no probe in flight until the next scan.
  • docs/state-machines/session-lifecycle.md has a new section, "An unconfirmed session holds one probe", with the invariant and the failures each part prevents. outbox-and-retries.md names the probe paths (supersede and withdrawal) as the penalty-free uses of the three-way teardown, next to the give-up paths that record a carrier failure.

An unconfirmed session is probed every five seconds and each probe entered
the outbox with its own retry ladder on top of the unanswered ones. Only a
relay unreachable verdict backed that off, which a relay that pushes to
offline users or a mesh-only link never produces. An account with ten such
contacts stacked about two probes a second, filled the 500-entry outbox,
and capacity eviction then failed the user's own messages. Each probe now
retires the previous one (no event, no carrier penalty), so a peer holds
one probe in the outbox on the same cadence.
Move the supersede into send_session_confirmation_probe so the Welcome
fast path's probe is covered too, share the retry/ack/outbox teardown
with retire_undeliverable_message via forget_outbound_message, and drop
persisted probes at restore, where no later probe could supersede them.
…racking

Confirming or tearing down a session dropped the probe's map entry but
left the probe queued with its full retry ladder, where nothing could
supersede it. The test now also pins that superseding records no
carrier failure and emits no message_failed.
Restore already discards every probe, so writing one per send and
deleting it on each supersede was secure-storage I/O under the protocol
lock for nothing. Also let the throttled scan run while a probe is
outstanding, so a fast-path probe is superseded or pruned, and pin the
pending-set prune in the test.
The docs said only an unreachable verdict backs the probe cadence off,
but park_relay_pushed_dm escalates it as well; the case that never backs
off is a mesh-only carrier. Also pin that a supersede clears a deferred
probe's retry entry, and note that a superseded probe's verdict is
dropped.
A full ACK tracker fails send_internal_message after the probe entered
the outbox, so no id came back to supersede it next time. The failed
send now withdraws any probe still queued for the peer. The probe sender
also stamps the cadence, so the scan no longer supersedes a fast-path
probe within two seconds, and restore logs how many legacy probes it
dropped.
… off

Both verdict handlers find the peer through the outbox entry, which the
supersede removes, so a verdict slower than the probe interval was
dropped and the peer kept being probed every five seconds. Remember the
superseded id per peer and escalate from it, once per probe. Drops the
redundant outstanding-map check from has_pending_work.
The superseded-probe path backed the schedule off but skipped the
reachability fact the queued-probe path records, so the next DM still
tried the carrier that had refused. A fast-path probe also keeps a
backed-off due time instead of resetting it to five seconds, and the
legacy-probe drop at restore logs at info.
…en dropped

The superseded slot holds one id per peer, so a verdict is honoured up
to two probe intervals late; say so. The no-outbox-entry log now fires
only when no superseded probe consumed the verdict.
@mizanxali
mizanxali requested a review from bahdotsh October 6, 2026 10:24
This branch was cut before the 0.28.0 release. Its entry sat under
the branch's [Unreleased] heading, which on main had since been
renamed to [0.28.0]. The merge is textually clean, so git happily
filed the bullet under 0.28.0's Fixed section.

0.28.0 did not ship this fix. Move it to the new [Unreleased] Fixed
section, where it belongs.
The supersede test was one function walking eight phases over shared
state. The first failure hid every phase after it, and the full ACK
tracker phase left the fixture broken for anything added later. This
is not great.

Split it into three tests on a shared fixture: the supersede itself,
late relay verdicts, and the withdrawals (confirm, session deleted
under the engine, send failing after the probe was queued).

While at it, assert two things the old test never checked. A verdict
two intervals late finds no remembered probe, records no fact, and
does not escalate. And the scan that withdraws a settled peer's probe
also prunes the superseded id it kept for a late verdict. The second
was checked to fail with that prune removed.
The upgrade path drops every confirmation probe an older build
persisted, charging each delete to the outbox walk's prune pool. That
only works out because the pool (512) happens to be bigger than the
outbox cap (500). Nothing said so.

If someone shrinks the pool, a device upgrading from the build that
stacked probes spends the whole pool on them, the walk breaks, and
the user's real messages behind them are not restored until a later
launch. Not lost, since they stay on disk unsettled, but not
delivered either. Silently.

Pin it twice: a const assert next to the advisory-floor one, so the
build refuses the change, and a restore test with a full outbox of
legacy probes plus one real message, which must come back in the same
launch. The test was checked to fail with the pool cut to 400.
@bahdotsh
bahdotsh merged commit 99aee10 into main Oct 6, 2026
24 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants