fix(stream_reader): add ASCII hex path + binary resync guard

Family-A wafers never send 0xAA 0x88 framing — _run() now routes
to _run_ascii() on first byte dispatch instead of treating the whole
stream as corrupt binary.

Binary path gets a 16 KB give-up cap (bytes, not attempts) so a
permanently-corrupt port can't stall the reader indefinitely. Raw
wire bytes go to DEBUG only; the ERROR line that surfaces in the
Activity Log is kept free of internal buffer content.

- add family_code param to StreamReader for ASCII decode routing
- add _run_binary give-up cap + DEBUG-only hex diagnostic
- fix stop(): close transport on wedged thread (binary + ASCII paths)
- refactor test_stream_reader: consolidate ASCII regression + resync
  give-up + DEBUG-vs-ERROR log split tests; drop redundant parse_line
- extend test_session_controller: live-stream gate for non-X family
- StreamControlPanel.qml: thread-safe stop on tab leave
This commit is contained in:
jack
2026-07-13 12:26:33 -07:00
parent 425f3731cd
commit c4cbc02a15
5 changed files with 269 additions and 160 deletions
+64 -7
View File
@@ -99,9 +99,10 @@ def test_leaving_live_mode_stops_stream(controller):
def test_binary_frame_decode_bounded_to_loaded_layout(controller, monkeypatch):
"""Regression for phantom sensor #239 polluting MIN/MAX/AVG: the wire
payload carries a fixed-width channel array (up to 244 slots) wider than
any single wafer family's real sensor count. Family "A" (aepwafer.yaml)
has 48 real sensors — everything past that in the payload is an
unpopulated hardware slot and must never reach Frame.values."""
any single wafer family's real sensor count. Family "X" (xwafer.yaml,
the only family live streaming is supported on) has 80 real sensors —
everything past that in the payload is an unpopulated hardware slot and
must never reach Frame.values."""
fake_transport = MagicMock()
fake_transport.read.return_value = b""
monkeypatch.setattr(
@@ -109,13 +110,13 @@ def test_binary_frame_decode_bounded_to_loaded_layout(controller, monkeypatch):
lambda *a, **k: fake_transport,
)
controller.startStream("COM_FAKE", "A")
controller.startStream("COM_FAKE", "X")
try:
assert len(controller._sensors) == 48 # sanity: aepwafer.yaml loaded
assert len(controller._sensors) == 80 # sanity: xwafer.yaml loaded
parse = controller._reader._parse
payload = bytes(244 * 2) # full-width wire payload, well beyond the real 48
payload = bytes(244 * 2) # full-width wire payload, well beyond the real 80
frame = parse(payload, seq=1)
assert len(frame.values) == 48
assert len(frame.values) == 80
finally:
controller.stopStream()
@@ -135,6 +136,62 @@ def test_live_error_surfaces_message_and_stops_stream(controller):
assert controller._reader is None
def test_start_stream_refuses_non_x_family(controller, monkeypatch):
"""C# parity: only X-family wafer firmware supports live streaming.
Family A produced ~10s of unrelated ASCII output, not real telemetry —
startStream must refuse before ever opening the port."""
open_port = MagicMock()
monkeypatch.setattr(
"pygui.serialcomm.serial_port.SerialPort.open_port", open_port
)
error_seen = MagicMock()
controller.liveError.connect(error_seen)
controller.startStream("COM_FAKE", "A")
open_port.assert_not_called()
error_seen.assert_called_once()
assert "X-family" in error_seen.call_args[0][0]
assert controller._reader is None
def test_start_stream_allows_x_family(controller, monkeypatch):
fake_transport = MagicMock()
fake_transport.read.return_value = b""
monkeypatch.setattr(
"pygui.serialcomm.serial_port.SerialPort.open_port",
lambda *a, **k: fake_transport,
)
error_seen = MagicMock()
controller.liveError.connect(error_seen)
controller.startStream("COM_FAKE", "X")
try:
error_seen.assert_not_called()
assert controller._reader is not None
finally:
controller.stopStream()
def test_stop_stream_skips_d2s_write_when_reader_already_exited(controller):
"""Reader's own worker thread can exit and close the transport (e.g.
after a resync give-up) before stopStream() runs on the main thread.
Writing D2S to that already-closed transport is a guaranteed EBADF —
skip it instead of racing the close."""
controller.setMode("live")
fake_reader = MagicMock()
fake_reader._thread = MagicMock()
fake_reader._thread.is_alive.return_value = False
fake_transport = MagicMock()
fake_transport.is_open = True
fake_reader._transport = fake_transport
controller._reader = fake_reader
controller.stopStream()
fake_transport.write.assert_not_called()
def test_compare_files_integration(controller):
# Mock the comparisonResult signal
mock_slot = MagicMock()
+99 -63
View File
@@ -17,66 +17,8 @@ class FakeTransport:
res = self._data[self._pos:self._pos+n]
self._pos += n
return res
def readline(self):
time.sleep(0.001)
if self._pos >= len(self._data):
return b""
idx = self._data.find(b"\n", self._pos)
if idx == -1:
res = self._data[self._pos:]
self._pos = len(self._data)
return res
res = self._data[self._pos:idx+1]
self._pos = idx + 1
return res
def close(self): self.closed = True
def parse_line(raw: str, seq: int) -> Frame:
parts = [float(x) for x in raw.split(",")]
return Frame(seq = seq, time = parts[0], values = parts[1:])
def test_reads_frames_and_counts_errors():
got, errors = [], []
transport = FakeTransport([b"0.0,149,148\n", b"garbage\n", b"0.5,150,149\n"])
r = StreamReader(transport, parse_line,
on_frame = got.append, on_error = lambda e: errors.append(e))
r.start()
time.sleep(0.1)
r.stop()
assert [f.values for f in got] == [[149.0, 148.0], [150.0, 149.0]]
assert r.error_count == 1
assert transport.closed
class WedgedTransport:
"""Simulates a worker thread stuck in a blocking read() that ignores
the stop event — read() never returns on its own. stop() must close
the port to unblock it rather than relying on the read loop to notice."""
def __init__(self):
self.closed = False
def read(self, n=1):
while not self.closed:
time.sleep(0.001)
raise OSError("port closed")
def close(self):
self.closed = True
def test_stop_closes_transport_even_if_worker_thread_is_wedged():
r = StreamReader(WedgedTransport(), parse_line,
on_frame=lambda f: None, on_error=lambda e: None)
r.start()
time.sleep(0.05) # let the thread enter the blocking read
r.stop()
assert r._transport.closed, (
"stop() must close the transport itself — otherwise a wedged "
"worker thread keeps the port open and a subsequent startStream() "
"opens a second competing handle to the same port"
)
def make_packet(payload: bytes, seq: int = 1) -> bytes:
"""Build a wire packet exactly as the device does: AA 88, msg type,
@@ -98,22 +40,24 @@ def test_crc16_known_vector():
def test_binary_stream_parses_crc_valid_packets():
got = []
data = make_packet(b"\x01\x02", seq=1) + make_packet(b"\x03\x04", seq=2)
r = StreamReader(FakeTransport([data]), parse_payload,
transport = FakeTransport([data])
r = StreamReader(transport, parse_payload,
on_frame=got.append, on_error=lambda e: None)
r.start()
time.sleep(0.2)
r.stop()
assert [f.seq for f in got] == [1, 2]
assert r.error_count == 0
assert transport.closed
def test_corrupt_crc_packet_dropped_and_stream_continues():
got = []
got, errors = [], []
bad = bytearray(make_packet(b"\x09\x09", seq=1))
bad[-1] ^= 0xFF # break the CRC
data = bytes(bad) + make_packet(b"\x01\x02", seq=2)
r = StreamReader(FakeTransport([data]), parse_payload,
on_frame=got.append, on_error=lambda e: None)
on_frame=got.append, on_error=errors.append)
r.start()
time.sleep(0.2)
r.stop()
@@ -146,19 +90,111 @@ def test_quiet_port_between_scans_is_not_a_resync_failure():
r = StreamReader(FakeTransport([make_packet(b"\x01\x02", seq=1)]),
parse_payload, on_frame=got.append, on_error=errors.append)
r.start()
time.sleep(0.5) # ~100 empty reads after the single packet drains
time.sleep(0.5) # long idle stretch after the single packet drains
r.stop()
assert [f.seq for f in got] == [1]
assert errors == []
def test_resync_giveup_debug_log_has_bytes_error_log_does_not(caplog):
"""Regression: the give-up log used to dump `buf` after it had already
been cleared for the new read, so the hex-diagnostic came back empty —
useless for diagnosing what was actually on the wire. Fix: real bytes
go to DEBUG only; the ERROR line the terminal/Activity Log show stays
free of raw internal buffer content."""
import logging
errors = []
# 0xAA/0x88 excluded so this exercises _run_binary's resync path even
# though it's called directly (bypassing the ASCII/binary dispatch,
# which is exercised separately — see test_ascii_hex_dump_stream_*).
garbage = b"\x42" * 20000
r = StreamReader(FakeTransport([garbage]), parse_payload,
on_frame=lambda f: None, on_error=errors.append)
with caplog.at_level(logging.DEBUG, logger="pygui.serialcomm.stream_reader"):
r._run_binary(bytearray())
assert len(errors) == 1
error_lines = [r.message for r in caplog.records if r.levelname == "ERROR"]
debug_lines = [r.message for r in caplog.records if r.levelname == "DEBUG"]
assert any("giving up" in m for m in error_lines)
assert not any("42424242" in m for m in error_lines), (
"raw wire bytes must not appear in the ERROR line users see"
)
assert any("42424242" in m for m in debug_lines)
def test_resync_gives_up_after_16kb_of_garbage():
"""Give-up cap counts discarded BYTES, not read attempts, so it fires
at a fixed data volume regardless of transfer rate."""
errors = []
got = []
garbage = b"\x01" * 20000 # never contains 0xAA 0x88; well past the cap
r = StreamReader(FakeTransport([garbage]), parse_payload,
on_frame=got.append, on_error=errors.append)
r._run_binary(bytearray()) # deterministic give-up — no thread needed
assert got == []
assert len(errors) == 1
assert "resync" in str(errors[0]).lower()
def test_ascii_hex_dump_stream_decodes_real_wafer_capture():
"""Regression using an exact byte capture from real hardware (family A):
the wire is plain ASCII hex text, 4 chars/word, no 0xAA 0x88 framing at
all — see docs/adr/0007. _run() must route to the ASCII path and the
words must decode to plausible room-temperature values, not stall."""
got = []
words = ["280B", "1C0B", "220B", "1B0B", "200B", "1F0B", "190B", "1D0B",
"1B0B", "240B", "180B", "210B", "2B0B", "260B", "290B", "2B0B"]
# aepwafer.yaml's family "A" has 48 real sensors; pad to one full block
# (256 words) the way the real device does — extra slots are unpopulated.
block = "".join(words) + "0000" * (256 - len(words))
r = StreamReader(FakeTransport([block.encode()]), parse_payload,
on_frame=got.append, on_error=lambda e: None,
family_code="A")
r.start()
time.sleep(0.2)
r.stop()
assert len(got) == 1
assert len(got[0].values) == 48 # sensor_count_for("A")
# First word "280B" byte-swaps to "0B28" -> ~22.31C via the AEP formula.
assert 22.0 < got[0].values[0] < 22.5
class WedgedTransport:
"""Simulates a worker thread stuck in a blocking read() that ignores
the stop event — read() never returns on its own. stop() must close
the port to unblock it rather than relying on the read loop to notice."""
def __init__(self):
self.closed = False
def read(self, n=1):
while not self.closed:
time.sleep(0.001)
raise OSError("port closed")
def close(self):
self.closed = True
def test_stop_closes_transport_even_if_worker_thread_is_wedged():
r = StreamReader(WedgedTransport(), parse_payload,
on_frame=lambda f: None, on_error=lambda e: None)
r.start()
time.sleep(0.05) # let the thread enter the blocking read
r.stop()
assert r._transport.closed, (
"stop() must close the transport itself — otherwise a wedged "
"worker thread keeps the port open and a subsequent startStream() "
"opens a second competing handle to the same port"
)
def test_stop_does_not_report_the_close_it_caused_as_an_error():
"""stop()'s own transport.close() unblocks the worker thread's read()
by making it raise — that's expected teardown, not a stream fault, and
must not reach on_error (Activity Log would otherwise show a fake
"Live stream error" on every ordinary Stop)."""
errors = []
r = StreamReader(WedgedTransport(), parse_line,
r = StreamReader(WedgedTransport(), parse_payload,
on_frame=lambda f: None, on_error=errors.append)
r.start()
time.sleep(0.05)