fix(stream): payload-length cap, quiet-port resync, main-thread error teardown

This commit is contained in:
jack
2026-07-13 11:54:11 -07:00
parent 92ff6fbd14
commit 425f3731cd
5 changed files with 276 additions and 20 deletions
+84 -11
View File
@@ -15,8 +15,30 @@ log = logging.getLogger(__name__)
ParseFrame = Callable[[bytes, int], Frame]
# Max consecutive resync attempts before giving up on a corrupt stream.
# Counted only when data WAS read and still no sync marker (pure garbage);
# an idle port never counts — C#'s findMessageStart waits forever on quiet.
_MAX_RESYNC_ATTEMPTS = 50
# Largest believable payload_len. Real frames are ≤ 488 bytes (244 words);
# anything bigger means we latched onto a false 0xAA 0x88 inside data and
# read garbage as a length — without this cap, _accumulate would swallow up
# to 64KB of good frames waiting for a packet that doesn't exist.
# ponytail: loose cap; exact per-family length check if false frames slip CRC.
_MAX_PAYLOAD_LEN = 4096
def _crc16(data: bytes) -> int:
"""CRC16/MODBUS (poly 0xA001, init 0xFFFF) — mirrors C# CalculateCRC16."""
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return crc
class StreamReader:
def __init__(self, transport, parse_frame: ParseFrame,
@@ -72,6 +94,8 @@ class StreamReader:
peek_buf.extend(b)
if b[0] == 10: # newline
break
if len(peek_buf) == 2 and peek_buf[0] == 0xAA and peek_buf[1] == 0x88:
break # binary sync marker — decisive, stop peeking
if self._stop.is_set() or not peek_buf:
return
@@ -95,11 +119,17 @@ class StreamReader:
# Default: ASCII hex dump stream.
self._run_ascii(raw)
except Exception as exc:
log.exception("StreamReader thread encountered error: %s", exc)
try:
self._on_error(exc)
except Exception:
pass
if self._stop.is_set():
# stop() closes the transport to unblock a pending read —
# that self-inflicted close raises here too (e.g. EBADF).
# Not a real stream fault, so don't report it as one.
log.debug("StreamReader thread exiting after stop(): %s", exc)
else:
log.exception("StreamReader thread encountered error: %s", exc)
try:
self._on_error(exc)
except Exception:
pass
finally:
try:
self._transport.close()
@@ -133,6 +163,12 @@ class StreamReader:
return
payload_len = int.from_bytes(buf[5:7], byteorder='little')
if payload_len > _MAX_PAYLOAD_LEN:
# False sync marker inside data — the "length" is garbage.
# Skip past the marker and rescan what we already have.
self.resync_count += 1
buf = buf[2:]
continue
total_packet_len = 2 + 5 + payload_len + 2
# Accumulate the rest of the packet in one buffered read.
@@ -144,6 +180,22 @@ class StreamReader:
if self._stop.is_set():
return
# CRC16 over sync+header+payload, trailing 2 bytes LE —
# mirrors C# VerifyData. A failed CRC is a corrupt or
# misframed packet: drop it, rescan, keep streaming.
received_crc = int.from_bytes(
buf[7 + payload_len:9 + payload_len], byteorder='little'
)
if _crc16(bytes(buf[:7 + payload_len])) != received_crc:
self.error_count += 1
if self.error_count <= 10:
log.warning(
"CRC mismatch on binary packet (seq bytes %s) — dropped",
bytes(buf[3:5]).hex(),
)
buf = buf[2:]
continue
header = buf[2:7]
seq = int.from_bytes(header[1:3], byteorder='little')
payload = buf[7:7 + payload_len]
@@ -160,6 +212,17 @@ class StreamReader:
buf = buf[total_packet_len:]
else:
# Resync: keep only trailing 0xAA if present, then read more.
if len(buf) > 0 and buf[-1] == 0xAA:
buf = bytearray([0xAA])
else:
buf.clear()
chunk = self._read_chunk()
if not chunk:
# Quiet port — waiting for data is not a resync failure.
# The device pauses between scans; C#'s findMessageStart
# waits forever. Only actual garbage counts toward the cap.
continue
resync_attempts += 1
self.resync_count += 1
if resync_attempts > _MAX_RESYNC_ATTEMPTS:
@@ -172,12 +235,6 @@ class StreamReader:
TimeoutError("Binary stream resync failed: no sync marker")
)
return
if len(buf) > 0 and buf[-1] == 0xAA:
buf = bytearray([0xAA])
else:
buf.clear()
chunk = self._read_chunk()
buf.extend(chunk)
def _run_ascii(self, initial_bytes: bytes) -> None:
@@ -276,6 +333,22 @@ class StreamReader:
def stop(self) -> None:
self._stop.set()
# Close the port out from under any blocked transport.read() call —
# relying on _stop alone means a thread stuck in a longer-than-
# expected read (or one the OS driver is slow to unblock) keeps the
# port open, and a subsequent startStream() then opens a *second*
# handle to the same port, racing the still-live old reader for
# bytes (garbled/duplicated frames on the next Live session).
try:
self._transport.close()
except Exception:
pass
if self._thread is not None and self._thread.is_alive():
self._thread.join(timeout = 2.0)
if self._thread.is_alive():
log.error(
"StreamReader: worker thread still alive %.1fs after "
"stop() — port may not be released for the next session",
2.0,
)
self._thread = None