镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

feat(ipc): display control socket, stage 1 - on-demand with acks - #706

Merged
ChuckBuilds merged 3 commits into
mainfrom
claude/ipc-control-socket
Oct 1, 2026
Merged

ChuckBuilds merged 3 commits into
mainfrom
claude/ipc-control-socket

Conversation

@ChuckBuilds

Copy link
Copy Markdown
Owner

Stage 1 of roadmap theme T2: the display process serves a control socket, and the web interface uses it for on-demand start and stop. Commands get an acknowledgement. The file mailbox stays as the fallback for one release.

Full write-up: docs/IPC_CONTROL_SOCKET.md (protocol, security model, stage plan).

What changes

  • src/ipc/contract.py: the typed, versioned messages. Standard library only; both processes import it.
    • Envelopes: request {v, id, cmd, args}, response {v, id, ok, result | error{code, message}}.
    • Commands: hello (version negotiation), ping, on_demand.start (plugin_id/mode/duration/pinned), on_demand.stop, on_demand.status.
    • Framing: newline-delimited JSON, at most 64 KiB per message.
    • Error codes and the socket path rules.
  • src/ipc/server.py (display side): a threaded server on /run/ledmatrix/control.sock.
    • Handlers only validate a command, put it on a bounded queue and ack it with {accepted, request_id}. on_demand.status answers from a read-only snapshot.
    • Limits: at most 8 connections, I/O timeouts, a 5 s deadline per message and a 10 s idle limit. Garbage gets an answer, an oversize message closes the connection, and a client that hangs up mid-message is dropped. No handler exception escapes.
    • It binds under a temporary name and renames into place with the final mode and group. It removes a stale socket, but never a live one or a file that is not a socket.
    • It is skipped cleanly on Windows, when there is no AF_UNIX, or with LEDMATRIX_CONTROL_SOCKET=off. A non-root dev run uses a private per-user path.
  • src/ipc/client.py (web side): one request with a 1 s total budget. Any failure raises ControlError(reason).
  • api_v3/display.py: on-demand/start and /stop try the socket first. On any failure they write the mailbox exactly as before.
    • The response gains transport: "socket" | "mailbox", plus socket_error on a fallback.
    • Both paths carry the same request_id, so a request that arrives both ways is processed once.
  • display_controller.py: kept small, because claude/run-loop-stage1 is restructuring run(). The changes:
    • run() gets one line: _start_control_server().
    • cleanup() closes the server.
    • The body of _poll_on_demand_requests is extracted, unchanged, into _handle_on_demand_request(request). The poll drains the socket queue into that same handler first, so there is no duplicated activation code.
    • _service_pending_changes skips its 0.25 s floor while a socket command is queued.
    • _on_demand_state() is factored out of _publish_on_demand_state so the status command can reuse it.
  • No unit or installer change. /run/ledmatrix already comes from feat(display): systemd watchdog and heartbeat for a frozen render loop #687's RuntimeDirectory=. Under an older unit the display creates it as root, as it already does for the heartbeat.
  • test/conftest.py: an autouse fixture sets LEDMATRIX_CONTROL_SOCKET=off. A suite run on a device therefore never serves or drives the live display's socket.
  • Also: the mypy ratchet gains the four src/ipc modules; CHANGELOG entry; ARCHITECTURE, PERMISSIONS, REST_API_REFERENCE and docs index updated.

Security model

  • Directory. /run/ledmatrix: root-owned, 0755, tmpfs, removed when the display stops.
  • Socket file. root:<shared group>, mode 0660, so the kernel refuses connect() to anyone else.
    • The shared group is the cache directory's group when that directory is group-writable. That is ledmatrix on an installed device, and it is the same rule DiskCache uses for every file the two services share.
    • Otherwise it is the project directory's group (get_shared_group_gid()).
    • With neither, the mode is 0600.
  • Peer credentials. On Linux, SO_PEERCRED is checked as well. Allowed: root, the display's own user, or a member of the group (primary gid, or a supplementary group from /proc/<pid>/status, falling back to the group database). Anyone else gets forbidden. This also covers a socket mode loosened by hand.
  • What a connected client can do. Only start, stop or read on-demand, which anyone who can reach the web UI can already do. Nothing runs a shell or names a path.

Verification

Windows unit tests (this host): the new and adjacent tests give 256 passed and 31 skipped (the skips are the Linux-only socket tests).

