authorclover caruso <git@paperclover.net> 2026-10-06 00:26:55-07:00
committerclover caruso <git@paperclover.net> 2026-10-10 22:56:05-07:00
logfe5ea9c240ad4fc12520c83370d4938f97ff87c5
tree62f15ebb1829416bdba1e85d4f85cf97952687a4
parent9a109461103375723db50473c2562c7195eea36c
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(notebook): durably acknowledge offline browser edits

Await ordered OPFS receipts without suspending logical section reads. Migrate browser edit, history, recycle and password callers to async continuations while keeping native synchronous completion. Retain cache ownership through pending receipts and release it after SQLite closes. Await destination durability before destructive page operations, including recycle-bin writes. Keep shown and kept models available on storage failure. Native notebook/snowbound all-feature cargo check passed; Node storage bridge regression passed (1 test). Exact native tests and wasm gate are still running or pending; root owns real renderer-crash and caller-path verification. Depends on #97 recoverable storage retries. Fixes #98 Assisted-by: gpt-6.1-sol

17 files changed, 936 insertions(+), 283 deletions(-)

crates/notebook/src/background.rs+1-1
......@@ -433,7 +433,7 @@ impl Background {
433433 }
434434 }
435435 let (shared, path) = (Arc::downgrade(&self.0), path.to_owned());
436 if let Ok(mut released) = replica.released.0.lock() {
436 if let Ok(mut released) = replica.section.released.0.lock() {
437437 *released = Some(Box::new(move || {
438438 if let Some(shared) = shared.upgrade() {
439439 shared.released(&path);
crates/notebook/src/lib.rs+40-10
......@@ -112,8 +112,6 @@ pub struct Replica {
112112 synchronization: Mutex<()>,
113113 /// The section's root object space, which names the document.
114114 root: ExGuid,
115 /// Last, so that it runs once the fields above have closed the cache.
116 released: Released,
117115}
118116
119117/// Runs once the replica is released and its cache closed, as `Background::hold` asks.
......@@ -254,7 +252,6 @@ impl Replica {
254252 section,
255253 synchronization: Mutex::new(()),
256254 root,
257 released: Released::default(),
258255 })
259256 }
260257
......@@ -281,18 +278,39 @@ impl Replica {
281278 .map_err(|_| io::Error::other("The section thread stopped"))?
282279 }
283280
281 async fn ask_async<T: Send + 'static>(
282 &self,
283 request: impl FnOnce(working::Reply<T>) -> working::Request + Send,
284 ) -> Result<T> {
285 let (sender, receiver) = task::response();
286 self.send(request(Box::new(move |result| {
287 let _ = sender.send(result);
288 })))?;
289 #[cfg(target_arch = "wasm32")]
290 let answer = task::receive(&receiver).await;
291 #[cfg(not(target_arch = "wasm32"))]
292 let answer = receiver.recv();
293 answer.map_err(|_| io::Error::other("The section thread stopped"))?
294 }
295
284296 /// Applies an edit and queues it durably for publication, returning its id once
285297 /// written. A refused edit returns `Rejected` and leaves every page as it was.
298 /// Browser callers use `apply_async`; this returns `WouldBlock` without submitting.
286299 pub fn apply(&self, author: &str, edit: Edit) -> Result<u64> {
287 self.ask(|reply| working::Request::Apply {
300 task::blocking(self.apply_async(author, edit))?
301 }
302
303 /// Applies an edit, awaiting its durable local queue receipt without blocking browser reads.
304 pub async fn apply_async(&self, author: &str, edit: Edit) -> Result<u64> {
305 self.ask_async(|reply| working::Request::Apply {
288306 author: author.to_owned(),
289307 edit,
290308 reply,
291309 })
310 .await
292311 }
293312
294 /// `apply` without waiting: `reply` runs on the section thread once the edit is
295 /// durable or refused.
313 /// `apply` without waiting: `reply` runs once the edit is durable or refused.
296314 pub(crate) fn submit(
297315 &self,
298316 author: &str,
......@@ -338,14 +356,26 @@ impl Replica {
338356 /// The section image the queued edits leave, the unsealed ones sealed as one more
339357 /// revision whose identities differ per call: O(section).
340358 pub fn snapshot(&self) -> Result<Vec<u8>> {
341 self.written()?;
359 self.ask(|reply| working::Request::Flush {
360 reply,
361 durable: false,
362 })?;
342363 working::image(&*self.lock()?, self.section.key.as_ref())
343364 }
344365
345 /// Waits until the edits applied before it are written to the queue, as reads answer
346 /// before an open burst of edits is.
366 /// Waits until the edits applied before it are durable in the local queue.
367 /// Browser callers use `written_async`; this returns `WouldBlock` without submitting.
347368 pub fn written(&self) -> Result<()> {
348 self.ask(|reply| working::Request::Flush { reply })
369 task::blocking(self.written_async())?
370 }
371
372 /// Waits for durable local storage of the edits submitted before this request.
373 pub async fn written_async(&self) -> Result<()> {
374 self.ask_async(|reply| working::Request::Flush {
375 reply,
376 durable: true,
377 })
378 .await
349379 }
350380
351381 /// The section file's identity, which internal links name as `section-id`.
crates/notebook/src/session.rs+122-43
......@@ -1347,6 +1347,10 @@ impl Notebook {
13471347 /// queued again under the key: each page they changed comes back as a copy, as a page
13481348 /// another client removed does, the section's pages having taken new identities.
13491349 pub fn unlock(&self, path: &str, password: &str) -> Result<protected::Key> {
1350 crate::task::blocking(self.unlock_async(path, password))?
1351 }
1352
1353 pub async fn unlock_async(&self, path: &str, password: &str) -> Result<protected::Key> {
13501354 let section = self.section_path(path)?;
13511355 let image = self.storage.read(&section.path)?;
13521356 let key = protected::Key::open(&image, password)?;
......@@ -1365,7 +1369,7 @@ impl Notebook {
13651369 continue;
13661370 };
13671371 for (author, edit) in old.copies()? {
1368 replica.apply(&author, edit)?;
1372 replica.apply_async(&author, edit).await?;
13691373 }
13701374 drop(old);
13711375 remove_replica(&path)?;
......@@ -2242,31 +2246,60 @@ impl Section {
22422246 /// fresh identities, queued like the user's own edits. Returns the new page's space.
22432247 /// Content outside the model refuses to copy.
22442248 pub fn import_page(&self, page: &Page, author: &str) -> Result<ExGuid> {
2245 let creation = PageCreation::new(None, Some(&page.title), author)?;
2246 let space = creation.space();
2247 self.replica.apply(
2248 author,
2249 Edit {
2250 at: crate::now(),
2251 ops: vec![Op::Section(SectionOp::Import {
2252 creation,
2253 page: page.copy()?,
2254 })],
2255 },
2256 )?;
2257 Ok(space)
2249 crate::task::blocking(self.import_page_async(page, author))?
2250 }
2251
2252 pub fn import_page_async(
2253 &self,
2254 page: &Page,
2255 author: &str,
2256 ) -> impl Future<Output = Result<ExGuid>> + Send + 'static + use<> {
2257 let replica = Arc::clone(&self.replica);
2258 let author = author.to_owned();
2259 let creation = PageCreation::new(None, Some(&page.title), &author);
2260 let page = page.copy();
2261 async move {
2262 let creation = creation?;
2263 let space = creation.space();
2264 replica
2265 .apply_async(
2266 &author,
2267 Edit {
2268 at: crate::now(),
2269 ops: vec![Op::Section(SectionOp::Import {
2270 creation,
2271 page: page?,
2272 })],
2273 },
2274 )
2275 .await?;
2276 Ok(space)
2277 }
22582278 }
22592279
22602280 /// Removes pages or conflict pages permanently, queued like the user's own edits (a
22612281 /// move across sections is `import_page` there, then this here).
22622282 pub fn delete_pages(&self, pages: &[ExGuid]) -> Result<u64> {
2263 self.replica.apply(
2264 "",
2265 Edit {
2266 at: crate::now(),
2267 ops: vec![Op::Section(SectionOp::Delete(pages.to_vec()))],
2268 },
2269 )
2283 crate::task::blocking(self.delete_pages_async(pages))?
2284 }
2285
2286 pub fn delete_pages_async(
2287 &self,
2288 pages: &[ExGuid],
2289 ) -> impl Future<Output = Result<u64>> + Send + 'static + use<> {
2290 let replica = Arc::clone(&self.replica);
2291 let pages = pages.to_vec();
2292 async move {
2293 replica
2294 .apply_async(
2295 "",
2296 Edit {
2297 at: crate::now(),
2298 ops: vec![Op::Section(SectionOp::Delete(pages))],
2299 },
2300 )
2301 .await
2302 }
22702303 }
22712304
22722305 pub fn status(&self, id: u64) -> Result<Option<EditStatus>> {
......@@ -2302,32 +2335,61 @@ impl Section {
23022335 /// Makes a page's version its current state, the page as it stood becoming the newest
23032336 /// version, queued like the user's own edits.
23042337 pub fn restore_version(&self, space: ExGuid, version: ExGuid, author: &str) -> Result<u64> {
2305 self.replica.apply(
2306 author,
2307 Edit {
2308 at: crate::now(),
2309 ops: vec![Op::Section(SectionOp::restore(space, version)?)],
2310 },
2311 )
2338 crate::task::blocking(self.restore_version_async(space, version, author))?
2339 }
2340
2341 pub fn restore_version_async(
2342 &self,
2343 space: ExGuid,
2344 version: ExGuid,
2345 author: &str,
2346 ) -> impl Future<Output = Result<u64>> + Send + 'static + use<> {
2347 let replica = Arc::clone(&self.replica);
2348 let author = author.to_owned();
2349 let op = SectionOp::restore(space, version);
2350 async move {
2351 replica
2352 .apply_async(
2353 &author,
2354 Edit {
2355 at: crate::now(),
2356 ops: vec![Op::Section(op?)],
2357 },
2358 )
2359 .await
2360 }
23122361 }
23132362
23142363 /// Deletes versions of pages, queued like the user's own edits.
23152364 pub fn delete_versions(&self, versions: &[(ExGuid, Vec<ExGuid>)]) -> Result<u64> {
2316 self.replica.apply(
2317 "",
2318 Edit {
2319 at: crate::now(),
2320 ops: versions
2321 .iter()
2322 .map(|(page, versions)| {
2323 Op::Section(SectionOp::DeleteVersions {
2324 page: *page,
2325 versions: versions.clone(),
2326 })
2327 })
2328 .collect(),
2329 },
2330 )
2365 crate::task::blocking(self.delete_versions_async(versions))?
2366 }
2367
2368 pub fn delete_versions_async(
2369 &self,
2370 versions: &[(ExGuid, Vec<ExGuid>)],
2371 ) -> impl Future<Output = Result<u64>> + Send + 'static + use<> {
2372 let replica = Arc::clone(&self.replica);
2373 let ops = versions
2374 .iter()
2375 .map(|(page, versions)| {
2376 Op::Section(SectionOp::DeleteVersions {
2377 page: *page,
2378 versions: versions.clone(),
2379 })
2380 })
2381 .collect();
2382 async move {
2383 replica
2384 .apply_async(
2385 "",
2386 Edit {
2387 at: crate::now(),
2388 ops,
2389 },
2390 )
2391 .await
2392 }
23312393 }
23322394
23332395 /// Retires an uncertain attempt after review, exporting the queue to `archive` first:
......@@ -2409,6 +2471,23 @@ impl Section {
24092471 None => Ok(()),
24102472 }
24112473 }
2474
2475 /// Stops synchronization without consuming the section's recoverable model.
2476 pub fn stop_async(&mut self) -> impl Future<Output = Result<()>> + Send + 'static + use<> {
2477 let worker = self.worker.take();
2478 async move {
2479 match worker {
2480 Some(worker) => worker.stop_async().await,
2481 None => Ok(()),
2482 }
2483 }
2484 }
2485
2486 /// Makes the submitted edits locally durable and awaits synchronization before closing.
2487 pub async fn close_async(mut self) -> Result<()> {
2488 self.replica.written_async().await?;
2489 self.stop_async().await
2490 }
24122491}
24132492
24142493/// The section file itself as the publication target, under OneNote-compatible exclusion.
crates/notebook/src/task.rs+45-18
......@@ -10,14 +10,25 @@ pub(crate) use std::{
1010 thread::JoinHandle,
1111};
1212
13#[cfg(all(feature = "live", not(target_arch = "wasm32")))]
13#[cfg(not(target_arch = "wasm32"))]
1414pub(crate) type Answer<T> = std::sync::mpsc::Sender<T>;
1515
16#[cfg(all(feature = "live", not(target_arch = "wasm32")))]
16#[cfg(not(target_arch = "wasm32"))]
1717pub(crate) fn response<T>() -> (Answer<T>, std::sync::mpsc::Receiver<T>) {
1818 std::sync::mpsc::channel()
1919}
2020
21/// Runs native blocking work; browser callers are refused before the future is polled.
22pub(crate) fn blocking<T>(work: impl Future<Output = T>) -> io::Result<T> {
23 #[cfg(not(target_arch = "wasm32"))]
24 return Ok(complete(work));
25 #[cfg(target_arch = "wasm32")]
26 {
27 drop(work);
28 Err(io::ErrorKind::WouldBlock.into())
29 }
30}
31
2132#[cfg(all(feature = "live", not(target_arch = "wasm32")))]
2233pub(crate) async fn answered<T>(
2334 receiver: &std::sync::mpsc::Receiver<T>,
......@@ -134,24 +145,23 @@ mod web {
134145 }
135146 }
136147
148 #[cfg(feature = "live")]
137149 pub(crate) async fn answered<T>(
138150 receiver: &Answered<T>,
139151 timeout: Duration,
140152 ) -> Result<T, std::sync::mpsc::RecvTimeoutError> {
141153 let deadline = web_time::Instant::now() + timeout;
142154 let mut armed = false;
155 let mut answer = std::pin::pin!(receive(receiver));
143156 std::future::poll_fn(|context| {
144 let mut response = receiver.0.lock().unwrap();
145 if let Some(value) = response.value.take() {
146 return Poll::Ready(Ok(value));
147 }
148 if response.closed {
149 return Poll::Ready(Err(std::sync::mpsc::RecvTimeoutError::Disconnected));
157 if let Poll::Ready(answer) = answer.as_mut().poll(context) {
158 return Poll::Ready(
159 answer.map_err(|_| std::sync::mpsc::RecvTimeoutError::Disconnected),
160 );
150161 }
151162 if web_time::Instant::now() >= deadline {
152163 return Poll::Ready(Err(std::sync::mpsc::RecvTimeoutError::Timeout));
153164 }
154 response.waiting = Some(context.waker().clone());
155165 if !std::mem::replace(&mut armed, true) {
156166 after(timeout, context.waker().clone());
157167 }
......@@ -160,6 +170,23 @@ mod web {
160170 .await
161171 }
162172
173 pub(crate) async fn receive<T>(
174 receiver: &Answered<T>,
175 ) -> Result<T, std::sync::mpsc::RecvError> {
176 std::future::poll_fn(|context| {
177 let mut response = receiver.0.lock().unwrap();
178 if let Some(value) = response.value.take() {
179 return Poll::Ready(Ok(value));
180 }
181 if response.closed {
182 return Poll::Ready(Err(std::sync::mpsc::RecvError));
183 }
184 response.waiting = Some(context.waker().clone());
185 Poll::Pending
186 })
187 .await
188 }
189
163190 #[derive(Default)]
164191 struct Bell {
165192 rung: bool,
......@@ -229,13 +256,17 @@ mod web {
229256 .await
230257 }
231258
232 /// A task the browser's event loop runs. It cannot be waited for, and ends on its own.
233 pub(crate) struct JoinHandle<T>(Arc<Mutex<Option<T>>>);
259 /// A task the browser's event loop runs, with a wakeable completion receipt.
260 pub(crate) struct JoinHandle<T>(Answered<T>);
234261
235262 impl<T> JoinHandle<T> {
236263 /// What the task ended with, if it has ended.
237264 pub(crate) fn finished(self) -> Option<T> {
238 self.0.lock().ok()?.take()
265 self.0.0.lock().ok()?.value.take()
266 }
267
268 pub(crate) async fn join(self) -> Result<T, std::sync::mpsc::RecvError> {
269 receive(&self.0).await
239270 }
240271 }
241272
......@@ -243,14 +274,10 @@ mod web {
243274 _: &str,
244275 start: impl FnOnce() -> F,
245276 ) -> io::Result<JoinHandle<T>> {
246 let output = Arc::new(Mutex::new(None));
247 let ended = Arc::clone(&output);
277 let (ended, output) = response();
248278 let work = start();
249279 wasm_bindgen_futures::spawn_local(async move {
250 let result = work.await;
251 if let Ok(mut ended) = ended.lock() {
252 *ended = Some(result);
253 }
280 let _ = ended.send(work.await);
254281 });
255282 Ok(JoinHandle(output))
256283 }
crates/notebook/src/worker.rs+16
......@@ -130,6 +130,22 @@ impl SyncWorker {
130130 .join()
131131 .map_err(|_| io::Error::other("Synchronization worker panicked"))?
132132 }
133
134 pub(crate) async fn stop_async(self) -> Result<()> {
135 #[cfg(not(target_arch = "wasm32"))]
136 return self.stop();
137 #[cfg(target_arch = "wasm32")]
138 {
139 let mut worker = self;
140 worker.signal.stopped.store(true, Ordering::Release);
141 worker.signal.wake();
142 let thread = worker.thread.take().expect("Worker owns its thread");
143 thread
144 .join()
145 .await
146 .map_err(|_| io::Error::other("Synchronization worker panicked"))?
147 }
148 }
133149}
134150
135151impl Drop for SyncWorker {
crates/notebook/src/working.rs+137-14
......@@ -44,9 +44,10 @@ pub(crate) enum Request {
4444 version: ExGuid,
4545 reply: Reply<Page>,
4646 },
47 /// Answers once the edits before it are written.
47 /// Answers once the edits before it are written, optionally awaiting host durability.
4848 Flush {
4949 reply: Reply<()>,
50 durable: bool,
5051 },
5152 /// Seals the open batch, when no sealed batch waits for publication, recording the
5253 /// attempt to publish it.
......@@ -90,7 +91,7 @@ impl Request {
9091 Self::Conflicts { reply } => reply(Err(error())),
9192 Self::Versions { reply } => reply(Err(error())),
9293 Self::Version { reply, .. } => reply(Err(error())),
93 Self::Flush { reply } => reply(Err(error())),
94 Self::Flush { reply, .. } => reply(Err(error())),
9495 Self::Seal { reply } => reply(Err(error())),
9596 Self::Rebase { reply, .. } => reply(Err(error())),
9697 Self::Reopen { reply } => reply(Err(error())),
......@@ -109,10 +110,16 @@ pub(crate) struct Thread {
109110 pub(crate) worker: Mutex<Weak<Signal>>,
110111 /// Taken when the replica drops, which ends every section thread.
111112 sender: Mutex<Option<mpsc::Sender<Request>>>,
113 #[cfg(target_arch = "wasm32")]
114 durable: Mutex<Option<mpsc::Sender<Receipt>>>,
115 #[cfg(target_arch = "wasm32")]
116 durable_wake: crate::task::Sender<()>,
112117 #[cfg(not(target_arch = "wasm32"))]
113118 threads: Mutex<Vec<std::thread::JoinHandle<()>>>,
114119 /// A password-protected section's key, under which its queue is sealed too.
115120 pub(crate) key: Option<Key>,
121 /// Last, so the cache closes before background synchronization takes it over.
122 pub(crate) released: crate::Released,
116123}
117124
118125impl Thread {
......@@ -127,13 +134,19 @@ impl Thread {
127134 Ok(())
128135 }
129136
130 /// Ends the section threads and waits for them, so the cache is released.
137 /// Ends request serving; pending browser receipts retain the cache until they settle.
131138 pub(crate) fn stop(&self) {
132139 if let Ok(mut sender) = self.sender.lock() {
133140 sender.take();
134141 }
135142 #[cfg(target_arch = "wasm32")]
136 serving::serve(self);
143 {
144 serving::serve(self);
145 if let Ok(mut durable) = self.durable.lock() {
146 durable.take();
147 }
148 let _ = self.durable_wake.try_send(());
149 }
137150 #[cfg(not(target_arch = "wasm32"))]
138151 while let Some(thread) = self
139152 .threads
......@@ -169,6 +182,77 @@ impl Thread {
169182 serving::start(self, Box::pin(run(Arc::clone(self))));
170183 Ok(())
171184 }
185
186 #[cfg(target_arch = "wasm32")]
187 fn receipt(self: &Arc<Self>, replies: Vec<Reply<()>>, edited: bool) {
188 let receipt = Receipt {
189 shared: Arc::clone(self),
190 replies,
191 edited,
192 };
193 let sent = match self.durable.lock() {
194 Ok(sender) => match sender.as_ref() {
195 Some(sender) => sender.send(receipt).map_err(|error| error.0),
196 None => Err(receipt),
197 },
198 Err(_) => Err(receipt),
199 };
200 if let Err(receipt) = sent {
201 for reply in receipt.replies {
202 reply(Err(io::Error::other("The section thread stopped").into()));
203 }
204 }
205 let _ = self.durable_wake.try_send(());
206 }
207}
208
209#[cfg(target_arch = "wasm32")]
210struct Receipt {
211 shared: Arc<Thread>,
212 replies: Vec<Reply<()>>,
213 edited: bool,
214}
215
216#[cfg(target_arch = "wasm32")]
217async fn receipts(requests: mpsc::Receiver<Receipt>, wake: crate::task::Receiver<()>) {
218 loop {
219 let Receipt {
220 shared,
221 replies,
222 edited,
223 } = match requests.try_recv() {
224 Ok(receipt) => receipt,
225 Err(mpsc::TryRecvError::Empty) => {
226 crate::task::wait(&wake, None).await;
227 continue;
228 }
229 Err(mpsc::TryRecvError::Disconnected) => return,
230 };
231 let result = loop {
232 match crate::fs::durable().await {
233 Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
234 crate::task::wait(&wake, Some(std::time::Duration::from_secs(1))).await;
235 }
236 result => break result,
237 }
238 };
239 match result {
240 Ok(()) => {
241 for reply in replies {
242 reply(Ok(()));
243 }
244 if edited {
245 crate::edited(&shared.worker);
246 }
247 }
248 Err(error) => {
249 let message = error.to_string();
250 for reply in replies {
251 reply(Err(io::Error::new(error.kind(), message.clone()).into()));
252 }
253 }
254 }
255 }
172256}
173257
174258/// The browser's section threads: each replica's `run`, polled whenever a request is sent
......@@ -225,14 +309,25 @@ async fn receive(requests: &mpsc::Receiver<Request>) -> Option<Request> {
225309/// Starts the section thread once the queue opens.
226310pub(crate) fn spawn(connection: Connection, key: Option<Key>) -> Result<(Arc<Thread>, ExGuid)> {
227311 let (sender, requests) = mpsc::channel();
312 #[cfg(target_arch = "wasm32")]
313 let (durable, written) = mpsc::channel();
314 #[cfg(target_arch = "wasm32")]
315 let (durable_wake, woken) = crate::task::channel();
228316 let shared = Arc::new(Thread {
229317 connection: Mutex::new(connection),
230318 worker: Mutex::new(Weak::new()),
231319 sender: Mutex::new(Some(sender)),
320 #[cfg(target_arch = "wasm32")]
321 durable: Mutex::new(Some(durable)),
322 #[cfg(target_arch = "wasm32")]
323 durable_wake,
232324 #[cfg(not(target_arch = "wasm32"))]
233325 threads: Mutex::new(Vec::new()),
234326 key,
327 released: crate::Released::default(),
235328 });
329 #[cfg(target_arch = "wasm32")]
330 crate::task::spawn("onestore-durable", move || receipts(written, woken))?;
236331 let (ready, opened) = mpsc::sync_channel(1);
237332 shared.start(move |shared| run(shared, requests, VecDeque::new(), Some(ready)))?;
238333 match opened.recv() {
......@@ -486,13 +581,25 @@ impl<'a> Working<'a> {
486581 later.push_back(request);
487582 false
488583 }
489 Request::Flush { reply } => {
584 Request::Flush { reply, durable } => {
490585 let written = self.flush(shared, &mut accepted);
491 reply(if written {
492 Ok(())
586 if written {
587 #[cfg(target_arch = "wasm32")]
588 if durable {
589 shared.receipt(vec![reply], false);
590 } else {
591 reply(Ok(()));
592 }
593 #[cfg(not(target_arch = "wasm32"))]
594 {
595 let _ = durable;
596 reply(Ok(()));
597 }
493598 } else {
494 Err(io::Error::other("The queue could not be written").into())
495 });
599 reply(Err(
600 io::Error::other("The queue could not be written").into()
601 ));
602 }
496603 !written
497604 }
498605 Request::Seal { reply } => {
......@@ -586,7 +693,7 @@ impl<'a> Working<'a> {
586693 edit: Edit,
587694 reply: Reply<u64>,
588695 accepted: &mut Vec<Accepted>,
589 shared: &Thread,
696 shared: &Arc<Thread>,
590697 ) -> std::result::Result<(), bool> {
591698 match self.section.apply(&author, &edit) {
592699 Ok(()) => {
......@@ -612,7 +719,7 @@ impl<'a> Working<'a> {
612719
613720 /// Writes the accepted edits in one transaction, then answers their senders; false
614721 /// when the write failed and the section holds edits the queue lacks.
615 fn flush(&mut self, shared: &Thread, accepted: &mut Vec<Accepted>) -> bool {
722 fn flush(&mut self, shared: &Arc<Thread>, accepted: &mut Vec<Accepted>) -> bool {
616723 if accepted.is_empty() {
617724 return true;
618725 }
......@@ -645,10 +752,26 @@ impl<'a> Working<'a> {
645752 let ok = written.is_ok();
646753 match written {
647754 Ok(ids) => {
648 for (edit, id) in accepted.drain(..).zip(ids) {
649 (edit.reply)(Ok(id));
755 #[cfg(target_arch = "wasm32")]
756 shared.receipt(
757 accepted
758 .drain(..)
759 .zip(ids)
760 .map(|(edit, id)| {
761 let reply: Reply<()> =
762 Box::new(move |result| (edit.reply)(result.map(|()| id)));
763 reply
764 })
765 .collect(),
766 true,
767 );
768 #[cfg(not(target_arch = "wasm32"))]
769 {
770 for (edit, id) in accepted.drain(..).zip(ids) {
771 (edit.reply)(Ok(id));
772 }
773 crate::edited(&shared.worker);
650774 }
651 crate::edited(&shared.worker);
652775 }
653776 Err(error) => {
654777 let message = error.to_string();
crates/notebook/tests/cache.rs+35
......@@ -132,6 +132,41 @@ fn cache_reopen_preserves_the_base_and_queued_edits() {
132132 assert_eq!(replica.pending().unwrap().len(), 2);
133133}
134134
135#[test]
136fn async_edit_receipts_reopen_with_the_same_id_and_text() {
137 use std::{
138 future::Future,
139 task::{Context, Poll, Waker},
140 };
141 let dir = tempfile::tempdir().unwrap();
142 let path = dir.path().join("async.sqlite");
143 let source = onestore::create_section("Async.one", "Receipt", "Fixture").unwrap();
144 let (space, text, _) = target(&source);
145 let replica = Replica::create(&path, &source).unwrap();
146 let id = {
147 let mut receipt =
148 std::pin::pin!(replica.apply_async("Author", typed(space, text, "Durable ")));
149 match receipt
150 .as_mut()
151 .poll(&mut Context::from_waker(Waker::noop()))
152 {
153 Poll::Ready(result) => result.unwrap(),
154 Poll::Pending => panic!("Native durability must finish without a pending wait"),
155 }
156 };
157 {
158 let mut flush = std::pin::pin!(replica.written_async());
159 assert!(matches!(
160 flush.as_mut().poll(&mut Context::from_waker(Waker::noop())),
161 Poll::Ready(Ok(()))
162 ));
163 }
164 drop(replica);
165 let reopened = Replica::open(&path).unwrap();
166 assert_eq!(reopened.pending().unwrap()[0].id, id);
167 assert_eq!(target(&reopened.snapshot().unwrap()).2, "Durable Receipt");
168}
169
135170#[test]
136171fn refused_edits_and_failed_writes_leave_the_queue_as_it_was() {
137172 let dir = tempfile::tempdir().unwrap();
crates/snowbound/src/history.rs+75-50
......@@ -169,13 +169,17 @@ impl State {
169169 version: ExGuid,
170170 ) -> Result<(), Box<dyn Error>> {
171171 let session = self.session.as_mut().ok_or("No section is open")?;
172 session
172 let operation = session
173173 .section
174 .restore_version(page, version, &self.author)?;
175 session.refresh_history()?;
176 self.edited(vec![page]);
177 self.commands.push(Command::OpenPage(page));
178 Ok(())
174 .restore_version_async(page, version, &self.author);
175 self.section_edit(operation, move |state, _| {
176 if let Some(session) = &mut state.session {
177 session.refresh_history()?;
178 }
179 state.edited(vec![page]);
180 state.commands.push(Command::OpenPage(page));
181 Ok(())
182 })
179183 }
180184
181185 pub(crate) fn delete_version(
......@@ -184,10 +188,16 @@ impl State {
184188 version: ExGuid,
185189 ) -> Result<(), Box<dyn Error>> {
186190 let session = self.session.as_mut().ok_or("No section is open")?;
187 session.section.delete_versions(&[(page, vec![version])])?;
188 session.refresh_history()?;
189 self.commands.push(Command::OpenPage(page));
190 Ok(())
191 let operation = session
192 .section
193 .delete_versions_async(&[(page, vec![version])]);
194 self.section_edit(operation, move |state, _| {
195 if let Some(session) = &mut state.session {
196 session.refresh_history()?;
197 }
198 state.commands.push(Command::OpenPage(page));
199 Ok(())
200 })
191201 }
192202
193203 /// Delete All Versions in the section, its group or the notebook, once confirmed.
......@@ -232,55 +242,70 @@ impl State {
232242 }) else {
233243 return Ok(());
234244 };
235 clear(&session.section)?;
236 session.refresh_history()?;
237 if session.version.is_some() {
238 self.commands.push(Command::OpenPage(session.space));
239 }
240 if scope == Scope::Section {
241 return Ok(());
242 }
243 // The other sections, each opened, cleared and closed in the background.
244 let folder = match scope {
245 Scope::Group => folder,
246 _ => String::new(),
247 };
245 let operation = clear(&session.section);
248246 let library = Arc::clone(&session.library);
249247 let proxy = self.proxy.clone();
250 crate::spawn(move || {
251 for path in sections(&library, &folder) {
252 if path == open {
253 continue;
254 }
255 let cleared = library
256 .open(&path, notify(proxy.clone()))
257 .and_then(|section| {
258 clear(&section)?;
259 Ok(section.close()?)
260 });
261 if let Err(error) = cleared {
262 eprintln!("Deleting the versions in {path} failed: {error}");
248 let operation = async move {
249 operation.await?;
250 if scope != Scope::Section {
251 let folder = if scope == Scope::Group {
252 folder
253 } else {
254 String::new()
255 };
256 crate::spawn_async(move || async move {
257 for path in sections(&library, &folder) {
258 if path == open {
259 continue;
260 }
261 let cleared: Result<(), Box<dyn Error>> = async {
262 let section = library.open(&path, notify(proxy.clone()))?;
263 clear(&section).await?;
264 Ok(section.close_async().await?)
265 }
266 .await;
267 if let Err(error) = cleared {
268 eprintln!("Deleting the versions in {path} failed: {error}");
269 }
270 }
271 });
272 }
273 Ok(())
274 };
275 self.section_edit(operation, move |state, ()| {
276 if let Some(session) = &mut state.session {
277 session.refresh_history()?;
278 if session.version.is_some() {
279 state.commands.push(Command::OpenPage(session.space));
263280 }
264281 }
265 });
266 Ok(())
282 Ok(())
283 })
267284 }
268285}
269286
270287/// Deletes every page version `section` holds.
271fn clear(section: &notebook::session::Section) -> Result<(), notebook::Error> {
272 let all: Vec<(ExGuid, Vec<ExGuid>)> = (section.versions()?.into_iter())
273 .map(|(page, versions)| {
274 (
275 page,
276 versions.iter().map(|version| version.context).collect(),
277 )
278 })
279 .collect();
280 if !all.is_empty() {
281 section.delete_versions(&all)?;
288fn clear(
289 section: &notebook::session::Section,
290) -> impl std::future::Future<Output = Result<(), notebook::Error>> + Send + 'static + use<> {
291 let operation = section.versions().map(|versions| {
292 let all: Vec<(ExGuid, Vec<ExGuid>)> = versions
293 .into_iter()
294 .map(|(page, versions)| {
295 (
296 page,
297 versions.iter().map(|version| version.context).collect(),
298 )
299 })
300 .collect();
301 (!all.is_empty()).then(|| section.delete_versions_async(&all))
302 });
303 async move {
304 if let Some(operation) = operation? {
305 operation.await?;
306 }
307 Ok(())
282308 }
283 Ok(())
284309}
285310
286311/// The readable sections at catalog path `folder` and in the groups inside it, leaving out
crates/snowbound/src/library.rs+47-1
......@@ -974,6 +974,41 @@ impl Library {
974974 }
975975 }
976976
977 pub async fn close_kept_async(&self) -> Result<(), Box<dyn Error>> {
978 #[cfg(not(target_arch = "wasm32"))]
979 {
980 self.close_kept();
981 Ok(())
982 }
983 #[cfg(target_arch = "wasm32")]
984 {
985 let replicas: Vec<_> = {
986 let mut kept = self
987 .kept
988 .lock()
989 .map_err(|_| io::Error::from(io::ErrorKind::Other))?;
990 kept.values_mut()
991 .map(|section| Arc::clone(section.replica()))
992 .collect()
993 };
994 for replica in replicas {
995 replica.written_async().await?;
996 }
997 let stopping: Vec<_> = {
998 let mut kept = self
999 .kept
1000 .lock()
1001 .map_err(|_| io::Error::from(io::ErrorKind::Other))?;
1002 kept.values_mut().map(Section::stop_async).collect()
1003 };
1004 for stopping in stopping {
1005 stopping.await?;
1006 }
1007 self.close_kept();
1008 Ok(())
1009 }
1010 }
1011
9771012 /// Where `file`, a section file of this notebook, sits on this computer: under the
9781013 /// mount it was opened from when the embedded client holds it share-relative.
9791014 pub fn local(&self, file: &Path) -> Option<PathBuf> {
......@@ -1186,10 +1221,21 @@ impl Library {
11861221 /// Unlocks the section at catalog `path` with `password`, as OneNote's Protected Section
11871222 /// dialog does; a wrong password is `PasswordMismatch`.
11881223 pub fn unlock(&self, path: &str, password: &str) -> Result<(), notebook::Error> {
1224 let operation = self.unlock_async(path, password);
1225 #[cfg(not(target_arch = "wasm32"))]
1226 return pollster::block_on(operation);
1227 #[cfg(target_arch = "wasm32")]
1228 {
1229 drop(operation);
1230 Err(io::ErrorKind::WouldBlock.into())
1231 }
1232 }
1233
1234 pub async fn unlock_async(&self, path: &str, password: &str) -> Result<(), notebook::Error> {
11891235 let Ok(Some(notebook)) = &self.notebook else {
11901236 return Err(io::Error::from(io::ErrorKind::NotFound).into());
11911237 };
1192 let key = notebook.unlock(path, password)?;
1238 let key = notebook.unlock_async(path, password).await?;
11931239 self.keep_key(path, key);
11941240 Ok(())
11951241 }
crates/snowbound/src/main.rs+70-14
......@@ -3582,9 +3582,14 @@ impl State {
35823582 }
35833583 Command::DeleteVersion { page, version } => {
35843584 let session = self.session.as_mut().ok_or("No section is open")?;
3585 session.section.delete_pages(&[version])?;
3586 session.refresh_conflicts()?;
3587 self.commands.push(Command::OpenPage(page));
3585 let operation = session.section.delete_pages_async(&[version]);
3586 self.section_edit(operation, move |state, _| {
3587 if let Some(session) = &mut state.session {
3588 session.refresh_conflicts()?;
3589 }
3590 state.commands.push(Command::OpenPage(page));
3591 Ok(())
3592 })?;
35883593 }
35893594 Command::CopyVersion { version, tab } => {
35903595 let session = self.session.as_mut().ok_or("No section is open")?;
......@@ -3595,18 +3600,25 @@ impl State {
35953600 _ => session.section.page(version)?,
35963601 };
35973602 if tab == session.tab {
3598 session.section.import_page(&page, &self.author)?;
3599 session.pages = session.section.pages()?;
3600 self.edited(Vec::new());
3603 let operation = session.section.import_page_async(&page, &self.author);
3604 self.section_edit(operation, |state, _| {
3605 if let Some(session) = &mut state.session {
3606 session.pages = session.section.pages()?;
3607 }
3608 state.edited(Vec::new());
3609 Ok(())
3610 })?;
36013611 } else {
36023612 let library = Arc::clone(&session.library);
36033613 let path = session.tabs[tab].path.clone();
36043614 let (notify, author) = (notify(self.proxy.clone()), self.author.clone());
3605 crate::spawn(move || {
3606 let copied = library.open(&path, notify).and_then(|section| {
3607 section.import_page(&page, &author)?;
3608 Ok(section.close()?)
3609 });
3615 crate::spawn_async(move || async move {
3616 let copied: Result<(), Box<dyn Error>> = async {
3617 let section = library.open(&path, notify)?;
3618 section.import_page_async(&page, &author).await?;
3619 Ok(section.close_async().await?)
3620 }
3621 .await;
36103622 if let Err(error) = copied {
36113623 eprintln!("Copying the page failed: {error}");
36123624 }
......@@ -3676,14 +3688,21 @@ impl State {
36763688 /// Runs `read` on a thread of its own, which lays out the page it read; `open_loaded`
36773689 /// shows it unless a newer read was asked for meanwhile.
36783690 fn load(&mut self, read: impl FnOnce() -> Result<Loaded, Box<dyn Error>> + Send + 'static) {
3691 self.load_async(move || async move { read() });
3692 }
3693
3694 fn load_async<F: Future<Output = Result<Loaded, Box<dyn Error>>> + 'static>(
3695 &mut self,
3696 read: impl FnOnce() -> F + Send + 'static,
3697 ) {
36793698 self.loading += 1;
36803699 let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone());
36813700 let layouts = Arc::clone(&self.layouts);
36823701 let requested = Instant::now();
36833702 let scenes = Arc::clone(&self.prefetch.scenes);
36843703 let open = self.session.as_ref().map(Session::key);
3685 crate::spawn(move || {
3686 let laid = read().and_then(|loaded| {
3704 crate::spawn_async(move || async move {
3705 let laid = read().await.and_then(|loaded| {
36873706 lap("switch read", requested);
36883707 let (shown, page) = match loaded {
36893708 Loaded::Section(session, page) => (Shown::Section(session), page),
......@@ -3730,6 +3749,44 @@ impl State {
37303749 });
37313750 }
37323751
3752 fn complete_async<T: Send + 'static, F: Future<Output = T> + Send + 'static>(
3753 &mut self,
3754 operation: F,
3755 then: impl FnOnce(&mut State, T) -> Result<(), Box<dyn Error>> + Send + 'static,
3756 ) -> Result<(), Box<dyn Error>> {
3757 #[cfg(not(target_arch = "wasm32"))]
3758 return then(self, pollster::block_on(operation));
3759 #[cfg(target_arch = "wasm32")]
3760 {
3761 let reply = self.reply(then);
3762 crate::spawn_async(move || async move { reply.send(operation.await) });
3763 Ok(())
3764 }
3765 }
3766
3767 fn section_edit<
3768 T: Send + 'static,
3769 F: Future<Output = Result<T, notebook::Error>> + Send + 'static,
3770 >(
3771 &mut self,
3772 operation: F,
3773 then: impl FnOnce(&mut State, T) -> Result<(), Box<dyn Error>> + Send + 'static,
3774 ) -> Result<(), Box<dyn Error>> {
3775 let key = self.session.as_ref().ok_or("No section is open")?.key();
3776 self.complete_async(operation, move |state, result| {
3777 let value = result?;
3778 if state
3779 .session
3780 .as_ref()
3781 .is_some_and(|session| session.key() == key)
3782 {
3783 then(state, value)
3784 } else {
3785 Ok(())
3786 }
3787 })
3788 }
3789
37333790 /// Shows the newest page read once the pictures it shows first are drawn, or after
37343791 /// `HOLD`.
37353792 fn open_loaded(&mut self) -> Result<(), Box<dyn Error>> {
......@@ -6207,7 +6264,6 @@ fn spawn(work: impl FnOnce() + Send + 'static) {
62076264}
62086265
62096266/// Runs asynchronous work beside the frame, creating the future on its executor.
6210#[cfg(feature = "live")]
62116267fn spawn_async<F: Future<Output = ()> + 'static>(work: impl FnOnce() -> F + Send + 'static) {
62126268 RUNNING.fetch_add(1, Ordering::Relaxed);
62136269 #[cfg(not(target_arch = "wasm32"))]
crates/snowbound/src/manage.rs+2-2
......@@ -781,9 +781,9 @@ impl State {
781781 .filter(|session| Arc::ptr_eq(&session.library, &library))
782782 .map(|session| session.tabs[session.tab].path.clone());
783783 let notify = notify(self.proxy.clone());
784 self.load(move || {
784 self.load_async(move || async move {
785785 // A section kept open would hold a file the change moves.
786 library.close_kept();
786 library.close_kept_async().await?;
787787 let mut notebook = library.reopen()?;
788788 let names = |notebook: &Notebook, folder: &str| -> Vec<String> {
789789 let found = crate::library::folders(notebook.catalog(), |_| true)
crates/snowbound/src/protection.rs+192-67
......@@ -565,23 +565,9 @@ impl State {
565565 (Some("Set Password"), _) => Some(Dialog::Set(Default::default(), None)),
566566 (Some("Change Password"), _) => Some(Dialog::Change(Default::default(), None)),
567567 (Some("Remove Password"), _) => Some(Dialog::Remove(Zeroizing::default(), false)),
568 (Some(_), Dialog::Unlock(value, _)) => match library.unlock(&path, &value) {
569 Ok(()) => {
570 self.commands
571 .push(Command::OpenSection(Arc::clone(&library), path.clone()));
572 None
573 }
574 Err(notebook::Error::Protected(_)) => {
575 Some(Dialog::Unlock(Zeroizing::default(), true))
576 }
577 Err(error) => {
578 crate::platform::alert(
579 "Couldn't unlock the section",
580 &crate::plain(&error, "section"),
581 );
582 None
583 }
584 },
568 (Some(_), dialog @ Dialog::Unlock(..)) => {
569 return self.submit_password(library, path, dialog);
570 }
585571 (Some(_), Dialog::Set([value, confirm], _)) => {
586572 if value.is_empty() {
587573 Some(Dialog::Set(Default::default(), Some("Enter a password.")))
......@@ -607,30 +593,16 @@ impl State {
607593 Some("The new passwords don\u{2019}t match."),
608594 ))
609595 } else {
610 match current(&library, &path, &old) {
611 Some(key) => {
612 self.set_password(
613 library.clone(),
614 path.clone(),
615 Some(key),
616 Some(value),
617 );
618 None
619 }
620 None => Some(Dialog::Change(
621 Default::default(),
622 Some("Password is incorrect."),
623 )),
624 }
596 return self.submit_password(
597 library,
598 path,
599 Dialog::Change([old, value, confirm], None),
600 );
625601 }
626602 }
627 (Some(_), Dialog::Remove(value, _)) => match current(&library, &path, &value) {
628 Some(key) => {
629 self.set_password(library.clone(), path.clone(), Some(key), None);
630 None
631 }
632 None => Some(Dialog::Remove(Zeroizing::default(), true)),
633 },
603 (Some(_), dialog @ Dialog::Remove(..)) => {
604 return self.submit_password(library, path, dialog);
605 }
634606 (Some(_), Dialog::Protection) => None,
635607 };
636608 match next {
......@@ -640,6 +612,84 @@ impl State {
640612 Ok(())
641613 }
642614
615 fn submit_password(
616 &mut self,
617 library: Arc<Library>,
618 path: String,
619 dialog: Dialog,
620 ) -> Result<(), Box<dyn Error>> {
621 #[cfg(target_arch = "wasm32")]
622 self.ui.close_popup(id());
623 let operation = async move {
624 let result = {
625 let password = match &dialog {
626 Dialog::Unlock(value, _) | Dialog::Remove(value, _) => value,
627 Dialog::Change([old, ..], _) => old,
628 _ => unreachable!("Only password checks are submitted"),
629 };
630 #[cfg(not(target_arch = "wasm32"))]
631 let result = library.unlock(&path, password);
632 #[cfg(target_arch = "wasm32")]
633 let result = library.unlock_async(&path, password).await;
634 result
635 };
636 (library, path, dialog, result)
637 };
638 self.complete_async(operation, |state, (library, path, dialog, result)| {
639 if let Err(error) = &result
640 && !matches!(error, notebook::Error::Protected(_))
641 {
642 state.ui.close_popup(id());
643 crate::platform::alert(
644 "Couldn't unlock the section",
645 &crate::plain(error, "section"),
646 );
647 return Ok(());
648 }
649 let key = result.as_ref().ok().and_then(|()| library.unlocked(&path));
650 let next = match dialog {
651 Dialog::Unlock(..) => {
652 if result.is_ok() {
653 state
654 .commands
655 .push(Command::OpenSection(Arc::clone(&library), path.clone()));
656 None
657 } else {
658 Some(Dialog::Unlock(Zeroizing::default(), true))
659 }
660 }
661 Dialog::Change([_, value, _], _) => match key {
662 Some(key) => {
663 state.set_password(
664 Arc::clone(&library),
665 path.clone(),
666 Some(key),
667 Some(value),
668 );
669 None
670 }
671 None => Some(Dialog::Change(
672 Default::default(),
673 Some("Password is incorrect."),
674 )),
675 },
676 Dialog::Remove(..) => match key {
677 Some(key) => {
678 state.set_password(Arc::clone(&library), path.clone(), Some(key), None);
679 None
680 }
681 None => Some(Dialog::Remove(Zeroizing::default(), true)),
682 },
683 _ => unreachable!("Only password checks are submitted"),
684 };
685 match next {
686 Some(dialog) => state.ask_password(library, path, dialog),
687 None => state.ui.close_popup(id()),
688 }
689 Ok(())
690 })
691 }
692
643693 /// Sets, changes or removes the password of `path` in `library`, closing its section
644694 /// first: the section is written anew, and its replica goes with the old file.
645695 fn set_password(
......@@ -649,38 +699,113 @@ impl State {
649699 key: Option<onestore::protected::Key>,
650700 password: Option<Zeroizing<String>>,
651701 ) {
652 let open = self.session.as_ref().is_some_and(|session| {
653 Arc::ptr_eq(&session.library, &library) && session.tabs[session.tab].path == path
654 });
655 if open {
656 let closed = self.persist().and_then(|()| {
657 if let Some(session) = self.session.take() {
658 session.section.close()?;
702 let replica = self
703 .session
704 .as_ref()
705 .filter(|session| {
706 Arc::ptr_eq(&session.library, &library) && session.tabs[session.tab].path == path
707 })
708 .map(|session| Arc::clone(session.section.replica()));
709 let Some(replica) = replica else {
710 library.lock(|locked, _| locked == path);
711 self.restructure(
712 library,
713 Structure::Password {
714 path,
715 key,
716 password,
717 },
718 );
719 return;
720 };
721 if let Err(error) = self.persist() {
722 return crate::platform::alert(
723 "Couldn't set the password",
724 &crate::plain(&*error, "section"),
725 );
726 }
727 let operation = async move {
728 let through = replica.pending()?.last().map_or(0, |edit| edit.id);
729 replica.written_async().await?;
730 Ok::<_, notebook::Error>((replica, through))
731 };
732 let _ = self.complete_async(operation, move |state, result| {
733 let prepared = (|| -> Result<(), Box<dyn Error>> {
734 let (replica, through) = result?;
735 let open = state
736 .session
737 .as_ref()
738 .is_some_and(|session| Arc::ptr_eq(session.section.replica(), &replica));
739 if open {
740 state.persist()?;
741 let pending = replica.pending()?.last().map_or(0, |edit| edit.id);
742 if pending > through {
743 state.set_password(library, path, key, password);
744 return Ok(());
745 }
659746 }
660 Ok(())
661 });
662 if let Err(error) = closed {
663 return crate::platform::alert(
747 let stopping = state
748 .session
749 .as_mut()
750 .filter(|_| open)
751 .map(|session| session.section.stop_async());
752 let operation = async move {
753 if let Some(stopping) = stopping {
754 stopping.await?;
755 }
756 let through = replica.pending()?.last().map_or(0, |edit| edit.id);
757 replica.written_async().await?;
758 Ok::<_, notebook::Error>((replica, through))
759 };
760 state.complete_async(operation, move |state, result| {
761 let closed =
762 (|| -> Result<(), Box<dyn Error>> {
763 let (replica, through) = result?;
764 if state.session.as_ref().is_some_and(|session| {
765 Arc::ptr_eq(session.section.replica(), &replica)
766 }) {
767 state.persist()?;
768 if replica
769 .pending()?
770 .last()
771 .is_some_and(|edit| edit.id > through)
772 {
773 state.set_password(library, path, key, password);
774 return Ok(());
775 }
776 if let Some(session) = state.session.take() {
777 session.section.close()?;
778 }
779 }
780 drop(replica);
781 state.prefetch.forget(&library.key(&path));
782 library.lock(|locked, _| locked == path);
783 state.restructure(
784 library,
785 Structure::Password {
786 path,
787 key,
788 password,
789 },
790 );
791 Ok(())
792 })();
793 if let Err(error) = closed {
794 crate::platform::alert(
795 "Couldn't set the password",
796 &crate::plain(&*error, "section"),
797 );
798 }
799 Ok(())
800 })
801 })();
802 if let Err(error) = prepared {
803 crate::platform::alert(
664804 "Couldn't set the password",
665805 &crate::plain(&*error, "section"),
666806 );
667807 }
668 self.prefetch.forget(&library.key(&path));
669 }
670 library.lock(|locked, _| locked == path);
671 self.restructure(
672 library,
673 Structure::Password {
674 path,
675 key,
676 password,
677 },
678 );
808 Ok(())
809 });
679810 }
680811}
681
682/// The key `password` opens the section at `path` with, if it does.
683fn current(library: &Library, path: &str, password: &str) -> Option<onestore::protected::Key> {
684 library.unlock(path, password).ok()?;
685 library.unlocked(path)
686}
crates/snowbound/src/recycle.rs+40-23
......@@ -135,8 +135,8 @@ impl State {
135135 (spaces.clone().skip(at).find(kept)).or_else(|| spaces.take(at).rev().find(kept))
136136 });
137137 let (author, proxy) = (self.author.clone(), self.proxy.clone());
138 self.load(move || {
139 carry_out(&library, &replica, request, &author)?;
138 self.load_async(move || async move {
139 carry_out(&library, &replica, request, &author).await?;
140140 if let Some(show) = if gone.contains(&shown) {
141141 after
142142 } else {
......@@ -192,7 +192,7 @@ impl State {
192192/// Does `request` to the bin's section `replica` holds, in `library`, as `author`: a page
193193/// restored goes to its section as one op, keeping its identity, a copy under a new one, and
194194/// what leaves the bin goes from it as another.
195fn carry_out(
195async fn carry_out(
196196 library: &Library,
197197 replica: &notebook::Replica,
198198 request: Request,
......@@ -203,31 +203,36 @@ fn carry_out(
203203 let page = replica.page(space)?;
204204 let section = library.open(&path, || {})?;
205205 if copy {
206 section.import_page(&page, author)?;
206 section.import_page_async(&page, author).await?;
207207 } else {
208208 let ops = vec![notebook::session::moved(&page, author)?];
209 section.replica().apply(
210 author,
211 Edit {
212 at: filetime(),
213 ops,
214 },
215 )?;
209 section
210 .replica()
211 .apply_async(
212 author,
213 Edit {
214 at: filetime(),
215 ops,
216 },
217 )
218 .await?;
216219 }
217 section.close()?;
220 section.close_async().await?;
218221 (!copy).then_some(vec![space]).unwrap_or_default()
219222 }
220223 Request::Purge(spaces) => spaces,
221224 };
222225 if !gone.is_empty() {
223226 let ops = vec![Op::Section(SectionOp::Delete(gone))];
224 replica.apply(
225 author,
226 Edit {
227 at: filetime(),
228 ops,
229 },
230 )?;
227 replica
228 .apply_async(
229 author,
230 Edit {
231 at: filetime(),
232 ops,
233 },
234 )
235 .await?;
231236 }
232237 Ok(())
233238}
......@@ -360,14 +365,26 @@ mod tests {
360365 path: FIRST.into(),
361366 copy,
362367 };
363 carry_out(&library, bin.replica(), restore(back, false), AUTHOR).unwrap();
364 carry_out(&library, bin.replica(), restore(copied, true), AUTHOR).unwrap();
365 carry_out(
368 pollster::block_on(carry_out(
369 &library,
370 bin.replica(),
371 restore(back, false),
372 AUTHOR,
373 ))
374 .unwrap();
375 pollster::block_on(carry_out(
376 &library,
377 bin.replica(),
378 restore(copied, true),
379 AUTHOR,
380 ))
381 .unwrap();
382 pollster::block_on(carry_out(
366383 &library,
367384 bin.replica(),
368385 Request::Purge(vec![purged]),
369386 AUTHOR,
370 )
387 ))
371388 .unwrap();
372389 bin.close().unwrap();
373390 published(&library, FIRST);
crates/snowbound/src/undo.rs+49-35
......@@ -350,14 +350,16 @@ struct Applied {
350350}
351351
352352impl Site {
353 fn apply(&self, ops: Vec<Op>) -> Result<(), Box<dyn Error>> {
354 self.replica.apply(
355 &self.author,
356 Edit {
357 at: crate::filetime(),
358 ops,
359 },
360 )?;
353 async fn apply(&self, ops: Vec<Op>) -> Result<(), Box<dyn Error>> {
354 self.replica
355 .apply_async(
356 &self.author,
357 Edit {
358 at: crate::filetime(),
359 ops,
360 },
361 )
362 .await?;
361363 Ok(())
362364 }
363365
......@@ -408,7 +410,11 @@ impl Site {
408410
409411 /// Does `change` to the section's pages, `shown` the page open; `None` where what it
410412 /// changes is gone or holds what it did not make, which it leaves alone.
411 fn change(&self, change: Change, shown: ExGuid) -> Result<Option<Applied>, Box<dyn Error>> {
413 async fn change(
414 &self,
415 change: Change,
416 shown: ExGuid,
417 ) -> Result<Option<Applied>, Box<dyn Error>> {
412418 let listed = self.replica.pages()?;
413419 let at = |space: ExGuid| listed.iter().position(|(listed, ..)| *listed == space);
414420 let binned = matches!(self.library.notebook, Ok(Some(_)));
......@@ -449,8 +455,9 @@ impl Site {
449455 if !created && binned {
450456 let pages: Vec<Page> = kept.iter().map(|kept| kept.page.clone()).collect();
451457 self.library.reopen()?.recycle_pages(&pages, &self.author)?;
458 self.replica.written_async().await?;
452459 }
453 self.apply(ops)?;
460 self.apply(ops).await?;
454461 Applied {
455462 undo: Change::Restore {
456463 pages: kept,
......@@ -494,7 +501,7 @@ impl Site {
494501 spaces.push(space);
495502 }
496503 let gone = self.take_added(added, &listed, &mut ops);
497 self.apply(ops)?;
504 self.apply(ops).await?;
498505 if !created && binned {
499506 let identities: Vec<[u8; 16]> =
500507 back.iter().filter_map(|kept| kept.page.identity).collect();
......@@ -515,7 +522,8 @@ impl Site {
515522 let Some(show) = edits.first().map(PageEdit::space) else {
516523 return Ok(None);
517524 };
518 self.apply(vec![Op::Section(SectionOp::Pages(edits))])?;
525 self.apply(vec![Op::Section(SectionOp::Pages(edits))])
526 .await?;
519527 Applied {
520528 undo: Change::Arrange {
521529 order: listed
......@@ -533,7 +541,7 @@ impl Site {
533541 return Ok(None);
534542 }
535543 let op = crate::rename::retitled(&self.replica.page(page)?, page, to.clone())?;
536 self.apply(vec![op])?;
544 self.apply(vec![op]).await?;
537545 Applied {
538546 undo: Change::Retitle {
539547 page,
......@@ -555,15 +563,18 @@ impl Site {
555563 let mut ops = vec![Op::Section(SectionOp::Delete(vec![page]))];
556564 let (show, added) = self.after(&listed, &[page], None, &mut ops);
557565 let section = self.library.open(&path, || {})?;
558 section.replica().apply(
559 &self.author,
560 Edit {
561 at: crate::filetime(),
562 ops: vec![import],
563 },
564 )?;
565 section.close()?;
566 self.apply(ops)?;
566 section
567 .replica()
568 .apply_async(
569 &self.author,
570 Edit {
571 at: crate::filetime(),
572 ops: vec![import],
573 },
574 )
575 .await?;
576 section.close_async().await?;
577 self.apply(ops).await?;
567578 Applied {
568579 undo: Change::Return {
569580 page: arrived,
......@@ -592,7 +603,7 @@ impl Site {
592603 false => None,
593604 };
594605 let Some(sent) = sent else {
595 section.close()?;
606 section.close_async().await?;
596607 return Ok(None);
597608 };
598609 let import = notebook::session::moved(&sent, &self.author)?;
......@@ -606,15 +617,18 @@ impl Site {
606617 )?])),
607618 ];
608619 let gone = self.take_added(added, &listed, &mut ops);
609 self.apply(ops)?;
610 section.replica().apply(
611 &self.author,
612 Edit {
613 at: crate::filetime(),
614 ops: vec![Op::Section(SectionOp::Delete(vec![page]))],
615 },
616 )?;
617 section.close()?;
620 self.apply(ops).await?;
621 section
622 .replica()
623 .apply_async(
624 &self.author,
625 Edit {
626 at: crate::filetime(),
627 ops: vec![Op::Section(SectionOp::Delete(vec![page]))],
628 },
629 )
630 .await?;
631 section.close_async().await?;
618632 Applied {
619633 undo: Change::Send { page: back, target },
620634 show: back,
......@@ -1052,8 +1066,8 @@ impl State {
10521066 let proxy = self.proxy.clone();
10531067 self.undo.busy += 1;
10541068 self.switching = Some((None, web_time::Instant::now()));
1055 self.load(move || {
1056 let applied = site.change(change, shown);
1069 self.load_async(move || async move {
1070 let applied = site.change(change, shown).await;
10571071 let show = match &applied {
10581072 Ok(Some(applied)) => applied.show,
10591073 _ => shown,
......@@ -1382,7 +1396,7 @@ mod tests {
13821396 /// Does `change`, as the app's thread does, returning what takes it back.
13831397 fn change(&self, change: Change) -> Option<Applied> {
13841398 let shown = self.pages()[0].0;
1385 self.site().change(change, shown).unwrap()
1399 pollster::block_on(self.site().change(change, shown)).unwrap()
13861400 }
13871401 }
13881402
crates/snowbound/src/web.rs+5-4
......@@ -1054,7 +1054,8 @@ fn restore(files: js_sys::Array) {
10541054}
10551055
10561056/// Writes the files changed since the last time out to the browser's storage.
1057fn store() {
1057#[wasm_bindgen]
1058pub fn flush_storage() {
10581059 use notebook::fs::Change;
10591060 STORE_DUE.set(None);
10601061 let changes = js_sys::Array::new();
......@@ -1489,7 +1490,7 @@ pub fn flush() {
14891490 report(error);
14901491 }
14911492 });
1492 store();
1493 flush_storage();
14931494}
14941495
14951496#[wasm_bindgen]
......@@ -1585,7 +1586,7 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> {
15851586 STORE_DUE.set(Some(now + STORE_AFTER));
15861587 }
15871588 if STORE_DUE.get().is_some_and(|due| due <= now) {
1588 store();
1589 flush_storage();
15891590 }
15901591 let updating = follow_updates(state, now);
15911592 let next = [
......@@ -1703,7 +1704,7 @@ pub fn update_now(state: &mut State) {
17031704 if let Err(error) = state.persist() {
17041705 report(error);
17051706 }
1706 store();
1707 flush_storage();
17071708 let Some(build) = UPDATE.with_borrow(Clone::clone) else {
17081709 return RELOADING.set(false);
17091710 };
crates/snowbound/web/glue.js+1-1
......@@ -639,7 +639,7 @@ export function adoptCanvas(fresh) {
639639/** Wires the page's canvas, text area and file input to `module`'s exports. */
640640export function attach(module) {
641641 wasm = module;
642 globalThis.snowboundFlushStorage = () => { wasm.flush(); return settled(); };
642 globalThis.snowboundFlushStorage = () => { wasm.flush_storage(); return settled(); };
643643 canvas = document.getElementById("page");
644644 input = document.getElementById("input");
645645 picker = document.getElementById("files");
tools/web/test_storage_barrier.mjs created+59
......@@ -0,0 +1,59 @@
1import assert from 'node:assert/strict';
2import { readFile } from 'node:fs/promises';
3import test from 'node:test';
4import vm from 'node:vm';
5
6test('the durability bridge drains files without reentering the editor and waits for ordered worker receipts', async () => {
7 const source = await readFile(new URL('../../crates/snowbound/web/glue.js', import.meta.url), 'utf8');
8 const messages = [];
9 let worker;
10 const element = { style: {}, addEventListener() {}, focus() {}, remove() {} };
11 const context = vm.createContext({
12 Worker: class {
13 constructor() { worker = this; }
14 postMessage(data) {
15 messages.push(data);
16 if (data.kind === 'load') queueMicrotask(() => this.onmessage({ data: [] }));
17 }
18 },
19 Blob,
20 URL,
21 document: { getElementById: () => element, addEventListener() {} },
22 localStorage: { getItem: () => null },
23 ResizeObserver: class { observe() {} },
24 matchMedia: () => ({ addEventListener() {} }),
25 addEventListener() {},
26 console,
27 });
28 vm.runInContext(source.replace(/\bexport /g, '').replaceAll('import.meta.url', JSON.stringify(import.meta.url)), context);
29 await vm.runInContext('loadFiles()', context);
30 const flushed = [];
31 context.module = {
32 flush() { throw Error('Durability reentered State.persist'); },
33 flush_storage() {
34 const marker = flushed.length + 1;
35 flushed.push(marker);
36 context.nextChange = [['replica-wal', marker, [[0, new Uint8Array([marker])]]]];
37 vm.runInContext('storeFiles(nextChange)', context);
38 },
39 };
40 vm.runInContext('attach(module)', context);
41 const replies = [];
42 const first = vm.runInContext('snowboundFlushStorage()', context).then(() => replies.push(1));
43 const second = vm.runInContext('snowboundFlushStorage()', context).then(() => replies.push(2));
44 assert.deepEqual(messages.map(message => message.kind), ['load', 'store', 'settle', 'store', 'settle']);
45 await Promise.resolve();
46 assert.deepEqual(replies, []);
47 worker.onmessage({ data: { error: null } });
48 await first;
49 assert.deepEqual(replies, [1]);
50 worker.onmessage({ data: { error: null } });
51 await second;
52 assert.deepEqual(replies, [1, 2]);
53 const rejected = vm.runInContext('snowboundFlushStorage()', context);
54 worker.onmessage({ data: { error: 'Storage unavailable' } });
55 await assert.rejects(rejected, /Storage unavailable/);
56 const retried = vm.runInContext('snowboundFlushStorage()', context);
57 worker.onmessage({ data: { error: null } });
58 await retried;
59});