Skip to content

fix: the volume-test fixes (ACK ceiling, false send failures, forward-window re-key, BLE queue discards) - #583

Merged
bahdotsh merged 22 commits into
mainfrom
fix/volume-test-backpressure
Oct 20, 2026
Merged

bahdotsh merged 22 commits into
mainfrom
fix/volume-test-backpressure

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Draft. Seven device runs of the two-phone volume test (iPhone 11 and Galaxy M36, 5,000 DMs each way at 5 msg/s over BLE) drove this branch. Run 7, on the head commit with the three review fixes, delivered and acknowledged 10,000 of 10,000.

Device runs

Run Build Delivered What it showed
1 main 70.6% Engine wedged at 1,000 pending ACKs; sendMessage failed sends already on the wire; Android went deaf past the forward window; BLE queues tore large messages
2 first fixes 44.8% Wedge and false failures gone; one-way iOS write stall for 17 min; resends burned ratchet keys
3 re-seal budget, stall teardown 58.2% iOS sent to a stale identifier after Android rotated its address
4 link resolution, pull mark 70.5% Congestion collapse: no per-peer flow control, ACKs queued behind data
5 window, ACK lane, coalesced ACKs 50.0% Flow control held (first 1,800 each way at 100%, ACK RTT about 760 ms); a 10-min outage from two LL links between the same radios
6 one link per pair, outbox admission 94.6% iPhone to Android 5,000/5,000; Android to iPhone 4,460, limited by the indication-only egress at exactly the offered load
7 review fixes (e8bc81e) 100% 10,000/10,000 delivered and acknowledged; 0 duplicates, 0 decrypt failures, 0 peers lost, 0 write-stall teardowns (run 6 had 4); about 50 retries per side; ACK RTT p50 under 1 s, p90 17 to 34 s from window holding at the offered load

What changed

Engine

  • A frame is never transmitted without an ACK slot. At the ceiling a send is held and reported as message_deferred (ack_capacity). process() runs every step on every tick. Two leaks that kept the table full are fixed.
  • sendMessage no longer reports failure for a message it sent.
  • A receiver past the forward window re-keys (TooDistantInTheFuture is SessionDesync; authenticated, so R2 is unchanged). The OpenMLS window rises to 8,192.
  • Per-peer window: at most 16 frames awaiting ACK to one peer over a mesh link; the rest are held (message_deferred, peer_window) and sent in order. Session control is never held.
  • BLE queues an ACK ahead of data. Delivery ACKs to a peer advertising ack_versions entry 2 are coalesced, up to 5 per frame within 500 ms (ack_more).
  • Re-seal budget: resends re-seal only while fewer than 256 frames are sealed past the last ACK, then go verbatim; a verbatim copy that can no longer decrypt is re-sealed up to 512. Both derive from the smallest receiver window, a leaf's 1,024, which mls-rs fixes and tools/mls-interop pins.
  • A frame still in the BLE transport queue gets a bounded ACK extension instead of a retry.
  • Outbox admission: a full outbox refuses the newest message to the recipient with the largest backlog. A lighter recipient evicts from the heaviest one (its newest held message first, else its oldest entry). A message awaiting its ACK is never evicted.
  • A message sent during a block is never delivered.

Bridges

  • Outbound fragment queues: cap 768, pull stops at 32 fragments, checked before the destructive pop and only for recipients with a link.
  • iOS: a link refusing every write for 15 s is torn down; a foreground connect is cancelled at 10 s, bound to its own attempt; the stall clock is forgotten on connect and disconnect; sends go to the connected identifier after an address rotation; a departed peer is not redialled forever.
  • One BLE link per peer pair (ADR 0030): no peer is reported lost while it has another live link (a subscribed central counts only while heard from in the last minute); Android refuses a client link beside a subscribed server link; Android's forced refresh keeps the advertisement while an identified peer is linked unless it has stopped. iOS eviction drops every identifier of the peer.
  • Android's indication path drains on confirmation; a failed indication is logged and paced.

