Skip to content

fix(startup): bound the initial plugin update so the panel lights sooner - #456

Merged
ChuckBuilds merged 4 commits into
mainfrom
fix/bounded-initial-plugin-update
Aug 12, 2026
Merged

fix(startup): bound the initial plugin update so the panel lights sooner#456
ChuckBuilds merged 4 commits into
mainfrom
fix/bounded-initial-plugin-update

Conversation

@ChuckBuilds

@ChuckBuilds ChuckBuilds commented Aug 12, 2026

Copy link
Copy Markdown
Owner

What

DisplayController.__init__ calls _update_modules() once to populate plugin data before the first frame. It walks every loaded plugin in turn, and each update blocks the calling thread for up to the executor's 30s timeout — so the uncapped total is the sum of every slow plugin on the system.

The controller's own log, from three boots of the dev rig:

Initial plugin update completed in 82.255 seconds
Initial plugin update completed in 55.123 seconds
Initial plugin update completed in 25.975 seconds

The panel shows nothing for all of it.

Measured result

before:  82.255 s
after:   20.006 s      (16 plugins deferred to the update tick)

Why deferring is safe

A plugin that has never updated is immediately due, so run_scheduled_updates() collects it seconds later — with the display already running instead of blank. The deferred plugins are named in the log rather than silently dropped.

A deadline alone wasn't enough

It's checked before each plugin, so the last one to start could still block for the full 30s: a 20s budget produced a 31.8s pass on the rig. The remaining budget is now passed down as that update's timeout as well, with a floor (_MIN_INITIAL_UPDATE_TIMEOUT_SECONDS) so a plugin starting on the last sliver isn't handed ~0s and recorded as having timed out for a slot it never really had.

Scope — this is not the stutter

I found this while profiling the scroll freeze, and I want to be clear about what it is and isn't. py-spy caught the main thread 9.34s inside execute_with_timeout's join, and I initially described it as "a second freeze, longer than the first". It isn't: _update_modules has exactly one caller, in __init__, and runtime plugin updates already run off the display thread via the Vegas update tick. This is startup latency, not a recurring freeze.

The recurring freeze is fixed separately in ledmatrix-plugins#272 (map tiles fetched on the render thread).

Tests

13 tests in test/test_initial_update_budget.py: every plugin runs without a deadline; a passed deadline stops the pass; one slow plugin doesn't drag in the twenty behind it; the deadline is re-checked per plugin rather than once up front; the per-plugin timeout is capped by the remaining budget and never drops below the floor; the executor default is left alone when no deadline is given; deferred plugins are named in the log and nothing is logged when all of them ran; and no-plugin-manager / empty-plugin-set stay harmless.

2757 passed on the full suite. The one failure, test_install_lowmem.py::TestDiskBackedTmpdir, is pre-existing on main and environment-dependent.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

Summary by CodeRabbit

  • New Features
    • Added a startup screen showing “Initializing” and the device’s detected local IP address when available.
    • Improved startup banner readability with adaptive sizing, centered layout, and high-contrast text.
  • Bug Fixes
    • Limited initial plugin updates to prevent startup delays.
    • Deferred updates that exceed the startup time budget while allowing initialization to continue.
  • Tests
    • Added coverage for startup timing, deferred updates, address detection, and banner rendering across display sizes.

DisplayController.__init__ calls _update_modules() once to populate
plugin data before the first frame. It walks every loaded plugin in
turn, and each update blocks the calling thread for up to the executor's
30s timeout, so the uncapped total is the sum of every slow plugin on
the system. The rig's own log:

    Initial plugin update completed in 82.255 seconds
    Initial plugin update completed in 55.123 seconds
    Initial plugin update completed in 25.975 seconds

The panel shows nothing for all of it.

Nothing is lost by stopping early. A plugin that has never updated is
immediately due, so run_scheduled_updates() collects it seconds later --
with the display already running rather than blank.

A deadline alone was not enough: it is checked before each plugin, so
the last one to start could still block for the full 30s, and a 20s
budget produced a 31.8s pass on the rig. The remaining budget is now
passed down as that update's timeout too, with a floor so a plugin
starting on the last sliver is not handed ~0s and recorded as having
timed out for a slot it never had. Measured after: 20.006s.

Found while profiling a scroll freeze with py-spy, which caught the main
thread 9.34s inside execute_with_timeout's join. Worth being clear that
this is startup latency, not the recurring stutter -- _update_modules
has exactly one caller and runtime updates already run off the display
thread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ChuckBuilds, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 27 minutes

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

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 45043563-3095-4c0b-b06e-a2725ad4e8ea

📥 Commits

Reviewing files that changed from the base of the PR and between 610747d and 9c17e03.

📒 Files selected for processing (4)
  • src/display_controller.py
  • src/display_manager.py
  • test/test_initial_update_budget.py
  • test/test_initializing_screen.py
📝 Walkthrough

Walkthrough

The PR bounds initial plugin updates to a 20-second startup budget and adds deferred-plugin logging. It also detects a local IPv4 address and displays it in a centered, adaptive initialization banner.

