authorclover caruso <git@paperclover.net> 2026-10-10 19:11:58-07:00
committerclover caruso <git@paperclover.net> 2026-10-10 22:56:05-07:00
loga217722937719101c12d3c2521a03b33abc32c8c
tree14ca4955e7ba5d4ca2882e0856993cd2d8d73faa
parent33d9c0e17eaf06bde98a3a918d895d5d2c829e23
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

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

15 files changed, 412 insertions(+), 54 deletions(-)

crates/notebook/Cargo.toml+4
......@@ -84,3 +84,7 @@ required-features = ["smb"]
8484[[example]]
8585name = "smb_publish_probe"
8686required-features = ["smb"]
87
88[[example]]
89name = "browser_receipts"
90crate-type = ["cdylib"]
crates/notebook/examples/browser_receipts.rs created+181
......@@ -0,0 +1,181 @@
1#[cfg(target_arch = "wasm32")]
2#[path = "../tests/support/server.rs"]
3mod server;
4
5#[cfg(target_arch = "wasm32")]
6mod web {
7 use super::server::{Server, text};
8 use notebook::{Remote, Replica};
9 use onestore::{
10 CommitError, Stamp, Transaction,
11 op::{Edit, Op, PageOp},
12 };
13 use std::{
14 future::Future,
15 io,
16 sync::{
17 Arc, Mutex,
18 atomic::{AtomicUsize, Ordering},
19 },
20 task::{Context, Poll, Waker},
21 time::Duration,
22 };
23 use wasm_bindgen::prelude::*;
24
25 #[wasm_bindgen]
26 extern "C" {
27 #[wasm_bindgen(js_namespace = console, js_name = error)]
28 fn report_failure(message: &str);
29 #[wasm_bindgen(js_namespace = globalThis, js_name = snowboundReceiptFailure)]
30 fn fail_storage(mode: u32);
31 #[wasm_bindgen(js_namespace = globalThis, js_name = snowboundReceiptTurn)]
32 fn turn() -> js_sys::Promise;
33 #[wasm_bindgen(js_namespace = globalThis, js_name = snowboundReceiptPhase)]
34 fn phase(message: &str);
35 }
36
37 async fn next_turn() {
38 wasm_bindgen_futures::JsFuture::from(turn()).await.unwrap();
39 }
40
41 #[derive(Clone)]
42 struct Shared(Arc<Mutex<Server>>);
43
44 impl Remote for Shared {
45 fn read(&mut self) -> io::Result<Vec<u8>> {
46 self.0.lock().unwrap().read()
47 }
48 fn stamp(&mut self) -> io::Result<Stamp> {
49 self.0.lock().unwrap().stamp()
50 }
51 fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> {
52 self.0.lock().unwrap().publish(transaction)
53 }
54 fn confirm(&mut self, base: &Stamp) -> Result<(), CommitError> {
55 self.0.lock().unwrap().confirm(base)
56 }
57 }
58
59 #[wasm_bindgen]
60 pub async fn receipt_probe() {
61 std::panic::set_hook(Box::new(|failure| report_failure(&failure.to_string())));
62 phase("opening idle worker");
63 notebook::fs::create_dir_all("/receipts").unwrap();
64 let source = onestore::create_section("Receipt.one", "original", "Fixture").unwrap();
65 let (space, text_id, _) = text(&source);
66 let unix = web_time::SystemTime::now()
67 .duration_since(web_time::UNIX_EPOCH)
68 .unwrap();
69 let at =
70 (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100);
71 let edit = || Edit {
72 at,
73 ops: vec![Op::Page {
74 space,
75 op: PageOp::Text {
76 text: text_id,
77 range: 0..0,
78 with: "once ".into(),
79 },
80 }],
81 };
82 let replica = Arc::new(Replica::create("/receipts/worker.sqlite", &source).unwrap());
83 let server = Shared(Arc::new(Mutex::new(Server::new(&source))));
84 let remote = server.clone();
85 let observed = Arc::new(AtomicUsize::new(0));
86 let observations = observed.clone();
87 let worker = replica
88 .start_sync(
89 Duration::from_secs(60),
90 move || Ok(remote.clone()),
91 move |result| {
92 assert!(result.is_ok());
93 observations.fetch_add(1, Ordering::Relaxed);
94 },
95 )
96 .unwrap();
97 while observed.load(Ordering::Relaxed) == 0 {
98 next_turn().await;
99 }
100 next_turn().await;
101
102 {
103 fail_storage(1);
104 let mut applying = std::pin::pin!(replica.apply_async("Fixture", edit()));
105 assert!(matches!(
106 applying
107 .as_mut()
108 .poll(&mut Context::from_waker(Waker::noop())),
109 Poll::Pending
110 ));
111 next_turn().await;
112 fail_storage(2);
113 let mut written = std::pin::pin!(replica.written_async());
114 assert!(matches!(
115 written
116 .as_mut()
117 .poll(&mut Context::from_waker(Waker::noop())),
118 Poll::Pending
119 ));
120 next_turn().await;
121 assert!(matches!(
122 applying
123 .as_mut()
124 .poll(&mut Context::from_waker(Waker::noop())),
125 Poll::Pending
126 ));
127 assert!(matches!(
128 written
129 .as_mut()
130 .poll(&mut Context::from_waker(Waker::noop())),
131 Poll::Pending
132 ));
133 assert_eq!(replica.pending().unwrap().len(), 1);
134 assert_eq!(text(&replica.snapshot().unwrap()).2, "once original");
135 assert_eq!(server.0.lock().unwrap().publications, 0);
136 fail_storage(0);
137 phase("recovering apply and barrier");
138 notebook::fs::durable().await.unwrap();
139 applying.await.unwrap();
140 written.await.unwrap();
141 phase("publishing recovered edit");
142 while !replica.pending().unwrap().is_empty() {
143 next_turn().await;
144 }
145 }
146 assert_eq!(server.0.lock().unwrap().publications, 1);
147 assert_eq!(text(&server.0.lock().unwrap().durable).2, "once original");
148 drop(worker);
149 drop(replica);
150 next_turn().await;
151
152 let replica = Replica::create("/receipts/dropped.sqlite", &source).unwrap();
153 phase("dropping pending edit owner");
154 fail_storage(1);
155 {
156 let mut applying = std::pin::pin!(replica.apply_async("Fixture", edit()));
157 assert!(matches!(
158 applying
159 .as_mut()
160 .poll(&mut Context::from_waker(Waker::noop())),
161 Poll::Pending
162 ));
163 }
164 drop(replica);
165 match Replica::open("/receipts/dropped.sqlite") {
166 Err(error) => assert!(error.busy()),
167 Ok(_) => panic!("A pending receipt released cache ownership"),
168 }
169 next_turn().await;
170 fail_storage(0);
171 phase("reopening recovered cache");
172 let reopened = loop {
173 if let Ok(replica) = Replica::open("/receipts/dropped.sqlite") {
174 break replica;
175 }
176 next_turn().await;
177 };
178 assert_eq!(reopened.pending().unwrap().len(), 1);
179 assert_eq!(text(&reopened.snapshot().unwrap()).2, "once original");
180 }
181}
crates/notebook/src/fs/web.rs+1-1
......@@ -1,7 +1,7 @@
11//! The browser's file system: std's calls over files held in memory, which the host loads
22//! before anything opens (`restore`) and writes out as they change, by the byte ranges that
33//! changed (`changes`). SQLite reaches the same files through `sqlite`, its default VFS here,
4//! so a replica is a file like any other. One thread: no file is ever locked.
4//! so a replica is a file like any other.
55
66use onestore::{CommitError, CommitIo, CommitState, Stamp, Transaction};
77use std::{
crates/notebook/src/fs/web/sqlite.rs+70-3
......@@ -5,9 +5,24 @@
55use super::{FILES, Node, Shared, normal, with};
66use rsqlite_vfs::{
77 OsCallback, SQLiteIoMethods, SQLiteVfs, SQLiteVfsFile, VfsError, VfsFile, VfsResult, VfsStore,
8 ffi::{SQLITE_CANTOPEN, SQLITE_IOERR, SQLITE_IOERR_DELETE, sqlite3_vfs},
8 ffi::{
9 SQLITE_BUSY, SQLITE_CANTOPEN, SQLITE_IOERR, SQLITE_IOERR_DELETE, SQLITE_LOCK_NONE,
10 SQLITE_LOCK_RESERVED, SQLITE_OK, sqlite3_file, sqlite3_vfs,
11 },
912};
10use std::{path::Path, rc::Rc, sync::Once, time::Duration};
13use std::{
14 cell::RefCell,
15 collections::BTreeMap,
16 path::{Path, PathBuf},
17 rc::Rc,
18 sync::Once,
19 time::Duration,
20};
21
22thread_local! {
23 // Replica connections are exclusive, including while browser durability is pending.
24 static LOCKS: RefCell<BTreeMap<PathBuf, (usize, i32)>> = const { RefCell::new(BTreeMap::new()) };
25}
1126
1227/// Registers the VFS as SQLite's default, once.
1328pub(crate) fn install() {
......@@ -110,12 +125,64 @@ impl SQLiteIoMethods for Methods {
110125 type Store = Store;
111126
112127 const VERSION: i32 = 1;
128
129 unsafe extern "C" fn xLock(file: *mut sqlite3_file, level: i32) -> i32 {
130 // SAFETY: SQLite supplies its live VFS file for every I/O callback.
131 let path = unsafe { normal(Path::new(SQLiteVfsFile::from_file(file).name())) };
132 LOCKS.with_borrow_mut(|locks| {
133 if locks
134 .get(&path)
135 .is_some_and(|(owner, _)| *owner != file.addr())
136 {
137 return SQLITE_BUSY;
138 }
139 locks.insert(path, (file.addr(), level));
140 SQLITE_OK
141 })
142 }
143
144 unsafe extern "C" fn xUnlock(file: *mut sqlite3_file, level: i32) -> i32 {
145 // SAFETY: SQLite supplies its live VFS file for every I/O callback.
146 let path = unsafe { normal(Path::new(SQLiteVfsFile::from_file(file).name())) };
147 LOCKS.with_borrow_mut(|locks| {
148 if let Some((owner, current)) = locks.get_mut(&path)
149 && *owner == file.addr()
150 {
151 if level == SQLITE_LOCK_NONE {
152 locks.remove(&path);
153 } else {
154 *current = level;
155 }
156 }
157 });
158 SQLITE_OK
159 }
160
161 unsafe extern "C" fn xCheckReservedLock(file: *mut sqlite3_file, reserved: *mut i32) -> i32 {
162 // SAFETY: SQLite supplies its live VFS file and writable result pointer.
163 unsafe {
164 let path = normal(Path::new(SQLiteVfsFile::from_file(file).name()));
165 *reserved = i32::from(LOCKS.with_borrow(|locks| {
166 locks
167 .get(&path)
168 .is_some_and(|(_, level)| *level >= SQLITE_LOCK_RESERVED)
169 }));
170 }
171 SQLITE_OK
172 }
173
174 unsafe extern "C" fn xClose(file: *mut sqlite3_file) -> i32 {
175 // SAFETY: SQLite closes this live file once; release its lock before freeing its name.
176 unsafe {
177 Self::xUnlock(file, SQLITE_LOCK_NONE);
178 Self::xCloseImpl(file)
179 }
180 }
113181}
114182
115183struct Vfs;
116184
117185impl OsCallback for Vfs {
118 /// Nothing else holds a lock to wait for.
119186 fn sleep(_: Duration) {}
120187
121188 fn random(bytes: &mut [u8]) {
crates/snowbound/src/history.rs+1-1
......@@ -259,7 +259,7 @@ impl State {
259259 continue;
260260 }
261261 let cleared: Result<(), Box<dyn Error>> = async {
262 let section = library.open(&path, notify(proxy.clone()))?;
262 let section = library.open_async(&path, notify(proxy.clone())).await?;
263263 clear(&section).await?;
264264 Ok(section.close_async().await?)
265265 }
crates/snowbound/src/library.rs+54-7
......@@ -831,19 +831,65 @@ impl Library {
831831 ) -> Result<Section, Box<dyn Error>> {
832832 // The background may hold the replica for a step, which takes a network round trip,
833833 // or be making it as an offline copy.
834 self.open_by(path, Box::new(notify), Instant::now() + TIMEOUT * 3)
834 self.open_by(
835 path,
836 Arc::new(Mutex::new(Box::new(notify))),
837 Instant::now() + TIMEOUT * 3,
838 )
839 }
840
841 /// Opens a section, yielding to pending cache receipts while another task owns it.
842 pub async fn open_async(
843 &self,
844 path: &str,
845 notify: impl Fn() + Send + 'static,
846 ) -> Result<Section, Box<dyn Error>> {
847 #[cfg(not(target_arch = "wasm32"))]
848 return self.open(path, notify);
849 #[cfg(target_arch = "wasm32")]
850 {
851 let deadline = Instant::now() + TIMEOUT * 3;
852 let notify: Arc<Mutex<Box<dyn Fn() + Send>>> = Arc::new(Mutex::new(Box::new(notify)));
853 loop {
854 let error = match self.open_by(path, Arc::clone(&notify), Instant::now()) {
855 Ok(section) => return Ok(section),
856 Err(error)
857 if Instant::now() < deadline
858 && (error
859 .downcast_ref::<notebook::Error>()
860 .is_some_and(notebook::Error::busy)
861 || crate::io_kind(&*error)
862 == Some(io::ErrorKind::AlreadyExists)) =>
863 {
864 error
865 }
866 Err(error) => return Err(error),
867 };
868 let wait = js_sys::Promise::new(&mut |resolve, reject| {
869 let scheduled = web_sys::window().and_then(|window| {
870 window
871 .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, 50)
872 .ok()
873 });
874 if scheduled.is_none() {
875 let _ = reject.call0(&wasm_bindgen::JsValue::NULL);
876 }
877 });
878 if wasm_bindgen_futures::JsFuture::from(wait).await.is_err() {
879 return Err(error);
880 }
881 }
882 }
835883 }
836884
837 /// `open`, waiting until `deadline` for whatever holds the section's replica. `notify` is
838 /// boxed to compile this once rather than once per caller's closure.
885 /// Opens a section, waiting natively until `deadline` for its current owner.
839886 fn open_by(
840887 &self,
841888 path: &str,
842 notify: Box<dyn Fn() + Send>,
889 notify: Arc<Mutex<Box<dyn Fn() + Send>>>,
843890 deadline: Instant,
844891 ) -> Result<Section, Box<dyn Error>> {
845892 crate::crash::conceal(path);
846 let notify = Arc::new(Mutex::new(notify));
847893 let notifier = || {
848894 let notify = Arc::clone(&notify);
849895 move || {
......@@ -917,6 +963,7 @@ impl Library {
917963 if (error.busy()
918964 || matches!(&error, notebook::Error::Io(error)
919965 if error.kind() == io::ErrorKind::AlreadyExists))
966 && !cfg!(target_arch = "wasm32")
920967 && Instant::now() < deadline =>
921968 {
922969 std::thread::sleep(Duration::from_millis(50));
......@@ -938,7 +985,7 @@ impl Library {
938985 path: &str,
939986 notify: impl Fn() + Send + 'static,
940987 ) -> Result<Arc<notebook::Replica>, Box<dyn Error>> {
941 let section = self.open_by(path, Box::new(notify), Instant::now())?;
988 let section = self.open_by(path, Arc::new(Mutex::new(Box::new(notify))), Instant::now())?;
942989 let replica = Arc::clone(section.replica());
943990 self.keep(path, section);
944991 Ok(replica)
......@@ -1227,7 +1274,7 @@ impl Library {
12271274 #[cfg(target_arch = "wasm32")]
12281275 {
12291276 drop(operation);
1230 Err(io::ErrorKind::WouldBlock.into())
1277 Err(io::Error::from(io::ErrorKind::WouldBlock).into())
12311278 }
12321279 }
12331280
crates/snowbound/src/main.rs+7-5
......@@ -1168,7 +1168,9 @@ impl State {
11681168 }
11691169 Input::Section { file, title } => {
11701170 let library = Arc::new(Library::section(&file, &cache));
1171 let section = library.open(&library.location, notify(proxy.clone()))?;
1171 let section = library
1172 .open_async(&library.location, notify(proxy.clone()))
1173 .await?;
11721174 let space = section
11731175 .pages()?
11741176 .into_iter()
......@@ -1212,7 +1214,7 @@ impl State {
12121214 });
12131215 match shown {
12141216 Some((library, path, left)) if !library.locked(&path) => {
1215 let section = library.open(&path, notify(proxy.clone()))?;
1217 let section = library.open_async(&path, notify(proxy.clone())).await?;
12161218 let (opened, page) =
12171219 read_session(section, Arc::clone(library), path, left)?;
12181220 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
......@@ -3552,9 +3554,9 @@ impl State {
35523554 self.switching = Some((tab, Instant::now()));
35533555 let notify = notify(self.proxy.clone());
35543556 let last = self.last_pages.get(&library.key(&path)).copied();
3555 self.load(move || {
3557 self.load_async(move || async move {
35563558 let start = Instant::now();
3557 let section = library.open(&path, notify)?;
3559 let section = library.open_async(&path, notify).await?;
35583560 lap("switch section open", start);
35593561 let (session, page) = read_session(section, library, path, last)?;
35603562 Ok(Loaded::Section(Box::new(session), page))
......@@ -3614,7 +3616,7 @@ impl State {
36143616 let (notify, author) = (notify(self.proxy.clone()), self.author.clone());
36153617 crate::spawn_async(move || async move {
36163618 let copied: Result<(), Box<dyn Error>> = async {
3617 let section = library.open(&path, notify)?;
3619 let section = library.open_async(&path, notify).await?;
36183620 section.import_page_async(&page, &author).await?;
36193621 Ok(section.close_async().await?)
36203622 }
crates/snowbound/src/manage.rs+4-4
......@@ -471,7 +471,7 @@ impl State {
471471 let page = self.dated_page(None, "")?;
472472 let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone()));
473473 let theme = self.notebook_theme.clone();
474 self.load(move || {
474 self.load_async(move || async move {
475475 let location = notebook::fs::absolute(&root)?
476476 .to_string_lossy()
477477 .into_owned();
......@@ -490,7 +490,7 @@ impl State {
490490 let path = library
491491 .first_section()
492492 .ok_or("The new notebook has no section")?;
493 let section = library.open(&path, notify)?;
493 let section = library.open_async(&path, notify).await?;
494494 let (session, page) = read_session(section, library, path, None)?;
495495 Ok(Loaded::Section(Box::new(session), page))
496496 });
......@@ -869,7 +869,7 @@ impl State {
869869 if let Some(key) = key {
870870 library.keep_key(&path, key);
871871 }
872 let section = library.open(&path, notify)?;
872 let section = library.open_async(&path, notify).await?;
873873 let (session, page) = read_session(section, library, path, None)?;
874874 return Ok(Loaded::Section(Box::new(session), page));
875875 }
......@@ -897,7 +897,7 @@ impl State {
897897 if followed.as_deref() == Some(path.as_str()) {
898898 return Ok(Loaded::Library(library, Some(path)));
899899 }
900 let section = library.open(&path, notify)?;
900 let section = library.open_async(&path, notify).await?;
901901 let (session, page) = read_session(section, library, path, None)?;
902902 Ok(if fresh {
903903 Loaded::Created(Box::new(session), page)
crates/snowbound/src/print.rs+23-22
......@@ -328,8 +328,8 @@ impl State {
328328 };
329329 let whole = matches!(setup.scope, Scope::Section | Scope::Notebook);
330330 let name = format!("{}.pdf", file_name(&title));
331 let print = move |destination: Option<std::path::PathBuf>| {
332 let made = (|| -> Result<Vec<u8>, Box<dyn Error>> {
331 let print = move |destination: Option<std::path::PathBuf>| async move {
332 let made: Result<Vec<u8>, Box<dyn Error>> = async {
333333 let mut open = Some(open);
334334 let this = spaces
335335 .into_iter()
......@@ -343,27 +343,28 @@ impl State {
343343 let sections = if others.is_empty() {
344344 this.into_iter().collect()
345345 } else {
346 others
347 .into_iter()
348 .map(|(path, name)| {
349 if path == open_tab.0
350 && let Some(open) = this.take()
351 {
352 return Ok(open);
353 }
354 let section = library.open(&path, || {})?;
355 let pages = section
356 .pages()?
357 .into_iter()
358 .map(|(space, ..)| section.page(space))
359 .collect::<Result<Vec<_>, _>>()?;
360 Ok((name, printed(pages)))
361 })
362 .collect::<Result<Vec<_>, Box<dyn Error>>>()?
346 let mut sections = Vec::new();
347 for (path, name) in others {
348 if path == open_tab.0
349 && let Some(open) = this.take()
350 {
351 sections.push(open);
352 continue;
353 }
354 let section = library.open_async(&path, || {}).await?;
355 let pages = section
356 .pages()?
357 .into_iter()
358 .map(|(space, ..)| section.page(space))
359 .collect::<Result<Vec<_>, _>>()?;
360 sections.push((name, printed(pages)));
361 }
362 sections
363363 };
364364 let mut engine = layouts.lock().map_err(|_| "Page layout failed")?;
365365 Ok(canvas::print::pdf(sections, &mut engine, &layout)?)
366 })();
366 }
367 .await;
367368 let done = made.and_then(|pdf| match &destination {
368369 Some(path) => notebook::fs::write(path, pdf)
369370 .map(|()| None)
......@@ -386,14 +387,14 @@ impl State {
386387 })));
387388 };
388389 if !export {
389 crate::spawn(move || print(None));
390 crate::spawn_async(move || print(None));
390391 return Ok(());
391392 }
392393 let reply = self.reply(|_, mut path: std::path::PathBuf| {
393394 if path.extension().is_none() {
394395 path.set_extension("pdf");
395396 }
396 crate::spawn(move || print(Some(path)));
397 crate::spawn_async(move || print(Some(path)));
397398 Ok(())
398399 });
399400 platform::pick_new("Export as PDF", &name, "Export", None, reply);
crates/snowbound/src/recycle.rs+5-5
......@@ -60,11 +60,11 @@ impl State {
6060 }
6161 let paths: Vec<String> = library.tabs(BIN).into_iter().map(|tab| tab.path).collect();
6262 let proxy = self.proxy.clone();
63 self.load(move || {
63 self.load_async(move || async move {
6464 for path in paths {
65 let section = library.open(&path, notify(proxy.clone()))?;
65 let section = library.open_async(&path, notify(proxy.clone())).await?;
6666 if section.pages()?.is_empty() {
67 section.close()?;
67 section.close_async().await?;
6868 continue;
6969 }
7070 let (session, page) = read_session(section, library, path, None)?;
......@@ -148,7 +148,7 @@ impl State {
148148 let path = library
149149 .first_section()
150150 .ok_or("The notebook has no sections")?;
151 let section = library.open(&path, notify(proxy))?;
151 let section = library.open_async(&path, notify(proxy)).await?;
152152 let (session, page) = read_session(section, library, path, None)?;
153153 Ok(Loaded::Section(Box::new(session), page))
154154 });
......@@ -201,7 +201,7 @@ async fn carry_out(
201201 let gone = match request {
202202 Request::Restore { space, path, copy } => {
203203 let page = replica.page(space)?;
204 let section = library.open(&path, || {})?;
204 let section = library.open_async(&path, || {}).await?;
205205 if copy {
206206 section.import_page_async(&page, author).await?;
207207 } else {
crates/snowbound/src/sidebar.rs+2-2
......@@ -1393,7 +1393,7 @@ impl crate::State {
13931393 .and_then(|path| self.last_pages.get(&crate::library::key(&location, path)))
13941394 .copied();
13951395 let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone()));
1396 self.load(move || {
1396 self.load_async(move || async move {
13971397 let library = match open {
13981398 Some(library) => library,
13991399 None => {
......@@ -1416,7 +1416,7 @@ impl crate::State {
14161416 }
14171417 return Err("This folder holds no notebook sections.".into());
14181418 };
1419 let section = library.open(&path, notify)?;
1419 let section = library.open_async(&path, notify).await?;
14201420 let (session, page) = crate::read_session(section, library, path, left)?;
14211421 Ok(crate::Loaded::Section(Box::new(session), page))
14221422 });
crates/snowbound/src/undo.rs+2-2
......@@ -562,7 +562,7 @@ impl Site {
562562 let level = listed[from].2;
563563 let mut ops = vec![Op::Section(SectionOp::Delete(vec![page]))];
564564 let (show, added) = self.after(&listed, &[page], None, &mut ops);
565 let section = self.library.open(&path, || {})?;
565 let section = self.library.open_async(&path, || {}).await?;
566566 section
567567 .replica()
568568 .apply_async(
......@@ -597,7 +597,7 @@ impl Site {
597597 let Some(path) = self.section_path(target) else {
598598 return Ok(None);
599599 };
600 let section = self.library.open(&path, || {})?;
600 let section = self.library.open_async(&path, || {}).await?;
601601 let sent = match section.pages()?.iter().any(|(listed, ..)| *listed == page) {
602602 true => Some(section.page(page)?),
603603 false => None,
tools/ci.py+3-2
......@@ -85,9 +85,10 @@ def lanes():
8585 targets = subprocess.run(['rustup', 'target', 'list', '--installed'], capture_output=True, text=True).stdout
8686 wasm = web_environment()
8787 result.append(dict(
88 name='web', minutes=20, packages=['snowbound'], paths=('crates/snowbound/web/', 'tools/release_web.py'),
88 name='web', minutes=20, packages=['snowbound'], paths=('crates/snowbound/web/', 'tools/release_web.py', 'tools/web/'),
8989 # Clippy builds it; the module itself is linked by release_web.py.
90 commands=[cargo('clippy', '-p', 'snowbound', '--target', 'wasm32-unknown-unknown', '--no-default-features', '--features', 'wgpu,live', '--', '-D', 'warnings')],
90 commands=[cargo('clippy', '-p', 'snowbound', '--target', 'wasm32-unknown-unknown', '--no-default-features', '--features', 'wgpu,live', '--', '-D', 'warnings'),
91 ['python3', 'tools/web/receipt_probe.py']],
9192 environment={**(wasm or {}), 'CARGO_TARGET_DIR': str(TARGET / 'wasm')},
9293 missing='needs the wasm32-unknown-unknown target' if 'wasm32-unknown-unknown' not in targets.split()
9394 else None if wasm else 'needs nix for a clang that builds for wasm32'))
tools/web/receipt_probe.mjs created+32
......@@ -0,0 +1,32 @@
1import assert from 'node:assert/strict';
2import { createRequire } from 'node:module';
3import { resolve } from 'node:path';
4import { webcrypto } from 'node:crypto';
5import { setTimeout } from 'node:timers';
6
7globalThis.crypto ??= webcrypto;
8let failure = 0;
9let phase = 'initializing wasm';
10globalThis.setTimeout = (callback, delay, ...args) => setTimeout(callback, delay, ...args).unref();
11const failures = [0, 0, 0];
12globalThis.snowboundReceiptPhase = value => { phase = value; };
13globalThis.snowboundReceiptFailure = value => { failure = value; };
14globalThis.snowboundReceiptTurn = () => new Promise(resolve => setTimeout(resolve, 10));
15globalThis.snowboundFlushStorage = () => {
16 failures[failure]++;
17 if (failure === 1) {
18 throw 'Injected persistent storage failure';
19 }
20 if (failure === 2) return Promise.reject('Injected asynchronous storage failure');
21 return Promise.resolve();
22};
23const wasm = createRequire(import.meta.url)(resolve(process.argv[2]));
24const timeout = setTimeout(() => { throw Error(`Receipt retry stalled while ${phase}: mode ${failure}, flushes ${failures}`); }, 10000);
25try {
26 await wasm.receipt_probe();
27 assert.ok(failures[1] >= 2, 'The active and dropped owner each retry synchronous storage failure');
28 assert.ok(failures[2] >= 1, 'An asynchronous storage rejection also keeps the receipt pending');
29 console.log(`Actual wasm receipts passed: ${failures[1]} throws, ${failures[2]} rejections, one publication, responsive reads, retained ownership`);
30} finally {
31 clearTimeout(timeout);
32}
tools/web/receipt_probe.py created+23
......@@ -0,0 +1,23 @@
1#!/usr/bin/env python3
2"""Build and run the actual wasm notebook receipt regression."""
3from pathlib import Path
4import sys
5
6sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
7import release_web
8
9
10def main():
11 environment = release_web.environment()
12 target = Path(environment['CARGO_TARGET_DIR'])
13 output = target / 'receipt-probe'
14 release_web.run(['cargo', 'build', '--locked', '-p', 'notebook', '--example', 'browser_receipts',
15 '--target', 'wasm32-unknown-unknown'], env=environment)
16 release_web.run([release_web.tool('wasm-bindgen'), '--target', 'nodejs', '--no-typescript',
17 '--out-dir', output,
18 target / 'wasm32-unknown-unknown/debug/examples/browser_receipts.wasm'])
19 release_web.run(['node', Path(__file__).with_suffix('.mjs'), output / 'browser_receipts.js'])
20
21
22if __name__ == '__main__':
23 main()