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"]...@@ -84,3 +84,7 @@ required-features = ["smb"]
84[[example]]84[[example]]
85name = "smb_publish_probe"85name = "smb_publish_probe"
86required-features = ["smb"]86required-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 @@...@@ -1,7 +1,7 @@
1//! The browser's file system: std's calls over files held in memory, which the host loads1//! The browser's file system: std's calls over files held in memory, which the host loads
2//! before anything opens (`restore`) and writes out as they change, by the byte ranges that2//! before anything opens (`restore`) and writes out as they change, by the byte ranges that
3//! changed (`changes`). SQLite reaches the same files through `sqlite`, its default VFS here,3//! 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
6use onestore::{CommitError, CommitIo, CommitState, Stamp, Transaction};6use onestore::{CommitError, CommitIo, CommitState, Stamp, Transaction};
7use std::{7use std::{
crates/notebook/src/fs/web/sqlite.rs+70-3
...@@ -5,9 +5,24 @@...@@ -5,9 +5,24 @@
5use super::{FILES, Node, Shared, normal, with};5use super::{FILES, Node, Shared, normal, with};
6use rsqlite_vfs::{6use rsqlite_vfs::{
7 OsCallback, SQLiteIoMethods, SQLiteVfs, SQLiteVfsFile, VfsError, VfsFile, VfsResult, VfsStore,7 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 },
9};12};
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
12/// Registers the VFS as SQLite's default, once.27/// Registers the VFS as SQLite's default, once.
13pub(crate) fn install() {28pub(crate) fn install() {
...@@ -110,12 +125,64 @@ impl SQLiteIoMethods for Methods {...@@ -110,12 +125,64 @@ impl SQLiteIoMethods for Methods {
110 type Store = Store;125 type Store = Store;
111126
112 const VERSION: i32 = 1;127 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 }
113}181}
114182
115struct Vfs;183struct Vfs;
116184
117impl OsCallback for Vfs {185impl OsCallback for Vfs {
118 /// Nothing else holds a lock to wait for.
119 fn sleep(_: Duration) {}186 fn sleep(_: Duration) {}
120187
121 fn random(bytes: &mut [u8]) {188 fn random(bytes: &mut [u8]) {
crates/snowbound/src/history.rs+1-1
...@@ -259,7 +259,7 @@ impl State {...@@ -259,7 +259,7 @@ impl State {
259 continue;259 continue;
260 }260 }
261 let cleared: Result<(), Box<dyn Error>> = async {261 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?;
263 clear(&section).await?;263 clear(&section).await?;
264 Ok(section.close_async().await?)264 Ok(section.close_async().await?)
265 }265 }
crates/snowbound/src/library.rs+54-7
...@@ -831,19 +831,65 @@ impl Library {...@@ -831,19 +831,65 @@ impl Library {
831 ) -> Result<Section, Box<dyn Error>> {831 ) -> Result<Section, Box<dyn Error>> {
832 // The background may hold the replica for a step, which takes a network round trip,832 // The background may hold the replica for a step, which takes a network round trip,
833 // or be making it as an offline copy.833 // 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 }
835 }883 }
836884
837 /// `open`, waiting until `deadline` for whatever holds the section's replica. `notify` is885 /// Opens a section, waiting natively until `deadline` for its current owner.
838 /// boxed to compile this once rather than once per caller's closure.
839 fn open_by(886 fn open_by(
840 &self,887 &self,
841 path: &str,888 path: &str,
842 notify: Box<dyn Fn() + Send>,889 notify: Arc<Mutex<Box<dyn Fn() + Send>>>,
843 deadline: Instant,890 deadline: Instant,
844 ) -> Result<Section, Box<dyn Error>> {891 ) -> Result<Section, Box<dyn Error>> {
845 crate::crash::conceal(path);892 crate::crash::conceal(path);
846 let notify = Arc::new(Mutex::new(notify));
847 let notifier = || {893 let notifier = || {
848 let notify = Arc::clone(&notify);894 let notify = Arc::clone(&notify);
849 move || {895 move || {
...@@ -917,6 +963,7 @@ impl Library {...@@ -917,6 +963,7 @@ impl Library {
917 if (error.busy()963 if (error.busy()
918 || matches!(&error, notebook::Error::Io(error)964 || matches!(&error, notebook::Error::Io(error)
919 if error.kind() == io::ErrorKind::AlreadyExists))965 if error.kind() == io::ErrorKind::AlreadyExists))
966 && !cfg!(target_arch = "wasm32")
920 && Instant::now() < deadline =>967 && Instant::now() < deadline =>
921 {968 {
922 std::thread::sleep(Duration::from_millis(50));969 std::thread::sleep(Duration::from_millis(50));
...@@ -938,7 +985,7 @@ impl Library {...@@ -938,7 +985,7 @@ impl Library {
938 path: &str,985 path: &str,
939 notify: impl Fn() + Send + 'static,986 notify: impl Fn() + Send + 'static,
940 ) -> Result<Arc<notebook::Replica>, Box<dyn Error>> {987 ) -> 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())?;
942 let replica = Arc::clone(section.replica());989 let replica = Arc::clone(section.replica());
943 self.keep(path, section);990 self.keep(path, section);
944 Ok(replica)991 Ok(replica)
...@@ -1227,7 +1274,7 @@ impl Library {...@@ -1227,7 +1274,7 @@ impl Library {
1227 #[cfg(target_arch = "wasm32")]1274 #[cfg(target_arch = "wasm32")]
1228 {1275 {
1229 drop(operation);1276 drop(operation);
1230 Err(io::ErrorKind::WouldBlock.into())1277 Err(io::Error::from(io::ErrorKind::WouldBlock).into())
1231 }1278 }
1232 }1279 }
12331280
crates/snowbound/src/main.rs+7-5
...@@ -1168,7 +1168,9 @@ impl State {...@@ -1168,7 +1168,9 @@ impl State {
1168 }1168 }
1169 Input::Section { file, title } => {1169 Input::Section { file, title } => {
1170 let library = Arc::new(Library::section(&file, &cache));1170 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?;
1172 let space = section1174 let space = section
1173 .pages()?1175 .pages()?
1174 .into_iter()1176 .into_iter()
...@@ -1212,7 +1214,7 @@ impl State {...@@ -1212,7 +1214,7 @@ impl State {
1212 });1214 });
1213 match shown {1215 match shown {
1214 Some((library, path, left)) if !library.locked(&path) => {1216 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?;
1216 let (opened, page) =1218 let (opened, page) =
1217 read_session(section, Arc::clone(library), path, left)?;1219 read_session(section, Arc::clone(library), path, left)?;
1218 let (scene, editor) = PageScene::from_page(page, &mut engine)?;1220 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
...@@ -3552,9 +3554,9 @@ impl State {...@@ -3552,9 +3554,9 @@ impl State {
3552 self.switching = Some((tab, Instant::now()));3554 self.switching = Some((tab, Instant::now()));
3553 let notify = notify(self.proxy.clone());3555 let notify = notify(self.proxy.clone());
3554 let last = self.last_pages.get(&library.key(&path)).copied();3556 let last = self.last_pages.get(&library.key(&path)).copied();
3555 self.load(move || {3557 self.load_async(move || async move {
3556 let start = Instant::now();3558 let start = Instant::now();
3557 let section = library.open(&path, notify)?;3559 let section = library.open_async(&path, notify).await?;
3558 lap("switch section open", start);3560 lap("switch section open", start);
3559 let (session, page) = read_session(section, library, path, last)?;3561 let (session, page) = read_session(section, library, path, last)?;
3560 Ok(Loaded::Section(Box::new(session), page))3562 Ok(Loaded::Section(Box::new(session), page))
...@@ -3614,7 +3616,7 @@ impl State {...@@ -3614,7 +3616,7 @@ impl State {
3614 let (notify, author) = (notify(self.proxy.clone()), self.author.clone());3616 let (notify, author) = (notify(self.proxy.clone()), self.author.clone());
3615 crate::spawn_async(move || async move {3617 crate::spawn_async(move || async move {
3616 let copied: Result<(), Box<dyn Error>> = async {3618 let copied: Result<(), Box<dyn Error>> = async {
3617 let section = library.open(&path, notify)?;3619 let section = library.open_async(&path, notify).await?;
3618 section.import_page_async(&page, &author).await?;3620 section.import_page_async(&page, &author).await?;
3619 Ok(section.close_async().await?)3621 Ok(section.close_async().await?)
3620 }3622 }
crates/snowbound/src/manage.rs+4-4
...@@ -471,7 +471,7 @@ impl State {...@@ -471,7 +471,7 @@ impl State {
471 let page = self.dated_page(None, "")?;471 let page = self.dated_page(None, "")?;
472 let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone()));472 let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone()));
473 let theme = self.notebook_theme.clone();473 let theme = self.notebook_theme.clone();
474 self.load(move || {474 self.load_async(move || async move {
475 let location = notebook::fs::absolute(&root)?475 let location = notebook::fs::absolute(&root)?
476 .to_string_lossy()476 .to_string_lossy()
477 .into_owned();477 .into_owned();
...@@ -490,7 +490,7 @@ impl State {...@@ -490,7 +490,7 @@ impl State {
490 let path = library490 let path = library
491 .first_section()491 .first_section()
492 .ok_or("The new notebook has no section")?;492 .ok_or("The new notebook has no section")?;
493 let section = library.open(&path, notify)?;493 let section = library.open_async(&path, notify).await?;
494 let (session, page) = read_session(section, library, path, None)?;494 let (session, page) = read_session(section, library, path, None)?;
495 Ok(Loaded::Section(Box::new(session), page))495 Ok(Loaded::Section(Box::new(session), page))
496 });496 });
...@@ -869,7 +869,7 @@ impl State {...@@ -869,7 +869,7 @@ impl State {
869 if let Some(key) = key {869 if let Some(key) = key {
870 library.keep_key(&path, key);870 library.keep_key(&path, key);
871 }871 }
872 let section = library.open(&path, notify)?;872 let section = library.open_async(&path, notify).await?;
873 let (session, page) = read_session(section, library, path, None)?;873 let (session, page) = read_session(section, library, path, None)?;
874 return Ok(Loaded::Section(Box::new(session), page));874 return Ok(Loaded::Section(Box::new(session), page));
875 }875 }
...@@ -897,7 +897,7 @@ impl State {...@@ -897,7 +897,7 @@ impl State {
897 if followed.as_deref() == Some(path.as_str()) {897 if followed.as_deref() == Some(path.as_str()) {
898 return Ok(Loaded::Library(library, Some(path)));898 return Ok(Loaded::Library(library, Some(path)));
899 }899 }
900 let section = library.open(&path, notify)?;900 let section = library.open_async(&path, notify).await?;
901 let (session, page) = read_session(section, library, path, None)?;901 let (session, page) = read_session(section, library, path, None)?;
902 Ok(if fresh {902 Ok(if fresh {
903 Loaded::Created(Box::new(session), page)903 Loaded::Created(Box::new(session), page)
crates/snowbound/src/print.rs+23-22
...@@ -328,8 +328,8 @@ impl State {...@@ -328,8 +328,8 @@ impl State {
328 };328 };
329 let whole = matches!(setup.scope, Scope::Section | Scope::Notebook);329 let whole = matches!(setup.scope, Scope::Section | Scope::Notebook);
330 let name = format!("{}.pdf", file_name(&title));330 let name = format!("{}.pdf", file_name(&title));
331 let print = move |destination: Option<std::path::PathBuf>| {331 let print = move |destination: Option<std::path::PathBuf>| async move {
332 let made = (|| -> Result<Vec<u8>, Box<dyn Error>> {332 let made: Result<Vec<u8>, Box<dyn Error>> = async {
333 let mut open = Some(open);333 let mut open = Some(open);
334 let this = spaces334 let this = spaces
335 .into_iter()335 .into_iter()
...@@ -343,27 +343,28 @@ impl State {...@@ -343,27 +343,28 @@ impl State {
343 let sections = if others.is_empty() {343 let sections = if others.is_empty() {
344 this.into_iter().collect()344 this.into_iter().collect()
345 } else {345 } else {
346 others346 let mut sections = Vec::new();
347 .into_iter()347 for (path, name) in others {
348 .map(|(path, name)| {348 if path == open_tab.0
349 if path == open_tab.0349 && let Some(open) = this.take()
350 && let Some(open) = this.take()350 {
351 {351 sections.push(open);
352 return Ok(open);352 continue;
353 }353 }
354 let section = library.open(&path, || {})?;354 let section = library.open_async(&path, || {}).await?;
355 let pages = section355 let pages = section
356 .pages()?356 .pages()?
357 .into_iter()357 .into_iter()
358 .map(|(space, ..)| section.page(space))358 .map(|(space, ..)| section.page(space))
359 .collect::<Result<Vec<_>, _>>()?;359 .collect::<Result<Vec<_>, _>>()?;
360 Ok((name, printed(pages)))360 sections.push((name, printed(pages)));
361 })361 }
362 .collect::<Result<Vec<_>, Box<dyn Error>>>()?362 sections
363 };363 };
364 let mut engine = layouts.lock().map_err(|_| "Page layout failed")?;364 let mut engine = layouts.lock().map_err(|_| "Page layout failed")?;
365 Ok(canvas::print::pdf(sections, &mut engine, &layout)?)365 Ok(canvas::print::pdf(sections, &mut engine, &layout)?)
366 })();366 }
367 .await;
367 let done = made.and_then(|pdf| match &destination {368 let done = made.and_then(|pdf| match &destination {
368 Some(path) => notebook::fs::write(path, pdf)369 Some(path) => notebook::fs::write(path, pdf)
369 .map(|()| None)370 .map(|()| None)
...@@ -386,14 +387,14 @@ impl State {...@@ -386,14 +387,14 @@ impl State {
386 })));387 })));
387 };388 };
388 if !export {389 if !export {
389 crate::spawn(move || print(None));390 crate::spawn_async(move || print(None));
390 return Ok(());391 return Ok(());
391 }392 }
392 let reply = self.reply(|_, mut path: std::path::PathBuf| {393 let reply = self.reply(|_, mut path: std::path::PathBuf| {
393 if path.extension().is_none() {394 if path.extension().is_none() {
394 path.set_extension("pdf");395 path.set_extension("pdf");
395 }396 }
396 crate::spawn(move || print(Some(path)));397 crate::spawn_async(move || print(Some(path)));
397 Ok(())398 Ok(())
398 });399 });
399 platform::pick_new("Export as PDF", &name, "Export", None, reply);400 platform::pick_new("Export as PDF", &name, "Export", None, reply);
crates/snowbound/src/recycle.rs+5-5
...@@ -60,11 +60,11 @@ impl State {...@@ -60,11 +60,11 @@ impl State {
60 }60 }
61 let paths: Vec<String> = library.tabs(BIN).into_iter().map(|tab| tab.path).collect();61 let paths: Vec<String> = library.tabs(BIN).into_iter().map(|tab| tab.path).collect();
62 let proxy = self.proxy.clone();62 let proxy = self.proxy.clone();
63 self.load(move || {63 self.load_async(move || async move {
64 for path in paths {64 for path in paths {
65 let section = library.open(&path, notify(proxy.clone()))?;65 let section = library.open_async(&path, notify(proxy.clone())).await?;
66 if section.pages()?.is_empty() {66 if section.pages()?.is_empty() {
67 section.close()?;67 section.close_async().await?;
68 continue;68 continue;
69 }69 }
70 let (session, page) = read_session(section, library, path, None)?;70 let (session, page) = read_session(section, library, path, None)?;
...@@ -148,7 +148,7 @@ impl State {...@@ -148,7 +148,7 @@ impl State {
148 let path = library148 let path = library
149 .first_section()149 .first_section()
150 .ok_or("The notebook has no sections")?;150 .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?;
152 let (session, page) = read_session(section, library, path, None)?;152 let (session, page) = read_session(section, library, path, None)?;
153 Ok(Loaded::Section(Box::new(session), page))153 Ok(Loaded::Section(Box::new(session), page))
154 });154 });
...@@ -201,7 +201,7 @@ async fn carry_out(...@@ -201,7 +201,7 @@ async fn carry_out(
201 let gone = match request {201 let gone = match request {
202 Request::Restore { space, path, copy } => {202 Request::Restore { space, path, copy } => {
203 let page = replica.page(space)?;203 let page = replica.page(space)?;
204 let section = library.open(&path, || {})?;204 let section = library.open_async(&path, || {}).await?;
205 if copy {205 if copy {
206 section.import_page_async(&page, author).await?;206 section.import_page_async(&page, author).await?;
207 } else {207 } else {
crates/snowbound/src/sidebar.rs+2-2
...@@ -1393,7 +1393,7 @@ impl crate::State {...@@ -1393,7 +1393,7 @@ impl crate::State {
1393 .and_then(|path| self.last_pages.get(&crate::library::key(&location, path)))1393 .and_then(|path| self.last_pages.get(&crate::library::key(&location, path)))
1394 .copied();1394 .copied();
1395 let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone()));1395 let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone()));
1396 self.load(move || {1396 self.load_async(move || async move {
1397 let library = match open {1397 let library = match open {
1398 Some(library) => library,1398 Some(library) => library,
1399 None => {1399 None => {
...@@ -1416,7 +1416,7 @@ impl crate::State {...@@ -1416,7 +1416,7 @@ impl crate::State {
1416 }1416 }
1417 return Err("This folder holds no notebook sections.".into());1417 return Err("This folder holds no notebook sections.".into());
1418 };1418 };
1419 let section = library.open(&path, notify)?;1419 let section = library.open_async(&path, notify).await?;
1420 let (session, page) = crate::read_session(section, library, path, left)?;1420 let (session, page) = crate::read_session(section, library, path, left)?;
1421 Ok(crate::Loaded::Section(Box::new(session), page))1421 Ok(crate::Loaded::Section(Box::new(session), page))
1422 });1422 });
crates/snowbound/src/undo.rs+2-2
...@@ -562,7 +562,7 @@ impl Site {...@@ -562,7 +562,7 @@ impl Site {
562 let level = listed[from].2;562 let level = listed[from].2;
563 let mut ops = vec![Op::Section(SectionOp::Delete(vec![page]))];563 let mut ops = vec![Op::Section(SectionOp::Delete(vec![page]))];
564 let (show, added) = self.after(&listed, &[page], None, &mut ops);564 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?;
566 section566 section
567 .replica()567 .replica()
568 .apply_async(568 .apply_async(
...@@ -597,7 +597,7 @@ impl Site {...@@ -597,7 +597,7 @@ impl Site {
597 let Some(path) = self.section_path(target) else {597 let Some(path) = self.section_path(target) else {
598 return Ok(None);598 return Ok(None);
599 };599 };
600 let section = self.library.open(&path, || {})?;600 let section = self.library.open_async(&path, || {}).await?;
601 let sent = match section.pages()?.iter().any(|(listed, ..)| *listed == page) {601 let sent = match section.pages()?.iter().any(|(listed, ..)| *listed == page) {
602 true => Some(section.page(page)?),602 true => Some(section.page(page)?),
603 false => None,603 false => None,
tools/ci.py+3-2
...@@ -85,9 +85,10 @@ def lanes():...@@ -85,9 +85,10 @@ def lanes():
85 targets = subprocess.run(['rustup', 'target', 'list', '--installed'], capture_output=True, text=True).stdout85 targets = subprocess.run(['rustup', 'target', 'list', '--installed'], capture_output=True, text=True).stdout
86 wasm = web_environment()86 wasm = web_environment()
87 result.append(dict(87 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/'),
89 # Clippy builds it; the module itself is linked by release_web.py.89 # 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']],
91 environment={**(wasm or {}), 'CARGO_TARGET_DIR': str(TARGET / 'wasm')},92 environment={**(wasm or {}), 'CARGO_TARGET_DIR': str(TARGET / 'wasm')},
92 missing='needs the wasm32-unknown-unknown target' if 'wasm32-unknown-unknown' not in targets.split()93 missing='needs the wasm32-unknown-unknown target' if 'wasm32-unknown-unknown' not in targets.split()
93 else None if wasm else 'needs nix for a clang that builds for wasm32'))94 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()