Skip to content

feat: per-node config revision system for causal consistency (Phase 2)#186

Open
staging-devin-ai-integration[bot] wants to merge 2 commits intomainfrom
devin/1774305158-compositor-causal-consistency-phase2
Open

feat: per-node config revision system for causal consistency (Phase 2)#186
staging-devin-ai-integration[bot] wants to merge 2 commits intomainfrom
devin/1774305158-compositor-causal-consistency-phase2

Conversation

@staging-devin-ai-integration
Copy link
Contributor

@staging-devin-ai-integration staging-devin-ai-integration bot commented Mar 23, 2026

Summary

Phase 2 of the compositor view-data causal consistency design. Implements the (_sender, _rev) tuple mechanism to prevent stale server-computed values from overwriting client state during high-frequency interactions (slider drags, live resizes).

Rust changes:

  • websocket_handlers.rs: Sanitize _-prefixed params (transient sync metadata) before storing in the durable pipeline model. Unsanitized params are still broadcast/forwarded to the engine so the compositor receives _sender/_rev.
  • compositor/mod.rs: Extract _sender/_rev from UpdateParams, store on CompositorNode, and stamp outgoing view data JSON with the same values. Always overwrite (not conditionally set) so that non-stamped UpdateParams clears stale values to defaults.

TypeScript changes:

  • websocket.ts: Add clientNonce (UUID v4) to WebSocketService, regenerated on each WS connect. Exposed via getClientNonce(). Stale echo-back gate in handleNodeParamsChanged — if incoming _sender matches our nonce and _rev <= local counter, skip the update. Also strips _-prefixed fields before writing to local state.
  • useConfigRev.ts (new): Per-node monotonic revision counter (Map<nodeId, number>). Exports getLocalConfigRev, bumpConfigRev, resetAllConfigRevs, getClientNonce.
  • compositorCommit.ts: Every onConfigChange commit path (commitLayers, commitOverlays, commitAll) now bumps the rev and injects _sender/_rev into outgoing config. The onParamChange fallback path intentionally does NOT send standalone _sender/_rev messages because each tuneNode call replaces the server's full node.params — standalone metadata messages would wipe durable params to {} after stripping.
  • compositorServerSync.ts: Stale view-data gate — if view data's _sender matches our nonce and _rev <= local counter, skip applying. Also accepts optional activeInteractionRef to suppress view data during live interactions.
  • useCompositorLayers.ts: Added activeInteractionRef (per-node boolean ref), threaded to useServerLayoutSync, and exposed in the hook result for consumer components to set during slider drags.
  • MonitorView.tsx: Strip _-prefixed fields from YAML export to avoid leaking transient metadata.

Benchmark results (compositor_pipeline, 300 frames, 1280×720):

wall-clock   : 1.534s  (min=1.300s  max=1.751s  σ=0.2259s)
throughput   : 195.6 fps
per-frame    : 5.11 ms/frame

Review & Testing Checklist for Human

  • Stale echo suppression: Open two browser tabs on Monitor view, edit compositor params in one tab. Verify the editing tab doesn't flicker/revert during rapid slider drags (opacity, rotation). The other tab should still receive updates.
  • View data gating: During a rapid slider drag, verify that server view-data echoes don't snap the layer position back to a stale value mid-interaction.
  • Multi-client sync: Edit params in tab A, verify tab B picks them up. Then edit in tab B, verify tab A picks them up. Neither should suppress the other's updates.
  • YAML export: Check the YAML pane in Monitor view — it should NOT contain _sender or _rev fields in any node's params.
  • Reconnect behavior: Disconnect and reconnect the WebSocket (e.g. toggle network). After reconnect, verify the nonce resets (old stale echoes from pre-reconnect are not suppressed).

