From f83ecbf46b07d10285c60e27dd02550d049219d1 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Tue, 20 Oct 2026 20:38:43 -0500 Subject: [PATCH 1/8] conference: reliable roster convergence via relay snapshot re-broadcast; scene roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WS relay re-fans a client's join to already-connected peers once, with no retry, so a join that races an established peer's socket can be missed at low traffic (the single-threaded browser's weak spot) and never recover. Add state_transport_ws::broadcast_snapshot() and call it ~once a second from arisrv's loop so every client idempotently re-converges on the room (and later scene) tree. Verified live: two browser tabs on cyberpcangel.com/cyprty see each other (an established client picks up a late joiner), and native WS clients over the internet. Also roadmap §5g: the server-authoritative shared 3D scene tree (conf.rooms..scene.*) that all clients render from their own camera, plus per-participant avatar pose, recorded as next work. --- docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md | 28 ++++++++++ inc/cvc/state/state_transport_ws.h | 7 +++ src/arisrv/main.cpp | 9 +++- src/cvc/state/state_transport_ws.cpp | 62 ++++++++++++++++++++++ 4 files changed, 105 insertions(+), 1 deletion(-) diff --git a/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md b/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md index 91d6a6c6f..fc19de19b 100644 --- a/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md +++ b/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md @@ -185,9 +185,13 @@ conf/ / meta/ { mode: sfu|mesh, created, title, relay-endpoint } ← server-owned roster/ ← server-owned index: → { name, joined, present } + scene/ ← server-owned: the SHARED 3D world every client renders (§5g). + env/ { background, ground, lights } + objects/ → { source(uri), transform, material } participants/ / ← owned by participant (and the server) profile/ { name, avatar } + pose/ { pos, orient } ← participant-owned: avatar position/orientation in scene/ (§5g) flags/ { mic, cam, hand, screenshare } streams/ / descriptor: { token, format_desc, endpoint-or-relay-slot } @@ -282,6 +286,30 @@ Join carries a credential. v1: a room bearer token the server checks at admissio the per-connection `pid` binding (§5d). Richer identity (OIDC via the existing SSO) and roles (host/moderator/guest, capacity, bans) are admission-time hooks layered on top. +### 5g. Shared 3D scene + avatars (NEXT WORK — user-requested) + +The conference is not just floating video windows: it is a **shared 3D space** that every client renders +**from its own camera viewpoint** (orbit/fly locally; the camera is never replicated). The scene itself is +**server-authoritative** — the room owns it as replicated state and all clients draw the same world. + +* **Scene tree.** `conf.rooms..scene.*` (server-owned, replicates on the existing `"conf"` shard + scope). The server seeds `scene.env` (background, ground, lights) and `scene.objects.` + (`source` URI + `transform` + `material`). Clients render it via a `scene:` block in the conference + `.ari` fed from that subtree — likely a small custom scene node type (the `agents`/`count:` pattern in + §3 and the example scenes), since the live tree, not the document, is the source of truth. +* **Avatars.** Each participant writes its own `participants..pose` (`pos`, `orient`) — e.g. derived + from its camera — which replicates; a custom "avatars" scene node reads the roster + each pose and draws + a marker/model per participant, so **users see each other moving around the scene** (the `agents` pattern + again, keyed by `pid`). Pose is participant-owned (§5b); the scene env is server-owned. +* **Why this matters for correctness, not just looks.** It was the broad (accidental) replication of each + client's per-document `graphics.root` that gave the earlier build a de-facto shared scene; scoping + replication to the room tree (the name-collision fix) correctly removed that. This section makes the + shared scene a **first-class, server-authoritative** feature instead of a replication side effect. +* **Sequencing.** MVP first = a minimal server-seeded environment (ground + grid + a reference object) to + prove the shared-scene render path end-to-end; then layer avatars (pose publish + the avatars node). + Schema impact (a `scene:` driven from state, a custom scene-node type) is coordinated with the host-caps + / `requires:` work (transfix/libcvc#754) so the terminal hosts gate it cleanly (`requires: [gl.scene]`). + --- ## 6. Data plane & transport diff --git a/inc/cvc/state/state_transport_ws.h b/inc/cvc/state/state_transport_ws.h index b52646c50..a62c4cea2 100644 --- a/inc/cvc/state/state_transport_ws.h +++ b/inc/cvc/state/state_transport_ws.h @@ -128,6 +128,13 @@ class state_transport_ws final : public state_transport, public media_endpoint { // server's main loop. No-op under wasm. Returns how many connections were erased this call. std::size_t reap_idle(std::chrono::milliseconds timeout); + // Relay reliability: push the full current shard snapshot to EVERY connection (not just a new one, + // the way admit does). A client-to-client live re-fan is single-shot with no retry, so a join that + // races an already-connected peer's socket can be missed and never recovers; calling this + // periodically on the server's main loop lets every client re-converge on the room/scene tree + // within one interval (idempotent bulk apply). Returns how many connections it wrote to. + std::size_t broadcast_snapshot(); + // ── Media plane (bring-up: media over this WS control channel) ── // publish_media_frame sends a frame to this transport's connections: a browser CLIENT has one // connection (the relay), so it pushes its camera/mic UP; a relay's media_router fans each diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index 8e5e167a9..efc823276 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -156,8 +156,15 @@ int main(int argc, char **argv) { // close stops streaming media; reap_idle shuts such a connection down after ~10 s so its // on-close retraction fires and the roster does not accumulate ghosts. A live client streams // continuously. - if (++reapticks % 5 == 0) + if (++reapticks % 5 == 0) { ws_tp.reap_idle(std::chrono::seconds(10)); + // Re-broadcast the room/scene snapshot to every client (~1s). A client-to-client live re-fan is + // single-shot with no retry, so a join that races an already-connected peer's socket is + // otherwise lost for good (an established client never sees a later joiner). This idempotent + // re-sync makes every client re-converge within ~1s — and is the reliable push the + // server-authoritative room+scene tree rides on. + ws_tp.broadcast_snapshot(); + } // Apply any queued departures (a browser tab closed) before pumping, so each retraction goes // out to the remaining clients this tick. { diff --git a/src/cvc/state/state_transport_ws.cpp b/src/cvc/state/state_transport_ws.cpp index 00ae9a6ba..32f54b6c6 100644 --- a/src/cvc/state/state_transport_ws.cpp +++ b/src/cvc/state/state_transport_ws.cpp @@ -1121,6 +1121,68 @@ void state_transport_ws::send_snapshot_locked(connection &c, } } +std::size_t state_transport_ws::broadcast_snapshot() { + // Build each shard's snapshot frame ONCE, then write it to every live connection. The receivers + // apply it idempotently (ingest_snapshot_entry is a bulk load), so re-sending the same roster/scene + // each interval is a no-op for a client that is already converged and a recovery for one that + // missed a live re-fan. Uses connection::write() (locks per connection) — do NOT hold a connection + // write lock here. Snapshotting the shard reads the tree under its own lock. + std::vector shards; + { + std::lock_guard lk(_shards_mu); + shards = _shards; + } + std::vector frames; + for (auto *s : shards) { + if (s == nullptr) + continue; + const auto snap = s->snapshot(); + if (snap.empty()) + continue; + pb::Frame f; + auto *rsp = f.mutable_snapshot_response(); + rsp->set_request_id(0); + rsp->set_final(true); + for (const auto &e : snap) { + auto *pe = rsp->add_entries(); + pe->set_path(e.path); + pe->set_string_value(e.string_value); + pe->set_comment(e.comment); + pe->set_hidden(e.hidden); + pe->set_read_only(e.read_only); + for (const auto &p : e.properties) + (*pe->mutable_properties())[p.first] = p.second; + pe->set_type_name(e.type_name); + pe->set_origin_node_id(e.origin_node_id); + pe->set_sequence(e.sequence); + pe->set_data_type_name(e.data_type_name); + if (e.data_payload.kind != state_payload_kind::none) + set_pb_payload(pe->mutable_data_payload(), e.data_payload); + } + frames.push_back(std::move(f)); + } + if (frames.empty()) + return 0; + std::vector> conns; + { + std::lock_guard lk(_conns_mu); + conns = _conns; + } + std::size_t wrote = 0; + for (auto &c : conns) { + if (!c || !c->alive.load() || !c->can_write()) + continue; + bool ok = true; + for (const auto &f : frames) + ok = c->write(f) && ok; + if (ok) { + ++wrote; + _sent_frames.fetch_add(frames.size(), std::memory_order_relaxed); + } + } + return wrote; +} + void state_transport_ws::unregister_connection(connection *conn) { std::lock_guard lk(_conns_mu); _conns.erase( From dd0c582aab2fc4fe9ded29477c48ec0df8f5911e Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Tue, 20 Oct 2026 22:11:29 -0500 Subject: [PATCH 2/8] state(ws): drop media / skip backed-up peers so a slow client can't stall the roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay fans video + state to each client over one connection with synchronous writes. Under a live conference's continuous video, a client that can't drain fast enough fills its OS send buffer and ws->write() blocks on it — and because the ~1s roster re-broadcast runs in one loop, blocking on ONE slow client stalls roster delivery (join/leave) for EVERY client. Observed live: a fast probe got the join snapshot but never saw a later join-once peer on the loaded relay, yet did instantly on an idle one. Add connection::send_backlog() (SIOCOUTQ, Linux) and, before each fan write, skip a backed-up peer: drop the loss-tolerant media frame above ~128KB queued, and defer the (reliable, retried-next-tick) mutation/broadcast writes above ~512KB. State is never starved by video; a slow peer drops invisible video frames instead of freezing everyone's roster. Verified under live browser-tab load: an established client now picks up a late join-once joiner, and a throttled background tab converges too. --- src/cvc/state/state_transport_ws.cpp | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/cvc/state/state_transport_ws.cpp b/src/cvc/state/state_transport_ws.cpp index 32f54b6c6..15d76f2d4 100644 --- a/src/cvc/state/state_transport_ws.cpp +++ b/src/cvc/state/state_transport_ws.cpp @@ -33,6 +33,10 @@ #include #include #include +#ifdef __linux__ +#include // SIOCOUTQ — unsent bytes in a socket's send buffer (send_backlog) +#include +#endif #else #include #include @@ -280,6 +284,24 @@ class state_transport_ws::connection { std::lock_guard lk(write_mu); return write_locked(f); } + + // Bytes still unsent in this connection's OS send buffer (Linux only; 0 elsewhere). A large value + // means the peer is not draining fast enough and the next write would BLOCK. The fan paths check + // this before writing and skip a backed-up connection — dropping a loss-tolerant media frame, or + // deferring the periodic roster re-broadcast to the next tick — instead of blocking the whole relay + // (and every other client's roster) on one slow peer. The continuous video flood otherwise fills a + // slow client's buffer, the synchronous ws->write() blocks, and the broadcast loop stalls for all. + std::size_t send_backlog() { +#if !defined(__EMSCRIPTEN__) && defined(__linux__) + if (!ws) + return 0; + int unsent = 0; + const int fd = ws->next_layer().native_handle(); + if (fd >= 0 && ::ioctl(fd, SIOCOUTQ, &unsent) == 0 && unsent > 0) + return static_cast(unsent); +#endif + return 0; + } }; #ifndef __EMSCRIPTEN__ @@ -789,6 +811,10 @@ state_transport::publish_stats state_transport_ws::publish(const state_mutation _peers.note_delivery_filtered(c->remote_node_id); continue; } + // Don't block the caller (the arisrv main loop drains local writes here) on a backed-up peer — it + // would stall the loop and its periodic broadcast for everyone. The ~1s broadcast re-delivers. + if (c->send_backlog() > 512 * 1024) + continue; if (c->write(frame)) { _sent_frames.fetch_add(1, std::memory_order_relaxed); ++stats.delivered; @@ -881,6 +907,11 @@ std::size_t state_transport_ws::write_media(const media_frame &m, const void *ex for (auto &c : conns) { if (!c || c.get() == exclude || !c->alive.load() || !c->can_write()) continue; + // Media is loss-tolerant: if this peer's send buffer is backed up (it can't keep pace with the + // video), DROP the frame rather than let the synchronous write block the relay. Dropping video to + // a slow peer is invisible; blocking here would stall state delivery (the roster) to EVERY peer. + if (c->send_backlog() > 128 * 1024) + continue; if (c->write(frame)) { _sent_frames.fetch_add(1, std::memory_order_relaxed); ++n; @@ -980,6 +1011,8 @@ void state_transport_ws::on_inbound_mutation(const state_mutation &m, connection for (auto &c : conns) { if (!c || c.get() == origin || !c->alive.load() || !c->can_write()) continue; + if (c->send_backlog() > 512 * 1024) + continue; // don't block the relay on a backed-up peer; the ~1s roster broadcast backstops it if (c->write(frame)) _sent_frames.fetch_add(1, std::memory_order_relaxed); } @@ -1017,6 +1050,8 @@ void state_transport_ws::on_inbound_message(const state_message &m, connection * for (auto &c : conns) { if (!c || c.get() == origin || !c->alive.load() || !c->can_write()) continue; + if (c->send_backlog() > 512 * 1024) + continue; // don't block the relay on a backed-up peer; the ~1s roster broadcast backstops it if (c->write(frame)) _sent_frames.fetch_add(1, std::memory_order_relaxed); } @@ -1172,6 +1207,12 @@ std::size_t state_transport_ws::broadcast_snapshot() { for (auto &c : conns) { if (!c || !c->alive.load() || !c->can_write()) continue; + // Skip a peer whose send buffer is badly backed up: writing the (multi-entry) snapshot would block + // the whole broadcast loop on that one slow peer, starving every other client. This is periodic + // (~1s), so a skipped peer simply catches up on the next tick once its buffer drains. The media + // drop above keeps a peer's buffer from filling with video, so this rarely trips for a live peer. + if (c->send_backlog() > 512 * 1024) + continue; bool ok = true; for (const auto &f : frames) ok = c->write(f) && ok; From b7c7c01317957bacdc03aba32bd7d77a1e57e9b0 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Tue, 20 Oct 2026 23:43:58 -0500 Subject: [PATCH 3/8] conference: fix roster/camera loss under video load (relay + client) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three compounding bugs left deployed clients unable to see each other's name or camera once real video was flowing: 1. Relay deferred the roster broadcast behind the video flood. Drop media hard (>32KB send backlog) to keep a peer's buffer tiny, and only defer a state write at 2MB — i.e. for an effectively-dead socket, never a merely-loaded one. Validated: 5 clients cross-fanning video, roster delivered to all every tick (wrote=5 skipped=0) while ~27% of video is shed. Env-gated CVC_WS_BROADCAST_DEBUG per-tick summary for observability. 2. Client roster collapsed to empty. The ari per-frame reactive budget (10ms) was exhausted mid-roster under wasm video decode, so the repeat-count expr (peer.count) fail-safed to 0 and the whole participant list + peer camera windows vanished that frame. Raise the budget to 30ms (clears ~6-7 peers) and cache the last-good repeat count so a transient skip can never blank a repeated list. 3. Peer camera windows all stacked at one authored pos. Pre-seed each cam_ geometry into a 2-column grid right of the Users window, so the roster is never covered and up to four cameras show side-by-side. Also seed peer.count="0" at host init so the roster's repeat count is a valid integer from frame 0 (no startup "did not yield an integer" warn). cyprty: V -> cyprty-ui6. --- src/arigl/main.cpp | 6 +++- src/cvc/ariadne/ariadne.cpp | 33 +++++++++++++++++++-- src/cvc/state/state_transport_ws.cpp | 37 ++++++++++++++++++------ src/cvcGL/examples/conference_roster.ari | 20 +++++++++++-- src/cvcGL/examples/wasm/cyprty.html | 2 +- 5 files changed, 82 insertions(+), 16 deletions(-) diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index 1b0c02587..5cb3db12c 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -451,7 +451,11 @@ struct ConferenceSession { ari::run_init(app, prefix, "(state-set \"conf.room\" \"" + room + "\") (state-set \"conf.self\" \"" + self + "\") (state-set \"cam_synthetic\" \"" + synth + - "\") (state-set \"self.pid\" \"" + nodeId + "\")", + "\") (state-set \"self.pid\" \"" + nodeId + + // Seed peer.count to a valid integer BEFORE the first render, so the roster's + // `repeat: (state-get "peer.count")` never evaluates an empty node on frame 0 + // (which warns "did not yield an integer" until the first on_tick sets it). + "\") (state-set \"peer.count\" \"0\")", &ce); for (const std::string &e : ce) std::fprintf(stderr, "arigl: conference init: %s\n", e.c_str()); diff --git a/src/cvc/ariadne/ariadne.cpp b/src/cvc/ariadne/ariadne.cpp index 754295a57..d252bd7ec 100644 --- a/src/cvc/ariadne/ariadne.cpp +++ b/src/cvc/ariadne/ariadne.cpp @@ -652,7 +652,17 @@ class ReactiveEngine { static constexpr uint64_t kMaxSteps = 100'000; // per-eval step cap (a predicate is tiny) static constexpr double kMaxSeconds = 0.005; // per-eval time cap (5 ms) - static constexpr double kFrameBudgetSeconds = 0.010; // aggregate across all predicates/frame + // Aggregate reactive-eval time across ALL predicates in a frame. This is a runaway guard, not a + // normal operating point: a handful of scalar predicates finish in well under a millisecond. It + // was 10 ms, but a live conference re-evaluates ~5-7 predicates PER PEER every frame (roster rows' + // visible_when, the per-peer camera-window count/title/texture binds), and the stackless evaluator + // is markedly slower under wasm while the browser's single thread is also decoding video — so at a + // realistic peer count the 10 ms guard tripped mid-roster, skipping the rest and blinking the + // participant list / peer windows empty. 30 ms clears a ~6-7 peer conference with margin and is + // still a hard bound on a pathological document (eval time the thread spends descheduled doesn't + // count against it, so a merely-loaded machine never trips it). See last_good_count for the + // structural backstop that keeps a repeated list stable across any residual skip. + static constexpr double kFrameBudgetSeconds = 0.030; // aggregate across all predicates/frame static constexpr std::size_t kMaxOptions = 1024; // cap on computed combo options std::size_t data_step_nodes_ = 0; // see resolve_data_settings() @@ -716,6 +726,13 @@ struct Runtime::Impl { std::vector reactive_warnings; std::set reactive_warned; int frame_instances = 0; // §3: repeat instances emitted this frame (bounds many repeats) + // §3: last successful repeat-count per expression. A reactive repeat-count eval fail-safes to 0 on + // a transient per-frame budget exhaustion (reactive eval is slow in wasm, and video decode can eat + // the frame); collapsing a repeated list — the conference roster, the per-peer camera windows — to + // EMPTY for that frame and back flickers it, and under sustained load leaves an apparently empty + // conference even though the peers are present in state. Reusing the last-good count bridges those + // skips so the list stays stable; a genuine count change is picked up on the next unskipped frame. + std::unordered_map last_good_count; // §4.7 action lane: a unique owner tag for this Runtime's processes on the app-wide // scheduler (app.exec_scheduler()), so teardown reaps exactly this document's process @@ -928,8 +945,16 @@ std::vector Runtime::Impl::eval_options(const std::string &expr) { int Runtime::Impl::eval_count(const std::string &expr) { constexpr int64_t kMaxRepeat = 4096; // a UI with thousands of repeated widgets is pathological const ReactiveEngine::IntOutcome o = ensure_reactive().eval_int(expr, /*dflt=*/0); - if (!o.error.empty()) + if (!o.error.empty()) { warn_once(o.error); + // The eval fail-safed (budget exhausted / broken). Rather than collapse the repeated list to 0 + // this frame, reuse the last count this expression yielded successfully (if any) — a transient + // skip then leaves the roster/peer-window list unchanged instead of blinking empty. With no + // prior success the fallback is still 0, so a genuinely-never-valid expr behaves as before. + const auto it = last_good_count.find(expr); + if (it != last_good_count.end()) + return it->second; + } int64_t n = o.value; if (n < 0) n = 0; @@ -938,6 +963,10 @@ int Runtime::Impl::eval_count(const std::string &expr) { std::to_string(kMaxRepeat) + ") — clamped"); n = kMaxRepeat; } + // Remember this frame's good value only when the eval actually succeeded (o.error empty): a + // fail-safe 0 must never be cached as "last good", or it would pin the list empty. + if (o.error.empty()) + last_good_count[expr] = static_cast(n); return static_cast(n); } diff --git a/src/cvc/state/state_transport_ws.cpp b/src/cvc/state/state_transport_ws.cpp index 15d76f2d4..7ade872f7 100644 --- a/src/cvc/state/state_transport_ws.cpp +++ b/src/cvc/state/state_transport_ws.cpp @@ -39,6 +39,7 @@ #endif #else #include +#include // std::getenv — CVC_WS_BROADCAST_DEBUG diagnostic gate #include #include #endif @@ -813,7 +814,7 @@ state_transport::publish_stats state_transport_ws::publish(const state_mutation } // Don't block the caller (the arisrv main loop drains local writes here) on a backed-up peer — it // would stall the loop and its periodic broadcast for everyone. The ~1s broadcast re-delivers. - if (c->send_backlog() > 512 * 1024) + if (c->send_backlog() > 2 * 1024 * 1024) continue; if (c->write(frame)) { _sent_frames.fetch_add(1, std::memory_order_relaxed); @@ -908,9 +909,11 @@ std::size_t state_transport_ws::write_media(const media_frame &m, const void *ex if (!c || c.get() == exclude || !c->alive.load() || !c->can_write()) continue; // Media is loss-tolerant: if this peer's send buffer is backed up (it can't keep pace with the - // video), DROP the frame rather than let the synchronous write block the relay. Dropping video to - // a slow peer is invisible; blocking here would stall state delivery (the roster) to EVERY peer. - if (c->send_backlog() > 128 * 1024) + // video), DROP the frame rather than let the synchronous write block the relay. A LOW threshold is + // deliberate — it keeps each peer's buffer small so the small, must-deliver STATE writes (roster + // join/leave + the periodic re-broadcast below) are never crowded out or deferred. Dropping video + // to a slow peer is invisible; a stalled roster makes clients unable to SEE each other at all. + if (c->send_backlog() > 32 * 1024) continue; if (c->write(frame)) { _sent_frames.fetch_add(1, std::memory_order_relaxed); @@ -1011,7 +1014,7 @@ void state_transport_ws::on_inbound_mutation(const state_mutation &m, connection for (auto &c : conns) { if (!c || c.get() == origin || !c->alive.load() || !c->can_write()) continue; - if (c->send_backlog() > 512 * 1024) + if (c->send_backlog() > 2 * 1024 * 1024) continue; // don't block the relay on a backed-up peer; the ~1s roster broadcast backstops it if (c->write(frame)) _sent_frames.fetch_add(1, std::memory_order_relaxed); @@ -1050,7 +1053,7 @@ void state_transport_ws::on_inbound_message(const state_message &m, connection * for (auto &c : conns) { if (!c || c.get() == origin || !c->alive.load() || !c->can_write()) continue; - if (c->send_backlog() > 512 * 1024) + if (c->send_backlog() > 2 * 1024 * 1024) continue; // don't block the relay on a backed-up peer; the ~1s roster broadcast backstops it if (c->write(frame)) _sent_frames.fetch_add(1, std::memory_order_relaxed); @@ -1203,16 +1206,29 @@ std::size_t state_transport_ws::broadcast_snapshot() { std::lock_guard lk(_conns_mu); conns = _conns; } - std::size_t wrote = 0; + // Diagnostic (off unless CVC_WS_BROADCAST_DEBUG is set): per-tick summary of how many peers the + // roster broadcast reached vs. skipped, and the worst backlog observed. This is the decisive signal + // for "clients can't see each other" — a skipped peer is one the periodic roster never reaches. + static const bool dbg = std::getenv("CVC_WS_BROADCAST_DEBUG") != nullptr; + std::size_t wrote = 0, skipped = 0, max_backlog = 0; for (auto &c : conns) { if (!c || !c->alive.load() || !c->can_write()) continue; + const std::size_t bl = c->send_backlog(); + if (bl > max_backlog) + max_backlog = bl; // Skip a peer whose send buffer is badly backed up: writing the (multi-entry) snapshot would block // the whole broadcast loop on that one slow peer, starving every other client. This is periodic // (~1s), so a skipped peer simply catches up on the next tick once its buffer drains. The media - // drop above keeps a peer's buffer from filling with video, so this rarely trips for a live peer. - if (c->send_backlog() > 512 * 1024) + // drop keeps a peer's buffer from filling with video, so at 2 MB this only trips for a peer whose + // socket is effectively dead (not draining state at all) — exactly when we must not block. + if (bl > 2 * 1024 * 1024) { + ++skipped; + if (dbg) + std::fprintf(stderr, "[ws-bcast] SKIP node='%s' backlog=%zuKB\n", + c->remote_node_id.c_str(), bl / 1024); continue; + } bool ok = true; for (const auto &f : frames) ok = c->write(f) && ok; @@ -1221,6 +1237,9 @@ std::size_t state_transport_ws::broadcast_snapshot() { _sent_frames.fetch_add(frames.size(), std::memory_order_relaxed); } } + if (dbg) + std::fprintf(stderr, "[ws-bcast] wrote=%zu skipped=%zu maxbacklog=%zuKB conns=%zu frames=%zu\n", + wrote, skipped, max_backlog / 1024, conns.size(), frames.size()); return wrote; } diff --git a/src/cvcGL/examples/conference_roster.ari b/src/cvcGL/examples/conference_roster.ari index 100f5d4a0..eea9a1855 100644 --- a/src/cvcGL/examples/conference_roster.ari +++ b/src/cvcGL/examples/conference_roster.ari @@ -144,13 +144,13 @@ windows: id: cam_{i} bind: peer.{i}.name repeat: (state-get "peer.count") - pos: [780, 40] - size: [400, 360] + pos: [612, 66] + size: [330, 290] closable: true children: - type: stream_view bind: peer.{i}.cam - size: 360 + size: 300 on_tick: | (begin @@ -159,6 +159,20 @@ on_tick: | (begin (state-set "joined" "1") (state-set "name_error" "") ; no name conflict yet (the web client's launch name is random) + ; ── pre-seed each peer camera window at a distinct TILED position ── + ; every cam_ declares the SAME authored pos:, so without this the per-peer windows land + ; exactly on top of one another and look like a single window — the #1 reason a user reports + ; "I can't see the other cameras". Tile them in a 2-column grid to the RIGHT of the Users + ; window (which sits at x≈390–610): the roster is NEVER covered, and up to four cameras show + ; side-by-side without overlapping on a laptop screen. Seeded ONCE here, before any peer window + ; exists, so each window's first-use geometry seed lands at its own spot; the user's drag then + ; persists and wins. Covers 6 peers; a 7th+ falls back to the authored pos. + (state-set "tree.cam_0.geometry" "612,66,330,290") + (state-set "tree.cam_1.geometry" "952,66,330,290") + (state-set "tree.cam_2.geometry" "612,364,330,290") + (state-set "tree.cam_3.geometry" "952,364,330,290") + (state-set "tree.cam_4.geometry" "612,662,330,290") + (state-set "tree.cam_5.geometry" "952,662,330,290") (state-set "cam_on" "1") ; on/off toggles start ON (streams open unmuted) (state-set "mic_on" "1") (room-join "" (state-get "conf.room") (dict "name" (state-get "conf.self"))) diff --git a/src/cvcGL/examples/wasm/cyprty.html b/src/cvcGL/examples/wasm/cyprty.html index 9b5c8401c..02cf5bf89 100644 --- a/src/cvcGL/examples/wasm/cyprty.html +++ b/src/cvcGL/examples/wasm/cyprty.html @@ -138,7 +138,7 @@

cyberpcangel · cyprty

}, }; - const V = 'cyprty-ui4'; // bump on every redeploy so caches can't serve a stale wasm/js + const V = 'cyprty-ui6'; // bump on every redeploy so caches can't serve a stale wasm/js async function boot() { try { const resp = await fetch(DEMO + '.wasm?v=' + V, {cache: 'no-cache'}); From d6fea0cec7f4ac80c91ed52b5dd74c0fe3545c94 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Wed, 21 Oct 2026 01:05:55 -0500 Subject: [PATCH 4/8] conference: fix real-camera roster collapse (nil device field throws, kills on_tick) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the browser real-camera path the roster was always empty — no peers, no names, no cameras, stale peers that never cleared — while synthetic test clients worked. Root cause: state-get on an UNSET node returns nil (not ""), and the on_tick's real-camera-only device-switch block guards with (= cam.want_device ""), which is false for nil. So before the host first populates that field the block runs and evaluates (int nil), which throws — and a throw in the resident on_tick handler kills it permanently, so the roster-build never runs. Synthetic clients skip the whole block (cam_synthetic=1), which is why the bug was invisible in every synthetic test. Fix: seed cam/mic.want_device and cam/mic.cur_device to "" in run_init, so the guard is correct (= "" "") and the block is skipped until a real device switch is requested. Verified by reproduction: two real-camera-path clients that showed a blank "in the room:" before now show "in the room: 2" and list each other. cyprty: V -> cyprty-ui7. --- src/arigl/main.cpp | 10 +++++++++- src/cvcGL/examples/wasm/cyprty.html | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index 5cb3db12c..2a58d3bc7 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -455,7 +455,15 @@ struct ConferenceSession { // Seed peer.count to a valid integer BEFORE the first render, so the roster's // `repeat: (state-get "peer.count")` never evaluates an empty node on frame 0 // (which warns "did not yield an integer" until the first on_tick sets it). - "\") (state-set \"peer.count\" \"0\")", + "\") (state-set \"peer.count\" \"0\")" + // Seed the device-selection fields to EMPTY STRINGS. state-get on an UNSET node + // returns nil, not "", so the real-camera on_tick guard (= cam.want_device "") + // was false before the host first populated it, running the device-switch body + // and evaluating (int nil) — which THROWS and permanently kills the on_tick + // resident, so the roster never builds (synthetic skips that block, so it was + // only ever broken for real cameras). With "" seeded the guard is correct. + " (state-set \"cam.want_device\" \"\") (state-set \"mic.want_device\" \"\")" + " (state-set \"cam.cur_device\" \"\") (state-set \"mic.cur_device\" \"\")", &ce); for (const std::string &e : ce) std::fprintf(stderr, "arigl: conference init: %s\n", e.c_str()); diff --git a/src/cvcGL/examples/wasm/cyprty.html b/src/cvcGL/examples/wasm/cyprty.html index 02cf5bf89..9a9fd414f 100644 --- a/src/cvcGL/examples/wasm/cyprty.html +++ b/src/cvcGL/examples/wasm/cyprty.html @@ -138,7 +138,7 @@

