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()