Merge PR #644 from NikitkaRa: Startup: survive damaged settings, font folders, recovery folders and small texture limits

# Conflicts:
#	crates/engine/src/recovery.rs
#	crates/fonts/src/fontdb.rs
#	crates/fonts/src/lib.rs
#	crates/ui-egui/src/lib.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:24:45 -04:00
11 changed files with 403 additions and 37 deletions
+23
View File
@@ -332,6 +332,29 @@ impl Default for Prefs {
}
}
impl Prefs {
/// The preferences with every value in a range the editor can use: `prefs.json` is a file
/// on disk, and a hand-edited or damaged one (a zero grid, a huge paste offset or undo
/// limit) must not break editing or exhaust memory. Out-of-range numbers take their
/// defaults or the nearest usable value.
pub fn sanitized(mut self) -> Prefs {
let d = Prefs::default();
let within = |v: f64, lo: f64, hi: f64, default: f64| if v.is_finite() { v.clamp(lo, hi) } else { default };
self.grid_spacing =
if self.grid_spacing.is_finite() && self.grid_spacing > 0.0 { self.grid_spacing.clamp(0.5, 1000.0) } else { d.grid_spacing };
self.duplicate_offset = within(self.duplicate_offset, -1000.0, 1000.0, d.duplicate_offset);
self.undo_limit = self.undo_limit.min(MAX_UNDO_LIMIT);
self.recovery_minutes = within(self.recovery_minutes, 0.1, 24.0 * 60.0, d.recovery_minutes);
if self.author.chars().count() > 256 {
self.author = self.author.chars().take(256).collect();
}
self
}
}
/// The most undo steps [`Prefs::undo_limit`] can keep.
pub const MAX_UNDO_LIMIT: usize = 10_000;
/// Things a command or tool asks the UI to do (open a dialog, show a pane, start the show…).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
+60 -6
View File
@@ -84,6 +84,20 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
std::fs::write(&tmp, bytes).and_then(|_| std::fs::rename(&tmp, path)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))
}
/// Presentations reopened per start; the rest stay on disk for the next one, so a folder
/// full of entries can't stall start-up.
pub const MAX_REOPEN: usize = 32;
/// Largest entry read back.
const MAX_ENTRY_BYTES: u64 = 2 << 30;
const MAX_META_BYTES: u64 = 64 << 10;
/// `p` as bytes when it's a regular file no bigger than `max`: reading a FIFO left where an
/// entry belongs would block start-up forever.
fn read_regular(p: &Path, max: u64) -> Option<Vec<u8>> {
let m = std::fs::metadata(p).ok()?;
(m.is_file() && m.len() <= max).then(|| std::fs::read(p).ok()).flatten()
}
fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) {
(dir.join(format!("{uid}.deckcraft")), dir.join(format!("{uid}.json")))
}
@@ -136,11 +150,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") {
continue;
}
if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) {
continue;
}
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
let meta = std::fs::read_to_string(p.with_extension("json"))
.ok()
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES)
.and_then(|t| serde_json::from_slice::<Value>(&t).ok())
.filter(Value::is_object)
.unwrap_or(json!({}));
out.push((uid, meta));
@@ -153,11 +169,17 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
/// Entries stay on disk, now owned by this instance: the presentation keeps the entry's uid, so
/// the next recovery save replaces the snapshot and saving or closing it removes it. Entries of a
/// running instance, unreadable entries and entries already open here are left alone. Returns the
/// opened documents' indices.
/// opened documents' indices. At most [`MAX_REOPEN`] are opened per start, the newest first; the rest
/// wait for the next one.
pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
let me = instance_id();
let mut opened = Vec::new();
for (uid, mut meta) in list(dir) {
let mut entries = list(dir);
entries.sort_by_key(|(uid, meta)| std::cmp::Reverse((meta.get("saved").and_then(Value::as_u64).unwrap_or(0), *uid)));
for (uid, mut meta) in entries {
if opened.len() >= MAX_REOPEN {
break;
}
if s.documents().iter().any(|d| d.uid == uid) {
continue;
}
@@ -168,7 +190,7 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
continue;
}
let (doc, meta_path) = files(dir, uid);
let Ok(bytes) = std::fs::read(&doc) else { continue };
let Some(bytes) = read_regular(&doc, MAX_ENTRY_BYTES) else { continue };
let Ok(mut d) = deckcraft_format::load(&bytes) else { continue };
deckcraft_format::repair(&mut d);
let path = meta.get("path").and_then(Value::as_str).map(str::to_string);
@@ -311,4 +333,36 @@ mod tests {
assert_eq!(v.as_array().map(Vec::len), Some(1));
let _ = std::fs::remove_dir_all(&dir);
}
/// A FIFO where an entry or its metadata belongs is skipped rather than read (that blocked
/// start-up forever), and a folder full of entries reopens only [`MAX_REOPEN`] per start.
#[cfg(unix)]
#[test]
fn hostile_recovery_folders_dont_stall_start_up() {
let dir = temp("recovery-hostile");
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
save(&s, &dir).unwrap();
let (good, _) = files(&dir, s.doc().unwrap().uid);
for i in 1..=(MAX_REOPEN as u64 + 3) {
std::fs::copy(&good, dir.join(format!("{}.deckcraft", 100_000 + i))).unwrap();
}
for name in ["5.deckcraft", "6.json"] {
let _ = std::process::Command::new("mkfifo").arg(dir.join(name)).status();
}
std::fs::write(dir.join("6.deckcraft"), std::fs::read(&good).unwrap()).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let d = dir.clone();
std::thread::spawn(move || {
let mut s2 = Session::new();
let _ = tx.send(open(&mut s2, &d).map(|v| v.len()).unwrap_or(0));
});
let n = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("recovery hung");
assert_eq!(n, MAX_REOPEN);
// Every entry stays on disk (main keeps snapshots until a newer save); the 32 opened plus
// the rest that wait for the next start.
assert_eq!(list(&dir).len(), MAX_REOPEN + 5, "the rest wait for the next start");
let _ = std::fs::remove_dir_all(&dir);
}
}