Skip to content

feat: unified frame pipe — single channel, WASM demux, shared frame type constants#721

Merged
rgbkrk merged 9 commits intomainfrom
feat/unified-frame-pipe
Mar 12, 2026
Merged

feat: unified frame pipe — single channel, WASM demux, shared frame type constants#721
rgbkrk merged 9 commits intomainfrom
feat/unified-frame-pipe

Conversation

@rgbkrk
Copy link
Member

@rgbkrk rgbkrk commented Mar 12, 2026

Replaces the multi-channel relay architecture with a single byte pipe. All frame types flow through one channel, one relay task, one Tauri event. Protocol knowledge moves to WASM.

What changed

Before: 2 channels (sync_tx, broadcast_tx), 2 relay tasks, 2 Tauri events (automerge:from-daemon, daemon:broadcast), presence not forwarded.

After: 1 channel (frame_tx), 1 relay task, 1 event (notebook:frame). AutomergeSync, Broadcast, and Presence all pipe through as raw typed frame bytes (type byte + payload). Response frames stay server-side for the request/response cycle.

Rust changes

  • notebook-doc::frame_types — shared constants (AUTOMERGE_SYNC, REQUEST, RESPONSE, BROADCAST, PRESENCE). Single source of truth.
  • PipeChannel { frame_tx } — replaces raw_sync_tx. Pipe mode is all-or-nothing.
  • Unified pipe-mode fast path — all frame types except Response pipe through frame_tx. Broadcasts that previously went through a separate broadcast_tx channel now flow through the unified pipe in daemon-sent order.
  • send_frame Tauri command — dispatches by frame type byte (0x00=sync, 0x04=presence). Old send_automerge_sync removed.
  • runtimed::connectionNotebookFrameType enum values reference frame_types constants.

WASM changes

  • receive_frame(bytes) → JSON — demuxes by frame type byte, applies sync internally, returns typed FrameEvent (sync_applied, sync_reply, broadcast, presence, unknown). All protocol knowledge in Rust.
  • Frame type constants use notebook_doc::frame_types (no duplication).
  • Presence encode functions (encode_cursor_presence, encode_selection_presence) remain as free #[wasm_bindgen] exports.

Frontend changes

  • useAutomergeNotebook — replaced automerge:from-daemon listener with notebook:frame + WASM receive_frame() demux. Broadcasts re-emitted as notebook:broadcast. Presence re-emitted as notebook:presence.
  • usePresence — new hook consuming notebook:presence events. Maintains remote peer state (cursors, selections). Exposes setCursor/setSelection for outgoing presence via WASM encode + send_frame. Infrastructure-only — no UI rendering yet.
  • syncToRelay — all paths migrated from send_automerge_sync to send_frame with frame type byte prefix.
  • frame-types.ts — TypeScript constants matching Rust.

Event naming convention

Room-scoped events use the notebook: prefix. Connection lifecycle stays daemon:.

Event Scope
notebook:frame Raw typed frames from relay
notebook:broadcast Kernel status, outputs, env progress
notebook:presence Peer cursors, selections
daemon:ready Connection established
daemon:disconnected Connection lost

What this enables

  • Zero plumbing overhead for new frame types
  • Frame ordering preserved (one channel = daemon-sent order)
  • Foundation for presence UI (cursors, selections from agents/peers)
  • WASM-first protocol handling

PR submitted by @rgbkrk's agent Quill, via Zed

Replaces the multi-channel relay architecture with one pipe:

Before: 2 channels (sync_tx, broadcast_tx), 2 relay tasks, 2 Tauri events
After:  1 channel (frame_tx), 1 relay task, 1 event (daemon:frame)

PipeChannel { frame_tx } replaces the old raw_sync_tx parameter.
All frame types (AutomergeSync, Broadcast, Presence) flow through
one channel preserving daemon-sent order. Response frames are still
consumed by the request/response cycle.

New send_frame Tauri command handles outgoing frames by type byte.
Old send_automerge_sync kept for backward compat during migration.

Frame type constants moved to notebook-doc::frame_types so all
consumers (daemon, WASM, Tauri, Python) share one source of truth.

WASM gains receive_frame() method that demuxes by frame type byte,
applies sync internally, and returns typed FrameEvent JSON for the
frontend — all protocol knowledge lives in Rust.

Pending frame buffering during request/response now prepends the
type byte so frames pipe correctly after the response arrives.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR refactors the notebook sync “pipe mode” to use a single unified byte channel/event carrying typed frames (type byte + payload), moving frame demux/protocol handling into the WASM layer and centralizing frame type constants in notebook-doc.

Changes:

  • Introduces a unified pipe channel (PipeChannel { frame_tx }) and forwards non-response daemon frames through a single path/event.
  • Adds shared frame type constants (notebook_doc::frame_types) and updates NotebookFrameType to reference them.
  • Adds WASM receive_frame() demux that applies sync internally and emits typed JSON “frame events”; adds new Tauri send_frame command for sending typed frames.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
