Skip to content

Commit 11127a3

Browse files
committed
jikong: check the framing invariant instead of flushing the buffer on junk (#392)
A proposed fix for the JK-PB 'AT\r\n' flood flushed self._buffer whenever feed_frames reported dropped bytes. But the junk is already deleted at the moment it is counted, so what is left buffered is the header-aligned head of the frame *behind* the junk - and dropped>0 together with a partial frame is exactly what an interleaved flood produces. Flushing there turns a recoverable split frame (#377) into a lost one, and the following tail then counts as junk too, so it repeats. Check the real invariant instead: feed_frames consumes or trims on every iteration, so it cannot return with a whole frame still buffered. If it ever does, log it and resync rather than letting the buffer grow silently.
1 parent 48d7315 commit 11127a3

2 files changed

Lines changed: 64 additions & 0 deletions

File tree

bmslib/models/jikong.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,20 @@ def _notification_handler(self, _sender, data):
152152
# complete FRAME_SIZE window, so a short buffer can no longer overrun.
153153
frames, dropped, corrupt = feed_frames(self._buffer, data)
154154

155+
# feed_frames consumes or trims on every iteration, so it must return with
156+
# less than one frame still buffered: either a header-aligned partial frame
157+
# or the <= 3 byte straddle reserve. More than that means the resync logic
158+
# stopped consuming, and keeping the buffer would stall decoding forever -
159+
# resync, and make the bug visible instead of growing quietly.
160+
# Do NOT key this on `dropped` (#392): junk is already deleted by the time
161+
# it is counted, so what is left over is the partial frame we still need,
162+
# and dropping it turns a recoverable split frame into a lost one (#377).
163+
if len(self._buffer) >= FRAME_SIZE:
164+
self.logger.error("%s framing invariant broken, %d byte(s) buffered after "
165+
"parsing (>= one %d B frame), resyncing - please report",
166+
self.name, len(self._buffer), FRAME_SIZE)
167+
self._buffer.clear()
168+
155169
for frame in corrupt:
156170
# A real frame that arrived corrupted - rare, keep visible.
157171
self.logger.error("%s crc check failed, discarding frame 0x%02x: %s...",

bmslib/test/test_jk_framing.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import pytest
1414

15+
from bmslib.models import jikong
1516
from bmslib.models.jikong import (FRAME_SIZE, HEADER, RESPONSE_TYPES, JKBt,
1617
calc_crc, feed_frames)
1718
from bmslib.test.data import jk_issue377 as fx
@@ -162,6 +163,55 @@ def test_junk_flood_does_not_grow_the_buffer():
162163
assert frames == [fx.FRAME_02_STATUS]
163164

164165

166+
def test_buffer_holds_less_than_one_frame_after_every_packet():
167+
"""The invariant the handler's resync guard checks: feed_frames consumes or
168+
trims on every iteration, so it can never return with a whole frame still
169+
buffered. Exercised over an adversarial interleaving of junk, split frames,
170+
a corrupt frame and a checksum-colliding false header."""
171+
stream = (b'AT\r\n' + fx.FRAME_02_STATUS + fx.JUNK_AFTER_02
172+
+ fx.CORRUPT_FRAME_02 + fx.FRAME_03_DEVINFO + fx.JUNK_AFTER_03
173+
+ _forge_impostor(fx.FRAME_01_SETTINGS) + fx.FRAME_01_SETTINGS
174+
+ b'AT\r\n' * 50 + fx.FRAME_02_STATUS[:137])
175+
buf = bytearray()
176+
for i in range(0, len(stream), 17): # 17: never aligned with anything
177+
feed_frames(buf, stream[i:i + 17])
178+
assert len(buf) < FRAME_SIZE, 'buffer grew to %d at offset %d' % (len(buf), i)
179+
180+
181+
def test_junk_before_a_partial_frame_keeps_the_partial_frame():
182+
"""#392: junk is already deleted by the time `dropped` is counted, so what is
183+
left in the buffer is the head of the frame *behind* the junk. Flushing the
184+
buffer on dropped>0 would destroy it — and dropped>0 together with a partial
185+
frame is exactly what an AT-flood produces, so every frame would be lost."""
186+
bms = _make_jk()
187+
bms._notification_handler(None, b'AT\r\n' + fx.FRAME_02_STATUS[:84])
188+
assert bytes(bms._buffer) == fx.FRAME_02_STATUS[:84]
189+
190+
bms._notification_handler(None, fx.FRAME_02_STATUS[84:])
191+
assert bytes(bms._resp_table[0x02][0]) == fx.FRAME_02_STATUS
192+
assert len(bms._buffer) == 0
193+
194+
195+
def test_resync_guard_fires_when_the_framing_invariant_breaks(monkeypatch):
196+
"""Calibrate the guard against the known-bad input it exists to catch: a
197+
feed_frames that stops consuming. Without it the buffer grows without bound
198+
and decoding never recovers; the guard must resync and say so."""
199+
bms = _make_jk()
200+
errors = []
201+
monkeypatch.setattr(bms.logger, 'error', lambda *a, **kw: errors.append(a[0]))
202+
203+
def _stuck(buf, chunk): # accumulates, never consumes or trims
204+
buf.extend(chunk)
205+
return [], 0, []
206+
207+
monkeypatch.setattr(jikong, 'feed_frames', _stuck)
208+
for _ in range(3):
209+
bms._notification_handler(None, fx.FRAME_02_STATUS)
210+
assert len(bms._buffer) < FRAME_SIZE
211+
212+
assert errors and all('framing invariant broken' in e for e in errors)
213+
214+
165215
def test_short_buffer_never_raises():
166216
"""#373: a header-aligned tail shorter than 300 B used to raise IndexError
167217
inside the bleak notify callback, tearing down the BLE event loop."""

0 commit comments

Comments
 (0)