| 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,3 +84,7 @@ required-features = ["smb"] |
| 84 | [[example]] | 84 | [[example]] |
| 85 | name = "smb_publish_probe" | 85 | name = "smb_publish_probe" |
| 86 | required-features = ["smb"] | 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,7 +1,7 @@ |
| 1 | //! The browser's file system: std's calls over files held in memory, which the host loads | 1 | //! 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 that | 2 | //! 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. |
| 5 | 5 | ||
| 6 | use onestore::{CommitError, CommitIo, CommitState, Stamp, Transaction}; | 6 | use onestore::{CommitError, CommitIo, CommitState, Stamp, Transaction}; |
| 7 | use std::{ | 7 | use std::{ |
crates/notebook/src/fs/web/sqlite.rs+70-3| ... | @@ -5,9 +5,24 @@ | ... | @@ -5,9 +5,24 @@ |
| 5 | use super::{FILES, Node, Shared, normal, with}; | 5 | use super::{FILES, Node, Shared, normal, with}; |
| 6 | use rsqlite_vfs::{ | 6 | use 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 | }; |
| 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 | /// Registers the VFS as SQLite's default, once. | 27 | /// Registers the VFS as SQLite's default, once. |
| 13 | pub(crate) fn install() { | 28 | pub(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; |
| 111 | 126 | ||
| 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 | } |
| 114 | 182 | ||
| 115 | struct Vfs; | 183 | struct Vfs; |
| 116 | 184 | ||
| 117 | impl OsCallback for Vfs { | 185 | impl OsCallback for Vfs { |
| 118 | /// Nothing else holds a lock to wait for. | ||
| 119 | fn sleep(_: Duration) {} | 186 | fn sleep(_: Duration) {} |
| 120 | 187 | ||
| 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 | } |
| 836 | 884 | ||
| 837 | /// `open`, waiting until `deadline` for whatever holds the section's replica. `notify` is | 885 | /// 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 | } |
| 1233 | 1280 |
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 = section | 1174 | 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 = library | 490 | 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 = spaces | 334 | 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 | others | 346 | 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.0 | 349 | && 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 = section | 355 | 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 = library | 148 | 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 | section | 566 | 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).stdout | 85 | 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 @@ | ||
| 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() | ||