From 436b893741fcafa3e88f5666ccc8d34e4674e4c0 Mon Sep 17 00:00:00 2001 From: fluid-chey Date: Wed, 4 Nov 2026 12:49:11 -0700 Subject: [PATCH] M9: pace queued sends without blocking, and address review comments Queued sync messages wait in a list the session loop drains, so Leave, a lost connection, presence and pings are never held up behind them. A text frame from the host stays final. A session that ended stays on its tab after a Save as: ending it there could turn a tab into a plain local file with no word about it. Co-Authored-By: Claude Opus 5.5 --- crates/collab-net/src/driver_tests.rs | 13 +++++++++++++ crates/collab-net/src/guest.rs | 19 ++++++++++++------- crates/collab-net/src/join.rs | 3 ++- crates/ui-egui/src/live.rs | 9 +++------ docs/collaboration-parity.md | 4 ++-- 5 files changed, 32 insertions(+), 16 deletions(-) diff --git a/crates/collab-net/src/driver_tests.rs b/crates/collab-net/src/driver_tests.rs index 57921df3d..df4d5c492 100644 --- a/crates/collab-net/src/driver_tests.rs +++ b/crates/collab-net/src/driver_tests.rs @@ -349,6 +349,19 @@ fn a_session_that_was_live_only_stops_for_a_refusal_that_is_for_good() { assert!(d.done(), "{reason}"); assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason: r }) if r.contains(reason)), "{reason}"); } + // What this end finds it can't talk to: a text frame, or a message it can't read. + for text in [true, false] { + let (mut d, inbox) = start(Script::default(), t0); + d.opened(t0); + d.message(&welcome(), t0); + if text { + d.text(t0); + } else { + d.message(&[0xFF, 1, 2, 3], t0); + } + assert!(d.done(), "text: {text}"); + assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { .. }))); + } } #[test] diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs index 84572b884..02685339f 100644 --- a/crates/collab-net/src/guest.rs +++ b/crates/collab-net/src/guest.rs @@ -9,6 +9,7 @@ //! Publishing ([`Guest::publish`]) first uploads the document to a server over a managing //! connection, then joins the new file with its edit link on the same thread. +use std::collections::VecDeque; use std::io; use std::net::TcpStream; use std::sync::Arc; @@ -280,17 +281,17 @@ fn session( let mut welcomed = false; let mut throttle = Throttle::new(PRESENCE_INTERVAL); let mut pace = Pace::new(Instant::now()); + // Sync messages waiting their turn (a long queue from while offline), and when each may go. + let mut paced: VecDeque<(Instant, ClientMsg)> = VecDeque::new(); let outcome = if send(&mut writer, &hello) { loop { let ping_at = last_ping + cfg.limits.keepalive; - let wake = earliest([throttle.due(), Some(ping_at)]).unwrap_or(ping_at); + let wake = earliest([throttle.due(), Some(ping_at), paced.front().map(|(at, _)| *at)]).unwrap_or(ping_at); let mut out = None; - let mut now_send = Vec::new(); match rx.recv_timeout(wake.saturating_duration_since(Instant::now())) { Ok(Cmd::Send(m)) if welcomed => { - // A long queue from while offline doesn't go out faster than the server takes it. - std::thread::sleep(pace.wait(Instant::now())); - now_send.push(m); + let now = Instant::now(); + paced.push_back((now + pace.wait(now), m)); } // Made for the previous connection (the sync client sends again what the server // didn't get once it has caught up with this connection's welcome). @@ -312,10 +313,14 @@ fn session( Ok(Cmd::Welcomed(_) | Cmd::Lost(..) | Cmd::Retry) => {} Err(RecvTimeoutError::Timeout) => {} } - if !now_send.iter().all(|m| send(&mut writer, m)) { + let now = Instant::now(); + let mut sent = true; + while sent && let Some((_, m)) = paced.pop_front_if(|(at, _)| *at <= now) { + sent = send(&mut writer, &m); + } + if !sent { break Outcome::Retry("the connection dropped".into()); } - let now = Instant::now(); let out = out.or_else(|| throttle.take_due(now)); let ok = out.is_none_or(|p| send(&mut writer, &ClientMsg::Presence(p))); let ok = ok diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index 63f94bec2..aa1c008eb 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -158,7 +158,7 @@ pub(crate) enum Next { /// gone, or the two versions can't talk. Anything else a server refuses a session with (it is /// busy, a limit was hit, the file couldn't be read just now) may pass, and servers of any version /// say so in these words, so a reason not listed here means try again. -const FINAL_REFUSALS: [&str; 11] = [ +const FINAL_REFUSALS: [&str; 12] = [ "isn't valid", "isn't on the server", "was removed from the server", @@ -170,6 +170,7 @@ const FINAL_REFUSALS: [&str; 11] = [ "couldn't be read", "message too large", "this version can't read", + "sent a text message", ]; /// Whether `reason`, from a server refusing a session that was live, is for good. diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index c80548323..ac00b2c4e 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -441,7 +441,9 @@ pub fn chat_box(app: &mut FigApp, ctx: &egui::Context) { pub fn connection_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) -> f32 { const H: f32 = 30.0; let live = app.session.live(); - let Some(uid) = app.session.docs.get(app.session.active).map(|d| d.uid).filter(|u| crate::tabs::live_lost(app, *u)) else { return 0.0 }; + if !app.session.docs.get(app.session.active).is_some_and(|d| crate::tabs::live_lost(app, d.uid)) { + return 0.0; + } let (text, retry, gone) = match &live.status { Status::Failed { reason } => { (trf("Not connected: {reason}. Your work is still in this tab.", &[("reason", &failure_text(reason))]), Some(tr("Try again")), true) @@ -451,11 +453,6 @@ pub fn connection_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) -> } _ => (tr("Disconnected. Reconnecting…").to_string(), None, false), }; - // Saved as a file of its own: it is one now. - if gone && app.session.docs.iter().any(|d| d.uid == uid && d.path.is_some()) { - app.run("collab.leave", json!({})); - return 0.0; - } let t = Tokens::get(ctx); let ink = text_on(t.danger); let (mut try_now, mut save, mut dismiss) = (false, false, false); diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index deab037ae..6c4aa9340 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); `figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (M9: a joined live session reconnects until you leave and says when it is disconnected, with a red tab mark and a strip across the canvas; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); `figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -12,7 +12,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Feature | Figma | FigCraft | Hours | |---|---|---|---| -| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | +| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, a live connection reconnecting until you leave), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. No accounts, teams or projects | 8–17 |