From 9573b8b2d219aa74817cedfd5878bd5cd7b8509a Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Fri, 30 Oct 2026 05:52:02 +0300 Subject: [PATCH 1/2] M8: a grouped command whose closure panics leaves the document as before (#1085) A panic in a Session::grouped closure outside any command skipped the group rollback: the documents stayed half edited, the history limit unbounded, live documents held and the group depth raised. checkpointed now catches the unwind, runs the same rollback as a failed group and resumes the panic, so the caller's guard still reports it. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Fable 5.1 Co-Authored-By: GPT-6.1 Sol Co-Authored-By: GPT-6 Astra --- crates/engine/src/group_panic_tests.rs | 110 +++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/session/undo.rs | 13 ++- docs/architecture.md | 3 +- docs/roadmap.md | 3 +- 5 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/group_panic_tests.rs diff --git a/crates/engine/src/group_panic_tests.rs b/crates/engine/src/group_panic_tests.rs new file mode 100644 index 00000000..aaa1a8bf --- /dev/null +++ b/crates/engine/src/group_panic_tests.rs @@ -0,0 +1,110 @@ +//! A panic in a grouped closure, outside any command, ends the group like a failure (#1085): the +//! documents, selection, undo and redo, history limit and journal are as they were, and the panic +//! goes on to the caller. + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::Arc; + +use serde_json::json; + +use crate::{HistoryEntry, NodeId, Session}; + +fn rect(s: &mut Session, x: i64) -> NodeId { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().map(NodeId).unwrap() +} + +fn labels(entries: &[HistoryEntry]) -> Vec { + entries.iter().map(|e| e.label.clone()).collect() +} + +#[test] +fn a_grouped_closure_that_panics_leaves_the_session_as_before() { + let mut s = Session::new(); + let first = rect(&mut s, 0); + let second = rect(&mut s, 20); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [first]})).unwrap(); + let ds = s.ds().unwrap(); + let (doc, selection, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + let (undo, redo, limit) = (labels(&ds.history.undo), labels(&ds.history.redo), ds.history.limit); + assert_eq!(redo.len(), 1); + let journal = s.journal.clone(); + let depth = s.undo_depth(); + + let unwound = catch_unwind(AssertUnwindSafe(|| { + s.grouped("Edit then panic", |s| -> crate::Result<()> { + s.execute("node.set", &json!({"id": first, "props": {"name": "Renamed"}}))?; + rect(s, 40); + s.execute("select.set", &json!({"ids": []}))?; + panic!("a bug in the closure"); + }) + })); + + assert!(unwound.is_err(), "the panic goes on to the caller"); + let ds = s.ds().unwrap(); + assert!(Arc::ptr_eq(&ds.doc, &doc)); + assert_eq!(*ds.doc, *doc); + assert_eq!(ds.selection, selection); + assert_eq!(ds.page, page); + assert_eq!(labels(&ds.history.undo), undo); + assert_eq!(labels(&ds.history.redo), redo); + assert_eq!(ds.history.limit, limit); + assert_eq!(s.journal, journal); + assert_eq!(s.undo_depth(), depth); + assert_eq!(s.group_depth, 0); + assert!(s.closed_in_group.is_empty()); + + // The redo step survived, and the next edit pushes one step of its own. + s.execute("edit.redo", &json!({})).unwrap(); + assert!(s.doc().unwrap().contains(second)); + rect(&mut s, 60); + assert_eq!(s.undo_depth(), (depth.0 + 2, 0)); +} + +#[test] +fn a_panic_in_an_inner_group_rolls_back_through_the_outer_one() { + let mut s = Session::new(); + let unwound = catch_unwind(AssertUnwindSafe(|| { + s.grouped("outer", |s| { + s.execute("comment.add", &json!({"x": 1, "y": 2, "text": "Rolled back"}))?; + s.grouped("inner", |s| -> crate::Result<()> { + s.execute("comment.add", &json!({"x": 3, "y": 4, "text": "Also rolled back"}))?; + panic!("a bug in the inner closure"); + }) + }) + })); + + assert!(unwound.is_err()); + assert!(s.doc().unwrap().comments.is_empty()); + assert!(s.pending_events.is_empty()); + assert_eq!(s.events.latest(), 0); + assert_eq!(s.group_depth, 0); + assert_eq!(s.undo_depth(), (0, 0)); + + // The session still groups normally afterwards. + s.grouped("again", |s| s.execute("comment.add", &json!({"x": 5, "y": 6, "text": "Kept"})).map(|_| ())).unwrap(); + assert_eq!(s.doc().unwrap().comments.len(), 1); + assert_eq!(s.undo_depth(), (1, 0)); +} + +#[test] +fn a_group_that_closes_and_opens_tabs_then_panics_brings_the_tabs_back() { + let mut s = Session::new(); + s.execute("file.new", &json!({"name": "Second"})).unwrap(); + let uids: Vec<_> = s.docs.iter().map(|ds| ds.uid).collect(); + let active = s.active; + + let unwound = catch_unwind(AssertUnwindSafe(|| { + s.grouped("Close, open, panic", |s| -> crate::Result<()> { + s.execute("file.close", &json!({"index": 1, "force": true}))?; + s.execute("file.new", &json!({"name": "Temporary"}))?; + panic!("a bug after switching tabs"); + }) + })); + + assert!(unwound.is_err()); + assert_eq!(s.docs.iter().map(|ds| ds.uid).collect::>(), uids); + assert_eq!(s.active, active); + assert!(s.closed_in_group.is_empty()); + assert!(s.docs.iter().all(|ds| ds.history.limit != usize::MAX)); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bf40c15f..deac29e2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -323,6 +323,8 @@ mod gesture_preview_tests; #[cfg(test)] mod gradient_shorthand_tests; #[cfg(test)] +mod group_panic_tests; +#[cfg(test)] mod guide_snap_tests; #[cfg(test)] mod hide_publishing_tests; diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index 285fc8a6..1a2ae80e 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -250,7 +250,8 @@ impl Session { } /// Run `f` as one undo step called `label`: the commands it executes push no steps of their - /// own, and when it fails the documents stay at their group-start state. + /// own, and when it fails or panics the documents stay at their group-start state (a panic + /// then carries on unwinding). pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result) -> Result { self.ds()?; self.checkpointed(Some(label), f) @@ -260,7 +261,12 @@ impl Session { /// A checkpoint also works between closing the last tab and opening another one. fn checkpointed(&mut self, label: Option<&str>, f: impl FnOnce(&mut Self) -> Result) -> Result { let before = self.group_checkpoint(); - let result = f(self); + // A panic in `f` ends the group like a failure, then goes on to the caller's guard (#1085); + // the stand-in error only drives the rollback and is never returned. + let (result, panic) = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) { + Ok(result) => (result, None), + Err(payload) => (Err(EngineError::Other("the group panicked".into())), Some(payload)), + }; self.group_depth -= 1; let at_root = self.group_depth == 0; for ds in &before.docs { @@ -291,6 +297,9 @@ impl Session { self.closed_in_group.clear(); self.flush_pending_events(); } + if let Some(payload) = panic { + std::panic::resume_unwind(payload); + } result } diff --git a/docs/architecture.md b/docs/architecture.md index 9faed547..5c7300f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,7 +75,8 @@ content. Exports: PNG, JPG, WebP, SVG, PDF, code. There is no `.fig` reader yet Workspace lints deny `unwrap`, `expect`, `panic!` and friends in shipped code; `unsafe_code` is forbidden. `figcraft_engine::guard` turns a panicking command into an error and rolls the document -back; the desktop app also guards each UI frame. The browser build can't catch a panic (wasm32 aborts), so there a panic stops the page and its panic hook keeps the files with unsaved changes in local storage for the next page. Junk-parameter sweeps run every command with bad +back, and a panic in a `Session::grouped` closure outside any command rolls the group back like a +failure before the panic continues; the desktop app also guards each UI frame. The browser build can't catch a panic (wasm32 aborts), so there a panic stops the page and its panic hook keeps the files with unsaved changes in local storage for the next page. Junk-parameter sweeps run every command with bad input. ## Revision history diff --git a/docs/roadmap.md b/docs/roadmap.md index a5ec70b3..acabfca4 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 (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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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, #1085 (the rest): a panic in a `Session::grouped` closure outside any command (plugin, widget, data fill and CLI groups) skipped the group rollback, leaving documents half edited, the history limit unbounded, live documents held and the group depth raised. `checkpointed` now catches the unwind, runs the same rollback as a failed group and resumes the panic, so the outer guard still reports it. Journal rollover and the browser (wasm aborts) are unchanged. 3 regression tests (`group_panic_tests`). | | 2026-10-30 | minor | M8, #1079: one total for what a `.figcraft` file unpacks on open (6 GiB; 2 GiB on 32-bit), shared by the document, images, library, versions and branches beyond their own caps; images past it stay in the file like those past the image cap (#1313). Tests: `unpacked_total_tests` | | 2026-10-30 | minor | M8, #1571 (part): laggy drags and high CPU while the pointer only moves. Measured on a synthetic file of about 1,900 layers (24 auto layout frames of 48 rows, 600 shapes, 24 images; release build, 300 to 600 pointer moves at 60 per second): every pointer move draws about three egui frames (the move, egui's follow-up, a finished render), and the canvas hit-tested the hover on each of them; a drag of an auto layout frame laid its contents out again on every move, though a move can't change them. Now a hover where nothing changed since the last (same place, file, page, revision, selection, tool and modes) isn't sent to the tools again, and a frame that only moved keeps its own layout (its parent's still runs). Hover: the UI pass 0.91 to 0.49 ms a frame, CPU 33% to 22% of a core; dragging 648 layers: the UI pass 3.8 to 3.2 ms, the engine's normalise 3.8 to 2.1 ms a move, CPU about the same (117% to 116%). `ui.inspect` reports `frameTimes` ({frames, meanMs, maxMs}). Not yet: the selection overlay and the Design panel recompute every frame for a large selection (about 2 ms each at 648 layers), the canvas texture is uploaded whole after each render, and egui draws its follow-up frames. Tests: `canvas_hover_tests.rs`, `auto_layout_move_tests.rs`. | | 2026-10-30 | minor | M8, #1077: `.fig` instance expansion near linear on crafted files: variable nodes are out of the layer tree, nested instances take only the override rows below them and share property values; 13.8 s → 21 ms and 40.5 s → 98 ms on crafted files. Tests: `expansion_cost_tests` | From cc0976a4a2f94414696b18ce41460863d3f18b94 Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Fri, 30 Oct 2026 07:13:03 +0300 Subject: [PATCH 2/2] #1609: a grouped panic keeps its payload and lets later live edits sync Co-Authored-By: Claude Opus 5.5 Co-Authored-By: GPT-6.1 Sol --- crates/engine/src/group_panic_tests.rs | 41 +++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/group_panic_tests.rs b/crates/engine/src/group_panic_tests.rs index aaa1a8bf..48754e38 100644 --- a/crates/engine/src/group_panic_tests.rs +++ b/crates/engine/src/group_panic_tests.rs @@ -40,7 +40,8 @@ fn a_grouped_closure_that_panics_leaves_the_session_as_before() { }) })); - assert!(unwound.is_err(), "the panic goes on to the caller"); + let payload = unwound.unwrap_err(); + assert_eq!(payload.downcast_ref::<&str>(), Some(&"a bug in the closure"), "the original panic goes on to the caller"); let ds = s.ds().unwrap(); assert!(Arc::ptr_eq(&ds.doc, &doc)); assert_eq!(*ds.doc, *doc); @@ -61,6 +62,44 @@ fn a_grouped_closure_that_panics_leaves_the_session_as_before() { assert_eq!(s.undo_depth(), (depth.0 + 2, 0)); } +#[test] +fn a_grouped_closure_that_panics_releases_the_live_hold_so_later_edits_sync() { + use crate::live_tests::{push, relay, serve, session}; + + let (mut s, script) = session(); + let first = rect(&mut s, 0); + s.execute("collab.host", &json!({})).unwrap(); + let (mut server, id, welcome) = serve(s.doc().unwrap()); + push(&script, vec![welcome]); + s.live_poll(); + assert!(s.live_doc(s.ds().unwrap().uid)); + let before = s.doc().unwrap().clone(); + script.lock().unwrap().msgs.clear(); + + let unwound = catch_unwind(AssertUnwindSafe(|| { + s.grouped("Rename then panic", |s| -> crate::Result<()> { + s.execute("node.set", &json!({"id": first, "props": {"name": "Rolled back"}}))?; + s.live_sync(); + assert!(script.lock().unwrap().msgs.is_empty(), "the edit waits for the group to end"); + panic!("a bug in the live closure"); + }) + })); + + assert!(unwound.is_err()); + assert_eq!(s.doc().unwrap(), &before); + s.live_sync(); + assert!(script.lock().unwrap().msgs.is_empty(), "the rolled-back edit never goes out"); + + s.execute("node.set", &json!({"id": first, "props": {"name": "Sent afterwards"}})).unwrap(); + s.live_sync(); + assert_eq!(script.lock().unwrap().msgs.len(), 1, "the later edit goes out"); + let replies = relay(&script, &mut server, id); + push(&script, replies); + s.live_poll(); + assert_eq!(server.document().node(first).unwrap().name, "Sent afterwards"); + assert_eq!(s.doc().unwrap(), server.document()); +} + #[test] fn a_panic_in_an_inner_group_rolls_back_through_the_outer_one() { let mut s = Session::new();