Changes

Startup plugin update budget

Layer / File(s) Summary
Bounded initial plugin updates
src/display_controller.py, test/test_initial_update_budget.py
Initial plugin updates use a 20-second deadline, per-plugin timeout bounds, deferred-plugin logging, and tests for deadline and executor behavior.

Initializing screen banner

Layer / File(s) Summary
Local address and startup banner
src/display_manager.py, test/test_initializing_screen.py
The startup pattern detects non-loopback IPv4 addresses and renders centered, fitted white text over a masked background pattern. Tests cover lookup failures, panel sizes, placement, and readability.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • ChuckBuilds/LEDMatrix#395: Both PRs modify DisplayController plugin-update behavior, but this PR adds startup timeout budgeting while that PR addresses live-update notifications and recomposition.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: bounding the initial plugin update to reduce startup delay.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/bounded-initial-plugin-update

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 Aug 12, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 17 complexity · 0 duplication

Metric Results
Complexity 17
Duplication 0

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.

That screen is what the panel holds for the whole initial plugin update,
and on a headless Pi it is the only place the address appears without
going looking for it -- so it now carries the address under
"Initializing".

The lookup connects a UDP socket, which sends no packets: it only asks
the kernel which source address it would route from. That costs 0.03ms
and works with the network down so long as a route exists. Deliberately
not `hostname -I` plus a systemctl probe for AP mode, which is how the
web launcher does it -- two subprocesses with multi-second timeouts, on
the startup path this branch exists to shorten.

Two things had to change for the address to be worth putting there.

The text is now sized to fit rather than fixed at 8px: "Initializing" is
96px in PressStart2P, drawn at x=10, so it already ran off the side of a
64px panel before an address was added. It falls back to 4x6 where that
does not fit, and both lines are centred.

And the test pattern is punched out from behind the block, with the text
drawn white rather than blue. The diagonal runs through the middle of
the panel, which is exactly where this sits, and blue on black reads
fine on a monitor but is marginal on a dim panel. An address that cannot
be read off the wall is not worth showing.

The rendering tests assert against pixels -- no green left behind the
text at any supported size, enough lit pixels to be visible -- rather
than against the geometry that produced them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/display_controller.py`:
- Around line 867-876: Update the startup update flow around execute_update so a
plugin is deferred when the remaining deadline budget is below
_MIN_INITIAL_UPDATE_TIMEOUT_SECONDS; otherwise pass the exact remaining budget
as timeout without applying a minimum floor. Add a regression test covering a
plugin that begins with less than two seconds remaining and verifies it is
deferred.

In `@test/test_initializing_screen.py`:
- Line 104: Update the `_layout` assignment in the initializing-screen test to
bind the unused third return value as `_top` instead of `top`, while preserving
the existing `_font`, `widths`, and `bottom` bindings.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5d095d58-4328-417a-8fa5-bce85f36499b

📥 Commits

Reviewing files that changed from the base of the PR and between fce1fda and 610747d.

📒 Files selected for processing (4)
  • src/display_controller.py
  • src/display_manager.py
  • test/test_initial_update_budget.py
  • test/test_initializing_screen.py

Comment thread src/display_controller.py Outdated
Comment thread test/test_initializing_screen.py Outdated
ChuckBuilds and others added 2 commits August 12, 2026 14:34
The test pattern lights one pure channel per element: red border, green
diagonal, blue text. That is how a glance at the panel tells you whether
led_rgb_sequence is right -- wire it BGR and the border comes up blue
and the text red. Drawing the text white, as the previous commit did for
contrast, lights all three channels and destroys the only blue reference
on the screen.

Reverted to blue, with the reason written down so it is not treated as a
style preference again, and with tests that pin it: the text must be
pure blue, nothing on the screen may be white, and all three primaries
must be present.

The punched-out backdrop stays. It only removes the diagonal from behind
the glyphs, which costs nothing diagnostically -- the diagonal is still
plainly visible across the rest of the panel -- and it is what makes the
address readable at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
…p it

The per-plugin timeout was clamped up to a floor, so a plugin that began
with a sliver of budget left was granted the full floor and ran on past
the deadline: a 20s budget could take 22. The floor existed to stop a
plugin being handed a slot too short to use and then recorded as having
timed out, which is a real concern, but clamping solved it by breaking
the bound.

Deferring solves both. Below the floor the plugin is left to the update
tick, which was already the fate of everything after the deadline, so
nothing new is lost -- a plugin that has never updated is immediately
due. Above it, the timeout is the exact remainder, and the pass cannot
outlast its deadline.

Measured on the rig after the change: 20.002s, 5 plugins deferred.

Also names an unused binding in the initializing-screen test.

Both reported by CodeRabbit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
@ChuckBuilds
ChuckBuilds merged commit 9cf30bb into main Aug 12, 2026
8 of 9 checks passed
@ChuckBuilds
ChuckBuilds deleted the fix/bounded-initial-plugin-update branch August 12, 2026 18:58
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