Skip to content

fix(nodejs-ble): reach a peripheral that stops responding after the BTP handshake - #4269

Open
Apollon77 wants to merge 7 commits into
mainfrom
fix/ble-btp-stalled-peer
Open

fix(nodejs-ble): reach a peripheral that stops responding after the BTP handshake#4269
Apollon77 wants to merge 7 commits into
mainfrom
fix/ble-btp-stalled-peer

Conversation

@Apollon77

Copy link
Copy Markdown
Collaborator

What breaks today

Some Bluetooth peripherals complete the BTP session handshake, acknowledge the first data write at the Bluetooth level, and then never act on it. Commissioning waits 15 seconds and gives up. Reported by three users against IKEA GRILLPLATS plugs; the same plug commissions fine from a different Bluetooth adapter.

BtpSessionHandler  Handshake request version: 4 attMtu: 241 windowSize: 5
BtpSessionHandler  Sending BTP packet raw: 0d0000650004000000bc7a…
  … 15.07 s …
BtpSessionHandler  Acknowledgement for the sent sequence number was not received ... disconnect

Why

BTP carries a Matter message in segments, and the segment size is negotiated during the handshake from the Bluetooth connection's ATT_MTU — up to 244 bytes. On a Bluetooth adapter without Data Length Extension (introduced in Bluetooth 4.2) a single over-the-air packet carries 27 bytes, so a 244-byte segment is split across roughly five packets. These peripherals reassemble the write at the Bluetooth level and answer it there, but their BTP layer never sees it.

Two of the affected users are on Bluetooth 4.1 adapters; the one on Bluetooth 5 succeeds. A user confirmed the mechanism directly by patching the built NobleBleChannel.js to force the minimum segment size — commissioning then succeeded on the first attempt on hardware that had never worked.

The fix

When a session we opened has received nothing at all from the peer since the handshake — no acknowledgement and no packet of its own — BtpSessionHandler now reports that through a new stalledAfterHandshake observable instead of closing, handing over the Matter messages the peer never acknowledged. The Node.js BLE transport then establishes a fresh session at the smallest segment size the specification allows (20 bytes) and resends them.

No reconnection is needed. Matter core specification §4.19.3.3: "To close a BTP session, a GATT client SHALL unsubscribe from characteristic C2", and §4.19.3.2: "A BTP session MAY open and close with no effect on the state of the underlying Bluetooth LE connection". So the recovery is unsubscribe, re-handshake, resubscribe — roughly half a second, well inside the 30 s the Matter layer allows for a response.

This is an interop workaround, not specified behaviour. The specification neither describes nor forbids re-establishing a session on a live connection; CHIP drops the connection and reconnects instead. It runs at most once per channel and only for a session above the minimum segment size, so a peripheral that stays silent afterwards is given up on exactly as before.

Two related fixes

  • A late ATT_MTU no longer pins the segment size to the minimum. noble reports the negotiated MTU through an event and leaves the initial value null until it arrives, which can be after the connection interview finishes. We read the initial value and derived a 20-byte segment size from it for the life of the session. The exchange now gets a short window to land.
  • A session no longer accepts a segment size larger than the one it offered. A peripheral must not select more than the client proposed; without this bound, one that echoes an oversized value would undo the reduction above.

Deliberately not in this PR

  • The replay reuses the original Matter message counter. A peer that received the message and only failed to acknowledge it would drop the replay as a duplicate. Structurally close to impossible here — the trigger requires zero BTP packets received, and a peer whose BTP engine reassembled the message would have acknowledged it, since the same engine produces both. Restarting PASE instead would sidestep it at the cost of a second pairing round; it did not seem worth the machinery.
  • @matter/react-native duplicates the handshake and gets the segment-size bound but not the renegotiation or the MTU wait. Unifying both transports behind a shared GATT-client port in @matter/protocol is filed as a follow-up; it is a refactor of two platform packages and does not belong in a bugfix.
  • Renaming MINIMUM_ATT_MTU. It holds 20, a segment size, while the minimum ATT_MTU is 23. Left alone deliberately.

Testing

npm run build-clean, npm run format-verify, npm run lint and the full npm test are green.

Twelve new tests across BtpSessionHandlerTest and NobleBleChannelTest. Every production hunk was mutation-tested — reverted individually, with the test confirmed to fail — including:

  • the stall predicate, the segment-size bound, and the C2 unsubscribe that closes the peer's session
  • a send issued during the renegotiation, which found a real ordering bug: the recovery promise was assigned only after its first step had already run, so the send reached the suspended session
  • the failure class a parked send sees, which must read as channel loss rather than internal BTP flow state
  • the abort that stops the renegotiation's handshake timer when the channel closes
  • the guards that stop a suspended session from writing its remaining segments onto the renegotiated connection — these two are individually redundant, so only removing both makes the test fail, and that is stated rather than claimed as per-hunk coverage

Not yet validated against real hardware. The fix is proven against a peripheral simulator; the bug it addresses is an interop bug, which is the class self-consistent tests do not settle. Live validation against an actual GRILLPLATS on a Bluetooth 4.1 adapter should happen before release.

