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 {
631631 path: &str,
632632 notify: impl Fn() + Send + 'static,
633633 ) -> Result<session::Section> {
634 let offline = self.offline.load(Ordering::Relaxed);
634635 match &self.place {
635636 Place::File(file) => Ok(session::Section::open_with(
636637 file,
637638 &self.cache,
638639 |file| Ok(Coordinated(file.to_owned())),
640 offline,
639641 notify,
640642 )?),
641643 Place::Folder(_) => {
......@@ -643,8 +645,10 @@ impl Library {
643645 let notebook = notebook.as_ref().ok_or("A lone section has no notebook")?;
644646 let connect = |file: &Path| Ok(Coordinated(file.to_owned()));
645647 Ok(match self.unlocked().get(path) {
646 Some(key) => notebook.section_unlocked_with(path, key, connect, notify)?,
647 None => notebook.section_with(path, connect, notify)?,
648 Some(key) => {
649 notebook.section_unlocked_with(path, key, connect, offline, notify)?
650 }
651 None => notebook.section_with(path, connect, offline, notify)?,
648652 })
649653 }
650654 Place::Share { server, root, .. } => {
......@@ -682,6 +686,7 @@ impl Library {
682686 replica,
683687 LIMIT,
684688 move || server.connect(),
689 offline,
685690 notify,
686691 )?)
687692 }
......@@ -857,6 +862,9 @@ impl Section {
857862 .open
858863 .lock()
859864 .unwrap_or_else(|error| error.into_inner());
865 shared
866 .section
867 .set_offline(library.offline.load(Ordering::Relaxed));
860868 open.push((path.to_owned(), Arc::downgrade(&shared)));
861869 drop(open);
862870 Ok(Self { shared, library })
......@@ -1785,13 +1793,24 @@ pub extern "C" fn sb_library_sync_status(library: &Library) -> *mut c_char {
17851793/// `sb_library_sync_now`. Sections opened later follow.
17861794#[unsafe(no_mangle)]
17871795pub 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());
17881800 library.offline.store(offline, Ordering::Relaxed);
17891801 if let Some(background) = &library.background {
17901802 background.set_offline(offline);
17911803 }
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 {
17931810 shared.section.set_offline(offline);
17941811 }
1812 // Releasing the last Shared can join its replica's threads.
1813 drop(open);
17951814}
17961815
17971816/// 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() {
376376 assert_eq!(stored.page(space).unwrap().color, Some(0x00ef_cdab));
377377}
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
379464#[test]
380465fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin() {
381466 let (directory, root) = copy("m6/native-features-01/notebook");
......@@ -666,8 +751,53 @@ fn a_notebook_on_a_share_saves_and_opens_offline() {
666751 let arena = onestore::Arena::default();
667752 let stored = onestore::Section::open(&arena, published).unwrap();
668753 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;
669767 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));
671801 Arc::into_inner(shared).unwrap().section.close().unwrap();
672802 // The same notebook while its server does not answer: a closed port on the lab's host.
673803 let host = address.split(':').next().unwrap();
crates/notebook/README.md+8-7
......@@ -52,14 +52,15 @@ thread rereads the queue.
5252`session::Notebook::open(root, cache_dir)` discovers a notebook directory, reading only
5353the files its folders list otherwise than when last opened (`discover::Cache`, kept under
5454`cache_dir/listings`), and
55`session::Section::open(file, cache_dir, notify)` opens one section file through
55`session::Section::open(file, cache_dir, offline, notify)` opens one section file through
5656a replica named by the file's location and the section's document identity, so the same
5757file reopens the same queue after a relaunch and a copy of it elsewhere has its own
5858(`notebook::location`: `folder`, `local`, `smb`, `moved` for a notebook the app moves,
5959and `forget` for one it deletes).
60`Notebook::section(path, notify)` opens a mounted notebook's section; a replica already
60`Notebook::section(path, offline, notify)` opens a mounted notebook's section; a replica already
6161named 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.
6364A section is `Send + Sync`: `apply(author, edit)` returns at once and reports a refusal as `Event::Rejected`; `events()` drains
6465remote changes (`Changed(spaces)`), publication outcomes, unreachable files and
6566failures; `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
9697thread and deletes the replicas holding nothing unpublished, as when the notebook closes.
9798`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` without
100`Section::resume(file, replica, offline, notify)` starts from an owned `Replica` without
100101consulting the remote file; with the `smb` feature, `Section::resume_smb(path,
101replica, limit, connect, notify)` binds a share-relative path, `connect` running on
102replica, limit, connect, offline, notify)` binds a share-relative path, `connect` running on
102103the worker again after transport failure.
103104
104105## Reconciliation
......@@ -358,7 +359,7 @@ sections it has not opened this way, and unread changes leave out the reader's o
358359
359360A password-protected section lists as `Locked`. `Notebook::unlock(path, password)` opens
360361its key (a wrong password is `Error::Protected(PasswordMismatch)`), and
361`section_unlocked(path, &key, notify)` (`section_unlocked_with`, or
362`section_unlocked(path, &key, offline, notify)` (`section_unlocked_with`, or
362363`Replica::open_or_create(path, Some(&key), source)` for a host that opens replicas itself)
363364opens it as any section opens: edits queue, publish, merge and become conflict pages as an ordinary section's do,
364365each 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)
567568`Welcome` (the share and its secret), or a `Refusal` saying why not: a wrong code, no one
568569sharing it, an expired code, too many wrong tries, or no relay. `Guest::start` joins the share;
569570`Notebook::open_hosted(guest, cache)`, `Background::hosted(guest, notify)` and
570`Section::resume_hosted(path, replica, guest, notify)` then work as on a share, with
571`Section::resume_hosted(path, replica, guest, offline, notify)` then work as on a share, with
571572`Guest::host()` and `stopped()` for whether the host is there and still sharing.
572573
573574## Queue measurement
crates/notebook/examples/browser_receipts.rs+1-1
......@@ -190,7 +190,7 @@ mod web {
190190 )
191191 .unwrap();
192192 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();
194194 section.set_offline(true);
195195 let (space, text_id, _) = text(&section.replica().snapshot().unwrap());
196196 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 {
191191 let image = notebook.read_section("Garden.one").unwrap();
192192 let file = onestore::Header::parse(&image[..1024]).unwrap().file_id;
193193 let replica = Replica::open_or_create(&replica, None, || Ok(image)).unwrap();
194 let section =
195 Section::resume_hosted("Garden.one".into(), replica, Arc::clone(&guest), || {}).unwrap();
194 let section = Section::resume_hosted(
195 "Garden.one".into(),
196 replica,
197 Arc::clone(&guest),
198 false,
199 || {},
200 )
201 .unwrap();
196202 background.hold("Garden.one", &section);
197203 Open {
198204 guest,
crates/notebook/examples/live_latency.rs+8-2
......@@ -69,8 +69,14 @@ fn guest(
6969 std::fs::create_dir_all(replica.parent().unwrap()).unwrap();
7070 let replica =
7171 Replica::open_or_create(&replica, None, || notebook.read_section("Garden.one")).unwrap();
72 let section =
73 Section::resume_hosted("Garden.one".into(), replica, Arc::clone(&guest), || {}).unwrap();
72 let section = Section::resume_hosted(
73 "Garden.one".into(),
74 replica,
75 Arc::clone(&guest),
76 false,
77 || {},
78 )
79 .unwrap();
7480 background.hold("Garden.one", &section);
7581 (
7682 guest,
crates/notebook/examples/open_probe.rs+1-1
......@@ -133,7 +133,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
133133 println!("section {} bytes", source.len());
134134 for label in ["fresh cache", "reopened cache"] {
135135 let start = Instant::now();
136 let section = Section::open(&file, &cache, || {})?;
136 let section = Section::open(&file, &cache, false, || {})?;
137137 let opened = start.elapsed();
138138 let pages = section.pages()?;
139139 let listed = start.elapsed();
crates/notebook/examples/save_probe.rs+1-1
......@@ -21,7 +21,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
2121 let directory = tempfile::tempdir()?;
2222 let file = directory.path().join("probe.one");
2323 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, || {})?;
2525 let words = [
2626 "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog",
2727 ];
crates/notebook/examples/session_client.rs+1-1
......@@ -115,7 +115,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
115115 return Err("Usage: session_client SECTION CACHE_DIR LABEL".into());
116116 };
117117 let (notify, notified) = mpsc::channel();
118 let section = Section::open(file, cache, move || {
118 let section = Section::open(file, cache, false, move || {
119119 let _ = notify.send(());
120120 })?;
121121 let pending = section.pending()?;
crates/notebook/src/background.rs+1-1
......@@ -1479,7 +1479,7 @@ mod tests {
14791479 let source = onestore::create_section("Held.one", "", "Author").unwrap();
14801480 fs::write(&file, &source).unwrap();
14811481 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 || {
14831483 let _ = changed.send(());
14841484 })
14851485 .unwrap();
crates/notebook/src/session.rs+51-12
......@@ -1748,8 +1748,18 @@ impl Notebook {
17481748 }
17491749
17501750 /// 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> {
1752 self.section_with(path, |file| Ok(FileRemote(file.to_owned())), notify)
1751 pub fn section(
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 )
17531763 }
17541764
17551765 /// `section`, reading and publishing through the remote `connect` makes for its file
......@@ -1758,9 +1768,10 @@ impl Notebook {
17581768 &self,
17591769 path: &str,
17601770 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
1771 offline: bool,
17611772 notify: impl Fn() + Send + 'static,
17621773 ) -> Result<Section> {
1763 self.open_section(path, None, connect, notify)
1774 self.open_section(path, None, connect, offline, notify)
17641775 }
17651776
17661777 /// `section` for a password-protected section, under the `key` `unlock` gave.
......@@ -1768,9 +1779,16 @@ impl Notebook {
17681779 &self,
17691780 path: &str,
17701781 key: &protected::Key,
1782 offline: bool,
17711783 notify: impl Fn() + Send + 'static,
17721784 ) -> 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 )
17741792 }
17751793
17761794 /// `section_with` for a password-protected section, under its `key`.
......@@ -1779,9 +1797,10 @@ impl Notebook {
17791797 path: &str,
17801798 key: &protected::Key,
17811799 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
1800 offline: bool,
17821801 notify: impl Fn() + Send + 'static,
17831802 ) -> Result<Section> {
1784 self.open_section(path, Some(key), connect, notify)
1803 self.open_section(path, Some(key), connect, offline, notify)
17851804 }
17861805
17871806 fn open_section<R: Remote + 'static>(
......@@ -1789,6 +1808,7 @@ impl Notebook {
17891808 path: &str,
17901809 key: Option<&protected::Key>,
17911810 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
1811 offline: bool,
17921812 notify: impl Fn() + Send + 'static,
17931813 ) -> Result<Section> {
17941814 let section = self.section_path(path)?;
......@@ -1823,14 +1843,14 @@ impl Notebook {
18231843 if fs::metadata(&cache).is_ok() {
18241844 let (replica, remote, mut connect) =
18251845 (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);
18271847 }
18281848 }
18291849 let replica = |identity: &[u8; 16], _: &[u8]| {
18301850 fs::create_dir_all(&replicas)?;
18311851 Ok(replica_file(&replicas, identity))
18321852 };
1833 Section::open_in(file, key, replica, connect, notify)
1853 Section::open_in(file, key, replica, connect, offline, notify)
18341854 }
18351855}
18361856
......@@ -2217,9 +2237,16 @@ impl Section {
22172237 pub fn open(
22182238 file: impl AsRef<Path>,
22192239 cache: impl AsRef<Path>,
2240 offline: bool,
22202241 notify: impl Fn() + Send + 'static,
22212242 ) -> 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 )
22232250 }
22242251
22252252 /// `open`, reading and publishing the file through the remote `connect` makes for it,
......@@ -2229,6 +2256,7 @@ impl Section {
22292256 file: impl AsRef<Path>,
22302257 cache: impl AsRef<Path>,
22312258 connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
2259 offline: bool,
22322260 notify: impl Fn() + Send + 'static,
22332261 ) -> Result<Self> {
22342262 let file = fs::canonicalize(file)?;
......@@ -2239,7 +2267,7 @@ impl Section {
22392267 })?;
22402268 Ok(replica_file(&folder, identity))
22412269 };
2242 Self::open_in(file, None, replicas, connect, notify)
2270 Self::open_in(file, None, replicas, connect, offline, notify)
22432271 }
22442272
22452273 /// Opens the canonical section `file`, a protected one under `key`, through the replica
......@@ -2249,6 +2277,7 @@ impl Section {
22492277 key: Option<&protected::Key>,
22502278 replica: impl FnOnce(&[u8; 16], &[u8]) -> Result<PathBuf>,
22512279 mut connect: impl FnMut(&Path) -> io::Result<R> + Send + 'static,
2280 offline: bool,
22522281 notify: impl Fn() + Send + 'static,
22532282 ) -> Result<Self> {
22542283 let source = connect(&file)?.read()?;
......@@ -2257,7 +2286,7 @@ impl Section {
22572286 let cache = replica(&identity.guid, &source)?;
22582287 let replica = Replica::open_or_create(&cache, key, || Ok(source))?;
22592288 let remote = file.clone();
2260 Self::start(file, replica, move || connect(&remote), notify)
2289 Self::start(file, replica, move || connect(&remote), offline, notify)
22612290 }
22622291
22632292 /// Resumes an owned local replica without reading the publication target.
......@@ -2265,6 +2294,7 @@ impl Section {
22652294 pub fn resume(
22662295 file: impl AsRef<Path>,
22672296 replica: Replica,
2297 offline: bool,
22682298 notify: impl Fn() + Send + 'static,
22692299 ) -> Result<Self> {
22702300 let file = fs::absolute(file)?;
......@@ -2273,6 +2303,7 @@ impl Section {
22732303 file,
22742304 replica,
22752305 move || Ok(FileRemote(remote.clone())),
2306 offline,
22762307 notify,
22772308 )
22782309 }
......@@ -2285,12 +2316,14 @@ impl Section {
22852316 replica: Replica,
22862317 limit: usize,
22872318 mut connect: impl FnMut() -> io::Result<crate::smb::Client> + Send + 'static,
2319 offline: bool,
22882320 notify: impl Fn() + Send + 'static,
22892321 ) -> Result<Self> {
22902322 Self::start(
22912323 PathBuf::from(&path),
22922324 replica,
22932325 move || Ok(crate::SmbRemote::new(connect()?, path.clone(), limit)),
2326 offline,
22942327 notify,
22952328 )
22962329 }
......@@ -2301,12 +2334,14 @@ impl Section {
23012334 path: String,
23022335 replica: Replica,
23032336 guest: Arc<crate::live::share::Guest>,
2337 offline: bool,
23042338 notify: impl Fn() + Send + 'static,
23052339 ) -> Result<Self> {
23062340 Self::start(
23072341 PathBuf::from(&path),
23082342 replica,
23092343 move || Ok(crate::live::share::HostedRemote::new(&guest, &path)),
2344 offline,
23102345 notify,
23112346 )
23122347 }
......@@ -2315,6 +2350,7 @@ impl Section {
23152350 file: PathBuf,
23162351 replica: Replica,
23172352 connect: impl FnMut() -> io::Result<R> + Send + 'static,
2353 offline: bool,
23182354 notify: impl Fn() + Send + 'static,
23192355 ) -> Result<Self> {
23202356 let replica = Arc::new(replica);
......@@ -2335,19 +2371,21 @@ impl Section {
23352371 notify,
23362372 observed,
23372373 };
2338 section.worker = Some(section.start_worker(connect, None)?);
2374 section.worker = Some(section.start_worker(connect, offline, None)?);
23392375 Ok(section)
23402376 }
23412377
23422378 fn start_worker<R: Remote + 'static>(
23432379 &self,
23442380 connect: impl FnMut() -> io::Result<R> + Send + 'static,
2381 offline: bool,
23452382 replacing: Option<&SyncWorker>,
23462383 ) -> Result<SyncWorker> {
23472384 let (sender, notify) = (self.sender.clone(), Arc::clone(&self.notify));
23482385 let observed = Arc::clone(&self.observed);
23492386 Ok(self.replica.start_sync_replacing(
23502387 Duration::from_secs(2),
2388 offline,
23512389 connect,
23522390 move |result| {
23532391 let error = result.as_ref().err().map(reached);
......@@ -2413,7 +2451,8 @@ impl Section {
24132451 worker.cancel();
24142452 }
24152453 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())?);
24172456 self.file = file;
24182457 Ok(())
24192458 }
crates/notebook/src/session/tests.rs+126-3
......@@ -9,14 +9,18 @@ fn cached_sections_refuse_links_outside_the_notebook_and_dangling_links() {
99 let creation = PageCreation::new(None, Some("Mine"), "Fixture").unwrap();
1010 let notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap();
1111 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();
1317 let file = folder.join(path);
1418 let outside = temporary.path().join("outside.one");
1519 fs::rename(&file, &outside).unwrap();
1620 std::os::unix::fs::symlink(&outside, &file).unwrap();
17 assert!(notebook.section(path, || {}).is_err());
21 assert!(notebook.section(path, false, || {}).is_err());
1822 fs::remove_file(outside).unwrap();
19 assert!(notebook.section(path, || {}).is_err());
23 assert!(notebook.section(path, false, || {}).is_err());
2024}
2125
2226#[test]
......@@ -501,3 +505,122 @@ fn recycling_rejects_unconfirmed_appends_and_can_retry_each_stage() {
501505 assert_eq!(fs::read(folder.join(&path)).unwrap(), source);
502506 }
503507}
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() {
783783 let header = onestore::Store::parse(&renamed).unwrap().header;
784784 assert_eq!(header.ancestor, toc_id);
785785 assert_eq!(header.name_crc, 0x6108912a);
786 assert!(fresh.section("Renamed.one", || {}).is_err());
786 assert!(fresh.section("Renamed.one", false, || {}).is_err());
787787 // A password supersedes the file under writer coordination, never over a newer stamp, and
788788 // while a reader holds it open, as OneNote's readers share it with deletion.
789789 let section = format!("{root}/Renamed.one");
crates/notebook/src/worker.rs+3-1
......@@ -179,12 +179,13 @@ impl Replica {
179179 F: FnMut() -> io::Result<R> + Send + 'static,
180180 O: FnMut(&Result<Synced>) + Send + 'static,
181181 {
182 self.start_sync_replacing(interval, connect, observe, None)
182 self.start_sync_replacing(interval, false, connect, observe, None)
183183 }
184184
185185 pub(crate) fn start_sync_replacing<R, F, O>(
186186 self: &Arc<Self>,
187187 interval: Duration,
188 offline: bool,
188189 mut connect: F,
189190 mut observe: O,
190191 replacing: Option<&SyncWorker>,
......@@ -214,6 +215,7 @@ impl Replica {
214215 return Err(io::ErrorKind::WouldBlock.into());
215216 }
216217 let (signal, receiver) = Signal::new();
218 signal.offline.store(offline, Ordering::Release);
217219 if let Some(previous) = previous {
218220 signal
219221 .offline
crates/notebook/tests/copy.rs+1-1
......@@ -152,7 +152,7 @@ fn pages_copy_into_another_section_with_their_content_and_fresh_identities() {
152152 onestore::create_section("copies.one", "Destination", "Author").unwrap(),
153153 )
154154 .unwrap();
155 let section = Section::open(&file, &cache, || {}).unwrap();
155 let section = Section::open(&file, &cache, false, || {}).unwrap();
156156 let mut expected = Vec::new();
157157 let mut grouped = Vec::new();
158158 for source in SOURCES {
crates/notebook/tests/links.rs+1-1
......@@ -30,7 +30,7 @@ fn internal_links_find_their_page_by_identity() {
3030 )
3131 .unwrap();
3232 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();
3434 let (space, _, _) = section.pages().unwrap()[0];
3535 let page = section.page(space).unwrap();
3636 section.close().unwrap();
crates/notebook/tests/live_share.rs+56
......@@ -53,6 +53,62 @@ fn a_guest_edits_the_host_s_notebook() {
5353 });
5454}
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
56112#[test]
57113fn presence_reconnect_reconciles_missed_changes_without_reconnecting_access() {
58114 use notebook::{
crates/notebook/tests/location.rs+8-8
......@@ -91,7 +91,7 @@ fn until(what: &str, mut accept: impl FnMut() -> bool) {
9191/// edit queued in its replica.
9292fn queued(root: &Path, cache: &Path, text: &str) -> ExGuid {
9393 let notebook = Notebook::open(root, cache).unwrap();
94 let section = notebook.section("First.one", || {}).unwrap();
94 let section = notebook.section("First.one", false, || {}).unwrap();
9595 section.set_offline(true);
9696 let space = typed(&section, text);
9797 assert_eq!(section.pending().unwrap().len(), 1);
......@@ -102,7 +102,7 @@ fn queued(root: &Path, cache: &Path, text: &str) -> ExGuid {
102102/// Opens `First.one` of `root` and waits for it to publish what it holds.
103103fn published(root: &Path, cache: &Path) -> usize {
104104 let notebook = Notebook::open(root, cache).unwrap();
105 let section = notebook.section("First.one", || {}).unwrap();
105 let section = notebook.section("First.one", false, || {}).unwrap();
106106 let pending = section.pending().unwrap().len();
107107 until("the queue published", || {
108108 section.sync_status().unwrap().queued == 0
......@@ -128,8 +128,8 @@ fn copies_of_a_notebook_keep_their_own_replicas_and_publish_only_to_themselves()
128128 );
129129 // Both open at once: neither waits on the other's replica.
130130 let (mine, theirs) = (
131 a.section("First.one", || {}).unwrap(),
132 b.section("First.one", || {}).unwrap(),
131 a.section("First.one", false, || {}).unwrap(),
132 b.section("First.one", false, || {}).unwrap(),
133133 );
134134 mine.set_offline(true);
135135 let space = typed(&mine, "Original ");
......@@ -242,7 +242,7 @@ fn a_replica_newer_than_a_surviving_copy_stays_behind() {
242242 // The original moves on past the copy, then queues an edit and goes away.
243243 {
244244 let notebook = Notebook::open(&root, &cache).unwrap();
245 let section = notebook.section("First.one", || {}).unwrap();
245 let section = notebook.section("First.one", false, || {}).unwrap();
246246 typed(&section, "Published ");
247247 until("the original published", || {
248248 section.sync_status().unwrap().queued == 0
......@@ -254,7 +254,7 @@ fn a_replica_newer_than_a_surviving_copy_stays_behind() {
254254 assert_eq!(published(&copy, &cache), 0);
255255 let space = Notebook::open(&copy, &cache)
256256 .unwrap()
257 .section("First.one", || {})
257 .section("First.one", false, || {})
258258 .unwrap()
259259 .pages()
260260 .unwrap()[0]
......@@ -297,8 +297,8 @@ fn a_section_copy_beside_its_original_opens_with_its_own_replica() {
297297 .count(),
298298 2
299299 );
300 let original = notebook.section("First.one", || {}).unwrap();
301 let copy = notebook.section("First 2.one", || {}).unwrap();
300 let original = notebook.section("First.one", false, || {}).unwrap();
301 let copy = notebook.section("First 2.one", false, || {}).unwrap();
302302 original.set_offline(true);
303303 let space = typed(&original, "Mine ");
304304 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() {
107107 notebook.unlock(&section.path, "wrong"),
108108 Err(notebook::Error::Protected(Error::PasswordMismatch))
109109 ));
110 assert!(notebook.section(&section.path, || {}).is_err());
110 assert!(notebook.section(&section.path, false, || {}).is_err());
111111 let key = notebook.unlock(&section.path, &password).unwrap();
112112 let session = notebook
113 .section_unlocked(&section.path, &key, || {})
113 .section_unlocked(&section.path, &key, false, || {})
114114 .unwrap();
115115 let pages = session.pages().unwrap();
116116 assert_eq!(pages.len(), 11);
......@@ -274,7 +274,11 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() {
274274 let mut notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap();
275275 let path = notebook.catalog().sections[0].path.clone();
276276 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();
278282 assert!(plain.exists());
279283 let identity = notebook.catalog().sections[0].file_id;
280284
......@@ -300,7 +304,9 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() {
300304 assert!(!image.windows(utf16.len()).any(|w| w == utf16));
301305 let key = notebook.unlock(&path, "first").unwrap();
302306 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();
304310 assert_eq!(session.pages().unwrap()[0].1, TEXT);
305311 session.close().unwrap();
306312 assert!(!leaks(&notebook.replica_path(&path).unwrap(), TEXT));
......@@ -311,7 +317,9 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() {
311317 .unwrap()
312318 .unwrap();
313319 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();
315323 assert_eq!(session.pages().unwrap()[0].1, TEXT);
316324 session.close().unwrap();
317325
......@@ -323,7 +331,7 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() {
323331 );
324332 let section = &notebook.catalog().sections[0];
325333 assert!(matches!(section.state, SectionState::Readable { .. }));
326 let session = notebook.section(&path, || {}).unwrap();
334 let session = notebook.section(&path, false, || {}).unwrap();
327335 assert_eq!(session.pages().unwrap()[0].1, TEXT);
328336}
329337
......@@ -341,7 +349,7 @@ fn a_password_waits_for_queued_edits() {
341349 )
342350 .unwrap();
343351 let path = notebook.catalog().sections[0].path.clone();
344 let session = notebook.section(&path, || {}).unwrap();
352 let session = notebook.section(&path, false, || {}).unwrap();
345353 session.set_offline(true);
346354 let (space, ..) = session.pages().unwrap()[0].clone();
347355 let page = session.page(space).unwrap();
......@@ -384,7 +392,7 @@ fn a_replica_drops_its_plaintext_once_its_section_is_protected_elsewhere() {
384392 const TITLE: &str = "Read on the other device";
385393 let temporary = tempfile::tempdir().unwrap();
386394 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();
388396 let replica = there.replica_path(&path).unwrap();
389397 assert!(leaks(&replica, TITLE));
390398 here.set_password(&path, None, Some("secret")).unwrap();
......@@ -408,7 +416,7 @@ fn an_open_section_protected_elsewhere_stops_as_protected() {
408416 const TITLE: &str = "Open while protected";
409417 let temporary = tempfile::tempdir().unwrap();
410418 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();
412420 let replica = there.replica_path(&path).unwrap();
413421 here.set_password(&path, None, Some("secret")).unwrap();
414422 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() {
435443 const TYPED: &str = "Typed offline meanwhile";
436444 let temporary = tempfile::tempdir().unwrap();
437445 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();
439447 session.set_offline(true);
440448 let (space, ..) = session.pages().unwrap()[0].clone();
441449 let page = session.page(space).unwrap();
......@@ -457,7 +465,7 @@ fn edits_queued_before_a_password_publish_under_it_once_unlocked() {
457465
458466 let key = there.unlock(&path, "secret").unwrap();
459467 assert!(!held.exists());
460 let session = there.section_unlocked(&path, &key, || {}).unwrap();
468 let session = there.section_unlocked(&path, &key, false, || {}).unwrap();
461469 let titles: Vec<String> = session
462470 .pages()
463471 .unwrap()
......@@ -523,7 +531,7 @@ fn a_notebook_protected_through_its_sessions() {
523531 let names = ["Protected", "Edited", "Changed", "Removed", "Conflicted"];
524532 for name in names {
525533 let path = notebook.create_section("", name, &creation).unwrap();
526 let section = notebook.section(&path, || {}).unwrap();
534 let section = notebook.section(&path, false, || {}).unwrap();
527535 for stored in &pages {
528536 section.import_page(&stored.page, "Snowbound Test").unwrap();
529537 }
......@@ -544,7 +552,7 @@ fn a_notebook_protected_through_its_sessions() {
544552 }
545553 // Edited under its key: text on every page, published as dependent revisions.
546554 let section = notebook
547 .section_unlocked(&path("Edited"), &keys["Edited"], || {})
555 .section_unlocked(&path("Edited"), &keys["Edited"], false, || {})
548556 .unwrap();
549557 for round in ["first", "second", "third"] {
550558 append(&section, &format!(" Edited {round} under the key."));
......@@ -562,7 +570,7 @@ fn a_notebook_protected_through_its_sessions() {
562570 .unwrap();
563571 passwords.insert(path("Changed"), "Changed again".into());
564572 let section = notebook
565 .section_unlocked(&path("Changed"), &changed, || {})
573 .section_unlocked(&path("Changed"), &changed, false, || {})
566574 .unwrap();
567575 append(&section, " Written under the changed password.");
568576 published(&section);
......@@ -580,10 +588,10 @@ fn a_notebook_protected_through_its_sessions() {
580588 let second = Notebook::open(&folder, temporary.path().join("second")).unwrap();
581589 let key = &keys["Conflicted"];
582590 let ours = notebook
583 .section_unlocked(&path("Conflicted"), key, || {})
591 .section_unlocked(&path("Conflicted"), key, false, || {})
584592 .unwrap();
585593 let theirs = second
586 .section_unlocked(&path("Conflicted"), key, || {})
594 .section_unlocked(&path("Conflicted"), key, false, || {})
587595 .unwrap();
588596 for (section, word) in [(&ours, " Ours offline."), (&theirs, " Theirs offline.")] {
589597 section.set_offline(true);
crates/notebook/tests/session.rs+53-18
......@@ -65,7 +65,7 @@ fn typed(
6565fn open(file: &Path, cache: &Path) -> (Section, Arc<AtomicUsize>) {
6666 let notified = Arc::new(AtomicUsize::new(0));
6767 let counter = Arc::clone(&notified);
68 let section = Section::open(file, cache, move || {
68 let section = Section::open(file, cache, false, move || {
6969 counter.fetch_add(1, Ordering::SeqCst);
7070 })
7171 .unwrap();
......@@ -369,7 +369,7 @@ fn a_notebook_directory_lists_its_sections_and_opens_them() {
369369 assert_eq!(catalog.groups.len(), 1);
370370 assert_eq!(catalog.groups[0].sections.len(), 1);
371371 let path = catalog.groups[0].sections[0].path.clone();
372 let section = notebook.section(&path, || {}).unwrap();
372 let section = notebook.section(&path, false, || {}).unwrap();
373373 let (space, _, _) = section.pages().unwrap()[0];
374374 let page = section.page(space).unwrap();
375375 let text = first_text(&page);
......@@ -409,7 +409,7 @@ fn a_notebook_only_opens_discovered_section_paths() {
409409 std::fs::write(root.join("added.one"), &source).unwrap();
410410 for path in ["../outside.one", outside.to_str().unwrap(), "added.one"] {
411411 assert!(matches!(
412 notebook.section(path, || {}),
412 notebook.section(path, false, || {}),
413413 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound
414414 ));
415415 }
......@@ -433,7 +433,7 @@ fn a_catalog_section_replaced_by_an_outside_symlink_is_rejected() {
433433 std::fs::remove_file(&file).unwrap();
434434 std::os::unix::fs::symlink(&outside, &file).unwrap();
435435 assert!(matches!(
436 notebook.section("notes.one", || {}),
436 notebook.section("notes.one", false, || {}),
437437 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::PermissionDenied
438438 ));
439439 assert_eq!(replicas(&cache), 0);
......@@ -447,7 +447,7 @@ fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() {
447447 let cache = directory.path().join("replica.sqlite");
448448 let source = onestore::create_section("remote.one", "Original", "Author").unwrap();
449449 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();
451451 let space = section.pages().unwrap()[0].0;
452452 let before = section.page(space).unwrap();
453453 let after = edited(&before, "First ");
......@@ -460,7 +460,13 @@ fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() {
460460 section.close().unwrap();
461461 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();
464470 assert_same(section.page(space).unwrap(), &after);
465471 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
466472 let before = section.page(space).unwrap();
......@@ -474,7 +480,13 @@ fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() {
474480 published(&section, next);
475481 assert_same(stored_page(&file, space), &after);
476482 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();
478490 assert_same(section.page(space).unwrap(), &after);
479491 assert!(section.pending().unwrap().is_empty());
480492 section.close().unwrap();
......@@ -487,7 +499,7 @@ fn resuming_against_another_document_preserves_both_remote_and_pending_edit() {
487499 let cache = directory.path().join("replica.sqlite");
488500 let source = onestore::create_section("notes.one", "Original", "Author").unwrap();
489501 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();
491503 let space = section.pages().unwrap()[0].0;
492504 let before = section.page(space).unwrap();
493505 let after = edited(&before, "Local ");
......@@ -497,7 +509,13 @@ fn resuming_against_another_document_preserves_both_remote_and_pending_edit() {
497509
498510 let other = onestore::create_section("other.one", "Unrelated", "Other").unwrap();
499511 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();
501519 wait(&section, |event| matches!(event, Event::Failed(_)));
502520 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
503521 assert_same(section.page(space).unwrap(), &after);
......@@ -515,7 +533,7 @@ fn offline_save_process() {
515533 };
516534 let root = std::path::PathBuf::from(root);
517535 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();
519537 let space = section.pages().unwrap()[0].0;
520538 let before = section.page(space).unwrap();
521539 let id = typed(&section, space, &before, 0..0, "Durable ");
......@@ -542,7 +560,13 @@ fn an_acknowledged_offline_save_survives_process_exit_without_cleanup() {
542560 .unwrap();
543561 let file = directory.path().join("absent.one");
544562 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();
546570 let space = section.pages().unwrap()[0].0;
547571 let original = model_ops::page_of(&source, space);
548572 let after = edited(&original, "Durable ");
......@@ -568,6 +592,7 @@ fn smb_connection_failure_keeps_the_session_locally_editable_and_retries() {
568592 counter.fetch_add(1, Ordering::SeqCst);
569593 Err(std::io::ErrorKind::PermissionDenied.into())
570594 },
595 false,
571596 || {},
572597 )
573598 .unwrap();
......@@ -619,6 +644,7 @@ fn live_smb_session_save_publishes_and_reopens() {
619644 replica,
620645 1024 * 1024,
621646 connect.clone(),
647 false,
622648 || {},
623649 )
624650 .unwrap();
......@@ -635,6 +661,7 @@ fn live_smb_session_save_publishes_and_reopens() {
635661 notebook::Replica::open(&cache).unwrap(),
636662 1024 * 1024,
637663 connect,
664 false,
638665 || {},
639666 )
640667 .unwrap();
......@@ -666,6 +693,7 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() {
666693 stalled.recv_timeout(Duration::from_secs(120)).unwrap();
667694 Err(std::io::ErrorKind::TimedOut.into())
668695 },
696 false,
669697 || {},
670698 )
671699 .unwrap();
......@@ -817,9 +845,14 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() {
817845
818846 let notified = Arc::new(AtomicUsize::new(0));
819847 let counter = Arc::clone(&notified);
820 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), move || {
821 counter.fetch_add(1, Ordering::SeqCst);
822 })
848 let section = Section::resume(
849 &file,
850 notebook::Replica::open(&cache).unwrap(),
851 false,
852 move || {
853 counter.fetch_add(1, Ordering::SeqCst);
854 },
855 )
823856 .unwrap();
824857 wait(
825858 &section,
......@@ -1043,7 +1076,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {
10431076 background.watch(notebook.replicas());
10441077 // The round started with the background ends before the edit exists.
10451078 std::thread::sleep(Duration::from_millis(200));
1046 let section = notebook.section("Second.one", || {}).unwrap();
1079 let section = notebook.section("Second.one", false, || {}).unwrap();
10471080 section.set_offline(true);
10481081 let space = section.pages().unwrap()[0].0;
10491082 let before = section.page(space).unwrap();
......@@ -1070,7 +1103,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {
10701103 drop(background);
10711104 let mut reopened = None;
10721105 until("the background released the replica", || {
1073 reopened = notebook.section("Second.one", || {}).ok();
1106 reopened = notebook.section("Second.one", false, || {}).ok();
10741107 reopened.is_some()
10751108 });
10761109 let reopened = reopened.unwrap();
......@@ -1084,7 +1117,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
10841117 let mut notebook = two_sections(directory.path());
10851118 // Second has a replica from being opened once; First has never been opened.
10861119 notebook
1087 .section("Second.one", || {})
1120 .section("Second.one", false, || {})
10881121 .unwrap()
10891122 .close()
10901123 .unwrap();
......@@ -1190,7 +1223,7 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {
11901223 .all(|(_, status)| status.synced.is_some())
11911224 });
11921225 // 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();
11941227 section.set_offline(true);
11951228 let space = section.pages().unwrap()[0].0;
11961229 let before = section.page(space).unwrap();
......@@ -1313,6 +1346,7 @@ fn a_moved_section_keeps_its_replica_while_the_old_remote_is_stalled() {
13131346 late_error,
13141347 })
13151348 },
1349 false,
13161350 || {},
13171351 )
13181352 .unwrap();
......@@ -1392,6 +1426,7 @@ fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_i
13921426 .section_with(
13931427 "Second.one",
13941428 move |file| Ok(Counted(file.to_owned(), Arc::clone(&counter))),
1429 false,
13951430 || {},
13961431 )
13971432 .unwrap();
crates/notebook/tests/smb_queue.rs+1
......@@ -197,6 +197,7 @@ fn live_session_applies_edits_through_the_embedded_client_and_reopens() {
197197 Duration::from_secs(5),
198198 )
199199 },
200 false,
200201 || {},
201202 )
202203 .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
121121 let replica = notebook.replica_path(path).unwrap();
122122 std::fs::create_dir_all(replica.parent().unwrap()).unwrap();
123123 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()
125125}
126126
127127pub 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(
2626 _: &Path,
2727 path: &str,
2828 key: Option<&onestore::protected::Key>,
29 offline: bool,
2930 notify: impl Fn() + Send + 'static,
3031) -> Result<Section, notebook::Error> {
3132 match key {
32 Some(key) => notebook.section_unlocked(path, key, notify),
33 None => notebook.section(path, notify),
33 Some(key) => notebook.section_unlocked(path, key, offline, notify),
34 None => notebook.section(path, offline, notify),
3435 }
3536}
3637
3738pub fn lone_section(
3839 file: &Path,
3940 cache: &Path,
41 offline: bool,
4042 notify: impl Fn() + Send + 'static,
4143) -> Result<Section, notebook::Error> {
42 Section::open(file, cache, notify)
44 Section::open(file, cache, offline, notify)
4345}
4446
4547pub fn background(
crates/snowbound/src/icloud_macos.rs+12-4
......@@ -365,6 +365,7 @@ pub fn section(
365365 root: &Path,
366366 path: &str,
367367 key: Option<&onestore::protected::Key>,
368 offline: bool,
368369 notify: impl Fn() + Send + 'static,
369370) -> Result<Section, notebook::Error> {
370371 if !notebook.replica_path(path)?.exists() {
......@@ -372,8 +373,8 @@ pub fn section(
372373 }
373374 let connect = |file: &Path| Ok(Coordinated(file.to_owned()));
374375 let section = match key {
375 Some(key) => notebook.section_unlocked_with(path, key, connect, notify)?,
376 None => notebook.section_with(path, connect, notify)?,
376 Some(key) => notebook.section_unlocked_with(path, key, connect, offline, notify)?,
377 None => notebook.section_with(path, connect, offline, notify)?,
377378 };
378379 section.set_pause(PAUSE);
379380 Ok(section)
......@@ -392,10 +393,17 @@ fn fetch(file: &Path) -> io::Result<()> {
392393pub fn lone_section(
393394 file: &Path,
394395 cache: &Path,
396 offline: bool,
395397 notify: impl Fn() + Send + 'static,
396398) -> Result<Section, notebook::Error> {
397399 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 )?;
399407 section.set_pause(PAUSE);
400408 Ok(section)
401409}
......@@ -582,7 +590,7 @@ mod tests {
582590 let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap();
583591 assert!(ubiquitous(&root));
584592 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();
586594 let (space, ..) = section.pages().unwrap()[0].clone();
587595 let title = section
588596 .page(space)
crates/snowbound/src/library.rs+21-7
......@@ -991,7 +991,13 @@ impl Library {
991991 })
992992 .and_then(|replica| {
993993 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 )
9951001 })
9961002 }
9971003 #[cfg(target_arch = "wasm32")]
......@@ -1010,21 +1016,28 @@ impl Library {
10101016 replica.and_then(|replica| {
10111017 let server = Arc::clone(server);
10121018 let connect = move || server.connect();
1013 Section::resume_smb(file, replica, LIMIT, connect, notifier())
1019 Section::resume_smb(file, replica, LIMIT, connect, offline(), notifier())
10141020 })
10151021 }
10161022 (Ok(Some(notebook)), None) if self.in_icloud() => {
10171023 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 )
10191032 }
10201033 (Ok(Some(notebook)), None) => match &key {
1021 Some(key) => notebook.section_unlocked(path, key, notifier()),
1022 None => notebook.section(path, notifier()),
1034 Some(key) => notebook.section_unlocked(path, key, offline(), notifier()),
1035 None => notebook.section(path, offline(), notifier()),
10231036 },
10241037 (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())
10261039 }
1027 (Ok(None), _) => Section::open(path, &self.cache, notifier()),
1040 (Ok(None), _) => Section::open(path, &self.cache, offline(), notifier()),
10281041 (Err(error), _) => return Err(error.clone().into()),
10291042 };
10301043 match opened {
......@@ -1763,6 +1776,7 @@ mod tests {
17631776 release: Arc::clone(&gate),
17641777 })
17651778 },
1779 false,
17661780 || {},
17671781 )
17681782 .unwrap();
crates/snowbound/src/main.rs+3-3
......@@ -7246,7 +7246,7 @@ mod tests {
72467246 )
72477247 .unwrap();
72487248 let section =
7249 notebook::session::Section::open(&file, directory.join("cache"), || {}).unwrap();
7249 notebook::session::Section::open(&file, directory.join("cache"), false, || {}).unwrap();
72507250 let mut engine = TextEngine::default();
72517251 for (space, ..) in section.pages().unwrap().into_iter().take(3) {
72527252 let mut editor =
......@@ -7348,7 +7348,7 @@ mod tests {
73487348 &file,
73497349 )
73507350 .unwrap();
7351 let ours = Section::open(&file, directory.join("ours"), || {}).unwrap();
7351 let ours = Section::open(&file, directory.join("ours"), false, || {}).unwrap();
73527352 let space = ours.pages().unwrap()[0].0;
73537353 let mut engine = TextEngine::default();
73547354 let mut editor = CanvasEditor::from_page(ours.page(space).unwrap(), &mut engine).unwrap();
......@@ -7378,7 +7378,7 @@ mod tests {
73787378 "{seen:?}"
73797379 );
73807380
7381 let theirs = Section::open(&file, directory.join("theirs"), || {}).unwrap();
7381 let theirs = Section::open(&file, directory.join("theirs"), false, || {}).unwrap();
73827382 let mut other = CanvasEditor::from_page(theirs.page(space).unwrap(), &mut engine).unwrap();
73837383 other.focus_outline(outline).unwrap();
73847384 let last = other.active_outline().document().paragraphs().count() - 1;