WSL Ubuntu 24.04 (Python 3.12), real sockets:

  • test_ipc_contract, test_ipc_server, test_ipc_display_hook and test_api_v3_on_demand_socket give 172 passed as an ordinary user and 172 passed as root.
    • One test skips in each run: the root-only test as the user, and the "outsider is refused" test as root, since root may always connect.
    • The root-only test forks children that drop to nobody:
      • in the socket's group: connects;
      • out of the group: the kernel refuses connect() (EACCES);
      • mode loosened to 0666 by hand: the outsider still gets forbidden.
  • Mutation check. Each of these 11 mutations was applied alone and made at least one test fail, both as the user and as root:
    • socket mode 0666;
    • peer check off;
    • no queue drain in the poll;
    • no floor bypass;
    • no mailbox fallback;
    • mailbox also written on socket success;
    • unbounded frame buffer;
    • queue bound ignored;
    • stale-socket probe that clobbers a live socket;
    • close() unlinking any file;
    • wrong request_id handed to the handler.
  • End to end, in the deployed shape: run.py -e as root (bundled web-ui-info plugin), and the real Flask routes as the user chuck, who is a supplementary member of the stand-in shared group users (/var/cache/ledmatrix set to root:users 2775).
    • The socket was created as srw-rw---- root users /run/ledmatrix/control.sock, logged as "Control socket listening ... (mode 660, group 100)".
    • From chuck, hello, ping and status all answered. status reported current_mode: web_ui_info.
    • Over the socket, three start/stop rounds each answered transport: "socket" in 1.6-1.8 ms of HTTP. Each start was active 0.41-0.44 s later and each stop idle 1.0 s later.
    • With the socket off, the same rounds went through the mailbox: active 0.41-0.48 s later, idle 1.0 s later. The 1.0 s is the static screen's 1 Hz frame loop, the same for both paths; the docs list it as a stage-2 item.
    • nobody was refused at connect() (EACCES → refused).
    • After SIGTERM to the display, the socket file was gone. The client then reported no_socket, and the route answered transport: "mailbox" with socket_error: "no_socket", with the request in the mailbox.
    • After restarting the display, the same web-side code connected again on its next request (one connection per request, so no reconnect logic is needed).

Full suite (Windows, pytest -m "not hardware" test/ --ignore=test/plugins), branch against a baseline worktree of origin/main: baseline origin/main (f4bda50) gave 59 failed, 6934 passed, 143 skipped, 6 errors; this branch gave 59 failed, 7077 passed, 174 skipped, 6 errors. The sorted FAILED/ERROR test IDs are identical (71 lines each, 0 differences). The failures are this host's known Windows-only ones. The extra passes and skips are the new tests; the skips are the Linux-only socket tests. plugin-repos/ was untouched after the run.

mypy ratchet (scripts/check_types.py, mypy 1.20.2): no issues in 82 modules, the 4 new ones included. flake8 (pre-commit selection) and bandit are clean on the new files.

Checks needed on ledpi (not run; the rigs were not touched)

  • Permissions. After deploying and restarting both services, check ls -l /run/ledmatrix/control.sock; it should show srw-rw---- root ledmatrix. Check id ledpi; it should include ledmatrix. journalctl -u ledmatrix | grep "Control socket" should show listening ... (mode 660, group <ledmatrix gid>).
  • The web user's permissions. Run sudo -u ledpi python3 -c 'from src.ipc import client; print(client.ping())' from the project directory, and the same as another account (sudo -u nobody ...), which should be refused. Then click on-demand in the UI; the response should show "transport": "socket".
  • Latency, socket vs mailbox. Time start→active and stop→idle from POST /api/v3/display/on-demand/start|stop, reading display_on_demand_state. Do it three ways:
    • on a scrolling or Vegas screen (the socket should be about one frame; the mailbox up to 0.25 s);
    • on a static screen (both about 1 s);
    • with LEDMATRIX_CONTROL_SOCKET=off in a drop-in for ledmatrix-web.service, for the mailbox numbers.
  • Display restart while the web is up. Run sudo systemctl restart ledmatrix and, during the restart, POST on-demand start. Expect transport: "mailbox", socket_error: "no_socket", and the request picked up from the mailbox once the display is back. After it is up, expect transport: "socket" again without restarting the web service.
  • No frame cost. Run python3 scripts/frame_soak.py --preview against main. The server threads sleep in accept(), and the render thread adds one Event.is_set() per on-demand check.

