§7e.8 Acceptance criteria
To be copied into 14-acceptance-criteria/ when the milestone is
scheduled.
- Bootstrap. Starting
loco-serverfrom a clean install creates the supervisord groupsloco(withscripts-executorper §7d) anddcc-bus(empty until a session selects a command station). Nodcc-bus-*process exists inpsuntil step 3. - Session-driven spawn. A driver logs in, picks a layout with
one attached command station, opens the throttle overlay and
selects that command station from the dropdown. Within 10 s a
dcc-bus-<layoutId>-<commandStationId>process is RUNNING insupervisorctl statusAND aloco-serverWS eventsession.commandStationChanged { wsUrl: "ws://…" }reaches the client. - Direct throttle write. Moving the slider with no other client
connected produces a single
loco.setSpeedon the daemon's WS followed by aloco.statepush within one poll interval. The DCC bus carries the matchingXPN_SET_LOCO_DRIVE(Z21) orOPC_LOCO_SPD(LocoNet) packet — verifiable with the existing command-station test harness. - External throttle convergence. A physical throttle on the same
command station changes the speed of a subscribed loco. The
browser slider converges to the external value within one poll
interval (=
--poll-interval, default 200 ms). - Two tabs, two daemons converge. The same user opens two
browser tabs to the same layout and the same command station. A
slider move on tab A appears on tab B within one round trip; the
daemon reports two distinct
sessionIds on Redisdcc-bus:<L>:<C>:sessions. SIGTERMtoloco-serverstops every daemon. SendingSIGTERMtoloco-servercausesSupervisordService.Stopto shut down everydcc-bus-*program (each drains its WS clients per §7e.2). After 30 s of grace,pslists nodcc-busand nosupervisordprocesses owned by the operator UID.- Daemon crash isolation.
kill -9 $(pgrep -f dcc-bus-1-2)terminates only the affected daemon. supervisord respawns it withinstartsecs(1 s) due toautorestart=true; throttles connected to it receiveloco.error { code:"command_station_disconnected" }then reconnect onRUNNING. Throttles on other command stations stay live throughout. - Authorization re-check. A lessee drives a vehicle through the
daemon. The owner revokes the lease via REST. The next
loco.setSpeedfrom the lessee returnsloco.error { code:"not_authorized_to_drive" }; the lessee's slider freezes. Verifiable because the daemon does receives an updatedallowed_vehiclessnapshot with the owner back incontrollerUserIdsand denies the lessee withnot_authorized. - Takeover propagation. A signalman issues
takeover.requestonloco-server's WS; after the 15 s window the granted takeover causes the affected driver's WS subscription ondcc-busto flip toloco.state { controlledBy: { kind:"signalman" } }, and any subsequent driverloco.setSpeedis denied withtaken_over. The signalman's own throttle on the same dcc-bus can now drive. - Dead-man's switch on the daemon. The driver locks the
phone; after
--heartbeat-grace(default 5 s) the daemon emitssession.warningand, on no recovery, runsSetSpeed(0)on every drive target. Vehicles on a different command station of the same user keep running (per-daemon scope). - Dead-man's switch on
loco-server. With theloco-serverWS dropping (e.g. network glitch) but the daemon WS still alive, the dead-man's switch on the daemon does not fire; howeverScriptService.StopAllForUserruns as before becauseloco-server's emergency path completed (existing §4.5). - Script DCC writes still gated. A user's running script calls
setSpeed. The path is:scripts-executor → loco-server → LocoServiceDriver → DccBusService.PublishCommand → dcc-bus → DCC bus. Authorization is enforced inloco-serverAND re-enforced indcc-bus. If the user's lease has just expired, both denials fire and the script receives the samenot_authorized_to_driveerror it would have received pre-daemon. - Train fan-out.
train.setSpeedon the dcc-bus data-plane WS fans out to every powered, non-excluded member on the picked command station; leading multiplier forced to1.0, non-leading members scaled byspeedMultiplier,Reversedflip applied; aggregateacklists per-member outcomes (§4.2). - Train member timing. With
startDelayMs,accelRampMs, orbrakeRampMsset on a member,train.setSpeedmay defer writes viaTrainSpeedScheduler(start delay, acceleration/braking ramps). A new slider move cancels pending ramps for that train. Accordion headers in the Throttle show per-memberloco.state.speedwhile ramps run (§6.3a, §7e.4.1). - Roster invalidation. Adding a vehicle to the layout's
roster (
POST /api/v1/layouts/{id}/vehicles) publishesbigfred:layout:<L>:vehicles; within 100 ms the daemon's interesting set widens, the poller picks up the new addr (on its next subscriber), and existing throttle pages canloco.subscribewithout restart. - Hot reload of
dcc-busprograms. Attaching a new command station to a non-system layout, then logging in and selecting it, causesSupervisordService.UpsertProgramandsupervisorctl reread+updateto add the new entry — no full supervisord daemon restart (verifiable: PID of the supervisord process stays stable across the operation). - Port pool exhaustion. When
[9200, 9299]is fully allocated,session.setCommandStationack returnsack { ok:false, error:"no_dcc_bus_ports_available" }; the server logs a warning, and operator action (widen--dcc-bus-port-max) re-enables it. - JWT mismatch. A WS upgrade to
dcc-bus-1-2with a JWT pinned to a differentlayoutIdis closed with HTTP 403; the daemon's stderr contains a structuredjwt_layout_mismatchlog. - Audit fan-in. A
system.estopfrom a driver appears inloco-server's audit log assession.emergency_executed, even though the WS frame never touchedloco-server's WS. Source attribution (Metadata.source = "dcc_bus_estop") is present. - Cold restart of
loco-serverpreserves daemons. Stopping justloco-server(not supervisord) leaves thedcc-bus-*programs running. Restartingloco-serverreadsdcc-bus:portsfrom Redis, re-publishes the same desired state to supervisord, and the WS sessions continue (thecontrolWson the browser reconnects with backoff; thedccBusWsnever blinks). - Cold restart of supervisord re-creates daemons. Killing
supervisord with
SIGKILLis followed byloco-server'stryRespawnDaemon(§7d.3) bringing it back. supervisord's[program:dcc-bus-…]entries from the rendered config restart every daemon; throttles reconnect.
These criteria are observable from outside the system (curl, ps,
browser devtools, supervisorctl status) and do not require
inspecting Go internals — same standard as §10 today.