From 00f55ef352e9b0b4a5ac1828bb6492ec110ea403 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:28:49 +0000 Subject: [PATCH] #2266: a presets.json that can't be written counts as unsaved Preset commands change the preset list outside pending_log, and a failed presets.json write was only logged: unsaved() stayed None, so nothing warned and the web backup (#1987) would zip a stale presets.json; on desktop a full disk lost the preset silently. The failure is now kept (presets_error), counted by unsaved() (plus one change), retried by persist_if_dirty with the same back-off as failed appends, and cleared once the write succeeds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK --- crates/engine/src/library.rs | 25 ++++++++++++++++++++----- crates/engine/src/tests_persist.rs | 25 +++++++++++++++++++++++++ docs/whats-new.md | 7 +++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index e91f0bfff..32ad1da53 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -84,6 +84,9 @@ pub struct Library { /// Why the last append of queued ops failed; `None` once a save succeeds. While set, changes /// exist only in memory ([`Session::unsaved`]). pub unsaved_error: Option, + /// Why the last write of presets.json failed; `None` once it is written. While set, the preset + /// changes exist only in memory and count as unsaved ([`Session::unsaved`], issue #2266). + presets_error: Option, /// When the frame loop may retry a failed append ([`Session::persist_if_dirty`] backs off). retry_at: Option, /// What forgetting untouched Local records did when the library opened. @@ -577,6 +580,7 @@ impl Session { report, last_error: None, unsaved_error: None, + presets_error: None, retry_at: None, forgot_local: None, presets_saved, @@ -651,10 +655,17 @@ impl Session { let Some(lib) = self.library.as_mut() else { return Ok(()) }; if state.hash == lib.presets_saved.hash { lib.presets_saved = state; + lib.presets_error = None; } else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) { - // the state stays stale: the next save tries again + // the state stays stale: retried by the next command and the frame loop (backing off); + // meanwhile the presets count as unsaved, so the UI warns and a backup refuses log::error!("library: presets: {e}"); + lib.presets_error = Some(format!("presets: {e}")); + lib.retry_at = Some(web_time::Instant::now() + RETRY_BACKOFF); } else { + if lib.presets_error.take().is_some() { + log::info!("library: presets written to disk"); + } lib.presets_saved = state; } Ok(()) @@ -664,7 +675,8 @@ impl Session { /// changed outside [`Session::execute`]). pub fn persist_if_dirty(&mut self) { let backing_off = self.library.as_ref().and_then(|l| l.retry_at).is_some_and(|t| web_time::Instant::now() < t); - if self.library.is_some() && !self.pending_log.is_empty() && !backing_off { + let presets_unsaved = self.library.as_ref().is_some_and(|l| l.presets_error.is_some()); + if self.library.is_some() && (!self.pending_log.is_empty() || presets_unsaved) && !backing_off { let _ = self.persist(); } else if let Some(lib) = self.library.as_mut() { poll_compaction(lib); @@ -672,11 +684,14 @@ impl Session { } /// Changes applied in memory but not yet written to disk because saving failed: the number of - /// queued ops and why (`None` when everything is saved, or no library is open). Retried by - /// every command and by [`Session::persist_if_dirty`]. + /// queued ops (plus one for presets.json) and why (`None` when everything is saved, or no library + /// is open). Retried by every command and by [`Session::persist_if_dirty`]. pub fn unsaved(&self) -> Option<(usize, &str)> { let lib = self.library.as_ref()?; - lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()).map(|e| (self.pending_log.len(), e)) + let ops = lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()); + let presets = lib.presets_error.as_deref(); + let n = ops.map_or(0, |_| self.pending_log.len()) + usize::from(presets.is_some()); + ops.or(presets).map(|why| (n, why)) } /// Flush everything and write a snapshot (on quit). Ends an open interaction first. diff --git a/crates/engine/src/tests_persist.rs b/crates/engine/src/tests_persist.rs index 4fb3f0234..7ebf8464b 100644 --- a/crates/engine/src/tests_persist.rs +++ b/crates/engine/src/tests_persist.rs @@ -191,3 +191,28 @@ fn close_fails_when_nothing_can_be_saved() { s.close_library().unwrap(); assert_eq!(s.unsaved(), None); } + +/// Issue #2266: a presets.json that can't be written isn't only logged: the preset counts as +/// unsaved (the top bar warns, the web backup refuses) until a later save writes it. +#[test] +fn a_preset_that_cannot_be_written_counts_as_unsaved_until_it_is() { + let files = Flaky::default(); + let mut s = Session::new(); + let stores = LibraryStores { dir: "flaky".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, true).unwrap(); + assert!(s.unsaved().is_none()); + files.set_failing(true); + s.execute("preset.create", &json!({"name": "Mine"})).unwrap(); + let (n, why) = s.unsaved().expect("the preset is unsaved"); + assert_eq!(n, 1); + assert!(why.contains("presets") && why.contains("disk full"), "{why}"); + let stored = |f: &Flaky| f.files.files.lock().unwrap().get("presets.json").cloned().unwrap_or_default(); + assert!(!String::from_utf8_lossy(&stored(&files)).contains("Mine")); + // still failing: still unsaved + s.persist().unwrap(); + assert!(s.unsaved().is_some()); + files.set_failing(false); + s.persist().unwrap(); + assert!(s.unsaved().is_none(), "written once the disk works again"); + assert!(String::from_utf8_lossy(&stored(&files)).contains("Mine")); +} diff --git a/docs/whats-new.md b/docs/whats-new.md index 2f02ffb91..57077fe4d 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-18 + +### A preset that couldn't be saved is no longer lost silently +- When presets.json can't be written (a full disk, or the browser's storage quota), the new or changed preset now + counts as unsaved, like other changes: the top bar warns, saving is retried, and quitting asks first (issue #2266). + Before, the failure was only logged, and the preset could be lost without any warning. + ## 2026-10-14 ### Model downloads time out on a name that doesn't resolve