Demo app: shows the lock for restored sessions.

Review fixes (last three commits)

  • fix(protocol): let a full outbox evict never-sent held messages: one slow live peer could make every send to every other recipient a refusal.
  • fix(protocol): size the re-seal budget for the leaf's receiver window: the budget assumed an 8,192 window that a leaf (mls-rs, fixed at 1,024) does not have.
  • fix(bindings): stop the BLE bridges stalling and flapping peers: the global backed-up stop, the connect-timer binding, the redial loop, the stall clock, the stale-subscription gate, evictPeer, and the Android refresh. A Rust guard now pins this wiring.

Verification

  • cargo fmt --all -- --check, cargo clippy --workspace -- -D warnings, cargo test --workspace --lib and RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps pass; each of the three review commits passes the unit tests on its own. offline-protocol-sealed lints clean on thumbv8m.main-none-eabihf.
  • tools/mls-interop passes, including the two new leaf-window steps; with the constant set to 1,025 it fails.
  • The new bridge-wiring guard fails when the connect timer is unbound.
  • Swift: the three policy suites pass 39 of 39, and the iOS bridge type-check exits 0.
  • Android: 701 of 711 pass from a copy outside the checkout; the 10 failures are PeerStreamFramingTest, which cannot find its vector file there.

Still owed: a three-device leg for the cross-recipient stall the two-phone harness cannot exercise. Open follow-ups: the iOS write-stall trigger (none fired in run 7), Android's indication-only egress ceiling, and the iPhone's futile dials to Android's rotating advertising address: the stack changed it 9 times in run 7 although the forced refresh kept the advertisement, and the iPhone timed out 92 dials to those addresses while already linked (no effect on delivery).

Checklist

  • cargo fmt --all -- --check passes
  • cargo clippy --workspace -- -D warnings passes
  • cargo test --workspace passes
  • cargo-deny is satisfied (no dependency changes; not run locally)
  • Commits follow Conventional Commits
  • Docs / CHANGELOG.md updated where relevant
  • No new unsafe in core crates
  • The UniFFI UDL did not change

… deaf

A frame more than the sender-ratchet forward distance ahead of the
receiver failed as an ordinary decryption error. So did every later frame
from that sender and every re-sealed resend, each further ahead, so the
session never decrypted from that sender again. The 2026-10-19 volume test
lost its iPhone-to-Android direction this way: 3,915 INVALID_CIPHERTEXT
failures on freshly sealed frames, with the sender logging 12,152 retries
against 2,434 messages the receiver decrypted.

TooDistantInTheFuture now classifies as SessionDesync, which the engine
answers with its rate-limited re-key. Unlike the epoch checks, this one is
authenticated: OpenMLS decrypts the sender data under AEAD before it reads
the generation. TooDistantInThePast stays Decryption, since the session is
healthy and a re-sealed resend recovers it.

The window rises from 1,000 to 8,192 generations, pinned at four times
the engine's in-flight ceiling. It is receiver-local policy and changes
nothing on the wire.
…of wedging the engine

At 1,000 pending acknowledgements the retry path propagated the capacity
error out of process(), which then returned before the timeout sweep on
every tick. The one step that frees slots never ran again, and the engine
never recovered. The direct send path returned the same error to the app
after the frame was on the wire, so one phone's API accepted 2,748
messages while the other received 4,626.

Every transmit path now asks for a slot before it writes. With none free
the message goes to the outbox and the retry queue, the app gets
message_deferred with reason ack_capacity, and the send returns its id.
A held retry is re-queued without being re-sealed or counted as an
attempt. process() runs every step on every tick and reports the first
error at the end. The capacity error is now typed (AckCapacity).

Two leaks that kept the table full are fixed with it. A refused
registration marked every timed-out entry and discarded the list, and the
sweep skips marked entries, so those messages were never retried. The
prune of timed-out entries ran at a fixed five minutes, exactly the retry
ladder's maximum delay, so a ninth retry found its slot gone and needed a
new one at capacity. The prune age now derives from the configuration.

