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:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user