mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-30 21:47:00 -06:00
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:
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user