cyberpcangel · cyprty

}, }; - const V = 'cyprty-ui6'; // bump on every redeploy so caches can't serve a stale wasm/js + const V = 'cyprty-ui7'; // bump on every redeploy so caches can't serve a stale wasm/js async function boot() { try { const resp = await fetch(DEMO + '.wasm?v=' + V, {cache: 'no-cache'}); From 70e5189ed4d4edc6094cab06b2a9bcf8f9f0a06a Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Wed, 21 Oct 2026 14:35:35 -0500 Subject: [PATCH 5/8] conference: clang-format changed lines after master merge --- src/arigl/main.cpp | 35 +++++++++--------- src/arisrv/main.cpp | 4 +-- src/cvc/ariadne/ariadne.cpp | 30 ++++++++-------- src/cvc/state/state_transport_ws.cpp | 54 ++++++++++++++++------------ 4 files changed, 67 insertions(+), 56 deletions(-) diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index 4acff73fd..5326bac30 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -466,23 +466,24 @@ struct ConferenceSession { const char *synthEnv = std::getenv("CVC_ARIADNE_SYNTHETIC_CAM"); const std::string synth = (synthEnv && *synthEnv && std::string(synthEnv) != "0") ? "1" : "0"; std::vector ce; - ari::run_init(app, prefix, - "(state-set \"conf.room\" \"" + room + "\") (state-set \"conf.self\" \"" + self + - "\") (state-set \"cam_synthetic\" \"" + synth + - "\") (state-set \"self.pid\" \"" + nodeId + - // Seed peer.count to a valid integer BEFORE the first render, so the roster's - // `repeat: (state-get "peer.count")` never evaluates an empty node on frame 0 - // (which warns "did not yield an integer" until the first on_tick sets it). - "\") (state-set \"peer.count\" \"0\")" - // Seed the device-selection fields to EMPTY STRINGS. state-get on an UNSET node - // returns nil, not "", so the real-camera on_tick guard (= cam.want_device "") - // was false before the host first populated it, running the device-switch body - // and evaluating (int nil) — which THROWS and permanently kills the on_tick - // resident, so the roster never builds (synthetic skips that block, so it was - // only ever broken for real cameras). With "" seeded the guard is correct. - " (state-set \"cam.want_device\" \"\") (state-set \"mic.want_device\" \"\")" - " (state-set \"cam.cur_device\" \"\") (state-set \"mic.cur_device\" \"\")", - &ce); + ari::run_init( + app, prefix, + "(state-set \"conf.room\" \"" + room + "\") (state-set \"conf.self\" \"" + self + + "\") (state-set \"cam_synthetic\" \"" + synth + "\") (state-set \"self.pid\" \"" + + nodeId + + // Seed peer.count to a valid integer BEFORE the first render, so the roster's + // `repeat: (state-get "peer.count")` never evaluates an empty node on frame 0 + // (which warns "did not yield an integer" until the first on_tick sets it). + "\") (state-set \"peer.count\" \"0\")" + // Seed the device-selection fields to EMPTY STRINGS. state-get on an UNSET node + // returns nil, not "", so the real-camera on_tick guard (= cam.want_device "") + // was false before the host first populated it, running the device-switch body + // and evaluating (int nil) — which THROWS and permanently kills the on_tick + // resident, so the roster never builds (synthetic skips that block, so it was + // only ever broken for real cameras). With "" seeded the guard is correct. + " (state-set \"cam.want_device\" \"\") (state-set \"mic.want_device\" \"\")" + " (state-set \"cam.cur_device\" \"\") (state-set \"mic.cur_device\" \"\")", + &ce); for (const std::string &e : ce) std::fprintf(stderr, "arigl: conference init: %s\n", e.c_str()); // NOTE: device enumeration is deferred to pump() (see devices_enumerated_), NOT done here — diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index efc823276..8cc0ba4fd 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -158,8 +158,8 @@ int main(int argc, char **argv) { // continuously. if (++reapticks % 5 == 0) { ws_tp.reap_idle(std::chrono::seconds(10)); - // Re-broadcast the room/scene snapshot to every client (~1s). A client-to-client live re-fan is - // single-shot with no retry, so a join that races an already-connected peer's socket is + // Re-broadcast the room/scene snapshot to every client (~1s). A client-to-client live re-fan + // is single-shot with no retry, so a join that races an already-connected peer's socket is // otherwise lost for good (an established client never sees a later joiner). This idempotent // re-sync makes every client re-converge within ~1s — and is the reliable push the // server-authoritative room+scene tree rides on. diff --git a/src/cvc/ariadne/ariadne.cpp b/src/cvc/ariadne/ariadne.cpp index 990f6ff24..d009745c3 100644 --- a/src/cvc/ariadne/ariadne.cpp +++ b/src/cvc/ariadne/ariadne.cpp @@ -689,14 +689,15 @@ class ReactiveEngine { static constexpr double kMaxSeconds = 0.005; // per-eval time cap (5 ms) // Aggregate reactive-eval time across ALL predicates in a frame. This is a runaway guard, not a // normal operating point: a handful of scalar predicates finish in well under a millisecond. It - // was 10 ms, but a live conference re-evaluates ~5-7 predicates PER PEER every frame (roster rows' - // visible_when, the per-peer camera-window count/title/texture binds), and the stackless evaluator - // is markedly slower under wasm while the browser's single thread is also decoding video — so at a - // realistic peer count the 10 ms guard tripped mid-roster, skipping the rest and blinking the - // participant list / peer windows empty. 30 ms clears a ~6-7 peer conference with margin and is - // still a hard bound on a pathological document (eval time the thread spends descheduled doesn't - // count against it, so a merely-loaded machine never trips it). See last_good_count for the - // structural backstop that keeps a repeated list stable across any residual skip. + // was 10 ms, but a live conference re-evaluates ~5-7 predicates PER PEER every frame (roster + // rows' visible_when, the per-peer camera-window count/title/texture binds), and the stackless + // evaluator is markedly slower under wasm while the browser's single thread is also decoding + // video — so at a realistic peer count the 10 ms guard tripped mid-roster, skipping the rest and + // blinking the participant list / peer windows empty. 30 ms clears a ~6-7 peer conference with + // margin and is still a hard bound on a pathological document (eval time the thread spends + // descheduled doesn't count against it, so a merely-loaded machine never trips it). See + // last_good_count for the structural backstop that keeps a repeated list stable across any + // residual skip. static constexpr double kFrameBudgetSeconds = 0.030; // aggregate across all predicates/frame static constexpr std::size_t kMaxOptions = 1024; // cap on computed combo options @@ -790,12 +791,13 @@ struct Runtime::Impl { std::vector reactive_warnings; std::set reactive_warned; int frame_instances = 0; // §3: repeat instances emitted this frame (bounds many repeats) - // §3: last successful repeat-count per expression. A reactive repeat-count eval fail-safes to 0 on - // a transient per-frame budget exhaustion (reactive eval is slow in wasm, and video decode can eat - // the frame); collapsing a repeated list — the conference roster, the per-peer camera windows — to - // EMPTY for that frame and back flickers it, and under sustained load leaves an apparently empty - // conference even though the peers are present in state. Reusing the last-good count bridges those - // skips so the list stays stable; a genuine count change is picked up on the next unskipped frame. + // §3: last successful repeat-count per expression. A reactive repeat-count eval fail-safes to 0 + // on a transient per-frame budget exhaustion (reactive eval is slow in wasm, and video decode can + // eat the frame); collapsing a repeated list — the conference roster, the per-peer camera windows + // — to EMPTY for that frame and back flickers it, and under sustained load leaves an apparently + // empty conference even though the peers are present in state. Reusing the last-good count + // bridges those skips so the list stays stable; a genuine count change is picked up on the next + // unskipped frame. std::unordered_map last_good_count; // §4.7 action lane: a unique owner tag for this Runtime's processes on the app-wide diff --git a/src/cvc/state/state_transport_ws.cpp b/src/cvc/state/state_transport_ws.cpp index 7ade872f7..dddaf4d35 100644 --- a/src/cvc/state/state_transport_ws.cpp +++ b/src/cvc/state/state_transport_ws.cpp @@ -289,9 +289,10 @@ class state_transport_ws::connection { // Bytes still unsent in this connection's OS send buffer (Linux only; 0 elsewhere). A large value // means the peer is not draining fast enough and the next write would BLOCK. The fan paths check // this before writing and skip a backed-up connection — dropping a loss-tolerant media frame, or - // deferring the periodic roster re-broadcast to the next tick — instead of blocking the whole relay - // (and every other client's roster) on one slow peer. The continuous video flood otherwise fills a - // slow client's buffer, the synchronous ws->write() blocks, and the broadcast loop stalls for all. + // deferring the periodic roster re-broadcast to the next tick — instead of blocking the whole + // relay (and every other client's roster) on one slow peer. The continuous video flood otherwise + // fills a slow client's buffer, the synchronous ws->write() blocks, and the broadcast loop stalls + // for all. std::size_t send_backlog() { #if !defined(__EMSCRIPTEN__) && defined(__linux__) if (!ws) @@ -812,8 +813,9 @@ state_transport::publish_stats state_transport_ws::publish(const state_mutation _peers.note_delivery_filtered(c->remote_node_id); continue; } - // Don't block the caller (the arisrv main loop drains local writes here) on a backed-up peer — it - // would stall the loop and its periodic broadcast for everyone. The ~1s broadcast re-delivers. + // Don't block the caller (the arisrv main loop drains local writes here) on a backed-up peer — + // it would stall the loop and its periodic broadcast for everyone. The ~1s broadcast + // re-delivers. if (c->send_backlog() > 2 * 1024 * 1024) continue; if (c->write(frame)) { @@ -909,10 +911,11 @@ std::size_t state_transport_ws::write_media(const media_frame &m, const void *ex if (!c || c.get() == exclude || !c->alive.load() || !c->can_write()) continue; // Media is loss-tolerant: if this peer's send buffer is backed up (it can't keep pace with the - // video), DROP the frame rather than let the synchronous write block the relay. A LOW threshold is - // deliberate — it keeps each peer's buffer small so the small, must-deliver STATE writes (roster - // join/leave + the periodic re-broadcast below) are never crowded out or deferred. Dropping video - // to a slow peer is invisible; a stalled roster makes clients unable to SEE each other at all. + // video), DROP the frame rather than let the synchronous write block the relay. A LOW threshold + // is deliberate — it keeps each peer's buffer small so the small, must-deliver STATE writes + // (roster join/leave + the periodic re-broadcast below) are never crowded out or deferred. + // Dropping video to a slow peer is invisible; a stalled roster makes clients unable to SEE each + // other at all. if (c->send_backlog() > 32 * 1024) continue; if (c->write(frame)) { @@ -1015,7 +1018,8 @@ void state_transport_ws::on_inbound_mutation(const state_mutation &m, connection if (!c || c.get() == origin || !c->alive.load() || !c->can_write()) continue; if (c->send_backlog() > 2 * 1024 * 1024) - continue; // don't block the relay on a backed-up peer; the ~1s roster broadcast backstops it + continue; // don't block the relay on a backed-up peer; the ~1s roster broadcast backstops + // it if (c->write(frame)) _sent_frames.fetch_add(1, std::memory_order_relaxed); } @@ -1054,7 +1058,8 @@ void state_transport_ws::on_inbound_message(const state_message &m, connection * if (!c || c.get() == origin || !c->alive.load() || !c->can_write()) continue; if (c->send_backlog() > 2 * 1024 * 1024) - continue; // don't block the relay on a backed-up peer; the ~1s roster broadcast backstops it + continue; // don't block the relay on a backed-up peer; the ~1s roster broadcast backstops + // it if (c->write(frame)) _sent_frames.fetch_add(1, std::memory_order_relaxed); } @@ -1161,10 +1166,10 @@ void state_transport_ws::send_snapshot_locked(connection &c, std::size_t state_transport_ws::broadcast_snapshot() { // Build each shard's snapshot frame ONCE, then write it to every live connection. The receivers - // apply it idempotently (ingest_snapshot_entry is a bulk load), so re-sending the same roster/scene - // each interval is a no-op for a client that is already converged and a recovery for one that - // missed a live re-fan. Uses connection::write() (locks per connection) — do NOT hold a connection - // write lock here. Snapshotting the shard reads the tree under its own lock. + // apply it idempotently (ingest_snapshot_entry is a bulk load), so re-sending the same + // roster/scene each interval is a no-op for a client that is already converged and a recovery for + // one that missed a live re-fan. Uses connection::write() (locks per connection) — do NOT hold a + // connection write lock here. Snapshotting the shard reads the tree under its own lock. std::vector shards; { std::lock_guard lk(_shards_mu); @@ -1207,8 +1212,9 @@ std::size_t state_transport_ws::broadcast_snapshot() { conns = _conns; } // Diagnostic (off unless CVC_WS_BROADCAST_DEBUG is set): per-tick summary of how many peers the - // roster broadcast reached vs. skipped, and the worst backlog observed. This is the decisive signal - // for "clients can't see each other" — a skipped peer is one the periodic roster never reaches. + // roster broadcast reached vs. skipped, and the worst backlog observed. This is the decisive + // signal for "clients can't see each other" — a skipped peer is one the periodic roster never + // reaches. static const bool dbg = std::getenv("CVC_WS_BROADCAST_DEBUG") != nullptr; std::size_t wrote = 0, skipped = 0, max_backlog = 0; for (auto &c : conns) { @@ -1217,16 +1223,18 @@ std::size_t state_transport_ws::broadcast_snapshot() { const std::size_t bl = c->send_backlog(); if (bl > max_backlog) max_backlog = bl; - // Skip a peer whose send buffer is badly backed up: writing the (multi-entry) snapshot would block - // the whole broadcast loop on that one slow peer, starving every other client. This is periodic + // Skip a peer whose send buffer is badly backed up: writing the (multi-entry) snapshot would + // block the whole broadcast loop on that one slow peer, starving every other client. This is + // periodic // (~1s), so a skipped peer simply catches up on the next tick once its buffer drains. The media - // drop keeps a peer's buffer from filling with video, so at 2 MB this only trips for a peer whose - // socket is effectively dead (not draining state at all) — exactly when we must not block. + // drop keeps a peer's buffer from filling with video, so at 2 MB this only trips for a peer + // whose socket is effectively dead (not draining state at all) — exactly when we must not + // block. if (bl > 2 * 1024 * 1024) { ++skipped; if (dbg) - std::fprintf(stderr, "[ws-bcast] SKIP node='%s' backlog=%zuKB\n", - c->remote_node_id.c_str(), bl / 1024); + std::fprintf(stderr, "[ws-bcast] SKIP node='%s' backlog=%zuKB\n", c->remote_node_id.c_str(), + bl / 1024); continue; } bool ok = true; From c61039c21bc86c3cac41dd667b5e84da0e42f092 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Wed, 21 Oct 2026 14:47:07 -0500 Subject: [PATCH 6/8] conference: clang-format comment reflow (match CI clang-format 18.1.8) --- inc/cvc/state/state_transport_ws.h | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/inc/cvc/state/state_transport_ws.h b/inc/cvc/state/state_transport_ws.h index a62c4cea2..8fa0fb068 100644 --- a/inc/cvc/state/state_transport_ws.h +++ b/inc/cvc/state/state_transport_ws.h @@ -128,11 +128,11 @@ class state_transport_ws final : public state_transport, public media_endpoint { // server's main loop. No-op under wasm. Returns how many connections were erased this call. std::size_t reap_idle(std::chrono::milliseconds timeout); - // Relay reliability: push the full current shard snapshot to EVERY connection (not just a new one, - // the way admit does). A client-to-client live re-fan is single-shot with no retry, so a join that - // races an already-connected peer's socket can be missed and never recovers; calling this - // periodically on the server's main loop lets every client re-converge on the room/scene tree - // within one interval (idempotent bulk apply). Returns how many connections it wrote to. + // Relay reliability: push the full current shard snapshot to EVERY connection (not just a new + // one, the way admit does). A client-to-client live re-fan is single-shot with no retry, so a + // join that races an already-connected peer's socket can be missed and never recovers; calling + // this periodically on the server's main loop lets every client re-converge on the room/scene + // tree within one interval (idempotent bulk apply). Returns how many connections it wrote to. std::size_t broadcast_snapshot(); // ── Media plane (bring-up: media over this WS control channel) ── From 9fd9c1b16bfb45ce6f5be6db25ed9d515e19af1f Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Wed, 21 Oct 2026 21:41:41 -0500 Subject: [PATCH 7/8] conference(roadmap): holon model + DSL-as-first-class + consolidate Ariadne DSL roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends §5g with the holon architecture the design discussion scoped: - §5h — the holon = {world_clock + physics(Jolt) + scene/pose} as a server- authoritative, client-replicable, composable unit: frame-loop driver, client prediction + reconcile, pose propose/veto/override, spatial-interest + geometry LOD (landed) + physics LOD (new), subdelegation/holarchy on the lease manager, cross-node clock sync, with data() object replication (DISTRIBUTED_STATE §52) as the leading prerequisite. Phasing H0-H4. - §5i — the DSL as a first-class read+write holon participant: client mods (local + propose), server/admin authoritative scripts, and cross-injection under a capability grant (intrinsics allowlist / state chroot / channel scoping / resource limits — all existing enforced primitives). Phasing D1-D4, threat model first. - §10 — baseline-shipped note (cyprty live on the WS-relay path; holon/DSL is the next arc). - §13 — consolidates the (substantially complete) Ariadne cvcGL UI/scene DSL roadmap: near-term fold-ins (input_text, multi-view §9.5 -> H1, light node, HTTP cache, async-scheduler parity), backlog retained (TF editor, JSON-Schema validator, s-expr dialect, program modularity, pycvc URI), and a stale-doc refresh (geometry LOD landed PR #249+, tvision + EGL harness done). Docs only; no code change. --- docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md | 149 +++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md b/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md index fc19de19b..43be56eb9 100644 --- a/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md +++ b/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md @@ -312,6 +312,108 @@ The conference is not just floating video windows: it is a **shared 3D space** t --- +### 5h. The holon — the shared scene formalized (authority, prediction, LOD, composition) + +§5g's shared scene is the first rung of a larger unit, the **holon**: a bundle of +`{ cvc::world_clock + cvc::physics::world (Jolt) + scene/pose state }` that is **server-authoritative, +client-replicable, and composable**. The clock lives *inside* the holon (per-holon, not an app global), so +a node interacting with several holons runs several clocks. The DSL, media and render *observe* a holon; +only its deterministic core (clock + physics) replicates and reconciles. All three primitives exist today +independently (`inc/cvc/core/world_clock.h`, `inc/cvc/physics/*`, the cvcGL retained scene graph) — nothing +bundles them yet. + +* **Driver.** The frame loop is the only driver (as in WORLD-CLOCK-SYNC): one `clock.advance(wall_dt)` → + `{steps, alpha, first_tick, dropped_steps}`; an `on_tick` pass runs each due fixed quantum (deterministic + physics prediction); an `on_frame` pass runs once (authoritative interpolation, DSL, media pump, render + tween via `alpha`). `fixed_dt` is **constant** (1/120 for a navigator-free room); CPU load varies the + *step count*, never dt — which is what makes the client's prediction bit-reproducible against the server + (`physics/det_math.h`). `clock_follower` already gives per-follower `ticks_per_step`/`steps_due`/`alpha`. +* **Authority & reconcile.** Server holon = truth; client holon = a replica that predicts locally and + reconciles on each authoritative snapshot (soft-blend while tracking, `seek_tick` + replay when it + diverged or `dropped_steps` fired). **Pose is server-authoritative with client proposal + veto + + override**: the client writes a proposal to `participants..intent.*` (replicated), the server + validates it as an input to that avatar's physics body and writes the authoritative `…pose/body` — + accept, veto (clamp/reject), or override at will (teleport, re-mass, re-parent). "Respect the veto" = + authoritative wins on reconcile. This is the server-mediated-write path (§5d) applied per avatar. + *(No client-prediction/reconcile/rollback loop exists today — it is new; distributed state resolves + last-writer with vector clocks, not predict/rollback.)* +* **LOD (realtime for large scenes).** A replica is a *scoped, decimated* view, not a full copy: **spatial + interest** (extend the shard's `add_interest` from `"conf"` to `scene.` near the camera) picks + *what*; **geometry LOD** (landed, PR #249+: `lod/select.h` rung/budget solver, `lod/pyramid.h`, QEM + `geometry/simplify.h`, `lod/tiles.h`, HDF5 `lod/store.h`, `LodGraphicsNode`) picks *how much*; reconcile + fixes what was skipped. **Physics LOD is new** (zero hits today) — a sim-detail ladder (collision-hull + decimation, substep/rate reduction, freeze-to-kinematic for distant/subordinate bodies) + re-hydrate when + a body re-enters interest. +* **Composition / holarchy.** A server may **subdelegate a sub-volume** `V` to a subordinate server running + a child holon (its own clients/streams/clock); the parent becomes a **client of the child that keeps + ultimate authority**. Two axes: **scene-graph** — mount the child's replicated scene as a frame node at + `V`'s transform (the `physics-world-frame = scene node` + render-origin-offset mechanism already exists + for city-scale), presented at the parent's chosen copy/LOD; **physics** — authority **handoff** at `∂V` + first, then cross-boundary kinematic proxies (research-grade). Subdelegation rides + `state_delegation_manager` (leases: delegate/renew/**revoke**, longest-prefix routing) with a + **spatial-volume → path-prefix** mapping (`state_brick_manifest::bricks_in_region` is the spatial index) + and a **new parent override/veto primitive** (leases give revoke, not per-write override; build on + `write_policy`/`state_authority_map`). +* **Cross-node clock sync.** `world_clock_binding` already publishes `sim.tick/time/fixed_dt/realized_speed/ + dropped` to the tree; replicate them and the replica tracks the remote authoritative tick — `seek_tick` + on hard correction, `set_scale` nudge toward the remote `realized_scale()` for drift (time dilation), + server-set `sim.mode/speed` replicated for **shared pause/slowmo** (local pause stays local). +* **Hard prerequisite.** `data()` object replication is a known DISTRIBUTED_STATE gap (§52: only scalar + value/child changes journal; `set_data` is a no-op pending a codec). Replicating live physics/scene + *state objects* rides on closing it — so it leads the phasing. + +**Phasing.** +- **H0 — substrate:** close `data()` object replication (DISTRIBUTED_STATE §52); spatial-interest filter; + confirm the deterministic physics path (`det_math.h`). +- **H1 — single holon** (the §5g scene + avatars, formalized): bundle `{clock,physics,scene}`; + server-authoritative; client replica with prediction + reconcile; pose propose→veto→override; geometry + LOD + spatial interest; **per-camera `RenderView` + `VisibilityMask`** (pull in CVCGL-UI-DSL §9.5). +- **H2 — physics/sim LOD + re-hydrate; cross-node clock tracking** (seek/scale, shared pause). +- **H3 — subdelegation / holarchy:** spatial→path delegation on the lease manager; parent-as-authority- + client + override primitive; live-remote-holon scene mount at copy/LOD; residency/paging driver + (VISIBILITY-AND-LOD HLOD §4.2 — designed, not landed). +- **H4 — physics boundary composition:** authority handoff at `∂V`, then cross-boundary kinematic proxies + *(research-grade; last)*. + +--- + +### 5i. DSL as a first-class holon citizen (programmability & modding) + +The DSL is a **first-class read *and write* participant** in the holon — a mutation/event source peer to +physics — **authority-scoped and capability-sandboxed**. It is not part of the deterministic core, so its +*authoritative* writes **replicate** (clients receive + reconcile them) rather than being predicted. Three +roles, each already separable by existing machinery: + +* **Client mods — local customize + propose.** A sandboxed client DSL over the replica: local-only effects + (UI, LOD prefs, cosmetic overrides, reactions) write the **non-replicated** `ui.docs.*` scope; + holon-affecting changes write the **replicated intent** scope as **proposals** the server accepts/vetoes — + the exact interest-filter split the name-collision fix relied on (`conf` replicates, `ui.docs.ari` does + not). +* **Server / admin DSLs — authoritative.** Run on the authority: their writes are holon truth (spawn, + enforce rules, react to events), role-gated by `state_authority_map`/`write_policy`. +* **Cross-injection — inject a DSL to run inside the counterpart** under a **capability grant**. Every + constraint the grant names is an existing *enforced* primitive: *intrinsics* → reduced-env default-deny + allowlist + `requires:` preflight (§7.6); *state tree* → `apply_chroot` to a sub-prefix (+ authority / + interest); *stream + message channels* → chroot'd channel scoping (#463–467) + stream-token scoping; + *resource* → step/time/memory budgets (§7.3/§7.4, `memory_tracker`). The **new** work is the *grant + object*, the *injection transport* (ship DSL + grant over a channel), *host-side sandboxed instantiation* + (build an env from a grant, run under it), and the *accept policy* (who may inject into whom; auto vs. + prompted). **Security-critical:** a client→server injection is untrusted code on the authority (the grant + + reduced-env + chroot + bounded budgets are the whole defense — no fs, no transport beyond granted + channels, deterministic caps, no escalation); server→client is the mirror. **Write the threat model + first.** + +**Phasing.** +- **D1 — DSL first-class writer** (rides H1): authority-scoped writes (client propose via `intent` → veto; + server authoritative) on `write_policy` + the replicated/non-replicated scope split. +- **D2 — Client mods:** sandboxed client DSL — local (`ui.docs`) customization + replicated proposals. +- **D3 — Server/admin DSLs:** authoritative holon scripting + event reactions, role-gated. +- **D4 — Cross-injection + capability grants:** grant object + injection transport + host-side + instantiation + accept policy + a **threat-model doc** (built on reduced-env / `apply_chroot` / + channel-scoping #463–467 / §7.3–7.6 limits + `requires:`). + +--- + ## 6. Data plane & transport ### 6a. Media transports @@ -516,6 +618,12 @@ browser, then the browser itself, then the MVP. for mesh authz; identity (OIDC via SSO / revocable txwtf-style JWT); the **WASI server** build (fill cvcpkg gaps, §6f). +> **Baseline shipped (2026-10-21).** The shared-scene + roster conference is **live at +> cyberpcangel.com/cyprty** on the **WebSocket-relay** path — Phases 2–3 + 5 realized (room control plane, +> room verbs, native proof, browser bring-up over a WS loopback); the gRPC/WebRTC legs (Phases 4/6) and +> scale/mesh (7/8) are still pending. The next arc is the **holon + DSL model (§5h/§5i)**; its H0–H4 / D1–D4 +> phasing lives there, parallel to the media/transport phases above (the shared 3D scene + avatars is H1). + --- ## 11. Open questions / decisions @@ -549,3 +657,44 @@ mesh works first, auth hardens later (§5, Phase 8); **WASI server committed** ( * Distributed state / cluster: [`DISTRIBUTED_STATE_ROADMAP.md`](DISTRIBUTED_STATE_ROADMAP.md), [`STATE_LIFETIME_AND_ATOMICITY.md`](STATE_LIFETIME_AND_ATOMICITY.md). * UI DSL (widgets, `input_text` gap): [`CVCGL-UI-DSL-ROADMAP.md`](CVCGL-UI-DSL-ROADMAP.md). +* World clock / sim pacing (holon driver, §5h): [`WORLD-CLOCK-SYNC-ROADMAP.md`](WORLD-CLOCK-SYNC-ROADMAP.md). +* LOD — geometry (holon §5h; **doc stale — landed PR #249+**): + [`VISIBILITY-AND-LOD-ROADMAP.md`](VISIBILITY-AND-LOD-ROADMAP.md). +* Physics (holon §5h): [`NAV-VEHICLE-PHYSICS-ROADMAP.md`](NAV-VEHICLE-PHYSICS-ROADMAP.md). + +--- + +## 13. Consolidated from the Ariadne (cvcGL UI & scene DSL) roadmap + +`CVCGL-UI-DSL-ROADMAP.md` is **substantially complete** — the `state_exec` engine, the widget/layout model, +the reactive + action lanes, composition/URI, the **scene-graph core**, the **streamed-source zero-copy +contract + clipping**, Unicode, and the backend-core split + AppRuntime refactor all landed. Its remaining +items are **retained here (nothing dropped)**: conference-relevant ones feed the H/D arc, the rest are +backlog we still want. + +**Near-term (fold into the H/D arc):** +* **`input_text` / `input_int` widgets** (§3.5/§6) — keyboard plumbing landed, the *widgets* do not exist; + needed for chat + real name entry (already Phase-0 in §10). +* Text **editing + clipboard** (§17.5/§17.8) — chat. +* **Multi-view `RenderView` + `VisibilityMask`** (§9.5) — pulled into **H1** (per-camera render + per-view + mask/LOD; this is a holon building block, not just a UI feature). +* **Light node** + binding scene props (transform/material/color/TF) **to state** (§9.1); `minimap` / + `view_embed` (§9.6) — shared-scene polish. +* HTTP **cache LRU/async** (§13.9) + **write side** (§13.10); document `on_key/on_pointer` + portable + `drain_input` (§16.1); **`state_exec` async-scheduler parity** (§7.1/§7.7 — port `receive_message`/`sleep`, + wire `memory_tracker`), which the DSL-in-the-frame-loop (§5h driver) and the D4 limits lean on. + +**Backlog (wanted, later — retained, not scheduled yet):** +* Transfer-function editor + `data()`-channel authoring (§10/§10.3). +* Formal draft-2020-12 JSON Schema + the C++ validator + `ari validate` (§15). +* YAML-free pure-s-expr `.ari` dialect (§18). +* `state_exec` program modularity — `(include/load "uri")` (§19). +* pycvc URI-scheme handler override + `py_to_value` bytes branch (§14.2). + +**Already landed — source roadmaps are stale (refresh action):** +* **Geometry LOD** — VISIBILITY-AND-LOD-ROADMAP.md header still says "design, not yet implemented"; it is + **landed (PR #249+)**: `lod/select`, `lod/pyramid`, `geometry/simplify` (QEM), `lod/tiles`, `lod/store` + (HDF5 `scene.cvch5`), `LodGraphicsNode`. +* **tvision** terminal backend (§16.4) and the **EGL golden-image test harness** (§16.6) — **done**. +* *Action:* scrub these statuses in `CVCGL-UI-DSL-ROADMAP.md` and `VISIBILITY-AND-LOD-ROADMAP.md` so they + stop misreporting finished work. From 7e934283623a6c24daa523ec1f4f2969b232c642 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Wed, 21 Oct 2026 21:47:39 -0500 Subject: [PATCH 8/8] =?UTF-8?q?conference(roadmap):=20correct=20=C2=A713?= =?UTF-8?q?=20=E2=80=94=20verify=20Ariadne=20DSL=20status=20against=20code?= =?UTF-8?q?,=20not=20the=20stale=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first §13 pass trusted a doc-audit of CVCGL-UI-DSL-ROADMAP.md, which is badly stale. Verified against the tree instead: input_text/input_int (loader.cpp:650, #636), RenderView/VisibilityMask (RenderView.cpp), light node (LightNode.cpp), clipboard (Clipboard*.cpp), async-scheduler parity (receive_message/sleep/ yield_frame in async_scheduler.cpp), and the JSON-Schema validator integration are ALL landed — not gaps. §13 now lists verified-landed vs genuinely-remaining (TF editor §10 and program modularity §19 have no code; the rest need per-item code checks), and flags that even this file's §9/§10 are stale on input_text. Also fixed §5h H1 to USE the existing RenderView rather than "pull it in". Docs only. --- docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md | 72 +++++++++++----------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md b/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md index 43be56eb9..0a99bd202 100644 --- a/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md +++ b/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md @@ -367,7 +367,8 @@ bundles them yet. confirm the deterministic physics path (`det_math.h`). - **H1 — single holon** (the §5g scene + avatars, formalized): bundle `{clock,physics,scene}`; server-authoritative; client replica with prediction + reconcile; pose propose→veto→override; geometry - LOD + spatial interest; **per-camera `RenderView` + `VisibilityMask`** (pull in CVCGL-UI-DSL §9.5). + LOD + spatial interest; **per-camera `RenderView` + `VisibilityMask`** (both already exist — + `src/cvcGL/RenderView.cpp`, §9.5 — so H1 *uses* them, it does not build them). - **H2 — physics/sim LOD + re-hydrate; cross-node clock tracking** (seek/scale, shared pause). - **H3 — subdelegation / holarchy:** spatial→path delegation on the lease manager; parent-as-authority- client + override primitive; live-remote-holon scene mount at copy/LOD; residency/paging driver @@ -664,37 +665,38 @@ mesh works first, auth hardens later (§5, Phase 8); **WASI server committed** ( --- -## 13. Consolidated from the Ariadne (cvcGL UI & scene DSL) roadmap - -`CVCGL-UI-DSL-ROADMAP.md` is **substantially complete** — the `state_exec` engine, the widget/layout model, -the reactive + action lanes, composition/URI, the **scene-graph core**, the **streamed-source zero-copy -contract + clipping**, Unicode, and the backend-core split + AppRuntime refactor all landed. Its remaining -items are **retained here (nothing dropped)**: conference-relevant ones feed the H/D arc, the rest are -backlog we still want. - -**Near-term (fold into the H/D arc):** -* **`input_text` / `input_int` widgets** (§3.5/§6) — keyboard plumbing landed, the *widgets* do not exist; - needed for chat + real name entry (already Phase-0 in §10). -* Text **editing + clipboard** (§17.5/§17.8) — chat. -* **Multi-view `RenderView` + `VisibilityMask`** (§9.5) — pulled into **H1** (per-camera render + per-view - mask/LOD; this is a holon building block, not just a UI feature). -* **Light node** + binding scene props (transform/material/color/TF) **to state** (§9.1); `minimap` / - `view_embed` (§9.6) — shared-scene polish. -* HTTP **cache LRU/async** (§13.9) + **write side** (§13.10); document `on_key/on_pointer` + portable - `drain_input` (§16.1); **`state_exec` async-scheduler parity** (§7.1/§7.7 — port `receive_message`/`sleep`, - wire `memory_tracker`), which the DSL-in-the-frame-loop (§5h driver) and the D4 limits lean on. - -**Backlog (wanted, later — retained, not scheduled yet):** -* Transfer-function editor + `data()`-channel authoring (§10/§10.3). -* Formal draft-2020-12 JSON Schema + the C++ validator + `ari validate` (§15). -* YAML-free pure-s-expr `.ari` dialect (§18). -* `state_exec` program modularity — `(include/load "uri")` (§19). -* pycvc URI-scheme handler override + `py_to_value` bytes branch (§14.2). - -**Already landed — source roadmaps are stale (refresh action):** -* **Geometry LOD** — VISIBILITY-AND-LOD-ROADMAP.md header still says "design, not yet implemented"; it is - **landed (PR #249+)**: `lod/select`, `lod/pyramid`, `geometry/simplify` (QEM), `lod/tiles`, `lod/store` - (HDF5 `scene.cvch5`), `LodGraphicsNode`. -* **tvision** terminal backend (§16.4) and the **EGL golden-image test harness** (§16.6) — **done**. -* *Action:* scrub these statuses in `CVCGL-UI-DSL-ROADMAP.md` and `VISIBILITY-AND-LOD-ROADMAP.md` so they - stop misreporting finished work. +## 13. Status of the Ariadne (cvcGL UI & scene DSL) roadmap — verified against code + +The Ariadne UI/scene DSL is **essentially complete in code, and its roadmap docs are badly stale** — a +*doc* audit mis-reports finished work as open (even this file's §9/§10 still call `input_text` a gap, though +it shipped in **#636**). The statuses below are **verified against the tree**, not read off the doc. + +**Landed (several still listed open/planned in the docs — refresh needed):** +* `input_text` / `input_int` widgets (§3.5/§6) — `src/cvc/ariadne/loader.cpp:650` (#636). +* Multi-view **`RenderView` / `VisibilityMask`** (§9.5) — `src/cvcGL/RenderView.cpp`. +* **Light** scene node (§9.1) — `src/cvcGL/LightNode.cpp` + loader `"light"`. +* **Clipboard** (§17.8) — `src/cvcGL/Clipboard*.{cpp,mm}`. +* `state_exec` **async-scheduler parity** (§7.1/§7.7) — `receive_message`/`sleep`/`yield_frame` in + `async_scheduler.cpp`. +* **JSON-Schema validator integration** (§15) — `nlohmann_json_schema_validator` wired in CMake + (dep-conditional). +* **s-expr in binds/scripts** (part of §18) — homoiconic read-lane in `ariadne.cpp` / `loader.cpp`. +* **Geometry LOD** (PR #249+), **tvision** terminal backend (§16.4), **EGL golden-image harness** (§16.6). +* The core: engine, widget/layout, reactive + action lanes, composition/URI, scene-graph core, + streamed-source zero-copy + clipping, Unicode, backend split + AppRuntime. + +**Genuinely remaining — VERIFY each in code before scheduling (the doc can't be trusted); all retained:** +* Transfer-function editor + `data()`-channel authoring (§10) — *no code found.* +* `state_exec` program modularity `(include/load "uri")` (§19) — *no code found.* +* pycvc URI-scheme handler override + bytes branch (§14.2) — *not found; verify.* +* Full YAML-free s-expr `.ari` **document** dialect (§18) — exprs land in binds/scripts; the whole-document + front-end is the remainder. +* Formal draft-2020-12 JSON Schema + the `ari validate` command (§15) — the validator *integration* exists; + the formal schema + CLI may be the gap. +* HTTP response-**cache LRU** (§13.9) — per-request `max_bytes` cap exists (`http_client_fetch.cpp`); the + response-cache LRU + the write side (§13.10) are unverified. + +**Action + takeaway:** refresh the stale statuses in `CVCGL-UI-DSL-ROADMAP.md`, +`VISIBILITY-AND-LOD-ROADMAP.md`, and this file's own §9/§10. Net: the DSL's UI/scene substrate is **done** — +the conference's forward work is the **holon (§5h) + DSL-programmability (§5i)**, genuinely new capability, +**not** DSL catch-up.