Skip to content

fix(web): plugin action params, refused on-demand starts, pending-operation 500, double-click 409, binary static files - #744

Merged
ChuckBuilds merged 6 commits into
mainfrom
fix/web-api-robustness
Oct 4, 2026
Merged

ChuckBuilds merged 6 commits into
mainfrom
fix/web-api-robustness

Conversation

@ChuckBuilds

Copy link
Copy Markdown
Owner

Summary

Five web API bugs, one commit each, plus one refinement.

  1. Plugin actions failed whenever params held true, false or null (plugins.py).
    • Cause: the route wrote json.dumps(params) into generated Python source (params = {...}), where true is an undefined name. The wrapper died with a NameError.
    • Impact: the of-the-day plugin's category toggle sends {"enabled": true} and always failed.
    • Fix: params now reach the wrapper on its stdin. Nothing from the request is written into source. The plugin script's own stdin contract is unchanged.
  2. A refused on-demand start ran later anyway.
    • Cause: the request was written to the mailbox before the route checked the service. The display reads the mailbox for an hour without checking a request's age. So a 400 "not running" (or a failed start's 500) still launched the plugin, pinned if asked, when the display next started.
    • Fix: on those refusals the request is taken back out of the mailbox, only if it is still this request_id.
    • Socket acknowledgement: this is the display itself answering, so it is now a success whatever systemd says. A hand-run or emulator display used to get a 400 for a request that had worked. With start_service on, the route also stops trying to start a second display beside it.
  3. Polling a pending plugin operation answered 500.
    • Cause: to_dict() serialized the queued _callback.
    • Fix: private (_-prefixed) parameters are left out. The operation keeps its callback.
  4. A second Install or Uninstall click answered 500, and the uninstall recorded a false "failed" history entry. It is now a 409 PLUGIN_OPERATION_CONFLICT with no history record.
  5. The plugin static route answered 500 for any binary file.
    • Cause: every file was read as UTF-8 text.
    • Fix: files go out with send_file. The path containment checks are unchanged.
    • Changes for clients: .svg is now image/svg+xml, and text is no longer newline-normalised.

Tests

  • New: test_api_v3_plugin_action_params.py (8), test_api_v3_on_demand_restart.py::TestARefusedStartLeavesNoRequestBehind (6), test_api_v3_operation_status_pending.py (4), test_api_v3_plugin_operation_conflict.py (3), test_api_v3_plugin_static_files.py (10). Each fails on main.
  • Related suites: 432 passed, 3 skipped.

On ledpi: the of-the-day toggle-category action with "enabled": true returned success; on main that call dies with a NameError. An on-demand start was acknowledged over the socket, and current-status showed it.

Part of a bug sweep

This is one of 10 independent fix PRs from one sweep, all based on main ef69201.

  • Merge order: any. 45 pairwise test merges gave 0 conflicts, and each PR also merges cleanly with fix(ipc): ticks carry the volatile timestamps, so current-status stays known over the socket #737.
  • CHANGELOG: each PR adds its bullet at a different place in Unreleased → Fixes, so squash-merging them one after another needs no conflict fixing.
  • Full suite (Windows), all 10 merged together vs plain main: the same 62 failures and 6 errors on both. These are the known Windows path and file-locking tests. 191 more tests pass.
    • One extra failure in that run, test_backup_manager.py::test_create_backup_contents (os.replace → WinError 5 on a temp zip), was a Windows file-lock flake. It passes on rerun, and nothing here touches create_backup.
  • ledpi: all 10 together ran on ledpi (Pi 4) on top of main, with a clean start and no errors or render stalls in the journal. ledpi is back on plain main.

🤖 Generated with Claude Code

ChuckBuilds and others added 6 commits October 3, 2026 20:50
POST /api/v3/plugins/action runs a plugin's script through a generated
Python wrapper, and the params went into that wrapper's source as
`params = <json.dumps(params)>`. JSON true, false and null are undefined
names in Python, so any params holding one made the wrapper die with a
NameError before the script ran, and the route answered "Action failed".
The plugin file manager's category toggle sends {"category_name": ...,
"enabled": true}, so of-the-day's category toggle failed every time.

