From 27f2fa61a05f0a4c061f61d408de090c51ba05c5 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Thu, 5 Nov 2026 00:12:35 +0530 Subject: [PATCH 1/8] fix(mls): drop a pending Welcome when its session is deleted 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. --- crates/offline-protocol-mls/src/manager.rs | 36 +++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/offline-protocol-mls/src/manager.rs b/crates/offline-protocol-mls/src/manager.rs index dc556410c..8b52ce230 100644 --- a/crates/offline-protocol-mls/src/manager.rs +++ b/crates/offline-protocol-mls/src/manager.rs @@ -1215,11 +1215,25 @@ impl MlsManager { /// still there, because callers clean up their own state for the peer /// only once this succeeds. Deleting the session first and then failing /// on the stamp left a peer recorded as confirmed with no session. + /// + /// A Welcome still pending for the peer goes last, and only after the + /// session is gone. It invites the peer into the group just deleted, so + /// leaving it let `get_pending_welcome` hand out an invitation to a group + /// this device no longer has. Clearing it is best-effort: by then the + /// session is deleted, and an `Err` here would claim it was not. pub fn delete_session(&self, other_user_id: &str) -> Result<()> { let session_id = self.session_manager.get_session_id(other_user_id)?; self.storage .delete(StorageKeyType::GroupMetadata.as_str(), session_id.as_str())?; - self.session_manager.delete_session(other_user_id) + self.session_manager.delete_session(other_user_id)?; + if let Err(error) = self.clear_pending_welcome(other_user_id) { + warn!( + other_user_id = %other_user_id, + error = %error, + "Session deleted but its pending Welcome could not be cleared" + ); + } + Ok(()) } // ======================================================================== @@ -4341,6 +4355,26 @@ mod tests { assert_eq!(alice.unacknowledged_seals(&bob), 0); } + /// A Welcome stored for a session must not outlive it: it invites the + /// peer into the group that was just deleted. + #[test] + fn deleting_a_session_drops_its_pending_welcome() { + let alice = create_test_manager("alice"); + let bob = create_test_manager("bob"); + let bob_kp = bob.generate_key_package().unwrap(); + alice + .import_key_package(&addr("bob"), &bob_kp.key_package_data) + .unwrap(); + + // The FFI path creates the session on first send and parks its Welcome. + alice.encrypt_for_user(&addr("bob"), b"hello").unwrap(); + assert!(alice.get_pending_welcome(&addr("bob")).unwrap().is_some()); + + alice.delete_session(&addr("bob")).unwrap(); + assert!(!alice.has_session(&addr("bob")).unwrap()); + assert!(alice.get_pending_welcome(&addr("bob")).unwrap().is_none()); + } + /// RFC 8032 ยง7.1 TEST 1 Ed25519 public key. const RFC8032_TV1_PK: [u8; 32] = [ 0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07, From 6e930a932d1be74db5ba6fbffd65bfb2bbb58ec0 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Thu, 5 Nov 2026 00:12:35 +0530 Subject: [PATCH 2/8] fix(protocol): count a chunk acknowledgement only for a chunk handed out 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. --- crates/offline-protocol/src/file_transfer.rs | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/offline-protocol/src/file_transfer.rs b/crates/offline-protocol/src/file_transfer.rs index d100699f1..8267bab96 100644 --- a/crates/offline-protocol/src/file_transfer.rs +++ b/crates/offline-protocol/src/file_transfer.rs @@ -1100,7 +1100,18 @@ impl OutboundTransferState { /// Records that a chunk was ACKed. Returns `true` if this was an in-flight /// chunk (i.e. the window has room for more). + /// + /// An acknowledgement only counts for a chunk this window has handed out. + /// Counting any index let a stray or duplicated index fill `acked` up to + /// `chunks.len()`, so `is_fully_acked` reported a transfer complete while + /// real chunks were still unacknowledged. pub fn on_chunk_ack(&mut self, chunk_index: u32) -> bool { + let handed_out = self.chunks[..self.next_unsent] + .iter() + .any(|chunk| chunk.chunk_index == chunk_index); + if !handed_out { + return false; + } self.acked.insert(chunk_index); self.in_flight.remove(&chunk_index) } @@ -2257,4 +2268,33 @@ mod tests { ); assert!(manager.get_progress("file1").is_none()); } + + #[test] + fn an_ack_for_a_chunk_never_handed_out_does_not_count() { + let make = |index: u32| FileChunk { + chunk_index: index, + total_chunks: 3, + ..sample_chunk() + }; + let mut window = OutboundTransferState::new(vec![make(0), make(1), make(2)], 1); + let sent = window.next_chunks_to_send(); + assert_eq!(sent.len(), 1); + + // Out of range, and in range but not yet sent: neither counts. + assert!(!window.on_chunk_ack(7)); + assert!(!window.on_chunk_ack(2)); + assert_eq!(window.acked_count(), 0); + + // The chunk that was sent counts once, however often it is acked. + assert!(window.on_chunk_ack(0)); + assert!(!window.on_chunk_ack(0)); + assert_eq!(window.acked_count(), 1); + assert!(!window.is_fully_acked()); + + for chunk in [1, 2] { + assert_eq!(window.next_chunks_to_send().len(), 1); + assert!(window.on_chunk_ack(chunk)); + } + assert!(window.is_fully_acked()); + } } From 80c0d347b261af19e1a26b701dcbe912bc82f3a8 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Thu, 5 Nov 2026 00:12:35 +0530 Subject: [PATCH 3/8] docs(protocol): say why the transport manager keeps its own current transport 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. --- crates/offline-protocol/src/transport_manager.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/offline-protocol/src/transport_manager.rs b/crates/offline-protocol/src/transport_manager.rs index 72bd4f0db..e76d70fb9 100644 --- a/crates/offline-protocol/src/transport_manager.rs +++ b/crates/offline-protocol/src/transport_manager.rs @@ -31,7 +31,15 @@ pub struct TransportManager { /// serializing sends against status/metrics reads. transports: HashMap>, - /// Current active transport. + /// The transport that last carried a frame, by any path. + /// + /// This is not the selector's current transport, and the two are allowed + /// to differ. The selector's copy is the DORS hysteresis anchor, and + /// setting it restarts the switch cooldown. A direct send to a mesh + /// neighbour (`send_via_transport`) updates this field only: moving the + /// anchor there would let every neighbour send reset the cooldown and pin + /// DORS to the mesh. A removed transport is cleared here; the selector + /// drops it on its next selection because it is no longer available. current_transport: Option, /// Transport selector (DORS). From 764d6af35d8c208aa8d3dd66c9b9a17e1228cf00 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Thu, 5 Nov 2026 00:14:59 +0530 Subject: [PATCH 4/8] fix(uniffi): deliver wrapper-raised events the way core events are delivered 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. --- crates/offline-protocol-uniffi/src/lib.rs | 166 +++++++++++++++------- 1 file changed, 114 insertions(+), 52 deletions(-) diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index f21392bb9..d0c8f991e 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -294,6 +294,52 @@ fn recover_rwlock_read<'a, T>( }) } +/// How many delivered events the polling queue keeps; the oldest goes first. +const EVENT_QUEUE_CAPACITY: usize = 1000; + +/// Hands one event to the binding's callback and to the polling queue. +/// +/// Every event the binding sees goes through here, whether the core engine +/// raised it or the wrapper did. There used to be two copies, and the wrapper's +/// called the callback while holding the slot's read guard and without +/// isolating a throw: a callback that called `set_event_callback` deadlocked, +/// and a throwing one unwound into the wrapper and skipped the queue. +/// +/// The callback is cloned out of the slot before it is called, so it may +/// re-enter the engine. A foreign callback that throws reaches here as a +/// panic, because UniFFI has no error type for a void callback's exception; +/// it is caught so the queue still gets the event, and the miss is named by +/// type (#514). +fn deliver_event( + callback_slot: &RwLock>>, + queue: &Mutex>, + event: &crate::CoreEvent, +) { + let Ok(event_json) = event.to_json() else { + return; + }; + let callback = recover_rwlock_read(callback_slot, "event_callback") + .as_ref() + .cloned(); + if let Some(callback) = callback { + let delivered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + callback.on_event(event_json.clone()); + })); + if delivered.is_err() { + tracing::error!( + event = event.telemetry_name(), + "EventCallback.on_event threw; the binding missed this event", + ); + } + } + + let mut queue = recover_mutex(queue, "event_queue"); + queue.push_back(event_json); + if queue.len() > EVENT_QUEUE_CAPACITY { + queue.pop_front(); + } +} + fn recover_rwlock_write<'a, T>( lock: &'a RwLock, name: &str, @@ -2809,40 +2855,7 @@ impl OfflineProtocol { // Register event handler with core protocol to forward all events // This bridges events from the core protocol to JavaScript protocol.on_event(move |event| { - // Convert event to JSON - if let Ok(event_json) = event.to_json() { - // Clone callback Arc outside the lock to avoid holding the - // RwLock during callback invocation (prevents deadlock if the - // callback re-enters the protocol). - let callback_arc = recover_rwlock_read(&event_callback_clone, "event_callback") - .as_ref() - .cloned(); - if let Some(callback) = callback_arc { - // A foreign callback that throws reaches here as a panic: - // UniFFI has no error type to lift a void callback's - // exception into. Isolated here rather than left to the - // engine's own isolation so the polling queue below still - // gets the event, and the miss is named by type (#514). - let delivered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - callback.on_event(event_json.clone()); - })); - if delivered.is_err() { - tracing::error!( - event = event.telemetry_name(), - "EventCallback.on_event threw; the binding missed this event", - ); - } - } - - // Add to event queue for polling - let mut queue = recover_mutex(&event_queue_clone, "event_queue"); - queue.push_back(event_json); - - // Limit queue size to prevent memory issues - if queue.len() > 1000 { - queue.pop_front(); - } - } + deliver_event(&event_callback_clone, &event_queue_clone, &event); }); Ok(Self { @@ -3261,24 +3274,7 @@ impl OfflineProtocol { /// Internal: Emit an event through the callback fn emit_event(&self, event: crate::CoreEvent) { - // Convert event to JSON - if let Ok(event_json) = event.to_json() { - // Call the callback if set - if let Some(callback) = - recover_rwlock_read(&self.event_callback, "event_callback").as_ref() - { - callback.on_event(event_json.clone()); - } - - // Also queue it for polling - let mut queue = recover_mutex(&self.event_queue, "event_queue"); - queue.push_back(event_json); - - // Limit queue size to prevent memory issues - if queue.len() > 1000 { - queue.pop_front(); - } - } + deliver_event(&self.event_callback, &self.event_queue, &event); } /// Internal: single seam for all carrier-level reachability signals @@ -9523,6 +9519,72 @@ mod tests { assert_eq!(protocol.ble_get_peer_count(), 1); } + /// An event the wrapper raises itself (here `NeighborDiscovered`) reaches + /// the callback with the slot released, so the callback may replace + /// itself. The wrapper's own path used to hold the read guard across the + /// call, and `set_event_callback` from inside it deadlocked. + #[test] + fn a_callback_may_replace_itself_from_a_wrapper_raised_event() { + use std::sync::mpsc; + use std::sync::Weak; + + struct Noop; + impl EventCallback for Noop { + fn on_event(&self, _event_json: String) {} + } + struct ReplacesItself(Weak); + impl EventCallback for ReplacesItself { + fn on_event(&self, _event_json: String) { + if let Some(protocol) = self.0.upgrade() { + protocol.set_event_callback(Box::new(Noop)); + } + } + } + + let protocol = Arc::new(OfflineProtocol::new(create_test_config()).unwrap()); + protocol.set_event_callback(Box::new(ReplacesItself(Arc::downgrade(&protocol)))); + + // On a thread with a deadline, so a regression fails instead of hanging. + let (done, finished) = mpsc::channel(); + let raiser = protocol.clone(); + std::thread::spawn(move || { + let result = raiser.ble_peer_discovered("peer1".to_string(), -50); + let _ = done.send(result.is_ok()); + }); + assert_eq!( + finished.recv_timeout(std::time::Duration::from_secs(10)), + Ok(true), + "set_event_callback from inside the callback deadlocked" + ); + } + + /// A callback that throws on a wrapper-raised event does not unwind into + /// the wrapper, and the polling queue still gets the event. + #[test] + fn a_throwing_callback_does_not_cost_the_polling_queue_a_wrapper_raised_event() { + struct Throws; + impl EventCallback for Throws { + fn on_event(&self, _event_json: String) { + panic!("the binding's listener threw"); + } + } + + let protocol = OfflineProtocol::new(create_test_config()).unwrap(); + protocol.set_event_callback(Box::new(Throws)); + protocol + .ble_peer_discovered("peer1".to_string(), -50) + .unwrap(); + + let events = drained_events(&protocol); + assert!( + events + .iter() + .any(|event| event.contains("neighbor_discovered") + || event.contains("NeighborDiscovered")), + "the queue lost the event: {events:?}" + ); + } + #[test] fn test_file_transfer_tracking() { let config = create_test_config(); From 5fa1342e507fee51ab8800eb59067a8a49fc2371 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Thu, 5 Nov 2026 00:15:56 +0530 Subject: [PATCH 5/8] fix(bindings): a browser BLE link-up refused before initialize leaves 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. --- crates/offline-protocol-web/src/ble_host.rs | 6 +++- crates/offline-protocol-web/src/ble_tests.rs | 38 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/offline-protocol-web/src/ble_host.rs b/crates/offline-protocol-web/src/ble_host.rs index 55c8a1b58..682c5c280 100644 --- a/crates/offline-protocol-web/src/ble_host.rs +++ b/crates/offline-protocol-web/src/ble_host.rs @@ -128,6 +128,11 @@ impl Node { /// and has said hello: tells the transport its write payload, then the /// engine that the peer is here. pub fn ble_link_up(&mut self, peer: &str, max_payload: u32) -> NodeResult<()> { + // ORDERING: the slot before either set changes. Before `initialize` + // there is no BLE transport, and refusing after the insert below left + // the address in `linked`: the next link-up read as a relink and was + // never announced, and fragments from the address were accepted. + let transport = self.ble_slot()?; let host = self.ble_host_mut()?; let Some(at) = host.verified.iter().position(|held| held == peer) else { return Err(NodeError(format!( @@ -141,7 +146,6 @@ impl Node { } host.verified.remove(at); let relinked = !host.linked.insert(peer.to_string()); - let transport = self.ble_slot()?; // ORDERING: the payload before the peer, as every binding does. transport.set_peer_mtu(peer, max_payload as usize); if relinked { diff --git a/crates/offline-protocol-web/src/ble_tests.rs b/crates/offline-protocol-web/src/ble_tests.rs index b57fcd6bf..a50f8cc80 100644 --- a/crates/offline-protocol-web/src/ble_tests.rs +++ b/crates/offline-protocol-web/src/ble_tests.rs @@ -496,3 +496,41 @@ fn two_browsers_reach_each_other_through_a_peripheral_that_notifies_everyone() { "a broadcasting peripheral stopped delivery" ); } + +/// A link-up refused because the node has no Bluetooth transport yet leaves +/// no trace: the address is not linked, so its fragments are refused, and +/// once the node is initialized the next link-up announces the peer. +#[test] +fn a_link_up_refused_before_initialize_leaves_the_address_unlinked() { + let mut phone = side("phone"); + let options = NodeOptions { + ble: true, + ..Default::default() + }; + let mut early = Node::with_options("web-ble-test", "early", options).expect("node"); + let identity = phone.node.ble_hello().expect("identity"); + + let proved = early + .ble_verify(phone.address.as_bytes(), &identity) + .expect("verifying needs no identity of our own"); + assert!(early.ble_link_up(&proved, 244).is_err()); + assert!( + early.ble_fragment_received(&proved, &[0u8; 4]).is_err(), + "a refused link-up left the address linked" + ); + + early.initialize().expect("initialize"); + early.start().expect("start"); + let proved = early + .ble_verify(phone.address.as_bytes(), &identity) + .expect("verify again"); + early.ble_link_up(&proved, 244).expect("link up"); + early.process().expect("process"); + let mut announced = false; + while let Some(json) = early.poll_event() { + let event: Value = serde_json::from_str(&json).expect("event json"); + announced |= event.to_string().contains(&phone.address) + && event.to_string().to_lowercase().contains("neighbor"); + } + assert!(announced, "the peer was never announced after initialize"); +} From 72f1e1ed668bbca7c331ed58b90f6f57738b82a3 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Thu, 5 Nov 2026 00:18:39 +0530 Subject: [PATCH 6/8] fix(bindings): iOS no longer reports a radio available that it saw go 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. --- .../ios/OfflineProtocolModule.swift | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/bindings/react-native/ios/OfflineProtocolModule.swift b/bindings/react-native/ios/OfflineProtocolModule.swift index cecf81d50..871e3a90a 100644 --- a/bindings/react-native/ios/OfflineProtocolModule.swift +++ b/bindings/react-native/ios/OfflineProtocolModule.swift @@ -479,8 +479,16 @@ class OfflineProtocolModule: RCTEventEmitter { // transition) โ€” and onto the same serial queue, which is what keeps // the false/true pair from reordering. No background assertion here: // the app is becoming active, not losing its runtime. - processQueue.async { - try? proto.wifiDirectStatusChanged(isConnected: true) + // + // Only when the peer-stream transport is running. The background edge + // reports the layer down unconditionally, which is always true; this + // edge used to report it up unconditionally too, so a transport that + // was disabled, stopped or had failed to start was restated as up on + // every foreground. The manager reports its own start and stop. + if wifiDirectManager?.state == .running { + processQueue.async { + try? proto.wifiDirectStatusChanged(isConnected: true) + } } // On `telemetryQueue`, not `processQueue`: it has to be the same queue // the background edge used, or `.active` could be delivered while the @@ -1099,12 +1107,21 @@ class OfflineProtocolModule: RCTEventEmitter { // Ensure bleStatusChanged(true) is called immediately and as backup // This ensures BLE transport is marked as Available for message sending - do { - try protocolInstance?.bleStatusChanged(isAvailable: true) - print("[OfflineProtocolModule] โœ… Called protocol.bleStatusChanged(true) immediately") - emitDiagnostic(level: "info", message: "BLE status set to available") - } catch { - print("[OfflineProtocolModule] Immediate bleStatusChanged failed: \(error.localizedDescription)") + // + // Never over a radio the manager has reported down. A + // powered-off, unauthorized or unsupported radio moves the + // manager to `.unavailable` and reports false; the backup + // below used to land a second later and report true over + // it, so the core routed sends to a radio that was off + // until the next radio state change. + if manager.state != .unavailable { + do { + try protocolInstance?.bleStatusChanged(isAvailable: true) + print("[OfflineProtocolModule] โœ… Called protocol.bleStatusChanged(true) immediately") + emitDiagnostic(level: "info", message: "BLE status set to available") + } catch { + print("[OfflineProtocolModule] Immediate bleStatusChanged failed: \(error.localizedDescription)") + } } // Backup call in case timing is off. Held in a work item so @@ -1122,7 +1139,11 @@ class OfflineProtocolModule: RCTEventEmitter { // a backup that already started running, which a // main-queue backup could dodge. bleStatusBackupWorkItem?.cancel() - let backup = DispatchWorkItem { [weak self] in + let backup = DispatchWorkItem { [weak self, weak manager] in + guard let manager, manager.state != .unavailable else { + self?.emitDiagnostic(level: "info", message: "Backup bleStatusChanged(true) skipped: the radio is unavailable") + return + } print("[OfflineProtocolModule] Backup bleStatusChanged(true) call") self?.emitDiagnostic(level: "info", message: "Backup call to protocol.bleStatusChanged(true)") try? self?.protocolInstance?.bleStatusChanged(isAvailable: true) From fa9d6b64e795bb5939fef2f9625ab5ce497e8e4e Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Thu, 5 Nov 2026 00:20:23 +0530 Subject: [PATCH 7/8] fix(bindings): Android start() refuses before any side effect when nothing 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. --- .../java/com/offlineprotocol/OfflineProtocolModule.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt index 54dcfb35d..34b295a48 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt @@ -1020,6 +1020,14 @@ class OfflineProtocolModule(reactContext: ReactApplicationContext) : @ReactMethod fun start(promise: Promise) { try { + // Refused before anything below runs. start() used to call + // protocol?.start() and then startForegroundService() regardless, + // so a start with no protocol put "Mesh Active" up and registered a + // host over no engine, which a sticky restart then read as + // KEEP_ALIVE. The JavaScript wrapper never does this; a caller of + // the native module directly could. + val proto = protocol ?: throw IllegalStateException("Protocol not initialized") + // A one-shot event held from the *previous* mesh session is moot the // moment a new one comes up, and redelivering it would be the bug // this whole mechanism exists to prevent, inverted: an app told @@ -1058,7 +1066,7 @@ class OfflineProtocolModule(reactContext: ReactApplicationContext) : // for the previous session belongs to whatever identity ran it. inboundEvents.beginSession() emitDiagnostic("info", "Starting protocol") - protocol?.start() + proto.start() emitDiagnostic("info", "Protocol core started") // Start foreground service to protect the process from being killed From 81c1a359451ada96f546e8b82d1ad8c101d56497 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Thu, 5 Nov 2026 00:21:29 +0530 Subject: [PATCH 8/8] docs: changelog entries for the state machine fixes --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71835674e..e1779e994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,38 @@ archived by series under [docs/changelog/](docs/changelog/); see the ### Fixed +- **An iPhone with Bluetooth off no longer tells the engine Bluetooth is up.** + `start()` reported Bluetooth available at once and again a second later, + whatever the radio's state. With Bluetooth off, unauthorized or unsupported, + the Bluetooth manager reports it down, and the second report then marked it + up again, so the engine routed sends to a radio that was off until the + radio next changed state. Neither report is made while the radio is + unavailable. Coming to the foreground likewise restated the Wi-Fi Direct + layer up when that transport was disabled, stopped or had failed to start; + it now does so only while the transport runs. + +- **An event callback may replace itself, and one that throws no longer + costs the polling queue an event.** Events the bindings raise themselves + (neighbor discovered and lost, signal strength, carrier status) reached the + callback with its slot locked and no isolation, unlike events from the + engine: a callback that called `set_event_callback` deadlocked, and one + that threw skipped the polling queue. Both kinds are now delivered the same + way. + +- **Deleting a 1:1 session drops the Welcome still waiting for it.** + `mls_get_pending_welcome` kept returning, after the session was deleted, an + invitation to the group that no longer existed. + +- **A browser Bluetooth link-up refused before `initialize` leaves no link + behind.** The address stayed recorded as linked, so its fragments were + accepted and the next link-up was taken for a relink and never announced + the peer. + +- **Android `start()` with no protocol created rejects instead of putting up + the mesh service.** It started the foreground service over no engine, which + a sticky restart then kept alive. The JavaScript wrapper never does this; a + direct caller of the native module could. + - **A second SDK app on an Android phone gets its own LAN streams.** The peer-stream listener bound the fixed port 8988, so a second app on the same phone failed to bind it and still advertised 8988 on the LAN: every