Skip to content

release: cuems-engine 0.1.0rc6-3 - #31

Merged
ibiltari merged 35 commits into
debian/bookwormfrom
release/0.1.0rc6-3
Oct 8, 2026
Merged

ibiltari merged 35 commits into
debian/bookwormfrom
release/0.1.0rc6-3

Conversation

@ibiltari

@ibiltari ibiltari commented Oct 8, 2026

Copy link
Copy Markdown
Member

Merges rc_1 into debian/bookworm and releases 0.1.0rc6-3. All engine PRs are merged (none open).

PR Ticket What
#23 869fa89uh a play-once video cue no longer wraps to frame 0 at its end
#25 869fc8ytz an Auto follow into a cue on another node starts it. Every GO value is now a dict carrying run_seq: the whole cluster must run this engine
#27 869fbyjzx, 869fcvz85 audio wiring waits for the player's RUNNING line (no doubled audio); edge-by-edge repair at GO
#28 869fedahu cue names broadcast on :9190 after each load, replayed to late clients
#29 869evtdf7 orphan sweeps never abort a project load
#30 — test-only: AudioMixer wait tests count only their own thread's sleeps

debian/ and pyproject.toml are unchanged by the merge. The dependency stays at cuems-utils >= 0.1.0rc13, as in rc6-2; utils rc16 is merged but not released. The deb builds on casas. Safety tag on the old debian/bookworm: pre-release-0.1.0rc6-3-merge. After the merge: signed tag v0.1.0rc6-3, deb built on casas from the tag, then rc1_packages.

… (869fa89uh)

loop_videoCue sent /loop 1 for every video cue, play-once ones included.
The videocomposer runs its display latency (33 ms) ahead of MTC, so with
wraparound on it reached end of file before the engine hid the layer and
showed the first frame again for a frame or two at the end of the cue.

Send /loop 1 only when cue.loop != 1, and on the last pass of a counted
loop send /loop 0 after the new offset, so the end holds the last frame.
…z) (red)

Every GO carries run_seq; STOP, load and unload bump it before they
forward anything; a node's follow message is relayed to every node only
while the show runs and only with the current run_seq. The four tests that
pinned the GO value now expect it to be a dict in every case.
…869fc8ytz)

The controller keeps a run counter, started from the clock. STOP, load
and unload bump it before forwarding; every GO ships it to the nodes. A
node's 'follow' status message (announce or cancel) is forwarded to every
node only while running and only with the current value; the check and
the forward share one lock with the bump, so a relayed follow is always
queued ahead of the STOP that ends its run. The controller keeps no
follow state.

The GO value is now always a dict. Nodes read nothing from it but the
anchor (_resolve_go_anchor), so the caller's own value is no longer
passed on.

Plan: cuems-RELATIONS Plans/2026-10-05-engine-cross-node-auto-follow.md
§3.3-3.4.
The receive hook (bus order, never the command lock), the receiving
node's follow table (announce, pre-arm, wait for the instant, dispatch
through a commit gate in go(), cancel by entry state, the 2 s rule), and
the owning node's announce/cancel (at the reveal or the last loop; one
cancel per announcement; the restart cancel from go()). Includes the
tests for the seven findings of the code review of the implementation.
…fc8ytz)

An Auto follow (post_go='go_at_end') fired its target only on the nodes
where the source is local, so a target on another node never started,
silently, on every engine line.

The node that owns an AudioCue or VideoCue source now announces the
follow instant when the cue reveals (or at the start of its last loop;
never for an endless loop), through the controller. Every other node
pre-arms what it would start, waits for the instant on MTC and
dispatches it there through go_from. ActionCue, FadeCue, CueList and
DmxCue sources are local on every node and keep firing their own follow.

If the follow does not happen the owner cancels: 'stop' (stop, pause or
fade-out action), 'restart' (sent from go()'s commit section when the
source is started again), 'error'. A STOP, a load or the yield send
nothing; every node handles those itself. A cancel that reaches a target
already on stage stops it only within 2 s and only for stop/restart.

