Skip to content

fix: stop stray BLE notifications from desyncing reads and corrupting config - #155

Merged
g4bri3lDev merged 2 commits into
OpenDisplay:mainfrom
davelee98:fix/drain-pr
Aug 14, 2026
Merged

fix: stop stray BLE notifications from desyncing reads and corrupting config#155
g4bri3lDev merged 2 commits into
OpenDisplay:mainfrom
davelee98:fix/drain-pr

Conversation

@davelee98

Copy link
Copy Markdown
Contributor

Problem

The notification queue (BLEConnection._notification_queue) is a plain FIFO of raw frames with no request/response correlation — read_response() returns whatever is at the front. One orphaned frame therefore shifts every subsequent read by one, permanently. drain_notifications() mitigates this before each write, but it only clears what has already arrived.

Two gaps let stray frames through, both observed in the field:

1. Frames survived a disconnect. disconnect() cleared _client and _notification_characteristic but left the queue populated. A BLEConnection is reusable (connect() after a drop keeps the same queue), so residue from a dead link could be returned as the first response on the next link. Neither the stack-signalled drop nor the connect-retry teardown flushed either.

2. A config read absorbed foreign frames silently. strip_command_echo() returns a non-matching frame unchanged, so in interrogate() a frame from another exchange had its 2-byte echo consumed as the chunk-number field and its body appended as config data. The chunk loop only counts bytes, so nothing detected it.

Observed downstream, from a single session's logs:

First chunk: 98 bytes, total length: 707
Received chunk, total: 190/707
...
Received chunk, total: 766/707        <- 59 bytes over
Received complete TLV data: 766 bytes
WARNING Unknown packet type 0x00 at offset 128, skipping

total_length (707) matched the TLV wrapper's own length field; the extra 59 bytes were a stray frame spliced in, and the parser ran off the end of the real config into it. The same root cause surfaces elsewhere as a hard failure, e.g. Firmware version echo mismatch: expected 0x0043, got 0x0040 — a leaked config chunk answering the firmware-version read.

Changes

Flush the queue on every disconnect path — all three, only one of which goes through disconnect():

  • _on_disconnect(), the stack-signalled callback, is the only path that runs on an unexpected drop. Flushes before notifying the owner so it still runs if that callback raises.
  • disconnect() flushes outside its is_connected branch — the branch is skipped when the link already died, and not all bleak backends invoke disconnected_callback for a caller-initiated disconnect.
  • _clear_cache_and_drop() drops the client directly rather than via disconnect(), and a retried connect reuses the queue.

Screen config-read frames by command echo. interrogate() now skips frames belonging to other exchanges rather than splicing them in, bounded by MAX_STRAY_CONFIG_FRAMES so a device streaming unrelated frames fails with a clear error instead of renewing the read timeout indefinitely. The no-config NACK ({0xFF,0x40,...}) is passed through rather than skipped — it answers this command, and discarding it would turn a clear "no stored configuration" error into a timeout.

strip_command_echo() keeps its lenient behavior (callers that cannot receive foreign frames rely on it); the mismatch case is now documented and factored into matches_command_echo() for callers that must screen first.

Name the command when a frame is dropped, at both drop sites. describe_command_code() resolves the ACK high bit and the 0xFF NACK prefix back to the originating command, so the frames most worth diagnosing don't log as unrecognized codes:

Ignoring stray READ_FW_VERSION (0x0043) frame (25 B) while reading config
Discarded 2 stale notification(s) before command: READ_CONFIG (0x0040) (4 B), AUTHENTICATE (0x0050) (3 B)

A bare count reported that a desync happened but not why.

Notes

  • No behavior change to strip_command_echo() itself, so no other call site is affected — it has only the two in interrogate().
  • Not addressed here: interrogate() still discards the chunk-number field without validating it, and never truncates tlv_data to total_length, so a correctly-echoed duplicate or overshooting final chunk can still get through. Happy to follow up separately.

Test plan

  • 9 new unit tests: disconnect/link-drop/connect-retry flush, owner still notified after flush, stray-frame skip, stray bound, error frame after a stray, ACK/NACK/unknown code labelling.
  • Full suite: 1028 passed.
  • ruff, ruff-format, mypy --strict, pylint clean via prek run --all-files.

The notification queue has no request/response correlation, so a single
leftover frame is read as the answer to the next command and desyncs every
subsequent read by one. `disconnect()` cleared the client and characteristic
but left the queue populated, and a BLEConnection is reusable — connect()
after a drop keeps the same queue — so residue from a dead link could be
returned as the first response on the next link.

Flush from all three disconnect paths, only one of which goes through
disconnect():

- `_on_disconnect()`, the stack-signalled callback, is the only path that
  runs on an unexpected drop (device reboot, out of range, proxy reset).
  Flushes before notifying the owner so it still runs if that callback
  raises.
- `disconnect()` flushes outside its `is_connected` branch: the branch is
  skipped when the link already died underneath us, and not all bleak
  backends invoke disconnected_callback for a caller-initiated disconnect.
- `_clear_cache_and_drop()` drops the client directly rather than via
  disconnect(), and a retried connect reuses the queue — an attempt that
  reached notification setup before failing could hand a frame to the next
  attempt.

Also log each discarded frame's command echo instead of only a count. The
echo leads every frame in the clear (encrypted responses carry it ahead of
the nonce), so it distinguishes a duplicated response from a frame belonging
to an entirely different exchange — a bare count reports that a desync
happened but not why.
interrogate() trusted whatever the notification queue handed it. The queue
has no request/response correlation, so a frame from a different exchange —
a duplicated response, or one the firmware held in its TX ring until
notifications were enabled — could land mid-transfer, and nothing rejected
it: strip_command_echo() returns a non-matching frame *unchanged*, so its
2-byte echo was consumed as the chunk-number field and its body appended as
config data. The chunk loop only counts bytes, so this silently overshot
total_length and fed the trailing garbage to the TLV parser, which reported
it as an unrelated parse failure (or skipped it as an unknown packet type).

Screen every config-read frame with the command echo and skip the ones that
belong elsewhere, bounded by MAX_STRAY_CONFIG_FRAMES so a device streaming
unrelated frames fails with a clear error instead of renewing the read
timeout indefinitely. The no-config NACK ({0xFF,0x40,...}) is passed through
rather than skipped: it answers this command, and discarding it would turn a
clear "no stored configuration" error into a timeout.

strip_command_echo() keeps its lenient behavior — callers that cannot
receive foreign frames rely on it — but the mismatch case is now documented
and factored into matches_command_echo() for callers that must screen first.

Also name the command when a frame is dropped, here and in the queue drain.
describe_command_code() resolves the ACK high bit and the 0xFF NACK prefix
back to the originating command, so the frames most worth diagnosing do not
log as unrecognized codes:

  Ignoring stray READ_FW_VERSION (0x0043) frame (25 B) while reading config
  Discarded 2 stale notification(s) before command: READ_CONFIG (0x0040) ...
@davelee98
davelee98 requested a review from g4bri3lDev as a code owner August 10, 2026 20:37
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@g4bri3lDev
g4bri3lDev merged commit 1bd7404 into OpenDisplay:main Aug 14, 2026
4 checks passed
@davelee98
davelee98 deleted the fix/drain-pr branch August 14, 2026 15:08
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