Summary
_poll_on_demand_requests reads the request through cache_manager.get('display_on_demand_request', max_age=3600). get_cached_data defaults the in-memory TTL to max_age, so the first request read is pinned in the display service's memory for an hour. Every later poll gets that stale entry, short-circuits on the already-processed check, and never re-reads the file the web service just wrote.
Net effect: after the first on-demand request, no further on-demand request is honoured for up to an hour, unless the display service restarts. /api/v3/display/on-demand/start still returns HTTP 200 with a request_id, so there is no sign of failure at the caller.
Why it's usually invisible
on-demand/start defaults to start_service: true, which restarts the display service and wipes the memory cache as a side effect. Passing start_service: false — the sensible choice when the service is already running, to avoid the ~40s boot splash — is what exposes it.
Reproduction
LEDMatrix v3.3.0-4-g0730d952, 256x64 rig, display service running throughout.
# request 1 — 17:45:47
POST /api/v3/display/on-demand/start {"plugin_id":"7-segment-clock","duration":90,"pinned":true,"start_service":false}
-> 200, request_id 179dfa66-...
journal: Received on-demand request 179dfa66-...: start
Requested on-demand mode '7-segment-clock' is not available # separate race, see below
# request 2 — 17:53:50, same session, no restart
POST /api/v3/display/on-demand/start {"plugin_id":"7-segment-clock","mode":"7-segment-clock","duration":75,"pinned":true,"start_service":false}
-> 200, request_id 80103dfb-...
Two and a half minutes later the second request had never been seen:
disk /var/cache/ledmatrix/display_on_demand_request.json -> request_id 80103dfb-... (17:53)
disk /var/cache/ledmatrix/display_on_demand_processed_id.json -> 179dfa66-... (17:45)
GET /api/v3/display/on-demand/status -> {"active": false, "status": "idle"}
journal: no mention of 80103dfb at all
Restarting the display service — changing nothing else — picked it up in 2 seconds and it activated fine:
$ sudo systemctl restart ledmatrix
17:56:23 INFO Received on-demand request 80103dfb-...: start (plugin_id=7-segment-clock, mode=7-segment-clock)
17:56:23 INFO Processing on-demand start request for plugin: 7-segment-clock
17:56:23 INFO Activated on-demand for plugin '7-segment-clock' with 1 modes: ['7-segment-clock']
17:56:38 INFO Rotating to next on-demand mode: 7-segment-clock (index 0/1)
processed_id.json -> 80103dfb-...
So the request itself was valid the whole time; only the stale in-memory copy stood between it and the controller.
Cause
src/display_controller.py:1270-1276:
def _poll_on_demand_requests(self) -> None:
"""Poll cache for new on-demand requests from external controllers."""
try:
# Use a long max_age (1 hour) to ensure requests aren't expired before processing
# The request_id check prevents duplicate processing
request = self.cache_manager.get('display_on_demand_request', max_age=3600)
src/cache_manager.py:299-320:
def get_cached_data(self, key: str, max_age: int = 300, memory_ttl: Optional[int] = None):
in_memory_ttl = memory_ttl if memory_ttl is not None else max_age
# 1) Memory cache
cached = self._memory_cache_component.get(key, max_age=in_memory_ttl)
if cached is not None:
return cached
# 2) Disk cache
...
The comment above the call explains that max_age=3600 is there so a disk record isn't considered expired before it's processed — a good reason. What it doesn't account for is that the same number silently becomes the memory TTL, which is the opposite of what a poll loop wants: this key is a mailbox written by another process, so it must be read from disk every time.
Suggested fix
Pass an explicit, short memory TTL (or zero) at the call site:
request = self.cache_manager.get('display_on_demand_request', max_age=3600, memory_ttl=0)
memory_ttl=0 should make _memory_cache_component.get(key, max_age=0) always miss; worth confirming that's how it behaves, otherwise bypass the memory layer for this key explicitly. The same question applies to any other cross-process mailbox key read through cache_manager.
It would also help if /display/on-demand/start didn't report success for a request that hasn't been accepted yet — the caller currently cannot distinguish "queued", "rejected", and "will be silently ignored for an hour".
Secondary, noticed alongside
Enabling a plugin and immediately issuing an on-demand request for it fails with Requested on-demand mode '<id>' is not available, because the display controller reconciles plugins on its own cadence — in this run the toggle landed at 17:45:4x and the reconcile at 17:46:36, ~49s later. The API returns 200 and the failure only surfaces as {"status": "error", "error": "invalid-mode"} in /on-demand/status. Not the same bug, but the same reporting gap.
Environment
LEDMatrix v3.3.0-4-g0730d952, Raspberry Pi, 256x64 panel, ledmatrix.service as root and ledmatrix-web.service as a normal user. Found while validating plugins on real hardware.
Summary
_poll_on_demand_requestsreads the request throughcache_manager.get('display_on_demand_request', max_age=3600).get_cached_datadefaults the in-memory TTL tomax_age, so the first request read is pinned in the display service's memory for an hour. Every later poll gets that stale entry, short-circuits on the already-processed check, and never re-reads the file the web service just wrote.Net effect: after the first on-demand request, no further on-demand request is honoured for up to an hour, unless the display service restarts.
/api/v3/display/on-demand/startstill returns HTTP 200 with arequest_id, so there is no sign of failure at the caller.Why it's usually invisible
on-demand/startdefaults tostart_service: true, which restarts the display service and wipes the memory cache as a side effect. Passingstart_service: false— the sensible choice when the service is already running, to avoid the ~40s boot splash — is what exposes it.Reproduction
LEDMatrix
v3.3.0-4-g0730d952, 256x64 rig, display service running throughout.Two and a half minutes later the second request had never been seen:
Restarting the display service — changing nothing else — picked it up in 2 seconds and it activated fine:
So the request itself was valid the whole time; only the stale in-memory copy stood between it and the controller.
Cause
src/display_controller.py:1270-1276:src/cache_manager.py:299-320:The comment above the call explains that
max_age=3600is there so a disk record isn't considered expired before it's processed — a good reason. What it doesn't account for is that the same number silently becomes the memory TTL, which is the opposite of what a poll loop wants: this key is a mailbox written by another process, so it must be read from disk every time.Suggested fix
Pass an explicit, short memory TTL (or zero) at the call site:
memory_ttl=0should make_memory_cache_component.get(key, max_age=0)always miss; worth confirming that's how it behaves, otherwise bypass the memory layer for this key explicitly. The same question applies to any other cross-process mailbox key read throughcache_manager.It would also help if
/display/on-demand/startdidn't report success for a request that hasn't been accepted yet — the caller currently cannot distinguish "queued", "rejected", and "will be silently ignored for an hour".Secondary, noticed alongside
Enabling a plugin and immediately issuing an on-demand request for it fails with
Requested on-demand mode '<id>' is not available, because the display controller reconciles plugins on its own cadence — in this run the toggle landed at 17:45:4x and the reconcile at 17:46:36, ~49s later. The API returns 200 and the failure only surfaces as{"status": "error", "error": "invalid-mode"}in/on-demand/status. Not the same bug, but the same reporting gap.Environment
LEDMatrix
v3.3.0-4-g0730d952, Raspberry Pi, 256x64 panel,ledmatrix.serviceas root andledmatrix-web.serviceas a normal user. Found while validating plugins on real hardware.