A frame that times out while still in the BLE transport's queue gets its
deadline extended, for up to a minute, instead of being retried. Each
retry used to append a re-sealed copy behind the original, growing the
queue, the round trip and the receiver's ratchet gap together.
…ssage

Both outbound fragment queues discard everything queued for a peer when
they reach their cap, and they reached it. Android's two-second poll
pulled fragments from the core with no backpressure check at all, and
both platforms' drains checked the backed-up mark only after the
destructive pull. With a cap of 100 a single sealed 16 KB message was
already more than the queue could hold, which is why 16 KB and 30 KB
messages from Android never arrived.

Both drains and the Android poll now stop before pulling while any peer
is past the mark. The cap is 768, so the mark holds a whole 512-fragment
message, the largest the core produces. iOS passed its shared inbound
constant to the outbound queues, so they get their own. A Rust guard
pins the two platforms' caps equal and above the message ceiling.
A blocked sender's frame was dropped without marking its id, on purpose,
so a retry arriving after an unblock was delivered. A sender whose retry
ladder was still running when the block was lifted delivered everything
it sent during the block. That is not what a user who blocked someone
expects, and it is the opposite of the post-unblock reset, which already
drops our own queued messages to that peer.

The id is now remembered. Nothing is answered while the block lasts, and
a retry after it meets the duplicate path, which answers it, so the
sender stops retrying.
…ked messages

States the new invariants where each state machine is described: a frame
is never transmitted without a slot to track it, a frame still queued
locally gets a bounded extension instead of a retry, a receiver past the
forward window re-keys (an authenticated trigger, so R2 is unchanged), and
a message sent during a block is never delivered. Changelog entries for
the volume-test fixes.
Sessions restored from storage never emit secure_session_established,
the only thing the demo listened to, so after a restart every contact
showed no lock. The context now seeds its session set from
mlsListSessions once the protocol starts.
The device rerun of the volume test still lost the whole
iPhone-to-Android direction to decrypt failures, 12,160 of them,
with the forward window already widened to 8,192.

It turns out every resend was re-sealed under a fresh ratchet
generation, unconditionally. The receiver has to fast-forward over
each one, and a resend of something it already holds is dropped as a
duplicate without ever being decrypted. So when the link stopped
carrying acknowledgements for 17 minutes, the retry ladder kept
re-sealing at up to 200 frames a second: 20,919 of them. The
receiver ended up past the window and could decrypt nothing.

The queued-frame grace was supposed to stop exactly this, and didn't:
the iOS bridge had already pulled those fragments out of the Rust
queue into its own, where they expired.

So bound the thing that matters directly. An acknowledgement for a
frame proves the receiver decrypted at least that far, so a sender
that never seals more than the window past the last acknowledged
frame can never strand its peer. The MLS session layer now counts
seals per session incarnation against the highest acknowledged one.
Past a quarter of the window, resends go out verbatim, which costs
nothing and still decrypts unless the receiver has moved more than
the out-of-order tolerance past it. The next acknowledgement re-opens
the budget. New messages always seal.

Acknowledgements are unauthenticated, so a forged one can hand
re-seals back. That is no worse than every sender before this.
In the device rerun the iPhone's writes to Android stopped at
16:41:38 and did not come back for 17 minutes. CoreBluetooth reported
the write buffer full the whole time, while the same link kept
delivering Android's notifications. So nothing ever said the link was
down. Every fragment queued for Android expired, and every ack the
iPhone owed went with them.

It recovered only when a connection to Android's *next* advertising
identity happened to complete. Main hit the same stall at 14:52 in
the first run and got lucky sooner.

Two things kept it stuck. A refused write-without-response just
waits for a ready callback that, on such a link, never comes. And
CoreBluetooth never times out a connect: the pending connect to the
new identity sat there, and every later attempt was refused as
"already connecting". The 10 s connection timeout constant has been
declared and unused this whole time. This is not great.