Notes

  • This PR builds on Phase 1 (PR feat(compositor): add source dimensions, layout re-emission, and client aspect-fit prediction #185) which added source dimensions and client aspect-fit.
  • Phase 3 (removing TuneNodeSilent) will follow as a separate PR once this is validated, since the rev-gating mechanism here replaces the need for silent echo suppression.
  • The activeInteractionRef is exposed but not yet wired to slider onPointerDown/onPointerUp in the UI components — that wiring will be done in Phase 3 or can be added incrementally.
  • Fixed two bugs found during review: (1) stale config_sender/config_rev preserved when UpdateParams lacks metadata, (2) standalone _sender/_rev messages in onParamChange path would wipe server durable params.

Link to Devin session: https://staging.itsdev.in/sessions/c3bbffbb30594d16845774b5bca4e32f
Requested by: @streamer45


Staging: Open in Devin

Implements the (_sender, _rev) tuple mechanism to prevent stale server-
computed values from overwriting client state during high-frequency
interactions.

Rust changes:
- Sanitize _-prefixed params before pipeline storage (websocket_handlers.rs)
- Compositor extracts _sender/_rev from params, stamps view data JSON
  (compositor/mod.rs)

TypeScript changes:
- Add clientNonce (UUID) to WebSocketService, regenerated on connect
- Add useConfigRev hook with per-node monotonic rev counters
- Inject _sender/_rev in every commit adapter path (compositorCommit.ts)
- Gate stale self-echoes in handleNodeParamsChanged (websocket.ts)
- Gate stale view data in useServerLayoutSync (compositorServerSync.ts)
- Add activeInteractionRef for live-mode interaction masking
- Strip _-prefixed fields from YAML export (MonitorView.tsx)

Tests:
- useConfigRev unit tests (singleton counter behavior)
- compositorCommit unit tests (stamping in all commit paths)
- compositorServerSync unit tests (mapServerLayers pure helpers)
- Integration tests for stale view-data gating and activeInteractionRef

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
@staging-devin-ai-integration
Copy link
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 5 additional findings in Devin Review.

Staging: Open in Devin
Debug

Playground

Comment on lines +744 to 753
if let Ok(mut json) = serde_json::to_value(&scene.layout) {
// Stamp view data with the sender/rev from the last
// UpdateParams so clients can detect stale self-echoes.
if !self.config_sender.is_empty() {
json["_sender"] = serde_json::Value::from(self.config_sender.as_str());
json["_rev"] = serde_json::Value::from(self.config_rev);
}
view_data_helpers::emit_view_data(&view_data_tx, &node_name, || json);
}
last_layout = Some(scene.layout.clone());
Copy link
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration bot Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Multi-client concurrent edit: last-writer-wins stamp can cause missed geometry updates

The compositor node stores only the most recent config_sender/config_rev from UpdateParams. When two clients send concurrent updates, the second writer's metadata overwrites the first's. The resulting view data is stamped with the second writer's identity. The second writer's client may gate this view data (if rev <= localRev), missing geometry effects contributed by the first writer's change.

Example: Client A sends _sender=A, _rev=5, then Client B sends _sender=B, _rev=3. Both arrive before a tick. View data is emitted with _sender=B, _rev=3. Client B gates it (if localRev ≥ 3) and misses A's geometry contribution. Client A applies it (different sender). This is an inherent limitation of single-sender stamping and would require vector clocks or similar for a complete solution. For the target use case (preventing stale self-echoes during high-frequency slider drags by a single user), the approach is sufficient.

Staging: Open in Devin

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

…ends

- Rust: Always overwrite config_sender/config_rev on UpdateParams instead
  of conditionally setting them. Non-stamped UpdateParams now clears to
  defaults, preventing stale sender/rev from being emitted in view data.

- TS: Remove standalone _sender/_rev messages from the onParamChange path.
  Each tuneNode call replaces the server's full node.params, so sending
  _sender/_rev as separate messages would wipe durable params to {} after
  stripping. Only the onConfigChange path (tuneNodeConfig) carries stamped
  metadata since it sends the full config in a single message.

- Remove unused useConfigRev hook export (fixes Knip CI failure). The
  standalone functions (getLocalConfigRev, bumpConfigRev, etc.) remain.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
Copy link
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

⚠️ 1 issue in files not directly in the diff

⚠️ tuneNodeConfig/tuneNodeConfigSilent persist transient _sender/_rev into local Jotai param store (ui/src/hooks/useSession.ts:102)

In useSession.ts:102 and useSession.ts:129, writeNodeParams(nodeId, config, sessionId) writes the full stamped config — including _sender and _rev — into the Jotai nodeParamsAtom. Because writeNodeParams (ui/src/stores/sessionAtoms.ts:72) performs a shallow merge ({ ...current, ...params }), these transient keys persist indefinitely in the atom and are never cleaned up. Any consumer of the atom (e.g. InspectorPane.tsx:275, pipelineBuilder.ts:132) sees them. While critical downstream paths handle this (YAML export strips _-prefixed keys, stamp() overwrites them in outgoing configs), the local store is polluted with metadata that was intended to be purely in-flight.

View 7 additional findings in Devin Review.

Staging: Open in Devin
Debug

Playground

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Stale _sender/_rev on view data after pin-management layout changes causes client to suppress legitimate updates

When a pin management event (input added/removed) triggers a layout change in the compositor, the emitted view data inherits the stale config_sender/config_rev from the last UpdateParams. The client that last sent a config update will suppress this view data via the stale echo gate in ui/src/hooks/compositorServerSync.ts:153-157, missing the layout change.

Scenario walkthrough
  1. Client A sends UpdateParams with _sender: "A", _rev: 5 → compositor stores config_sender="A", config_rev=5, marks dirty, emits view data stamped A/5.
  2. Client A's gate: sender==A && rev(5) <= localRev(5) → suppressed (correct — client already has this layout).
  3. A new input connects → handle_pin_management runs at line 664, sets layer_configs_dirty = true but does NOT clear config_sender/config_rev.
  4. On next tick, resolve_scene rebuilds layout (new auto-PiP layer), layout differs from last_layout → view data emitted stamped with stale A/5.
  5. Client A's gate: sender==A && rev(5) <= localRev(5) → suppressed (incorrect — this is a new layout from the pin event that the client needs).

Client A misses the layout update until its next config change bumps the rev.

(Refers to lines 664-671)

Staging: Open in Devin

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Synchronous handle_tune_node lacks _-prefix stripping (inconsistency with fire-and-forget handler)

The synchronous handle_tune_node at apps/skit/src/websocket_handlers.rs:850 stores params.clone() directly into the pipeline model without stripping _-prefixed keys, unlike its fire-and-forget counterpart at line 983-986 which does map.retain(|k, _| !k.starts_with('_')). While the current client never sends _sender/_rev through the synchronous TuneNode action (the stamped configs use tunenodeasync and tunenodesilent), this inconsistency means that if any API client sends _sender/_rev via the synchronous path, those keys would leak into the durable pipeline model and appear in GetPipeline responses. Consider applying the same sanitization for defensive consistency.

(Refers to lines 847-857)

Staging: Open in Devin

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants