Skip to content

fix: close six reachable contradictions in implicit state machines - #697

Merged
bahdotsh merged 8 commits into
mainfrom
fix/implicit-state-machine-bugs
Nov 4, 2026
Merged

bahdotsh merged 8 commits into
mainfrom
fix/implicit-state-machine-bugs

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Nov 4, 2026

Copy link
Copy Markdown
Member

Fixes found by an audit of the SDK's implicit state machines: lifecycles encoded in flags, Option fields and map membership rather than one enum. Each fix closes a combination of fields that the code could reach and should not.

Nine candidates went in. Six are fixed here, and three turned out to be intended behaviour once read in full; those are listed at the end so nobody re-reports them.

Fixed

Layer What was wrong Fix
iOS start() reported Bluetooth available at once and again after 1 s, whatever the radio's state. With Bluetooth off, unauthorized or unsupported, the manager reports false, and the backup then reported true over it, so the engine routed sends to a dead radio until its next state change. Both reports are skipped while BleManager.state == .unavailable. Coming to the foreground also restated the Wi-Fi Direct layer up for a transport that was disabled, stopped or had failed to start; it now does so only while the manager is .running.
FFI Two copies of event delivery. Core events clone the callback out of its slot and catch a throw. Wrapper events (NeighborDiscovered, NeighborLost, PeerRssi, carrier status) called the callback under the read guard with no catch_unwind: a callback calling set_event_callback deadlocked, and a throwing one skipped the polling queue. One deliver_event used by both paths.
MLS delete_session left the PendingWelcome that encrypt_for_user parks, so mls_get_pending_welcome returned an invitation to a deleted group. Cleared after the session goes, best-effort, so an Err still means the session exists.
Browser ble_link_up added the address to linked and consumed its verification before looking up the BLE transport, which does not exist until initialize. The refused call left the address linked: its fragments were accepted, and the next link-up read as a relink and never announced the peer. The transport is resolved before either set changes.
Android start() ran protocol?.start() then startForegroundService() even with no protocol, putting "Mesh Active" up over no engine; a sticky restart then read that as KEEP_ALIVE. The JS wrapper never does this; a direct native-module caller could. Rejects with "Protocol not initialized", as the module's other methods do, before any side effect.
Engine OutboundTransferState::on_chunk_ack counted any index, so is_fully_acked could go true early. Latent: the engine completes transfers from its own delivered set. Only a chunk the window handed out counts.

Investigated and left as is

  • The transport manager and the DORS selector each keep a current transport. They mean different things. The selector's copy is the hysteresis anchor, and its setter restarts the switch cooldown; the manager's is the carrier that last carried a frame, which direct neighbour sends update. Merging them would let every neighbour send reset the cooldown. A doc comment on the field now says so.
  • An epoch fork whose leader fails update_keys is not retried. check_epoch_forks documents this as intended: the 5-minute stale sweep allows re-detection, and escalation is the application's.
  • The browser worker keeps engine set after stop(). OfflineNode.start is a static constructor that creates a fresh worker and sends one start, and stop() terminates the worker, so neither a restart nor a concurrent start reaches it through the page API.

Verification

  • Each Rust fix has a test that fails with the fix reverted. The FFI deadlock test runs on a thread with a 10 s deadline so a regression fails instead of hanging; on the old path it hit the deadline.
  • cargo clippy --workspace -- -D warnings, cargo fmt --all -- --check and RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps are clean.
  • cargo test --workspace: 3,752 passed. The one local failure, no_shipped_file_names_a_relay, flags only files in a gitignored .claude/worktrees checkout that CI does not have.
  • iOS: OfflineProtocolModule.swift typechecks with React using the CI step's recipe; a probe calling a missing method on the module was caught, so the harness is not vacuous.
  • Android: the module compiles and all 773 JVM unit tests pass in the CI harness. There is no test for start() itself, because the module class needs a React context.
  • Not run locally: the Swift package and Android library jobs, and the browser pages in three engines. The browser change is in the Rust engine and is covered by cargo test -p offline-protocol-web.

delete_session removed the group and its establishment stamp but left the
Welcome that encrypt_for_user parks for the peer. get_pending_welcome then
handed out an invitation to a group this device no longer has. The Welcome is
cleared after the session goes, best-effort, so an Err still means the
session is there.
on_chunk_ack inserted any index into acked, so a stray or out-of-range index
could fill the set to chunks.len() and is_fully_acked would report a transfer
complete with real chunks unacknowledged. The engine completes transfers from
its own delivered set today, so this is latent, not live.
…ransport

The manager's copy is the carrier that last carried a frame; the selector's
is the DORS hysteresis anchor, whose setter restarts the switch cooldown.
Merging them would let every direct neighbour send reset the cooldown.
…livered

The wrapper had two copies of event delivery. The core-event path clones the
callback out of its slot and catches a throw; emit_event, which carries
NeighborDiscovered, NeighborLost, PeerRssi and the carrier status events,
called the callback under the slot's read guard with no catch_unwind. A
callback that called set_event_callback from inside it deadlocked, and a
throwing one unwound into the wrapper and the polling queue lost the event.
Both paths now go through deliver_event.
… no link

ble_link_up added the address to linked and consumed its verification before
looking up the BLE transport, which does not exist until initialize. The
refusal left the address linked: fragments from it were accepted, and the next
link-up read as a relink and never announced the peer. The transport is now
resolved before either set changes.
… down

start() told the core BLE was available at once and again a second later,
whatever the manager's state. With Bluetooth off, unauthorized or unsupported
the manager reports false, and the one-second backup then reported true over
it, so the core routed sends to a radio that was off until its next state
change. Both reports are now skipped while the manager is unavailable.

The foreground edge likewise restated the Wi-Fi Direct layer up on every
foreground, for a transport that was disabled, stopped or had failed to
start. It now does so only while the peer-stream transport is running.
…thing was created

start() called protocol?.start() and then startForegroundService() even with
no protocol, so "Mesh Active" went up and a host was registered over no
engine; a sticky restart then read that as KEEP_ALIVE. It now rejects with
"Protocol not initialized", as the module's other methods do, before the
event buffers or the service are touched. The JavaScript wrapper never
reaches this; a direct caller of the native module could.
@bahdotsh
bahdotsh merged commit ae81b70 into main Nov 4, 2026
45 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 4, 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