Skip to content

fix(rnode): Model-B RNode-over-BLE connect + hardening pass#101

Open
torlando-tech wants to merge 3 commits into
mainfrom
fix/ios-model-b-rnode-hardening
Open

fix(rnode): Model-B RNode-over-BLE connect + hardening pass#101
torlando-tech wants to merge 3 commits into
mainfrom
fix/ios-model-b-rnode-hardening

Conversation

@torlando-tech

Copy link
Copy Markdown
Owner

Summary

Adding an RNode interface never connected until the app was relaunched — the live-add path persisted the entity but never started the app-side CoreBluetooth radio (radio in app, RNS in NE on Model B). This wires up live bring-up and lands a full hardening pass over the RNode-over-BLE path.

Device-verified on a physical iPhone 14: RNode connects on first add (no relaunch) and exchanges LXMF messages over RNode.

Core fix

  • InterfaceManagementViewModel.applyRNodeLiveChange() drives startRNodeInterface()/stopRNodeInterface() on save/toggle/delete for .rnode on Model B — starts the app-side AppGroupRNodeServer AND writes the RNodeSeamConfig the NE rebuilds its RNodeInterface from.

Hardening (from a verified audit)

  • Blocker: COLUMBA_RNODE_ENABLED added to Release-Swift — the RNode UI was compiled out of Release/Archive builds.
  • Wizard surfaces Bluetooth off/denied/unsupported (actionable banner + Open Settings).
  • 25s connect watchdog → real failure message instead of forever "Connecting…".
  • Failure reason threaded through the seam wire (.stateChanged += reason); "RNode saved — connecting…" + async failure toast.
  • NE setupRNodeInterface() generation + config-diff guard (no double seam-wire / needless radio bounce) + cross-process config read retry.
  • Seam send-timeout watchdog (suspended/jettisoned radio can't wedge TX).
  • Single-RNode-radio guard; lock-backed seam state/callbacks (data race); empty-name rejected; SharedFrameQueue.append reports failures + pins protection class; dropped the dead NSBluetoothWhenInUseUsageDescription key.

Gated off (flip after on-device verification)

  • AppServices.rnodeBadgeFromNE — NE-authoritative badge / kills ~10s premature-green.
  • ModelBRNodeService.rnodeBackgroundRestoreEnabled — CoreBluetooth state restoration / background reception.

Follow-up

Connect-by-CoreBluetooth-identifier (robust to duplicate/renamed RNodes) lands separately once the reticulum-swift BLETransport change merges and the pin bumps.

🤖 Generated with Claude Code

Adding an RNode interface never connected until the app was relaunched: the
live-add path persisted the entity but never started the app-side CoreBluetooth
radio (the radio lives in the app on Model B; RNS runs in the NE). saveInterface()
never called startRNodeInterface(), and applyInterfaceChanges() early-returns on
Model B, so only cold-launch brought RNode up.

Core fix:
- InterfaceManagementViewModel.applyRNodeLiveChange() drives
  AppServices.startRNodeInterface()/stopRNodeInterface() on save/toggle/delete
  for .rnode on Model B (starts the app-side AppGroupRNodeServer AND posts the
  RNodeSeamConfig the NE rebuilds its RNodeInterface from). Device-verified:
  connects on first add and exchanges LXMF over RNode.

Hardening (from a verified audit of the RNode-over-BLE path):
- BLOCKER: add COLUMBA_RNODE_ENABLED to the Release-Swift config — the RNode UI
  was compiled out of Release/Archive builds entirely.
- Wizard surfaces Bluetooth off/denied/unsupported with an actionable banner +
  Open Settings, instead of spinning "Scanning…" forever.
- 25s connect watchdog fails a stuck "Connecting…" with a real message.
- Thread the failure reason through the RNode seam wire (.stateChanged += reason)
  so BT/permission/timeout errors surface instead of a bare "Unreachable";
  "RNode saved — connecting…" instead of a premature "added", and async connect
  failures now toast.
- NE setupRNodeInterface(): generation token + config-diff guard so re-entrant
  notifications can't build two seam wires or needlessly bounce the live radio;
  retry the cross-process config read before bailing "no RNode configured".
- Seam send-timeout watchdog so a suspended/jettisoned radio can't silently wedge
  all outbound TX.
- Single-RNode-radio guard (no silent overwrite / cross-kill on disable/delete).
- Lock-back the seam transport state/callbacks (reconnect-churn data race).
- Reject an empty device name (no silent scan-only "connecting" trap).
- SharedFrameQueue.append reports write failures + pins data-protection class.
- Drop the dead/ignored NSBluetoothWhenInUseUsageDescription plist key.

Landed but GATED off (flip after on-device verification):
- AppServices.rnodeBadgeFromNE — NE-authoritative badge + kills the ~10s
  premature green.
- ModelBRNodeService.rnodeBackgroundRestoreEnabled — re-create the RNode central
  at launch for CoreBluetooth state restoration / background reception.

Connect-by-CoreBluetooth-identifier (robust to duplicate/renamed RNodes) lands
separately once the reticulum-swift BLETransport change merges and the pin bumps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the core bug where adding an RNode interface on Model B never connected without a relaunch, and lands a comprehensive hardening pass over the entire RNode-over-BLE path — live bring-up wiring, generation-guarded NE setup, send-timeout watchdog, BT unavailability UX, seam race fixes, and a new test suite.

  • Core fix: applyRNodeLiveChange() drives startRNodeInterface()/stopRNodeInterface() on every save/toggle/delete for .rnode on Model B, wiring the app-side AppGroupRNodeServer immediately instead of waiting for a cold launch.
  • Hardening: COLUMBA_RNODE_ENABLED added to Release compilation conditions; 25s connect watchdog; stateChanged reason threading; rnodeSetupGeneration guard prevents double seam-wire in the NE; lock-backed state/callbacks in AppGroupRNodeSeamTransport; SharedFrameQueue.append returns Bool; file protection class pinned to .completeUntilFirstUserAuthentication.
  • Gated off (flags off by default): NE-authoritative badge (rnodeBadgeFromNE) and CoreBluetooth state restoration (rnodeBackgroundRestoreEnabled).

Confidence Score: 4/5

Safe to merge with one transport lifecycle question worth clarifying before enabling automatic reconnects.

The new disconnect() on AppGroupRNodeSeamTransport calls wire.stop()inboundCont.finish(), permanently terminating the underlying AsyncStream. Any subsequent connect() on the same transport instance — as RNodeInterface.attemptReconnect might do — produces an inbound task that exits immediately, leaving the NE deaf to all radio messages. Whether this path is actually exercised depends on ReticulumSwift internals not reviewed here; if RNodeInterface always uses a factory to create a fresh transport on reconnect the issue is moot, but the code as written has no guard against the broken case.

Sources/Shared/AppGroupRNodeSeamTransport.swift — specifically the disconnect()/connect() lifecycle when the same instance is reused across a reconnect.

Important Files Changed

Filename Overview
Sources/Shared/AppGroupRNodeSeamTransport.swift Lock-backed transport properties and send-timeout watchdog are well-structured, but disconnect() now permanently finishes the wire's AsyncStream — a subsequent connect() on the same instance silently kills inbound delivery.
Sources/ColumbaApp/Services/AppServices.swift Connect watchdog, rnodeBadgeFromNE gate, and neRNodeStatus() helper are cleanly added; all watchdog accesses are on the main actor.
Sources/ColumbaApp/Services/ModelBRNodeService.swift Re-wire path for the gated background restore case correctly re-wires the callback; data race on onLinkStateChange is a future concern when the gate is flipped.
Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift Live-change wiring, single-RNode guard, and error toast for failures are correct.
Sources/ColumbaNetworkExtension/NEReticulumNode.swift Generation-guard pattern and config-diff no-op fast path are both sound; nil-before-await slot claim prevents double-teardown.
Sources/Shared/AppGroupRNodeServer.swift Empty-name guard, reason threading, and restoreRadio are correct; onLinkStateChange is unprotected by a lock in the gated restore path.
Sources/Shared/RNodeSeam.swift stateChanged reason field added with backward-compatible codec; start()/stop() protocol requirement matches existing implementations.
Tests/ColumbaAppTests/RNodeSeamTests.swift Covers wire codec round-trip, frame-queue failure, empty-name guard, send-timeout, and real-result-before-timeout — all deterministic.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as InterfaceManagementVM
    participant AS as AppServices
    participant MRS as ModelBRNodeService
    participant AGS as AppGroupRNodeServer
    participant BLE as BLETransport
    participant Wire as AppGroupRNodeSeamWire
    participant NE as NEReticulumNode
    participant AGT as AppGroupRNodeSeamTransport

    UI->>AS: applyRNodeLiveChange(enabled:true)
    AS->>AS: startRNodeInterface(config,name)
    AS->>AS: "rnodeInterface.state = .connecting"
    AS->>AS: start 25s watchdog
    AS->>MRS: start(onLinkStateChange:)
    MRS->>AGS: AppGroupRNodeServer.start()
    AGS->>Wire: wire.start()
    AS->>Wire: RNodeSeamConfig.saveToAppGroup()
    Wire-->>NE: rnodeConfigChanged Darwin notify
    NE->>NE: setupRNodeInterface() [gen guard + config diff]
    NE->>AGT: AppGroupRNodeSeamTransport.connect()
    AGT->>Wire: wire.send(.connect(deviceName:))
    Wire-->>AGS: inbound .connect
    AGS->>BLE: connectRadio(deviceName:)
    BLE-->>AGS: onStateChange(.connected)
    AGS->>Wire: wire.send(.stateChanged(.connected, nil))
    Wire-->>AGT: inbound .stateChanged
    AGT->>NE: onStateChange(.connected)
    AGS->>AS: onLinkStateChange(.connected, nil)
    AS->>AS: applyRNodeLinkState: cancel watchdog
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as InterfaceManagementVM
    participant AS as AppServices
    participant MRS as ModelBRNodeService
    participant AGS as AppGroupRNodeServer
    participant BLE as BLETransport
    participant Wire as AppGroupRNodeSeamWire
    participant NE as NEReticulumNode
    participant AGT as AppGroupRNodeSeamTransport

    UI->>AS: applyRNodeLiveChange(enabled:true)
    AS->>AS: startRNodeInterface(config,name)
    AS->>AS: "rnodeInterface.state = .connecting"
    AS->>AS: start 25s watchdog
    AS->>MRS: start(onLinkStateChange:)
    MRS->>AGS: AppGroupRNodeServer.start()
    AGS->>Wire: wire.start()
    AS->>Wire: RNodeSeamConfig.saveToAppGroup()
    Wire-->>NE: rnodeConfigChanged Darwin notify
    NE->>NE: setupRNodeInterface() [gen guard + config diff]
    NE->>AGT: AppGroupRNodeSeamTransport.connect()
    AGT->>Wire: wire.send(.connect(deviceName:))
    Wire-->>AGS: inbound .connect
    AGS->>BLE: connectRadio(deviceName:)
    BLE-->>AGS: onStateChange(.connected)
    AGS->>Wire: wire.send(.stateChanged(.connected, nil))
    Wire-->>AGT: inbound .stateChanged
    AGT->>NE: onStateChange(.connected)
    AGS->>AS: onLinkStateChange(.connected, nil)
    AS->>AS: applyRNodeLinkState: cancel watchdog
Loading

Comments Outside Diff (1)

  1. Sources/Shared/AppGroupRNodeSeamTransport.swift, line 117-130 (link)

    P1 disconnect() permanently kills the inbound stream

    wire.stop() calls inboundCont.finish() on the underlying AppGroupRNodeSeamWire, which permanently terminates the AsyncStreamAsyncStream.Continuation.yield is a documented no-op after finish(). A subsequent connect() call (e.g. via RNodeInterface.attemptReconnect) creates a new inboundTask that exits immediately because the stream is already finished; wire.start() re-registers the Darwin observer and drains the queue but yields into a finished continuation (no-op). The transport becomes silently deaf — it sends .connect over the wire, the app responds with .stateChanged/.dataReceived, but the NE never processes those responses.

    The send-timeout watchdog comment explicitly notes it "restarts the NE-local reconnect"; if that reconnect path calls disconnect() + connect() on the same transport instance rather than a factory-created fresh one, all reconnects after the first timeout would fail silently. Consider creating a fresh AppGroupRNodeSeamWire in connect() (replacing wire.start()) if the existing wire has been stopped, or switching AppGroupRNodeSeamWire to support reinitialisation of its continuation. Does RNodeInterface.attemptReconnect (or whatever reconnect path ReticulumSwift uses on .disconnected transport state) call transport.disconnect() followed by transport.connect() on the same AppGroupRNodeSeamTransport instance, or does it always use a factory to create a fresh transport?

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/Shared/AppGroupRNodeSeamTransport.swift
    Line: 117-130
    
    Comment:
    **`disconnect()` permanently kills the inbound stream**
    
    `wire.stop()` calls `inboundCont.finish()` on the underlying `AppGroupRNodeSeamWire`, which permanently terminates the `AsyncStream``AsyncStream.Continuation.yield` is a documented no-op after `finish()`. A subsequent `connect()` call (e.g. via `RNodeInterface.attemptReconnect`) creates a new `inboundTask` that exits immediately because the stream is already finished; `wire.start()` re-registers the Darwin observer and drains the queue but yields into a finished continuation (no-op). The transport becomes silently deaf — it sends `.connect` over the wire, the app responds with `.stateChanged`/`.dataReceived`, but the NE never processes those responses.
    
    The send-timeout watchdog comment explicitly notes it "restarts the NE-local reconnect"; if that reconnect path calls `disconnect()` + `connect()` on the same transport instance rather than a factory-created fresh one, all reconnects after the first timeout would fail silently. Consider creating a fresh `AppGroupRNodeSeamWire` in `connect()` (replacing `wire.start()`) if the existing wire has been stopped, or switching `AppGroupRNodeSeamWire` to support reinitialisation of its continuation. Does `RNodeInterface.attemptReconnect` (or whatever reconnect path `ReticulumSwift` uses on `.disconnected` transport state) call `transport.disconnect()` followed by `transport.connect()` on the same `AppGroupRNodeSeamTransport` instance, or does it always use a factory to create a fresh transport?
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
Sources/Shared/AppGroupRNodeSeamTransport.swift:117-130
**`disconnect()` permanently kills the inbound stream**

`wire.stop()` calls `inboundCont.finish()` on the underlying `AppGroupRNodeSeamWire`, which permanently terminates the `AsyncStream``AsyncStream.Continuation.yield` is a documented no-op after `finish()`. A subsequent `connect()` call (e.g. via `RNodeInterface.attemptReconnect`) creates a new `inboundTask` that exits immediately because the stream is already finished; `wire.start()` re-registers the Darwin observer and drains the queue but yields into a finished continuation (no-op). The transport becomes silently deaf — it sends `.connect` over the wire, the app responds with `.stateChanged`/`.dataReceived`, but the NE never processes those responses.

The send-timeout watchdog comment explicitly notes it "restarts the NE-local reconnect"; if that reconnect path calls `disconnect()` + `connect()` on the same transport instance rather than a factory-created fresh one, all reconnects after the first timeout would fail silently. Consider creating a fresh `AppGroupRNodeSeamWire` in `connect()` (replacing `wire.start()`) if the existing wire has been stopped, or switching `AppGroupRNodeSeamWire` to support reinitialisation of its continuation.

Reviews (3): Last reviewed commit: "test(rnode): unit-test the Model-B RNode..." | Re-trigger Greptile

Comment thread Sources/ColumbaApp/Services/AppServices.swift
Comment thread Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift Outdated
@codecov

codecov Bot commented Jun 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

torlando-agent Bot and others added 2 commits June 22, 2026 15:19
- watchdog: explicit guard !Task.isCancelled (don't fire after cancellation)
- refreshNEBackedStatus: skip the neRNodeStatus() IPC entirely while the
  rnodeBadgeFromNE gate is off (was an unconditional round-trip)
- AppGroupRNodeSeamTransport: take pending send under a sync helper so the
  async watchdog never touches NSLock directly (Swift 6 async-context safety)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…watchdog)

Adds RNodeSeamTests covering the seam logic that runs real production code via an
in-memory RNodeSeamWire loopback (no CoreBluetooth, no app-group container):
- RNodeSeamMessage wire round-trip, incl. the new .stateChanged(state:reason:).
- SharedFrameQueue append→read round-trip + the new Bool failure return (and the
  write-failure branch the wire layer now depends on).
- AppGroupRNodeServer rejects an empty device name (.failed/reason, no radio built).
- AppGroupRNodeSeamTransport send-timeout watchdog: fires → .disconnected, and is a
  no-op when the real .sendResult arrives first.

Small testability seams (no behavior change):
- RNodeSeamWire protocol gains start()/stop() (the app-group impl already has them),
  so the server/transport hold the protocol and tests inject an in-memory wire.
- AppGroupRNodeSeamTransport takes an injectable sendTimeoutNanos (default 8s).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant