mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
The start route held a request open for up to 45 s while a cold-started display loaded its plugins; the MQTT bridge (15 s timeout) and browsers reported a failure for a request that was then delivered. Now, when no display is listening, the route starts the service if asked and answers 202 with status "starting" at once. A single worker in the web process (web_interface/on_demand_dispatch.py) sends the request until the display acknowledges it or the wait runs out (45 s cold start, 10 s for a running service without a socket yet). A newer start supersedes the pending one; a stop cancels it (and succeeds, with cancelled_request_id, even with no display listening). The outcome is reported by /display/on-demand/status (source "web": starting, or error with start-timeout or the socket's reason, until the display publishes something newer) and by /display/current-status as on_demand_pending. Callers: the web UI's on-demand modal and "Preview on display" treat "starting" as taken (an info toast); the MQTT bridge already treats any non-error 2xx as success (now pinned by a test). Tests: the dispatcher (ack, retry then ack, start-timeout, other failures, superseded, an in-flight ack for a superseded start, stop while pending, a per-start wait, outcome lifetime); the routes (202, status routes while pending and after a timeout, a later display state replacing the failure, stop while pending, a new start superseding); a JS suite for app.js. Mutation check: 20 mutants on the worker, the routes and app.js, 20 killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
250 lines
8.8 KiB
Python
250 lines
8.8 KiB
Python
"""The web process's on-demand dispatcher (web_interface/on_demand_dispatch.py).
|
|
|
|
A start that finds no display listening -- the service was just started, or
|
|
is still loading its plugins -- is answered at once (202), and the
|
|
dispatcher's one worker thread sends it again until the display
|
|
acknowledges it or the wait runs out. These tests drive the worker with a
|
|
fake ``send`` and short waits:
|
|
|
|
* acknowledged: delivered once, and reported as such;
|
|
* nothing listening for the whole wait: ``start-timeout``;
|
|
* any other failure: reported at once, not retried;
|
|
* a newer start supersedes the pending one; a stop cancels it;
|
|
* the outcome is reported for a while, then forgotten.
|
|
"""
|
|
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from src.ipc import client as control_client
|
|
from web_interface import on_demand_dispatch
|
|
from web_interface.on_demand_dispatch import OnDemandDispatcher
|
|
|
|
|
|
def _not_listening():
|
|
return control_client.ControlError("no_socket", "x", sent=False)
|
|
|
|
|
|
def _payload(rid, plugin_id="weather"):
|
|
return {"request_id": rid, "action": "start", "plugin_id": plugin_id,
|
|
"mode": plugin_id, "duration": 30, "pinned": False}
|
|
|
|
|
|
class FakeSend:
|
|
"""Answers with ``outcomes`` in turn (an exception is raised, anything
|
|
else acks); the last one repeats. Records the request ids it was sent."""
|
|
|
|
def __init__(self, *outcomes):
|
|
self.outcomes = list(outcomes) or ["ack"]
|
|
self.sent = []
|
|
self.lock = threading.Lock()
|
|
|
|
def __call__(self, payload):
|
|
with self.lock:
|
|
self.sent.append(payload["request_id"])
|
|
outcome = self.outcomes.pop(0) if len(self.outcomes) > 1 else self.outcomes[0]
|
|
if isinstance(outcome, BaseException):
|
|
raise outcome
|
|
if callable(outcome):
|
|
return outcome(payload)
|
|
return {"accepted": True}
|
|
|
|
|
|
def _until(predicate, timeout=5.0):
|
|
end = time.monotonic() + timeout
|
|
while time.monotonic() < end:
|
|
if predicate():
|
|
return True
|
|
time.sleep(0.005)
|
|
return False
|
|
|
|
|
|
def _settled(d):
|
|
return _until(lambda: not d.pending() and d._thread is None)
|
|
|
|
|
|
@pytest.fixture
|
|
def make():
|
|
made = []
|
|
|
|
def build(send, wait_seconds=2.0, retry_interval=0.01):
|
|
d = OnDemandDispatcher(send, wait_seconds=wait_seconds, retry_interval=retry_interval)
|
|
made.append(d)
|
|
return d
|
|
yield build
|
|
for d in made:
|
|
d.cancel("teardown")
|
|
|
|
|
|
class TestDelivery:
|
|
def test_an_acknowledged_start_is_delivered_once(self, make):
|
|
send = FakeSend("ack")
|
|
d = make(send)
|
|
d.submit(_payload("r1"))
|
|
assert _settled(d)
|
|
assert send.sent == ["r1"]
|
|
status = d.status()
|
|
assert status["status"] == "delivered" and status["request_id"] == "r1"
|
|
assert status["source"] == "web"
|
|
|
|
def test_it_is_sent_again_until_the_display_listens(self, make):
|
|
send = FakeSend(_not_listening(), _not_listening(), "ack")
|
|
d = make(send)
|
|
d.submit(_payload("r1"))
|
|
assert _settled(d)
|
|
assert send.sent == ["r1", "r1", "r1"]
|
|
assert d.status()["status"] == "delivered"
|
|
|
|
def test_while_it_waits_it_reports_starting(self, make):
|
|
gate = threading.Event()
|
|
|
|
def blocked(payload):
|
|
gate.wait(5)
|
|
raise _not_listening()
|
|
|
|
send = FakeSend(blocked, "ack")
|
|
d = make(send)
|
|
d.submit(_payload("r1"))
|
|
status = d.status()
|
|
assert status["status"] == "starting" and status["active"] is False
|
|
assert (status["plugin_id"], status["mode"], status["duration"]) == ("weather", "weather", 30)
|
|
gate.set()
|
|
assert _settled(d)
|
|
assert d.status()["status"] == "delivered"
|
|
|
|
|
|
class TestGivingUp:
|
|
def test_nothing_listening_for_the_whole_wait_is_a_start_timeout(self, make):
|
|
send = FakeSend(_not_listening())
|
|
d = make(send, wait_seconds=0.2)
|
|
started = time.monotonic()
|
|
d.submit(_payload("r1"))
|
|
assert _settled(d)
|
|
waited = time.monotonic() - started
|
|
status = d.status()
|
|
assert status["status"] == "error" and status["error"] == "start-timeout"
|
|
assert 0.15 <= waited < 2.0
|
|
assert len(send.sent) > 3 # it kept trying in between
|
|
|
|
def test_a_start_can_carry_its_own_wait(self, make):
|
|
# The route passes a shorter wait for a service that was already
|
|
# running; the dispatcher's default must not override it.
|
|
d = make(FakeSend(_not_listening()), wait_seconds=30.0)
|
|
d.submit(_payload("r1"), wait_seconds=0.1)
|
|
assert _until(lambda: not d.pending(), timeout=3.0), "it waited the default"
|
|
assert d.status()["error"] == "start-timeout"
|
|
|
|
@pytest.mark.parametrize("reason,sent", [("busy", True), ("unknown_command", True),
|
|
("timeout", True), ("forbidden", False)])
|
|
def test_any_other_failure_is_reported_at_once(self, make, reason, sent):
|
|
send = FakeSend(control_client.ControlError(reason, "x", sent=sent))
|
|
d = make(send)
|
|
d.submit(_payload("r1"))
|
|
assert _settled(d)
|
|
assert send.sent == ["r1"]
|
|
status = d.status()
|
|
assert status["status"] == "error" and status["error"] == reason
|
|
|
|
def test_a_client_bug_is_internal(self, make):
|
|
d = make(FakeSend(RuntimeError("boom")))
|
|
d.submit(_payload("r1"))
|
|
assert _settled(d)
|
|
assert d.status()["error"] == "internal"
|
|
|
|
|
|
class TestOneAtATime:
|
|
def test_a_newer_start_supersedes_the_pending_one(self, make):
|
|
send = FakeSend(_not_listening())
|
|
d = make(send)
|
|
d.submit(_payload("old"))
|
|
assert _until(lambda: "old" in send.sent)
|
|
d.submit(_payload("new", plugin_id="clock"))
|
|
assert d.status()["request_id"] == "new"
|
|
n = len(send.sent)
|
|
send.outcomes = ["ack"]
|
|
assert _settled(d)
|
|
assert send.sent[n:] and set(send.sent[n + 1:]) <= {"new"}
|
|
assert send.sent[-1] == "new"
|
|
status = d.status()
|
|
assert status["status"] == "delivered" and status["plugin_id"] == "clock"
|
|
|
|
def test_an_answer_for_a_superseded_start_is_not_reported(self, make):
|
|
# The old start's send is in flight when the new one arrives; its
|
|
# ack must not mark the new one delivered.
|
|
in_flight, release = threading.Event(), threading.Event()
|
|
|
|
def slow(payload):
|
|
in_flight.set()
|
|
release.wait(5)
|
|
return {"accepted": True}
|
|
|
|
send = FakeSend(slow, _not_listening())
|
|
d = make(send, wait_seconds=0.3)
|
|
d.submit(_payload("old"))
|
|
assert in_flight.wait(5)
|
|
d.submit(_payload("new"))
|
|
release.set()
|
|
assert _settled(d)
|
|
status = d.status()
|
|
assert status["request_id"] == "new"
|
|
assert status["status"] == "error" and status["error"] == "start-timeout"
|
|
|
|
def test_a_stop_cancels_the_pending_start(self, make):
|
|
send = FakeSend(_not_listening())
|
|
d = make(send)
|
|
d.submit(_payload("r1"))
|
|
assert _until(lambda: send.sent)
|
|
assert d.cancel("requested-stop") == "r1"
|
|
assert _settled(d)
|
|
n = len(send.sent)
|
|
time.sleep(0.05)
|
|
assert len(send.sent) == n, "it kept sending a cancelled start"
|
|
status = d.status()
|
|
assert status["status"] == "idle" and status["last_event"] == "requested-stop"
|
|
|
|
def test_a_cancel_with_nothing_pending_does_nothing(self, make):
|
|
d = make(FakeSend("ack"))
|
|
assert d.cancel() is None
|
|
assert d.status() is None
|
|
|
|
def test_a_cancel_after_delivery_leaves_the_outcome(self, make):
|
|
d = make(FakeSend("ack"))
|
|
d.submit(_payload("r1"))
|
|
assert _settled(d)
|
|
assert d.cancel() is None
|
|
assert d.status()["status"] == "delivered"
|
|
|
|
|
|
class TestOutcomeLifetime:
|
|
def test_an_outcome_is_forgotten_after_a_while(self, make, monkeypatch):
|
|
d = make(FakeSend("ack"))
|
|
d.submit(_payload("r1"))
|
|
assert _settled(d)
|
|
assert d.status() is not None
|
|
monkeypatch.setattr(on_demand_dispatch, "OUTCOME_SECONDS", 0.0)
|
|
time.sleep(0.01)
|
|
assert d.status() is None
|
|
|
|
def test_a_pending_start_never_expires(self, make, monkeypatch):
|
|
monkeypatch.setattr(on_demand_dispatch, "OUTCOME_SECONDS", 0.0)
|
|
gate = threading.Event()
|
|
d = make(FakeSend(lambda p: gate.wait(5) and {"accepted": True}))
|
|
d.submit(_payload("r1"))
|
|
time.sleep(0.01)
|
|
assert d.status()["status"] == "starting"
|
|
gate.set()
|
|
assert _settled(d)
|
|
|
|
|
|
def test_the_process_has_one_dispatcher():
|
|
on_demand_dispatch.reset_for_tests()
|
|
try:
|
|
assert on_demand_dispatch.current() is None
|
|
first = on_demand_dispatch.get_dispatcher(FakeSend())
|
|
assert on_demand_dispatch.get_dispatcher(FakeSend()) is first
|
|
assert on_demand_dispatch.current() is first
|
|
finally:
|
|
on_demand_dispatch.reset_for_tests()
|