The wrapper now reads the params from its own stdin (json.loads) and the
route passes them there; nothing taken from the request is written into
the generated source any more. The script's side is unchanged: the same
json.dumps(params) on its stdin, LEDMATRIX_ROOT set, stdout parsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /api/v3/display/on-demand/start delivered the request (control
socket, else the file mailbox) before it checked the display service.
With the service stopped the socket is absent, so the request went to the
mailbox; the route then answered 400 "Display service is not running"
when start_service was off, or 500 "Failed to start display service" when
the start failed. The display reads that mailbox with max_age=3600 and
never checks a request's timestamp, so the next time it was started it
ran the refused request, pinned if asked.

The service is now checked before anything is delivered, and nothing is
posted when start_service is off and the service is down. When the start
itself fails, the request is withdrawn from the mailbox, but only while
the mailbox still holds this request_id (the compare-before-delete the
display's _consume_on_demand_request uses), so a newer request posted in
the meantime is left for the display.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PluginOperationQueue.enqueue_operation stores the operation's callback in
operation.parameters['_callback'], and the worker pops it only when it
runs the operation. PluginOperation.to_dict() returned parameters as they
were, so GET /api/v3/plugins/operation/<id> for an operation still
waiting in the queue (an install queued behind another plugin's) handed
jsonify a function and answered 500 "A system error occurred" on every
poll until the worker reached it.

to_dict() now leaves out parameters whose name starts with "_". The
operation itself keeps its callback for the worker; every other field of
the answer, and the operation-history records (a different class), are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PluginOperationQueue.enqueue_operation raises ValueError when the plugin
already has an operation waiting or running. /plugins/install did not
catch it, so a double-clicked Install (the button is never disabled)
answered 500 "An error occurred; see logs for details" from the
blueprint's catch-all while the first install carried on.
/plugins/uninstall caught it in its own catch-all: a 500 "Failed to
uninstall plugin", plus an "uninstall failed" operation-history record
for an uninstall that never started.

Both routes now enqueue through _enqueue_or_conflict, which turns the
queue's refusal into a 409 PLUGIN_OPERATION_CONFLICT naming the plugin,
and records nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /api/v3/plugins/<plugin_id>/static/<path> read every file with
open(..., 'r', encoding='utf-8') and returned the decoded text, so any
binary file -- a plugin icon or preview image, which is what the REST API
reference says the route is for -- raised UnicodeDecodeError and answered
500.

The file is now sent with send_file, as bytes. HTML, JavaScript, CSS and
JSON keep the content types the route always set, and other text keeps
text/plain; anything else gets the type mimetypes knows it by (image/png
for a .png). The plugin id and path validation and the resolve_under
containment check are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cdaeb38 checked the systemd unit before delivering the on-demand
request, so a display run by hand or in the emulator (no active unit)
with start_service off now got nothing, where before the request went
over the control socket and took effect behind a 400. A socket
acknowledgement is the display itself saying it is running and has the
request queued, so it is the better witness than systemd.

The request is delivered first again. When the display acknowledged it
over the socket, the route answers success without consulting systemd for
the "not running" 400 and without starting the unit (with start_service
on it tried to start a second display beside the one that answered); the
service is still reported the way _ensure_display_service_running reports
a running one. When it went to the mailbox, the 400 (service down,
start_service off) and the failed-start 500 both withdraw this request_id
from the mailbox, leaving a newer request alone, so neither refusal runs
later.

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

coderabbitai Bot commented Oct 4, 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 29 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: 37e69b19-4ab8-4508-b76e-83f0033937c5
📥 Commits

Reviewing files that changed from the base of the PR and between ef69201 and 59889fa.

📒 Files selected for processing (11)
  • CHANGELOG.md
  • src/plugin_system/operation_types.py
  • test/test_api_v3_on_demand_restart.py
  • test/test_api_v3_operation_status_pending.py
  • test/test_api_v3_plugin_action_params.py
  • test/test_api_v3_plugin_operation_conflict.py
  • test/test_api_v3_plugin_static_files.py
  • web_interface/blueprints/api_v3/display.py
  • web_interface/blueprints/api_v3/plugin_assets.py
  • web_interface/blueprints/api_v3/plugin_store.py
  • web_interface/blueprints/api_v3/plugins.py
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 12 complexity

Metric Results
Complexity 12

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.

@ChuckBuilds
ChuckBuilds merged commit 5ad5e9a into main Oct 4, 2026
15 checks passed
@ChuckBuilds
ChuckBuilds deleted the fix/web-api-robustness branch October 4, 2026 02:30
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.

1 participant