diff --git a/apps/effectcraft-web/src/api.rs b/apps/effectcraft-web/src/api.rs index 57cfd98d..793e557c 100644 --- a/apps/effectcraft-web/src/api.rs +++ b/apps/effectcraft-web/src/api.rs @@ -148,7 +148,8 @@ pub fn remove_stored(path: String) -> bool { crate::files::STORE.remove_file(&path) } -/// Resolves once every change is written to browser storage. +/// Resolves once every change is written to browser storage; rejects when a write failed (it is +/// tried again). #[wasm_bindgen] pub fn flush() -> js_sys::Promise { crate::persist::flushed() diff --git a/apps/effectcraft-web/src/persist.rs b/apps/effectcraft-web/src/persist.rs index 6ee2c86b..93fe8906 100644 --- a/apps/effectcraft-web/src/persist.rs +++ b/apps/effectcraft-web/src/persist.rs @@ -3,7 +3,8 @@ //! //! - [`load`] (before the app starts) opens the storage and reads every entry into the mirror. //! - Changes flush in the background ([`schedule_flush`], wired as the store's change hook): -//! one write per changed key, in order, with nothing written twice concurrently. +//! one write per changed key, in order, with nothing written twice concurrently. A write that +//! fails (quota exceeded…) is kept and tried again, after 2 s, then longer, up to a minute. //! - The session snapshot ([`snapshot`]) records the open project and editor state every second //! or so while it changes, so a reload (or a crash) comes back to where the user was //! ([`restore`]). @@ -37,11 +38,18 @@ thread_local! { static FLUSHING: Cell = const { Cell::new(false) }; static BACKEND: std::cell::RefCell = const { std::cell::RefCell::new(String::new()) }; static LAST_ERROR: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; - static FLUSH_WAITERS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + /// [`flushed`] promises waiting for the running flush: (resolve, reject). + static FLUSH_WAITERS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + /// Delay before retrying failed writes (ms; doubles while they keep failing). + static RETRY_MS: Cell = const { Cell::new(RETRY_FIRST_MS) }; + static RETRY_PENDING: Cell = const { Cell::new(false) }; static SNAP: Cell<(u64, f64)> = const { Cell::new((u64::MAX, 0.0)) }; static LAST_META: std::cell::RefCell = const { std::cell::RefCell::new(String::new()) }; } +const RETRY_FIRST_MS: i32 = 2_000; +const RETRY_MAX_MS: i32 = 60_000; + /// The storage in use: `opfs`, `indexeddb` or `memory`. pub fn backend() -> String { BACKEND.with(|b| b.borrow().clone()) @@ -86,8 +94,11 @@ pub fn schedule_flush() { return; // the running flush picks the new changes up } wasm_bindgen_futures::spawn_local(async { + // Keys whose write failed in this flush, and the last failure. + let mut failed: Vec = vec![]; + let mut error = None; loop { - let pending = STORE.lock().take_pending(); + let pending: Vec<_> = STORE.lock().take_pending().into_iter().filter(|p| !failed.contains(&p.key)).collect(); if pending.is_empty() { break; } @@ -99,24 +110,68 @@ pub fn schedule_flush() { if let Err(e) = r { let msg = format!("saving {} to browser storage failed: {}", p.key, js_err(e)); log::warn!("{msg}"); - LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg)); + LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg.clone())); + error = Some(msg); + failed.push(p.key); } } } + // Failed writes stay pending (#215): flushed later, and the waiters hear of the failure + // instead of being told the changes are stored. + { + let mut m = STORE.lock(); + for k in &failed { + m.retry(k); + } + } FLUSHING.with(|f| f.set(false)); - for w in FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut())) { - let _ = w.call0(&JsValue::NULL); + let waiters = FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut())); + match &error { + None => { + RETRY_MS.with(|r| r.set(RETRY_FIRST_MS)); + for (resolve, _) in waiters { + let _ = resolve.call0(&JsValue::NULL); + } + } + Some(msg) => { + let e: JsValue = js_sys::Error::new(msg).into(); + for (_, reject) in waiters { + let _ = reject.call1(&JsValue::NULL, &e); + } + retry_later(); + } } }); } -/// A promise that resolves once every change so far is in the browser storage. +/// Flush again after the retry delay (once; the delay doubles up to a minute while writes keep +/// failing). +fn retry_later() { + if RETRY_PENDING.with(|r| r.replace(true)) { + return; + } + let ms = RETRY_MS.with(|r| { + let ms = r.get(); + r.set(ms.saturating_mul(2).min(RETRY_MAX_MS)); + ms + }); + let again = Closure::once_into_js(|| { + RETRY_PENDING.with(|r| r.set(false)); + schedule_flush(); + }); + if let Some(w) = web_sys::window() { + let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(again.unchecked_ref(), ms); + } +} + +/// A promise that resolves once every change so far is in the browser storage, and rejects +/// when a write failed (the change stays pending and is tried again). pub fn flushed() -> js_sys::Promise { - js_sys::Promise::new(&mut |resolve, _| { + js_sys::Promise::new(&mut |resolve, reject| { if !FLUSHING.with(|f| f.get()) && !STORE.lock().has_pending() { let _ = resolve.call0(&JsValue::NULL); } else { - FLUSH_WAITERS.with(|w| w.borrow_mut().push(resolve)); + FLUSH_WAITERS.with(|w| w.borrow_mut().push((resolve, reject))); schedule_flush(); } }) diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs index 7017b620..b3a858de 100644 --- a/apps/effectcraft-web/src/store.rs +++ b/apps/effectcraft-web/src/store.rs @@ -98,6 +98,11 @@ impl Mirror { self.version } + /// A write of `key` that failed: the next flush writes the key's latest state again (#215). + pub fn retry(&mut self, key: &str) { + self.touch(key); + } + fn touch(&mut self, key: &str) { if !self.dirty.iter().any(|k| k == key) { self.dirty.push(key.to_string()); @@ -443,6 +448,24 @@ mod tests { assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it"); } + /// A write that failed is flushed again with the key's latest state (#215). + #[test] + fn failed_writes_retry() { + let s = store(); + s.put_file("/collision.png", Arc::from(&b"blue"[..]), true); + let mut m = s.lock(); + let p = m.take_pending(); + assert_eq!(p.len(), 1); + assert!(!m.has_pending()); + m.retry(&p[0].key); + assert_eq!(m.take_pending(), p, "the same write again"); + // A newer change wins over the failed one. + m.retry("files/collision.png"); + m.put("files/collision.png", Arc::from(&b"gold"[..]), 2.0, true); + let again = m.take_pending(); + assert_eq!((again.len(), again[0].data.as_deref()), (1, Some(&b"gold"[..]))); + } + #[test] fn keys_and_paths_round_trip() { assert_eq!(file_key("/a b/c.mov"), "files/a b/c.mov"); diff --git a/docs/web.md b/docs/web.md index 12d2b24d..f3b44306 100644 --- a/docs/web.md +++ b/docs/web.md @@ -384,7 +384,7 @@ with the method's `result`, errors reject with the message. | `effectcraft.saveToBrowser(path?)` | save the project to browser storage without downloading it (default: its path, or `/.ecproj`); resolves with `{path, bytes}` | | `effectcraft.listStored()` | `{backend, usage, quota, persisted, pending, files: [{path, size, modified}], config: [name]}` | | `effectcraft.removeStored(path)` | delete a stored file | -| `effectcraft.flush()` | resolves once every change is written to browser storage | +| `effectcraft.flush()` | resolves once every change is written to browser storage; rejects when a write failed (quota exceeded…: the change stays pending and is retried) | | `effectcraft.info()` | graphics backend, `gpu`, `storage`, `audio` (`{state, sampleRate, backend, posted, played, underruns}`), `workers`, `frameWorkers`, `diskCache`, `restored`, `webgpu`, `serviceWorker`, version, `crossOriginIsolated`, load timings | | `effectcraft.workerFrameCheck(params)` | a frame rendered in a frame worker (its GPU by default) against the page's CPU render: `{width, height, maxDiff, meanDiff, over4, workerMs}` |