diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index 55bb06d08e32e2ef30a0d3310eb1795b541ec807..eae5500df38c6a32f2856013f38014c4724b9e7e 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -631,11 +631,13 @@ impl Library { path: &str, notify: impl Fn() + Send + 'static, ) -> Result { + let offline = self.offline.load(Ordering::Relaxed); match &self.place { Place::File(file) => Ok(session::Section::open_with( file, &self.cache, |file| Ok(Coordinated(file.to_owned())), + offline, notify, )?), Place::Folder(_) => { @@ -643,8 +645,10 @@ impl Library { let notebook = notebook.as_ref().ok_or("A lone section has no notebook")?; let connect = |file: &Path| Ok(Coordinated(file.to_owned())); Ok(match self.unlocked().get(path) { - Some(key) => notebook.section_unlocked_with(path, key, connect, notify)?, - None => notebook.section_with(path, connect, notify)?, + Some(key) => { + notebook.section_unlocked_with(path, key, connect, offline, notify)? + } + None => notebook.section_with(path, connect, offline, notify)?, }) } Place::Share { server, root, .. } => { @@ -682,6 +686,7 @@ impl Library { replica, LIMIT, move || server.connect(), + offline, notify, )?) } @@ -857,6 +862,9 @@ impl Section { .open .lock() .unwrap_or_else(|error| error.into_inner()); + shared + .section + .set_offline(library.offline.load(Ordering::Relaxed)); open.push((path.to_owned(), Arc::downgrade(&shared))); drop(open); Ok(Self { shared, library }) @@ -1785,13 +1793,24 @@ pub extern "C" fn sb_library_sync_status(library: &Library) -> *mut c_char { /// `sb_library_sync_now`. Sections opened later follow. #[unsafe(no_mangle)] pub extern "C" fn sb_library_set_offline(library: &Library, offline: bool) { + let mut open = library + .open + .lock() + .unwrap_or_else(|error| error.into_inner()); library.offline.store(offline, Ordering::Relaxed); if let Some(background) = &library.background { background.set_offline(offline); } - for (_, shared) in library.open_sections() { + open.retain(|(_, shared)| shared.strong_count() > 0); + let sections: Vec<_> = open + .iter() + .filter_map(|(_, shared)| shared.upgrade()) + .collect(); + for shared in §ions { shared.section.set_offline(offline); } + // Releasing the last Shared can join its replica's threads. + drop(open); } /// Checks the sections at or below `path`, `/`-separated from the notebook's folder, as the diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index d61e8bc604131a64ab6833118d124fe0a03166d8..35608a5e858586e99743e5f6c750874c8b8fefdc 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -376,6 +376,91 @@ fn cached_cloud_sections_reopen_with_offline_edits_after_eviction() { assert_eq!(stored.page(space).unwrap().color, Some(0x00ef_cdab)); } +#[test] +fn reopening_a_reachable_source_while_offline_retains_its_original_queue() { + extern "C" fn wake(_: usize) {} + + for lone in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("notebook"); + let cache = directory.path().join("cache"); + let creation = onestore::PageCreation::new(None, Some("Original"), "Fixture").unwrap(); + let notebook = + notebook::session::Notebook::create(&root, &cache, 0x00f0_a0c0, &creation).unwrap(); + let path = notebook.catalog().sections[0].path.clone(); + drop(notebook); + let file = root.join(&path); + let source = std::fs::read(&file).unwrap(); + let (location, path) = if lone { + (file.clone(), file.to_string_lossy().into_owned()) + } else { + (root, path) + }; + let library = Arc::new(Library::open(&location, &cache, false).unwrap()); + sb_library_set_offline(&library, true); + let section = open(&library, &path); + let creation = + onestore::PageCreation::new(None, Some("Mobile offline reopen marker"), "Fixture") + .unwrap(); + section + .shared + .apply(Edit { + at: library::filetime(), + ops: vec![Op::Section(onestore::op::SectionOp::Create(creation))], + }) + .unwrap(); + section.shared.section.replica().written().unwrap(); + let pending = section.shared.section.pending().unwrap(); + assert_eq!(pending.len(), 1); + let shared = Arc::clone(§ion.shared); + drop(section); + Arc::into_inner(shared).unwrap().section.close().unwrap(); + let path = std::ffi::CString::new(path).unwrap(); + for reopen in 0..2 { + let mut error = std::ptr::null_mut(); + let raw = unsafe { + library::sb_section_open( + &library, + path.as_ptr(), + c"Fixture".as_ptr(), + wake, + 0, + &mut error, + ) + }; + assert!(error.is_null()); + assert!(!raw.is_null()); + let section = unsafe { Box::from_raw(raw) }; + for _ in 0..20 { + assert_eq!(section.shared.section.pending().unwrap(), pending); + assert_eq!(std::fs::read(&file).unwrap(), source); + std::thread::sleep(Duration::from_millis(10)); + } + if reopen == 1 { + sb_library_sync_now(&library); + assert!(eventually(|| section + .shared + .section + .pending() + .unwrap() + .is_empty())); + let pages = + notebook::session::stored_pages(&std::fs::read(&file).unwrap()).unwrap(); + assert_eq!( + pages + .iter() + .filter(|page| page.page.title == "Mobile offline reopen marker") + .count(), + 1 + ); + } + let shared = Arc::clone(§ion.shared); + drop(section); + Arc::into_inner(shared).unwrap().section.close().unwrap(); + } + } +} + #[test] fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin() { let (directory, root) = copy("m6/native-features-01/notebook"); @@ -666,8 +751,53 @@ fn a_notebook_on_a_share_saves_and_opens_offline() { let arena = onestore::Arena::default(); let stored = onestore::Section::open(&arena, published).unwrap(); assert!(canvas::search::page_text(&stored.page(canvas.space).unwrap()).contains("Shared ")); + sb_library_set_offline(&library, true); + let source = client + .read(&format!("{root}/Features.one"), 1 << 26) + .unwrap(); + canvas.insert("Reachable offline marker ".into()).unwrap(); + section + .shared + .apply(canvas.edit().unwrap().unwrap()) + .unwrap(); + section.shared.section.replica().written().unwrap(); + let pending = section.shared.section.pending().unwrap(); + assert_eq!(pending.len(), 1); + let page_space = canvas.space; let shared = Arc::clone(§ion.shared); - drop((canvas, section, library)); + drop((canvas, section)); + Arc::into_inner(shared).unwrap().section.close().unwrap(); + let section = open(&library, "Features.one"); + for _ in 0..10 { + assert_eq!(section.shared.section.pending().unwrap(), pending); + assert_eq!( + client + .read(&format!("{root}/Features.one"), 1 << 26) + .unwrap(), + source + ); + std::thread::sleep(Duration::from_millis(20)); + } + sb_library_sync_now(&library); + assert!(eventually(|| section + .shared + .section + .pending() + .unwrap() + .is_empty())); + let arena = onestore::Arena::default(); + let published = client + .read(&format!("{root}/Features.one"), 1 << 26) + .unwrap(); + let stored = onestore::Section::open(&arena, published).unwrap(); + assert_eq!( + canvas::search::page_text(&stored.page(page_space).unwrap()) + .matches("Reachable offline marker ") + .count(), + 1 + ); + let shared = Arc::clone(§ion.shared); + drop((section, library)); Arc::into_inner(shared).unwrap().section.close().unwrap(); // The same notebook while its server does not answer: a closed port on the lab's host. let host = address.split(':').next().unwrap(); diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 405af56e90ea92a735715c39430ec9664ca83ac2..c07caced6ae3bd69dfd18f64f0b310aaffa03dcb 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -52,14 +52,15 @@ thread rereads the queue. `session::Notebook::open(root, cache_dir)` discovers a notebook directory, reading only the files its folders list otherwise than when last opened (`discover::Cache`, kept under `cache_dir/listings`), and -`session::Section::open(file, cache_dir, notify)` opens one section file through +`session::Section::open(file, cache_dir, offline, notify)` opens one section file through a replica named by the file's location and the section's document identity, so the same file reopens the same queue after a relaunch and a copy of it elsewhere has its own (`notebook::location`: `folder`, `local`, `smb`, `moved` for a notebook the app moves, and `forget` for one it deletes). -`Notebook::section(path, notify)` opens a mounted notebook's section; a replica already +`Notebook::section(path, offline, notify)` opens a mounted notebook's section; a replica already named by the identity its discovery read resumes without reading the file, which the -worker checks next, as on a share. +worker checks next, as on a share. `offline` seeds Work Offline before the worker starts; +explicit `wake()` still synchronizes while offline. A section is `Send + Sync`: `apply(author, edit)` returns at once and reports a refusal as `Event::Rejected`; `events()` drains remote changes (`Changed(spaces)`), publication outcomes, unreachable files and failures; `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 thread and deletes the replicas holding nothing unpublished, as when the notebook closes. `Notebook::replica_path(path)` names a section's replica for either kind of notebook. -`Section::resume(file, replica, notify)` starts from an owned `Replica` without +`Section::resume(file, replica, offline, notify)` starts from an owned `Replica` without consulting the remote file; with the `smb` feature, `Section::resume_smb(path, -replica, limit, connect, notify)` binds a share-relative path, `connect` running on +replica, limit, connect, offline, notify)` binds a share-relative path, `connect` running on the worker again after transport failure. ## Reconciliation @@ -358,7 +359,7 @@ sections it has not opened this way, and unread changes leave out the reader's o A password-protected section lists as `Locked`. `Notebook::unlock(path, password)` opens its key (a wrong password is `Error::Protected(PasswordMismatch)`), and -`section_unlocked(path, &key, notify)` (`section_unlocked_with`, or +`section_unlocked(path, &key, offline, notify)` (`section_unlocked_with`, or `Replica::open_or_create(path, Some(&key), source)` for a host that opens replicas itself) opens it as any section opens: edits queue, publish, merge and become conflict pages as an ordinary section's do, each 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) `Welcome` (the share and its secret), or a `Refusal` saying why not: a wrong code, no one sharing it, an expired code, too many wrong tries, or no relay. `Guest::start` joins the share; `Notebook::open_hosted(guest, cache)`, `Background::hosted(guest, notify)` and -`Section::resume_hosted(path, replica, guest, notify)` then work as on a share, with +`Section::resume_hosted(path, replica, guest, offline, notify)` then work as on a share, with `Guest::host()` and `stopped()` for whether the host is there and still sharing. ## Queue measurement diff --git a/crates/notebook/examples/browser_receipts.rs b/crates/notebook/examples/browser_receipts.rs index 88d4b08d0e0a47b6e3c2da255b295e6613940f15..5cc751328102d4c6329293b0b512da6606dd6e04 100644 --- a/crates/notebook/examples/browser_receipts.rs +++ b/crates/notebook/examples/browser_receipts.rs @@ -190,7 +190,7 @@ mod web { ) .unwrap(); let original_cache = notebook.replica_path("New Section 1.one").unwrap(); - let section = notebook.section("New Section 1.one", || {}).unwrap(); + let section = notebook.section("New Section 1.one", false, || {}).unwrap(); section.set_offline(true); let (space, text_id, _) = text(§ion.replica().snapshot().unwrap()); let replica = Arc::clone(section.replica()); diff --git a/crates/notebook/examples/live_crowd.rs b/crates/notebook/examples/live_crowd.rs index 7bc4e6912386a8ad36979240e8992185093e1548..6a98337d8f43a9a93f7ebb30ff51b055df83e6be 100644 --- a/crates/notebook/examples/live_crowd.rs +++ b/crates/notebook/examples/live_crowd.rs @@ -191,8 +191,14 @@ fn join(name: &str, code: &str, url: &str, cache: &Path) -> Open { let image = notebook.read_section("Garden.one").unwrap(); let file = onestore::Header::parse(&image[..1024]).unwrap().file_id; let replica = Replica::open_or_create(&replica, None, || Ok(image)).unwrap(); - let section = - Section::resume_hosted("Garden.one".into(), replica, Arc::clone(&guest), || {}).unwrap(); + let section = Section::resume_hosted( + "Garden.one".into(), + replica, + Arc::clone(&guest), + false, + || {}, + ) + .unwrap(); background.hold("Garden.one", §ion); Open { guest, diff --git a/crates/notebook/examples/live_latency.rs b/crates/notebook/examples/live_latency.rs index 942a1c94a3e0959994a342a00c4b904ee188d7a2..fd2161ed74b7ddd47bb80ed4bb16630d246c5462 100644 --- a/crates/notebook/examples/live_latency.rs +++ b/crates/notebook/examples/live_latency.rs @@ -69,8 +69,14 @@ fn guest( std::fs::create_dir_all(replica.parent().unwrap()).unwrap(); let replica = Replica::open_or_create(&replica, None, || notebook.read_section("Garden.one")).unwrap(); - let section = - Section::resume_hosted("Garden.one".into(), replica, Arc::clone(&guest), || {}).unwrap(); + let section = Section::resume_hosted( + "Garden.one".into(), + replica, + Arc::clone(&guest), + false, + || {}, + ) + .unwrap(); background.hold("Garden.one", §ion); ( guest, diff --git a/crates/notebook/examples/open_probe.rs b/crates/notebook/examples/open_probe.rs index aa7c8c2fd10ca6e61a6eacbaa4549f57ccbccbc7..a6570a5741b9de83db6777c9e32f550d73c8dd42 100644 --- a/crates/notebook/examples/open_probe.rs +++ b/crates/notebook/examples/open_probe.rs @@ -133,7 +133,7 @@ fn main() -> Result<(), Box> { println!("section {} bytes", source.len()); for label in ["fresh cache", "reopened cache"] { let start = Instant::now(); - let section = Section::open(&file, &cache, || {})?; + let section = Section::open(&file, &cache, false, || {})?; let opened = start.elapsed(); let pages = section.pages()?; let listed = start.elapsed(); diff --git a/crates/notebook/examples/save_probe.rs b/crates/notebook/examples/save_probe.rs index 44fa95f0e0e5e23d21370453b04c105f663bea1c..e4d64c7ab12b70b9ce174e0d822af10df7bbc4ca 100644 --- a/crates/notebook/examples/save_probe.rs +++ b/crates/notebook/examples/save_probe.rs @@ -21,7 +21,7 @@ fn main() -> Result<(), Box> { let directory = tempfile::tempdir()?; let file = directory.path().join("probe.one"); std::fs::write(&file, std::fs::read(source)?)?; - let section = Section::open(&file, directory.path().join("cache"), || {})?; + let section = Section::open(&file, directory.path().join("cache"), false, || {})?; let words = [ "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog", ]; diff --git a/crates/notebook/examples/session_client.rs b/crates/notebook/examples/session_client.rs index 06f0f0109f87f17dd635bc9b32977647d1e28a95..642bb8ccd682ca4aab1ee209e0319f458a8493ba 100644 --- a/crates/notebook/examples/session_client.rs +++ b/crates/notebook/examples/session_client.rs @@ -115,7 +115,7 @@ fn main() -> Result<(), Box> { return Err("Usage: session_client SECTION CACHE_DIR LABEL".into()); }; let (notify, notified) = mpsc::channel(); - let section = Section::open(file, cache, move || { + let section = Section::open(file, cache, false, move || { let _ = notify.send(()); })?; let pending = section.pending()?; diff --git a/crates/notebook/src/background.rs b/crates/notebook/src/background.rs index c856043c4a9fd0c94d2c0ebbd83e163368a20701..d713fbb37ec1fb3295ea946589cef83e7e92fc16 100644 --- a/crates/notebook/src/background.rs +++ b/crates/notebook/src/background.rs @@ -1479,7 +1479,7 @@ mod tests { let source = onestore::create_section("Held.one", "", "Author").unwrap(); fs::write(&file, &source).unwrap(); let (changed, changes) = mpsc::channel(); - let section = Section::open(&file, directory.path().join("cache"), move || { + let section = Section::open(&file, directory.path().join("cache"), false, move || { let _ = changed.send(()); }) .unwrap(); diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 6adfb5898b32570119fe5ba05af88437d4a73134..a5b020f693bf8ed1000045c1013bb8facb6bf87f 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -1748,8 +1748,18 @@ impl Notebook { } /// Opens a section of a mounted notebook by its catalog path. - pub fn section(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result
{ - self.section_with(path, |file| Ok(FileRemote(file.to_owned())), notify) + pub fn section( + &self, + path: &str, + offline: bool, + notify: impl Fn() + Send + 'static, + ) -> Result
{ + self.section_with( + path, + |file| Ok(FileRemote(file.to_owned())), + offline, + notify, + ) } /// `section`, reading and publishing through the remote `connect` makes for its file @@ -1758,9 +1768,10 @@ impl Notebook { &self, path: &str, connect: impl FnMut(&Path) -> io::Result + Send + 'static, + offline: bool, notify: impl Fn() + Send + 'static, ) -> Result
{ - self.open_section(path, None, connect, notify) + self.open_section(path, None, connect, offline, notify) } /// `section` for a password-protected section, under the `key` `unlock` gave. @@ -1768,9 +1779,16 @@ impl Notebook { &self, path: &str, key: &protected::Key, + offline: bool, notify: impl Fn() + Send + 'static, ) -> Result
{ - self.section_unlocked_with(path, key, |file| Ok(FileRemote(file.to_owned())), notify) + self.section_unlocked_with( + path, + key, + |file| Ok(FileRemote(file.to_owned())), + offline, + notify, + ) } /// `section_with` for a password-protected section, under its `key`. @@ -1779,9 +1797,10 @@ impl Notebook { path: &str, key: &protected::Key, connect: impl FnMut(&Path) -> io::Result + Send + 'static, + offline: bool, notify: impl Fn() + Send + 'static, ) -> Result
{ - self.open_section(path, Some(key), connect, notify) + self.open_section(path, Some(key), connect, offline, notify) } fn open_section( @@ -1789,6 +1808,7 @@ impl Notebook { path: &str, key: Option<&protected::Key>, connect: impl FnMut(&Path) -> io::Result + Send + 'static, + offline: bool, notify: impl Fn() + Send + 'static, ) -> Result
{ let section = self.section_path(path)?; @@ -1823,14 +1843,14 @@ impl Notebook { if fs::metadata(&cache).is_ok() { let (replica, remote, mut connect) = (Replica::open(&cache)?, file.clone(), connect); - return Section::start(file, replica, move || connect(&remote), notify); + return Section::start(file, replica, move || connect(&remote), offline, notify); } } let replica = |identity: &[u8; 16], _: &[u8]| { fs::create_dir_all(&replicas)?; Ok(replica_file(&replicas, identity)) }; - Section::open_in(file, key, replica, connect, notify) + Section::open_in(file, key, replica, connect, offline, notify) } } @@ -2217,9 +2237,16 @@ impl Section { pub fn open( file: impl AsRef, cache: impl AsRef, + offline: bool, notify: impl Fn() + Send + 'static, ) -> Result { - Self::open_with(file, cache, |file| Ok(FileRemote(file.to_owned())), notify) + Self::open_with( + file, + cache, + |file| Ok(FileRemote(file.to_owned())), + offline, + notify, + ) } /// `open`, reading and publishing the file through the remote `connect` makes for it, @@ -2229,6 +2256,7 @@ impl Section { file: impl AsRef, cache: impl AsRef, connect: impl FnMut(&Path) -> io::Result + Send + 'static, + offline: bool, notify: impl Fn() + Send + 'static, ) -> Result { let file = fs::canonicalize(file)?; @@ -2239,7 +2267,7 @@ impl Section { })?; Ok(replica_file(&folder, identity)) }; - Self::open_in(file, None, replicas, connect, notify) + Self::open_in(file, None, replicas, connect, offline, notify) } /// Opens the canonical section `file`, a protected one under `key`, through the replica @@ -2249,6 +2277,7 @@ impl Section { key: Option<&protected::Key>, replica: impl FnOnce(&[u8; 16], &[u8]) -> Result, mut connect: impl FnMut(&Path) -> io::Result + Send + 'static, + offline: bool, notify: impl Fn() + Send + 'static, ) -> Result { let source = connect(&file)?.read()?; @@ -2257,7 +2286,7 @@ impl Section { let cache = replica(&identity.guid, &source)?; let replica = Replica::open_or_create(&cache, key, || Ok(source))?; let remote = file.clone(); - Self::start(file, replica, move || connect(&remote), notify) + Self::start(file, replica, move || connect(&remote), offline, notify) } /// Resumes an owned local replica without reading the publication target. @@ -2265,6 +2294,7 @@ impl Section { pub fn resume( file: impl AsRef, replica: Replica, + offline: bool, notify: impl Fn() + Send + 'static, ) -> Result { let file = fs::absolute(file)?; @@ -2273,6 +2303,7 @@ impl Section { file, replica, move || Ok(FileRemote(remote.clone())), + offline, notify, ) } @@ -2285,12 +2316,14 @@ impl Section { replica: Replica, limit: usize, mut connect: impl FnMut() -> io::Result + Send + 'static, + offline: bool, notify: impl Fn() + Send + 'static, ) -> Result { Self::start( PathBuf::from(&path), replica, move || Ok(crate::SmbRemote::new(connect()?, path.clone(), limit)), + offline, notify, ) } @@ -2301,12 +2334,14 @@ impl Section { path: String, replica: Replica, guest: Arc, + offline: bool, notify: impl Fn() + Send + 'static, ) -> Result { Self::start( PathBuf::from(&path), replica, move || Ok(crate::live::share::HostedRemote::new(&guest, &path)), + offline, notify, ) } @@ -2315,6 +2350,7 @@ impl Section { file: PathBuf, replica: Replica, connect: impl FnMut() -> io::Result + Send + 'static, + offline: bool, notify: impl Fn() + Send + 'static, ) -> Result { let replica = Arc::new(replica); @@ -2335,19 +2371,21 @@ impl Section { notify, observed, }; - section.worker = Some(section.start_worker(connect, None)?); + section.worker = Some(section.start_worker(connect, offline, None)?); Ok(section) } fn start_worker( &self, connect: impl FnMut() -> io::Result + Send + 'static, + offline: bool, replacing: Option<&SyncWorker>, ) -> Result { let (sender, notify) = (self.sender.clone(), Arc::clone(&self.notify)); let observed = Arc::clone(&self.observed); Ok(self.replica.start_sync_replacing( Duration::from_secs(2), + offline, connect, move |result| { let error = result.as_ref().err().map(reached); @@ -2413,7 +2451,8 @@ impl Section { worker.cancel(); } self.observed = Arc::new(Mutex::new(None)); - self.worker = Some(self.start_worker(move || connect(&target), previous.as_ref())?); + self.worker = + Some(self.start_worker(move || connect(&target), false, previous.as_ref())?); self.file = file; Ok(()) } diff --git a/crates/notebook/src/session/tests.rs b/crates/notebook/src/session/tests.rs index 9c949a74b5b8a82c695bfad91d2fe33554e9248d..3a0aebeddff713a6abd37e2d6b30a36e4f2f7a4b 100644 --- a/crates/notebook/src/session/tests.rs +++ b/crates/notebook/src/session/tests.rs @@ -9,14 +9,18 @@ fn cached_sections_refuse_links_outside_the_notebook_and_dangling_links() { let creation = PageCreation::new(None, Some("Mine"), "Fixture").unwrap(); let notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap(); let path = ¬ebook.catalog().sections[0].path; - notebook.section(path, || {}).unwrap().close().unwrap(); + notebook + .section(path, false, || {}) + .unwrap() + .close() + .unwrap(); let file = folder.join(path); let outside = temporary.path().join("outside.one"); fs::rename(&file, &outside).unwrap(); std::os::unix::fs::symlink(&outside, &file).unwrap(); - assert!(notebook.section(path, || {}).is_err()); + assert!(notebook.section(path, false, || {}).is_err()); fs::remove_file(outside).unwrap(); - assert!(notebook.section(path, || {}).is_err()); + assert!(notebook.section(path, false, || {}).is_err()); } #[test] @@ -501,3 +505,122 @@ fn recycling_rejects_unconfirmed_appends_and_can_retry_each_stage() { assert_eq!(fs::read(folder.join(&path)).unwrap(), source); } } + +#[test] +fn initially_offline_workers_keep_the_original_queue_until_explicitly_woken() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Instant; + + struct Counted { + file: FileRemote, + calls: Arc<[AtomicUsize; 5]>, + } + impl Remote for Counted { + fn read(&mut self) -> io::Result> { + self.calls[1].fetch_add(1, Ordering::SeqCst); + self.file.read() + } + fn stamp(&mut self) -> io::Result { + self.calls[2].fetch_add(1, Ordering::SeqCst); + self.file.stamp() + } + fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> { + self.calls[3].fetch_add(1, Ordering::SeqCst); + self.file.publish(transaction) + } + fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> { + self.calls[4].fetch_add(1, Ordering::SeqCst); + self.file.confirm(base) + } + } + + for relocate in [false, true] { + let temporary = tempfile::tempdir().unwrap(); + let file = temporary.path().join("Original.one"); + let cache = temporary.path().join("original.sqlite"); + let source = onestore::create_section("Original.one", "Original", "Fixture").unwrap(); + fs::write(&file, &source).unwrap(); + let replica = Replica::create(&cache, &source).unwrap(); + let creation = + PageCreation::new(None, Some("Offline initialization marker"), "Fixture").unwrap(); + let id = replica + .apply( + "Fixture", + Edit { + at: crate::now(), + ops: vec![Op::Section(SectionOp::Create(creation))], + }, + ) + .unwrap(); + let pending = replica.pending().unwrap(); + drop(replica); + let calls = Arc::new(std::array::from_fn(|_| AtomicUsize::new(0))); + let counter = Arc::clone(&calls); + let remote = file.clone(); + let (notify, notified) = mpsc::channel(); + let mut section = Section::start( + file.clone(), + Replica::open(&cache).unwrap(), + move || { + counter[0].fetch_add(1, Ordering::SeqCst); + Ok(Counted { + file: FileRemote(remote.clone()), + calls: Arc::clone(&counter), + }) + }, + true, + move || { + let _ = notify.send(()); + }, + ) + .unwrap(); + let target = if relocate { + let target = temporary.path().join("Moved.one"); + fs::rename(&file, &target).unwrap(); + let counter = Arc::clone(&calls); + section + .relocate_with(&target, move |file| { + counter[0].fetch_add(1, Ordering::SeqCst); + Ok(Counted { + file: FileRemote(file.to_owned()), + calls: Arc::clone(&counter), + }) + }) + .unwrap(); + target + } else { + file + }; + let deadline = Instant::now() + Duration::from_millis(300); + while Instant::now() < deadline { + assert_eq!(section.pending().unwrap(), pending); + assert_eq!(fs::read(&target).unwrap(), source); + assert!(calls.iter().all(|count| count.load(Ordering::SeqCst) == 0)); + std::thread::sleep(Duration::from_millis(10)); + } + section.wake(); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + notified + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .unwrap(); + if section.events().iter().any(|event| matches!(event, + Event::Attempt { id: published, status: EditStatus::Published { .. } } if *published == id)) { + break; + } + } + assert!(section.pending().unwrap().is_empty()); + assert_eq!(calls[0].load(Ordering::SeqCst), 1); + assert_eq!(calls[3].load(Ordering::SeqCst), 1); + section.close().unwrap(); + let pages = stored_pages(&fs::read(&target).unwrap()).unwrap(); + assert_eq!( + pages + .iter() + .filter(|page| page.page.title == "Offline initialization marker") + .count(), + 1 + ); + assert!(Replica::open(&cache).unwrap().pending().unwrap().is_empty()); + } +} diff --git a/crates/notebook/src/smb/tests.rs b/crates/notebook/src/smb/tests.rs index 519ee28aa4819d8dfc57cab84863f280256b3872..3c00cccb08ce7bdafe4337ac5e99d4fafe983c30 100644 --- a/crates/notebook/src/smb/tests.rs +++ b/crates/notebook/src/smb/tests.rs @@ -783,7 +783,7 @@ fn live_structure() { let header = onestore::Store::parse(&renamed).unwrap().header; assert_eq!(header.ancestor, toc_id); assert_eq!(header.name_crc, 0x6108912a); - assert!(fresh.section("Renamed.one", || {}).is_err()); + assert!(fresh.section("Renamed.one", false, || {}).is_err()); // A password supersedes the file under writer coordination, never over a newer stamp, and // while a reader holds it open, as OneNote's readers share it with deletion. let section = format!("{root}/Renamed.one"); diff --git a/crates/notebook/src/worker.rs b/crates/notebook/src/worker.rs index f9526500b75e3b8e9b08a02c25c098934a7186fc..2bff7a5be4c2ef1c270af3f5f53c162eaef6e8a8 100644 --- a/crates/notebook/src/worker.rs +++ b/crates/notebook/src/worker.rs @@ -179,12 +179,13 @@ impl Replica { F: FnMut() -> io::Result + Send + 'static, O: FnMut(&Result) + Send + 'static, { - self.start_sync_replacing(interval, connect, observe, None) + self.start_sync_replacing(interval, false, connect, observe, None) } pub(crate) fn start_sync_replacing( self: &Arc, interval: Duration, + offline: bool, mut connect: F, mut observe: O, replacing: Option<&SyncWorker>, @@ -214,6 +215,7 @@ impl Replica { return Err(io::ErrorKind::WouldBlock.into()); } let (signal, receiver) = Signal::new(); + signal.offline.store(offline, Ordering::Release); if let Some(previous) = previous { signal .offline diff --git a/crates/notebook/tests/copy.rs b/crates/notebook/tests/copy.rs index 206c5fa9c3d8805e54203c35e3680613a740bccc..c9297bcbefa9f2e858666ef812e3bdf4130f0472 100644 --- a/crates/notebook/tests/copy.rs +++ b/crates/notebook/tests/copy.rs @@ -152,7 +152,7 @@ fn pages_copy_into_another_section_with_their_content_and_fresh_identities() { onestore::create_section("copies.one", "Destination", "Author").unwrap(), ) .unwrap(); - let section = Section::open(&file, &cache, || {}).unwrap(); + let section = Section::open(&file, &cache, false, || {}).unwrap(); let mut expected = Vec::new(); let mut grouped = Vec::new(); for source in SOURCES { diff --git a/crates/notebook/tests/links.rs b/crates/notebook/tests/links.rs index 429f140f2e564863b03cc397cc83102a2c8a5046..fdc5f297eabbc35be6e5775693a5f6a951a9b487 100644 --- a/crates/notebook/tests/links.rs +++ b/crates/notebook/tests/links.rs @@ -30,7 +30,7 @@ fn internal_links_find_their_page_by_identity() { ) .unwrap(); let mut notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap(); - let section = notebook.section("First.one", || {}).unwrap(); + let section = notebook.section("First.one", false, || {}).unwrap(); let (space, _, _) = section.pages().unwrap()[0]; let page = section.page(space).unwrap(); section.close().unwrap(); diff --git a/crates/notebook/tests/live_share.rs b/crates/notebook/tests/live_share.rs index 6ec3cd9e0bf1e8684dc56415882b7af92df585f6..a98ef1f3546f76192417fc0f30f57212ef2b45bc 100644 --- a/crates/notebook/tests/live_share.rs +++ b/crates/notebook/tests/live_share.rs @@ -53,6 +53,62 @@ fn a_guest_edits_the_host_s_notebook() { }); } +#[test] +fn an_offline_guest_reopens_its_queue_while_the_host_remains_reachable() { + use notebook::{Remote, Replica, live::share::HostedRemote, session::Section}; + use std::{thread, time::Duration}; + + let directory = tempfile::tempdir().unwrap(); + let folder = notebook(directory.path()); + let url = relay(Default::default()); + let sharing = Sharing::new("").unwrap(); + let host = host(&folder, &directory.path().join("host"), &sharing, &url); + let (guest, notebook) = guest("Grace", &code(&host), &url, &directory.path().join("grace")); + let file = folder.join("Garden.one"); + let source = std::fs::read(&file).unwrap(); + let cache = notebook.replica_path("Garden.one").unwrap(); + std::fs::create_dir_all(cache.parent().unwrap()).unwrap(); + let replica = + Replica::open_or_create(&cache, None, || notebook.read_section("Garden.one")).unwrap(); + let section = Section::resume_hosted( + "Garden.one".into(), + replica, + Arc::clone(&guest), + true, + || {}, + ) + .unwrap(); + let id = replace(§ion, &source, 0..0, "Hosted offline reopen marker "); + let pending = section.pending().unwrap(); + assert_eq!(pending.len(), 1); + section.close().unwrap(); + let section = Section::resume_hosted( + "Garden.one".into(), + Replica::open(&cache).unwrap(), + Arc::clone(&guest), + true, + || {}, + ) + .unwrap(); + let mut probe = HostedRemote::new(&guest, "Garden.one"); + let stamp = onestore::Stamp::of(&source).unwrap(); + for _ in 0..10 { + assert_eq!(probe.stamp().unwrap(), stamp); + assert_eq!(section.pending().unwrap(), pending); + assert_eq!(std::fs::read(&file).unwrap(), source); + thread::sleep(Duration::from_millis(20)); + } + section.wake(); + published(§ion, id); + assert_eq!( + server::text(&std::fs::read(&file).unwrap()).2, + "Hosted offline reopen marker Original text" + ); + assert!(section.pending().unwrap().is_empty()); + section.close().unwrap(); + host.stop(); +} + #[test] fn presence_reconnect_reconciles_missed_changes_without_reconnecting_access() { use notebook::{ diff --git a/crates/notebook/tests/location.rs b/crates/notebook/tests/location.rs index bcb13abca7d4dee92c9267b4b1f3e0c0a9566158..7e2fbc0cc51f0c7f9cfce51f2cfd0cc67c48a825 100644 --- a/crates/notebook/tests/location.rs +++ b/crates/notebook/tests/location.rs @@ -91,7 +91,7 @@ fn until(what: &str, mut accept: impl FnMut() -> bool) { /// edit queued in its replica. fn queued(root: &Path, cache: &Path, text: &str) -> ExGuid { let notebook = Notebook::open(root, cache).unwrap(); - let section = notebook.section("First.one", || {}).unwrap(); + let section = notebook.section("First.one", false, || {}).unwrap(); section.set_offline(true); let space = typed(§ion, text); assert_eq!(section.pending().unwrap().len(), 1); @@ -102,7 +102,7 @@ fn queued(root: &Path, cache: &Path, text: &str) -> ExGuid { /// Opens `First.one` of `root` and waits for it to publish what it holds. fn published(root: &Path, cache: &Path) -> usize { let notebook = Notebook::open(root, cache).unwrap(); - let section = notebook.section("First.one", || {}).unwrap(); + let section = notebook.section("First.one", false, || {}).unwrap(); let pending = section.pending().unwrap().len(); until("the queue published", || { section.sync_status().unwrap().queued == 0 @@ -128,8 +128,8 @@ fn copies_of_a_notebook_keep_their_own_replicas_and_publish_only_to_themselves() ); // Both open at once: neither waits on the other's replica. let (mine, theirs) = ( - a.section("First.one", || {}).unwrap(), - b.section("First.one", || {}).unwrap(), + a.section("First.one", false, || {}).unwrap(), + b.section("First.one", false, || {}).unwrap(), ); mine.set_offline(true); let space = typed(&mine, "Original "); @@ -242,7 +242,7 @@ fn a_replica_newer_than_a_surviving_copy_stays_behind() { // The original moves on past the copy, then queues an edit and goes away. { let notebook = Notebook::open(&root, &cache).unwrap(); - let section = notebook.section("First.one", || {}).unwrap(); + let section = notebook.section("First.one", false, || {}).unwrap(); typed(§ion, "Published "); until("the original published", || { section.sync_status().unwrap().queued == 0 @@ -254,7 +254,7 @@ fn a_replica_newer_than_a_surviving_copy_stays_behind() { assert_eq!(published(©, &cache), 0); let space = Notebook::open(©, &cache) .unwrap() - .section("First.one", || {}) + .section("First.one", false, || {}) .unwrap() .pages() .unwrap()[0] @@ -297,8 +297,8 @@ fn a_section_copy_beside_its_original_opens_with_its_own_replica() { .count(), 2 ); - let original = notebook.section("First.one", || {}).unwrap(); - let copy = notebook.section("First 2.one", || {}).unwrap(); + let original = notebook.section("First.one", false, || {}).unwrap(); + let copy = notebook.section("First 2.one", false, || {}).unwrap(); original.set_offline(true); let space = typed(&original, "Mine "); assert!(copy.pending().unwrap().is_empty()); diff --git a/crates/notebook/tests/protected.rs b/crates/notebook/tests/protected.rs index feb4f6196ba8de50b34b28a03eda6ec9d2c5dc3e..83051d526c01523a82c8453fea0f84aafeaeca77 100644 --- a/crates/notebook/tests/protected.rs +++ b/crates/notebook/tests/protected.rs @@ -107,10 +107,10 @@ fn a_locked_section_unlocks_into_a_session() { notebook.unlock(§ion.path, "wrong"), Err(notebook::Error::Protected(Error::PasswordMismatch)) )); - assert!(notebook.section(§ion.path, || {}).is_err()); + assert!(notebook.section(§ion.path, false, || {}).is_err()); let key = notebook.unlock(§ion.path, &password).unwrap(); let session = notebook - .section_unlocked(§ion.path, &key, || {}) + .section_unlocked(§ion.path, &key, false, || {}) .unwrap(); let pages = session.pages().unwrap(); assert_eq!(pages.len(), 11); @@ -274,7 +274,11 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() { let mut notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap(); let path = notebook.catalog().sections[0].path.clone(); let plain = notebook.replica_path(&path).unwrap(); - notebook.section(&path, || {}).unwrap().close().unwrap(); + notebook + .section(&path, false, || {}) + .unwrap() + .close() + .unwrap(); assert!(plain.exists()); let identity = notebook.catalog().sections[0].file_id; @@ -300,7 +304,9 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() { assert!(!image.windows(utf16.len()).any(|w| w == utf16)); let key = notebook.unlock(&path, "first").unwrap(); assert_eq!(key.secret(), first.secret()); - let session = notebook.section_unlocked(&path, &key, || {}).unwrap(); + let session = notebook + .section_unlocked(&path, &key, false, || {}) + .unwrap(); assert_eq!(session.pages().unwrap()[0].1, TEXT); session.close().unwrap(); assert!(!leaks(¬ebook.replica_path(&path).unwrap(), TEXT)); @@ -311,7 +317,9 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() { .unwrap() .unwrap(); assert!(notebook.unlock(&path, "first").is_err()); - let session = notebook.section_unlocked(&path, &second, || {}).unwrap(); + let session = notebook + .section_unlocked(&path, &second, false, || {}) + .unwrap(); assert_eq!(session.pages().unwrap()[0].1, TEXT); session.close().unwrap(); @@ -323,7 +331,7 @@ fn a_password_is_set_changed_and_removed_in_a_notebook() { ); let section = ¬ebook.catalog().sections[0]; assert!(matches!(section.state, SectionState::Readable { .. })); - let session = notebook.section(&path, || {}).unwrap(); + let session = notebook.section(&path, false, || {}).unwrap(); assert_eq!(session.pages().unwrap()[0].1, TEXT); } @@ -341,7 +349,7 @@ fn a_password_waits_for_queued_edits() { ) .unwrap(); let path = notebook.catalog().sections[0].path.clone(); - let session = notebook.section(&path, || {}).unwrap(); + let session = notebook.section(&path, false, || {}).unwrap(); session.set_offline(true); let (space, ..) = session.pages().unwrap()[0].clone(); let page = session.page(space).unwrap(); @@ -384,7 +392,7 @@ fn a_replica_drops_its_plaintext_once_its_section_is_protected_elsewhere() { const TITLE: &str = "Read on the other device"; let temporary = tempfile::tempdir().unwrap(); let (mut here, mut there, path) = two_devices(temporary.path(), TITLE); - there.section(&path, || {}).unwrap().close().unwrap(); + there.section(&path, false, || {}).unwrap().close().unwrap(); let replica = there.replica_path(&path).unwrap(); assert!(leaks(&replica, TITLE)); here.set_password(&path, None, Some("secret")).unwrap(); @@ -408,7 +416,7 @@ fn an_open_section_protected_elsewhere_stops_as_protected() { const TITLE: &str = "Open while protected"; let temporary = tempfile::tempdir().unwrap(); let (mut here, mut there, path) = two_devices(temporary.path(), TITLE); - let session = there.section(&path, || {}).unwrap(); + let session = there.section(&path, false, || {}).unwrap(); let replica = there.replica_path(&path).unwrap(); here.set_password(&path, None, Some("secret")).unwrap(); 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() { const TYPED: &str = "Typed offline meanwhile"; let temporary = tempfile::tempdir().unwrap(); let (mut here, mut there, path) = two_devices(temporary.path(), TITLE); - let session = there.section(&path, || {}).unwrap(); + let session = there.section(&path, false, || {}).unwrap(); session.set_offline(true); let (space, ..) = session.pages().unwrap()[0].clone(); let page = session.page(space).unwrap(); @@ -457,7 +465,7 @@ fn edits_queued_before_a_password_publish_under_it_once_unlocked() { let key = there.unlock(&path, "secret").unwrap(); assert!(!held.exists()); - let session = there.section_unlocked(&path, &key, || {}).unwrap(); + let session = there.section_unlocked(&path, &key, false, || {}).unwrap(); let titles: Vec = session .pages() .unwrap() @@ -523,7 +531,7 @@ fn a_notebook_protected_through_its_sessions() { let names = ["Protected", "Edited", "Changed", "Removed", "Conflicted"]; for name in names { let path = notebook.create_section("", name, &creation).unwrap(); - let section = notebook.section(&path, || {}).unwrap(); + let section = notebook.section(&path, false, || {}).unwrap(); for stored in &pages { section.import_page(&stored.page, "Snowbound Test").unwrap(); } @@ -544,7 +552,7 @@ fn a_notebook_protected_through_its_sessions() { } // Edited under its key: text on every page, published as dependent revisions. let section = notebook - .section_unlocked(&path("Edited"), &keys["Edited"], || {}) + .section_unlocked(&path("Edited"), &keys["Edited"], false, || {}) .unwrap(); for round in ["first", "second", "third"] { append(§ion, &format!(" Edited {round} under the key.")); @@ -562,7 +570,7 @@ fn a_notebook_protected_through_its_sessions() { .unwrap(); passwords.insert(path("Changed"), "Changed again".into()); let section = notebook - .section_unlocked(&path("Changed"), &changed, || {}) + .section_unlocked(&path("Changed"), &changed, false, || {}) .unwrap(); append(§ion, " Written under the changed password."); published(§ion); @@ -580,10 +588,10 @@ fn a_notebook_protected_through_its_sessions() { let second = Notebook::open(&folder, temporary.path().join("second")).unwrap(); let key = &keys["Conflicted"]; let ours = notebook - .section_unlocked(&path("Conflicted"), key, || {}) + .section_unlocked(&path("Conflicted"), key, false, || {}) .unwrap(); let theirs = second - .section_unlocked(&path("Conflicted"), key, || {}) + .section_unlocked(&path("Conflicted"), key, false, || {}) .unwrap(); for (section, word) in [(&ours, " Ours offline."), (&theirs, " Theirs offline.")] { section.set_offline(true); diff --git a/crates/notebook/tests/session.rs b/crates/notebook/tests/session.rs index df92a48a82a68d2863fb332269268584b1ab7719..316d3c3ecee86f7e25c2ef602294897208f235e8 100644 --- a/crates/notebook/tests/session.rs +++ b/crates/notebook/tests/session.rs @@ -65,7 +65,7 @@ fn typed( fn open(file: &Path, cache: &Path) -> (Section, Arc) { let notified = Arc::new(AtomicUsize::new(0)); let counter = Arc::clone(¬ified); - let section = Section::open(file, cache, move || { + let section = Section::open(file, cache, false, move || { counter.fetch_add(1, Ordering::SeqCst); }) .unwrap(); @@ -369,7 +369,7 @@ fn a_notebook_directory_lists_its_sections_and_opens_them() { assert_eq!(catalog.groups.len(), 1); assert_eq!(catalog.groups[0].sections.len(), 1); let path = catalog.groups[0].sections[0].path.clone(); - let section = notebook.section(&path, || {}).unwrap(); + let section = notebook.section(&path, false, || {}).unwrap(); let (space, _, _) = section.pages().unwrap()[0]; let page = section.page(space).unwrap(); let text = first_text(&page); @@ -409,7 +409,7 @@ fn a_notebook_only_opens_discovered_section_paths() { std::fs::write(root.join("added.one"), &source).unwrap(); for path in ["../outside.one", outside.to_str().unwrap(), "added.one"] { assert!(matches!( - notebook.section(path, || {}), + notebook.section(path, false, || {}), Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound )); } @@ -433,7 +433,7 @@ fn a_catalog_section_replaced_by_an_outside_symlink_is_rejected() { std::fs::remove_file(&file).unwrap(); std::os::unix::fs::symlink(&outside, &file).unwrap(); assert!(matches!( - notebook.section("notes.one", || {}), + notebook.section("notes.one", false, || {}), Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::PermissionDenied )); assert_eq!(replicas(&cache), 0); @@ -447,7 +447,7 @@ fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() { let cache = directory.path().join("replica.sqlite"); let source = onestore::create_section("remote.one", "Original", "Author").unwrap(); let replica = notebook::Replica::create(&cache, &source).unwrap(); - let section = Section::resume(&file, replica, || {}).unwrap(); + let section = Section::resume(&file, replica, false, || {}).unwrap(); let space = section.pages().unwrap()[0].0; let before = section.page(space).unwrap(); let after = edited(&before, "First "); @@ -460,7 +460,13 @@ fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() { section.close().unwrap(); assert!(!file.exists()); - let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap(); + let section = Section::resume( + &file, + notebook::Replica::open(&cache).unwrap(), + false, + || {}, + ) + .unwrap(); assert_same(section.page(space).unwrap(), &after); assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending)); let before = section.page(space).unwrap(); @@ -474,7 +480,13 @@ fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() { published(§ion, next); assert_same(stored_page(&file, space), &after); section.close().unwrap(); - let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap(); + let section = Section::resume( + &file, + notebook::Replica::open(&cache).unwrap(), + false, + || {}, + ) + .unwrap(); assert_same(section.page(space).unwrap(), &after); assert!(section.pending().unwrap().is_empty()); section.close().unwrap(); @@ -487,7 +499,7 @@ fn resuming_against_another_document_preserves_both_remote_and_pending_edit() { let cache = directory.path().join("replica.sqlite"); let source = onestore::create_section("notes.one", "Original", "Author").unwrap(); let replica = notebook::Replica::create(&cache, &source).unwrap(); - let section = Section::resume(&file, replica, || {}).unwrap(); + let section = Section::resume(&file, replica, false, || {}).unwrap(); let space = section.pages().unwrap()[0].0; let before = section.page(space).unwrap(); let after = edited(&before, "Local "); @@ -497,7 +509,13 @@ fn resuming_against_another_document_preserves_both_remote_and_pending_edit() { let other = onestore::create_section("other.one", "Unrelated", "Other").unwrap(); std::fs::write(&file, &other).unwrap(); - let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap(); + let section = Section::resume( + &file, + notebook::Replica::open(&cache).unwrap(), + false, + || {}, + ) + .unwrap(); wait(§ion, |event| matches!(event, Event::Failed(_))); assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending)); assert_same(section.page(space).unwrap(), &after); @@ -515,7 +533,7 @@ fn offline_save_process() { }; let root = std::path::PathBuf::from(root); let replica = notebook::Replica::open(root.join("replica.sqlite")).unwrap(); - let section = Section::resume(root.join("absent.one"), replica, || {}).unwrap(); + let section = Section::resume(root.join("absent.one"), replica, false, || {}).unwrap(); let space = section.pages().unwrap()[0].0; let before = section.page(space).unwrap(); let id = typed(§ion, space, &before, 0..0, "Durable "); @@ -542,7 +560,13 @@ fn an_acknowledged_offline_save_survives_process_exit_without_cleanup() { .unwrap(); let file = directory.path().join("absent.one"); assert!(!file.exists()); - let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap(); + let section = Section::resume( + &file, + notebook::Replica::open(&cache).unwrap(), + false, + || {}, + ) + .unwrap(); let space = section.pages().unwrap()[0].0; let original = model_ops::page_of(&source, space); let after = edited(&original, "Durable "); @@ -568,6 +592,7 @@ fn smb_connection_failure_keeps_the_session_locally_editable_and_retries() { counter.fetch_add(1, Ordering::SeqCst); Err(std::io::ErrorKind::PermissionDenied.into()) }, + false, || {}, ) .unwrap(); @@ -619,6 +644,7 @@ fn live_smb_session_save_publishes_and_reopens() { replica, 1024 * 1024, connect.clone(), + false, || {}, ) .unwrap(); @@ -635,6 +661,7 @@ fn live_smb_session_save_publishes_and_reopens() { notebook::Replica::open(&cache).unwrap(), 1024 * 1024, connect, + false, || {}, ) .unwrap(); @@ -666,6 +693,7 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() { stalled.recv_timeout(Duration::from_secs(120)).unwrap(); Err(std::io::ErrorKind::TimedOut.into()) }, + false, || {}, ) .unwrap(); @@ -817,9 +845,14 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() { let notified = Arc::new(AtomicUsize::new(0)); let counter = Arc::clone(¬ified); - let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), move || { - counter.fetch_add(1, Ordering::SeqCst); - }) + let section = Section::resume( + &file, + notebook::Replica::open(&cache).unwrap(), + false, + move || { + counter.fetch_add(1, Ordering::SeqCst); + }, + ) .unwrap(); wait( §ion, @@ -1043,7 +1076,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() { background.watch(notebook.replicas()); // The round started with the background ends before the edit exists. std::thread::sleep(Duration::from_millis(200)); - let section = notebook.section("Second.one", || {}).unwrap(); + let section = notebook.section("Second.one", false, || {}).unwrap(); section.set_offline(true); let space = section.pages().unwrap()[0].0; let before = section.page(space).unwrap(); @@ -1070,7 +1103,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() { drop(background); let mut reopened = None; until("the background released the replica", || { - reopened = notebook.section("Second.one", || {}).ok(); + reopened = notebook.section("Second.one", false, || {}).ok(); reopened.is_some() }); let reopened = reopened.unwrap(); @@ -1084,7 +1117,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { let mut notebook = two_sections(directory.path()); // Second has a replica from being opened once; First has never been opened. notebook - .section("Second.one", || {}) + .section("Second.one", false, || {}) .unwrap() .close() .unwrap(); @@ -1190,7 +1223,7 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() { .all(|(_, status)| status.synced.is_some()) }); // Opening a section starts from its copy, and edits made offline wait in it. - let section = notebook.section("Second.one", || {}).unwrap(); + let section = notebook.section("Second.one", false, || {}).unwrap(); section.set_offline(true); let space = section.pages().unwrap()[0].0; let before = section.page(space).unwrap(); @@ -1313,6 +1346,7 @@ fn a_moved_section_keeps_its_replica_while_the_old_remote_is_stalled() { late_error, }) }, + false, || {}, ) .unwrap(); @@ -1392,6 +1426,7 @@ fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_i .section_with( "Second.one", move |file| Ok(Counted(file.to_owned(), Arc::clone(&counter))), + false, || {}, ) .unwrap(); diff --git a/crates/notebook/tests/smb_queue.rs b/crates/notebook/tests/smb_queue.rs index 38b02a72a263582fd82452818c2b36050b5c12bb..88130fee33f86d57408b0432d90464b6354e995b 100644 --- a/crates/notebook/tests/smb_queue.rs +++ b/crates/notebook/tests/smb_queue.rs @@ -197,6 +197,7 @@ fn live_session_applies_edits_through_the_embedded_client_and_reopens() { Duration::from_secs(5), ) }, + false, || {}, ) .unwrap(); diff --git a/crates/notebook/tests/support/live.rs b/crates/notebook/tests/support/live.rs index b080fdbb52bb37bff28955b24d1a4965e5bb98d0..213fdcf509b71121edcebced1b5eb893f69df4fc 100644 --- a/crates/notebook/tests/support/live.rs +++ b/crates/notebook/tests/support/live.rs @@ -121,7 +121,7 @@ pub fn open(notebook: &Notebook, guest: &Arc, path: &str, key: Option<&Ke let replica = notebook.replica_path(path).unwrap(); std::fs::create_dir_all(replica.parent().unwrap()).unwrap(); let replica = Replica::open_or_create(&replica, key, || notebook.read_section(path)).unwrap(); - Section::resume_hosted(path.into(), replica, Arc::clone(guest), || {}).unwrap() + Section::resume_hosted(path.into(), replica, Arc::clone(guest), false, || {}).unwrap() } pub fn replace(section: &Section, image: &[u8], range: std::ops::Range, with: &str) -> u64 { diff --git a/crates/snowbound/src/icloud_linux.rs b/crates/snowbound/src/icloud_linux.rs index ba171f1840ffecbae43ddadcd6c4204bf2b7b494..e302c40b808d7eb666669bdf6bc0cc6744123541 100644 --- a/crates/snowbound/src/icloud_linux.rs +++ b/crates/snowbound/src/icloud_linux.rs @@ -26,20 +26,22 @@ pub fn section( _: &Path, path: &str, key: Option<&onestore::protected::Key>, + offline: bool, notify: impl Fn() + Send + 'static, ) -> Result { match key { - Some(key) => notebook.section_unlocked(path, key, notify), - None => notebook.section(path, notify), + Some(key) => notebook.section_unlocked(path, key, offline, notify), + None => notebook.section(path, offline, notify), } } pub fn lone_section( file: &Path, cache: &Path, + offline: bool, notify: impl Fn() + Send + 'static, ) -> Result { - Section::open(file, cache, notify) + Section::open(file, cache, offline, notify) } pub fn background( diff --git a/crates/snowbound/src/icloud_macos.rs b/crates/snowbound/src/icloud_macos.rs index 6c434f8481a36afc7ca145dfeae04d93ee507dd8..307445c7b57338f4423b86ce3c7b327281b3c4f5 100644 --- a/crates/snowbound/src/icloud_macos.rs +++ b/crates/snowbound/src/icloud_macos.rs @@ -365,6 +365,7 @@ pub fn section( root: &Path, path: &str, key: Option<&onestore::protected::Key>, + offline: bool, notify: impl Fn() + Send + 'static, ) -> Result { if !notebook.replica_path(path)?.exists() { @@ -372,8 +373,8 @@ pub fn section( } let connect = |file: &Path| Ok(Coordinated(file.to_owned())); let section = match key { - Some(key) => notebook.section_unlocked_with(path, key, connect, notify)?, - None => notebook.section_with(path, connect, notify)?, + Some(key) => notebook.section_unlocked_with(path, key, connect, offline, notify)?, + None => notebook.section_with(path, connect, offline, notify)?, }; section.set_pause(PAUSE); Ok(section) @@ -392,10 +393,17 @@ fn fetch(file: &Path) -> io::Result<()> { pub fn lone_section( file: &Path, cache: &Path, + offline: bool, notify: impl Fn() + Send + 'static, ) -> Result { fetch(file)?; - let section = Section::open_with(file, cache, |file| Ok(Coordinated(file.to_owned())), notify)?; + let section = Section::open_with( + file, + cache, + |file| Ok(Coordinated(file.to_owned())), + offline, + notify, + )?; section.set_pause(PAUSE); Ok(section) } @@ -582,7 +590,7 @@ mod tests { let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap(); assert!(ubiquitous(&root)); let path = notebook.catalog().sections[0].path.clone(); - let section = section(¬ebook, &root, &path, None, || {}).unwrap(); + let section = section(¬ebook, &root, &path, None, false, || {}).unwrap(); let (space, ..) = section.pages().unwrap()[0].clone(); let title = section .page(space) diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index 4875a14ed56861b3a60df3da8927d27c3722fe18..a96c76975d61defde51827582b344d572b24f1eb 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -991,7 +991,13 @@ impl Library { }) .and_then(|replica| { let guest = Arc::clone(&joined.guest); - Section::resume_hosted(path.to_owned(), replica, guest, notifier()) + Section::resume_hosted( + path.to_owned(), + replica, + guest, + offline(), + notifier(), + ) }) } #[cfg(target_arch = "wasm32")] @@ -1010,21 +1016,28 @@ impl Library { replica.and_then(|replica| { let server = Arc::clone(server); let connect = move || server.connect(); - Section::resume_smb(file, replica, LIMIT, connect, notifier()) + Section::resume_smb(file, replica, LIMIT, connect, offline(), notifier()) }) } (Ok(Some(notebook)), None) if self.in_icloud() => { let root = Path::new(&self.location); - crate::icloud::section(notebook, root, path, key.as_ref(), notifier()) + crate::icloud::section( + notebook, + root, + path, + key.as_ref(), + offline(), + notifier(), + ) } (Ok(Some(notebook)), None) => match &key { - Some(key) => notebook.section_unlocked(path, key, notifier()), - None => notebook.section(path, notifier()), + Some(key) => notebook.section_unlocked(path, key, offline(), notifier()), + None => notebook.section(path, offline(), notifier()), }, (Ok(None), _) if self.in_icloud() => { - crate::icloud::lone_section(Path::new(path), &self.cache, notifier()) + crate::icloud::lone_section(Path::new(path), &self.cache, offline(), notifier()) } - (Ok(None), _) => Section::open(path, &self.cache, notifier()), + (Ok(None), _) => Section::open(path, &self.cache, offline(), notifier()), (Err(error), _) => return Err(error.clone().into()), }; match opened { @@ -1763,6 +1776,7 @@ mod tests { release: Arc::clone(&gate), }) }, + false, || {}, ) .unwrap(); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 1f6d525c84d5e1262628be84beaa71e6b07de9d8..017f6f0783b4fe526d646446786040ee22ea1fa1 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -7246,7 +7246,7 @@ mod tests { ) .unwrap(); let section = - notebook::session::Section::open(&file, directory.join("cache"), || {}).unwrap(); + notebook::session::Section::open(&file, directory.join("cache"), false, || {}).unwrap(); let mut engine = TextEngine::default(); for (space, ..) in section.pages().unwrap().into_iter().take(3) { let mut editor = @@ -7348,7 +7348,7 @@ mod tests { &file, ) .unwrap(); - let ours = Section::open(&file, directory.join("ours"), || {}).unwrap(); + let ours = Section::open(&file, directory.join("ours"), false, || {}).unwrap(); let space = ours.pages().unwrap()[0].0; let mut engine = TextEngine::default(); let mut editor = CanvasEditor::from_page(ours.page(space).unwrap(), &mut engine).unwrap(); @@ -7378,7 +7378,7 @@ mod tests { "{seen:?}" ); - let theirs = Section::open(&file, directory.join("theirs"), || {}).unwrap(); + let theirs = Section::open(&file, directory.join("theirs"), false, || {}).unwrap(); let mut other = CanvasEditor::from_page(theirs.page(space).unwrap(), &mut engine).unwrap(); other.focus_outline(outline).unwrap(); let last = other.active_outline().document().paragraphs().count() - 1;