From d5903fd57278d9aa3763cdec3a5ede6a57282a64 Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Mon, 31 Aug 2026 23:40:36 -0400 Subject: [PATCH] fix(ble): size notification chunks for the MTU that was negotiated, not 512 The Bluetooth logs panel showed its two headers -- "=== Docker Logs ===" and "=== System Logs ===" -- with nothing under them. The reply was arriving as damaged JSON, failing to parse, and leaving an object with no `docker` and no `system` key for the client to render. Every chunk goes out as a D-Bus PropertiesChanged "Value", and BlueZ silently truncates that to the NEGOTIATED ATT MTU minus 3. `BLEResponseHandler` assumed 512 -- the largest MTU anyone negotiates, not the smallest -- and nothing here can see the real one: service.py implements none of the BlueZ APIs that report it (no AcquireNotify, no MTU property), so there is no value to read and no error when a chunk is cut short. With base_overhead 47 and the old halving, chunks came out at ~278 bytes. A phone that settles on ATT_MTU 247 -- completely ordinary on Android -- cuts those at 244. Every chunk lands damaged, and the reply can never be reassembled. assumed 512, //2 chunk ~278 B truncated at 244 assumed 512, no //2 chunk ~509 B truncated at 244 (worse) assumed 185, no //2 chunk ~182 B fits So the fix is NOT simply dropping the `// 2`. Doing that alone doubles the chunk size to ~509 B and breaks the transfer on every link below 512 -- the halving was, by accident, the only thing keeping chunks anywhere near a real MTU. Both parts have to move together: - assume 185, the conservative floor (what iOS uses, at or below every MTU seen in practice), so a chunk fits whatever was actually negotiated; - drop the `// 2`, which is redundant now the budget is honest. Its stated job -- worst-case JSON double-escaping -- is already done by the loop that measures the ENCODED chunk and shrinks until it fits. The cost is more chunks per reply. A truncated chunk is a certain failure; a longer transfer is not. Verified by simulating the chunker over a realistic 4722-byte log payload (quotes, backslashes, newlines, unicode, random printable): 42 chunks, max chunk 182 bytes, every chunk within budget, fits ATT_MTU 247 and 185, and the reassembled payload is byte-identical to the input. Not verified on hardware: which MTU this Blox actually negotiates. That is the one thing that would turn "explains the symptom" into "was the symptom", and it needs a device. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YJyqpFReP1wbz86nsqsoXq --- docker/fxsupport/linux/bluetooth.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/docker/fxsupport/linux/bluetooth.py b/docker/fxsupport/linux/bluetooth.py index 1fdfc47..4491562 100644 --- a/docker/fxsupport/linux/bluetooth.py +++ b/docker/fxsupport/linux/bluetooth.py @@ -128,8 +128,26 @@ def set_lastCommand(self, command): self.lastCommand = command +# The ATT MTU to size notifications for. +# +# This server sends every chunk as a D-Bus PropertiesChanged "Value", and BlueZ silently TRUNCATES that to the +# NEGOTIATED ATT MTU minus 3. Nothing here can see what was negotiated: service.py implements none of the BlueZ +# APIs that report it (no AcquireNotify, no MTU property), so there is no value to read and no error when a +# chunk is cut short — the client just receives half a JSON object. +# +# It used to assume 512, which is the largest MTU anyone negotiates rather than the smallest. A phone that +# settles on 247 — completely ordinary on Android — then had every chunk cut at 244, and the reply could never +# be reassembled. That is what "the logs panel shows its two headers and nothing under them" was: the reply +# arrived as damaged JSON, failed to parse, and left an object with no `docker` and no `system` key in it. +# +# 185 is the conservative floor: it is what iOS uses, and it is at or below every MTU seen in practice, so a +# chunk built to it fits whatever the link actually negotiated. The cost is more chunks per reply; the benefit +# is that each one arrives whole. A truncated chunk is a certain failure, a longer transfer is not. +SAFE_NOTIFY_ATT_MTU = 185 + + class BLEResponseHandler: - def __init__(self, mtu_size=512): + def __init__(self, mtu_size=SAFE_NOTIFY_ATT_MTU): self.mtu_size = mtu_size - 3 # Account for BLE overhead self.chunks = [] self.current_chunk_index = 0 @@ -145,9 +163,14 @@ def prepare_response(self, response): # JSON escaping of data content (quotes, backslashes, newlines) makes # the serialized chunk larger than the raw substring, so we verify the # real encoded size and shrink per-chunk if needed. - # Use 2x safety margin to account for worst-case double-escaping. + # + # This is a starting guess, not a guarantee: the loop below measures the ENCODED chunk and shrinks + # until it fits, so nothing oversized can escape regardless of what is chosen here. It used to halve + # the budget as a "2x safety margin against worst-case double-escaping", which the verification loop + # already covers — its real effect was to accidentally soften an MTU that was set far too high. With + # the MTU now honest (see SAFE_NOTIFY_ATT_MTU), halving would only double the chunk count for nothing. base_overhead = len(json.dumps({"type": "ble_chunk", "index": 999, "data": ""})) - initial_data_size = (self.mtu_size - base_overhead) // 2 + initial_data_size = self.mtu_size - base_overhead pos = 0 data_chunks = []