diff --git a/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md b/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md index 91d6a6c6f..0a99bd202 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,133 @@ 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]`). + +--- + +### 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`** (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 + (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 @@ -488,6 +619,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 @@ -521,3 +658,45 @@ 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. 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. diff --git a/examples/ariadne/documents/conference_roster.ari b/examples/ariadne/documents/conference_roster.ari index 100f5d4a0..eea9a1855 100644 --- a/examples/ariadne/documents/conference_roster.ari +++ b/examples/ariadne/documents/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/inc/cvc/state/state_transport_ws.h b/inc/cvc/state/state_transport_ws.h index b52646c50..8fa0fb068 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/arigl/main.cpp b/src/arigl/main.cpp index 4ec749481..5326bac30 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -466,11 +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 + "\")", - &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/arigl/web/cyprty.html b/src/arigl/web/cyprty.html index 9b5c8401c..9a9fd414f 100644 --- a/src/arigl/web/cyprty.html +++ b/src/arigl/web/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-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'}); diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index 8e5e167a9..8cc0ba4fd 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/ariadne/ariadne.cpp b/src/cvc/ariadne/ariadne.cpp index 5410934a1..d009745c3 100644 --- a/src/cvc/ariadne/ariadne.cpp +++ b/src/cvc/ariadne/ariadne.cpp @@ -687,7 +687,18 @@ 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() @@ -780,6 +791,14 @@ 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 @@ -993,8 +1012,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; @@ -1003,6 +1030,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 00ae9a6ba..dddaf4d35 100644 --- a/src/cvc/state/state_transport_ws.cpp +++ b/src/cvc/state/state_transport_ws.cpp @@ -33,8 +33,13 @@ #include #include #include +#ifdef __linux__ +#include // SIOCOUTQ — unsent bytes in a socket's send buffer (send_backlog) +#include +#endif #else #include +#include // std::getenv — CVC_WS_BROADCAST_DEBUG diagnostic gate #include #include #endif @@ -280,6 +285,25 @@ 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 +813,11 @@ 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() > 2 * 1024 * 1024) + continue; if (c->write(frame)) { _sent_frames.fetch_add(1, std::memory_order_relaxed); ++stats.delivered; @@ -881,6 +910,14 @@ 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. 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); ++n; @@ -980,6 +1017,9 @@ 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() > 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); } @@ -1017,6 +1057,9 @@ 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() > 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); } @@ -1121,6 +1164,93 @@ 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; + } + // 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 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; + if (ok) { + ++wrote; + _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; +} + void state_transport_ws::unregister_connection(connection *conn) { std::lock_guard lk(_conns_mu); _conns.erase(