- cues/FollowRelay.py: both halves of the node side; one entry per
  source, its own lock, never calls into CueHandler while holding it.
- NodeCommunications: a receive hook in bus order, before any command
  thread; send_operation_nowait.
- NodeEngine: run_seq recorded and pending follows dropped at receipt;
  follow messages applied there, never under the command lock; STOP and
  load marked applied after they run.
- CueHandler: go() gets a commit_gate (refusals, gate, restart cancel,
  assignments); go_from passes arm_epoch/require_stop_epoch/commit_gate
  through; stop_dispatched_follow and disarm_if_unchanged (guarded disarm
  under the cue's arm claim); the reveal stamp; go_threaded wraps the
  pass so every exit decides its cancel once.
- loop_cue: on_last_loop callback for audio and video.

Verified on test2 (suite 1109 passed; 40 live runs, 0 LATE, 0 fallback;
old-node smoke test; PR #22 drills and a Medina sala1 replay unchanged).
Plan: cuems-RELATIONS Plans/2026-10-05-engine-cross-node-auto-follow.md.
…pair its wiring at GO edge by edge; honest arm-wait log lines (869fbyjzx) (red)

- the player's [OK] RUNNING! stdout line sets Player.ready; the mixer
  wiring waits for it (one budget with the port wait, after the no-JACK
  fail-fast; a dead player ends the wait; a player that never reports it
  is wired on port presence with a WARNING);
- player_connection_diff reports missing and stray edges (None when no
  mixer input resolves); repair_player_connections fixes them one by one
  and never disconnects an expected edge; the GO-time check uses them;
- the routing-equivalence tests of player_connections_correct now drive
  get_connections (one read per outport), every case kept;
- go() and the dispatch site say whether a cue was still being armed
  (waited for) or not armed at all (a real fallback).
…jzx)

The engine wired a player to the mixer as soon as its JACK port was
visible, polling every 0.5 s. The port exists before the player has
finished starting: RtAudio auto-connects its outports to system:playback
only when the stream starts, and the constructor goes on after that.

- Wiring before the auto-connect found nothing to disconnect; the
  auto-connect then landed and stayed, and the cue sounded through the
  mixer AND straight to the outputs (869fcvz85, ~3.5 % of arms).
- A GO waiting on the arm could message a half-built player.
- The 0.5 s poll made a pre-arm that started at a hand-off miss a GO
  0.5 s later by ~360 ms (869fbyjzx).

The player prints [OK] RUNNING! once it is fully started (main.cpp), and
the stdout reader now sets Player.ready on it. connect_player_to_outputs
waits for that event when given one (after the no-JACK fail-fast, in
counted 50 ms slices, sharing today's 14.5 s budget with the port wait),
then wires; the existing disconnect step now always finds and removes
the auto-connects. A dead player ends the wait; a player that never
reports ready is wired on port presence with a WARNING. new_audio_output
passes the player's event; the test fixtures' mocked players set it.
…9fbyjzx, 869fcvz85)

The GO-time check (player_connections_correct) confirmed only that the
expected player->mixer edges exist, so a stray direct edge to
system:playback passed, and the cue sounded doubled and outside the
mixer's volume and mute (869fcvz85). Its repair was a full rewire
(connect_player_to_outputs disconnects every edge first). A GO can reach
it while the cue is audible: go() refuses a duplicate only inside one
chain pass, the 'play' action has no playing check, and run_audioCue has
no early exit. So the rewire also cut the healthy channels.

- _expected_edges: the one copy of the routing (L/R pairs, mono fan-out,
  missing mixer inputs dropped), used by the arm-time wiring and the
  check, so they cannot drift.
- player_connection_diff -> (missing, stray), one get_connections per
  outport; None when the player's port is gone or no mixer input
  resolves (a silent cue, never "correct").
- player_connections_correct stays, as a wrapper on the diff.
- repair_player_connections: disconnects each stray (re-checked first,
  since a failed disconnect resets the shared JACK client), connects
  each missing edge, never touches an expected edge; it reports a failed
  connect and the strays that survive.
- run_audioCue calls _verify_audio_wiring_at_go, which uses the diff and
  the repair and logs a SILENT error for no mixer input or a failed
  connect, and an error for a surviving stray. The WARNING keeps its
  prefix "graph not wired correctly at GO".
… (869fbyjzx)

A GO that found its cue still being armed (a pre-arm, an audio player
still starting) logged "this should not happen, pre-arm may have failed.
Re-arming as fallback." It had not failed: the arm was in flight and the
GO waited for it.

- go() and the dispatch site now ask describe_arm_in_flight first:
  - an arm in flight: INFO "still being armed (...) — waiting for the arm
    in progress";
  - nothing in flight: the WARNING, which keeps "not loaded at go() time"
    / "not loaded at dispatch" and "Re-arming as fallback", so the
    harnesses keep counting real fallbacks only;
  - a continuation at go() (which never arms there): INFO "dispatching;
    its own thread arms it or waits".
- arm() takes an optional wait_report and fills it when it actually waits
  for another thread's arm, so "waited Y s" reports what happened, not
  the guess made before the call.
- The control flow is unchanged: same arm() calls, same epoch checks.
- Test stand-ins of arm() accept the new optional argument.
They imported helpers from test_dispatch_reorder, which only resolves when
tests/ is on sys.path; the test2 suite run does not put it there.
…byjzx)