Let's fix both. A central link that refuses every write for 15 s, with
no accepted write and no ready callback in between, is cancelled so
it reconnects. Refusals alone don't count: a busy link refuses
constantly while still moving. And a foreground connect that has not
landed in 10 s is cancelled so the next discovery can try again. In
the background a pending connect is how iOS reconnects a peer coming
back into range, so it is left alone.
The third device rerun of the volume test lost the whole
iPhone-to-Android direction for its last 25 minutes. The new write
stall teardown fired, the iPhone reconnected to Android twice under
Android's new advertising address and verified it there both times,
and still never wrote another fragment.

It turns out the registry answered "which peripheral is this peer
on?" with the first match of a dictionary walk. After a rotation two
identifiers map to the same device id: the verified, connected one,
and the one Android just left, which nothing ever unmaps on an
ordinary disconnect. The walk kept landing on the stale one. So every
send found "no connected peripheral", re-dialled an address nobody
advertised any more, and the connect timeout cancelled and re-armed
it every 10 s. 709 attempts over 19 identifiers, with a perfectly
good link to the same peer sitting idle the whole time.

Confusion ensues.

Let's fix it at all three levels. A send prefers a connected link,
with ties broken on the identifier, so the answer no longer depends
on hash order. Verifying a peer on one identifier retires every other
disconnected one mapped to it, so neither the send path nor the retry
closures dial it again, and a connect still pending to it is
cancelled. And the connection monitor stops re-dialling a peripheral
it never identified once it has not advertised for a minute; the
dozen pending connects to Android's earlier addresses were only
crowding out the one to where it went. A known peer that goes quiet
is still redialled, since rotation is handled by retirement.

Central-role mappings are left alone on purpose: they key inbound
fragments on a link the peer opened, which a verification on our
outbound link says nothing about.
In the third volume rerun Android resent 19,404 times against 5,000
messages, nearly all of it over the one direction that was already
the bottleneck: notifies to the iPhone go out as indications, one
confirmed fragment at a time, about 12 a second.

The queued-frame grace exists for exactly this. A message whose
acknowledgement times out while it is still waiting in the BLE queue
gets its timeout extended instead of a second copy. But the core can
only see what it still holds, and a message stops counting as queued
the moment the bridge pulls its first fragment. Both bridges pulled
until a peer held 576 fragments, three quarters of the cap, because
the pull mark was derived from the cap. Android sat at exactly 576
for most of the run. So the core saw nothing queued, resent
everything behind the first few seconds of link time, and the
fragments it had already handed over aged out after 30 s, tearing
their messages on the way.

Give the mark its own value: 32 fragments, a few seconds of the
slowest link, on both platforms. The rest of the backlog stays in
the core, which costs nothing and is where the grace can see it.

The 768 cap is untouched, and it still never tears a message. Not
because the mark holds a whole 512-fragment message, which is what
the old tests and guard claimed, but because every fragment in the
queue arrives through a pop that checks the mark first, so a peer
never holds more than the mark. The guard now pins what actually
matters: the same cap and the same mark on both platforms, the cap
above the largest message, and the mark below the cap.
A delivery acknowledgement is the thing that stops its sender's
retry timer. The BLE carrier queued it like any other frame, at the
back of one FIFO, behind every data frame and every resend already
waiting for the bridge.

On a link carrying less than it is offered that is exactly
backwards. In run 4 of the 2026-10-19 volume test the round trip
for an acknowledgement reached 196 seconds against a 10-second
timeout, so every message was retried, and every retry the receiver
saw cost it one *more* acknowledgement on the same saturated link.
Confusion ensues.

Queue an acknowledgement ahead of every queued data frame, behind
earlier acknowledgements so they keep their order. The fragments of
a message already being written are not overtaken: they are past
this queue, and tearing a message to save one frame's latency would
be silly.
Android writes every fragment to a subscribed central as an ATT
indication, one outstanding at a time, and then waited another
20 ms before draining the next one.

The pause exists for completions that only say *our* controller
took the op: firing the next write instantly out-runs a slower peer
and drops fragments mid-burst. An indication's completion is not
that. It fires on the central's confirmation, so the peer already
has the fragment and there is nothing left to wait for.

