Merge pull request #1609 from avhn/fix/1085-grouped-panic-rollback

M8: a grouped command whose closure panics leaves the document as before (#1085)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-30 15:27:30 +07:00
commit 328bb6e269
5 files changed
+166 -4

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
//! 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<String> {
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");
})
}));
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);
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_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();
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::<Vec<_>>(), uids);
assert_eq!(s.active, active);
assert!(s.closed_in_group.is_empty());
assert!(s.docs.iter().all(|ds| ds.history.limit != usize::MAX));
}
+2
View File
@@ -327,6 +327,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;
+11 -2
View File
@@ -251,7 +251,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<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
self.ds()?;
self.checkpointed(Some(label), f)
@@ -261,7 +262,12 @@ impl Session {
/// A checkpoint also works between closing the last tab and opening another one.
fn checkpointed<T>(&mut self, label: Option<&str>, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
let before = self.group_checkpoint(label.is_some());
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 {
@@ -297,6 +303,9 @@ impl Session {
self.closed_in_group.clear();
self.flush_pending_events();
}
if let Some(payload) = panic {
std::panic::resume_unwind(payload);
}
result
}
+2 -1
View File
@@ -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
+2 -1
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); 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 wherLine truncated
> **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); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); 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, Line truncated
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
@@ -76,6 +76,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 | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged |
| 2026-10-30 | minor | M8, #1076: `.fig` ZIP image reads share a total budget, 4 GiB on 64-bit targets and 1 GiB on 32-bit targets, keeping the 256 MiB per-image cap. Members that do not fit are skipped before allocation with an import warning; failed reads consume their bytes, repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. |
| 2026-10-30 | minor | M3, follow-up to #1542: stored variable and collection references use exact ids in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker, and prototype actions and expressions. Interaction edits resolve only incoming names and leave stored references alone. Command arguments still accept names as before. Tests: the `stored_variable_ids_*` tests in the doc, engine, format and ui-egui present and responsive suites. Numbers unchanged |