authorclover caruso <git@paperclover.net> 2026-10-11 20:49:10-07:00
committerclover caruso <git@paperclover.net> 2026-10-11 20:50:36-07:00
log0b3748b14dba39c904ac5359c0560831ef382139
tree483bb450dab6f033d7b7352d94b32d5ab1cdea19
parent849fdb2dfd099502ca37d1e55ec80762b65dcd96
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix: seed section workers with the existing offline policy

Seed section synchronization with the current Work Offline policy. Reconcile mobile policy while registering open sections, preserving replacement inheritance and explicit Sync Now. Release upgraded section references after unlocking to keep replica teardown outside the registry lock. Fixes #135 Assisted-by: gpt-6.1-sol

26 files changed, 549 insertions(+), 99 deletions(-)

crates/mobile/src/library.rs+22-3
...@@ -631,11 +631,13 @@ impl Library {...@@ -631,11 +631,13 @@ impl Library {
631 path: &str,631 path: &str,
632 notify: impl Fn() + Send + 'static,632 notify: impl Fn() + Send + 'static,
633 ) -> Result<session::Section> {633 ) -> Result<session::Section> {
634 let offline = self.offline.load(Ordering::Relaxed);
634 match &self.place {635 match &self.place {
635 Place::File(file) => Ok(session::Section::open_with(636 Place::File(file) => Ok(session::Section::open_with(
636 file,637 file,
637 &self.cache,638 &self.cache,
638 |file| Ok(Coordinated(file.to_owned())),639 |file| Ok(Coordinated(file.to_owned())),
640 offline,
639 notify,641 notify,
640 )?),642 )?),
641 Place::Folder(_) => {643 Place::Folder(_) => {
...@@ -643,8 +645,10 @@ impl Library {...@@ -643,8 +645,10 @@ impl Library {
643 let notebook = notebook.as_ref().ok_or("A lone section has no notebook")?;645 let notebook = notebook.as_ref().ok_or("A lone section has no notebook")?;
644 let connect = |file: &Path| Ok(Coordinated(file.to_owned()));646 let connect = |file: &Path| Ok(Coordinated(file.to_owned()));
645 Ok(match self.unlocked().get(path) {647 Ok(match self.unlocked().get(path) {
646 Some(key) => notebook.section_unlocked_with(path, key, connect, notify)?,648 Some(key) => {
647 None => notebook.section_with(path, connect, notify)?,649 notebook.section_unlocked_with(path, key, connect, offline, notify)?
650 }
651 None => notebook.section_with(path, connect, offline, notify)?,
648 })652 })
649 }653 }
650 Place::Share { server, root, .. } => {654 Place::Share { server, root, .. } => {
...@@ -682,6 +686,7 @@ impl Library {...@@ -682,6 +686,7 @@ impl Library {
682 replica,686 replica,
683 LIMIT,687 LIMIT,
684 move || server.connect(),688 move || server.connect(),
689 offline,
685 notify,690 notify,
686 )?)691 )?)
687 }692 }
...@@ -857,6 +862,9 @@ impl Section {...@@ -857,6 +862,9 @@ impl Section {
857 .open862 .open
858 .lock()863 .lock()
859 .unwrap_or_else(|error| error.into_inner());864 .unwrap_or_else(|error| error.into_inner());
865 shared
866 .section
867 .set_offline(library.offline.load(Ordering::Relaxed));
860 open.push((path.to_owned(), Arc::downgrade(&shared)));868 open.push((path.to_owned(), Arc::downgrade(&shared)));
861 drop(open);869 drop(open);
862 Ok(Self { shared, library })870 Ok(Self { shared, library })
...@@ -1785,13 +1793,24 @@ pub extern "C" fn sb_library_sync_status(library: &Library) -> *mut c_char {...@@ -1785,13 +1793,24 @@ pub extern "C" fn sb_library_sync_status(library: &Library) -> *mut c_char {
1785/// `sb_library_sync_now`. Sections opened later follow.1793/// `sb_library_sync_now`. Sections opened later follow.
1786#[unsafe(no_mangle)]1794#[unsafe(no_mangle)]
1787pub extern "C" fn sb_library_set_offline(library: &Library, offline: bool) {1795pub extern "C" fn sb_library_set_offline(library: &Library, offline: bool) {
1796 let mut open = library
1797 .open
1798 .lock()
1799 .unwrap_or_else(|error| error.into_inner());
1788 library.offline.store(offline, Ordering::Relaxed);1800 library.offline.store(offline, Ordering::Relaxed);
1789 if let Some(background) = &library.background {1801 if let Some(background) = &library.background {
1790 background.set_offline(offline);1802 background.set_offline(offline);
1791 }1803 }
1792 for (_, shared) in library.open_sections() {1804 open.retain(|(_, shared)| shared.strong_count() > 0);
1805 let sections: Vec<_> = open
1806 .iter()
1807 .filter_map(|(_, shared)| shared.upgrade())
1808 .collect();
1809 for shared in &sections {
1793 shared.section.set_offline(offline);1810 shared.section.set_offline(offline);
1794 }1811 }
1812 // Releasing the last Shared can join its replica's threads.
1813 drop(open);
1795}1814}
17961815
1797/// Checks the sections at or below `path`, `/`-separated from the notebook's folder, as the1816/// Checks the sections at or below `path`, `/`-separated from the notebook's folder, as the
crates/mobile/src/tests.rs+131-1
...@@ -376,6 +376,91 @@ fn cached_cloud_sections_reopen_with_offline_edits_after_eviction() {...@@ -376,6 +376,91 @@ fn cached_cloud_sections_reopen_with_offline_edits_after_eviction() {
376 assert_eq!(stored.page(space).unwrap().color, Some(0x00ef_cdab));376 assert_eq!(stored.page(space).unwrap().color, Some(0x00ef_cdab));
377}377}
378378
379#[test]
380fn reopening_a_reachable_source_while_offline_retains_its_original_queue() {
381 extern "C" fn wake(_: usize) {}
382
383 for lone in [false, true] {
384 let directory = tempfile::tempdir().unwrap();
385 let root = directory.path().join("notebook");
386 let cache = directory.path().join("cache");
387 let creation = onestore::PageCreation::new(None, Some("Original"), "Fixture").unwrap();
388 let notebook =
389 notebook::session::Notebook::create(&root, &cache, 0x00f0_a0c0, &creation).unwrap();
390 let path = notebook.catalog().sections[0].path.clone();
391 drop(notebook);
392 let file = root.join(&path);
393 let source = std::fs::read(&file).unwrap();
394 let (location, path) = if lone {
395 (file.clone(), file.to_string_lossy().into_owned())
396 } else {
397 (root, path)
398 };
399 let library = Arc::new(Library::open(&location, &cache, false).unwrap());
400 sb_library_set_offline(&library, true);
401 let section = open(&library, &path);
402 let creation =
403 onestore::PageCreation::new(None, Some("Mobile offline reopen marker"), "Fixture")
404 .unwrap();
405 section
406 .shared
407 .apply(Edit {
408 at: library::filetime(),
409 ops: vec![Op::Section(onestore::op::SectionOp::Create(creation))],
410 })
411 .unwrap();
412 section.shared.section.replica().written().unwrap();
413 let pending = section.shared.section.pending().unwrap();
414 assert_eq!(pending.len(), 1);
415 let shared = Arc::clone(&section.shared);
416 drop(section);
417 Arc::into_inner(shared).unwrap().section.close().unwrap();
418 let path = std::ffi::CString::new(path).unwrap();
419 for reopen in 0..2 {
420 let mut error = std::ptr::null_mut();
421 let raw = unsafe {
422 library::sb_section_open(
423 &library,
424 path.as_ptr(),
425 c"Fixture".as_ptr(),
426 wake,
427 0,
428 &mut error,
429 )
430 };
431 assert!(error.is_null());
432 assert!(!raw.is_null());
433 let section = unsafe { Box::from_raw(raw) };
434 for _ in 0..20 {
435 assert_eq!(section.shared.section.pending().unwrap(), pending);
436 assert_eq!(std::fs::read(&file).unwrap(), source);
437 std::thread::sleep(Duration::from_millis(10));
438 }
439 if reopen == 1 {
440 sb_library_sync_now(&library);
441 assert!(eventually(|| section
442 .shared
443 .section
444 .pending()
445 .unwrap()
446 .is_empty()));
447 let pages =
448 notebook::session::stored_pages(&std::fs::read(&file).unwrap()).unwrap();
449 assert_eq!(
450 pages
451 .iter()
452 .filter(|page| page.page.title == "Mobile offline reopen marker")
453 .count(),
454 1
455 );
456 }
457 let shared = Arc::clone(&section.shared);
458 drop(section);
459 Arc::into_inner(shared).unwrap().section.close().unwrap();
460 }
461 }
462}
463
379#[test]464#[test]
380fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin() {465fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin() {
381 let (directory, root) = copy("m6/native-features-01/notebook");466 let (directory, root) = copy("m6/native-features-01/notebook");
...@@ -666,8 +751,53 @@ fn a_notebook_on_a_share_saves_and_opens_offline() {...@@ -666,8 +751,53 @@ fn a_notebook_on_a_share_saves_and_opens_offline() {
666 let arena = onestore::Arena::default();751 let arena = onestore::Arena::default();
667 let stored = onestore::Section::open(&arena, published).unwrap();752 let stored = onestore::Section::open(&arena, published).unwrap();
668 assert!(canvas::search::page_text(&stored.page(canvas.space).unwrap()).contains("Shared "));753 assert!(canvas::search::page_text(&stored.page(canvas.space).unwrap()).contains("Shared "));
754 sb_library_set_offline(&library, true);
755 let source = client
756 .read(&format!("{root}/Features.one"), 1 << 26)
757 .unwrap();
758 canvas.insert("Reachable offline marker ".into()).unwrap();
759 section
760 .shared
761 .apply(canvas.edit().unwrap().unwrap())
762 .unwrap();
763 section.shared.section.replica().written().unwrap();
764 let pending = section.shared.section.pending().unwrap();
765 assert_eq!(pending.len(), 1);
766 let page_space = canvas.space;
669 let shared = Arc::clone(&section.shared);767 let shared = Arc::clone(&section.shared);
670 drop((canvas, section, library));768 drop((canvas, section));
769 Arc::into_inner(shared).unwrap().section.close().unwrap();
770 let section = open(&library, "Features.one");
771 for _ in 0..10 {
772 assert_eq!(section.shared.section.pending().unwrap(), pending);
773 assert_eq!(
774 client
775 .read(&format!("{root}/Features.one"), 1 << 26)
776 .unwrap(),
777 source
778 );
779 std::thread::sleep(Duration::from_millis(20));
780 }
781 sb_library_sync_now(&library);
782 assert!(eventually(|| section
783 .shared
784 .section
785 .pending()
786 .unwrap()
787 .is_empty()));
788 let arena = onestore::Arena::default();
789 let published = client
790 .read(&format!("{root}/Features.one"), 1 << 26)
791 .unwrap();
792 let stored = onestore::Section::open(&arena, published).unwrap();
793 assert_eq!(
794 canvas::search::page_text(&stored.page(page_space).unwrap())
795 .matches("Reachable offline marker ")
796 .count(),
797 1
798 );
799 let shared = Arc::clone(&section.shared);
800 drop((section, library));
671 Arc::into_inner(shared).unwrap().section.close().unwrap();801 Arc::into_inner(shared).unwrap().section.close().unwrap();
672 // The same notebook while its server does not answer: a closed port on the lab's host.802 // The same notebook while its server does not answer: a closed port on the lab's host.
673 let host = address.split(':').next().unwrap();803 let host = address.split(':').next().unwrap();
crates/notebook/README.md+8-7
...@@ -52,14 +52,15 @@ thread rereads the queue....@@ -52,14 +52,15 @@ thread rereads the queue.
52`session::Notebook::open(root, cache_dir)` discovers a notebook directory, reading only52`session::Notebook::open(root, cache_dir)` discovers a notebook directory, reading only
53the files its folders list otherwise than when last opened (`discover::Cache`, kept under53the files its folders list otherwise than when last opened (`discover::Cache`, kept under
54`cache_dir/listings`), and54`cache_dir/listings`), and
55`session::Section::open(file, cache_dir, notify)` opens one section file through55`session::Section::open(file, cache_dir, offline, notify)` opens one section file through
56a replica named by the file's location and the section's document identity, so the same56a replica named by the file's location and the section's document identity, so the same
57file reopens the same queue after a relaunch and a copy of it elsewhere has its own57file reopens the same queue after a relaunch and a copy of it elsewhere has its own
58(`notebook::location`: `folder`, `local`, `smb`, `moved` for a notebook the app moves,58(`notebook::location`: `folder`, `local`, `smb`, `moved` for a notebook the app moves,
59and `forget` for one it deletes).59and `forget` for one it deletes).
60`Notebook::section(path, notify)` opens a mounted notebook's section; a replica already60`Notebook::section(path, offline, notify)` opens a mounted notebook's section; a replica already
61named by the identity its discovery read resumes without reading the file, which the61named by the identity its discovery read resumes without reading the file, which the
62worker checks next, as on a share.62worker checks next, as on a share. `offline` seeds Work Offline before the worker starts;
63explicit `wake()` still synchronizes while offline.
63A section is `Send + Sync`: `apply(author, edit)` returns at once and reports a refusal as `Event::Rejected`; `events()` drains64A section is `Send + Sync`: `apply(author, edit)` returns at once and reports a refusal as `Event::Rejected`; `events()` drains
64remote changes (`Changed(spaces)`), publication outcomes, unreachable files and65remote changes (`Changed(spaces)`), publication outcomes, unreachable files and
65failures; `notify` runs on a background thread whenever an event waits.66failures; `notify` runs on a background thread whenever an event waits.
...@@ -96,9 +97,9 @@ changed, `wake` and `set_offline` follow Sync Now and Work Offline, and `discard...@@ -96,9 +97,9 @@ changed, `wake` and `set_offline` follow Sync Now and Work Offline, and `discard
96thread and deletes the replicas holding nothing unpublished, as when the notebook closes.97thread and deletes the replicas holding nothing unpublished, as when the notebook closes.
97`Notebook::replica_path(path)` names a section's replica for either kind of notebook.98`Notebook::replica_path(path)` names a section's replica for either kind of notebook.
9899
99`Section::resume(file, replica, notify)` starts from an owned `Replica` without100`Section::resume(file, replica, offline, notify)` starts from an owned `Replica` without
100consulting the remote file; with the `smb` feature, `Section::resume_smb(path,101consulting the remote file; with the `smb` feature, `Section::resume_smb(path,
101replica, limit, connect, notify)` binds a share-relative path, `connect` running on102replica, limit, connect, offline, notify)` binds a share-relative path, `connect` running on
102the worker again after transport failure.103the worker again after transport failure.
103104
104## Reconciliation105## Reconciliation
...@@ -358,7 +359,7 @@ sections it has not opened this way, and unread changes leave out the reader's o...@@ -358,7 +359,7 @@ sections it has not opened this way, and unread changes leave out the reader's o
358359
359A password-protected section lists as `Locked`. `Notebook::unlock(path, password)` opens360A password-protected section lists as `Locked`. `Notebook::unlock(path, password)` opens
360its key (a wrong password is `Error::Protected(PasswordMismatch)`), and361its key (a wrong password is `Error::Protected(PasswordMismatch)`), and
361`section_unlocked(path, &key, notify)` (`section_unlocked_with`, or362`section_unlocked(path, &key, offline, notify)` (`section_unlocked_with`, or
362`Replica::open_or_create(path, Some(&key), source)` for a host that opens replicas itself)363`Replica::open_or_create(path, Some(&key), source)` for a host that opens replicas itself)
363opens it as any section opens: edits queue, publish, merge and become conflict pages as an ordinary section's do,364opens it as any section opens: edits queue, publish, merge and become conflict pages as an ordinary section's do,
364each revision sealed under the section's key. The replica keeps none of it in the clear:365each revision sealed under the section's key. The replica keeps none of it in the clear:
...@@ -567,7 +568,7 @@ on, and `stop()` lets every guest go. `join(hello, code, password, reach, relay)...@@ -567,7 +568,7 @@ on, and `stop()` lets every guest go. `join(hello, code, password, reach, relay)
567`Welcome` (the share and its secret), or a `Refusal` saying why not: a wrong code, no one568`Welcome` (the share and its secret), or a `Refusal` saying why not: a wrong code, no one
568sharing it, an expired code, too many wrong tries, or no relay. `Guest::start` joins the share;569sharing it, an expired code, too many wrong tries, or no relay. `Guest::start` joins the share;
569`Notebook::open_hosted(guest, cache)`, `Background::hosted(guest, notify)` and570`Notebook::open_hosted(guest, cache)`, `Background::hosted(guest, notify)` and
570`Section::resume_hosted(path, replica, guest, notify)` then work as on a share, with571`Section::resume_hosted(path, replica, guest, offline, notify)` then work as on a share, with
571`Guest::host()` and `stopped()` for whether the host is there and still sharing.572`Guest::host()` and `stopped()` for whether the host is there and still sharing.
572573
573## Queue measurement574## Queue measurement
crates/notebook/examples/browser_receipts.rs+1-1
...@@ -190,7 +190,7 @@ mod web {...@@ -190,7 +190,7 @@ mod web {
190 )190 )
191 .unwrap();191 .unwrap();
192 let original_cache = notebook.replica_path("New Section 1.one").unwrap();192 let original_cache = notebook.replica_path("New Section 1.one").unwrap();
193 let section = notebook.section("New Section 1.one", || {}).unwrap();193 let section = notebook.section("New Section 1.one", false, || {}).unwrap();
194 section.set_offline(true);194 section.set_offline(true);
195 let (space, text_id, _) = text(&section.replica().snapshot().unwrap());195 let (space, text_id, _) = text(&section.replica().snapshot().unwrap());
196 let replica = Arc::clone(section.replica());196 let replica = Arc::clone(section.replica());
crates/notebook/examples/live_crowd.rs+8-2
...@@ -191,8 +191,14 @@ fn join(name: &str, code: &str, url: &str, cache: &Path) -> Open {...@@ -191,8 +191,14 @@ fn join(name: &str, code: &str, url: &str, cache: &Path) -> Open {
191 let image = notebook.read_section("Garden.one").unwrap();191 let image = notebook.read_section("Garden.one").unwrap();
192 let file = onestore::Header::parse(&image[..1024]).unwrap().file_id;192 let file = onestore::Header::parse(&image[..1024]).unwrap().file_id;
193 let replica = Replica::open_or_create(&replica, None, || Ok(image)).unwrap();193 let replica = Replica::open_or_create(&replica, None, || Ok(image)).unwrap();
194 let section =194 let section = Section::resume_hosted(
195 Section::resume_hosted("Garden.one".into(), replica, Arc::clone(&guest), || {}).unwrap();195 "Garden.one".into(),
196 replica,
197 Arc::clone(&guest),
198 false,
199 || {},
200 )
201 .unwrap();
196 background.hold("Garden.one", &section);202 background.hold("Garden.one", &section);
197 Open {203 Open {
198 guest,204 guest,
crates/notebook/examples/live_latency.rs+8-2
...@@ -69,8 +69,14 @@ fn guest(...@@ -69,8 +69,14 @@ fn guest(
69 std::fs::create_dir_all(replica.parent().unwrap()).unwrap();69 std::fs::create_dir_all(replica.parent().unwrap()).unwrap();
70 let replica =70 let replica =
71 Replica::open_or_create(&replica, None, || notebook.read_section("Garden.one")).unwrap();71 Replica::open_or_create(&replica, None, || notebook.read_section("Garden.one")).unwrap();
72 let section =72 let section = Section::resume_hosted(
73 Section::resume_hosted("Garden.one".into(), replica, Arc::clone(&guest), || {}).unwrap();73 "Garden.one".into(),
74 replica,
75 Arc::clone(&guest),
76 false,
77 || {},
78 )
79 .unwrap();
74 background.hold("Garden.one", &section);80 background.hold("Garden.one", &section);
75 (81 (
76 guest,82 guest,
crates/notebook/examples/open_probe.rs+1-1
...@@ -133,7 +133,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -133,7 +133,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
133 println!("section {} bytes", source.len());133 println!("section {} bytes", source.len());
134 for label in ["fresh cache", "reopened cache"] {134 for label in ["fresh cache", "reopened cache"] {
135 let start = Instant::now();135 let start = Instant::now();
136 let section = Section::open(&file, &cache, || {})?;136 let section = Section::open(&file, &cache, false, || {})?;
137 let opened = start.elapsed();137 let opened = start.elapsed();
138 let pages = section.pages()?;138 let pages = section.pages()?;
139 let listed = start.elapsed();139 let listed = start.elapsed();
crates/notebook/examples/save_probe.rs+1-1
...@@ -21,7 +21,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -21,7 +21,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
21 let directory = tempfile::tempdir()?;21 let directory = tempfile::tempdir()?;
22 let file = directory.path().join("probe.one");22 let file = directory.path().join("probe.one");
23 std::fs::write(&file, std::fs::read(source)?)?;23 std::fs::write(&file, std::fs::read(source)?)?;
24 let section = Section::open(&file, directory.path().join("cache"), || {})?;24 let section = Section::open(&file, directory.path().join("cache"), false, || {})?;
25 let words = [25 let words = [
26 "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog",26 "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog",
27 ];27 ];
crates/notebook/examples/session_client.rs+1-1
...@@ -115,7 +115,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -115,7 +115,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
115 return Err("Usage: session_client SECTION CACHE_DIR LABEL".into());115 return Err("Usage: session_client SECTION CACHE_DIR LABEL".into());
116 };116 };
117 let (notify, notified) = mpsc::channel();117 let (notify, notified) = mpsc::channel();
118 let section = Section::open(file, cache, move || {118 let section = Section::open(file, cache, false, move || {
119 let _ = notify.send(());119 let _ = notify.send(());
120 })?;120 })?;
121 let pending = section.pending()?;121 let pending = section.pending()?;
crates/notebook/src/background.rs+1-1
...@@ -1479,7 +1479,7 @@ mod tests {...@@ -1479,7 +1479,7 @@ mod tests {
1479 let source = onestore::create_section("Held.one", "", "Author").unwrap();1479 let source = onestore::create_section("Held.one", "", "Author").unwrap();
1480 fs::write(&file, &source).unwrap();1480 fs::write(&file, &source).unwrap();
1481 let (changed, changes) = mpsc::channel();1481 let (changed, changes) = mpsc::channel();
1482 let section = Section::open(&file, directory.path().join("cache"), move || {1482 let section = Section::open(&file, directory.path().join("cache"), false, move || {
1483 let _ = changed.send(());1483 let _ = changed.send(());
1484 })1484 })
1485 .unwrap();1485 .unwrap();
crates/notebook/src/session.rs+51-12
...@@ -1748,8 +1748,18 @@ impl Notebook {...@@ -1748,8 +1748,18 @@ impl Notebook {
1748 }1748 }
17491749
1750 /// Opens a section of a mounted notebook by its catalog path.1750 /// Opens a section of a mounted notebook by its catalog path.
1751 pub fn section(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result<Section> {1751 pub fn section(
1752 self.section_with(path, |file| Ok(FileRemote(file.to_owned())), notify)1752 &self,
1753 path: &str,
1754 offline: bool,
1755 notify: impl Fn() + Send + 'static,
1756 ) -> Result<Section> {
1757 self.section_with(
1758 path,
1759 |file| Ok(FileRemote(file.to_owned())),
1760 offline,
1761 notify,
1762 )
1753 }1763 }
17541764
1755 /// `section`, reading and publishing through the remote `connect` makes for its file1765 /// `section`, reading and publishing through the remote `connect` makes for its file
...@@ -1758,9 +1768,10 @@ impl Notebook {...@@ -1758,9 +1768,10 @@ impl Notebook {
1758 &self,1768 &self,
1759 path: &str,1769 path: &str,
1760 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,1770 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
1771 offline: bool,
1761 notify: impl Fn() + Send + 'static,1772 notify: impl Fn() + Send + 'static,
1762 ) -> Result<Section> {1773 ) -> Result<Section> {
1763 self.open_section(path, None, connect, notify)1774 self.open_section(path, None, connect, offline, notify)
1764 }1775 }
17651776
1766 /// `section` for a password-protected section, under the `key` `unlock` gave.1777 /// `section` for a password-protected section, under the `key` `unlock` gave.
...@@ -1768,9 +1779,16 @@ impl Notebook {...@@ -1768,9 +1779,16 @@ impl Notebook {
1768 &self,1779 &self,
1769 path: &str,1780 path: &str,
1770 key: &protected::Key,1781 key: &protected::Key,
1782 offline: bool,
1771 notify: impl Fn() + Send + 'static,1783 notify: impl Fn() + Send + 'static,
1772 ) -> Result<Section> {1784 ) -> Result<Section> {
1773 self.section_unlocked_with(path, key, |file| Ok(FileRemote(file.to_owned())), notify)1785 self.section_unlocked_with(
1786 path,
1787 key,
1788 |file| Ok(FileRemote(file.to_owned())),
1789 offline,
1790 notify,
1791 )
1774 }1792 }
17751793
1776 /// `section_with` for a password-protected section, under its `key`.1794 /// `section_with` for a password-protected section, under its `key`.
...@@ -1779,9 +1797,10 @@ impl Notebook {...@@ -1779,9 +1797,10 @@ impl Notebook {
1779 path: &str,1797 path: &str,
1780 key: &protected::Key,1798 key: &protected::Key,
1781 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,1799 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
1800 offline: bool,
1782 notify: impl Fn() + Send + 'static,1801 notify: impl Fn() + Send + 'static,
1783 ) -> Result<Section> {1802 ) -> Result<Section> {
1784 self.open_section(path, Some(key), connect, notify)1803 self.open_section(path, Some(key), connect, offline, notify)
1785 }1804 }
17861805
1787 fn open_section<R: Remote + 'static>(1806 fn open_section<R: Remote + 'static>(
...@@ -1789,6 +1808,7 @@ impl Notebook {...@@ -1789,6 +1808,7 @@ impl Notebook {
1789 path: &str,1808 path: &str,
1790 key: Option<&protected::Key>,1809 key: Option<&protected::Key>,
1791 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,1810 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
1811 offline: bool,
1792 notify: impl Fn() + Send + 'static,1812 notify: impl Fn() + Send + 'static,
1793 ) -> Result<Section> {1813 ) -> Result<Section> {
1794 let section = self.section_path(path)?;1814 let section = self.section_path(path)?;
...@@ -1823,14 +1843,14 @@ impl Notebook {...@@ -1823,14 +1843,14 @@ impl Notebook {
1823 if fs::metadata(&cache).is_ok() {1843 if fs::metadata(&cache).is_ok() {
1824 let (replica, remote, mut connect) =1844 let (replica, remote, mut connect) =
1825 (Replica::open(&cache)?, file.clone(), connect);1845 (Replica::open(&cache)?, file.clone(), connect);
1826 return Section::start(file, replica, move || connect(&remote), notify);1846 return Section::start(file, replica, move || connect(&remote), offline, notify);
1827 }1847 }
1828 }1848 }
1829 let replica = |identity: &[u8; 16], _: &[u8]| {1849 let replica = |identity: &[u8; 16], _: &[u8]| {
1830 fs::create_dir_all(&replicas)?;1850 fs::create_dir_all(&replicas)?;
1831 Ok(replica_file(&replicas, identity))1851 Ok(replica_file(&replicas, identity))
1832 };1852 };
1833 Section::open_in(file, key, replica, connect, notify)1853 Section::open_in(file, key, replica, connect, offline, notify)
1834 }1854 }
1835}1855}
18361856
...@@ -2217,9 +2237,16 @@ impl Section {...@@ -2217,9 +2237,16 @@ impl Section {
2217 pub fn open(2237 pub fn open(
2218 file: impl AsRef<Path>,2238 file: impl AsRef<Path>,
2219 cache: impl AsRef<Path>,2239 cache: impl AsRef<Path>,
2240 offline: bool,
2220 notify: impl Fn() + Send + 'static,2241 notify: impl Fn() + Send + 'static,
2221 ) -> Result<Self> {2242 ) -> Result<Self> {
2222 Self::open_with(file, cache, |file| Ok(FileRemote(file.to_owned())), notify)2243 Self::open_with(
2244 file,
2245 cache,
2246 |file| Ok(FileRemote(file.to_owned())),
2247 offline,
2248 notify,
2249 )
2223 }2250 }
22242251
2225 /// `open`, reading and publishing the file through the remote `connect` makes for it,2252 /// `open`, reading and publishing the file through the remote `connect` makes for it,
...@@ -2229,6 +2256,7 @@ impl Section {...@@ -2229,6 +2256,7 @@ impl Section {
2229 file: impl AsRef<Path>,2256 file: impl AsRef<Path>,
2230 cache: impl AsRef<Path>,2257 cache: impl AsRef<Path>,
2231 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,2258 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
2259 offline: bool,
2232 notify: impl Fn() + Send + 'static,2260 notify: impl Fn() + Send + 'static,
2233 ) -> Result<Self> {2261 ) -> Result<Self> {
2234 let file = fs::canonicalize(file)?;2262 let file = fs::canonicalize(file)?;
...@@ -2239,7 +2267,7 @@ impl Section {...@@ -2239,7 +2267,7 @@ impl Section {
2239 })?;2267 })?;
2240 Ok(replica_file(&folder, identity))2268 Ok(replica_file(&folder, identity))
2241 };2269 };
2242 Self::open_in(file, None, replicas, connect, notify)2270 Self::open_in(file, None, replicas, connect, offline, notify)
2243 }2271 }
22442272
2245 /// Opens the canonical section `file`, a protected one under `key`, through the replica2273 /// Opens the canonical section `file`, a protected one under `key`, through the replica
...@@ -2249,6 +2277,7 @@ impl Section {...@@ -2249,6 +2277,7 @@ impl Section {
2249 key: Option<&protected::Key>,2277 key: Option<&protected::Key>,
2250 replica: impl FnOnce(&[u8; 16], &[u8]) -> Result<PathBuf>,2278 replica: impl FnOnce(&[u8; 16], &[u8]) -> Result<PathBuf>,
2251 mut connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,2279 mut connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
2280 offline: bool,
2252 notify: impl Fn() + Send + 'static,2281 notify: impl Fn() + Send + 'static,
2253 ) -> Result<Self> {2282 ) -> Result<Self> {
2254 let source = connect(&file)?.read()?;2283 let source = connect(&file)?.read()?;
...@@ -2257,7 +2286,7 @@ impl Section {...@@ -2257,7 +2286,7 @@ impl Section {
2257 let cache = replica(&identity.guid, &source)?;2286 let cache = replica(&identity.guid, &source)?;
2258 let replica = Replica::open_or_create(&cache, key, || Ok(source))?;2287 let replica = Replica::open_or_create(&cache, key, || Ok(source))?;
2259 let remote = file.clone();2288 let remote = file.clone();
2260 Self::start(file, replica, move || connect(&remote), notify)2289 Self::start(file, replica, move || connect(&remote), offline, notify)
2261 }2290 }
22622291
2263 /// Resumes an owned local replica without reading the publication target.2292 /// Resumes an owned local replica without reading the publication target.
...@@ -2265,6 +2294,7 @@ impl Section {...@@ -2265,6 +2294,7 @@ impl Section {
2265 pub fn resume(2294 pub fn resume(
2266 file: impl AsRef<Path>,2295 file: impl AsRef<Path>,
2267 replica: Replica,2296 replica: Replica,
2297 offline: bool,
2268 notify: impl Fn() + Send + 'static,2298 notify: impl Fn() + Send + 'static,
2269 ) -> Result<Self> {2299 ) -> Result<Self> {
2270 let file = fs::absolute(file)?;2300 let file = fs::absolute(file)?;
...@@ -2273,6 +2303,7 @@ impl Section {...@@ -2273,6 +2303,7 @@ impl Section {
2273 file,2303 file,
2274 replica,2304 replica,
2275 move || Ok(FileRemote(remote.clone())),2305 move || Ok(FileRemote(remote.clone())),
2306 offline,
2276 notify,2307 notify,
2277 )2308 )
2278 }2309 }
...@@ -2285,12 +2316,14 @@ impl Section {...@@ -2285,12 +2316,14 @@ impl Section {
2285 replica: Replica,2316 replica: Replica,
2286 limit: usize,2317 limit: usize,
2287 mut connect: impl FnMut() -> io::Result<crate::smb::Client> + Send + 'static,2318 mut connect: impl FnMut() -> io::Result<crate::smb::Client> + Send + 'static,
2319 offline: bool,
2288 notify: impl Fn() + Send + 'static,2320 notify: impl Fn() + Send + 'static,
2289 ) -> Result<Self> {2321 ) -> Result<Self> {
2290 Self::start(2322 Self::start(
2291 PathBuf::from(&path),2323 PathBuf::from(&path),
2292 replica,2324 replica,
2293 move || Ok(crate::SmbRemote::new(connect()?, path.clone(), limit)),2325 move || Ok(crate::SmbRemote::new(connect()?, path.clone(), limit)),
2326 offline,
2294 notify,2327 notify,
2295 )2328 )
2296 }2329 }
...@@ -2301,12 +2334,14 @@ impl Section {...@@ -2301,12 +2334,14 @@ impl Section {
2301 path: String,2334 path: String,
2302 replica: Replica,2335 replica: Replica,
2303 guest: Arc<crate::live::share::Guest>,2336 guest: Arc<crate::live::share::Guest>,
2337 offline: bool,
2304 notify: impl Fn() + Send + 'static,2338 notify: impl Fn() + Send + 'static,
2305 ) -> Result<Self> {2339 ) -> Result<Self> {
2306 Self::start(2340 Self::start(
2307 PathBuf::from(&path),2341 PathBuf::from(&path),
2308 replica,2342 replica,
2309 move || Ok(crate::live::share::HostedRemote::new(&guest, &path)),2343 move || Ok(crate::live::share::HostedRemote::new(&guest, &path)),
2344 offline,
2310 notify,2345 notify,
2311 )2346 )
2312 }2347 }
...@@ -2315,6 +2350,7 @@ impl Section {...@@ -2315,6 +2350,7 @@ impl Section {
2315 file: PathBuf,2350 file: PathBuf,
2316 replica: Replica,2351 replica: Replica,
2317 connect: impl FnMut() -> io::Result<R> + Send + 'static,2352 connect: impl FnMut() -> io::Result<R> + Send + 'static,
2353 offline: bool,
2318 notify: impl Fn() + Send + 'static,2354 notify: impl Fn() + Send + 'static,
2319 ) -> Result<Self> {2355 ) -> Result<Self> {
2320 let replica = Arc::new(replica);2356 let replica = Arc::new(replica);
...@@ -2335,19 +2371,21 @@ impl Section {...@@ -2335,19 +2371,21 @@ impl Section {
2335 notify,2371 notify,
2336 observed,2372 observed,
2337 };2373 };
2338 section.worker = Some(section.start_worker(connect, None)?);2374 section.worker = Some(section.start_worker(connect, offline, None)?);
2339 Ok(section)2375 Ok(section)
2340 }2376 }
23412377
2342 fn start_worker<R: Remote + 'static>(2378 fn start_worker<R: Remote + 'static>(
2343 &self,2379 &self,
2344 connect: impl FnMut() -> io::Result<R> + Send + 'static,2380 connect: impl FnMut() -> io::Result<R> + Send + 'static,
2381 offline: bool,
2345 replacing: Option<&SyncWorker>,2382 replacing: Option<&SyncWorker>,
2346 ) -> Result<SyncWorker> {2383 ) -> Result<SyncWorker> {
2347 let (sender, notify) = (self.sender.clone(), Arc::clone(&self.notify));2384 let (sender, notify) = (self.sender.clone(), Arc::clone(&self.notify));
2348 let observed = Arc::clone(&self.observed);2385 let observed = Arc::clone(&self.observed);
2349 Ok(self.replica.start_sync_replacing(2386 Ok(self.replica.start_sync_replacing(
2350 Duration::from_secs(2),2387 Duration::from_secs(2),
2388 offline,
2351 connect,2389 connect,
2352 move |result| {2390 move |result| {
2353 let error = result.as_ref().err().map(reached);2391 let error = result.as_ref().err().map(reached);
...@@ -2413,7 +2451,8 @@ impl Section {...@@ -2413,7 +2451,8 @@ impl Section {
2413 worker.cancel();2451 worker.cancel();
2414 }2452 }
2415 self.observed = Arc::new(Mutex::new(None));2453 self.observed = Arc::new(Mutex::new(None));
2416 self.worker = Some(self.start_worker(move || connect(&target), previous.as_ref())?);2454 self.worker =
2455 Some(self.start_worker(move || connect(&target), false, previous.as_ref())?);
2417 self.file = file;2456 self.file = file;
2418 Ok(())2457 Ok(())
2419 }2458 }
crates/notebook/src/session/tests.rs+126-3
...@@ -9,14 +9,18 @@ fn cached_sections_refuse_links_outside_the_notebook_and_dangling_links() {...@@ -9,14 +9,18 @@ fn cached_sections_refuse_links_outside_the_notebook_and_dangling_links() {
9 let creation = PageCreation::new(None, Some("Mine"), "Fixture").unwrap();9 let creation = PageCreation::new(None, Some("Mine"), "Fixture").unwrap();
10 let notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap();10 let notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap();
11 let path = &notebook.catalog().sections[0].path;11 let path = &notebook.catalog().sections[0].path;
12 notebook.section(path, || {}).unwrap().close().unwrap();12 notebook
13 .section(path, false, || {})
14 .unwrap()
15 .close()
16 .unwrap();
13 let file = folder.join(path);17 let file = folder.join(path);
14 let outside = temporary.path().join("outside.one");18 let outside = temporary.path().join("outside.one");
15 fs::rename(&file, &outside).unwrap();19 fs::rename(&file, &outside).unwrap();
16 std::os::unix::fs::symlink(&outside, &file).unwrap();20 std::os::unix::fs::symlink(&outside, &file).unwrap();
17 assert!(notebook.section(path, || {}).is_err());21 assert!(notebook.section(path, false, || {}).is_err());
18 fs::remove_file(outside).unwrap();22 fs::remove_file(outside).unwrap();
19 assert!(notebook.section(path, || {}).is_err());23 assert!(notebook.section(path, false, || {}).is_err());
20}24}
2125
22#[test]26#[test]
...@@ -501,3 +505,122 @@ fn recycling_rejects_unconfirmed_appends_and_can_retry_each_stage() {...@@ -501,3 +505,122 @@ fn recycling_rejects_unconfirmed_appends_and_can_retry_each_stage() {
501 assert_eq!(fs::read(folder.join(&path)).unwrap(), source);505 assert_eq!(fs::read(folder.join(&path)).unwrap(), source);
502 }506 }
503}507}
508
509#[test]
510fn initially_offline_workers_keep_the_original_queue_until_explicitly_woken() {
511 use std::sync::atomic::{AtomicUsize, Ordering};
512 use std::time::Instant;
513
514 struct Counted {
515 file: FileRemote,
516 calls: Arc<[AtomicUsize; 5]>,
517 }
518 impl Remote for Counted {
519 fn read(&mut self) -> io::Result<Vec<u8>> {
520 self.calls[1].fetch_add(1, Ordering::SeqCst);
521 self.file.read()
522 }
523 fn stamp(&mut self) -> io::Result<Stamp> {
524 self.calls[2].fetch_add(1, Ordering::SeqCst);
525 self.file.stamp()
526 }
527 fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> {
528 self.calls[3].fetch_add(1, Ordering::SeqCst);
529 self.file.publish(transaction)
530 }
531 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> {
532 self.calls[4].fetch_add(1, Ordering::SeqCst);
533 self.file.confirm(base)
534 }
535 }
536
537 for relocate in [false, true] {
538 let temporary = tempfile::tempdir().unwrap();
539 let file = temporary.path().join("Original.one");
540 let cache = temporary.path().join("original.sqlite");
541 let source = onestore::create_section("Original.one", "Original", "Fixture").unwrap();
542 fs::write(&file, &source).unwrap();
543 let replica = Replica::create(&cache, &source).unwrap();
544 let creation =
545 PageCreation::new(None, Some("Offline initialization marker"), "Fixture").unwrap();
546 let id = replica
547 .apply(
548 "Fixture",
549 Edit {
550 at: crate::now(),
551 ops: vec![Op::Section(SectionOp::Create(creation))],
552 },
553 )
554 .unwrap();
555 let pending = replica.pending().unwrap();
556 drop(replica);
557 let calls = Arc::new(std::array::from_fn(|_| AtomicUsize::new(0)));
558 let counter = Arc::clone(&calls);
559 let remote = file.clone();
560 let (notify, notified) = mpsc::channel();
561 let mut section = Section::start(
562 file.clone(),
563 Replica::open(&cache).unwrap(),
564 move || {
565 counter[0].fetch_add(1, Ordering::SeqCst);
566 Ok(Counted {
567 file: FileRemote(remote.clone()),
568 calls: Arc::clone(&counter),
569 })
570 },
571 true,
572 move || {
573 let _ = notify.send(());
574 },
575 )
576 .unwrap();
577 let target = if relocate {
578 let target = temporary.path().join("Moved.one");
579 fs::rename(&file, &target).unwrap();
580 let counter = Arc::clone(&calls);
581 section
582 .relocate_with(&target, move |file| {
583 counter[0].fetch_add(1, Ordering::SeqCst);
584 Ok(Counted {
585 file: FileRemote(file.to_owned()),
586 calls: Arc::clone(&counter),
587 })
588 })
589 .unwrap();
590 target
591 } else {
592 file
593 };
594 let deadline = Instant::now() + Duration::from_millis(300);
595 while Instant::now() < deadline {
596 assert_eq!(section.pending().unwrap(), pending);
597 assert_eq!(fs::read(&target).unwrap(), source);
598 assert!(calls.iter().all(|count| count.load(Ordering::SeqCst) == 0));
599 std::thread::sleep(Duration::from_millis(10));
600 }
601 section.wake();
602 let deadline = Instant::now() + Duration::from_secs(10);
603 loop {
604 notified
605 .recv_timeout(deadline.saturating_duration_since(Instant::now()))
606 .unwrap();
607 if section.events().iter().any(|event| matches!(event,
608 Event::Attempt { id: published, status: EditStatus::Published { .. } } if *published == id)) {
609 break;
610 }
611 }
612 assert!(section.pending().unwrap().is_empty());
613 assert_eq!(calls[0].load(Ordering::SeqCst), 1);
614 assert_eq!(calls[3].load(Ordering::SeqCst), 1);
615 section.close().unwrap();
616 let pages = stored_pages(&fs::read(&target).unwrap()).unwrap();
617 assert_eq!(
618 pages
619 .iter()
620 .filter(|page| page.page.title == "Offline initialization marker")
621 .count(),
622 1
623 );
624 assert!(Replica::open(&cache).unwrap().pending().unwrap().is_empty());
625 }
626}
crates/notebook/src/smb/tests.rs+1-1
...@@ -783,7 +783,7 @@ fn live_structure() {...@@ -783,7 +783,7 @@ fn live_structure() {
783 let header = onestore::Store::parse(&renamed).unwrap().header;783 let header = onestore::Store::parse(&renamed).unwrap().header;
784 assert_eq!(header.ancestor, toc_id);784 assert_eq!(header.ancestor, toc_id);
785 assert_eq!(header.name_crc, 0x6108912a);785 assert_eq!(header.name_crc, 0x6108912a);
786 assert!(fresh.section("Renamed.one", || {}).is_err());786 assert!(fresh.section("Renamed.one", false, || {}).is_err());
787 // A password supersedes the file under writer coordination, never over a newer stamp, and787 // A password supersedes the file under writer coordination, never over a newer stamp, and
788 // while a reader holds it open, as OneNote's readers share it with deletion.788 // while a reader holds it open, as OneNote's readers share it with deletion.
789 let section = format!("{root}/Renamed.one");789 let section = format!("{root}/Renamed.one");
crates/notebook/src/worker.rs+3-1
...@@ -179,12 +179,13 @@ impl Replica {...@@ -179,12 +179,13 @@ impl Replica {
179 F: FnMut() -> io::Result<R> + Send + 'static,179 F: FnMut() -> io::Result<R> + Send + 'static,
180 O: FnMut(&Result<Synced>) + Send + 'static,180 O: FnMut(&Result<Synced>) + Send + 'static,
181 {181 {
182 self.start_sync_replacing(interval, connect, observe, None)182 self.start_sync_replacing(interval, false, connect, observe, None)
183 }183 }
184184
185 pub(crate) fn start_sync_replacing<R, F, O>(185 pub(crate) fn start_sync_replacing<R, F, O>(
186 self: &Arc<Self>,186 self: &Arc<Self>,
187 interval: Duration,187 interval: Duration,
188 offline: bool,
188 mut connect: F,189 mut connect: F,
189 mut observe: O,190 mut observe: O,
190 replacing: Option<&SyncWorker>,191 replacing: Option<&SyncWorker>,
...@@ -214,6 +215,7 @@ impl Replica {...@@ -214,6 +215,7 @@ impl Replica {
214 return Err(io::ErrorKind::WouldBlock.into());215 return Err(io::ErrorKind::WouldBlock.into());
215 }216 }
216 let (signal, receiver) = Signal::new();217 let (signal, receiver) = Signal::new();
218 signal.offline.store(offline, Ordering::Release);
217 if let Some(previous) = previous {219 if let Some(previous) = previous {
218 signal220 signal
219 .offline221 .offline
crates/notebook/tests/copy.rs+1-1
...@@ -152,7 +152,7 @@ fn pages_copy_into_another_section_with_their_content_and_fresh_identities() {...@@ -152,7 +152,7 @@ fn pages_copy_into_another_section_with_their_content_and_fresh_identities() {
152 onestore::create_section("copies.one", "Destination", "Author").unwrap(),152 onestore::create_section("copies.one", "Destination", "Author").unwrap(),
153 )153 )
154 .unwrap();154 .unwrap();
155 let section = Section::open(&file, &cache, || {}).unwrap();155 let section = Section::open(&file, &cache, false, || {}).unwrap();
156 let mut expected = Vec::new();156 let mut expected = Vec::new();
157 let mut grouped = Vec::new();157 let mut grouped = Vec::new();
158 for source in SOURCES {158 for source in SOURCES {
crates/notebook/tests/links.rs+1-1
...@@ -30,7 +30,7 @@ fn internal_links_find_their_page_by_identity() {...@@ -30,7 +30,7 @@ fn internal_links_find_their_page_by_identity() {
30 )30 )
31 .unwrap();31 .unwrap();
32 let mut notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap();32 let mut notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap();
33 let section = notebook.section("First.one", || {}).unwrap();33 let section = notebook.section("First.one", false, || {}).unwrap();
34 let (space, _, _) = section.pages().unwrap()[0];34 let (space, _, _) = section.pages().unwrap()[0];
35 let page = section.page(space).unwrap();35 let page = section.page(space).unwrap();
36 section.close().unwrap();36 section.close().unwrap();
crates/notebook/tests/live_share.rs+56
...@@ -53,6 +53,62 @@ fn a_guest_edits_the_host_s_notebook() {...@@ -53,6 +53,62 @@ fn a_guest_edits_the_host_s_notebook() {
53 });53 });
54}54}
5555
56#[test]
57fn an_offline_guest_reopens_its_queue_while_the_host_remains_reachable() {
58 use notebook::{Remote, Replica, live::share::HostedRemote, session::Section};
59 use std::{thread, time::Duration};
60
61 let directory = tempfile::tempdir().unwrap();
62 let folder = notebook(directory.path());
63 let url = relay(Default::default());
64 let sharing = Sharing::new("").unwrap();
65 let host = host(&folder, &directory.path().join("host"), &sharing, &url);
66 let (guest, notebook) = guest("Grace", &code(&host), &url, &directory.path().join("grace"));
67 let file = folder.join("Garden.one");
68 let source = std::fs::read(&file).unwrap();
69 let cache = notebook.replica_path("Garden.one").unwrap();
70 std::fs::create_dir_all(cache.parent().unwrap()).unwrap();
71 let replica =
72 Replica::open_or_create(&cache, None, || notebook.read_section("Garden.one")).unwrap();
73 let section = Section::resume_hosted(
74 "Garden.one".into(),
75 replica,
76 Arc::clone(&guest),
77 true,
78 || {},
79 )
80 .unwrap();
81 let id = replace(&section, &source, 0..0, "Hosted offline reopen marker ");
82 let pending = section.pending().unwrap();
83 assert_eq!(pending.len(), 1);
84 section.close().unwrap();
85 let section = Section::resume_hosted(
86 "Garden.one".into(),
87 Replica::open(&cache).unwrap(),
88 Arc::clone(&guest),
89 true,
90 || {},
91 )
92 .unwrap();
93 let mut probe = HostedRemote::new(&guest, "Garden.one");
94 let stamp = onestore::Stamp::of(&source).unwrap();
95 for _ in 0..10 {
96 assert_eq!(probe.stamp().unwrap(), stamp);
97 assert_eq!(section.pending().unwrap(), pending);
98 assert_eq!(std::fs::read(&file).unwrap(), source);
99 thread::sleep(Duration::from_millis(20));
100 }
101 section.wake();
102 published(&section, id);
103 assert_eq!(
104 server::text(&std::fs::read(&file).unwrap()).2,
105 "Hosted offline reopen marker Original text"
106 );
107 assert!(section.pending().unwrap().is_empty());
108 section.close().unwrap();
109 host.stop();
110}
111
56#[test]112#[test]
57fn presence_reconnect_reconciles_missed_changes_without_reconnecting_access() {113fn presence_reconnect_reconciles_missed_changes_without_reconnecting_access() {
58 use notebook::{114 use notebook::{
crates/notebook/tests/location.rs+8-8
...@@ -91,7 +91,7 @@ fn until(what: &str, mut accept: impl FnMut() -> bool) {...@@ -91,7 +91,7 @@ fn until(what: &str, mut accept: impl FnMut() -> bool) {
91/// edit queued in its replica.91/// edit queued in its replica.
92fn queued(root: &Path, cache: &Path, text: &str) -> ExGuid {92fn queued(root: &Path, cache: &Path, text: &str) -> ExGuid {
93 let notebook = Notebook::open(root, cache).unwrap();93 let notebook = Notebook::open(root, cache).unwrap();
94 let section = notebook.section("First.one", || {}).unwrap();94 let section = notebook.section("First.one", false, || {}).unwrap();
95 section.set_offline(true);95 section.set_offline(true);
96 let space = typed(&section, text);96 let space = typed(&section, text);
97 assert_eq!(section.pending().unwrap().len(), 1);97 assert_eq!(section.pending().unwrap().len(), 1);
...@@ -102,7 +102,7 @@ fn queued(root: &Path, cache: &Path, text: &str) -> ExGuid {...@@ -102,7 +102,7 @@ fn queued(root: &Path, cache: &Path, text: &str) -> ExGuid {
102/// Opens `First.one` of `root` and waits for it to publish what it holds.102/// Opens `First.one` of `root` and waits for it to publish what it holds.
103fn published(root: &Path, cache: &Path) -> usize {103fn published(root: &Path, cache: &Path) -> usize {
104 let notebook = Notebook::open(root, cache).unwrap();104 let notebook = Notebook::open(root, cache).unwrap();
105 let section = notebook.section("First.one", || {}).unwrap();105 let section = notebook.section("First.one", false, || {}).unwrap();
106 let pending = section.pending().unwrap().len();106 let pending = section.pending().unwrap().len();
107 until("the queue published", || {107 until("the queue published", || {
108 section.sync_status().unwrap().queued == 0108 section.sync_status().unwrap().queued == 0
...@@ -128,8 +128,8 @@ fn copies_of_a_notebook_keep_their_own_replicas_and_publish_only_to_themselves()...@@ -128,8 +128,8 @@ fn copies_of_a_notebook_keep_their_own_replicas_and_publish_only_to_themselves()
128 );128 );
129 // Both open at once: neither waits on the other's replica.129 // Both open at once: neither waits on the other's replica.
130 let (mine, theirs) = (130 let (mine, theirs) = (
131 a.section("First.one", || {}).unwrap(),131 a.section("First.one", false, || {}).unwrap(),
132 b.section("First.one", || {}).unwrap(),132 b.section("First.one", false, || {}).unwrap(),
133 );133 );
134 mine.set_offline(true);134 mine.set_offline(true);
135 let space = typed(&mine, "Original ");135 let space = typed(&mine, "Original ");
...@@ -242,7 +242,7 @@ fn a_replica_newer_than_a_surviving_copy_stays_behind() {...@@ -242,7 +242,7 @@ fn a_replica_newer_than_a_surviving_copy_stays_behind() {
242 // The original moves on past the copy, then queues an edit and goes away.242 // The original moves on past the copy, then queues an edit and goes away.
243 {243 {
244 let notebook = Notebook::open(&root, &cache).unwrap();244 let notebook = Notebook::open(&root, &cache).unwrap();
245 let section = notebook.section("First.one", || {}).unwrap();245 let section = notebook.section("First.one", false, || {}).unwrap();
246 typed(&section, "Published ");246 typed(&section, "Published ");
247 until("the original published", || {247 until("the original published", || {
248 section.sync_status().unwrap().queued == 0248 section.sync_status().unwrap().queued == 0
...@@ -254,7 +254,7 @@ fn a_replica_newer_than_a_surviving_copy_stays_behind() {...@@ -254,7 +254,7 @@ fn a_replica_newer_than_a_surviving_copy_stays_behind() {
254 assert_eq!(published(&copy, &cache), 0);254 assert_eq!(published(&copy, &cache), 0);
255 let space = Notebook::open(&copy, &cache)255 let space = Notebook::open(&copy, &cache)
256 .unwrap()256 .unwrap()
257 .section("First.one", || {})257 .section("First.one", false, || {})
258 .unwrap()258 .unwrap()
259 .pages()259 .pages()
260 .unwrap()[0]260 .unwrap()[0]
...@@ -297,8 +297,8 @@ fn a_section_copy_beside_its_original_opens_with_its_own_replica() {...@@ -297,8 +297,8 @@ fn a_section_copy_beside_its_original_opens_with_its_own_replica() {
297 .count(),297 .count(),
298 2298 2
299 );299 );
300 let original = notebook.section("First.one", || {}).unwrap();300 let original = notebook.section("First.one", false, || {}).unwrap();
301 let copy = notebook.section("First 2.one", || {}).unwrap();301 let copy = notebook.section("First 2.one", false, || {}).unwrap();
302 original.set_offline(true);302 original.set_offline(true);
303 let space = typed(&original, "Mine ");303 let space = typed(&original, "Mine ");
304 assert!(copy.pending().unwrap().is_empty());304 assert!(copy.pending().unwrap().is_empty());
crates/notebook/tests/protected.rs+24-16
...@@ -107,10 +107,10 @@ fn a_locked_section_unlocks_into_a_session() {...@@ -107,10 +107,10 @@ fn a_locked_section_unlocks_into_a_session() {
107 notebook.unlock(&section.path, "wrong"),107 notebook.unlock(&section.path, "wrong"),
108 Err(notebook::Error::Protected(Error::PasswordMismatch))108 Err(notebook::Error::Protected(Error::PasswordMismatch))
109 ));109 ));
110 assert!(notebook.section(&section.path, || {}).is_err());110 assert!(notebook.section(&section.path, false, || {}).is_err());
111 let key = notebook.unlock(&section.path, &password).unwrap();111 let key = notebook.unlock(&section.path, &password).unwrap();
112 let session = notebook112 let session = notebook
113 .section_unlocked(&section.path, &key, || {})113 .section_unlocked(&section.path, &key, false, || {})
114 .unwrap();114 .unwrap();
115 let pages = session.pages().unwrap();115 let pages = session.pages().unwrap();
116 assert_eq!(pages.len(), 11);116 assert_eq!(pages.len(), 11);
...@@ -274,7 +274,11 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() {...@@ -274,7 +274,11 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() {
274 let mut notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap();274 let mut notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap();
275 let path = notebook.catalog().sections[0].path.clone();275 let path = notebook.catalog().sections[0].path.clone();
276 let plain = notebook.replica_path(&path).unwrap();276 let plain = notebook.replica_path(&path).unwrap();
277 notebook.section(&path, || {}).unwrap().close().unwrap();277 notebook
278 .section(&path, false, || {})
279 .unwrap()
280 .close()
281 .unwrap();
278 assert!(plain.exists());282 assert!(plain.exists());
279 let identity = notebook.catalog().sections[0].file_id;283 let identity = notebook.catalog().sections[0].file_id;
280284
...@@ -300,7 +304,9 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() {...@@ -300,7 +304,9 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() {
300 assert!(!image.windows(utf16.len()).any(|w| w == utf16));304 assert!(!image.windows(utf16.len()).any(|w| w == utf16));
301 let key = notebook.unlock(&path, "first").unwrap();305 let key = notebook.unlock(&path, "first").unwrap();
302 assert_eq!(key.secret(), first.secret());306 assert_eq!(key.secret(), first.secret());
303 let session = notebook.section_unlocked(&path, &key, || {}).unwrap();307 let session = notebook
308 .section_unlocked(&path, &key, false, || {})
309 .unwrap();
304 assert_eq!(session.pages().unwrap()[0].1, TEXT);310 assert_eq!(session.pages().unwrap()[0].1, TEXT);
305 session.close().unwrap();311 session.close().unwrap();
306 assert!(!leaks(&notebook.replica_path(&path).unwrap(), TEXT));312 assert!(!leaks(&notebook.replica_path(&path).unwrap(), TEXT));
...@@ -311,7 +317,9 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() {...@@ -311,7 +317,9 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() {
311 .unwrap()317 .unwrap()
312 .unwrap();318 .unwrap();
313 assert!(notebook.unlock(&path, "first").is_err());319 assert!(notebook.unlock(&path, "first").is_err());
314 let session = notebook.section_unlocked(&path, &second, || {}).unwrap();320 let session = notebook
321 .section_unlocked(&path, &second, false, || {})
322 .unwrap();
315 assert_eq!(session.pages().unwrap()[0].1, TEXT);323 assert_eq!(session.pages().unwrap()[0].1, TEXT);
316 session.close().unwrap();324 session.close().unwrap();
317325
...@@ -323,7 +331,7 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() {...@@ -323,7 +331,7 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() {
323 );331 );
324 let section = &notebook.catalog().sections[0];332 let section = &notebook.catalog().sections[0];
325 assert!(matches!(section.state, SectionState::Readable { .. }));333 assert!(matches!(section.state, SectionState::Readable { .. }));
326 let session = notebook.section(&path, || {}).unwrap();334 let session = notebook.section(&path, false, || {}).unwrap();
327 assert_eq!(session.pages().unwrap()[0].1, TEXT);335 assert_eq!(session.pages().unwrap()[0].1, TEXT);
328}336}
329337
...@@ -341,7 +349,7 @@ fn a_password_waits_for_queued_edits() {...@@ -341,7 +349,7 @@ fn a_password_waits_for_queued_edits() {
341 )349 )
342 .unwrap();350 .unwrap();
343 let path = notebook.catalog().sections[0].path.clone();351 let path = notebook.catalog().sections[0].path.clone();
344 let session = notebook.section(&path, || {}).unwrap();352 let session = notebook.section(&path, false, || {}).unwrap();
345 session.set_offline(true);353 session.set_offline(true);
346 let (space, ..) = session.pages().unwrap()[0].clone();354 let (space, ..) = session.pages().unwrap()[0].clone();
347 let page = session.page(space).unwrap();355 let page = session.page(space).unwrap();
...@@ -384,7 +392,7 @@ fn a_replica_drops_its_plaintext_once_its_section_is_protected_elsewhere() {...@@ -384,7 +392,7 @@ fn a_replica_drops_its_plaintext_once_its_section_is_protected_elsewhere() {
384 const TITLE: &str = "Read on the other device";392 const TITLE: &str = "Read on the other device";
385 let temporary = tempfile::tempdir().unwrap();393 let temporary = tempfile::tempdir().unwrap();
386 let (mut here, mut there, path) = two_devices(temporary.path(), TITLE);394 let (mut here, mut there, path) = two_devices(temporary.path(), TITLE);
387 there.section(&path, || {}).unwrap().close().unwrap();395 there.section(&path, false, || {}).unwrap().close().unwrap();
388 let replica = there.replica_path(&path).unwrap();396 let replica = there.replica_path(&path).unwrap();
389 assert!(leaks(&replica, TITLE));397 assert!(leaks(&replica, TITLE));
390 here.set_password(&path, None, Some("secret")).unwrap();398 here.set_password(&path, None, Some("secret")).unwrap();
...@@ -408,7 +416,7 @@ fn an_open_section_protected_elsewhere_stops_as_protected() {...@@ -408,7 +416,7 @@ fn an_open_section_protected_elsewhere_stops_as_protected() {
408 const TITLE: &str = "Open while protected";416 const TITLE: &str = "Open while protected";
409 let temporary = tempfile::tempdir().unwrap();417 let temporary = tempfile::tempdir().unwrap();
410 let (mut here, mut there, path) = two_devices(temporary.path(), TITLE);418 let (mut here, mut there, path) = two_devices(temporary.path(), TITLE);
411 let session = there.section(&path, || {}).unwrap();419 let session = there.section(&path, false, || {}).unwrap();
412 let replica = there.replica_path(&path).unwrap();420 let replica = there.replica_path(&path).unwrap();
413 here.set_password(&path, None, Some("secret")).unwrap();421 here.set_password(&path, None, Some("secret")).unwrap();
414 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);422 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
...@@ -435,7 +443,7 @@ fn edits_queued_before_a_password_publish_under_it_once_unlocked() {...@@ -435,7 +443,7 @@ fn edits_queued_before_a_password_publish_under_it_once_unlocked() {
435 const TYPED: &str = "Typed offline meanwhile";443 const TYPED: &str = "Typed offline meanwhile";
436 let temporary = tempfile::tempdir().unwrap();444 let temporary = tempfile::tempdir().unwrap();
437 let (mut here, mut there, path) = two_devices(temporary.path(), TITLE);445 let (mut here, mut there, path) = two_devices(temporary.path(), TITLE);
438 let session = there.section(&path, || {}).unwrap();446 let session = there.section(&path, false, || {}).unwrap();
439 session.set_offline(true);447 session.set_offline(true);
440 let (space, ..) = session.pages().unwrap()[0].clone();448 let (space, ..) = session.pages().unwrap()[0].clone();
441 let page = session.page(space).unwrap();449 let page = session.page(space).unwrap();
...@@ -457,7 +465,7 @@ fn edits_queued_before_a_password_publish_under_it_once_unlocked() {...@@ -457,7 +465,7 @@ fn edits_queued_before_a_password_publish_under_it_once_unlocked() {
457465
458 let key = there.unlock(&path, "secret").unwrap();466 let key = there.unlock(&path, "secret").unwrap();
459 assert!(!held.exists());467 assert!(!held.exists());
460 let session = there.section_unlocked(&path, &key, || {}).unwrap();468 let session = there.section_unlocked(&path, &key, false, || {}).unwrap();
461 let titles: Vec<String> = session469 let titles: Vec<String> = session
462 .pages()470 .pages()
463 .unwrap()471 .unwrap()
...@@ -523,7 +531,7 @@ fn a_notebook_protected_through_its_sessions() {...@@ -523,7 +531,7 @@ fn a_notebook_protected_through_its_sessions() {
523 let names = ["Protected", "Edited", "Changed", "Removed", "Conflicted"];531 let names = ["Protected", "Edited", "Changed", "Removed", "Conflicted"];
524 for name in names {532 for name in names {
525 let path = notebook.create_section("", name, &creation).unwrap();533 let path = notebook.create_section("", name, &creation).unwrap();
526 let section = notebook.section(&path, || {}).unwrap();534 let section = notebook.section(&path, false, || {}).unwrap();
527 for stored in &pages {535 for stored in &pages {
528 section.import_page(&stored.page, "Snowbound Test").unwrap();536 section.import_page(&stored.page, "Snowbound Test").unwrap();
529 }537 }
...@@ -544,7 +552,7 @@ fn a_notebook_protected_through_its_sessions() {...@@ -544,7 +552,7 @@ fn a_notebook_protected_through_its_sessions() {
544 }552 }
545 // Edited under its key: text on every page, published as dependent revisions.553 // Edited under its key: text on every page, published as dependent revisions.
546 let section = notebook554 let section = notebook
547 .section_unlocked(&path("Edited"), &keys["Edited"], || {})555 .section_unlocked(&path("Edited"), &keys["Edited"], false, || {})
548 .unwrap();556 .unwrap();
549 for round in ["first", "second", "third"] {557 for round in ["first", "second", "third"] {
550 append(&section, &format!(" Edited {round} under the key."));558 append(&section, &format!(" Edited {round} under the key."));
...@@ -562,7 +570,7 @@ fn a_notebook_protected_through_its_sessions() {...@@ -562,7 +570,7 @@ fn a_notebook_protected_through_its_sessions() {
562 .unwrap();570 .unwrap();
563 passwords.insert(path("Changed"), "Changed again".into());571 passwords.insert(path("Changed"), "Changed again".into());
564 let section = notebook572 let section = notebook
565 .section_unlocked(&path("Changed"), &changed, || {})573 .section_unlocked(&path("Changed"), &changed, false, || {})
566 .unwrap();574 .unwrap();
567 append(&section, " Written under the changed password.");575 append(&section, " Written under the changed password.");
568 published(&section);576 published(&section);
...@@ -580,10 +588,10 @@ fn a_notebook_protected_through_its_sessions() {...@@ -580,10 +588,10 @@ fn a_notebook_protected_through_its_sessions() {
580 let second = Notebook::open(&folder, temporary.path().join("second")).unwrap();588 let second = Notebook::open(&folder, temporary.path().join("second")).unwrap();
581 let key = &keys["Conflicted"];589 let key = &keys["Conflicted"];
582 let ours = notebook590 let ours = notebook
583 .section_unlocked(&path("Conflicted"), key, || {})591 .section_unlocked(&path("Conflicted"), key, false, || {})
584 .unwrap();592 .unwrap();
585 let theirs = second593 let theirs = second
586 .section_unlocked(&path("Conflicted"), key, || {})594 .section_unlocked(&path("Conflicted"), key, false, || {})
587 .unwrap();595 .unwrap();
588 for (section, word) in [(&ours, " Ours offline."), (&theirs, " Theirs offline.")] {596 for (section, word) in [(&ours, " Ours offline."), (&theirs, " Theirs offline.")] {
589 section.set_offline(true);597 section.set_offline(true);
crates/notebook/tests/session.rs+53-18
...@@ -65,7 +65,7 @@ fn typed(...@@ -65,7 +65,7 @@ fn typed(
65fn open(file: &Path, cache: &Path) -> (Section, Arc<AtomicUsize>) {65fn open(file: &Path, cache: &Path) -> (Section, Arc<AtomicUsize>) {
66 let notified = Arc::new(AtomicUsize::new(0));66 let notified = Arc::new(AtomicUsize::new(0));
67 let counter = Arc::clone(&notified);67 let counter = Arc::clone(&notified);
68 let section = Section::open(file, cache, move || {68 let section = Section::open(file, cache, false, move || {
69 counter.fetch_add(1, Ordering::SeqCst);69 counter.fetch_add(1, Ordering::SeqCst);
70 })70 })
71 .unwrap();71 .unwrap();
...@@ -369,7 +369,7 @@ fn a_notebook_directory_lists_its_sections_and_opens_them() {...@@ -369,7 +369,7 @@ fn a_notebook_directory_lists_its_sections_and_opens_them() {
369 assert_eq!(catalog.groups.len(), 1);369 assert_eq!(catalog.groups.len(), 1);
370 assert_eq!(catalog.groups[0].sections.len(), 1);370 assert_eq!(catalog.groups[0].sections.len(), 1);
371 let path = catalog.groups[0].sections[0].path.clone();371 let path = catalog.groups[0].sections[0].path.clone();
372 let section = notebook.section(&path, || {}).unwrap();372 let section = notebook.section(&path, false, || {}).unwrap();
373 let (space, _, _) = section.pages().unwrap()[0];373 let (space, _, _) = section.pages().unwrap()[0];
374 let page = section.page(space).unwrap();374 let page = section.page(space).unwrap();
375 let text = first_text(&page);375 let text = first_text(&page);
...@@ -409,7 +409,7 @@ fn a_notebook_only_opens_discovered_section_paths() {...@@ -409,7 +409,7 @@ fn a_notebook_only_opens_discovered_section_paths() {
409 std::fs::write(root.join("added.one"), &source).unwrap();409 std::fs::write(root.join("added.one"), &source).unwrap();
410 for path in ["../outside.one", outside.to_str().unwrap(), "added.one"] {410 for path in ["../outside.one", outside.to_str().unwrap(), "added.one"] {
411 assert!(matches!(411 assert!(matches!(
412 notebook.section(path, || {}),412 notebook.section(path, false, || {}),
413 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound413 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound
414 ));414 ));
415 }415 }
...@@ -433,7 +433,7 @@ fn a_catalog_section_replaced_by_an_outside_symlink_is_rejected() {...@@ -433,7 +433,7 @@ fn a_catalog_section_replaced_by_an_outside_symlink_is_rejected() {
433 std::fs::remove_file(&file).unwrap();433 std::fs::remove_file(&file).unwrap();
434 std::os::unix::fs::symlink(&outside, &file).unwrap();434 std::os::unix::fs::symlink(&outside, &file).unwrap();
435 assert!(matches!(435 assert!(matches!(
436 notebook.section("notes.one", || {}),436 notebook.section("notes.one", false, || {}),
437 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::PermissionDenied437 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::PermissionDenied
438 ));438 ));
439 assert_eq!(replicas(&cache), 0);439 assert_eq!(replicas(&cache), 0);
...@@ -447,7 +447,7 @@ fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() {...@@ -447,7 +447,7 @@ fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() {
447 let cache = directory.path().join("replica.sqlite");447 let cache = directory.path().join("replica.sqlite");
448 let source = onestore::create_section("remote.one", "Original", "Author").unwrap();448 let source = onestore::create_section("remote.one", "Original", "Author").unwrap();
449 let replica = notebook::Replica::create(&cache, &source).unwrap();449 let replica = notebook::Replica::create(&cache, &source).unwrap();
450 let section = Section::resume(&file, replica, || {}).unwrap();450 let section = Section::resume(&file, replica, false, || {}).unwrap();
451 let space = section.pages().unwrap()[0].0;451 let space = section.pages().unwrap()[0].0;
452 let before = section.page(space).unwrap();452 let before = section.page(space).unwrap();
453 let after = edited(&before, "First ");453 let after = edited(&before, "First ");
...@@ -460,7 +460,13 @@ fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() {...@@ -460,7 +460,13 @@ fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() {
460 section.close().unwrap();460 section.close().unwrap();
461 assert!(!file.exists());461 assert!(!file.exists());
462462
463 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap();463 let section = Section::resume(
464 &file,
465 notebook::Replica::open(&cache).unwrap(),
466 false,
467 || {},
468 )
469 .unwrap();
464 assert_same(section.page(space).unwrap(), &after);470 assert_same(section.page(space).unwrap(), &after);
465 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));471 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
466 let before = section.page(space).unwrap();472 let before = section.page(space).unwrap();
...@@ -474,7 +480,13 @@ fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() {...@@ -474,7 +480,13 @@ fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() {
474 published(&section, next);480 published(&section, next);
475 assert_same(stored_page(&file, space), &after);481 assert_same(stored_page(&file, space), &after);
476 section.close().unwrap();482 section.close().unwrap();
477 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap();483 let section = Section::resume(
484 &file,
485 notebook::Replica::open(&cache).unwrap(),
486 false,
487 || {},
488 )
489 .unwrap();
478 assert_same(section.page(space).unwrap(), &after);490 assert_same(section.page(space).unwrap(), &after);
479 assert!(section.pending().unwrap().is_empty());491 assert!(section.pending().unwrap().is_empty());
480 section.close().unwrap();492 section.close().unwrap();
...@@ -487,7 +499,7 @@ fn resuming_against_another_document_preserves_both_remote_and_pending_edit() {...@@ -487,7 +499,7 @@ fn resuming_against_another_document_preserves_both_remote_and_pending_edit() {
487 let cache = directory.path().join("replica.sqlite");499 let cache = directory.path().join("replica.sqlite");
488 let source = onestore::create_section("notes.one", "Original", "Author").unwrap();500 let source = onestore::create_section("notes.one", "Original", "Author").unwrap();
489 let replica = notebook::Replica::create(&cache, &source).unwrap();501 let replica = notebook::Replica::create(&cache, &source).unwrap();
490 let section = Section::resume(&file, replica, || {}).unwrap();502 let section = Section::resume(&file, replica, false, || {}).unwrap();
491 let space = section.pages().unwrap()[0].0;503 let space = section.pages().unwrap()[0].0;
492 let before = section.page(space).unwrap();504 let before = section.page(space).unwrap();
493 let after = edited(&before, "Local ");505 let after = edited(&before, "Local ");
...@@ -497,7 +509,13 @@ fn resuming_against_another_document_preserves_both_remote_and_pending_edit() {...@@ -497,7 +509,13 @@ fn resuming_against_another_document_preserves_both_remote_and_pending_edit() {
497509
498 let other = onestore::create_section("other.one", "Unrelated", "Other").unwrap();510 let other = onestore::create_section("other.one", "Unrelated", "Other").unwrap();
499 std::fs::write(&file, &other).unwrap();511 std::fs::write(&file, &other).unwrap();
500 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap();512 let section = Section::resume(
513 &file,
514 notebook::Replica::open(&cache).unwrap(),
515 false,
516 || {},
517 )
518 .unwrap();
501 wait(&section, |event| matches!(event, Event::Failed(_)));519 wait(&section, |event| matches!(event, Event::Failed(_)));
502 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));520 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
503 assert_same(section.page(space).unwrap(), &after);521 assert_same(section.page(space).unwrap(), &after);
...@@ -515,7 +533,7 @@ fn offline_save_process() {...@@ -515,7 +533,7 @@ fn offline_save_process() {
515 };533 };
516 let root = std::path::PathBuf::from(root);534 let root = std::path::PathBuf::from(root);
517 let replica = notebook::Replica::open(root.join("replica.sqlite")).unwrap();535 let replica = notebook::Replica::open(root.join("replica.sqlite")).unwrap();
518 let section = Section::resume(root.join("absent.one"), replica, || {}).unwrap();536 let section = Section::resume(root.join("absent.one"), replica, false, || {}).unwrap();
519 let space = section.pages().unwrap()[0].0;537 let space = section.pages().unwrap()[0].0;
520 let before = section.page(space).unwrap();538 let before = section.page(space).unwrap();
521 let id = typed(&section, space, &before, 0..0, "Durable ");539 let id = typed(&section, space, &before, 0..0, "Durable ");
...@@ -542,7 +560,13 @@ fn an_acknowledged_offline_save_survives_process_exit_without_cleanup() {...@@ -542,7 +560,13 @@ fn an_acknowledged_offline_save_survives_process_exit_without_cleanup() {
542 .unwrap();560 .unwrap();
543 let file = directory.path().join("absent.one");561 let file = directory.path().join("absent.one");
544 assert!(!file.exists());562 assert!(!file.exists());
545 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap();563 let section = Section::resume(
564 &file,
565 notebook::Replica::open(&cache).unwrap(),
566 false,
567 || {},
568 )
569 .unwrap();
546 let space = section.pages().unwrap()[0].0;570 let space = section.pages().unwrap()[0].0;
547 let original = model_ops::page_of(&source, space);571 let original = model_ops::page_of(&source, space);
548 let after = edited(&original, "Durable ");572 let after = edited(&original, "Durable ");
...@@ -568,6 +592,7 @@ fn smb_connection_failure_keeps_the_session_locally_editable_and_retries() {...@@ -568,6 +592,7 @@ fn smb_connection_failure_keeps_the_session_locally_editable_and_retries() {
568 counter.fetch_add(1, Ordering::SeqCst);592 counter.fetch_add(1, Ordering::SeqCst);
569 Err(std::io::ErrorKind::PermissionDenied.into())593 Err(std::io::ErrorKind::PermissionDenied.into())
570 },594 },
595 false,
571 || {},596 || {},
572 )597 )
573 .unwrap();598 .unwrap();
...@@ -619,6 +644,7 @@ fn live_smb_session_save_publishes_and_reopens() {...@@ -619,6 +644,7 @@ fn live_smb_session_save_publishes_and_reopens() {
619 replica,644 replica,
620 1024 * 1024,645 1024 * 1024,
621 connect.clone(),646 connect.clone(),
647 false,
622 || {},648 || {},
623 )649 )
624 .unwrap();650 .unwrap();
...@@ -635,6 +661,7 @@ fn live_smb_session_save_publishes_and_reopens() {...@@ -635,6 +661,7 @@ fn live_smb_session_save_publishes_and_reopens() {
635 notebook::Replica::open(&cache).unwrap(),661 notebook::Replica::open(&cache).unwrap(),
636 1024 * 1024,662 1024 * 1024,
637 connect,663 connect,
664 false,
638 || {},665 || {},
639 )666 )
640 .unwrap();667 .unwrap();
...@@ -666,6 +693,7 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() {...@@ -666,6 +693,7 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() {
666 stalled.recv_timeout(Duration::from_secs(120)).unwrap();693 stalled.recv_timeout(Duration::from_secs(120)).unwrap();
667 Err(std::io::ErrorKind::TimedOut.into())694 Err(std::io::ErrorKind::TimedOut.into())
668 },695 },
696 false,
669 || {},697 || {},
670 )698 )
671 .unwrap();699 .unwrap();
...@@ -817,9 +845,14 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() {...@@ -817,9 +845,14 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() {
817845
818 let notified = Arc::new(AtomicUsize::new(0));846 let notified = Arc::new(AtomicUsize::new(0));
819 let counter = Arc::clone(&notified);847 let counter = Arc::clone(&notified);
820 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), move || {848 let section = Section::resume(
821 counter.fetch_add(1, Ordering::SeqCst);849 &file,
822 })850 notebook::Replica::open(&cache).unwrap(),
851 false,
852 move || {
853 counter.fetch_add(1, Ordering::SeqCst);
854 },
855 )
823 .unwrap();856 .unwrap();
824 wait(857 wait(
825 &section,858 &section,
...@@ -1043,7 +1076,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {...@@ -1043,7 +1076,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {
1043 background.watch(notebook.replicas());1076 background.watch(notebook.replicas());
1044 // The round started with the background ends before the edit exists.1077 // The round started with the background ends before the edit exists.
1045 std::thread::sleep(Duration::from_millis(200));1078 std::thread::sleep(Duration::from_millis(200));
1046 let section = notebook.section("Second.one", || {}).unwrap();1079 let section = notebook.section("Second.one", false, || {}).unwrap();
1047 section.set_offline(true);1080 section.set_offline(true);
1048 let space = section.pages().unwrap()[0].0;1081 let space = section.pages().unwrap()[0].0;
1049 let before = section.page(space).unwrap();1082 let before = section.page(space).unwrap();
...@@ -1070,7 +1103,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {...@@ -1070,7 +1103,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {
1070 drop(background);1103 drop(background);
1071 let mut reopened = None;1104 let mut reopened = None;
1072 until("the background released the replica", || {1105 until("the background released the replica", || {
1073 reopened = notebook.section("Second.one", || {}).ok();1106 reopened = notebook.section("Second.one", false, || {}).ok();
1074 reopened.is_some()1107 reopened.is_some()
1075 });1108 });
1076 let reopened = reopened.unwrap();1109 let reopened = reopened.unwrap();
...@@ -1084,7 +1117,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {...@@ -1084,7 +1117,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
1084 let mut notebook = two_sections(directory.path());1117 let mut notebook = two_sections(directory.path());
1085 // Second has a replica from being opened once; First has never been opened.1118 // Second has a replica from being opened once; First has never been opened.
1086 notebook1119 notebook
1087 .section("Second.one", || {})1120 .section("Second.one", false, || {})
1088 .unwrap()1121 .unwrap()
1089 .close()1122 .close()
1090 .unwrap();1123 .unwrap();
...@@ -1190,7 +1223,7 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {...@@ -1190,7 +1223,7 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {
1190 .all(|(_, status)| status.synced.is_some())1223 .all(|(_, status)| status.synced.is_some())
1191 });1224 });
1192 // Opening a section starts from its copy, and edits made offline wait in it.1225 // Opening a section starts from its copy, and edits made offline wait in it.
1193 let section = notebook.section("Second.one", || {}).unwrap();1226 let section = notebook.section("Second.one", false, || {}).unwrap();
1194 section.set_offline(true);1227 section.set_offline(true);
1195 let space = section.pages().unwrap()[0].0;1228 let space = section.pages().unwrap()[0].0;
1196 let before = section.page(space).unwrap();1229 let before = section.page(space).unwrap();
...@@ -1313,6 +1346,7 @@ fn a_moved_section_keeps_its_replica_while_the_old_remote_is_stalled() {...@@ -1313,6 +1346,7 @@ fn a_moved_section_keeps_its_replica_while_the_old_remote_is_stalled() {
1313 late_error,1346 late_error,
1314 })1347 })
1315 },1348 },
1349 false,
1316 || {},1350 || {},
1317 )1351 )
1318 .unwrap();1352 .unwrap();
...@@ -1392,6 +1426,7 @@ fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_i...@@ -1392,6 +1426,7 @@ fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_i
1392 .section_with(1426 .section_with(
1393 "Second.one",1427 "Second.one",
1394 move |file| Ok(Counted(file.to_owned(), Arc::clone(&counter))),1428 move |file| Ok(Counted(file.to_owned(), Arc::clone(&counter))),
1429 false,
1395 || {},1430 || {},
1396 )1431 )
1397 .unwrap();1432 .unwrap();
crates/notebook/tests/smb_queue.rs+1
...@@ -197,6 +197,7 @@ fn live_session_applies_edits_through_the_embedded_client_and_reopens() {...@@ -197,6 +197,7 @@ fn live_session_applies_edits_through_the_embedded_client_and_reopens() {
197 Duration::from_secs(5),197 Duration::from_secs(5),
198 )198 )
199 },199 },
200 false,
200 || {},201 || {},
201 )202 )
202 .unwrap();203 .unwrap();
crates/notebook/tests/support/live.rs+1-1
...@@ -121,7 +121,7 @@ pub fn open(notebook: &Notebook, guest: &Arc<Guest>, path: &str, key: Option<&Ke...@@ -121,7 +121,7 @@ pub fn open(notebook: &Notebook, guest: &Arc<Guest>, path: &str, key: Option<&Ke
121 let replica = notebook.replica_path(path).unwrap();121 let replica = notebook.replica_path(path).unwrap();
122 std::fs::create_dir_all(replica.parent().unwrap()).unwrap();122 std::fs::create_dir_all(replica.parent().unwrap()).unwrap();
123 let replica = Replica::open_or_create(&replica, key, || notebook.read_section(path)).unwrap();123 let replica = Replica::open_or_create(&replica, key, || notebook.read_section(path)).unwrap();
124 Section::resume_hosted(path.into(), replica, Arc::clone(guest), || {}).unwrap()124 Section::resume_hosted(path.into(), replica, Arc::clone(guest), false, || {}).unwrap()
125}125}
126126
127pub fn replace(section: &Section, image: &[u8], range: std::ops::Range<u32>, with: &str) -> u64 {127pub fn replace(section: &Section, image: &[u8], range: std::ops::Range<u32>, with: &str) -> u64 {
crates/snowbound/src/icloud_linux.rs+5-3
...@@ -26,20 +26,22 @@ pub fn section(...@@ -26,20 +26,22 @@ pub fn section(
26 _: &Path,26 _: &Path,
27 path: &str,27 path: &str,
28 key: Option<&onestore::protected::Key>,28 key: Option<&onestore::protected::Key>,
29 offline: bool,
29 notify: impl Fn() + Send + 'static,30 notify: impl Fn() + Send + 'static,
30) -> Result<Section, notebook::Error> {31) -> Result<Section, notebook::Error> {
31 match key {32 match key {
32 Some(key) => notebook.section_unlocked(path, key, notify),33 Some(key) => notebook.section_unlocked(path, key, offline, notify),
33 None => notebook.section(path, notify),34 None => notebook.section(path, offline, notify),
34 }35 }
35}36}
3637
37pub fn lone_section(38pub fn lone_section(
38 file: &Path,39 file: &Path,
39 cache: &Path,40 cache: &Path,
41 offline: bool,
40 notify: impl Fn() + Send + 'static,42 notify: impl Fn() + Send + 'static,
41) -> Result<Section, notebook::Error> {43) -> Result<Section, notebook::Error> {
42 Section::open(file, cache, notify)44 Section::open(file, cache, offline, notify)
43}45}
4446
45pub fn background(47pub fn background(
crates/snowbound/src/icloud_macos.rs+12-4
...@@ -365,6 +365,7 @@ pub fn section(...@@ -365,6 +365,7 @@ pub fn section(
365 root: &Path,365 root: &Path,
366 path: &str,366 path: &str,
367 key: Option<&onestore::protected::Key>,367 key: Option<&onestore::protected::Key>,
368 offline: bool,
368 notify: impl Fn() + Send + 'static,369 notify: impl Fn() + Send + 'static,
369) -> Result<Section, notebook::Error> {370) -> Result<Section, notebook::Error> {
370 if !notebook.replica_path(path)?.exists() {371 if !notebook.replica_path(path)?.exists() {
...@@ -372,8 +373,8 @@ pub fn section(...@@ -372,8 +373,8 @@ pub fn section(
372 }373 }
373 let connect = |file: &Path| Ok(Coordinated(file.to_owned()));374 let connect = |file: &Path| Ok(Coordinated(file.to_owned()));
374 let section = match key {375 let section = match key {
375 Some(key) => notebook.section_unlocked_with(path, key, connect, notify)?,376 Some(key) => notebook.section_unlocked_with(path, key, connect, offline, notify)?,
376 None => notebook.section_with(path, connect, notify)?,377 None => notebook.section_with(path, connect, offline, notify)?,
377 };378 };
378 section.set_pause(PAUSE);379 section.set_pause(PAUSE);
379 Ok(section)380 Ok(section)
...@@ -392,10 +393,17 @@ fn fetch(file: &Path) -> io::Result<()> {...@@ -392,10 +393,17 @@ fn fetch(file: &Path) -> io::Result<()> {
392pub fn lone_section(393pub fn lone_section(
393 file: &Path,394 file: &Path,
394 cache: &Path,395 cache: &Path,
396 offline: bool,
395 notify: impl Fn() + Send + 'static,397 notify: impl Fn() + Send + 'static,
396) -> Result<Section, notebook::Error> {398) -> Result<Section, notebook::Error> {
397 fetch(file)?;399 fetch(file)?;
398 let section = Section::open_with(file, cache, |file| Ok(Coordinated(file.to_owned())), notify)?;400 let section = Section::open_with(
401 file,
402 cache,
403 |file| Ok(Coordinated(file.to_owned())),
404 offline,
405 notify,
406 )?;
399 section.set_pause(PAUSE);407 section.set_pause(PAUSE);
400 Ok(section)408 Ok(section)
401}409}
...@@ -582,7 +590,7 @@ mod tests {...@@ -582,7 +590,7 @@ mod tests {
582 let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap();590 let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap();
583 assert!(ubiquitous(&root));591 assert!(ubiquitous(&root));
584 let path = notebook.catalog().sections[0].path.clone();592 let path = notebook.catalog().sections[0].path.clone();
585 let section = section(&notebook, &root, &path, None, || {}).unwrap();593 let section = section(&notebook, &root, &path, None, false, || {}).unwrap();
586 let (space, ..) = section.pages().unwrap()[0].clone();594 let (space, ..) = section.pages().unwrap()[0].clone();
587 let title = section595 let title = section
588 .page(space)596 .page(space)
crates/snowbound/src/library.rs+21-7
...@@ -991,7 +991,13 @@ impl Library {...@@ -991,7 +991,13 @@ impl Library {
991 })991 })
992 .and_then(|replica| {992 .and_then(|replica| {
993 let guest = Arc::clone(&joined.guest);993 let guest = Arc::clone(&joined.guest);
994 Section::resume_hosted(path.to_owned(), replica, guest, notifier())994 Section::resume_hosted(
995 path.to_owned(),
996 replica,
997 guest,
998 offline(),
999 notifier(),
1000 )
995 })1001 })
996 }1002 }
997 #[cfg(target_arch = "wasm32")]1003 #[cfg(target_arch = "wasm32")]
...@@ -1010,21 +1016,28 @@ impl Library {...@@ -1010,21 +1016,28 @@ impl Library {
1010 replica.and_then(|replica| {1016 replica.and_then(|replica| {
1011 let server = Arc::clone(server);1017 let server = Arc::clone(server);
1012 let connect = move || server.connect();1018 let connect = move || server.connect();
1013 Section::resume_smb(file, replica, LIMIT, connect, notifier())1019 Section::resume_smb(file, replica, LIMIT, connect, offline(), notifier())
1014 })1020 })
1015 }1021 }
1016 (Ok(Some(notebook)), None) if self.in_icloud() => {1022 (Ok(Some(notebook)), None) if self.in_icloud() => {
1017 let root = Path::new(&self.location);1023 let root = Path::new(&self.location);
1018 crate::icloud::section(notebook, root, path, key.as_ref(), notifier())1024 crate::icloud::section(
1025 notebook,
1026 root,
1027 path,
1028 key.as_ref(),
1029 offline(),
1030 notifier(),
1031 )
1019 }1032 }
1020 (Ok(Some(notebook)), None) => match &key {1033 (Ok(Some(notebook)), None) => match &key {
1021 Some(key) => notebook.section_unlocked(path, key, notifier()),1034 Some(key) => notebook.section_unlocked(path, key, offline(), notifier()),
1022 None => notebook.section(path, notifier()),1035 None => notebook.section(path, offline(), notifier()),
1023 },1036 },
1024 (Ok(None), _) if self.in_icloud() => {1037 (Ok(None), _) if self.in_icloud() => {
1025 crate::icloud::lone_section(Path::new(path), &self.cache, notifier())1038 crate::icloud::lone_section(Path::new(path), &self.cache, offline(), notifier())
1026 }1039 }
1027 (Ok(None), _) => Section::open(path, &self.cache, notifier()),1040 (Ok(None), _) => Section::open(path, &self.cache, offline(), notifier()),
1028 (Err(error), _) => return Err(error.clone().into()),1041 (Err(error), _) => return Err(error.clone().into()),
1029 };1042 };
1030 match opened {1043 match opened {
...@@ -1763,6 +1776,7 @@ mod tests {...@@ -1763,6 +1776,7 @@ mod tests {
1763 release: Arc::clone(&gate),1776 release: Arc::clone(&gate),
1764 })1777 })
1765 },1778 },
1779 false,
1766 || {},1780 || {},
1767 )1781 )
1768 .unwrap();1782 .unwrap();
crates/snowbound/src/main.rs+3-3
...@@ -7246,7 +7246,7 @@ mod tests {...@@ -7246,7 +7246,7 @@ mod tests {
7246 )7246 )
7247 .unwrap();7247 .unwrap();
7248 let section =7248 let section =
7249 notebook::session::Section::open(&file, directory.join("cache"), || {}).unwrap();7249 notebook::session::Section::open(&file, directory.join("cache"), false, || {}).unwrap();
7250 let mut engine = TextEngine::default();7250 let mut engine = TextEngine::default();
7251 for (space, ..) in section.pages().unwrap().into_iter().take(3) {7251 for (space, ..) in section.pages().unwrap().into_iter().take(3) {
7252 let mut editor =7252 let mut editor =
...@@ -7348,7 +7348,7 @@ mod tests {...@@ -7348,7 +7348,7 @@ mod tests {
7348 &file,7348 &file,
7349 )7349 )
7350 .unwrap();7350 .unwrap();
7351 let ours = Section::open(&file, directory.join("ours"), || {}).unwrap();7351 let ours = Section::open(&file, directory.join("ours"), false, || {}).unwrap();
7352 let space = ours.pages().unwrap()[0].0;7352 let space = ours.pages().unwrap()[0].0;
7353 let mut engine = TextEngine::default();7353 let mut engine = TextEngine::default();
7354 let mut editor = CanvasEditor::from_page(ours.page(space).unwrap(), &mut engine).unwrap();7354 let mut editor = CanvasEditor::from_page(ours.page(space).unwrap(), &mut engine).unwrap();
...@@ -7378,7 +7378,7 @@ mod tests {...@@ -7378,7 +7378,7 @@ mod tests {
7378 "{seen:?}"7378 "{seen:?}"
7379 );7379 );
73807380
7381 let theirs = Section::open(&file, directory.join("theirs"), || {}).unwrap();7381 let theirs = Section::open(&file, directory.join("theirs"), false, || {}).unwrap();
7382 let mut other = CanvasEditor::from_page(theirs.page(space).unwrap(), &mut engine).unwrap();7382 let mut other = CanvasEditor::from_page(theirs.page(space).unwrap(), &mut engine).unwrap();
7383 other.focus_outline(outline).unwrap();7383 other.focus_outline(outline).unwrap();
7384 let last = other.active_outline().document().paragraphs().count() - 1;7384 let last = other.active_outline().document().paragraphs().count() - 1;