An audio arm is ~0.47 s cold, so a GO that reaches the cue sooner after its
arm began still fires late (second GO at 0.3 s: ~0.2 s late; 0.5 s and later:
on time). Only arming before the hand-off removes it (plan B / Part B, not
built: no real show hits it today). Recorded in the code and CLAUDE.md.
From a fresh-eyes review of the branch:
- new_audio_output: a player without a ready event now logs a WARNING (the
  wiring would fall back to port presence and the doubled-audio race would
  come back unnoticed); tests that the player's Event reaches the wiring.
- The ready wait and the port wait share the ceiling exactly: the slices the
  ready wait used are rounded UP into port attempts (they were floored, so the
  total could exceed the ceiling by up to one retry step); parametrized test.
- Player.call_subprocess clears ready first, so a second run of the same
  Player cannot pass the gate on a stale Event.
- repair_player_connections: a stray whose disconnect failed but is no longer
  connected (removed by a concurrent re-arm wiring) is not reported as left.
fix(audio): wire an audio player only once it reports RUNNING; check and repair its wiring at GO edge by edge (869fbyjzx, 869fcvz85)
One /engine/status/cue_name/<uuid> per cue after the load status, replayed
to late joiners, emptied on unload; a second load without unload replaces
the map (duplicated projects share cue uuids). Pure unit tests, safe on a
live rig.
…fedahu)

The UI named the engine's next cue from whatever project its own page had
loaded, so a tab viewing X while the engine held Y showed a raw uuid. The
controller already pushes cue status and enabled flags per cue; the name is
the missing member of that family.

- cue_names built at load from the script (same walk as _collect_cue_ids;
  item.get("name"), None -> "")
- one /engine/status/cue_name/<uuid> per cue, sent AFTER set_status("load"):
  duplicated projects share cue uuids, so clients treat a change of load as
  "new table" and refill it from this burst
- replayed in _on_ws_client_connect after cue_enabled, before mixer
- emptied on unload; nothing is sent then (clients clear on load "")
- "Cue names initialised for N cues" next to the cue status line

Node engine untouched. Verified on test2 (rc6-2+nodeadopt1 base): wire
capture order, late join, X->Y without unload, unload; full suite 1057 passed.
…rs (869evtdf7) (red)

An orphan the node cannot kill (EPERM, owned by another user) made
kill_orphaned_audio_processes raise in the middle of _load_project_inner:
the old project was torn down, the new one never armed, and the only trace
was "Error executing command callback for load: [Errno 1] Operation not
permitted". The mixer sweep has the same flaw at mixer start.