🤖 Generated with Claude Code

The display process now serves a Unix socket, /run/ledmatrix/control.sock,
carrying versioned newline-delimited JSON commands that are acknowledged.
Stage 1 moves on-demand start/stop (plus status, hello and ping) onto it;
the cache-file mailbox stays as the fallback for one release.

- src/ipc/contract.py: typed request/response envelopes, command args,
  error codes, NDJSON framing with a 64 KiB limit, socket path rules.
- src/ipc/server.py: threaded server owned by the display. Handlers only
  queue onto a bounded queue and ack with the request id; the render
  thread drains it where it reads the mailbox. Bounded clients, timeouts,
  garbage/oversize/disconnect handling; 0660 socket in the cache dir's
  group plus SO_PEERCRED checks; skips cleanly on Windows or when off.
- src/ipc/client.py: one short-timeout request; any failure raises
  ControlError(reason).
- api_v3/display.py: on-demand start/stop try the socket, fall back to
  the mailbox exactly as before, and report transport/socket_error.
- display_controller.py: start/close the server; the mailbox handler body
  is extracted into _handle_on_demand_request and shared by both paths.
- docs/IPC_CONTROL_SOCKET.md: protocol, security model, stage plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 17 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 8c1f63d2-870e-4817-9a1b-c266357aa710

📥 Commits

Reviewing files that changed from the base of the PR and between 74696d2 and 7ab1e05.

📒 Files selected for processing (18)
  • CHANGELOG.md
  • docs/ARCHITECTURE.md
  • docs/IPC_CONTROL_SOCKET.md
  • docs/PERMISSIONS.md
  • docs/README.md
  • docs/REST_API_REFERENCE.md
  • mypy-clean.txt
  • src/display_controller.py
  • src/ipc/__init__.py
  • src/ipc/client.py
  • src/ipc/contract.py
  • src/ipc/server.py
  • test/conftest.py
  • test/test_api_v3_on_demand_socket.py
  • test/test_ipc_contract.py
  • test/test_ipc_display_hook.py
  • test/test_ipc_server.py
  • web_interface/blueprints/api_v3/display.py
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

codacy-production Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 291 complexity

Metric Results
Complexity 291

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

The on-demand routes returned ControlError.reason verbatim. Every reason the
client raises is a fixed code, but the display's error code arrives over the
socket, so map the value onto the known set (transport reasons plus
ErrorCode) and report anything else as "other". Resolves CodeQL
py/stack-trace-exposure on the on-demand stop/start responses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ChuckBuilds

Copy link
Copy Markdown
Owner Author

Hardware check on ledpi (Pi 4, 192x48, 2026-10-01)

Branch af8dc394, compared against its merge-base f4bda507 ("main"). Older unit file (no RuntimeDirectory=), so the display created /run/ledmatrix itself.

1. Permissions: PASS. srw-rw---- root ledmatrix /run/ledmatrix/control.sock, logged as listening ... (mode 660, group 1001). ledpi is in ledmatrix (1001).

2. Access: PASS, with one gap. As ledpi, hello, ping and status all answered. In the UI flow, every start and stop answered "transport": "socket". There is no second account and no root, so I could not run a real outsider. Instead, a ControlServer from the branch ran as ledpi and took real kernel SO_PEERCRED credentials:

  • With own_uid forced to 0 and group 0: forbidden, with the log line Control socket refused pid … (uid 1000, gid 1000): not root, this user or group 0.
  • With the same forced uid and group 1001: allowed, through the supplementary group read from /proc.
  • With the socket at mode 000 or 006: the kernel refused connect() for its owner (refused, EACCES).

Not run: an actual uid outside the group against the live socket.

3. Latency: no gain on this rig. Times run from the POST to display_on_demand_state changing. 8 rounds each, medians given.

screen socket (branch) start→active / stop→idle mailbox (main) start→active / stop→idle
static rotation 1.021 s / 0.413 s 1.022 s / 0.415 s
Vegas 0.387 s (steady rounds 0.29 s; range 0.29–1.80) / 0.398 s 0.292 s / 0.397 s

On Vegas the socket is no faster. The journal shows Control socket accepted and then Received on-demand request 0.3–0.5 s later. The command waits for the Vegas interrupt check, which runs every 10 frames (check_interval=10, commented "~80ms at 125 FPS"). This rig's Vegas presents about 24 frames/s, so 10 frames is about 0.4 s, and that bounds both paths. Static screens are bound by the 1 Hz loop, as the PR says. I measured the mailbox on main because LEDMATRIX_CONTROL_SOCKET=off cannot be set without editing the unit.

