Files
LEDMatrix/docs/RUN_LOOP_REDESIGN.md
T
ChuckandClaude Opus 5.5 c6b064b220 fix(on-demand): show a named live mode; end a session that cannot resume
A request naming a *_live mode (football-scoreboard / ncaa_fb_live with
15 college games on) answered 200 and showed nfl_recent. The session's
mode list kept live modes only when has_live_content() said so, which is
the live-priority question and is answered for favourite teams only. A
mode the request names now leads the session; display() decides whether
it has anything to draw, and an empty one moves on to the plugin's next
mode as any empty on-demand mode does. The name is saved in
display_on_demand_config (named_mode) so a restart resumes on it. A bare
plugin-id request still skips quiet live modes, as before.

A restart during a session whose plugin then failed to load (clock-simple
failed config validation after a crash on ledpi) left the session active
with no modes and its cached request in place. It now ends at startup
with status error / restore-failed, and the cached request is dropped;
likewise when the plugin system fails to start.

Golden traces: two new scenarios (on_demand_named_live,
on_demand_restore_failed); every existing trace is unchanged. The
harness's restore_on_demand takes named_mode and logs a failed restore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:20:26 -04:00

18 KiB

Restructuring DisplayController.run()

run() in src/display_controller.py decides what the panel shows and runs it. This document is the plan for turning it from one long loop into three parts with clear jobs: an Arbiter that decides, a ScreenRunner that runs one screen, and Sources that each know about one kind of content. It covers the target design, the stages that get there, and how each stage is checked.

The goal is to change how the control flow is organised, not to move code into more files. Each stage ships as its own PR, and none of them changes what the panel shows unless that PR says so and updates the golden traces on purpose.

