From 0b3748b14dba39c904ac5359c0560831ef382139 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sun, 11 Oct 2026 20:49:10 -0700 Subject: [PATCH] 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 --- crates/mobile/src/library.rs | 25 +++- crates/mobile/src/tests.rs | 132 ++++++++++++++++++- crates/notebook/README.md | 15 ++- crates/notebook/examples/browser_receipts.rs | 2 +- crates/notebook/examples/live_crowd.rs | 10 +- crates/notebook/examples/live_latency.rs | 10 +- crates/notebook/examples/open_probe.rs | 2 +- crates/notebook/examples/save_probe.rs | 2 +- crates/notebook/examples/session_client.rs | 2 +- crates/notebook/src/background.rs | 2 +- crates/notebook/src/session.rs | 63 +++++++-- crates/notebook/src/session/tests.rs | 129 +++++++++++++++++- crates/notebook/src/smb/tests.rs | 2 +- crates/notebook/src/worker.rs | 4 +- crates/notebook/tests/copy.rs | 2 +- crates/notebook/tests/links.rs | 2 +- crates/notebook/tests/live_share.rs | 56 ++++++++ crates/notebook/tests/location.rs | 16 +-- crates/notebook/tests/protected.rs | 40 +++--- crates/notebook/tests/session.rs | 71 +++++++--- crates/notebook/tests/smb_queue.rs | 1 + crates/notebook/tests/support/live.rs | 2 +- crates/snowbound/src/icloud_linux.rs | 8 +- crates/snowbound/src/icloud_macos.rs | 16 ++- crates/snowbound/src/library.rs | 28 +++- crates/snowbound/src/main.rs | 6 +- 26 files changed, 549 insertions(+), 99 deletions(-) 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; -- 2.54.0