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()