Why

  • The priority order is written in branch order, twice. It is Follower, on-demand, WiFi notice, live priority, Vegas, rotation. In run() that order exists only as the order of if blocks. Vegas repeats part of it in its interrupt callback (_check_vegas_interrupt).
  • Preemption is found by re-checking. A screen ends early when something else changed current_display_mode or is_display_active underneath it. run() notices with five separate current_display_mode != active_mode checks: after an empty pass, in each of the two frame loops, after the frame loops, and before rotating.
  • Most recent fixes were ordering bugs between these branches (#618, #644, #649, #652): a lost mode switch, rotating past an on-demand request, spinning when every mode is empty.
  • It could not be tested without threads, real sleeps and stopping the loop by raising from a patched method.

What run() does today

Each pass, in order:

  1. loop_pass() (watchdog). Apply a pending plugin enable/disable, then any plugin reloads the control socket asked for (_apply_pending_plugin_reloads; a pending reload ends the screen before it, like a WiFi notice, through _screen_preempted). The static screen's frame sleep and the dwell wait on the socket's queue instead of sleeping (_wait_frame_interval, _sleep_with_plugin_updates); without a socket, as in the golden traces, they are the plain sleeps.
  2. With no modes: dwell 1 s, next pass.
  3. Poll on-demand requests and expiry, release plugins loaded only for on-demand, tick plugin updates, drop an expired WiFi notice, evaluate the schedule (an on-demand session overrides scheduled-off), apply the brightness target. Then gather the Arbiter's inputs (_arbiter_inputs) and call Arbiter.decide(), which picks one of steps 4-6 or returns LEGACY for steps 7-9 (stage 2).
  4. Scheduled off: blank, dwell up to 60 s. _blank_while_scheduled_off
  5. Follower: render one frame from the leader. _run_follower_frame
  6. WiFi notice (unless on-demand): draw it, dwell 0.5 s. _show_wifi_notice. It is also polled mid-screen (_wifi_notice_pending): the frame loops, the dwell sleep and an interrupted Vegas iteration end within about a second when one arrives, and a screen cut short resumes after it.
  7. Live priority (unless on-demand, or Vegas keeps live content in the ticker): switch to the next live mode, or resume the rotation. A game that goes live during a screen is caught sooner, by _check_live_takeover in the frame loops and the dwell sleep (at most once a second, and not while a live mode is showing).
  8. Vegas (unless on-demand, or live content preempts it): run one iteration of up to max_cycle_duration. A completed iteration ends the pass, and so does one that yielded for a WiFi notice or the schedule. Any other interrupted one falls through to step 9 in the same pass.
  9. One screen: pick the mode (_resolve_active_mode), the plugin (_plugin_for_mode), draw the first frame through the executor (_dispatch_first_frame). On no content, rotate at once (_note_empty_pass, _skip_failed_plugin_modes). Otherwise work out the bounds (_track_dynamic_cycle, _resolve_durations, _clamp_to_on_demand) and the frame rate (_needs_high_fps), run the 125 Hz or 1 Hz frame loop, make up the minimum duration, then pick the next mode (_advance_after_screen).

The helpers named above were extracted in stage 1 without changing behaviour. Since stage 2 the choice between steps 4, 5, 6 and the rest is made by Arbiter.decide() in src/display_arbiter.py. The frame loops, the Vegas branch and every early exit are still inline in run().

Target design

def run(self):
    while True:
        inputs = self._drain_inputs()                 # requests, schedule, config, sync
        plan = self.arbiter.decide(self.state, inputs, clock.now())
        outcome = self.runner.run(plan)               # ExitReason + elapsed
        self.state = self.state.after(plan, outcome)  # rotation, on-demand index, live resume

Sources

Each kind of content is a Source. A Source looks at the state and the inputs and either offers a screen or passes. The Arbiter asks them in this order:

Order Source Offers a screen when Today
gate ScheduledOff the schedule is off and no on-demand session overrides it step 4
1 Follower a sync leader is driving this panel step 5
2 OnDemand a session is active (its mode list, index, expiry and pin) _resolve_active_mode
3 Wifi a status message is pending and on-demand is not active step 6
4 Live a live-priority plugin has live content (round-robin across several) step 7
5 Vegas Vegas is enabled and nothing above wants the panel step 8
6 Rotation always: available_modes[current_mode_index] step 9

ScheduledOff is a gate in front of the Sources because that is how it works today: a scheduled-off panel stays blank even for a follower, and only an on-demand session overrides it.

Arbiter

Arbiter.decide(state, inputs, now) -> ScreenPlan

decide is a pure function: it does no I/O, takes no locks and does not sleep. It can be tested with plain tables of (state, inputs, now) mapped to an expected plan. It returns a ScreenPlan:

Field Meaning
source which Source won
mode, plugin what to draw (None for a blank or follower plan)
min_duration, max_duration from _resolve_durations and _clamp_to_on_demand
dynamic run until the plugin's cycle completes, between min and max
frame_policy today _needs_high_fps (125 Hz or 1 Hz); see stage 5
preemptible_by the Sources allowed to interrupt this plan mid-screen

ScreenRunner

ScreenRunner(clock: FrameClock).run(plan) -> Outcome(exit_reason, elapsed)

The ScreenRunner draws the first frame (_dispatch_first_frame), runs the frame loop that the plan's frame policy selects, services pending changes between frames, and returns one ExitReason:

ExitReason Today's equivalent (golden-trace exit)
DURATION target duration reached (duration)
CYCLE_COMPLETE dynamic plugin finished after its minimum (cycle-complete)
EMPTY first frame returned False (empty; raised when display() raised inside the executor)
ERROR the dispatch itself raised (error)
DISPLAY_FALSE a later frame returned False (display-false)
PREEMPTED another Source took the panel (on-demand-*, schedule-off, vegas-interrupt, ...)

PREEMPTED replaces the five current_display_mode != active_mode checks. The runner asks the Arbiter, at the throttled service points it already has, whether a Source in plan.preemptible_by now wants the panel.

FrameClock provides now() and sleep(). In production it is time.monotonic/time.sleep. In the golden traces it is the fake clock that the harness patches in today.

Stages

Stage Change Behaviour change Verified by
1 Golden traces; extract helpers from run() none traces generated on main pass unchanged; mutation check
2 Arbiter with Follower and Wifi Sources none traces unchanged; Arbiter unit tables; ledpi smoke
3 ScreenRunner, FrameClock, ExitReason, PREEMPTED; OnDemand, Live, Rotation Sources none traces unchanged; ledpi frame soak A/B
4 Vegas as a Source driven by run_frame() none intended traces against the real coordinator; ledpi Vegas soak A/B
5 Plugins declare frame_policy DEBUG instead of INFO for the FPS line traces; soak on a static-heavy rotation

Stage 1 (#704)

  • test/_run_loop_harness.py builds a real DisplayController through __init__ on in-memory fakes (plugins, cache, config service, plugin manager, sync manager, display manager). It swaps the module's time and datetime for one fake clock and runs the real run() until a horizon. The first frame of each screen still goes through the real PluginExecutor and the per-plugin locks.
  • test/test_run_loop_golden.py has 15 scenarios, each compared with test/fixtures/run_loop_golden/<scenario>.json:
    • plain rotation (display_durations override, a high-FPS scroller, a plugin whose display() takes no display_mode)
    • empty modes and a mode with no plugin; an all-empty rotation (the 1 s pause)
    • plugin errors and the circuit breaker
    • dynamic duration (cycle complete, plugin cap, global cap)
    • live priority taking over and handing back; live round-robin
    • on-demand start/stop/expiry; pinned on-demand; a session resumed after a restart, and one that cannot resume (its plugin did not load); a request naming a live mode the plugin's live check would drop
    • schedule off and dim, with an on-demand override during downtime
    • WiFi notice; sync follower
    • Vegas, with and without live_in_ticker
  • Each trace row is [start, mode, duration, exit_reason, frames, force_clear]. The exit reason is the event that decided what came next.
  • All 18 tests run in under a second. The goldens were generated from main's run() before any code moved.
  • Vegas uses FakeVegas, which implements only the contract the controller depends on: run_iteration() returns True after its duration and False when the interrupt or live check asks it to yield, checking at the real coordinator's cadence. Running the real coordinator on the fake clock belongs to stage 4.
  • Twelve helpers were extracted from run() (listed under "What run() does today"). Breaking any one of them fails at least one golden trace.

Stage 2: Arbiter, starting with Follower and Wifi (done)

  1. Add ScreenPlan and an Arbiter with the ScheduledOff gate, Follower and Wifi. Every other case returns a LEGACY plan, which means "carry on with the existing code" (steps 7-9).
  2. run() calls decide() after the bookkeeping in step 3 and dispatches on plan.source: blank, _run_follower_frame(), the WiFi notice, or the existing path. Inputs that Sources read (follower active, the pending WiFi message, schedule state) are collected first, so decide() stays pure.
  3. Unit-test decide() with tables. The golden traces must not change. The Wifi Source must keep the mid-screen preemption described in step 6 of "What run() does today".

Follower and Wifi go first because each is one self-contained branch that ends the pass. They prove the plumbing without touching the frame loops.

What shipped:

  • src/display_arbiter.py (on the mypy ratchet) holds Source (SCHEDULED_OFF, FOLLOWER, WIFI, LEGACY), ArbiterInputs, ArbiterState, WifiNotice, ScreenPlan and Arbiter.decide. ScreenPlan has only the fields stage 2 uses: source, max_duration (60 s for the blank, 0.5 s for the notice, the constants run() used to hard-code) and notice. mode, plugin, the other durations, frame_policy and preemptible_by arrive with the Sources that need them.
  • ArbiterState is empty: no stage-2 Source remembers anything between passes. now is passed but not read, because the top-of-pass WiFi check never compared the expiry and must not start (the table pins this).
  • ArbiterInputs holds schedule_on, on_demand_active, follower_active and wifi_notice. _arbiter_inputs derives schedule_on as is_display_active and not on_demand_schedule_override, so the gate (blank when the schedule is off and no on-demand session overrides it) blanks exactly when is_display_active is False, as before, including #714's on-demand ending in off hours. It reads the WiFi notice only when the notice could win, because _check_wifi_status_message has side effects (its 1 Hz throttle, deleting an expired file) that those passes never had.
  • The mid-screen rule is wifi_notice_preempts(notice, on_demand, now), which _wifi_notice_pending calls; it does compare the expiry.
  • run() still calls _publish_current_mode_state_if_changed, _apply_pending_vegas_init and process_deferred_updates at the same points relative to the branches, so the order of side effects in a pass is unchanged.
  • test/test_display_arbiter.py: the 16-row table (every combination of the four inputs, written out), the mid-screen table, purity checks (no clock reads, nothing mutated, no I/O imports), and the controller's snapshot through an on-demand session that overrides the schedule and ends. A mutation run broke 23 pieces once each (the gate, the order, each Source, the dwells, the expiry comparison, the snapshot's reads, each dispatch in run()); every one failed a test.

Stage 3: ScreenRunner and PREEMPTED

Move the two frame loops, the make-up dwell and the dynamic-duration exit into ScreenRunner.run(plan) with an injected FrameClock. Replace the five re-checks with PREEMPTED. Add the OnDemand, Live and Rotation Sources so LEGACY is left meaning only Vegas.

Concretely, from where stage 2 left off:

  1. ArbiterState gains the rotation index, the on-demand mode list, index, expiry and pin, and the live resume point (today current_mode_index, on_demand_* and the live-priority stash). ArbiterInputs gains the live modes (_collect_live_modes) and whether Vegas is enabled and keeps live content in the ticker.
  2. OnDemand returns its current mode with _clamp_to_on_demand's bound, reading now for the expiry. Live returns the next live mode (round-robin). Rotation returns available_modes[current_mode_index]. ScreenPlan gains mode, plugin, min_duration, max_duration, dynamic, frame_policy and preemptible_by.
  3. ScreenRunner.run(plan) returns an ExitReason; state.after(plan, outcome) replaces _advance_after_screen and the live-resume bookkeeping. Each mid-screen check asks decide() whether a Source in plan.preemptible_by now wins, so _screen_preempted, _check_live_takeover and _wifi_notice_pending become one call.
  4. The control socket (_drain_control_commands, _wait_for_control) and state publishing stay where they are; the runner calls them at its service points.

This stage touches frame pacing (the 8 ms deadline sleep, the 1 ms yield), so it needs a frame soak on ledpi, A/B against main. Coordinate with whoever owns scroll performance (docs/SCROLL_PERFORMANCE.md).

Stage 4: Vegas as a Source

The controller calls coordinator.run_frame() once per frame from the ScreenRunner instead of handing over to run_iteration() for up to max_cycle_duration. The interrupt callback and the second copy of the priority order go away, because preemption becomes PREEMPTED. The vegas-plugin-tick thread that is spawned every 4 s becomes the controller's normal update tick. Extend the harness to drive the real coordinator on the fake clock, which means patching its time and running its prefetch inline. Verify with a Vegas soak on ledpi, A/B.

Stage 5: frame_policy

Plugins declare frame_policy (STATIC, PERIODIC(hz), ANIMATED(fps), SCROLL). _needs_high_fps becomes the mapping for legacy plugins (needs_high_fps, the static-image special case, enable_scrolling), and its per-screen INFO line drops to DEBUG. It is already read twice per screen: once quietly before the first frame, so _dispatch_first_frame can end the previous scroll for a screen that runs the 1 Hz loop (_start_screen_handover), and once after it to pick the loop. A declared policy answers both.

How each stage is verified

  • Golden traces. Run python -m pytest test/test_run_loop_golden.py; it takes about a second. A refactoring stage must leave every trace unchanged. A deliberate behaviour change regenerates them with LEDMATRIX_REGEN_GOLDEN=1 in its own commit, and the commit message explains each changed row. A new scenario's golden is generated against main's run() first, then checked against the branch.
  • Mutation check. Break each moved or new piece once, for example take max of the caps instead of min, or skip the live hold. At least one trace must fail each time. Stage 1 did this for all twelve helpers.
  • Full suite. Diff the FAILED/ERROR ids against a baseline run of main in a separate worktree. The Windows host has a stable set of pre-existing failures, so never compare against zero.
  • ledpi soak (stages 2-5). With the service running the branch: python3 scripts/frame_soak.py --preview for 10 minutes on a scrolling rotation, and on Vegas for stages 3-4. Alternate which build goes first. Compare late-frame rate and freezes with main. Also check by hand that on-demand start, stop and expiry, a live game taking over and handing back, and the schedule turning the panel off and on all behave as before.

Behaviour the traces pin down that may be wrong

Stage 1 recorded six behaviours as they were, each to be fixed in its own PR that updates the affected trace and explains why. All six are fixed:

  • A WiFi notice was only checked between screens, and Vegas yielded to one and then showed a rotation screen instead. Notices now preempt within about a second, and Vegas yields straight to them (#712; wifi_notice, vegas).
  • A live game only took over between screens, and Vegas yielded to one and then showed a rotation screen first. Games now take over within about a second, and Vegas yields straight to them (#713; live_priority, vegas).
  • An on-demand session that ended during scheduled-off kept the panel on until the next minute, and a schedule window's end minute counted as on only sometimes. Windows are now half-open [start, end), and the panel blanks as soon as on-demand ends in off hours (#714; schedule).

A new one found later goes the same way: record it here with the trace that shows it, then fix it in its own PR, not inside a restructure stage.