fix(stream): payload-length cap, quiet-port resync, main-thread error teardown
This commit is contained in:
@@ -96,6 +96,45 @@ def test_leaving_live_mode_stops_stream(controller):
|
||||
assert not controller._repaint_timer.isActive()
|
||||
|
||||
|
||||
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."""
|
||||
fake_transport = MagicMock()
|
||||
fake_transport.read.return_value = b""
|
||||
monkeypatch.setattr(
|
||||
"pygui.serialcomm.serial_port.SerialPort.open_port",
|
||||
lambda *a, **k: fake_transport,
|
||||
)
|
||||
|
||||
controller.startStream("COM_FAKE", "A")
|
||||
try:
|
||||
assert len(controller._sensors) == 48 # sanity: aepwafer.yaml loaded
|
||||
parse = controller._reader._parse
|
||||
payload = bytes(244 * 2) # full-width wire payload, well beyond the real 48
|
||||
frame = parse(payload, seq=1)
|
||||
assert len(frame.values) == 48
|
||||
finally:
|
||||
controller.stopStream()
|
||||
|
||||
|
||||
def test_live_error_surfaces_message_and_stops_stream(controller):
|
||||
"""A fatal StreamReader error must reach QML with its message (for the
|
||||
Activity Log) and must tear the stream down cleanly."""
|
||||
controller.setMode("live")
|
||||
controller._reader = MagicMock()
|
||||
|
||||
error_seen = MagicMock()
|
||||
controller.liveError.connect(error_seen)
|
||||
|
||||
controller._on_live_error("Binary stream resync failed: no sync marker")
|
||||
|
||||
error_seen.assert_called_once_with("Binary stream resync failed: no sync marker")
|
||||
assert controller._reader is None
|
||||
|
||||
|
||||
def test_compare_files_integration(controller):
|
||||
# Mock the comparisonResult signal
|
||||
mock_slot = MagicMock()
|
||||
|
||||
+119
-1
@@ -1,7 +1,7 @@
|
||||
import time
|
||||
|
||||
from pygui.backend.models.frame import Frame
|
||||
from pygui.serialcomm.stream_reader import StreamReader
|
||||
from pygui.serialcomm.stream_reader import StreamReader, _crc16
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
@@ -47,3 +47,121 @@ def test_reads_frames_and_counts_errors():
|
||||
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,
|
||||
seq (LE), payload len (LE), payload, CRC16/MODBUS (LE) over the rest."""
|
||||
body = (b"\xaa\x88\x01" + seq.to_bytes(2, "little")
|
||||
+ len(payload).to_bytes(2, "little") + payload)
|
||||
return body + _crc16(body).to_bytes(2, "little")
|
||||
|
||||
|
||||
def parse_payload(payload: bytes, seq: int) -> Frame:
|
||||
return Frame(seq=seq, time=0.0, values=[float(b) for b in payload])
|
||||
|
||||
|
||||
def test_crc16_known_vector():
|
||||
# CRC16/MODBUS check value for "123456789" is 0x4B37.
|
||||
assert _crc16(b"123456789") == 0x4B37
|
||||
|
||||
|
||||
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,
|
||||
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
|
||||
|
||||
|
||||
def test_corrupt_crc_packet_dropped_and_stream_continues():
|
||||
got = []
|
||||
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)
|
||||
r.start()
|
||||
time.sleep(0.2)
|
||||
r.stop()
|
||||
assert [f.seq for f in got] == [2]
|
||||
assert r.error_count == 1
|
||||
|
||||
|
||||
def test_bogus_giant_payload_len_does_not_stall_the_stream():
|
||||
"""A false 0xAA 0x88 inside data yields a garbage payload_len (up to
|
||||
64KB). Without a sanity cap the reader waits forever accumulating a
|
||||
packet that doesn't exist — the 'frames stop, no error' symptom."""
|
||||
got = []
|
||||
# False sync + header claiming a 0xFFFF-byte payload, then a real packet.
|
||||
bogus = b"\xaa\x88\x01\x00\x00\xff\xff"
|
||||
data = bogus + make_packet(b"\x01\x02", seq=7)
|
||||
r = StreamReader(FakeTransport([data]), 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] == [7]
|
||||
|
||||
|
||||
def test_quiet_port_between_scans_is_not_a_resync_failure():
|
||||
"""Device pauses between measurement scans. Empty reads must not count
|
||||
toward the resync give-up cap (C#'s findMessageStart waits forever) —
|
||||
the old code died with TimeoutError after ~50 empty reads."""
|
||||
errors = []
|
||||
got = []
|
||||
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
|
||||
r.stop()
|
||||
assert [f.seq for f in got] == [1]
|
||||
assert errors == []
|
||||
|
||||
|
||||
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,
|
||||
on_frame=lambda f: None, on_error=errors.append)
|
||||
r.start()
|
||||
time.sleep(0.05)
|
||||
r.stop()
|
||||
time.sleep(0.05) # let the worker thread's except-handler run
|
||||
assert errors == []
|
||||
|
||||
Reference in New Issue
Block a user