From a217722937719101c12d3c2521a03b33abc32c8c Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 10 Oct 2026 19:11:58 -0700 Subject: [PATCH] fix(web): await cache receipts and enforce browser ownership Retry browser section opens asynchronously while pending durable receipts or synchronization owns the cache. Migrate startup, navigation, creation, history, undo, recycle, and PDF collection, preserving existing navigation request IDs. Correct the recovered password wrapper wasm error conversion. Enforce exclusive SQLite ownership in the browser VFS, whose dependency ignores locks, so pending receipts keep the cache after the replica drops and a second connection cannot reopen it early. Add an actual-wasm notebook probe for pending apply and barrier receipts through synchronous and asynchronous host storage failures, responsive reads, recovery with one publication, and retained dropped-owner locks. Require its locked build, wasm-bindgen conversion, and Node run in the web CI lane. Depends on the SMB relocation change replacing adopt close/reopen. Fixes #98 Assisted-by: gpt-6.1-sol --- crates/notebook/Cargo.toml | 4 + crates/notebook/examples/browser_receipts.rs | 181 +++++++++++++++++++ crates/notebook/src/fs/web.rs | 2 +- crates/notebook/src/fs/web/sqlite.rs | 73 +++++++- crates/snowbound/src/history.rs | 2 +- crates/snowbound/src/library.rs | 61 ++++++- crates/snowbound/src/main.rs | 12 +- crates/snowbound/src/manage.rs | 8 +- crates/snowbound/src/print.rs | 45 ++--- crates/snowbound/src/recycle.rs | 10 +- crates/snowbound/src/sidebar.rs | 4 +- crates/snowbound/src/undo.rs | 4 +- tools/ci.py | 5 +- tools/web/receipt_probe.mjs | 32 ++++ tools/web/receipt_probe.py | 23 +++ 15 files changed, 412 insertions(+), 54 deletions(-) create mode 100644 crates/notebook/examples/browser_receipts.rs create mode 100644 tools/web/receipt_probe.mjs create mode 100644 tools/web/receipt_probe.py diff --git a/crates/notebook/Cargo.toml b/crates/notebook/Cargo.toml index c6528b3e83a9102b1830ce86347ec4e289d63c3b..352dc352f2d8884fb31ca53791a721fc375157a1 100644 --- a/crates/notebook/Cargo.toml +++ b/crates/notebook/Cargo.toml @@ -84,3 +84,7 @@ required-features = ["smb"] [[example]] name = "smb_publish_probe" required-features = ["smb"] + +[[example]] +name = "browser_receipts" +crate-type = ["cdylib"] diff --git a/crates/notebook/examples/browser_receipts.rs b/crates/notebook/examples/browser_receipts.rs new file mode 100644 index 0000000000000000000000000000000000000000..2291a762d3be4b11e5f554214ed677521a64015d --- /dev/null +++ b/crates/notebook/examples/browser_receipts.rs @@ -0,0 +1,181 @@ +#[cfg(target_arch = "wasm32")] +#[path = "../tests/support/server.rs"] +mod server; + +#[cfg(target_arch = "wasm32")] +mod web { + use super::server::{Server, text}; + use notebook::{Remote, Replica}; + use onestore::{ + CommitError, Stamp, Transaction, + op::{Edit, Op, PageOp}, + }; + use std::{ + future::Future, + io, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + task::{Context, Poll, Waker}, + time::Duration, + }; + use wasm_bindgen::prelude::*; + + #[wasm_bindgen] + extern "C" { + #[wasm_bindgen(js_namespace = console, js_name = error)] + fn report_failure(message: &str); + #[wasm_bindgen(js_namespace = globalThis, js_name = snowboundReceiptFailure)] + fn fail_storage(mode: u32); + #[wasm_bindgen(js_namespace = globalThis, js_name = snowboundReceiptTurn)] + fn turn() -> js_sys::Promise; + #[wasm_bindgen(js_namespace = globalThis, js_name = snowboundReceiptPhase)] + fn phase(message: &str); + } + + async fn next_turn() { + wasm_bindgen_futures::JsFuture::from(turn()).await.unwrap(); + } + + #[derive(Clone)] + struct Shared(Arc>); + + impl Remote for Shared { + fn read(&mut self) -> io::Result> { + self.0.lock().unwrap().read() + } + fn stamp(&mut self) -> io::Result { + self.0.lock().unwrap().stamp() + } + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { + self.0.lock().unwrap().publish(transaction) + } + fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> { + self.0.lock().unwrap().confirm(base) + } + } + + #[wasm_bindgen] + pub async fn receipt_probe() { + std::panic::set_hook(Box::new(|failure| report_failure(&failure.to_string()))); + phase("opening idle worker"); + notebook::fs::create_dir_all("/receipts").unwrap(); + let source = onestore::create_section("Receipt.one", "original", "Fixture").unwrap(); + let (space, text_id, _) = text(&source); + let unix = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) + .unwrap(); + let at = + (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100); + let edit = || Edit { + at, + ops: vec![Op::Page { + space, + op: PageOp::Text { + text: text_id, + range: 0..0, + with: "once ".into(), + }, + }], + }; + let replica = Arc::new(Replica::create("/receipts/worker.sqlite", &source).unwrap()); + let server = Shared(Arc::new(Mutex::new(Server::new(&source)))); + let remote = server.clone(); + let observed = Arc::new(AtomicUsize::new(0)); + let observations = observed.clone(); + let worker = replica + .start_sync( + Duration::from_secs(60), + move || Ok(remote.clone()), + move |result| { + assert!(result.is_ok()); + observations.fetch_add(1, Ordering::Relaxed); + }, + ) + .unwrap(); + while observed.load(Ordering::Relaxed) == 0 { + next_turn().await; + } + next_turn().await; + + { + fail_storage(1); + let mut applying = std::pin::pin!(replica.apply_async("Fixture", edit())); + assert!(matches!( + applying + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())), + Poll::Pending + )); + next_turn().await; + fail_storage(2); + let mut written = std::pin::pin!(replica.written_async()); + assert!(matches!( + written + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())), + Poll::Pending + )); + next_turn().await; + assert!(matches!( + applying + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())), + Poll::Pending + )); + assert!(matches!( + written + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())), + Poll::Pending + )); + assert_eq!(replica.pending().unwrap().len(), 1); + assert_eq!(text(&replica.snapshot().unwrap()).2, "once original"); + assert_eq!(server.0.lock().unwrap().publications, 0); + fail_storage(0); + phase("recovering apply and barrier"); + notebook::fs::durable().await.unwrap(); + applying.await.unwrap(); + written.await.unwrap(); + phase("publishing recovered edit"); + while !replica.pending().unwrap().is_empty() { + next_turn().await; + } + } + assert_eq!(server.0.lock().unwrap().publications, 1); + assert_eq!(text(&server.0.lock().unwrap().durable).2, "once original"); + drop(worker); + drop(replica); + next_turn().await; + + let replica = Replica::create("/receipts/dropped.sqlite", &source).unwrap(); + phase("dropping pending edit owner"); + fail_storage(1); + { + let mut applying = std::pin::pin!(replica.apply_async("Fixture", edit())); + assert!(matches!( + applying + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())), + Poll::Pending + )); + } + drop(replica); + match Replica::open("/receipts/dropped.sqlite") { + Err(error) => assert!(error.busy()), + Ok(_) => panic!("A pending receipt released cache ownership"), + } + next_turn().await; + fail_storage(0); + phase("reopening recovered cache"); + let reopened = loop { + if let Ok(replica) = Replica::open("/receipts/dropped.sqlite") { + break replica; + } + next_turn().await; + }; + assert_eq!(reopened.pending().unwrap().len(), 1); + assert_eq!(text(&reopened.snapshot().unwrap()).2, "once original"); + } +} diff --git a/crates/notebook/src/fs/web.rs b/crates/notebook/src/fs/web.rs index 71094d0dfce208ccb15715862adf055f77afbaec..0f3e51ef2d89a95fd1354389c18f962cb3c30c01 100644 --- a/crates/notebook/src/fs/web.rs +++ b/crates/notebook/src/fs/web.rs @@ -1,7 +1,7 @@ //! The browser's file system: std's calls over files held in memory, which the host loads //! before anything opens (`restore`) and writes out as they change, by the byte ranges that //! changed (`changes`). SQLite reaches the same files through `sqlite`, its default VFS here, -//! so a replica is a file like any other. One thread: no file is ever locked. +//! so a replica is a file like any other. use onestore::{CommitError, CommitIo, CommitState, Stamp, Transaction}; use std::{ diff --git a/crates/notebook/src/fs/web/sqlite.rs b/crates/notebook/src/fs/web/sqlite.rs index c619435b54070a00cc857f38f02fa29d9c2e0433..51d24e9ee90c20bf0deffe40cd3c4827675c93f9 100644 --- a/crates/notebook/src/fs/web/sqlite.rs +++ b/crates/notebook/src/fs/web/sqlite.rs @@ -5,9 +5,24 @@ use super::{FILES, Node, Shared, normal, with}; use rsqlite_vfs::{ OsCallback, SQLiteIoMethods, SQLiteVfs, SQLiteVfsFile, VfsError, VfsFile, VfsResult, VfsStore, - ffi::{SQLITE_CANTOPEN, SQLITE_IOERR, SQLITE_IOERR_DELETE, sqlite3_vfs}, + ffi::{ + SQLITE_BUSY, SQLITE_CANTOPEN, SQLITE_IOERR, SQLITE_IOERR_DELETE, SQLITE_LOCK_NONE, + SQLITE_LOCK_RESERVED, SQLITE_OK, sqlite3_file, sqlite3_vfs, + }, }; -use std::{path::Path, rc::Rc, sync::Once, time::Duration}; +use std::{ + cell::RefCell, + collections::BTreeMap, + path::{Path, PathBuf}, + rc::Rc, + sync::Once, + time::Duration, +}; + +thread_local! { + // Replica connections are exclusive, including while browser durability is pending. + static LOCKS: RefCell> = const { RefCell::new(BTreeMap::new()) }; +} /// Registers the VFS as SQLite's default, once. pub(crate) fn install() { @@ -110,12 +125,64 @@ impl SQLiteIoMethods for Methods { type Store = Store; const VERSION: i32 = 1; + + unsafe extern "C" fn xLock(file: *mut sqlite3_file, level: i32) -> i32 { + // SAFETY: SQLite supplies its live VFS file for every I/O callback. + let path = unsafe { normal(Path::new(SQLiteVfsFile::from_file(file).name())) }; + LOCKS.with_borrow_mut(|locks| { + if locks + .get(&path) + .is_some_and(|(owner, _)| *owner != file.addr()) + { + return SQLITE_BUSY; + } + locks.insert(path, (file.addr(), level)); + SQLITE_OK + }) + } + + unsafe extern "C" fn xUnlock(file: *mut sqlite3_file, level: i32) -> i32 { + // SAFETY: SQLite supplies its live VFS file for every I/O callback. + let path = unsafe { normal(Path::new(SQLiteVfsFile::from_file(file).name())) }; + LOCKS.with_borrow_mut(|locks| { + if let Some((owner, current)) = locks.get_mut(&path) + && *owner == file.addr() + { + if level == SQLITE_LOCK_NONE { + locks.remove(&path); + } else { + *current = level; + } + } + }); + SQLITE_OK + } + + unsafe extern "C" fn xCheckReservedLock(file: *mut sqlite3_file, reserved: *mut i32) -> i32 { + // SAFETY: SQLite supplies its live VFS file and writable result pointer. + unsafe { + let path = normal(Path::new(SQLiteVfsFile::from_file(file).name())); + *reserved = i32::from(LOCKS.with_borrow(|locks| { + locks + .get(&path) + .is_some_and(|(_, level)| *level >= SQLITE_LOCK_RESERVED) + })); + } + SQLITE_OK + } + + unsafe extern "C" fn xClose(file: *mut sqlite3_file) -> i32 { + // SAFETY: SQLite closes this live file once; release its lock before freeing its name. + unsafe { + Self::xUnlock(file, SQLITE_LOCK_NONE); + Self::xCloseImpl(file) + } + } } struct Vfs; impl OsCallback for Vfs { - /// Nothing else holds a lock to wait for. fn sleep(_: Duration) {} fn random(bytes: &mut [u8]) { diff --git a/crates/snowbound/src/history.rs b/crates/snowbound/src/history.rs index da1a3e1f858a1284361493a4098f14d3bbfcedf1..c5d98bb0a21c79e29d634f7d373188d248b2d573 100644 --- a/crates/snowbound/src/history.rs +++ b/crates/snowbound/src/history.rs @@ -259,7 +259,7 @@ impl State { continue; } let cleared: Result<(), Box> = async { - let section = library.open(&path, notify(proxy.clone()))?; + let section = library.open_async(&path, notify(proxy.clone())).await?; clear(§ion).await?; Ok(section.close_async().await?) } diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index 5be7913fc05dd26487eaa99071355bf2791a7965..3545d6381e303f6b651a9aa462b999d28da068aa 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -831,19 +831,65 @@ impl Library { ) -> Result> { // The background may hold the replica for a step, which takes a network round trip, // or be making it as an offline copy. - self.open_by(path, Box::new(notify), Instant::now() + TIMEOUT * 3) + self.open_by( + path, + Arc::new(Mutex::new(Box::new(notify))), + Instant::now() + TIMEOUT * 3, + ) } - /// `open`, waiting until `deadline` for whatever holds the section's replica. `notify` is - /// boxed to compile this once rather than once per caller's closure. + /// Opens a section, yielding to pending cache receipts while another task owns it. + pub async fn open_async( + &self, + path: &str, + notify: impl Fn() + Send + 'static, + ) -> Result> { + #[cfg(not(target_arch = "wasm32"))] + return self.open(path, notify); + #[cfg(target_arch = "wasm32")] + { + let deadline = Instant::now() + TIMEOUT * 3; + let notify: Arc>> = Arc::new(Mutex::new(Box::new(notify))); + loop { + let error = match self.open_by(path, Arc::clone(¬ify), Instant::now()) { + Ok(section) => return Ok(section), + Err(error) + if Instant::now() < deadline + && (error + .downcast_ref::() + .is_some_and(notebook::Error::busy) + || crate::io_kind(&*error) + == Some(io::ErrorKind::AlreadyExists)) => + { + error + } + Err(error) => return Err(error), + }; + let wait = js_sys::Promise::new(&mut |resolve, reject| { + let scheduled = web_sys::window().and_then(|window| { + window + .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, 50) + .ok() + }); + if scheduled.is_none() { + let _ = reject.call0(&wasm_bindgen::JsValue::NULL); + } + }); + if wasm_bindgen_futures::JsFuture::from(wait).await.is_err() { + return Err(error); + } + } + } + } + + /// Opens a section, waiting natively until `deadline` for its current owner. fn open_by( &self, path: &str, - notify: Box, + notify: Arc>>, deadline: Instant, ) -> Result> { crate::crash::conceal(path); - let notify = Arc::new(Mutex::new(notify)); let notifier = || { let notify = Arc::clone(¬ify); move || { @@ -917,6 +963,7 @@ impl Library { if (error.busy() || matches!(&error, notebook::Error::Io(error) if error.kind() == io::ErrorKind::AlreadyExists)) + && !cfg!(target_arch = "wasm32") && Instant::now() < deadline => { std::thread::sleep(Duration::from_millis(50)); @@ -938,7 +985,7 @@ impl Library { path: &str, notify: impl Fn() + Send + 'static, ) -> Result, Box> { - let section = self.open_by(path, Box::new(notify), Instant::now())?; + let section = self.open_by(path, Arc::new(Mutex::new(Box::new(notify))), Instant::now())?; let replica = Arc::clone(section.replica()); self.keep(path, section); Ok(replica) @@ -1227,7 +1274,7 @@ impl Library { #[cfg(target_arch = "wasm32")] { drop(operation); - Err(io::ErrorKind::WouldBlock.into()) + Err(io::Error::from(io::ErrorKind::WouldBlock).into()) } } diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 9865ad77c12cb2278db3ecde99452ce262f81631..f897c2f75d3845e2056de950716d031cec6fb2b9 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1168,7 +1168,9 @@ impl State { } Input::Section { file, title } => { let library = Arc::new(Library::section(&file, &cache)); - let section = library.open(&library.location, notify(proxy.clone()))?; + let section = library + .open_async(&library.location, notify(proxy.clone())) + .await?; let space = section .pages()? .into_iter() @@ -1212,7 +1214,7 @@ impl State { }); match shown { Some((library, path, left)) if !library.locked(&path) => { - let section = library.open(&path, notify(proxy.clone()))?; + let section = library.open_async(&path, notify(proxy.clone())).await?; let (opened, page) = read_session(section, Arc::clone(library), path, left)?; let (scene, editor) = PageScene::from_page(page, &mut engine)?; @@ -3552,9 +3554,9 @@ impl State { self.switching = Some((tab, Instant::now())); let notify = notify(self.proxy.clone()); let last = self.last_pages.get(&library.key(&path)).copied(); - self.load(move || { + self.load_async(move || async move { let start = Instant::now(); - let section = library.open(&path, notify)?; + let section = library.open_async(&path, notify).await?; lap("switch section open", start); let (session, page) = read_session(section, library, path, last)?; Ok(Loaded::Section(Box::new(session), page)) @@ -3614,7 +3616,7 @@ impl State { let (notify, author) = (notify(self.proxy.clone()), self.author.clone()); crate::spawn_async(move || async move { let copied: Result<(), Box> = async { - let section = library.open(&path, notify)?; + let section = library.open_async(&path, notify).await?; section.import_page_async(&page, &author).await?; Ok(section.close_async().await?) } diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index f51c83b3331f991cc1aa66743cee020eace66145..7fd0494925836d11cc8a218120ff9c7fe934531f 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -471,7 +471,7 @@ impl State { let page = self.dated_page(None, "")?; let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone())); let theme = self.notebook_theme.clone(); - self.load(move || { + self.load_async(move || async move { let location = notebook::fs::absolute(&root)? .to_string_lossy() .into_owned(); @@ -490,7 +490,7 @@ impl State { let path = library .first_section() .ok_or("The new notebook has no section")?; - let section = library.open(&path, notify)?; + let section = library.open_async(&path, notify).await?; let (session, page) = read_session(section, library, path, None)?; Ok(Loaded::Section(Box::new(session), page)) }); @@ -869,7 +869,7 @@ impl State { if let Some(key) = key { library.keep_key(&path, key); } - let section = library.open(&path, notify)?; + let section = library.open_async(&path, notify).await?; let (session, page) = read_session(section, library, path, None)?; return Ok(Loaded::Section(Box::new(session), page)); } @@ -897,7 +897,7 @@ impl State { if followed.as_deref() == Some(path.as_str()) { return Ok(Loaded::Library(library, Some(path))); } - let section = library.open(&path, notify)?; + let section = library.open_async(&path, notify).await?; let (session, page) = read_session(section, library, path, None)?; Ok(if fresh { Loaded::Created(Box::new(session), page) diff --git a/crates/snowbound/src/print.rs b/crates/snowbound/src/print.rs index dc82626e5ad165695a2bf9732ce5e5ee8f779d21..87c3fb0ccd96b160023543eeb8e71cfc9e07defc 100644 --- a/crates/snowbound/src/print.rs +++ b/crates/snowbound/src/print.rs @@ -328,8 +328,8 @@ impl State { }; let whole = matches!(setup.scope, Scope::Section | Scope::Notebook); let name = format!("{}.pdf", file_name(&title)); - let print = move |destination: Option| { - let made = (|| -> Result, Box> { + let print = move |destination: Option| async move { + let made: Result, Box> = async { let mut open = Some(open); let this = spaces .into_iter() @@ -343,27 +343,28 @@ impl State { let sections = if others.is_empty() { this.into_iter().collect() } else { - others - .into_iter() - .map(|(path, name)| { - if path == open_tab.0 - && let Some(open) = this.take() - { - return Ok(open); - } - let section = library.open(&path, || {})?; - let pages = section - .pages()? - .into_iter() - .map(|(space, ..)| section.page(space)) - .collect::, _>>()?; - Ok((name, printed(pages))) - }) - .collect::, Box>>()? + let mut sections = Vec::new(); + for (path, name) in others { + if path == open_tab.0 + && let Some(open) = this.take() + { + sections.push(open); + continue; + } + let section = library.open_async(&path, || {}).await?; + let pages = section + .pages()? + .into_iter() + .map(|(space, ..)| section.page(space)) + .collect::, _>>()?; + sections.push((name, printed(pages))); + } + sections }; let mut engine = layouts.lock().map_err(|_| "Page layout failed")?; Ok(canvas::print::pdf(sections, &mut engine, &layout)?) - })(); + } + .await; let done = made.and_then(|pdf| match &destination { Some(path) => notebook::fs::write(path, pdf) .map(|()| None) @@ -386,14 +387,14 @@ impl State { }))); }; if !export { - crate::spawn(move || print(None)); + crate::spawn_async(move || print(None)); return Ok(()); } let reply = self.reply(|_, mut path: std::path::PathBuf| { if path.extension().is_none() { path.set_extension("pdf"); } - crate::spawn(move || print(Some(path))); + crate::spawn_async(move || print(Some(path))); Ok(()) }); platform::pick_new("Export as PDF", &name, "Export", None, reply); diff --git a/crates/snowbound/src/recycle.rs b/crates/snowbound/src/recycle.rs index 0415a8fbacff60f0768c06964c72813fa9410e75..303d32b078226bddff33e7d3725f3f30120d2a76 100644 --- a/crates/snowbound/src/recycle.rs +++ b/crates/snowbound/src/recycle.rs @@ -60,11 +60,11 @@ impl State { } let paths: Vec = library.tabs(BIN).into_iter().map(|tab| tab.path).collect(); let proxy = self.proxy.clone(); - self.load(move || { + self.load_async(move || async move { for path in paths { - let section = library.open(&path, notify(proxy.clone()))?; + let section = library.open_async(&path, notify(proxy.clone())).await?; if section.pages()?.is_empty() { - section.close()?; + section.close_async().await?; continue; } let (session, page) = read_session(section, library, path, None)?; @@ -148,7 +148,7 @@ impl State { let path = library .first_section() .ok_or("The notebook has no sections")?; - let section = library.open(&path, notify(proxy))?; + let section = library.open_async(&path, notify(proxy)).await?; let (session, page) = read_session(section, library, path, None)?; Ok(Loaded::Section(Box::new(session), page)) }); @@ -201,7 +201,7 @@ async fn carry_out( let gone = match request { Request::Restore { space, path, copy } => { let page = replica.page(space)?; - let section = library.open(&path, || {})?; + let section = library.open_async(&path, || {}).await?; if copy { section.import_page_async(&page, author).await?; } else { diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index 453837a65b9b3a070c518cf0dd21d46d3e0bc2fb..a5b80aed888fe789ca199c845648aaa8782d35d6 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -1393,7 +1393,7 @@ impl crate::State { .and_then(|path| self.last_pages.get(&crate::library::key(&location, path))) .copied(); let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone())); - self.load(move || { + self.load_async(move || async move { let library = match open { Some(library) => library, None => { @@ -1416,7 +1416,7 @@ impl crate::State { } return Err("This folder holds no notebook sections.".into()); }; - let section = library.open(&path, notify)?; + let section = library.open_async(&path, notify).await?; let (session, page) = crate::read_session(section, library, path, left)?; Ok(crate::Loaded::Section(Box::new(session), page)) }); diff --git a/crates/snowbound/src/undo.rs b/crates/snowbound/src/undo.rs index 21def366923867254053c50a6a72817dc37944c0..a89422a38ae288a4e7507835c52bc4de3c91fb8f 100644 --- a/crates/snowbound/src/undo.rs +++ b/crates/snowbound/src/undo.rs @@ -562,7 +562,7 @@ impl Site { let level = listed[from].2; let mut ops = vec![Op::Section(SectionOp::Delete(vec![page]))]; let (show, added) = self.after(&listed, &[page], None, &mut ops); - let section = self.library.open(&path, || {})?; + let section = self.library.open_async(&path, || {}).await?; section .replica() .apply_async( @@ -597,7 +597,7 @@ impl Site { let Some(path) = self.section_path(target) else { return Ok(None); }; - let section = self.library.open(&path, || {})?; + let section = self.library.open_async(&path, || {}).await?; let sent = match section.pages()?.iter().any(|(listed, ..)| *listed == page) { true => Some(section.page(page)?), false => None, diff --git a/tools/ci.py b/tools/ci.py index b0c12de57f912530e198861dd9da2e7b6e2a1cf8..c644657bb1c9610a1bf555e27e5c5d8f88482eba 100644 --- a/tools/ci.py +++ b/tools/ci.py @@ -85,9 +85,10 @@ def lanes(): targets = subprocess.run(['rustup', 'target', 'list', '--installed'], capture_output=True, text=True).stdout wasm = web_environment() result.append(dict( - name='web', minutes=20, packages=['snowbound'], paths=('crates/snowbound/web/', 'tools/release_web.py'), + name='web', minutes=20, packages=['snowbound'], paths=('crates/snowbound/web/', 'tools/release_web.py', 'tools/web/'), # Clippy builds it; the module itself is linked by release_web.py. - commands=[cargo('clippy', '-p', 'snowbound', '--target', 'wasm32-unknown-unknown', '--no-default-features', '--features', 'wgpu,live', '--', '-D', 'warnings')], + commands=[cargo('clippy', '-p', 'snowbound', '--target', 'wasm32-unknown-unknown', '--no-default-features', '--features', 'wgpu,live', '--', '-D', 'warnings'), + ['python3', 'tools/web/receipt_probe.py']], environment={**(wasm or {}), 'CARGO_TARGET_DIR': str(TARGET / 'wasm')}, missing='needs the wasm32-unknown-unknown target' if 'wasm32-unknown-unknown' not in targets.split() else None if wasm else 'needs nix for a clang that builds for wasm32')) diff --git a/tools/web/receipt_probe.mjs b/tools/web/receipt_probe.mjs new file mode 100644 index 0000000000000000000000000000000000000000..5fc7b43f1186a27220944792f80246385f843d07 --- /dev/null +++ b/tools/web/receipt_probe.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { webcrypto } from 'node:crypto'; +import { setTimeout } from 'node:timers'; + +globalThis.crypto ??= webcrypto; +let failure = 0; +let phase = 'initializing wasm'; +globalThis.setTimeout = (callback, delay, ...args) => setTimeout(callback, delay, ...args).unref(); +const failures = [0, 0, 0]; +globalThis.snowboundReceiptPhase = value => { phase = value; }; +globalThis.snowboundReceiptFailure = value => { failure = value; }; +globalThis.snowboundReceiptTurn = () => new Promise(resolve => setTimeout(resolve, 10)); +globalThis.snowboundFlushStorage = () => { + failures[failure]++; + if (failure === 1) { + throw 'Injected persistent storage failure'; + } + if (failure === 2) return Promise.reject('Injected asynchronous storage failure'); + return Promise.resolve(); +}; +const wasm = createRequire(import.meta.url)(resolve(process.argv[2])); +const timeout = setTimeout(() => { throw Error(`Receipt retry stalled while ${phase}: mode ${failure}, flushes ${failures}`); }, 10000); +try { + await wasm.receipt_probe(); + assert.ok(failures[1] >= 2, 'The active and dropped owner each retry synchronous storage failure'); + assert.ok(failures[2] >= 1, 'An asynchronous storage rejection also keeps the receipt pending'); + console.log(`Actual wasm receipts passed: ${failures[1]} throws, ${failures[2]} rejections, one publication, responsive reads, retained ownership`); +} finally { + clearTimeout(timeout); +} diff --git a/tools/web/receipt_probe.py b/tools/web/receipt_probe.py new file mode 100644 index 0000000000000000000000000000000000000000..6c12b2048ee3f407feb2f5846ee15961b96a37e6 --- /dev/null +++ b/tools/web/receipt_probe.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Build and run the actual wasm notebook receipt regression.""" +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import release_web + + +def main(): + environment = release_web.environment() + target = Path(environment['CARGO_TARGET_DIR']) + output = target / 'receipt-probe' + release_web.run(['cargo', 'build', '--locked', '-p', 'notebook', '--example', 'browser_receipts', + '--target', 'wasm32-unknown-unknown'], env=environment) + release_web.run([release_web.tool('wasm-bindgen'), '--target', 'nodejs', '--no-typescript', + '--out-dir', output, + target / 'wasm32-unknown-unknown/debug/examples/browser_receipts.wasm']) + release_web.run(['node', Path(__file__).with_suffix('.mjs'), output / 'browser_receipts.js']) + + +if __name__ == '__main__': + main() -- 2.54.0