Red tests for plan Plans/2026-10-07-engine-orphan-kill-eperm-aborts-load.md
(cuems-RELATIONS), rev 4:
- both sweeps: EPERM / any other kill error logged and the sweep goes on;
  ESRCH silent; pgrep rc 1 silent, rc >= 2 and "cannot run" logged; a bad
  pgrep line skipped; the owner named in the EPERM warning (G1, G2);
- pgrep asked for own-user processes only, audioplayer argv[0] anchored, with
  the pattern table (G4);
- a configured binary name the cleanup does not know is warned at start
  (G4b);
- the load reaches ready_project/ready_script with the sweep running for real
  and os.kill raising EPERM;
- the command callback failure is logged with its traceback (G6);
- cleanup_zombie_jack_clients survives a JACK error (G7).

test_player_handler_mixer_orphan.py: pgrep argv now carries -u <euid>.
Both sweeps now share module-level helpers that never raise:
_orphan_pids runs pgrep (rc 1 = nothing to do, silent; rc >= 2 or pgrep
that cannot run = WARNING with rc and stderr; a bad line is skipped with a
WARNING), _kill_orphan kills one PID (ESRCH silent; EPERM = WARNING naming
the PID, its owner and the silent-cue risk; anything else = WARNING), and
_pid_owner reads the owner from /proc/<pid>/status.

Before, an EPERM on an orphan owned by another user escaped
kill_orphaned_audio_processes in the middle of _load_project_inner (old
project torn down, new one never armed), and escaped
kill_orphaned_mixer_processes at mixer start (node without a mixer, all
audio silent). Module-level, not methods: the existing tests call the
sweeps unbound on a SimpleNamespace stand-in.
… (869evtdf7)

Both sweeps ask pgrep for the engine's own user only (-u <euid>, the scope
of the node-engine unit's own pkill -u cuems): another user's process cannot
hold a client name on our per-user JACK server, and trying to kill it is
what produced the EPERM. The audioplayer pattern is anchored on argv[0]
(^([^ ]*/)?cuems-audioplayer( |$)), so a tail -f of a cuems-audioplayer log,
a grep or a sh -c containing the word are no longer SIGKILLed. The mixer
pattern (jack-volume -c) is unchanged.

The cleanups now rely on the binary's name, so set_players warns at start
when a configured audioplayer/audiomixer/dmxplayer path has a basename they
do not know (check_orphan_binary_names): such orphans would escape every
cleanup.
A node load that died on an EPERM in the orphan sweep left only
"Error executing command callback for load: [Errno 1] Operation not
permitted": no step, no line. The error now carries its traceback
(exc_info=True; cuemsutils' Logger forwards it to logging).
…df7)

cleanup_zombie_jack_clients runs right after the audioplayer orphan sweep
in _load_project_inner, and its conn_man.get_ports call was unguarded: a
JACK error aborted the load at the same point, after the old project was
torn down. Now it logs a WARNING and returns 0; audio cues still fail
loudly at their own arm, video and DMX load.
fix(orphan-kill): an orphan the node cannot kill no longer aborts the load (869evtdf7)
fix(video): only enable videocomposer wraparound for cues that repeat (869fa89uh)
…it tests (869fbyjzx)

Seven tests patched time.sleep process-wide and counted every call. Under
pytest-xdist, threads left running by other tests in the same worker sleep
through that patch too: on PR #25's CI run the shared-budget check saw 6083 s
of foreign sleeps against a 1 s ceiling. _this_thread_sleeps() records only
the calls made by the test's own thread (foreign calls are still swallowed),
with a guard test. Test-only change.
…-noise

test(audio): count only the test thread's sleeps in the AudioMixer wait tests (869fbyjzx)
@ibiltari
ibiltari requested a review from backenv as a code owner October 8, 2026 19:13
@ibiltari
ibiltari merged commit a828db5 into debian/bookworm Oct 8, 2026
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