Repository navigation
fix(ble): offline routing by device id, one GATT operation per link, owed frames reached for - #1030
Merged
Merged
Conversation
…the stack did Android allows one outstanding GATT operation per client connection. The client serialized only its TX_REQUEST writes and the identity read, so the identity write-back and the first message chunk collided on the device run: "writePairingData: wrote 95 bytes", then "writeCharacteristic() - prior command is not finished" five times, and the outbox dropped the item after five attempts while sendOffline still answered OK. - GattOperationQueue: every operation on a link (the MTU request, both CCCD writes including the TX_RESPONSE re-subscribe, the identity read, the pairing write-back and confirm, message chunks and chunk ACKs) goes through one queue. Only the operation's own callback, or the link ending, clears the in-flight slot. A start the stack refuses is reported as refused; the writeDescriptor and writeCharacteristic returns are honoured (the CCCD writes ignored theirs). - GattClientSession.sendMessage takes a message's chunks at once, keeps them contiguous, and completes true only when the stack took every chunk. The TX_REQUEST write type is set explicitly (write without response, as the characteristic's default already was). - BleCoordinator.writeMessage replaces sendTransactionRequest, which returned a hard-coded true. The UnifiedBleBridge send paths use it. - BleOutbox and BleOutboxRepository are deleted (owner, 2026-09-27): a second, durable, address-keyed store that retried by attempt count and held one appliance's prepare under another's address. The SDK's owed frames are the only redelivery authority. The TransactionWriteCompleted sequencing, currentTransaction, the dead pending-confirm stash (always cleared by clearClientState before it could be retried), requestGattWrite and retryLastBleTransaction go with it. - A message write that fails no longer reports ErrorOccurred, so it no longer tears down the session as "stale". - Status-133 retries are bounded: cleanup() reset the retry count on every retry, so the bound was never reached. Tests: GattOperationQueueTest runs the queue against a fake stack that refuses a second outstanding operation; a pairing write followed by a message meets zero refusals. Mutations (in-flight check removed; any callback clears the slot) each turned a named test red and were restored.
resolveSession's step 4 routed a send for an unknown or stale address to the sole ready session, whoever that was. On the device run A54's prepare for 5GN went to 8XK: "resolveSession: single-peer fallback 2C:DA:46:4B:73:FA → 48:4F:B6:BA:D4:02", one second after 48:4F had been anchored to 8XK's identity. No identity match is "no route" — a liveness state; the frame stays owed until the addressed appliance is reached. The two tests that pinned the fallback now assert null, and a new one puts one ready peer anchored to B against a send addressed to A. Mutation: re-adding a sole-ready-peer route turned resolveSession_neverRoutesATargetToAReadyPeerAnchoredToAnotherIdentity red; restored.
…ce its identity is anchored; owed frames are reached for The device run routed A54's prepare for 5GN by address. A stale address fell through to "the sole ready peer" (deleted in the previous commit), a link counted as ready at raw GATT Connected, pairing was decided by the rotating address, the scan stopped at the first DSM advertiser, and nothing re-drove the owed prepare: step 392XXK8ZMME2E has sat Prepared since. Routing (Kotlin + JNI, a wire change on the JNI surface): - send_ble_chunks_via_unified takes the 32-byte device id and an address hint; Unified.requestGattWriteChunks(deviceId, hint, chunks). All callers pass the counterparty: sendOffline, accept, reject, cancel, owed-frame delivery. The address-required refusals are gone — the address is a hint. - BleCoordinator.resolveRoute(deviceId, hint): our client link anchored to the appliance, its server link anchored to it, or an unanchored server link at the hint — never a link anchored to another appliance. - A client link is a route only once the identity read on it is anchored, after the CCCD chain (PeerSession.clientRouteReady); connect completes then, not at MtuNegotiated. - reach(deviceId, hint) replaces connectToDevice: the hint first, and a scan that connects DSM advertisers one at a time (the hint in its own slot beside one scanned candidate) until the identity read on one names the appliance. A non-target is disconnected and excluded for the reach. - Replies (Kotlin follow-ups, the receiver's commit ack) go back on the link the frame they answer arrived on, and nothing reconnects for them. - Deleted: addressIndex, persistedIdentityLookup, hydratePersistedIdentity, updatePeerAddress, resolvePeerIdentityForBleAddressBin and its SQLite echo recorded as a sighting, get_contact_by_ble_address, the dead sendBleChunks export, and the no-op markDeviceAsPaired / getPairedDeviceIds (a hard-coded empty list) / isDevicePaired. Identity (Rust): - processGattIdentityRead(address, raw, expectedDeviceId) replaces the observe/process pair. gatt_identity::identity_read_verdict decides before anything is recorded: a reach candidate that is not the expected appliance records nothing (no contact address, no pairing session, no sighting); otherwise pairing is decided by the contact's device id, not the address the link is at. Redelivery (Rust): - bleNotifyLink(deviceId, address, up) replaces the address-keyed bleNotifyConnectionState: link-up after the identity is anchored delivers what is owed to that counterparty; link-down (every close path, the server side included, when the link carried an identified appliance) marks its frames undelivered again. - owed_frame_driver: while an owed frame has not been delivered on a live link, reach for its counterparty through the same device-id send, paced 15 s doubling to 5 min. Started when the BLE transport is injected and kicked after session restore, an undelivered send and link-down. Transport pacing only: it never ends, orders or validates a step. Tests: resolveRoute (anchored client link, current address, waits for the identity read on a new link to a known appliance, never a link anchored to another appliance, unanchored server link only at the hint); gatt_identity (a reach establishes nothing on another appliance; pairing by device id); owed_frame_driver (pacing; a delivered frame waits for its link to end; per frame and per counterparty). Mutations, each red on its named test and restored: the identity-read gate on client routes; the identity match; the hint rule's anchor check; the verdict's expected check; pairing by device id; link-down's reset; delivered frames skipped.
…tes; a claimed tip is kept only when it recomputes The receiver's door read, decided and wrote in the wrong order for a prepare that was not its own. On the device run 8XK was handed A54's prepare for 5GN: it synced the sender's contact, established the relationship, cleared the §5.4 online gate, and then decided — and a proposal that does not extend its own tip is answered with a signed rejection and records the sender's claimed tip. Owner ruling: a misaddressed message is meaningless bytes. - Core `decide_prepare` takes this device's id; `PrepareClaims.addressed_to` carries the request's addressee. A prepare whose request names another device, or whose committed transfer pays another, is `NotAddressed`, decided before the signature, the step in flight or the tip. - `StaleTip.expected_recomputes`: the claimed tip is evidence of the sender's tip only when the proposal's commitment is its operation's commitment on that tip. - `handle_prepare_request` reads first (the held tip, the sender's pinned contact, the step in flight), lets Core decide, and returns at once on NotAddressed; the §5.4 gate, the contact sync, the relationship and the manager's tip follow the decision. A stale proposal's claim is recorded only when it recomputes. Tests: Core — a prepare addressed to another device (by the request, by the transfer) is not addressed here, before the signature; a stale proposal says whether its claimed tip recomputes. Byte carrier — C, holding A as a contact, handed A's prepare for B answers nothing and writes nothing (no session, no tip, no observed tip); B handed A's prepare with its claimed tip replaced keeps nothing of the claim. Mutations, each red on its named tests and restored: both address checks dropped; the request's addressee unchecked; the transfer's payee unchecked; the recompute flag forced false; the claim kept regardless. Docs: VERIFICATION_MATRIX rows for the addressing and claim gates, the GATT identity verdict and the owed-frame deliveries; CONFORMANCE_GAPS §6.34 records this BLE round, with C2, C3, C4 and the device run open.
On A54 (2026-09-27 02:08:52) anchoring a paired peer threw a NullPointerException right after the route was handed over: the three BleGattIdentityReadResult extractors answered null both for a field the result does not carry (a paired peer owes no write-back; a non-target reach candidate carries no identity) and for a result that does not decode, while Kotlin declared them non-null. A non-target candidate reached its rejection only through the exception. - identityReadResultExtractWriteBack / ExtractPeerDeviceId / ExtractPeerGenesisHash return an empty array for a field the result does not carry and null only when the result does not decode; their doc comments said "empty on decode error" and the code did the opposite. - The Kotlin declarations are ByteArray?; the coordinator anchors only on a 32-byte id and genesis and writes back only a non-empty envelope.
…ain after a restart Found on the device run: 5GN held A54's step 392XXK8ZMME2E as pending_user_action across an app restart, and nothing on any screen offered it. The incoming dialog opens only on a PREPARE_RECEIVED event, which does not outlive a restart, and a prepare delivered again is answered from the stored step without announcing anything. No screen renders the SDK's pending list for incoming steps (the pending panel is gone). Once the wallet is up, BilateralTransferDialog reads bilateral.pending_list and shows the first incoming step the SDK lists as awaiting this user, in the same dialog, with the SDK's counterparty, amount and token. A list that cannot be read is shown as an error, not as nothing waiting. Tests (BilateralTransferDialog.pendingAfterRestart): shown once the wallet is up; not read before; an outgoing step, or one past its decision, is not offered; an unreadable list is said. Mutations: the direction check, the phase check, the wallet-ready guard and the error report each removed → red.
On A54 (03:31:12) a reach found 5GN while a second candidate, 9FF, was still connecting. The reach's cleanup closes leftover candidates on the lifecycle lane, which runs ahead of the queued identity read, so that read arrived with no reach active and was processed as an ordinary link: 9FF was anchored and its address recorded, though the reach had been for 5GN. A finished reach now records its leftover candidates with the appliance it was for (abandonedCandidates). takeExpectedIdentity gives an identity read the active reach's appliance, else that recorded one (taken once), else none. A late read that does not name it is closed with nothing recorded; the map is cleared at each new reach. Test: BleCoordinatorTest.anAbandonedCandidatesIdentityReadExpectsTheReachsAppliance. Mutation: the recorded candidates not consulted → red.
The resurfaced incoming dialog came up over the boot intro the moment the wallet was ready, hiding the cutscene (seen on 5GN, 03:23). It is armed only once the intro is over and the wallet is ready. Test: App.pendingDialogWaitsForIntro (not armed while the intro shows, armed past it). Mutation: the intro not consulted → red.
…akes The three BLE pairing frames a device sends its peer — the scanner's identity write-back, the advertiser's pairing accept, the scanner's pairing confirm — were built with build_ble_event_envelope, the headerless local-answer form the WebView's BLE events use. The peer decodes them in processBleIdentityEnvelope with the strict addressed decoder, which refuses a frame without headers and a message id: on hardware, 9FF answered 8XK's write-back with "Envelope.headers is required" (2026-09-27), so BLE pairing could not complete. bluetooth::bilateral_envelope::pairing_frame builds them as Envelope v3 from this device (its id and genesis in the headers, the content-addressed message id) behind the 0x03 framing byte; ble_events::local_pairing_frame supplies the local identity and refuses without one. WebView events keep the local-answer form. requiresBleAck still sees the 0x03 byte, so the server routes them as before. Test: a_pairing_frame_is_taken_by_the_peers_strict_decoder (the frame decodes with its headers and event; the local-answer form is refused). Mutation: the frame built as a local answer → red. CONFORMANCE_GAPS §6.34 records it; the address-persistence half of C2 stays open with its reason.
When requestMtu was refused and no onMtuChanged followed, a fallback timer assumed MTU 517 after 2 s and started the CCCD chain on it: an invented value the chunk size was then sized against. The fallback (runnable, flag, MTU_FALLBACK_DELAY_MS and its test) is deleted. An MTU the link reports on its own — a peer-initiated or automatic exchange — still starts the chain whenever it arrives; a refused request with no such report is an ErrorOccurred (mtu_request_refused) and the link does not become a route. Every link on the rig reports its MTU (517) through the callback. CONFORMANCE_GAPS §6.34: C3's chunk budget against the link's MTU stays open, with the two ways it can be closed.
cryptskii
force-pushed
the
fix/ble-offline-routing-and-redelivery
branch
from
September 27, 2026 07:54
d9d172d to
c36c688
Compare
This was referenced Sep 27, 2026
cryptskii
added a commit
that referenced
this pull request
Sep 27, 2026
#1030 routes an offline send by the counterparty's device id; a known BLE address is only where the transport looks first, so the send no longer refuses "no BLE address is known for the counterparty". The test pinned in #1021 still expected that refusal, and main's Rust board went red on it (a4ed751, f69f33f: dsm_sdk --lib 1042 passed, 1 failed), which also stopped the board before dsm_sdk's integration binaries. The test now pins the routing #1030 ships: with no address the contact is not refused for want of one; its send goes exactly as far as one to a contact holding an address, which on a host build is the dispatch. Verification: dsm_sdk --release send_offline_tests 1/1; every dsm_sdk integration binary the red board never reached, --no-fail-fast: 11/11 ok.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The four-device run of 2026-09-26 showed A54's offline prepare for 5GN written to 8XK (the sole-ready-peer fallback), the first chunk colliding with the pairing write-back ("prior command is not finished" ×5), the outbox dropping it, and nothing re-driving the owed step (392XXK8ZMME2E, Prepared since). Owner rulings applied: BLE is transport only; a lost link is liveness; a misaddressed message is meaningless bytes; the SDK's owed frames are the only redelivery authority (outbox deleted).
Commits
fix(ble): one GATT operation in flight per link (GattOperationQueuecovers the MTU request, both CCCD writes, identity read, pairing writes, message chunks and chunk ACKs); a send reports what the stack did (writeMessage, no hard-codedtrue); BleOutbox/BleOutboxRepository deleted.fix(ble): the single-peer fallback inresolveSessiondeleted.fix(ble): sends keyed by the counterparty's device id, with the address only as a hint. A client link counts as a route once the identity read on it is anchored. A reach tries the hint first, then scans past other appliances, and a non-target's identity read records nothing (gatt_identity::identity_read_verdict). Pairing is decided by the contact's device id. Replies go back on the link they answer.bleNotifyLink(deviceId, …)replaces the address-keyed link event, andowed_frame_driverreaches for counterparties whose owed frames were never delivered.fix(offline): Coredecide_preparedecidesNotAddressedbefore anything else, and the receiver reads, decides and only then writes. A stale proposal's claimed tip is kept only when the commitment recomputes on it.fix(ble): identity-read result extractors answer empty (not null) for absent fields. This fixes an NPE seen on A54.fix(frontend): an incoming offline step awaiting the user is shown again after a restart. Without this, no screen offered it.fix(ble): a finished reach's leftover candidate stays excluded (its late identity read is judged against the reach's appliance).fix(frontend): the resurfaced incoming dialog waits until the intro is over.fix(ble): the BLE pairing frames (identity write-back, accept, confirm) are addressed envelopes the peer's strict decoder takes (C2; 9FF refused the old ones on hardware).fix(ble): an MTU the link never reported is no longer assumed to be 517.Docs: CONFORMANCE_GAPS §6.34; VERIFICATION_MATRIX rows for the addressing, claimed-tip, identity-verdict and delivery gates.
Verification so far
make lintclean.cargo ndk … checkand CI's Android-target test build (test --no-run --release) clean.bilateral::offline10/0, offline step tests and handler 35/0. Android bridge unit tests 236/0. Frontend dialog suites pass.Proven on real Android devices (A54 + 3×A16, GCP beta fleet)
A54's driver drained the stuck step 392XXK8ZMME2E: it reached 5GN by identity and wrote 255 chunks with 0 refusals. 5GN stored the step for its user.
8XK → 9FF offline, with 5GN running nearby: 8XK had no stored address for 9FF, so the reach scanned, identified 9FF and wrote the 255-chunk prepare. 9FF's user accepted, and its 254-chunk response came back over the same link. 5GN received no frames.
8XK's confirm was refused, fail-closed, because no anchor appliance was attached. The step stays owed on both phones.
Second run (A54 with a Pico attached, 5GN, with 8XK and 9FF running nearby). A54's owed-frame driver redelivered 392XXK8ZMME2E on each restart. Its reach identified 8XK and 9FF, excluded both, and reached 5GN. After a restart, 5GN offered the waiting step again. I accepted it, and the response reached A54 after one server-side retry.
The confirm failed again because the Pico does not answer: with a bench build carrying the anchor factory,
PicoSelfTestActivityreports "H2 FAIL: no real chip response".Open