On the indicate path the pause was dead time. It held the
Android-to-iPhone direction to 12 to 16 fragments a second in the
2026-10-19 volume test, below the 15 it was offered, and that
deficit is where the collapse started.

Drain at once on a confirmed indication. Central writes keep the
pacing.
Two phones sending 5 messages a second each way over Bluetooth LE
delivered 70.5%. The link was up the whole time. It just carried
12 to 16 frames a second in one direction against 15 offered.

It turns out the sender has no flow control at all. Every new
frame, every resend and one acknowledgement per received frame go
into one unbounded queue, on a retry timer that starts at enqueue.
So the acknowledgement round trip passed the 10-second timeout
after about two minutes, everything was retried, each resend cost
the receiver another acknowledgement on the saturated path, and
that path's rate *halved*. Both phones then filled their
1,000-slot acknowledgement tables, and every later send evicted a
message as "Outbox capacity exceeded". 50,000 retries, 6,000
messages failed.

The four earlier fixes on this branch each removed one amplifier
of that loop, and the loop found the next one every time. This
removes the loop.

At most 16 frames that wait for an acknowledgement are in flight to
one peer over a mesh carrier. The next one waits in the outbox, the
app gets message_deferred with reason peer_window, and held messages
go out oldest first as slots free. A new message never overtakes a
held one, holding is not an attempt, and a resend of an in-flight
frame always passes. That bounds the queue, and with it the round
trip, the resends and the receiver's ratchet gap, at any link rate.

The count is derived from the outbox rather than kept on the side,
so no settle, give-up, expiry or eviction path can leak a slot and
wedge a peer. Please don't turn it into a counter.

Only the mesh carriers. A relay mailbox is store-and-forward, and
holding a message back from it would strand it on a phone the user
is about to close.
Every delivered frame got its own acknowledgement frame: a 264-byte
unsealed Message, one BLE fragment. In the 2026-10-19 volume test
that was a third of the frames each phone sent, on a link already
carrying less than it was offered. 13,058 of the 25,133 fragments
Android managed to push to the iPhone were acknowledgements.

That's a lot of radio spent saying "got it".

To a peer that advertises ack_versions entry 2, delivery
acknowledgements for frames that arrived over a mesh carrier are now
held for up to 500 ms and sent as one frame of up to five: the first
id in ack_for, the rest in a new ack_more key. At a few messages a
second that is two or three answers per frame. Five still fits in
one 512-byte fragment.

The receiving side settles each id exactly as if it had arrived in
its own frame, every check included, so a coalesced acknowledgement
can do nothing five single ones couldn't. It reads at most four
extra ids, and ignores ack_more entirely next to ack_status: an
undecryptable answer is not a delivery and is never coalesced.

It's capability-gated because an older SDK reads only ack_for and
would settle one id of five. A peer that never advertised entry 2
keeps getting one acknowledgement per frame. The relay isn't
coalesced: a relay frame costs a round trip to a server, not a
share of a radio link.
Past the re-seal budget a resend goes out verbatim, which spends no
ratchet generation. That's the point of the budget. The catch is
that a verbatim copy only decrypts while the receiver is within the
out-of-order tolerance (32 generations) of it.

It turns out a copy sealed thousands of generations back is refused
outright (TooDistantInThePast), and on a mesh link nothing tells the
sender. So it went out verbatim, failed, was retried, went out
verbatim again, until its ladder ran out. In run 4 of the volume
test 525 messages died exactly like that: 3,308 decrypt failures on
the receiver and not one of them delivered.

A resend that cannot decrypt is a wasted resend either way, so spend
the generation and re-seal it. The exception stops at half the
forward window, because the receiver going deaf is the failure the
budget exists for, and a wasted resend is not.

With the per-peer window this should essentially never fire on a
mesh link. It's here for the paths the window does not govern.
Record the volume-test collapse and the five changes that fix it,
and tell application teams the one thing they can see: a burst of
more than 16 direct messages to a nearby peer now reports the rest
as message_deferred with reason peer_window instead of
message_sent, and sends them in order as acknowledgements return.

An app that renders that deferral as a failure will look broken
while working fine. Show it as queued.
When the outbox hit 500 entries it failed its least recently sent
entry to admit any new one, whatever either of them was doing. In
run 5 of the 2026-10-19 volume test a ten-minute Bluetooth outage
turned that into about 2,500 terminal failures per phone, each one
a message the app had shown as queued minutes earlier, thrown away
so a *newer* message to the same unreachable peer could take its
slot. Under load on a live link the same rule failed frames that
were already on the wire, whose acknowledgements then settled
nothing.

That is backwards. Let's admit instead of evict.

An app send to a full outbox is refused when its recipient holds
the largest backlog, ties included: no entry, message_failed with
"Outbox capacity exceeded" and zero attempts, and send still
returns the id, exactly like the ack_capacity deferral. A send to a
recipient with a smaller backlog evicts the heaviest recipient's
oldest entry, so one dead peer's week of messages can't block a
message to someone reachable. Nothing awaiting its acknowledgement,
and nothing the per-peer window holds for a live mesh link, is ever
evicted.

Internal frames are never refused: they evict by the same rules and
are admitted over the cap only while every entry is in delivery,
which the acknowledgement table bounds. Media keeps its own cap.
The existing eviction tests now force eviction through a lighter
recipient, and the awaiting-ack case asserts the message survives
and is delivered instead of being settled once.
Run 5 of the 2026-10-19 volume test lost ten minutes to a peer that
was never gone. The iPhone held a link to Android's server; its
random address rotated, Android saw a "new" device, dialled it,
verified it as the iPhone and kept it. Two Link Layer connections
between the same two radios. When the first one's write buffer
stalled, the stall policy tore it down and the iPhone carried on
over the second link for exactly ten seconds.

Then the redial of the dead address hit the connect timeout.
CoreBluetooth reports a cancelled connect as a disconnect, the
identifier still mapped to Android, and the "wasn't connected"
branch reported Android lost. The core refused every send to a peer
the iPhone was connected to.

It turns out no reconnect could have ended it either. Two devices
hold one connection between them (Core 5.4, Vol 6 Part B, 1.1.1),
and every iPhone dial came from the address Android's second link
already held. Seven advertising addresses in a row timed out, and
Android minted a new one every two minutes because its "forced BLE
refresh" restarts the advertising set. The one dial that worked
followed the iPhone's own address rotation.

So: iOS never reports a peer lost, and never dials it, while the
peer is connected and verified on another identifier or subscribed
to our server from one; the dropped identifier is retired instead.
Android treats a client link as a duplicate when the peer already
holds a subscribed link to its server, and the periodic refresh
restarts the advertisement only while no link is up. ADR 0030
records why both halves are needed.

