fix(lf): flush stale input before bulk download (desync -> garbage/DEADBEEF)

A real download came back as BigBuf memory (0xDEADBEEF poison) with a PM3 response
magic (0x62334d50) embedded — the classic signature of a desynced read: the stream
had stale bytes (a prior partial transaction, or the C client having touched the
port), and the bulk reader consumes fixed 544-byte OLD frames that carry no magic to
resync on, so once misaligned it stays misaligned and reads memory as "samples".

transport.download_bulk now flushes the input first (reset_input_buffer + drain the
asyncio StreamReader until an 80ms quiet gap), so the transfer always begins byte-
aligned. Drains no-op when disconnected. Test asserts the flush runs before the request.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-14 16:52:22 -07:00
parent 81625a0e93
commit f09946c4a3
2 changed files with 47 additions and 3 deletions

View File

@@ -278,16 +278,42 @@ class PM3Transport:
frame = encode_old_frame(cmd, arg0, arg1, arg2, data)
await self.send_frame(frame)
async def _drain_input(self, quiet: float = 0.08) -> int:
"""Discard buffered/stale bytes (a prior partial transaction, or another process that
touched the port) so the next request/response starts byte-aligned. OLD-format frames
carry no magic to resync on, so a bulk download MUST begin on a clean stream. Returns the
number of bytes dropped."""
if not self._reader:
return 0
try: # clear the OS/pyserial buffer too
tr = self._writer.transport
if hasattr(tr, "serial"):
tr.serial.reset_input_buffer()
except Exception:
pass
dropped = 0
while True:
try:
chunk = await asyncio.wait_for(self._reader.read(4096), timeout=quiet)
except (asyncio.TimeoutError, Exception):
break
if not chunk:
break
dropped += len(chunk)
return dropped
async def download_bulk(self, cmd: int, start: int, count: int, data_cmd: int,
ack_cmd: int, timeout: float = 5.0, on_bytes=None) -> bytes:
"""Run a bulk BigBuf/EML/SPIFFS download and return the concatenated bytes.
The firmware answers one download request (a MIX frame) by streaming ``count`` bytes as
a run of *OLD*-format ``data_cmd`` frames (no magic — that's why the plain NG reader
can't see them and the naive download hangs), then a final ``ack_cmd`` frame. We read
with the NG/OLD-agnostic reader until the ACK, or until we've collected ``count`` bytes
(draining the trailing ACK best-effort so it can't poison the next command)."""
can't see them and the naive download hangs), then a final ``ack_cmd`` frame. We flush
any stale input first (OLD frames can't resync mid-stream), then read with the NG/OLD-
agnostic reader until the ACK, or until we've collected ``count`` bytes (draining the
trailing ACK best-effort so it can't poison the next command)."""
async with self._lock:
await self._drain_input() # start byte-aligned — no stale frames
await self.send_frame(encode_mix_frame(cmd, start, count, 0))
buf = bytearray()
while True: