feat(app): add the session supervisor - #282
Conversation
The one-shot CLI's process and its session have the same lifetime, so nothing ever had to answer "which session is this?". A long-lived host does: SessionManager owns at most one Session at a time and serialises every transition behind one lock — idle -> starting -> running -> stopping -> idle, with error as where a failed build parks its diagnostic. Build and teardown are injected, so the state machine is testable against fakes with no hardware, no sockets and no sleeping. The build seam is a publish callback rather than a bare return value: a build that fails after the stacks are up has already taken the hardware, and handing the session over the moment it exists keeps "no hardware is left held" on the single path out of a generation instead of splitting it across every build function. That is why a failed build routes through stopping on its way to error. Also here, because they only matter once a session outlives the command that started it: - A settle window between teardown and the next start. Two hardware facts, one timer: the U64's DMA service refuses new connections for a few seconds after one closes, and AVFoundation refuses to reopen a camera straight after release(). Re-armed after a marker recovery, which opens and closes a backend of its own. - A reap poller. A non-looping show ends by itself; the CLI notices because it is parked in a join, a daemon has nothing watching. - A run marker under the data dir, so a start that finds one knows the last run died mid-show and resets the machine first. - A bounded, generation-tagged log tail, so a failure to start can be read somewhere other than the terminal. start() never implicitly stops — replacing a show is switch(), so the one path that has to get stop -> settle -> start right is the one path that does it. Nothing runs it yet: no flag, no config key, no endpoint.
|
Hardware verification — 22 assertions over five generations against the real machine, driving the actual Transition log, one process, five generations in 41 seconds: The DMA socket was opened and closed five times in that window with no wedge, which is the settle timer doing its job. The marker-recovery case is the one that changed the code: safe-state opens and closes a backend of its own, and handing that straight to the build is exactly the socket-reuse hazard the cooldown exists for. A frame captured off HDMI mid-run confirms the C64 is actually rendering under the supervisor, not just reporting |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #282 +/- ##
==========================================
+ Coverage 82.18% 82.28% +0.09%
==========================================
Files 144 145 +1
Lines 25235 25566 +331
Branches 3705 3731 +26
==========================================
+ Hits 20739 21036 +297
- Misses 3689 3715 +26
- Partials 807 815 +8 ☔ View full report in Codecov by Harness. |
Plan step 3 of the web console: the state machine a long-lived host runs. No HTTP — that is the next step.
SessionManagerowns at most oneSessionat a time and serialises every transition behind one lock:erroris not sticky — it is where a failed build parks its diagnostic, and a machine that was unreachable a minute ago usually isn't now.Design points worth the review
Everything slow runs off the caller's thread.
build_sessionblocks for many seconds and teardown is not much cheaper, sostart()/stop()return as soon as the transition is claimed. What deliberately does not run off-thread is validation:validate_configsis hardware-free, so a bad config can be refused synchronously. That is what the split in #279 was for.The build seam is a
publishcallback, not just a return value. A build that fails after the stacks are up has already taken the hardware. Handing the session over the moment it exists keeps "no hardware is left held" on the single path out of a generation, rather than splitting it between the supervisor and every build function — which is why a failed build routes throughstoppingon its way toerror.start()never implicitly stops. Replacing a running show isswitch(), so the one path that has to get stop → settle → start right is the one path that does it;start()while running raisesSupervisorBusy(a 409 later).wait_for(state, generation=…)exists because "wait until running" is ambiguous across a switch — the show being replaced is running too.Plus the parts that only matter once a session outlives the command that started it: a settle window (the U64's DMA service refuses new connections for a few seconds after one closes, and AVFoundation refuses to reopen a camera straight after
release()— two facts, one timer, re-armed after a marker recovery since that opens a backend of its own), a reap poller for shows that end by themselves, a run marker that triggers a safe-state reset if the last run died mid-show, and a bounded generation-tagged log tail so a failure to start is readable somewhere other than the terminal.Supervisor threads are
daemon=False, unlike every other background thread here, for the same reason the playlist threads are: these are the threads that tear the hardware down.Verification
make checkgreen (ruff,mypy --strict—serve.pyjoins the strict list — pyright, 3895 tests),make site-checkOK. 29 new unit tests cover the transitions, the failed-build path, the cooldown, the reaper,switch, the run marker, safe-state recovery and the log buffer, all against injected fakes with no sleeping.Hardware verification against the real U64 — 22 assertions over five generations, driving the real
build_and_start/teardown, results in the PR thread.Not in this PR
--serve, the[web]config section and the/api/*routes are the next step.