4. Display restart with the web up: PASS.

  • The socket was gone about 100 ms after the restart began.
  • A start POSTed during the restart answered transport: mailbox, socket_error: no_socket.
  • The new display listened 5 s later and took the mailbox request at once: Activated 9 ms after listening.
  • The next stop and start went back to transport: socket with no web restart.

5. Frame cost: PASS. frame_soak.py --preview, Vegas on, 5 min each, in the order B M M B:

run frames late late % freezes
branch 7219 2 0.028 0
main 7220 5 0.069 0
main 7220 0 0.000 0
branch 7222 7 0.097 0

That is 9 late frames out of 14,441 on the branch and 5 out of 14,440 on main. Every run is under the 0.1% limit, and the spread between the two main runs (0 to 5) is as large as the gap between builds. There were no new journal warnings or errors.

🤖 Generated with Claude Code

# Conflicts:
#	CHANGELOG.md
#	docs/README.md
@ChuckBuilds
ChuckBuilds merged commit 695ff92 into main Oct 1, 2026
13 checks passed
@ChuckBuilds
ChuckBuilds deleted the claude/ipc-control-socket branch October 1, 2026 14:33
ChuckBuilds added a commit that referenced this pull request Oct 1, 2026
#703, #705, #706 and #693 merged after this branch was cut; their CHANGELOG
entries now sit under 3.8.0. The summary and New modules list name them
(sports consolidation stage 4's four modules, src/ipc, field_model), stage
4's section says to floor on 3.8.0, and src/common/README.md marks its four
modules 3.8.0 instead of Unreleased.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ChuckBuilds added a commit that referenced this pull request Oct 1, 2026
* chore(deprecation): remove the 35 APIs deprecated for 3.8.0

The usage scan (docs/DEPRECATIONS_3.8.md, regenerated 2026-10-01 and
committed here) finds no call or override of any of them in the 46
monorepo plugins or the 8 third-party plugins plugins.json lists; the
only core callers were other deprecated methods removed alongside.

- CacheManager: 13 methods, plus the private helpers only
  has_data_changed used (_has_*_changed, _is_market_open).
- DisplayManager: 7 methods, plus WEATHER_COLORS and the private
  _draw_sun/_cloud/_rain/_snow/_storm helpers only the icon methods used.
- FontManager: 14 methods, plus size_tokens, _save_overrides and
  _clear_plugin_font_cache. font_overrides and _load_overrides stay:
  resolve_font() still applies config/font_overrides.json.
  performance_stats stays: get_font() keeps it and tests read it.
- PluginManager.get_enabled_plugins.

test_deprecation.py pins only the two 3.9.0 markers now; the scanner
tests run against a stand-in core instead of the real markers. The
memory-tier tests read stats through log_memory_cache_stats() and the
component, and the test of the removed _clear_plugin_font_cache goes.
Docs drop the removed methods' reference entries; the Deprecated APIs
table becomes "Removed in 3.8.0". CHANGELOG gains a Removed section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(deprecation): drop the test harness's copies of the removed icon methods

VisualTestDisplayManager still drew weather icons that DisplayManager no
longer has, so a plugin's visual tests could pass on calls that raise
AttributeError on the real display. Its draw_sun/draw_cloud/draw_rain/
draw_snow/draw_weather_icon/draw_text_with_icons, WEATHER_COLORS and the
private helpers go, with the tests that exercised them. The CHANGELOG's
Deprecations entries no longer say nothing is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: prepare the 3.8.0 release

Bumps src.__version__ to 3.8.0 and turns Unreleased into ## 3.8.0, with a
summary and a New modules list (vegas_elements, testing.vegas, sports_vegas;
display_watchdog, plugin_catalog, plugin_runtime) for plugins flooring on
3.8.0. Adds the CHANGELOG line #701's second commit lacked (blocks laid out
off the render thread).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: 3.8.0 also ships what landed on main since the prep

#703, #705, #706 and #693 merged after this branch was cut; their CHANGELOG
entries now sit under 3.8.0. The summary and New modules list name them
(sports consolidation stage 4's four modules, src/ipc, field_model), stage
4's section says to floor on 3.8.0, and src/common/README.md marks its four
modules 3.8.0 instead of Unreleased.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants