mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-16 03:49:13 +00:00
feat(ipc)!: remove the cache-key mailboxes, control socket stage 5 (for the release after 3.8.1) (#773)
* feat(ipc)!: remove the cache-key mailboxes (control socket stage 5) The control socket is now the only way the web interface sends the display a command. The display stops reading display_on_demand_request and plugin_error_clear_request, and the web interface stops writing them. - Display: no mailbox poll (MailboxWatch, the 1 s / 0.25 s cadence, _consume_on_demand_request, the deprecation log) and no persisted display_on_demand_processed_id guard; the error publisher reads no clear request. CacheManager.file_signature and MailboxWatch are removed. - A write to either retired key is dropped by CacheManager.save_cache and logged once per writer, naming the plugin from the call stack (or the request's plugin_id), with the API to move to. - Web: on-demand start with no display listening starts the service (when start_service) and sends the request again once the socket answers (45 s, 10 s for a running service without a socket yet); every other failure is a 503 (400 for invalid_args). Stop answers 503 when no display listens, unless stop_service. errors/clear answers 503 with a reason-specific message instead of writing a request; clear_pending is always false. src.ipc.client.should_fall_back is replaced by display_not_listening. - Kept: display_current_state, display_on_demand_state, plugin_runtime_snapshot and the heartbeat (read whenever the socket cannot answer), and display_on_demand_config (the display's resume record). Tests: mailbox-only tests removed (test_on_demand_mailbox.py, the mailbox cadence, file_signature and MailboxWatch tests); tests that injected requests through the mailbox now use the socket queue or a plugin's in-process request. The run-loop harness sends on-demand requests over its fake control socket, so four golden traces change: on-demand starts and stops land at the request instant instead of the next 0.25 s mailbox look (one frame fewer on the screen they end), and in vegas.json within one frame instead of 263 ms, which shifts the later 1 s-throttled WiFi-notice check by under a second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the on-demand routes' other callers ack over the socket, not the mailbox test_api_v3_bool_coercion, test_api_v3_lazy_plugin_discovery and test_web_api's stop test checked the mailbox write; they now get an ack from a mocked control socket. The stop route touches no manager any more, so it leaves the removed-catch-all sample. A request that was sent is never 'not listening' (pins the sent check the mutation run let through). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): a start that waits for the display answers 202 and is delivered in the background 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> * fix(web): a cancelled on-demand start never reaches the display after its replacement CI caught a race in test_a_new_start_supersedes_the_pending_one: the dispatcher's worker could read the old start, the route cancel it and send the new one, and the worker's send of the old one land after it. cancel() now waits out a send already in flight (the worker holds a send lock while it reads the pending start and sends it), so whatever the caller sends next lands after it. Pinned by test_a_cancel_waits_for_a_send_in_flight, which fails without the wait. The Linux-only TestRealSocket test for a display that went away now expects the 202 and the start-timeout that follows, as the route answers since the background dispatcher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * 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> * fix(display): on_demand_request_id has a class default for controllers built without __init__ _on_demand_state now publishes it, and test_state_stream_readers builds controllers with __new__. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the case-spelling on-demand test patches _send_on_demand now the mailbox is gone Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs: blank line before 3.8.7 heading --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+60
-34
@@ -466,7 +466,7 @@ Request a specific plugin to display on-demand.
|
||||
- `mode` (string, optional): Display mode name (plugin_id inferred if not provided)
|
||||
- `duration` (number, optional): Duration in seconds (0 = until stopped)
|
||||
- `pinned` (boolean, optional): Pin display (pause rotation)
|
||||
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within a frame over its control socket (within about a second through the mailbox fallback). When false and the service is stopped, the route returns 400.
|
||||
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within a frame over its control socket. A stopped one is started, and the route answers `202` at once (see below); the request is sent once the display's socket is up, which can take as long as the display takes to load its plugins. When false and the service is stopped, the route returns 400.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
@@ -486,24 +486,54 @@ Request a specific plugin to display on-demand.
|
||||
|
||||
`service` is `null` when `start_service` is false.
|
||||
|
||||
`transport` says how the request reached the display: `"socket"` means the
|
||||
display's control socket acknowledged it (it is queued for the render thread,
|
||||
which wakes for it and applies it within a frame; see
|
||||
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), `"mailbox"` means it was
|
||||
written to the cache mailbox the display polls, as before the socket existed.
|
||||
The mailbox is used only when the socket could not carry the request. With
|
||||
`"mailbox"`, `socket_error` gives the reason (`no_socket` when the display is
|
||||
stopped or predates the socket, `refused`, a connect `timeout`,
|
||||
`unknown_command` from a display too old for the command, ...). Either way
|
||||
the request is applied the same way; `request_id` is the same id in both.
|
||||
`transport` is always `"socket"`: the display's control socket acknowledged
|
||||
the request (it is queued for the render thread, which wakes for it and
|
||||
applies it within a frame; see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)).
|
||||
The `"mailbox"` value earlier releases could answer is gone with the
|
||||
mailbox: nothing is written to the cache.
|
||||
|
||||
When the display had the request and did not take it -- a full queue
|
||||
(`busy`), bad arguments (`invalid_args`), no answer after the request was
|
||||
sent (`timeout`, `closed`) -- the route answers `503` (`400` for
|
||||
`invalid_args`) with `status: "error"` and `data: {request_id, transport:
|
||||
"socket", socket_error}`, and writes nothing to the mailbox. The stop route
|
||||
does the same, except that with `stop_service: true` it still stops the
|
||||
service and answers success.
|
||||
**No display listening yet** (`no_socket`, `refused`: the service is
|
||||
stopped, or still loading its plugins). With the service stopped and
|
||||
`start_service` false, `400`. Otherwise the route starts the service if
|
||||
needed and answers at once:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "starting",
|
||||
"message": "The display service is starting; ...",
|
||||
"data": {"request_id": "uuid-here", "plugin_id": "football-scoreboard", "mode": "nfl_live",
|
||||
"duration": 45, "pinned": true, "service": {"active": true, "started": true},
|
||||
"transport": "socket", "socket_error": "no_socket",
|
||||
"pending": true, "wait_seconds": 45.0}
|
||||
}
|
||||
```
|
||||
|
||||
with HTTP `202`. The web process sends the request until the display takes
|
||||
it, for up to `wait_seconds` (45 after a cold start, 10 when the service was
|
||||
already running). Follow it with `GET /api/v3/display/on-demand/status`:
|
||||
its `state` is `{status: "starting", source: "web", request_id, ...}` while
|
||||
it waits, and still `starting` with `delivered: true` once the display has
|
||||
acknowledged it but not yet published the state for that `request_id` (at
|
||||
most 30 s), then the display's own state, or `{status: "error",
|
||||
error: "start-timeout"}` (or the socket's reason) if it never was;
|
||||
`GET /api/v3/display/current-status` carries the same as
|
||||
`on_demand_pending`. A newer start replaces a pending one; a stop cancels it.
|
||||
|
||||
Otherwise, when the display did not take the request, the route answers an
|
||||
error with `status: "error"` and `data: {request_id, transport: "socket",
|
||||
socket_error}`:
|
||||
|
||||
- a full queue (`busy`), no answer after the request was sent (`timeout`,
|
||||
`closed`), a display older than the command (`unknown_command`), no
|
||||
socket in the web process (`disabled`, `unsupported`): `503` at once;
|
||||
- bad arguments (`invalid_args`): `400`.
|
||||
|
||||
The stop route answers the same errors (`503` when no display is
|
||||
listening, with a message saying whether the service is stopped), except
|
||||
that with `stop_service: true` it still stops the service and answers
|
||||
success, with `socket_error` set, and that a stop which cancelled a pending
|
||||
start succeeds with `cancelled_request_id` even when no display is
|
||||
listening.
|
||||
|
||||
### Stop On-Demand Display
|
||||
|
||||
@@ -533,7 +563,7 @@ Stop the current on-demand display.
|
||||
}
|
||||
```
|
||||
|
||||
`transport` and `socket_error` are as for start.
|
||||
`transport` and the errors are as for start; a stop is never retried.
|
||||
|
||||
---
|
||||
|
||||
@@ -2199,7 +2229,7 @@ Every response below adds three fields to the shape it always had:
|
||||
|---|---|
|
||||
| `snapshot_available` | `false` until the display service has reported (for example, it is not running). Counts are then zero. |
|
||||
| `generated_at` | When the display service produced the snapshot (ISO, the Pi's local time), or `null`. |
|
||||
| `clear_pending` | A clear has been requested and the display service has not applied it yet. |
|
||||
| `clear_pending` | Always `false`: a clear is applied before its route answers. Kept for compatibility. |
|
||||
|
||||
### Get Error Summary
|
||||
|
||||
@@ -2280,21 +2310,17 @@ before it answers: `applied` is `true`, `transport` is `"socket"`, and
|
||||
}
|
||||
```
|
||||
|
||||
When the socket cannot carry it (the display is stopped, or older than
|
||||
`errors.clear`) the clear is asynchronous, as before: the web interface
|
||||
records a request (`plugin_error_clear_request` in the shared cache),
|
||||
`applied` is `false` and `transport` is `"mailbox"`, and the display service
|
||||
applies it within about 5 seconds. Reads hide the cleared errors from the
|
||||
moment the request is recorded. Until the display service applies an
|
||||
age-based clear, `recent_errors` and `active_patterns` are already filtered
|
||||
but the counts are the old ones, and `clear_pending` is `true`. Then
|
||||
`cleared_count` is how many of the reported errors the clear hides, and
|
||||
`null` when that cannot be known before the display service applies it (an
|
||||
age-based clear over more errors than the report lists).
|
||||
`clear_requested`, `applied` and `transport` are always `true`, `true` and
|
||||
`"socket"`, and are kept for compatibility.
|
||||
|
||||
A request that could not be written to the shared cache answers `500`. A
|
||||
display that had the request and failed it (`internal`, a timeout after the
|
||||
request was sent) answers `503`, with `context.socket_error`.
|
||||
When the socket cannot carry the clear, the route answers `503` with
|
||||
`context.socket_error`, and nothing is cleared or recorded: the
|
||||
`plugin_error_clear_request` mailbox earlier releases fell back to is gone.
|
||||
The message says why: the display service is not running (its errors are
|
||||
then the last run's, and its next run starts with none), it is too old for
|
||||
`errors.clear` (restart it), the web process has no socket, or the display
|
||||
had the request and failed it (`internal`, a timeout after the request was
|
||||
sent).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user