The write stall itself is still unexplained. It has only ever shown
up with two links up, so the teardown now logs the peer's other
live links and run 6 will say whether that suspicion holds.
Two Fixed entries for the false peer loss and the outbox admission
rule, and an upgrade note: a send to the recipient with the largest
backlog in a full outbox now gets message_failed ("Outbox capacity
exceeded", no attempts) for the id it returns, instead of an older
queued message failing to make room.
A full outbox refused every send to every recipient as soon as one
slow mesh peer's held line filled it. The per-peer window parks the
seventeenth message onward in the outbox, and those held entries
were marked unevictable on a live link. So with 16 in flight and 484
held for one Bluetooth peer, a message to anyone else found nothing
it was allowed to evict and was refused.

That rule was protecting the wrong thing. A held message has never
left the device. The thing that must never be evicted is a message
awaiting its acknowledgement, because failing it leaves an ACK that
settles nothing. Held messages are now evictable, from the back of
their recipient's line, so the front (the part about to go out)
survives and the conversation stays contiguous. The order comes from
the line position, not a timestamp: a burst is held within a single
millisecond, and ranking by time picked a random message.

While at it, session control is no longer windowed. A key package,
Welcome, confirmation probe or its answer is what heals the session
the held frames are sealed under. Parking one behind sixteen frames
the peer cannot decrypt waits for acknowledgements that will never
come. That is a deadlock with extra steps.

The MessageDeferred reference now lists the reasons it can carry,
and says that a deferred message never gets message_sent.
The re-seal budget was a quarter of SENDER_RATCHET_MAXIMUM_FORWARD_
DISTANCE, 2,048 generations, on the assumption that every 1:1
receiver runs OpenMLS configured with 8,192. A leaf node does not.
It runs mls-rs, and mls-rs hard-codes its receiver window at 1,024
(MAX_RATCHET_BACK_HISTORY in the secret tree) with no setting to
change it. The group code even claimed the leaf was "configured with
the same two numbers". It was not, and could not be.

So a phone could legitimately burn 2,048 generations re-sealing for
a leaf that gives up at 1,024, and walk it past its window with the
very mechanism meant to prevent exactly that. This is not great.

Name the leaf's window in the sealed layer, derive the budget and
the dead-verbatim hard limit from the smaller of the two windows
(256 and 512 now), and pin the number against mls-rs itself: the
interop harness now sends a frame exactly 1,024 generations ahead,
which must decrypt, and one 1,025 ahead, which must be refused. An
mls-rs upgrade that moves the limit fails CI instead of a device.

The smaller budget costs nothing in practice. The per-peer window
caps unacknowledged growth at resends of sixteen frames, so the
budget only trips after a whole retry ladder with no ACK, and the
existing compile-time assertion still holds (176 < 256).
Review of the volume-test branch found that the bridge wiring, which
CI only type-checks, undid several of its own fixes. These are all
the same kind of bug, so they go in together, one paragraph each.

The backed-up stop was global. Both drains stopped pulling from the
core while *any* recipient sat at the 32-fragment mark, but the pop
is one FIFO across peers. A peer whose link had just closed kept
collecting fragments it could never send, and every other peer's
egress waited until those expired, in 30-second rounds. Only a
recipient with a link to drain into may stop the pull now.

The iOS connect timeout captured only the peripheral. A timer from
a dial that had already failed cancelled the healthy redial armed
after it, and the cancel was reported as the peer lost. The timer
is now bound to the attempt that armed it.

A verified peer that walked away was redialled forever. The
never-connected branch kept the identifier mapped, so the monitor
dialled it every tick, the timeout cancelled it at 10 s, and every
cycle re-reported the peer lost and restarted the advertisement.
That branch now unmaps the identifier, so the monitor's 60-second
advertisement window applies.

The write-stall clock outlived the link it was timing. A refusal
between the teardown verdict and the disconnect re-armed it, the
identifier survives the reconnect, and the fresh link could be torn
down on its first refusal. It is forgotten on connect and
disconnect, and reset on stop.

A subscribed central counted as a live link until didUnsubscribeFrom
arrived. If that callback never came, the gate kept a departed peer
a neighbour for good: it retired the real identifiers, refused every
dial and reported nothing lost. A subscription counts only while its
central has been heard from in the last minute. evictPeer had the
opposite problem, evicting one of a peer's identifiers and reporting
the peer lost while the other link stayed up. It now evicts them all.

On Android the forced refresh never restarted a stopped advertisement
while anything was linked, including an unverified central. It now
goes through a small policy: restart when no identified peer is
linked, or when the advertiser says it is not running.

While at it: a failed indication is logged and paced instead of
treated as a confirmation, the tiny-cap mark can no longer be zero,
and the dead hasOtherClientLink is gone. A new Rust guard pins the
wiring above by reading the Swift and Kotlin sources, since nothing
else would notice it breaking.
@bahdotsh
bahdotsh marked this pull request as ready for review October 20, 2026 05:18
@bahdotsh
bahdotsh merged commit 5195fa2 into main Oct 20, 2026
27 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 20, 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.

1 participant