🤖 Generated with Claude Code

…TP handshake

Some Bluetooth peripherals complete the BTP session handshake, acknowledge the first data
write at the Bluetooth level, and then never act on it. Commissioning sat there for 15
seconds and gave up. It is reproducible with IKEA GRILLPLATS plugs on Bluetooth 4.1
adapters, and the same plug commissions fine from a Bluetooth 5 adapter.

The cause is packet size. BTP carries a Matter message in segments, and the segment size is
negotiated during the handshake from the Bluetooth connection's ATT_MTU - up to 244 bytes.
On an adapter without Data Length Extension a single Bluetooth packet carries only 27
bytes, so a 244-byte segment is split across roughly five packets on the air. These
peripherals reassemble the write at the Bluetooth level and answer it there, but their BTP
layer never sees it. A segment small enough to fit one Bluetooth packet reaches them.

When a session that we opened has received nothing at all from the peer since the handshake
- no acknowledgement and no packet of its own - it now reports that instead of closing, and
the transport establishes a fresh session with the smallest segment size the specification
allows (20 bytes) and resends what the peer never acknowledged. The specification says a
client closes a BTP session by unsubscribing from characteristic C2 and that this leaves
the Bluetooth connection alone (Matter core specification section 4.19.3.3), so this costs
no reconnection. It happens at most once per channel, and only for a session above the
minimum segment size, so a peripheral that stays silent afterwards is given up on as
before.

This is an interop workaround, not specified behaviour: the specification neither describes
nor forbids re-establishing a session on a live connection.

Two related fixes:

- The negotiated ATT_MTU can still be in flight when the connection interview finishes, and
  it arrives through an event rather than the initial value. We waited on neither, so a
  late value pinned the segment size to the 20-byte minimum for the whole session. The
  exchange is now given a short window to land.

- A session no longer accepts a segment size larger than the one it offered. A peripheral
  is not allowed to select more than the client proposed, and without this bound one that
  echoes an oversized value would undo the reduction above.

@matter/react-native shares the handshake code and gets the segment-size bound, but not the
renegotiation or the ATT_MTU wait; unifying the two transports is filed separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings August 18, 2026 21:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Node.js BLE recovery for peripherals that stall after BTP handshake.

Changes:

  • Detects stalled BTP sessions and retries using minimum segment size.
  • Waits briefly for late ATT_MTU negotiation and bounds peer-selected segment size.
  • Adds comprehensive protocol and transport tests.

Verification commands are reported green in the PR description.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
CHANGELOG.md Documents BLE and protocol changes.
packages/nodejs-ble/src/NobleBleChannel.ts Implements MTU waiting and session renegotiation.
packages/nodejs-ble/test/NobleBleChannelTest.ts Tests recovery and failure paths.
packages/protocol/src/ble/BtpSessionHandler.ts Adds stall detection and replay support.
packages/protocol/src/codec/BtpCodec.ts Adds handshake-response detection.
packages/protocol/test/ble/BtpSessionHandlerTest.ts Tests stall and segment-size behavior.
packages/react-native/src/ble/ReactNativeBleChannel.ts Supplies the requested segment-size bound.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/nodejs-ble/src/NobleBleChannel.ts
Comment thread packages/protocol/src/ble/BtpSessionHandler.ts Outdated
Apollon77 and others added 2 commits August 19, 2026 08:46
…eral abandons

Two problems with the session renegotiation, both found in review.

A transport that does not observe the new stall report was left worse off than before. The
session suspended itself and reported the stall regardless of whether anyone was listening,
so for @matter/react-native - which observes only the close - an unresponsive peer left the
channel connected with a dormant session instead of closing after the acknowledgement
timeout. The stall report is now made only when someone observes it; a session nobody
observes closes exactly as it always did.

A physical disconnect did not end a renegotiation that was waiting for its handshake
response. Only an explicit close cancelled the wait, so a peripheral that vanished mid
renegotiation left the handshake timer armed, and any send waiting on the renegotiation
parked, until that timer expired 15 seconds later. The disconnect path now cancels the same
way a close does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (4)

packages/protocol/src/codec/BtpCodec.ts:76

  • This new public codec API is missing the required Matter specification reference in its JSDoc. Please link the handshake-response section so generated API docs retain the protocol contract.
    static isHandshakeResponse(data: Bytes) {

packages/protocol/src/ble/BtpSessionHandler.ts:524

  • Because suspend() is a new exported public API, its JSDoc needs the required Matter specification reference.
    suspend() {

packages/protocol/src/ble/BtpSessionHandler.ts:69

  • This new public observable also needs the repository-required @see reference to the relevant BTP session section for generated API documentation.
    get stalledAfterHandshake() {

packages/protocol/src/ble/BtpSessionHandler.ts:151

  • The updated public factory JSDoc documents the new bound but omits the required reference to the handshake negotiation rules it implements.
    static async createAsCentral(

@mergify

mergify Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

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.

2 participants