| author | |
| committer | |
| log | a217722937719101c12d3c2521a03b33abc32c8c |
| tree | 14ca4955e7ba5d4ca2882e0856993cd2d8d73faa |
| parent | 33d9c0e17eaf06bde98a3a918d895d5d2c829e23 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
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-sol15 files changed, 412 insertions(+), 54 deletions(-)
crates/notebook/Cargo.toml+4| ... | ... | @@ -84,3 +84,7 @@ required-features = ["smb"] |
| 84 | 84 | [[example]] |
| 85 | 85 | name = "smb_publish_probe" |
| 86 | 86 | required-features = ["smb"] |
| 87 | ||
| 88 | [[example]] | |
| 89 | name = "browser_receipts" | |
| 90 | crate-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"] | |
| 3 | mod server; | |
| 4 | ||
| 5 | #[cfg(target_arch = "wasm32")] | |
| 6 | mod 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 | 1 | //! The browser's file system: std's calls over files held in memory, which the host loads |
| 2 | 2 | //! before anything opens (`restore`) and writes out as they change, by the byte ranges that |
| 3 | 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. | |
| 5 | 5 | |
| 6 | 6 | use onestore::{CommitError, CommitIo, CommitState, Stamp, Transaction}; |
| 7 | 7 | use std::{ |
crates/notebook/src/fs/web/sqlite.rs+70-3| ... | ... | @@ -5,9 +5,24 @@ |
| 5 | 5 | use super::{FILES, Node, Shared, normal, with}; |
| 6 | 6 | use rsqlite_vfs::{ |
| 7 | 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 | }; |
| 10 | use std::{path::Path, rc::Rc, sync::Once, time::Duration}; | |
| 13 | use std::{ | |
| 14 | cell::RefCell, | |
| 15 | collections::BTreeMap, | |
| 16 | path::{Path, PathBuf}, | |
| 17 | rc::Rc, | |
| 18 | sync::Once, | |
| 19 | time::Duration, | |
| 20 | }; | |
| 21 | ||
| 22 | thread_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 | } | |
| 11 | 26 | |
| 12 | 27 | /// Registers the VFS as SQLite's default, once. |
| 13 | 28 | pub(crate) fn install() { |
| ... | ... | @@ -110,12 +125,64 @@ impl SQLiteIoMethods for Methods { |
| 110 | 125 | type Store = Store; |
| 111 | 126 | |
| 112 | 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 | } |
| 114 | 182 | |
| 115 | 183 | struct Vfs; |
| 116 | 184 | |
| 117 | 185 | impl OsCallback for Vfs { |
| 118 | /// Nothing else holds a lock to wait for. | |
| 119 | 186 | fn sleep(_: Duration) {} |
| 120 | 187 | |
| 121 | 188 | fn random(bytes: &mut [u8]) { |
crates/snowbound/src/history.rs+1-1| ... | ... | @@ -259,7 +259,7 @@ impl State { |
| 259 | 259 | continue; |
| 260 | 260 | } |
| 261 | 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 | 263 | clear(&section).await?; |
| 264 | 264 | Ok(section.close_async().await?) |
| 265 | 265 | } |
crates/snowbound/src/library.rs+54-7| ... | ... | @@ -831,19 +831,65 @@ impl Library { |
| 831 | 831 | ) -> Result<Section, Box<dyn Error>> { |
| 832 | 832 | // The background may hold the replica for a step, which takes a network round trip, |
| 833 | 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 | } |
| 836 | 884 | |
| 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. | |
| 839 | 886 | fn open_by( |
| 840 | 887 | &self, |
| 841 | 888 | path: &str, |
| 842 | notify: Box<dyn Fn() + Send>, | |
| 889 | notify: Arc<Mutex<Box<dyn Fn() + Send>>>, | |
| 843 | 890 | deadline: Instant, |
| 844 | 891 | ) -> Result<Section, Box<dyn Error>> { |
| 845 | 892 | crate::crash::conceal(path); |
| 846 | let notify = Arc::new(Mutex::new(notify)); | |
| 847 | 893 | let notifier = || { |
| 848 | 894 | let notify = Arc::clone(&notify); |
| 849 | 895 | move || { |
| ... | ... | @@ -917,6 +963,7 @@ impl Library { |
| 917 | 963 | if (error.busy() |
| 918 | 964 | || matches!(&error, notebook::Error::Io(error) |
| 919 | 965 | if error.kind() == io::ErrorKind::AlreadyExists)) |
| 966 | && !cfg!(target_arch = "wasm32") | |
| 920 | 967 | && Instant::now() < deadline => |
| 921 | 968 | { |
| 922 | 969 | std::thread::sleep(Duration::from_millis(50)); |
| ... | ... | @@ -938,7 +985,7 @@ impl Library { |
| 938 | 985 | path: &str, |
| 939 | 986 | notify: impl Fn() + Send + 'static, |
| 940 | 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 | 989 | let replica = Arc::clone(section.replica()); |
| 943 | 990 | self.keep(path, section); |
| 944 | 991 | Ok(replica) |
| ... | ... | @@ -1227,7 +1274,7 @@ impl Library { |
| 1227 | 1274 | #[cfg(target_arch = "wasm32")] |
| 1228 | 1275 | { |
| 1229 | 1276 | drop(operation); |
| 1230 | Err(io::ErrorKind::WouldBlock.into()) | |
| 1277 | Err(io::Error::from(io::ErrorKind::WouldBlock).into()) | |
| 1231 | 1278 | } |
| 1232 | 1279 | } |
| 1233 | 1280 |
crates/snowbound/src/main.rs+7-5| ... | ... | @@ -1168,7 +1168,9 @@ impl State { |
| 1168 | 1168 | } |
| 1169 | 1169 | Input::Section { file, title } => { |
| 1170 | 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 | 1174 | let space = section |
| 1173 | 1175 | .pages()? |
| 1174 | 1176 | .into_iter() |
| ... | ... | @@ -1212,7 +1214,7 @@ impl State { |
| 1212 | 1214 | }); |
| 1213 | 1215 | match shown { |
| 1214 | 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 | 1218 | let (opened, page) = |
| 1217 | 1219 | read_session(section, Arc::clone(library), path, left)?; |
| 1218 | 1220 | let (scene, editor) = PageScene::from_page(page, &mut engine)?; |
| ... | ... | @@ -3552,9 +3554,9 @@ impl State { |
| 3552 | 3554 | self.switching = Some((tab, Instant::now())); |
| 3553 | 3555 | let notify = notify(self.proxy.clone()); |
| 3554 | 3556 | let last = self.last_pages.get(&library.key(&path)).copied(); |
| 3555 | self.load(move || { | |
| 3557 | self.load_async(move || async move { | |
| 3556 | 3558 | let start = Instant::now(); |
| 3557 | let section = library.open(&path, notify)?; | |
| 3559 | let section = library.open_async(&path, notify).await?; | |
| 3558 | 3560 | lap("switch section open", start); |
| 3559 | 3561 | let (session, page) = read_session(section, library, path, last)?; |
| 3560 | 3562 | Ok(Loaded::Section(Box::new(session), page)) |
| ... | ... | @@ -3614,7 +3616,7 @@ impl State { |
| 3614 | 3616 | let (notify, author) = (notify(self.proxy.clone()), self.author.clone()); |
| 3615 | 3617 | crate::spawn_async(move || async move { |
| 3616 | 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 | 3620 | section.import_page_async(&page, &author).await?; |
| 3619 | 3621 | Ok(section.close_async().await?) |
| 3620 | 3622 | } |
crates/snowbound/src/manage.rs+4-4| ... | ... | @@ -471,7 +471,7 @@ impl State { |
| 471 | 471 | let page = self.dated_page(None, "")?; |
| 472 | 472 | let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone())); |
| 473 | 473 | let theme = self.notebook_theme.clone(); |
| 474 | self.load(move || { | |
| 474 | self.load_async(move || async move { | |
| 475 | 475 | let location = notebook::fs::absolute(&root)? |
| 476 | 476 | .to_string_lossy() |
| 477 | 477 | .into_owned(); |
| ... | ... | @@ -490,7 +490,7 @@ impl State { |
| 490 | 490 | let path = library |
| 491 | 491 | .first_section() |
| 492 | 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 | 494 | let (session, page) = read_session(section, library, path, None)?; |
| 495 | 495 | Ok(Loaded::Section(Box::new(session), page)) |
| 496 | 496 | }); |
| ... | ... | @@ -869,7 +869,7 @@ impl State { |
| 869 | 869 | if let Some(key) = key { |
| 870 | 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 | 873 | let (session, page) = read_session(section, library, path, None)?; |
| 874 | 874 | return Ok(Loaded::Section(Box::new(session), page)); |
| 875 | 875 | } |
| ... | ... | @@ -897,7 +897,7 @@ impl State { |
| 897 | 897 | if followed.as_deref() == Some(path.as_str()) { |
| 898 | 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 | 901 | let (session, page) = read_session(section, library, path, None)?; |
| 902 | 902 | Ok(if fresh { |
| 903 | 903 | Loaded::Created(Box::new(session), page) |
crates/snowbound/src/print.rs+23-22| ... | ... | @@ -328,8 +328,8 @@ impl State { |
| 328 | 328 | }; |
| 329 | 329 | let whole = matches!(setup.scope, Scope::Section | Scope::Notebook); |
| 330 | 330 | 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 { | |
| 333 | 333 | let mut open = Some(open); |
| 334 | 334 | let this = spaces |
| 335 | 335 | .into_iter() |
| ... | ... | @@ -343,27 +343,28 @@ impl State { |
| 343 | 343 | let sections = if others.is_empty() { |
| 344 | 344 | this.into_iter().collect() |
| 345 | 345 | } 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 | |
| 363 | 363 | }; |
| 364 | 364 | let mut engine = layouts.lock().map_err(|_| "Page layout failed")?; |
| 365 | 365 | Ok(canvas::print::pdf(sections, &mut engine, &layout)?) |
| 366 | })(); | |
| 366 | } | |
| 367 | .await; | |
| 367 | 368 | let done = made.and_then(|pdf| match &destination { |
| 368 | 369 | Some(path) => notebook::fs::write(path, pdf) |
| 369 | 370 | .map(|()| None) |
| ... | ... | @@ -386,14 +387,14 @@ impl State { |
| 386 | 387 | }))); |
| 387 | 388 | }; |
| 388 | 389 | if !export { |
| 389 | crate::spawn(move || print(None)); | |
| 390 | crate::spawn_async(move || print(None)); | |
| 390 | 391 | return Ok(()); |
| 391 | 392 | } |
| 392 | 393 | let reply = self.reply(|_, mut path: std::path::PathBuf| { |
| 393 | 394 | if path.extension().is_none() { |
| 394 | 395 | path.set_extension("pdf"); |
| 395 | 396 | } |
| 396 | crate::spawn(move || print(Some(path))); | |
| 397 | crate::spawn_async(move || print(Some(path))); | |
| 397 | 398 | Ok(()) |
| 398 | 399 | }); |
| 399 | 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 | 60 | } |
| 61 | 61 | let paths: Vec<String> = library.tabs(BIN).into_iter().map(|tab| tab.path).collect(); |
| 62 | 62 | let proxy = self.proxy.clone(); |
| 63 | self.load(move || { | |
| 63 | self.load_async(move || async move { | |
| 64 | 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 | 66 | if section.pages()?.is_empty() { |
| 67 | section.close()?; | |
| 67 | section.close_async().await?; | |
| 68 | 68 | continue; |
| 69 | 69 | } |
| 70 | 70 | let (session, page) = read_session(section, library, path, None)?; |
| ... | ... | @@ -148,7 +148,7 @@ impl State { |
| 148 | 148 | let path = library |
| 149 | 149 | .first_section() |
| 150 | 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 | 152 | let (session, page) = read_session(section, library, path, None)?; |
| 153 | 153 | Ok(Loaded::Section(Box::new(session), page)) |
| 154 | 154 | }); |
| ... | ... | @@ -201,7 +201,7 @@ async fn carry_out( |
| 201 | 201 | let gone = match request { |
| 202 | 202 | Request::Restore { space, path, copy } => { |
| 203 | 203 | let page = replica.page(space)?; |
| 204 | let section = library.open(&path, || {})?; | |
| 204 | let section = library.open_async(&path, || {}).await?; | |
| 205 | 205 | if copy { |
| 206 | 206 | section.import_page_async(&page, author).await?; |
| 207 | 207 | } else { |
crates/snowbound/src/sidebar.rs+2-2| ... | ... | @@ -1393,7 +1393,7 @@ impl crate::State { |
| 1393 | 1393 | .and_then(|path| self.last_pages.get(&crate::library::key(&location, path))) |
| 1394 | 1394 | .copied(); |
| 1395 | 1395 | let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone())); |
| 1396 | self.load(move || { | |
| 1396 | self.load_async(move || async move { | |
| 1397 | 1397 | let library = match open { |
| 1398 | 1398 | Some(library) => library, |
| 1399 | 1399 | None => { |
| ... | ... | @@ -1416,7 +1416,7 @@ impl crate::State { |
| 1416 | 1416 | } |
| 1417 | 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 | 1420 | let (session, page) = crate::read_session(section, library, path, left)?; |
| 1421 | 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 | 562 | let level = listed[from].2; |
| 563 | 563 | let mut ops = vec![Op::Section(SectionOp::Delete(vec![page]))]; |
| 564 | 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 | 566 | section |
| 567 | 567 | .replica() |
| 568 | 568 | .apply_async( |
| ... | ... | @@ -597,7 +597,7 @@ impl Site { |
| 597 | 597 | let Some(path) = self.section_path(target) else { |
| 598 | 598 | return Ok(None); |
| 599 | 599 | }; |
| 600 | let section = self.library.open(&path, || {})?; | |
| 600 | let section = self.library.open_async(&path, || {}).await?; | |
| 601 | 601 | let sent = match section.pages()?.iter().any(|(listed, ..)| *listed == page) { |
| 602 | 602 | true => Some(section.page(page)?), |
| 603 | 603 | false => None, |
tools/ci.py+3-2| ... | ... | @@ -85,9 +85,10 @@ def lanes(): |
| 85 | 85 | targets = subprocess.run(['rustup', 'target', 'list', '--installed'], capture_output=True, text=True).stdout |
| 86 | 86 | wasm = web_environment() |
| 87 | 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 | 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 | 92 | environment={**(wasm or {}), 'CARGO_TARGET_DIR': str(TARGET / 'wasm')}, |
| 92 | 93 | missing='needs the wasm32-unknown-unknown target' if 'wasm32-unknown-unknown' not in targets.split() |
| 93 | 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 @@ |
| 1 | import assert from 'node:assert/strict'; | |
| 2 | import { createRequire } from 'node:module'; | |
| 3 | import { resolve } from 'node:path'; | |
| 4 | import { webcrypto } from 'node:crypto'; | |
| 5 | import { setTimeout } from 'node:timers'; | |
| 6 | ||
| 7 | globalThis.crypto ??= webcrypto; | |
| 8 | let failure = 0; | |
| 9 | let phase = 'initializing wasm'; | |
| 10 | globalThis.setTimeout = (callback, delay, ...args) => setTimeout(callback, delay, ...args).unref(); | |
| 11 | const failures = [0, 0, 0]; | |
| 12 | globalThis.snowboundReceiptPhase = value => { phase = value; }; | |
| 13 | globalThis.snowboundReceiptFailure = value => { failure = value; }; | |
| 14 | globalThis.snowboundReceiptTurn = () => new Promise(resolve => setTimeout(resolve, 10)); | |
| 15 | globalThis.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 | }; | |
| 23 | const wasm = createRequire(import.meta.url)(resolve(process.argv[2])); | |
| 24 | const timeout = setTimeout(() => { throw Error(`Receipt retry stalled while ${phase}: mode ${failure}, flushes ${failures}`); }, 10000); | |
| 25 | try { | |
| 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.""" | |
| 3 | from pathlib import Path | |
| 4 | import sys | |
| 5 | ||
| 6 | sys.path.insert(0, str(Path(__file__).resolve().parents[1])) | |
| 7 | import release_web | |
| 8 | ||
| 9 | ||
| 10 | def 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 | ||
| 22 | if __name__ == '__main__': | |
| 23 | main() |