feat: unified frame pipe — single channel, WASM demux, shared frame type constants#721
feat: unified frame pipe — single channel, WASM demux, shared frame type constants#721
Conversation
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.
There was a problem hiding this comment.
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 updatesNotebookFrameTypeto reference them. - Adds WASM
receive_frame()demux that applies sync internally and emits typed JSON “frame events”; adds new Taurisend_framecommand 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.
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
There was a problem hiding this comment.
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.
…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
There was a problem hiding this comment.
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.
| @@ -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()); | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed — added .catch(() => {}) to all four unlisten calls in the cleanup.
| 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
PR description was updated before this review landed. The code emits notebook:frame and the description now matches.
| // 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; | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Good call — pipe mode tests for type-prefixed forwarding and buffered frame drain ordering would be valuable. Tracking as follow-up.
| const unlistenBroadcast = webview.listen<DaemonBroadcast>( | ||
| "daemon:broadcast", | ||
| "notebook:broadcast", | ||
| (event) => { | ||
| if (cancelled) return; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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.
| * 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: |
There was a problem hiding this comment.
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.
- 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
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: 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
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 }— replacesraw_sync_tx. Pipe mode is all-or-nothing.frame_tx. Broadcasts that previously went through a separatebroadcast_txchannel now flow through the unified pipe in daemon-sent order.send_frameTauri command — dispatches by frame type byte (0x00=sync, 0x04=presence). Oldsend_automerge_syncremoved.runtimed::connection—NotebookFrameTypeenum values referenceframe_typesconstants.WASM changes
receive_frame(bytes) → JSON— demuxes by frame type byte, applies sync internally, returns typedFrameEvent(sync_applied, sync_reply, broadcast, presence, unknown). All protocol knowledge in Rust.notebook_doc::frame_types(no duplication).encode_cursor_presence,encode_selection_presence) remain as free#[wasm_bindgen]exports.Frontend changes
useAutomergeNotebook— replacedautomerge:from-daemonlistener withnotebook:frame+ WASMreceive_frame()demux. Broadcasts re-emitted asnotebook:broadcast. Presence re-emitted asnotebook:presence.usePresence— new hook consumingnotebook:presenceevents. Maintains remote peer state (cursors, selections). ExposessetCursor/setSelectionfor outgoing presence via WASM encode +send_frame. Infrastructure-only — no UI rendering yet.syncToRelay— all paths migrated fromsend_automerge_synctosend_framewith frame type byte prefix.frame-types.ts— TypeScript constants matching Rust.Event naming convention
Room-scoped events use the
notebook:prefix. Connection lifecycle staysdaemon:.notebook:framenotebook:broadcastnotebook:presencedaemon:readydaemon:disconnectedWhat this enables
PR submitted by @rgbkrk's agent Quill, via Zed