crates/runtimed/src/notebook_sync_client.rs Replaces raw-sync-only piping with a unified PipeChannel and pipes typed frames (except responses).
crates/runtimed/src/connection.rs Switches NotebookFrameType discriminants and parsing to use shared notebook_doc::frame_types constants.
crates/runtimed-wasm/src/lib.rs Adds receive_frame() typed-frame demux and returns JSON-serialized FrameEvent results; keeps presence encoders as exports.
crates/notebook/src/lib.rs Updates the Tauri-side relay to emit a single daemon:frame event and adds the send_frame Tauri command.
crates/notebook-doc/src/lib.rs Exposes the new frame_types module.
crates/notebook-doc/src/frame_types.rs Defines canonical frame type byte constants shared across consumers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

rgbkrk added 2 commits March 12, 2026 00:02
Replaces the automerge:from-daemon listener with daemon:frame which
receives all frame types through the unified pipe. The WASM
handle.receive_frame() demuxes by frame type byte, applies sync
internally, and returns typed FrameEvent JSON.

Broadcasts are re-emitted as daemon:broadcast for backward compat
with useDaemonKernel and useEnvProgress. Presence events re-emitted
as daemon:presence for usePresence.

Sync replies from receive_frame (SyncReply events) are sent back
to the daemon via send_frame with the type byte prepended.

frame-types.ts provides shared constants matching
notebook_doc::frame_types in Rust.

WASM artifacts rebuilt with receive_frame() and presence exports.
… update docs

- Buffer Broadcast and Presence frames during wait_for_response_with_broadcast
  as typed frame bytes (type byte + payload) in pending_sync_frames, so they
  reach the frontend via the unified pipe after the response arrives.
  Previously only AutomergeSync was buffered; Broadcast went through a
  separate broadcast_tx and Presence was dropped.
- Rename connect_split_with_raw_sync → connect_split_with_pipe (Unix + Windows)
- Update setup_sync_receivers doc comment to describe unified relay
- Update receive_frame doc comment to clarify SyncReply wire format
- Remove unnecessary braces around process_incoming_frame block
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 12 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

rgbkrk added 4 commits March 12, 2026 00:31
…pe_frames

pending_sync_frames now stores fully-typed frames (type byte + payload)
for AutomergeSync, Broadcast, and Presence. The drain loop was
prepending another AutomergeSync type byte, corrupting frames and
mislabeling non-sync frames.

Fix: drain loop forwards as-is. Rename to pending_pipe_frames to
reflect that it buffers all frame types, not just sync.
- Create usePresence.ts: listens for daemon:presence events, maintains
  remote peer state (cursors/selections), exposes setCursor/setSelection
  for outgoing presence via send_frame
- Migrate remaining send_automerge_sync calls to send_frame in save()
  and notebook-metadata syncToRelay()
- Remove dead send_automerge_sync Tauri command (frontend fully migrated)
- Update doc comments to reference send_frame
Room-scoped events belong to the notebook: namespace. The daemon:
prefix is reserved for connection lifecycle events (daemon:ready,
daemon:disconnected).

