mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-14 23:28:05 +00:00
fix: keep callback faults out of the frame-decode fallback
Review follow-up on the previous two commits. Widening the control-message except tuple put the callback dispatch inside it, so an _on_new_cycle() that raised ValueError, TypeError or AttributeError sent a perfectly good control packet to the legacy PNG decoder -- which reported it as an image decode error and buried the real fault. Split the two: whether the payload parses as JSON decides frame vs control message, a second guard covers reading the fields of an attacker-shaped body, and the callback fires outside both. It still cannot kill the receive thread; the loop's own handler catches it, and now says what actually went wrong. The logo download's temp file was a fixed "<name>.part". Two plugins asking for the same logo at once would interleave writes into it, publish the mixture, or delete each other's partial. mkstemp gives each download its own name in the same directory, so os.replace stays atomic. Its descriptor is adopted by fdopen before the request runs, since a request that raises before the write would otherwise leak the fd -- quietly, because load_logo_with_download swallows that. Two test fixes: the oversized-frame test replaced PIL.Image.open process-wide, the same hazard the clock helper documents, and Ruff B007 on an unused loop variable. Full suite: 3355 passed, coverage 54%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
This commit is contained in:
@@ -511,6 +511,28 @@ class TestFollowerRecvLoop:
|
||||
assert mgr.get_latest_scroll_x() is None
|
||||
sleeps.assert_not_called()
|
||||
|
||||
def test_callback_failure_is_not_mistaken_for_a_malformed_packet(self, monkeypatch):
|
||||
# A payload that parses is a control message, full stop. If the
|
||||
# callback it triggers raises one of the types the field guard
|
||||
# catches, that fault belongs to the callback: it must not send
|
||||
# the packet to the image decoder, which would report it as a
|
||||
# decode error and bury the real cause. The loop still survives
|
||||
# it — the outer handler catches it like any other fault.
|
||||
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||
mgr._follower_state = FollowerState.FOLLOWER
|
||||
|
||||
def boom():
|
||||
raise ValueError("callback is broken")
|
||||
|
||||
mgr._on_new_cycle = boom
|
||||
fake_clock(monkeypatch, sleep_fn=MagicMock())
|
||||
self._drive(mgr, json.dumps({"t": "nc"}).encode())
|
||||
|
||||
logged = " | ".join(str(c) for c in mgr.logger.debug.call_args_list)
|
||||
assert "callback is broken" in logged
|
||||
assert "frame decode error" not in logged
|
||||
assert "malformed control message" not in logged
|
||||
|
||||
def test_oversized_legacy_frame_is_rejected_before_decode(self, monkeypatch):
|
||||
# The UDP path is reachable by any host on the LAN, so it caps
|
||||
# dimensions before load() just as the TCP image server does.
|
||||
@@ -522,7 +544,16 @@ class TestFollowerRecvLoop:
|
||||
def load(self):
|
||||
raise AssertionError("load() must not run past the cap")
|
||||
|
||||
monkeypatch.setattr(sync_manager.Image, "open", lambda *a, **kw: Huge())
|
||||
# Rebind the module's reference rather than mutating PIL.Image
|
||||
# itself, which would hand Huge() to every caller in the process
|
||||
# — including daemon threads earlier tests left running. Same
|
||||
# reasoning as fake_clock above. The other names the receive loop
|
||||
# reads off this reference pass through to the real module.
|
||||
monkeypatch.setattr(sync_manager, "Image", SimpleNamespace(
|
||||
open=lambda *a, **kw: Huge(),
|
||||
frombuffer=Image.frombuffer,
|
||||
DecompressionBombError=Image.DecompressionBombError,
|
||||
))
|
||||
self._drive(mgr, b"\x89PNG not really but not JSON either")
|
||||
assert mgr.get_latest_frame() is None
|
||||
|
||||
@@ -895,7 +926,7 @@ class TestRealSocketHandshake:
|
||||
leader = follower = None
|
||||
# The free-port probe is inherently racy — the port can be taken
|
||||
# between release and rebind — so retry rather than fail on it.
|
||||
for attempt in range(5):
|
||||
for _attempt in range(5):
|
||||
# Probed on loopback: this only needs a port number, and the
|
||||
# manager's own bind is what has to succeed. If the port turns
|
||||
# out to be taken on another interface, the retry below covers
|
||||
|
||||
Reference in New Issue
Block a user