From f1eaf3fcb4fc7987bec4867f0eab994707de2941 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 12:33:29 +0700 Subject: [PATCH] M8: cap the people a client keeps track of and the deleted ids a session remembers (#1622) * M8: cap the people a client keeps track of and the deleted ids a session remembers (#1121) A hostile host or server could grow a client's presence, seen-peers and who-is-here lists without bound, and a session's deleted-id set only grew. Clients keep at most MAX_PEERS (4,096) others, leaving newcomers out with a warning; max_people is at most that; a session remembers at most 262,144 deleted ids, the lowest forgotten first. * M8: a max_people above what clients track is lowered with a warning, not refused (#1121) Refusing it would stop a server whose existing config says more from starting after an upgrade. Config::people() lowers max_people to MAX_PEERS for the core's limits, and the startup warnings name the configured and the used value. * M8: the live welcome's handling in its own function (Sonar S3776) --- ROADMAP.md | 3 +- apps/figcraft-server/src/config.rs | 17 ++++++- apps/figcraft-server/src/public_tests.rs | 7 +++ crates/collab-net/src/client_cap_tests.rs | 61 +++++++++++++++++++++++ crates/collab-net/src/inbox.rs | 16 +++++- crates/collab-net/src/join.rs | 15 ++++-- crates/collab-net/src/lib.rs | 2 + crates/collab/src/deleted_cap_tests.rs | 50 +++++++++++++++++++ crates/collab/src/lib.rs | 2 + crates/collab/src/message.rs | 5 ++ crates/collab/src/server.rs | 16 +++++- crates/engine/src/lib.rs | 2 + crates/engine/src/live_peer_cap_tests.rs | 36 +++++++++++++ crates/engine/src/sharing/live.rs | 6 +-- crates/engine/src/sharing/live/events.rs | 41 ++++++++------- crates/engine/src/sharing/live/types.rs | 2 + docs/collab-protocol.md | 6 +++ docs/roadmap.md | 3 +- docs/server.md | 2 +- 19 files changed, 261 insertions(+), 31 deletions(-) create mode 100644 crates/collab-net/src/client_cap_tests.rs create mode 100644 crates/collab/src/deleted_cap_tests.rs create mode 100644 crates/engine/src/live_peer_cap_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 2aa6b318b..4c7d4dced 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); 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; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); 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); 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); 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); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); 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); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); 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; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); 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); 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); 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); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); 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); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1121: client-side maps were unbounded. A hostile host or server could mint people without end: the presence waiting for the UI, the people a connection had seen and the session's list of who is here each grew with every new id; and a session server's set of deleted layer ids (kept so undo may create them again) only grew. Now a client keeps track of at most `MAX_PEERS` (4,096) others: past it, someone new is left out with a logged warning (in a welcome too), someone already here can still be renamed, and someone leaving makes room; a server's `max_people` above 4,096 is lowered to it with a startup warning (the server still starts). A session remembers at most 262,144 deleted ids, forgetting the lowest first (on restore too). Threat model in `collab-protocol.md`. Tests: `client_cap_tests.rs` (collab-net), `live_peer_cap_tests.rs` (engine), `deleted_cap_tests.rs` (collab), the server's `max_people` clamp. | | 2026-10-30 | minor | M8, #1117: `doc.list` returned the path on disk of every open file to any caller, plugins and attached AI agents included. It now gives an agent's calls (MCP, or a control call naming an `agent`) and plugins each file's `id`, name and state without `path`; the user's own scripts (the CLI, control calls without an agent) still get paths, and the app's UI reads its tabs directly. Numbers unchanged. Tests: `doc_list_paths_tests`. | | 2026-10-30 | minor | M8, #1571 (part): a large selection redid the same work on every frame. A pointer move draws two to five egui frames, and each one worked out every selected layer's outline, the selection box and size, the slots shown, and the Design panel's instance checks and position values (X, Y, rotation, constraints, alignment) again, though only the first frame after a move or an edit can differ. Now they are kept with the document snapshot, the selection and, for the overlay, the view's mapping to the screen and the overlay green, and made again when any of those changes (`selection_cache`); the hover and drop-target highlights are drawn every frame as before. Nothing looks different. Dragging 648 layers of a synthetic file of about 1,900 (release build, control-driven, two frames per move): the canvas overlays 1.99 to 0.86 ms a frame, the Design panel 2.54 to 1.40 ms. Not yet: the frame of each move still works everything out (the selection spacing, the export section, the outlines), the canvas texture is uploaded whole after each render. Tests: `canvas/selection_cache_tests.rs`. | | 2026-10-30 | minor | M8, #1089: libraries and plugins larger than one address's download burst (16 MiB) could never be downloaded: the per-address token bucket never held more than its burst, so a private server's 40 or 64 MiB team library was refused for good. Now a download larger than the burst is served once the address's budget is full, leaving it in debt that refills at 1 MiB a second before its next download (`Bucket::take_spread`, also for everyone's library budget); within the burst nothing changes. Over any stretch of time an address still gets at most the largest object plus the rate times that time. `server.md` (rates, threat model). Tests: `download_burst_tests.rs` (the bucket's loan and payback, ten minutes of 40 MiB requests kept to the rate, a 40 MiB library served and then the address waits). | diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index 01ef3cbda..e2c48e050 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -147,6 +147,14 @@ impl Config { client_max - 1 )); } + if self.people() < self.max_people { + out.push(format!( + "max_people is {}: the app keeps track of at most {} people in a file, so {} are let in", + self.max_people, + self.people(), + self.people() + )); + } if !self.open_publish { return out; } @@ -171,6 +179,12 @@ impl Config { out } + /// Most people in one file at once: `max_people`, lowered to what the app keeps track of + /// ([`figcraft_collab::message::MAX_PEERS`]). + pub fn people(&self) -> usize { + self.max_people.min(figcraft_collab::message::MAX_PEERS) + } + /// The trusted proxy's address, when set. pub fn proxy(&self) -> Result, String> { let Some(p) = self.trusted_proxy.as_deref().map(str::trim).filter(|p| !p.is_empty()) else { return Ok(None) }; @@ -284,8 +298,7 @@ impl Config { /// most, and those batches also at most a quarter of a file's bytes (256 KiB at least). fn core(&self) -> figcraft_collab::Limits { let bytes = |mb: u64| usize::try_from(mb.saturating_mul(1 << 20)).unwrap_or(usize::MAX); - let base = - figcraft_collab::Limits { max_peers: self.max_people, max_log_bytes: bytes(self.max_log_mb), ..figcraft_collab::Limits::default() }; + let base = figcraft_collab::Limits { max_peers: self.people(), max_log_bytes: bytes(self.max_log_mb), ..figcraft_collab::Limits::default() }; if !self.open_publish { return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base }; } diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index b63f09932..be69b030c 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -481,6 +481,13 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() { assert_eq!(set.file_mb(), 8); assert!(Config { trusted_proxy: Some("localhost".into()), ..set.clone() }.check().is_err()); assert!(Config { terms_url: "javascript:alert(1)".into(), ..set.clone() }.check().is_err()); + // Clients keep track of at most `MAX_PEERS` others (#1121). + let people = figcraft_collab::message::MAX_PEERS; + let many = Config { max_people: people + 1, ..set.clone() }; + assert!(many.check().is_ok(), "a server configured for more still starts"); + assert_eq!(many.people(), people, "with that many"); + assert!(many.warnings().iter().any(|w| w.contains("max_people is 4097") && w.contains("4096 are let in")), "{:?}", many.warnings()); + assert_eq!(Config { max_people: people, ..set.clone() }.people(), people); assert!(Config { idle_days: f64::NAN, ..set }.check().is_err()); } diff --git a/crates/collab-net/src/client_cap_tests.rs b/crates/collab-net/src/client_cap_tests.rs new file mode 100644 index 000000000..920d53483 --- /dev/null +++ b/crates/collab-net/src/client_cap_tests.rs @@ -0,0 +1,61 @@ +//! A hostile server can't grow a guest's memory without bound (#1121): the presence waiting for +//! the UI and the people a connection has seen hold at most [`MAX_PEERS`] others; past it, +//! someone new is left out, and someone leaving (or the UI taking the presence) makes room. + +use std::sync::{Arc, Mutex}; + +use figcraft_collab::message::{Peer, Presence, ServerMsg}; +use figcraft_engine::live::{Event, MAX_PEERS}; + +use crate::guest::GuestConfig; +use crate::inbox::Inbox; +use crate::invite::Invite; +use crate::join::{Seen, take}; + +fn inbox() -> Inbox { + Inbox::new(Arc::new(|| {})) +} + +#[test] +fn presence_from_more_people_than_the_cap_is_left_out() { + let inbox = inbox(); + let over = MAX_PEERS as u32 + 500; + for id in 0..over { + inbox.presence(id, Presence::default()); + } + // Someone already waiting is still updated. + let moved = Presence { selection: Some(Vec::new()), ..Presence::default() }; + inbox.presence(3, moved.clone()); + let out = inbox.drain(); + assert_eq!(out.len(), MAX_PEERS); + assert!(out.iter().all(|e| matches!(e, Event::Presence(id, _) if (*id as usize) < MAX_PEERS))); + assert!(out.iter().any(|e| matches!(e, Event::Presence(3, p) if *p == moved))); + // Taken by the UI: room again. + inbox.presence(over, Presence::default()); + assert!(matches!(inbox.drain().as_slice(), [Event::Presence(id, _)] if *id == over)); +} + +#[test] +fn joins_past_the_cap_are_left_out_until_someone_leaves() { + let inbox = inbox(); + let cfg = GuestConfig::new(Invite::new("127.0.0.1:47900", "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv"), "Ada".into()); + let resume = Mutex::new(None); + let mut seen = Seen::default(); + let mut send = |m: ServerMsg| { + take(m, &cfg, &inbox, &mut seen, &resume, &|| {}); + }; + let joined = |id: u32| ServerMsg::PeerJoined(Peer { client_id: id, name: format!("P{id}"), color: String::new() }); + let over = MAX_PEERS as u32 + 500; + for id in 0..over { + send(joined(id)); + } + let count = |events: &[Event]| events.iter().filter(|e| matches!(e, Event::PeerJoined(_))).count(); + assert_eq!(count(&inbox.drain()), MAX_PEERS, "joins past the cap reach the UI"); + // Someone already here may be renamed; someone left out stays out until someone leaves. + send(joined(5)); + send(joined(over)); + assert!(matches!(inbox.drain().as_slice(), [Event::PeerJoined(p)] if p.client_id == 5)); + send(ServerMsg::PeerLeft { client_id: 7 }); + send(joined(over)); + assert!(matches!(inbox.drain().as_slice(), [Event::PeerLeft(7), Event::PeerJoined(p)] if p.client_id == over)); +} diff --git a/crates/collab-net/src/inbox.rs b/crates/collab-net/src/inbox.rs index 1ec00e67c..860a80cfe 100644 --- a/crates/collab-net/src/inbox.rs +++ b/crates/collab-net/src/inbox.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, VecDeque}; use std::sync::{Arc, Mutex, PoisonError}; -use figcraft_engine::live::{Event, Presence}; +use figcraft_engine::live::{Event, MAX_PEERS, Presence}; /// Wakes the UI (the desktop app passes egui's `request_repaint`). pub type Waker = Arc; @@ -23,7 +23,10 @@ struct State { syncs: VecDeque<(u64, Event)>, /// Every other event, capped at [`MAX_EVENTS`]. others: VecDeque<(u64, Event)>, + /// The latest presence of each peer, for at most [`MAX_PEERS`] peers. presence: BTreeMap, + /// A presence was left out since the last drain (warned once). + presence_full: bool, } /// One session's mailbox (a new session gets a new one, so a stopped session's threads can't @@ -64,9 +67,17 @@ impl Inbox { }); } - /// Peer `id`'s latest presence (replaces one not taken yet). + /// Peer `id`'s latest presence (replaces one not taken yet). Past [`MAX_PEERS`] peers waiting, + /// another peer's is left out. pub fn presence(&self, id: u32, p: Presence) { self.with(|s| { + if s.presence.len() >= MAX_PEERS && !s.presence.contains_key(&id) { + if !s.presence_full { + log::warn!("live session: presence of more than {MAX_PEERS} people; leaving the rest out"); + s.presence_full = true; + } + return; + } s.presence.insert(id, p); }); } @@ -75,6 +86,7 @@ impl Inbox { /// peer. pub fn drain(&self) -> Vec { let mut s = self.state.lock().unwrap_or_else(PoisonError::into_inner); + s.presence_full = false; let mut syncs = std::mem::take(&mut s.syncs).into_iter().peekable(); let mut others = std::mem::take(&mut s.others).into_iter().peekable(); let mut out = Vec::with_capacity(syncs.len() + others.len() + s.presence.len()); diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index 43b0b7b88..29c477f73 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -7,7 +7,7 @@ use std::time::Duration; use figcraft_collab::Client; use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg}; -use figcraft_engine::live::{Event, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN}; +use figcraft_engine::live::{Event, MAX_PEERS, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN}; use crate::inbox::Inbox; use crate::invite::Invite; @@ -194,7 +194,7 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se *r = Some(Resume { session, client_id, seq, secret }); } // The sync client opens the document (and fills in its name and pages). - let peers = peers.clone(); + let peers: Vec = peers.iter().take(MAX_PEERS).cloned().collect(); let inv = &cfg.invite; let info = SessionInfo { role: Role::Guest, @@ -224,7 +224,16 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se inbox.push(Event::Welcome { info: info.clone(), peers: seen.peers.clone() }); } _ => { - seen.peers.retain(|q| q.client_id != p.client_id); + match seen.peers.iter().position(|q| q.client_id == p.client_id) { + Some(i) => { + seen.peers.remove(i); + } + None if seen.peers.len() >= MAX_PEERS => { + log::warn!("live session: {MAX_PEERS} people already here; leaving out another"); + return Taken::Continue; + } + None => {} + } seen.peers.push(p.clone()); inbox.push(Event::PeerJoined(p)); } diff --git a/crates/collab-net/src/lib.rs b/crates/collab-net/src/lib.rs index 656c41b07..523fbda08 100644 --- a/crates/collab-net/src/lib.rs +++ b/crates/collab-net/src/lib.rs @@ -55,6 +55,8 @@ pub use link::{DEFAULT_PORT, NetLink, lan_ip}; #[cfg(target_arch = "wasm32")] pub use web::NetLink; +#[cfg(test)] +mod client_cap_tests; #[cfg(test)] mod code_tests; #[cfg(test)] diff --git a/crates/collab/src/deleted_cap_tests.rs b/crates/collab/src/deleted_cap_tests.rs new file mode 100644 index 000000000..148024383 --- /dev/null +++ b/crates/collab/src/deleted_cap_tests.rs @@ -0,0 +1,50 @@ +//! A session remembers at most [`MAX_DELETED`] deleted node ids for undo (#1121): a long session +//! deleting layer after layer, or a saved state listing more, keeps the highest ids and forgets the +//! lowest. + +use std::collections::BTreeSet; + +use figcraft_doc::{Document, Node, NodeId, NodeKind}; + +use crate::message::ClientMsg; +use crate::server::MAX_DELETED; +use crate::{Client, Limits, Outgoing, Server}; + +fn filled() -> BTreeSet { + (1..=MAX_DELETED as u64 + 10).map(NodeId).collect() +} + +#[test] +fn a_restored_state_keeps_the_highest_deleted_ids() { + let mut d = Document::new("Shared"); + d.repair(); + let mut state = Server::new(d, "tok", Limits::default()).state().unwrap(); + state.deleted = filled(); + let kept = Server::restore(state, "tok", Limits::default()).unwrap().state().unwrap().deleted; + assert_eq!(kept.len(), MAX_DELETED); + assert_eq!(kept.first(), Some(&NodeId(11)), "the lowest went first"); +} + +#[test] +fn deleting_past_the_cap_forgets_the_lowest() { + let mut d = Document::new("Shared"); + let page = d.first_page().unwrap(); + // From an earlier session's id space, above every id in `filled`. + let frame = NodeId((7 << 32) | 1); + d.insert(Node::new(frame, NodeKind::Frame, "Frame", 100.0, 100.0), page, None).unwrap(); + let mut state = Server::new(d, "tok", Limits::default()).state().unwrap(); + state.deleted = filled(); + let mut s = Server::restore(state, "tok", Limits::default()).unwrap(); + let ClientMsg::Hello(hello) = Client::hello("f", "tok", "Ada", "#FF8800") else { panic!("not a hello") }; + let (id, out) = s.join(hello).unwrap(); + let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!("no welcome") }; + let mut c = Client::join(welcome).unwrap(); + let mut edited = c.document().clone(); + edited.remove(frame).unwrap(); + let msg = c.edit(&edited).unwrap().expect("a change"); + s.receive(id, msg); + let deleted = s.state().unwrap().deleted; + assert_eq!(deleted.len(), MAX_DELETED); + assert!(deleted.contains(&frame), "the newly deleted frame is remembered"); + assert!(!deleted.contains(&NodeId(11))); +} diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 51be86b4e..fd0b9bcbf 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -133,6 +133,8 @@ mod codec_tests; #[cfg(test)] mod comment_access_tests; #[cfg(test)] +mod deleted_cap_tests; +#[cfg(test)] mod diff_tests; #[cfg(test)] mod entry_ids_tests; diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index b3f924154..8cc37167c 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -375,6 +375,11 @@ pub enum Admin { /// The most files one [`Admin::Owned`] asks about. pub const MAX_OWNED: usize = 200; +/// Most other people a client keeps track of in one session (their names, presence and chat). +/// A server holds far fewer (256 by default, a larger `max_people` lowered to this): past it, someone new is +/// left out, so a hostile server can't grow a client's memory without bound. +pub const MAX_PEERS: usize = 4_096; + /// A file its owner asked about ([`Admin::Owned`]). #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct OwnedFile { diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index 38d87c2c9..9977b2309 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -64,7 +64,7 @@ pub struct Server { presence: BTreeMap, acked: BTreeMap, /// Deleted node ids: only these may be created again (by undo); new ids must come from the - /// creating client's id space. + /// creating client's id space. At most [`MAX_DELETED`]. deleted: BTreeSet, /// Removed document-level entries (styles, variables, collections, comments): these may be /// put back by anyone (undo); a new one must not have an id from another client's space. @@ -98,6 +98,11 @@ pub struct Server { /// first; a forgotten client joins as new). const MAX_REMEMBERED: usize = 65_536; +/// The most deleted node ids a session remembers for undo. Past it the lowest ids go first (the +/// earliest clients' first layers): undoing the delete of one of those is then refused, unless the +/// layer is in the undoing client's own id space, which it may always create. +pub(crate) const MAX_DELETED: usize = 1 << 18; + /// What a session server needs to carry on after a restart: the document, the sequence number, /// the client-id counter (so ids are never handed out twice), the clients that can reconnect, /// the deleted ids and removed entries that may be created again and the log of recent batches. @@ -178,6 +183,7 @@ impl Server { server.next_client = server.next_client.max(saved); server.issued = state.issued.into_iter().filter(|(id, _)| *id < server.next_client).collect(); server.deleted = state.deleted; + server.forget_deleted(); server.removed = state.removed; let contiguous = state.log.windows(2).all(|w| matches!(w, [a, b] if a.seq + 1 == b.seq)); // History saved before the image checks may hold image entries they refuse. The snapshot @@ -251,6 +257,13 @@ impl Server { } } + /// Beyond [`MAX_DELETED`] deleted ids, forget the lowest. + fn forget_deleted(&mut self) { + while self.deleted.len() > MAX_DELETED { + self.deleted.pop_first(); + } + } + /// Beyond [`MAX_REMEMBERED`] client ids, forget the oldest one that isn't connected. fn forget_oldest(&mut self) { if self.issued.len() > MAX_REMEMBERED @@ -522,6 +535,7 @@ impl Server { } } self.deleted.extend(applied.deleted); + self.forget_deleted(); for (entry, now) in entries { if now { self.removed.remove(&entry); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 64afe6177..35875518f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -373,6 +373,8 @@ mod live_access_tests; #[cfg(test)] mod live_docs_tests; #[cfg(test)] +mod live_peer_cap_tests; +#[cfg(test)] mod live_share_tests; #[cfg(test)] mod live_signal_tests; diff --git a/crates/engine/src/live_peer_cap_tests.rs b/crates/engine/src/live_peer_cap_tests.rs new file mode 100644 index 000000000..2035aa8c4 --- /dev/null +++ b/crates/engine/src/live_peer_cap_tests.rs @@ -0,0 +1,36 @@ +//! A hostile server can't grow a client's list of people without bound (#1121): past +//! [`MAX_PEERS`], someone new is left out (their presence and chat with them), in a welcome or one +//! join at a time; someone leaving makes room again. + +use serde_json::json; + +use crate::live::{Event, MAX_PEERS, Peer, Presence, Role}; +use crate::live_tests::{info, peer, push, session}; + +fn many(from: u32, n: usize) -> Vec { + (from..).take(n).map(|id| peer(id, "Someone")).collect() +} + +#[test] +fn a_flood_of_joins_keeps_at_most_the_cap() { + let (mut s, script) = session(); + s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); + push(&script, vec![Event::Welcome { info: info(Role::Guest, 1), peers: many(2, MAX_PEERS + 100) }]); + s.live_poll(); + assert_eq!(s.live().peers.len(), MAX_PEERS, "a welcome listing too many"); + let first_left_out = 2 + MAX_PEERS as u32; + let joins = many(first_left_out, 1_000).into_iter().map(Event::PeerJoined).collect(); + push(&script, joins); + s.live_poll(); + assert_eq!(s.live().peers.len(), MAX_PEERS, "joins past the cap"); + // Presence of someone left out isn't kept either. + push(&script, vec![Event::Presence(first_left_out, Presence::default())]); + s.live_poll(); + assert!(!s.live().presence.contains_key(&first_left_out)); + // Someone already here may still be renamed; someone leaving makes room. + push(&script, vec![Event::PeerJoined(peer(2, "Renamed")), Event::PeerLeft(3), Event::PeerJoined(peer(first_left_out, "Late"))]); + s.live_poll(); + assert_eq!(s.live().peers.len(), MAX_PEERS); + assert_eq!(s.live().peers[&2].name, "Renamed"); + assert!(s.live().peers.contains_key(&first_left_out) && !s.live().peers.contains_key(&3)); +} diff --git a/crates/engine/src/sharing/live.rs b/crates/engine/src/sharing/live.rs index 4de47b11f..4065f817b 100644 --- a/crates/engine/src/sharing/live.rs +++ b/crates/engine/src/sharing/live.rs @@ -71,9 +71,9 @@ pub(crate) use host::{clean_shared, share_answer}; pub(crate) use join::start_join; pub use join::{can_remove_people, specs}; pub use types::{ - Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, MAX_NAME, MAX_SELECTION, NO_HOSTING, NO_SESSION, NetReply, NetRequest, ONLINE_OFF, PEOPLE, - PageInfo, PublishOptions, Published, Role, SessionId, SessionInfo, Share, Shared, SharedLink, Status, TOO_LARGE_TO_JOIN, Via, join_target, - parse_color, + Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, MAX_NAME, MAX_PEERS, MAX_SELECTION, NO_HOSTING, NO_SESSION, NetReply, NetRequest, ONLINE_OFF, + PEOPLE, PageInfo, PublishOptions, Published, Role, SessionId, SessionInfo, Share, Shared, SharedLink, Status, TOO_LARGE_TO_JOIN, Via, + join_target, parse_color, }; /// The view a follow last applied: any other view means the user moved. diff --git a/crates/engine/src/sharing/live/events.rs b/crates/engine/src/sharing/live/events.rs index c50f0acb9..757decbc5 100644 --- a/crates/engine/src/sharing/live/events.rs +++ b/crates/engine/src/sharing/live/events.rs @@ -1,5 +1,5 @@ use super::host::{clean_hosting, clean_shared}; -use super::types::{Event, PageInfo, Role, SessionId, Status}; +use super::types::{Event, MAX_PEERS, PageInfo, Role, SessionId, SessionInfo, Status}; use super::{ Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal, ToolId, display_name, editing_hint, editing_of, limited_by, sanitize, tool_left, @@ -27,6 +27,24 @@ fn reset_for_reconnect(live: &mut Live) { } } +/// Joined, or joined again: who you are and who else is here (at most [`MAX_PEERS`] of them). +fn welcome_in(live: &mut Live, mut info: SessionInfo, peers: Vec) { + // The shared file's name and pages come with the document (the sync welcome). + if let Some(old) = live.info.as_ref().filter(|_| info.file_name.is_empty()) { + info.file_name = old.file_name.clone(); + info.pages = old.pages.clone(); + } + live.status = Status::Live; + live.info = Some(info); + live.peers = peers.into_iter().take(MAX_PEERS).map(|p| (p.client_id, p)).collect(); + live.presence.retain(|id, _| live.peers.contains_key(id)); + if live.follow.is_some_and(|f| !live.peers.contains_key(&f)) { + live.unfollow(); + } + // Others need your presence again. + live.sent = None; +} + impl Session { /// The active document (`None`: no document is open). pub(super) fn tab(&self) -> Option { @@ -160,25 +178,14 @@ impl Session { } live.status = st; } - Event::Welcome { mut info, peers } => { - // The shared file's name and pages come with the document (the sync welcome). - if let Some(old) = live.info.as_ref().filter(|_| info.file_name.is_empty()) { - info.file_name = old.file_name.clone(); - info.pages = old.pages.clone(); - } - live.status = Status::Live; - live.info = Some(info); - live.peers = peers.into_iter().map(|p| (p.client_id, p)).collect(); - live.presence.retain(|id, _| live.peers.contains_key(id)); - if live.follow.is_some_and(|f| !live.peers.contains_key(&f)) { - live.unfollow(); - } - // Others need your presence again. - live.sent = None; - } + Event::Welcome { info, peers } => welcome_in(live, info, peers), Event::PeerJoined(p) => { // Someone already here was renamed: no newcomer to announce. if !live.peers.contains_key(&p.client_id) { + if live.peers.len() >= MAX_PEERS { + log::warn!("live session: {MAX_PEERS} people already here; leaving out another"); + return; + } self.message = Some(format!("{} joined{behind}", p.name)); } live.peers.insert(p.client_id, p); diff --git a/crates/engine/src/sharing/live/types.rs b/crates/engine/src/sharing/live/types.rs index 31ada26ac..93455c0d8 100644 --- a/crates/engine/src/sharing/live/types.rs +++ b/crates/engine/src/sharing/live/types.rs @@ -17,6 +17,8 @@ pub const DEFAULT_SERVER: &str = match option_env!("FIGCRAFT_DEFAULT_SERVER") { /// Most selected ids one presence update carries (the rest aren't shown to others). pub const MAX_SELECTION: usize = 10_000; +pub use figcraft_collab::message::MAX_PEERS; + /// Longest name shown to others (characters). pub const MAX_NAME: usize = Signal::MAX_NAME; diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 2636fc322..4ddef04df 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -955,6 +955,12 @@ chooses to share on the network; the default is the invite link with a random to characters; presence numbers must be finite and selections are cut to 10,000 ids; signals are at most 1 KiB, chat 100 characters without control characters, 20 a second (bursts of 40, the rest dropped), and only known kinds are passed on. +- **A hostile host or server.** A client keeps track of at most 4,096 other people (their presence + waiting for the UI, the people a connection has seen, the session's list of who is here); past + that, someone new is left out with a logged warning, and someone leaving makes room. Events + waiting for the UI are capped at 10,000 (document batches excepted: dropping one would end the + session). The session core remembers at most 262,144 deleted layer ids for undo, forgetting the + lowest first; undoing the delete of a forgotten layer outside your own id space is refused. - **Malformed input.** Anything that isn't a binary message of the current wire version that decodes in full is answered with `Refused` and a close; the first message must be `Hello` (for this file) or, on a server, `Manage`. The invite parser and the host's reader are fuzzed diff --git a/docs/roadmap.md b/docs/roadmap.md index eb5d7694f..b8c2a92f4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); 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; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); 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); 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); 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); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); 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); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); 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; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); 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); 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); 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); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); 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); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1121: client-side maps were unbounded. A hostile host or server could mint people without end: the presence waiting for the UI, the people a connection had seen and the session's list of who is here each grew with every new id; and a session server's set of deleted layer ids (kept so undo may create them again) only grew. Now a client keeps track of at most `MAX_PEERS` (4,096) others: past it, someone new is left out with a logged warning (in a welcome too), someone already here can still be renamed, and someone leaving makes room; a server's `max_people` above 4,096 is lowered to it with a startup warning (the server still starts). A session remembers at most 262,144 deleted ids, forgetting the lowest first (on restore too). Threat model in `collab-protocol.md`. Tests: `client_cap_tests.rs` (collab-net), `live_peer_cap_tests.rs` (engine), `deleted_cap_tests.rs` (collab), the server's `max_people` clamp. | | 2026-10-30 | minor | M8, #1117: `doc.list` leaves out file paths for AI agents and plugins (ids and names stay). Tests: `doc_list_paths_tests` | | 2026-10-30 | minor | M8, #1571 (part): a large selection redid the same work on every frame. A pointer move draws two to five egui frames, and each one worked out every selected layer's outline, the selection box and size, the slots shown, and the Design panel's instance checks and position values (X, Y, rotation, constraints, alignment) again, though only the first frame after a move or an edit can differ. Now they are kept with the document snapshot, the selection and, for the overlay, the view's mapping to the screen and the overlay green, and made again when any of those changes (`selection_cache`); the hover and drop-target highlights are drawn every frame as before. Nothing looks different. Dragging 648 layers of a synthetic file of about 1,900 (release build, control-driven, two frames per move): the canvas overlays 1.99 to 0.86 ms a frame, the Design panel 2.54 to 1.40 ms. Not yet: the frame of each move still works everything out (the selection spacing, the export section, the outlines), the canvas texture is uploaded whole after each render. Tests: `canvas/selection_cache_tests.rs`. | | 2026-10-30 | minor | M8, #1089: libraries and plugins larger than one address's download burst (16 MiB) could never be downloaded: the per-address token bucket never held more than its burst, so a private server's 40 or 64 MiB team library was refused for good. Now a download larger than the burst is served once the address's budget is full, leaving it in debt that refills at 1 MiB a second before its next download (`Bucket::take_spread`, also for everyone's library budget); within the burst nothing changes. Over any stretch of time an address still gets at most the largest object plus the rate times that time. `server.md` (rates, threat model). Tests: `download_burst_tests.rs` (the bucket's loan and payback, ten minutes of 40 MiB requests kept to the rate, a 40 MiB library served and then the address waits). | diff --git a/docs/server.md b/docs/server.md index 5b4967f35..2aa8cf733 100644 --- a/docs/server.md +++ b/docs/server.md @@ -291,7 +291,7 @@ are errors): | `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, so messages may be as large as a file and 1 MiB (at least 64 MiB on a private server). The app opens files of up to 1023 MiB; above 64 with `open_publish` the server warns | | `max_links` | 100 | Most share links per file | | `max_connections` | 512 | Most open connections | -| `max_people` | 256 | Most people in one file at once | +| `max_people` | 256 | Most people in one file at once (a value above 4096 is lowered to it, with a warning at startup: clients keep track of no more) | | `compact_after` | 1000 | Compact a file's log into a new snapshot after this many entries… | | `compact_mb` | 16 | …or once its log is this large (MiB) | | `max_log_mb` | 64 | Most MiB of recent batches a file in use keeps in memory (and in its snapshot) for apps that reconnect; an app further behind catches up with the whole file. Open to the public, at most a quarter of `max_file_mb` too |