mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 15:25:08 +00:00
fix(web): a delivered on-demand start reads as starting until the display acts on it
On ledpi (three cold starts) the display acknowledged the start as its socket opened, then took ~5 s to act on it while Vegas built its first strip; the status routes meanwhile showed the display's own idle state, so a UI polling every 700 ms flashed idle. The display's on-demand state now names the request it answers (request_id). A delivered start keeps reading as status "starting" with delivered: true, in /display/on-demand/status and as on_demand_pending in /display/current-status, until the display publishes state for that request id (a display without the field: any state newer than the delivery), for at most DELIVERED_SHOWN_SECONDS (30 s). The display's startup state, which can be published after the acknowledgement, names no request and does not end it. Tests: stays starting against the startup idle state (no id, an older id); the matching active state and the matching error take over; an older display's newer state takes over; the 30 s cap; the display's state names its request. Mutation check: 11 mutants, 11 killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,17 +47,43 @@ def _dispatcher():
|
||||
|
||||
|
||||
def _pending_start_state():
|
||||
"""A start the dispatcher is still delivering, or one it gave up on:
|
||||
the state the status routes report instead of the display's. None when
|
||||
there is none (or it was delivered, after which the display's own
|
||||
state is the truth)."""
|
||||
"""A start the dispatcher is still delivering, one it delivered but the
|
||||
display has not acted on yet, or one it gave up on: what the status
|
||||
routes report instead of the display's own state (see _shadows). None
|
||||
when there is none.
|
||||
|
||||
A delivered start reads as ``status: "starting"`` with ``delivered:
|
||||
true`` for at most DELIVERED_SHOWN_SECONDS: the display acknowledges it
|
||||
when its socket opens and acts on it seconds later, and until then
|
||||
publishes its own idle state, which would flash in the UI.
|
||||
"""
|
||||
dispatcher = on_demand_dispatch.current()
|
||||
status = dispatcher.status() if dispatcher is not None else None
|
||||
if status is None or status.get('status') not in ('starting', 'error'):
|
||||
if status is None:
|
||||
return None
|
||||
if status.get('status') == 'delivered':
|
||||
delivered_at = status.get('last_updated') or 0
|
||||
if _pkg.time.time() - delivered_at > on_demand_dispatch.DELIVERED_SHOWN_SECONDS:
|
||||
return None
|
||||
return dict(status, status='starting', delivered=True, delivered_at=delivered_at)
|
||||
if status.get('status') not in ('starting', 'error'):
|
||||
return None
|
||||
return status
|
||||
|
||||
|
||||
def _display_on_demand_state(snapshot):
|
||||
"""The display's own on-demand state: (state, source). From the socket's
|
||||
snapshot, else the cache key it also writes; state None when neither."""
|
||||
state = display_state.on_demand_state(snapshot)
|
||||
if state is not None:
|
||||
return state, 'socket'
|
||||
# memory_ttl=0: the display service writes this key, so only the file
|
||||
# is current. This process's memory tier would keep serving the first
|
||||
# copy it read for the full max_age -- "active" for two minutes after
|
||||
# the display had already stopped.
|
||||
return _cache_manager().get('display_on_demand_state', max_age=120, memory_ttl=0), 'cache'
|
||||
|
||||
|
||||
def _send_on_demand(payload):
|
||||
"""Hand an on-demand request to the display over the control socket.
|
||||
|
||||
@@ -200,20 +226,12 @@ def get_on_demand_status():
|
||||
available (``source: "socket"``), else the cache key it also writes
|
||||
(``source: "cache"``).
|
||||
"""
|
||||
state = display_state.on_demand_state(display_state.read_state())
|
||||
source = 'socket'
|
||||
state, source = _display_on_demand_state(display_state.read_state())
|
||||
pending = _pending_start_state()
|
||||
if state is None:
|
||||
source = 'cache'
|
||||
cache = _cache_manager()
|
||||
# memory_ttl=0: the display service writes this key, so only the file
|
||||
# is current. This process's memory tier would keep serving the first
|
||||
# copy it read for the full max_age -- "active" for two minutes after
|
||||
# the display had already stopped.
|
||||
state = cache.get('display_on_demand_state', max_age=120, memory_ttl=0)
|
||||
if pending is not None and _shadows(pending, state):
|
||||
# A start the web process is still delivering, or gave up on
|
||||
# (start-timeout): newer than anything the display has said.
|
||||
# A start the web process is still delivering, has delivered but
|
||||
# the display has not answered yet, or gave up on (start-timeout):
|
||||
# newer than anything the display has said.
|
||||
state, source = pending, 'web'
|
||||
if state is None:
|
||||
state = {
|
||||
@@ -231,11 +249,24 @@ def get_on_demand_status():
|
||||
}
|
||||
})
|
||||
def _shadows(pending, state):
|
||||
"""Whether the web process's pending start (or its failure) is newer
|
||||
than the display's on-demand ``state``. While it is still being sent it
|
||||
always is; a failure is, until the display publishes something later."""
|
||||
if pending.get('status') == 'starting' or not isinstance(state, dict):
|
||||
"""Whether the web process's start (or its failure) is newer than the
|
||||
display's on-demand ``state``.
|
||||
|
||||
* Still being sent: always.
|
||||
* Delivered: until the display publishes the state that answers it.
|
||||
A display that names its request (``request_id``) answers when the
|
||||
id matches; its startup state, published as the socket opens and so
|
||||
possibly after the acknowledgement, names no request or an older one
|
||||
and does not count. An older display without the field answers with
|
||||
any state published after the delivery.
|
||||
* Failed: until the display publishes something later.
|
||||
"""
|
||||
if not isinstance(state, dict):
|
||||
return True
|
||||
if pending.get('status') == 'starting' and not pending.get('delivered'):
|
||||
return True
|
||||
if pending.get('delivered') and 'request_id' in state:
|
||||
return state.get('request_id') != pending.get('request_id')
|
||||
shown = state.get('last_updated')
|
||||
if not isinstance(shown, (int, float)) or isinstance(shown, bool):
|
||||
return True
|
||||
@@ -482,8 +513,9 @@ def get_current_display_status():
|
||||
}
|
||||
data = dict(state, source=source)
|
||||
pending = _pending_start_state()
|
||||
if pending is not None:
|
||||
# An on-demand start the web process is still delivering (or gave
|
||||
# up on): what the panel is about to show, or why it will not.
|
||||
if pending is not None and _shadows(pending, _display_on_demand_state(snapshot)[0]):
|
||||
# An on-demand start the web process is still delivering, has
|
||||
# delivered but the display has not acted on yet, or gave up on:
|
||||
# what the panel is about to show, or why it will not.
|
||||
data['on_demand_pending'] = pending
|
||||
return jsonify({'status': 'success', 'data': data})
|
||||
|
||||
@@ -44,6 +44,14 @@ RETRY_INTERVAL = 0.5
|
||||
#: reported, so a client polling every few seconds sees it.
|
||||
OUTCOME_SECONDS = 120.0
|
||||
|
||||
#: How long a delivered start is still reported as ``starting`` while the
|
||||
#: display has not published the state that answers it. The display
|
||||
#: acknowledges a start when its socket opens, but its run loop acts on it
|
||||
#: only after its first screen is built (about 5 s on a Pi 4 in Vegas), and
|
||||
#: until then it reports its own idle state. See
|
||||
#: ``web_interface/blueprints/api_v3/display.py:_pending_start_state``.
|
||||
DELIVERED_SHOWN_SECONDS = 30.0
|
||||
|
||||
#: ``send(payload)`` hands the request to the display (the route's
|
||||
#: ``_send_on_demand``) and raises ``ControlError`` when it does not take it.
|
||||
Sender = Callable[[Dict[str, Any]], Any]
|
||||
|
||||
Reference in New Issue
Block a user