- daemon:frame → notebook:frame
- daemon:broadcast → notebook:broadcast
- daemon:presence → notebook:presence
@rgbkrk rgbkrk marked this pull request as ready for review March 12, 2026 15:45
@rgbkrk rgbkrk requested a review from Copilot March 12, 2026 15:45
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 16 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines 309 to 314
@@ -259,15 +310,15 @@ export function useAutomergeNotebook() {
cancelled = true;
unlistenReady.then((fn) => fn());
unlistenFileOpened.then((fn) => fn());
unlistenSync.then((fn) => fn());
unlistenFrame.then((fn) => fn());
unlistenClearOutputs.then((fn) => fn());
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

Cleanup of Tauri event listeners doesn’t follow the repository’s established pattern of handling the Promise returned by listen() with a .catch(() => {}) to avoid unhandled rejections (see apps/notebook/src/App.tsx:847-851). Consider updating these unlisten*.then(...) calls (including the newly added unlistenFrame) to also attach a catch handler.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — added .catch(() => {}) to all four unlisten calls in the cleanup.

Comment on lines +667 to +671
while let Some(frame_bytes) = raw_frame_rx.recv().await {
if let Err(e) =
emit_to_label::<_, _, _>(&window, window.label(), "daemon:broadcast", &broadcast)
emit_to_label::<_, _, _>(&window, window.label(), "notebook:frame", &frame_bytes)
{
warn!("[notebook-sync] Failed to emit daemon:broadcast: {}", e);
warn!("[notebook-sync] Failed to emit notebook:frame: {}", e);
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

The PR description mentions a daemon:frame event, but the relay here emits notebook:frame (and the frontend hook listens to notebook:frame). Please align the naming (either change the emitted event or update the PR description/docs) so downstream consumers aren’t confused about which event to subscribe to.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

PR description was updated before this review landed. The code emits notebook:frame and the description now matches.

Comment on lines +2889 to +2911
// Pipe mode (Tauri): forward all frame types except Response
// as raw typed frame bytes (type byte + payload) through
// one channel, preserving daemon-sent order. Response frames
// are consumed by the request/response cycle.
if let Some(ref pipe) = pipe_channel {
match frame.frame_type {
NotebookFrameType::Response => {
// Fall through to process_incoming_frame —
// needed for send_request/wait_for_response
}
NotebookFrameType::Request => {
warn!(
"[notebook-sync-task] Unexpected Request frame from daemon"
);
continue;
}
_ => {
// AutomergeSync, Broadcast, Presence — pipe raw
let mut frame_bytes = vec![frame.frame_type as u8];
frame_bytes.extend_from_slice(&frame.payload);
let _ = pipe.frame_tx.send(frame_bytes);
continue;
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

Pipe mode behavior changed significantly (forwarding all non-Response frames as typed bytes, plus buffering/draining pending_pipe_frames during request/response). This file has unit tests, but none appear to cover the new unified pipe semantics (e.g., ensuring piped frames are correctly type-prefixed and that buffered frames are flushed in daemon-sent order after SendRequest). Adding a focused test would help prevent regressions.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Good call — pipe mode tests for type-prefixed forwarding and buffered frame drain ordering would be valuable. Tracking as follow-up.

Comment on lines 148 to 151
const unlistenBroadcast = webview.listen<DaemonBroadcast>(
"daemon:broadcast",
"notebook:broadcast",
(event) => {
if (cancelled) return;
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

This hook has been migrated to notebook:broadcast, but there’s still a comment later in this file stating that cell data comes through automerge:from-daemon, which is no longer accurate with the unified notebook:frame pipe. Please update/remove that stale reference to avoid confusion during future maintenance.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed — updated the stale automerge:from-daemon reference to notebook:frame.

/**
* Receive a typed frame from the daemon, demux by type byte, return events for the frontend.
*
* The input is the raw frame bytes from the `daemon:frame` Tauri event:
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

The JSDoc for receive_frame() says frames come from the daemon:frame Tauri event, but the codebase emits/listens on notebook:frame (e.g. useAutomergeNotebook listens to notebook:frame and the Rust relay emits notebook:frame). Update this docstring (and any related docs) to match the actual event name so consumers don’t wire the listener incorrectly.

Suggested change
* The input is the raw frame bytes from the `daemon:frame` Tauri event:
* The input is the raw frame bytes from the `notebook:frame` Tauri event:

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

The Rust source (crates/runtimed-wasm/src/lib.rs) already says notebook:frame. The generated JS/TS artifacts will pick up the fix on the next wasm-pack build.

rgbkrk added 2 commits March 12, 2026 09:06
- Add .catch(() => {}) to unlisten cleanup calls per repo pattern
- Update stale automerge:from-daemon reference to notebook:frame
- WASM artifacts still have daemon:frame in docs — will fix on next
  wasm-pack rebuild
@rgbkrk rgbkrk merged commit 81b73cf into main Mar 12, 2026
16 checks passed
@rgbkrk rgbkrk deleted the feat/unified-frame-pipe branch March 12, 2026 17:03
rgbkrk added a commit that referenced this pull request Mar 12, 2026
Replace stale event/command names across all docs to match the
unified frame pipe architecture from PR #721:

- automerge:from-daemon → notebook:frame
- daemon:broadcast → notebook:broadcast (re-emitted by useAutomergeNotebook)
- send_automerge_sync → send_frame (type-byte-prefixed)
- receive_sync_message() → receive_frame() (WASM demux)

Also adds 0x04 Presence to the typed frames table, updates data flow
diagrams to show single-ingress WASM demux pattern, and adds missing
key file references (frame_types.rs, usePresence.ts, etc.).
rgbkrk added a commit that referenced this pull request Mar 12, 2026
* docs: update references for unified frame pipe

Replace stale event/command names across all docs to match the
unified frame pipe architecture from PR #721:

- automerge:from-daemon → notebook:frame
- daemon:broadcast → notebook:broadcast (re-emitted by useAutomergeNotebook)
- send_automerge_sync → send_frame (type-byte-prefixed)
- receive_sync_message() → receive_frame() (WASM demux)

Also adds 0x04 Presence to the typed frames table, updates data flow
diagrams to show single-ingress WASM demux pattern, and adds missing
key file references (frame_types.rs, usePresence.ts, etc.).

* docs: address copilot review feedback

- Add { frameData } arg to invoke("send_frame") in AGENTS.md,
  frontend-architecture.md, and protocol.md
- Clarify environments.md mermaid/sequence diagrams to show the
  notebook:frame → notebook:broadcast re-emission hop
@rgbkrk rgbkrk mentioned this pull request Mar 12, 2026
48 tasks
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