Skip to content

feat(core): clock_driver — unified per-frame time-slice fan-out; drive the conference pump through it - #766

Merged
transfix merged 3 commits into
masterfrom
feat/holon-tick-driver
Oct 22, 2026
Merged

transfix merged 3 commits into
masterfrom
feat/holon-tick-driver

Conversation

@transfix

Copy link
Copy Markdown
Owner

What

Introduces cvc::clock_driver — the reusable spine for the holon tick model — and puts the arigl conference host on it. This is the "time-slice framework" piece: one advance per clock per frame, fanned out to ordered observers that share a single reading of time.

Slice 1 — cvc::clock_driver (new)

inc/cvc/core/clock_driver.h + src/cvc/core/clock_driver.cpp

The host-side loop that world_clock always assumed but left to each caller to open-code (tick_scene is this loop; the conference host was this loop as an 11-step pump). Factored out so there is exactly one advance per clock per frame and one place that owns the order.

Two observer kinds mirror the advance() / for-each-step / present shape:

  • step observers fire first, ascending order — "advance your own sim by the quanta that fell due" (a physics follow(), a holon's simulation; use the shared clock_frame.step / a clock_follower).
  • frame observers fire after every step observer, ascending order — "do the once-per-frame work / present at frame.alpha".

Every observer sees the same clock_frame computed once at the top of advance(), so a media timestamp (frame.t) read by one observer is bit-identical to another's — what lets a receiver align two streams stamped in the same frame. clock_frame also carries raw wall_dt (for work that must track real time regardless of sim rate — talk-hold, camera, fps), a monotonic index, and a first flag; a measured first advance books no startup burst. Optional world_clock_binding routing lets the sim.* control keys steer the driver and publishes telemetry each frame. Observers may remove/add themselves from inside the fan-out (a self-removing one-shot); re-entering advance() throws.

13 gtest cases (src/cvc/tests/clock_driver_test.cpp) cover ordering, the shared frame, first-frame seeding, remove/add during the fan-out, re-entrancy, the follower-driven step path, and the binding route.

Slice 2 — drive the conference pump through it

src/arigl/main.cpp

ConferenceSession's monolithic 11-step pump() and its hand-rolled media_clock_now() are replaced by a clock_driver over app.world_clock(). setup() builds the driver and calls install_observers(), which registers the 11 steps as frame observers at explicit ascending orders (kOrderPumpAll .. kOrderActivity) — the exact order the open-coded pump ran them. pump() is now just driver_->advance(). Device enumeration becomes a self-removing one-shot observer.

Behavior-preserving: same step order; one world-clock reading per frame shared by every step (frame.t → forward_streams); the first measured advance books no startup burst (as media_clock_now's last_wall_ < 0 skip did); now_wall_ stays a once-per-frame steady_clock reading for the frame-rate-independent talk-hold; conf.pump still runs before the document on:tick (the deliberate one-frame lag read_peer_controls relies on); a plain local document has no driver and pump() no-ops. This also removes the conference's second, hand-rolled world_clock advance path (the "never double-advance" hazard).

Why

The flagship holon model is {world_clock + physics + scene/pose}, server-authoritative and client-replicable. A holon needs a single, ordered, one-advance-per-frame tick surface that both the server and each client replica run; the conference host needed to stop hand-advancing its own clock and join that surface. clock_driver is that surface, and the conference is the first consumer. (First step of the holon plan; H0 data()-replication and H1 holon bundle build on top.)

Testing

  • clock_driver_test: 13/13 green.
  • world_clock_test: 49/49 green (library healthy with the new TU).
  • arigl builds clean; the no-relay path (arigl check conference_roster.ari) is green.
  • The live relay+camera path (cyprty) is validated interactively — a camera-less/synthetic repro cannot exercise it.

Formatted with clang-format 18.1.8 (matches CI).

…lock

Introduce cvc::clock_driver (inc/cvc/core/clock_driver.h,
src/cvc/core/clock_driver.cpp): the host-side loop that advances a
world_clock exactly once per frame and fans the result out to ordered
observers that share a single clock_frame.

Two observer kinds mirror the advance()/for-each-step/present shape the
world_clock header sketches and that tick_scene already open-codes:

  - step observers  fire first, ascending `order`: "advance your own sim
    by the quanta that fell due" (physics follow, a holon's simulation).
  - frame observers  fire after every step observer, ascending `order`:
    "do the once-per-frame work / present at frame.alpha" (drain a
    transport, forward media stamped with frame.t, interpolate a pose).

Every observer sees the SAME clock_frame computed once at the top of
advance(), so a media timestamp (frame.t) read by one observer is
bit-identical to the one read by another — what lets a receiver align
two streams stamped in the same frame. clock_frame also carries the raw
wall_dt for work that must track real time regardless of sim rate
(talk-hold decay, camera, fps), a monotonic frame index, and a `first`
flag; a measured first advance books no startup burst.

Optional world_clock_binding routing lets the sim.* control keys steer
the driver and publishes telemetry each frame. Observers may be removed
or added from inside the fan-out (a self-removing one-shot, deferred
adds); re-entering advance() throws.

This is the reusable spine for the holon tick model and for refactoring
the conference host's monolithic per-frame pump onto an ordered,
single-advance observer set.

13 gtest cases in src/cvc/tests/clock_driver_test.cpp cover ordering,
the shared frame, first-frame seeding, remove/add during fan-out,
re-entrancy, the follower-driven step path, and the binding route.
Replace ConferenceSession's monolithic eleven-step pump() and its
hand-rolled media_clock_now() with a single cvc::clock_driver that owns
the one per-frame advance of the per-app world_clock and fans the steps
out as ordered observers.

setup() now builds a clock_driver over app.world_clock() and calls
install_observers(), which registers the eleven per-frame steps as frame
observers at explicit ascending orders (kOrderPumpAll .. kOrderActivity)
— the exact order the open-coded pump ran them in. pump() is now just
driver_->advance(): it measures the wall delta, advances the clock once
(the single media timestamp, frame.t, handed to forward_streams so a
receiver can A/V-align a frame's camera and mic), and fires the
observers. The device enumeration becomes a self-removing one-shot
observer instead of a per-frame latched branch.

Behavior is preserved: same step order, one world-clock reading per
frame shared by every step, the first measured advance books no startup
burst (as media_clock_now's last_wall_ < 0 skip did), now_wall_ stays a
once-per-frame steady_clock reading for the frame-rate-independent
talk-hold, and conf.pump still runs before the document on:tick (the
deliberate one-frame lag read_peer_controls relies on). A plain local
document has no driver and pump() no-ops, exactly as before.

This removes the conference's second, hand-rolled world_clock advance
path (the map's "never double-advance" hazard) and puts the host on the
same one-advance/ordered-observer spine the holon simulation will use.

Exercises both the run-mode input pump and the snapshot-mode direct
pump. arigl builds clean; the no-relay path (arigl check) is green.
ConferenceSession had no destructor, so its members were destroyed in
reverse declaration order: media_mu_ and inbox_ (declared after the
transport) were torn down BEFORE ws_/grpc_, whose destructor is the only
thing that joins the transport reader thread. On every teardown path
that does not reach the run loop's explicit conf.stop() — snapshot,
check, info, a document-load failure, or an exception unwinding the run
loop — the still-running reader could invoke on_media and lock a
destroyed media_mu_ / push to a destroyed inbox_: a use-after-free and
data race. The snapshot-of-a-live-conference path is explicitly
supported, so this was reachable, not theoretical.

Add ~ConferenceSession() { stop(); }. The destructor body runs ahead of
member destruction, so stop() joins the reader (via stop_(), which the
transport makes idempotent with _running.exchange(false)) while
media_mu_/inbox_ are still alive. ConferenceSession::stop() is already
idempotent (clear/reset are no-ops the second time), so the run path's
explicit stop() followed by this one is safe. Single-threaded wasm has
no reader thread and is unaffected.

Pre-existing hazard (not introduced by the clock_driver refactor);
surfaced by an adversarial review of this branch and fixed here since it
lives in the same struct the refactor touches.
@transfix

Copy link
Copy Markdown
Owner Author

Ran a 3-lens adversarial review of this branch (framework correctness / refactor faithfulness vs the original pump / lifetime-threading-wasm), each finding independently verified.

Faithfulness + framework: no confirmed issues — the observer order reproduces the original pump exactly, frame.t is the single per-frame media stamp handed to forward_streams, the first measured advance books zero (matching the old last_wall_ < 0 skip), and conf.pump still runs before the document on:tick.

One confirmed finding (high), pre-existing — now fixed in 535f397: ConferenceSession had no destructor, so media_mu_/inbox_ (declared after the transport) were destroyed before ws_/grpc_, whose destructor is the only thing that joins the transport reader thread. On teardown paths that don't reach the run loop's explicit conf.stop() (snapshot, check, info, load-failure, or an exception unwinding the run loop), the still-running reader could on_media into a destroyed mutex/vector — a UAF/data race, reachable via the explicitly-supported "snapshot of a live conference" path. Fix: ~ConferenceSession() { stop(); } — stop() runs in the destructor body ahead of member destruction and joins the reader (idempotent via the transport's _running.exchange(false)). Single-threaded wasm has no reader thread and is unaffected.

The review cleared the driver/app lifetime, the observers' captured this (struct holds a mutex, never moved/copied), and the dev_enum_h_ one-shot as safe.

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