authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-11 04:01:57-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 17:00:32-07:00
loga380cb56a0a524f24a9edfa7a153336e4db3201c
treecf3ebcb314dbc25b8b96284666e48ca43ae18d11
parentd7b8dcee3e1a2f16ec6335f18cd91b96ed0f3b12
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: add replica-backed section sessions with offline resume

Expose local and embedded SMB sessions over the existing durable replica. Preserve typed transport failures and cache ownership through cancellation. Restrict notebook opens to catalog paths. Validated with notebook tests, live Samba publication, iOS Simulator runtime recovery and a cold OneNote read of a session-published fixture. Device compilation is separate from runtime acceptance. Assisted-by: gpt-6-astra

6 files changed, 953 insertions(+), 1 deletions(-)

crates/notebook/README.md+38
......@@ -51,6 +51,44 @@ filesystem: the connection holds exclusive ownership between transactions, and a
5151second open fails busy. No network wait occurs in a local save. After a database
5252error, reopen and inspect the durable state before retrying.
5353
54## Sessions
55
56`session::Notebook::open(root, cache_dir)` discovers a notebook directory and
57`session::Section::open(file, cache_dir, notify)` opens one section file through
58a replica stored under the cache directory, named by the section's document
59identity so the same file reopens the same queue after a relaunch. A section
60publishes in the background to the file itself under OneNote-compatible
61exclusion. `pages()` lists page spaces and titles from the local image,
62`page(space)` returns the model to edit, and `save(space, before, after,
63author)` queues the edited model: `Save::Queued(id)` is durable locally,
64`Save::Unchanged` means the model equals the stored page, and `Save::Stale`
65means the stored page no longer matches `before` because the section changed
66underneath the editor, so the page must be reloaded before saving again.
67`events()` drains what the synchronization thread reported since the last
68poll: refreshes, attempt outcomes and unreachable files; `notify` runs on that
69thread whenever an event is available so the application can wake its event
70loop. `close()` stops publication.
71
72`Event::Unreachable` retains an `io::Error`: callers can distinguish permission
73denial, missing targets, timeouts, and connection failures through `kind()` without
74parsing display text. Document/cache failures are reported as `Event::Failed` and
75stop the worker. Durable publication outcomes remain available through `status`.
76
77`Section::resume(file, replica, notify)` starts a session from an owned
78`Replica::open(cache_file)` without consulting the remote file. The caller retains
79the cache location and publication path for offline relaunch. Local pages and
80saves remain available while the target is absent; synchronization verifies the
81document identity before adopting or publishing remote content. A different
82document stops the worker and retains the pending local edits. `Section::open`
83remains the online convenience constructor that discovers the identity from the
84file and creates or reopens its cache.
85
86With the `smb` feature, `Section::resume_smb(path, replica, limit, connect,
87notify)` binds the same session operations to a share-relative path. `connect`
88returns a new SMB client on the synchronization worker and is called again after
89transport failure. The caller supplies credentials there, outside the replica
90schema; construction and local saving do not wait for a network connection.
91
5492## Pages of a section
5593
5694`create_page` accepts the core `PageCreation` intent and queues its page space
crates/notebook/src/lib.rs+1
......@@ -25,6 +25,7 @@ mod pages;
2525mod rebase;
2626mod recovery;
2727mod schema;
28pub mod session;
2829pub use pages::PageEdits;
2930pub use recovery::{Recovery, RecoverySummary};
3031mod sync;
crates/notebook/src/session.rs created+297
......@@ -0,0 +1,297 @@
1//! The application's view of a notebook: sections opened through a local replica that
2//! publishes page saves to the section file in the background.
3
4use crate::{EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover};
5use onestore::{
6 CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, page::Page,
7};
8use std::{
9 io,
10 path::{Path, PathBuf},
11 sync::{
12 Arc,
13 mpsc::{self, Receiver},
14 },
15 time::Duration,
16};
17
18/// A notebook directory and the cache directory holding its section replicas.
19pub struct Notebook {
20 root: PathBuf,
21 cache: PathBuf,
22 catalog: discover::Folder,
23}
24
25impl Notebook {
26 pub fn open(root: impl AsRef<Path>, cache: impl AsRef<Path>) -> Result<Self> {
27 let root = root.as_ref().canonicalize()?;
28 let cache = cache.as_ref().to_path_buf();
29 std::fs::create_dir_all(&cache)?;
30 let catalog = discover::discover(
31 &mut discover::Local::open(&root)?,
32 discover::Limits {
33 entries: 100_000,
34 bytes_per_file: 256 * 1024 * 1024,
35 depth: 64,
36 },
37 )?;
38 Ok(Self {
39 root,
40 cache,
41 catalog,
42 })
43 }
44
45 pub fn catalog(&self) -> &discover::Folder {
46 &self.catalog
47 }
48
49 /// Opens a section by its catalog path.
50 pub fn section(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result<Section> {
51 let mut folders = vec![&self.catalog];
52 while let Some(folder) = folders.pop() {
53 if folder.sections.iter().any(|section| section.path == path) {
54 let file = self.root.join(path).canonicalize()?;
55 if !file.starts_with(&self.root) {
56 return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());
57 }
58 return Section::open(file, &self.cache, notify);
59 }
60 folders.extend(&folder.groups);
61 }
62 Err(io::Error::from(io::ErrorKind::NotFound).into())
63 }
64}
65
66/// What happened on the synchronization thread since the last poll.
67#[derive(Debug)]
68pub enum Event {
69 /// The working image was refreshed from the section file with no local edit pending.
70 Refreshed,
71 /// A publication attempt finished with this durable state.
72 Attempt { id: u64, status: EditStatus },
73 /// The section file could not be reached; the replica keeps its state.
74 Unreachable(io::Error),
75 /// The replica itself failed; the worker has stopped.
76 Failed(String),
77}
78
79/// The outcome of saving an edited page.
80#[derive(Debug, PartialEq, Eq)]
81pub enum Save {
82 /// The model equals the stored page.
83 Unchanged,
84 /// The edit is durable locally under this id.
85 Queued(u64),
86 /// The stored page no longer matches `before`; reload it before saving again.
87 Stale,
88}
89
90/// A section file with its replica and background publication.
91pub struct Section {
92 file: PathBuf,
93 replica: Arc<Replica>,
94 worker: Option<SyncWorker>,
95 events: Receiver<Event>,
96}
97
98impl Section {
99 /// Opens the section file through a replica in `cache`, creating the replica from the
100 /// file on first use. `notify` runs on the synchronization thread whenever an event is
101 /// available.
102 pub fn open(
103 file: impl AsRef<Path>,
104 cache: impl AsRef<Path>,
105 notify: impl Fn() + Send + 'static,
106 ) -> Result<Self> {
107 let file = file.as_ref().canonicalize()?;
108 let source = onestore::read_file(&file)?;
109 let store = Store::parse(&source)?;
110 let identity = RevisionIndex::parse(&store)?.root;
111 let name: String = identity
112 .guid
113 .iter()
114 .map(|byte| format!("{byte:02x}"))
115 .collect();
116 std::fs::create_dir_all(&cache)?;
117 let cache = cache.as_ref().join(format!("{name}.sqlite"));
118 let replica = if cache.exists() {
119 Replica::open(&cache)?
120 } else {
121 Replica::create(&cache, &source)?
122 };
123 Self::resume(file, replica, notify)
124 }
125
126 /// Resumes an owned local replica without reading the publication target.
127 /// A target with a different document identity is rejected by synchronization.
128 pub fn resume(
129 file: impl AsRef<Path>,
130 replica: Replica,
131 notify: impl Fn() + Send + 'static,
132 ) -> Result<Self> {
133 let file = std::path::absolute(file)?;
134 let remote = file.clone();
135 Self::start(
136 file,
137 replica,
138 move || Ok(FileRemote(remote.clone())),
139 notify,
140 )
141 }
142
143 /// Resumes a replica against a share-relative section path. Credentials remain in
144 /// `connect`, which is called on the worker again after transport failures.
145 #[cfg(feature = "smb")]
146 pub fn resume_smb(
147 path: String,
148 replica: Replica,
149 limit: usize,
150 mut connect: impl FnMut() -> io::Result<crate::smb::Client> + Send + 'static,
151 notify: impl Fn() + Send + 'static,
152 ) -> Result<Self> {
153 Self::start(
154 PathBuf::from(&path),
155 replica,
156 move || Ok(crate::SmbRemote::new(connect()?, path.clone(), limit)),
157 notify,
158 )
159 }
160
161 fn start<R: Remote + 'static>(
162 file: PathBuf,
163 replica: Replica,
164 connect: impl FnMut() -> io::Result<R> + Send + 'static,
165 notify: impl Fn() + Send + 'static,
166 ) -> Result<Self> {
167 let replica = Arc::new(replica);
168 let (sender, events) = mpsc::channel();
169 let worker = replica.start_sync(Duration::from_secs(2), connect, move |result| {
170 let event = match result {
171 Ok(None) => Event::Refreshed,
172 Ok(Some((id, status))) => Event::Attempt {
173 id: *id,
174 status: *status,
175 },
176 Err(Error::RemoteIo(error)) => Event::Unreachable(match error.raw_os_error() {
177 Some(code) => io::Error::from_raw_os_error(code),
178 None => io::Error::new(error.kind(), error.to_string()),
179 }),
180 Err(Error::Remote(error)) => {
181 Event::Unreachable(io::Error::new(error.error.kind(), error.to_string()))
182 }
183 Err(error) => Event::Failed(error.to_string()),
184 };
185 if sender.send(event).is_ok() {
186 notify();
187 }
188 })?;
189 Ok(Self {
190 file,
191 replica,
192 worker: Some(worker),
193 events,
194 })
195 }
196
197 /// The absolute local path, or share-relative path for an SMB session.
198 pub fn file(&self) -> &Path {
199 &self.file
200 }
201
202 /// Page spaces and titles in section order, from the local working image.
203 pub fn pages(&self) -> Result<Vec<(ExGuid, String)>> {
204 let snapshot = self.replica.snapshot()?;
205 let store = Store::parse(&snapshot)?;
206 let index = RevisionIndex::parse(&store)?;
207 let document = Document::parse(&index)?;
208 document
209 .pages()?
210 .into_iter()
211 .map(|(space, _)| Ok((space, Page::from_space(&document, space)?.title)))
212 .collect()
213 }
214
215 pub fn page(&self, space: ExGuid) -> Result<Page> {
216 let snapshot = self.replica.snapshot()?;
217 let store = Store::parse(&snapshot)?;
218 let index = RevisionIndex::parse(&store)?;
219 Ok(Page::from_space(&Document::parse(&index)?, space)?)
220 }
221
222 /// Saves an edited page. `before` is the model the edit started from; a stored page
223 /// that differs from it means the section changed underneath the editor.
224 pub fn save(&self, space: ExGuid, before: &Page, after: &Page, author: &str) -> Result<Save> {
225 loop {
226 let snapshot = self.replica.snapshot()?;
227 let store = Store::parse(&snapshot)?;
228 let index = RevisionIndex::parse(&store)?;
229 if Page::from_space(&Document::parse(&index)?, space)? != *before {
230 return Ok(Save::Stale);
231 }
232 match self.replica.save(&snapshot, space, after, author) {
233 Ok(Some(id)) => return Ok(Save::Queued(id)),
234 Ok(None) => return Ok(Save::Unchanged),
235 Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {}
236 Err(error) => return Err(error),
237 }
238 }
239 }
240
241 pub fn status(&self, id: u64) -> Result<Option<EditStatus>> {
242 self.replica.status(id)
243 }
244
245 pub fn pending(&self) -> Result<Vec<PendingEdit>> {
246 self.replica.pending()
247 }
248
249 /// Events since the last poll, oldest first.
250 pub fn events(&self) -> Vec<Event> {
251 self.events.try_iter().collect()
252 }
253
254 /// Requests a synchronization attempt now.
255 pub fn wake(&self) {
256 if let Some(worker) = &self.worker {
257 worker.wake();
258 }
259 }
260
261 pub fn replica(&self) -> &Replica {
262 &self.replica
263 }
264
265 /// Waits for the in-flight operation and callback before releasing the replica.
266 /// Dropping instead requests cancellation without waiting; the worker retains
267 /// cache ownership until that operation finishes. Remote calls must be bounded.
268 pub fn close(mut self) -> Result<()> {
269 match self.worker.take() {
270 Some(worker) => worker.stop(),
271 None => Ok(()),
272 }
273 }
274}
275
276/// The section file itself as the publication target, under OneNote-compatible exclusion.
277struct FileRemote(PathBuf);
278
279impl Remote for FileRemote {
280 fn read(&mut self) -> io::Result<Vec<u8>> {
281 onestore::read_file(&self.0)
282 }
283
284 fn publish(&mut self, edit: &PreparedEdit<'_>) -> std::result::Result<(), CommitError> {
285 edit.commit_file(&self.0)
286 }
287
288 fn confirm(&mut self, snapshot: &[u8]) -> std::result::Result<(), CommitError> {
289 onestore::confirm_file_snapshot(&self.0, snapshot)
290 }
291}
292
293impl Drop for Section {
294 fn drop(&mut self) {
295 drop(self.worker.take());
296 }
297}
crates/notebook/tests/session.rs created+603
......@@ -0,0 +1,603 @@
1use notebook::{
2 EditStatus,
3 session::{Event, Notebook, Save, Section},
4};
5use onestore::{ExGuid, PreparedEdit, page::Page};
6use std::{
7 path::Path,
8 sync::{
9 Arc,
10 atomic::{AtomicUsize, Ordering},
11 },
12 time::{Duration, Instant},
13};
14
15#[path = "support/model_ops.rs"]
16mod model_ops;
17
18fn first_text(page: &Page) -> ExGuid {
19 page.objects
20 .iter()
21 .find_map(|object| match object {
22 onestore::page::PageObject::Outline(outline) => outline
23 .paragraphs
24 .iter()
25 .find_map(|p| p.text().map(|t| t.id)),
26 _ => None,
27 })
28 .unwrap()
29}
30
31fn edited(page: &Page, text: &str) -> Page {
32 let mut after = page.clone();
33 let id = first_text(page);
34 model_ops::replace_text(&mut after, id, 0..0, text);
35 after
36}
37
38fn open(file: &Path, cache: &Path) -> (Section, Arc<AtomicUsize>) {
39 let notified = Arc::new(AtomicUsize::new(0));
40 let counter = Arc::clone(&notified);
41 let section = Section::open(file, cache, move || {
42 counter.fetch_add(1, Ordering::SeqCst);
43 })
44 .unwrap();
45 (section, notified)
46}
47
48fn wait(section: &Section, mut accept: impl FnMut(&Event) -> bool) {
49 let deadline = Instant::now() + Duration::from_secs(20);
50 loop {
51 for event in section.events() {
52 if accept(&event) {
53 return;
54 }
55 }
56 assert!(
57 Instant::now() < deadline,
58 "the expected event did not arrive"
59 );
60 std::thread::sleep(Duration::from_millis(20));
61 }
62}
63
64fn published(section: &Section, id: u64) {
65 wait(
66 section,
67 |event| matches!(event, Event::Attempt { id: n, status: EditStatus::Published { .. } } if *n == id),
68 );
69}
70
71fn stored_page(file: &Path, space: ExGuid) -> Page {
72 model_ops::page_of(&onestore::read_file(file).unwrap(), space)
73}
74
75/// The page title is derived from its content on read; an edited model keeps the old one.
76fn assert_same(actual: Page, expected: &Page) {
77 let mut expected = expected.clone();
78 expected.title = actual.title.clone();
79 assert_eq!(actual, expected);
80}
81
82#[test]
83fn a_section_opens_through_its_replica_and_a_save_reaches_the_file_and_survives_relaunch() {
84 let directory = tempfile::tempdir().unwrap();
85 let file = directory.path().join("notes.one");
86 let cache = directory.path().join("cache");
87 std::fs::write(
88 &file,
89 onestore::create_section("notes.one", "Original", "Author").unwrap(),
90 )
91 .unwrap();
92 let (section, notified) = open(&file, &cache);
93 let pages = section.pages().unwrap();
94 assert_eq!(pages.len(), 1);
95 let space = pages[0].0;
96 let before = section.page(space).unwrap();
97 assert_eq!(
98 section.save(space, &before, &before, "Editor").unwrap(),
99 Save::Unchanged
100 );
101 let after = edited(&before, "Saved ");
102 let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else {
103 panic!()
104 };
105 assert_same(section.page(space).unwrap(), &after);
106 published(&section, id);
107 assert!(notified.load(Ordering::SeqCst) > 0);
108 assert_same(stored_page(&file, space), &after);
109 assert!(matches!(
110 section.status(id).unwrap(),
111 Some(EditStatus::Published { .. })
112 ));
113 section.close().unwrap();
114 let (section, _) = open(&file, &cache);
115 assert_same(section.page(space).unwrap(), &after);
116 assert!(section.pending().unwrap().is_empty());
117 assert!(matches!(
118 section.status(id).unwrap(),
119 Some(EditStatus::Published { .. })
120 ));
121 section.close().unwrap();
122 if let Some(destination) = std::env::var_os("ONESTORE_SESSION_NATIVE_EXPORT") {
123 let destination = std::path::PathBuf::from(destination);
124 std::fs::create_dir(&destination).unwrap();
125 let bytes = onestore::read_file(&file).unwrap();
126 let identity = onestore::Store::parse(&bytes).unwrap().header.file_id;
127 std::fs::write(destination.join("notes.one"), bytes).unwrap();
128 std::fs::write(
129 destination.join("Open Notebook.onetoc2"),
130 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("notes.one", identity)])
131 .unwrap(),
132 )
133 .unwrap();
134 }
135}
136
137#[test]
138fn saves_wait_for_an_unreachable_file_and_publish_after_relaunch() {
139 let directory = tempfile::tempdir().unwrap();
140 let file = directory.path().join("notes.one");
141 let cache = directory.path().join("cache");
142 std::fs::write(
143 &file,
144 onestore::create_section("notes.one", "Original", "Author").unwrap(),
145 )
146 .unwrap();
147 let (section, _) = open(&file, &cache);
148 let space = section.pages().unwrap()[0].0;
149 let before = section.page(space).unwrap();
150 use std::os::unix::fs::PermissionsExt;
151 let permissions = std::fs::metadata(&file).unwrap().permissions();
152 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o444)).unwrap();
153 let after = edited(&before, "Offline ");
154 let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else {
155 panic!()
156 };
157 wait(&section, |event| matches!(event, Event::Unreachable(_)));
158 section.close().unwrap();
159 assert_eq!(
160 notebook::Replica::open(
161 std::fs::read_dir(&cache)
162 .unwrap()
163 .next()
164 .unwrap()
165 .unwrap()
166 .path()
167 )
168 .unwrap()
169 .status(id)
170 .unwrap(),
171 Some(EditStatus::Pending)
172 );
173 let (section, _) = open(&file, &cache);
174 assert_same(section.page(space).unwrap(), &after);
175 assert_eq!(section.pending().unwrap().len(), 1);
176 assert_eq!(stored_page(&file, space), before);
177 std::fs::set_permissions(&file, permissions).unwrap();
178 section.wake();
179 published(&section, id);
180 assert_same(stored_page(&file, space), &after);
181 section.close().unwrap();
182}
183
184#[test]
185fn an_external_change_refreshes_the_page_and_stales_a_save_from_the_old_model() {
186 let directory = tempfile::tempdir().unwrap();
187 let file = directory.path().join("notes.one");
188 let cache = directory.path().join("cache");
189 std::fs::write(
190 &file,
191 onestore::create_section("notes.one", "Original", "Author").unwrap(),
192 )
193 .unwrap();
194 let (section, _) = open(&file, &cache);
195 let space = section.pages().unwrap()[0].0;
196 let before = section.page(space).unwrap();
197 let native = edited(&before, "Native ");
198 let deadline = Instant::now() + Duration::from_secs(20);
199 loop {
200 let bytes = onestore::read_file(&file).unwrap();
201 match PreparedEdit::page(&bytes, space, &native, "Native")
202 .unwrap()
203 .commit_file(&file)
204 {
205 Ok(()) => break,
206 Err(error) if error.error.kind() == std::io::ErrorKind::WouldBlock => {
207 assert!(Instant::now() < deadline);
208 std::thread::sleep(Duration::from_millis(20));
209 }
210 Err(error) => panic!("{error:?}"),
211 }
212 }
213 section.wake();
214 let deadline = Instant::now() + Duration::from_secs(20);
215 while section.page(space).unwrap().title == before.title {
216 assert!(Instant::now() < deadline, "the refresh did not arrive");
217 wait(&section, |event| matches!(event, Event::Refreshed));
218 }
219 assert_same(section.page(space).unwrap(), &native);
220 let native = section.page(space).unwrap();
221 let stale = edited(&before, "Local ");
222 assert_eq!(
223 section.save(space, &before, &stale, "Editor").unwrap(),
224 Save::Stale
225 );
226 let after = edited(&native, "Local ");
227 let Save::Queued(id) = section.save(space, &native, &after, "Editor").unwrap() else {
228 panic!()
229 };
230 published(&section, id);
231 assert_same(stored_page(&file, space), &after);
232 section.close().unwrap();
233}
234
235#[test]
236fn a_notebook_directory_lists_its_sections_and_opens_them() {
237 let directory = tempfile::tempdir().unwrap();
238 let root = directory.path().join("Personal");
239 std::fs::create_dir_all(root.join("Group")).unwrap();
240 for (name, text) in [
241 ("Personal/First.one", "One"),
242 ("Personal/Group/Second.one", "Two"),
243 ] {
244 std::fs::write(
245 directory.path().join(name),
246 onestore::create_section(
247 Path::new(name).file_name().unwrap().to_str().unwrap(),
248 text,
249 "Author",
250 )
251 .unwrap(),
252 )
253 .unwrap();
254 }
255 let notebook = Notebook::open(&root, directory.path().join("cache")).unwrap();
256 let catalog = notebook.catalog();
257 assert_eq!(catalog.sections.len(), 1);
258 assert_eq!(catalog.groups.len(), 1);
259 assert_eq!(catalog.groups[0].sections.len(), 1);
260 let path = catalog.groups[0].sections[0].path.clone();
261 let section = notebook.section(&path, || {}).unwrap();
262 let (space, _) = section.pages().unwrap()[0];
263 let page = section.page(space).unwrap();
264 let text = first_text(&page);
265 assert_eq!(
266 model_ops::paragraph_with(&page, text)
267 .unwrap()
268 .text()
269 .unwrap()
270 .text
271 .text(),
272 "Two"
273 );
274 section.close().unwrap();
275}
276
277#[test]
278fn a_notebook_only_opens_discovered_section_paths() {
279 let directory = tempfile::tempdir().unwrap();
280 let root = directory.path().join("Notebook");
281 let cache = directory.path().join("cache");
282 std::fs::create_dir(&root).unwrap();
283 let source = onestore::create_section("notes.one", "Original", "Author").unwrap();
284 std::fs::write(root.join("notes.one"), &source).unwrap();
285 let outside = directory.path().join("outside.one");
286 std::fs::write(&outside, &source).unwrap();
287 let notebook = Notebook::open(&root, &cache).unwrap();
288 std::fs::write(root.join("added.one"), &source).unwrap();
289 for path in ["../outside.one", outside.to_str().unwrap(), "added.one"] {
290 assert!(matches!(
291 notebook.section(path, || {}),
292 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound
293 ));
294 }
295 assert_eq!(std::fs::read_dir(&cache).unwrap().count(), 0);
296 assert_eq!(std::fs::read(&outside).unwrap(), source);
297}
298
299#[cfg(unix)]
300#[test]
301fn a_catalog_section_replaced_by_an_outside_symlink_is_rejected() {
302 let directory = tempfile::tempdir().unwrap();
303 let root = directory.path().join("Notebook");
304 let cache = directory.path().join("cache");
305 std::fs::create_dir(&root).unwrap();
306 let file = root.join("notes.one");
307 let outside = directory.path().join("outside.one");
308 let source = onestore::create_section("notes.one", "Original", "Author").unwrap();
309 std::fs::write(&file, &source).unwrap();
310 std::fs::write(&outside, &source).unwrap();
311 let notebook = Notebook::open(&root, &cache).unwrap();
312 std::fs::remove_file(&file).unwrap();
313 std::os::unix::fs::symlink(&outside, &file).unwrap();
314 assert!(matches!(
315 notebook.section("notes.one", || {}),
316 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::PermissionDenied
317 ));
318 assert_eq!(std::fs::read_dir(&cache).unwrap().count(), 0);
319 assert_eq!(std::fs::read(&outside).unwrap(), source);
320}
321
322#[test]
323fn an_absent_remote_does_not_prevent_local_relaunch_or_further_saves() {
324 let directory = tempfile::tempdir().unwrap();
325 let file = directory.path().join("remote.one");
326 let cache = directory.path().join("replica.sqlite");
327 let source = onestore::create_section("remote.one", "Original", "Author").unwrap();
328 let replica = notebook::Replica::create(&cache, &source).unwrap();
329 let section = Section::resume(&file, replica, || {}).unwrap();
330 let space = section.pages().unwrap()[0].0;
331 let before = section.page(space).unwrap();
332 let after = edited(&before, "First ");
333 let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else {
334 panic!()
335 };
336 wait(
337 &section,
338 |event| matches!(event, Event::Unreachable(error) if error.kind() == std::io::ErrorKind::NotFound),
339 );
340 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
341 section.close().unwrap();
342 assert!(!file.exists());
343
344 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap();
345 assert_same(section.page(space).unwrap(), &after);
346 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
347 let before = section.page(space).unwrap();
348 let after = edited(&before, "Second ");
349 let Save::Queued(next) = section.save(space, &before, &after, "Editor").unwrap() else {
350 panic!()
351 };
352 assert!(!file.exists());
353 let restored = directory.path().join("restored.one");
354 std::fs::write(&restored, &source).unwrap();
355 std::fs::rename(restored, &file).unwrap();
356 section.wake();
357 published(&section, next);
358 assert_same(stored_page(&file, space), &after);
359 section.close().unwrap();
360 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap();
361 assert_same(section.page(space).unwrap(), &after);
362 assert!(section.pending().unwrap().is_empty());
363 section.close().unwrap();
364}
365
366#[test]
367fn resuming_against_another_document_preserves_both_remote_and_pending_edit() {
368 let directory = tempfile::tempdir().unwrap();
369 let file = directory.path().join("other.one");
370 let cache = directory.path().join("replica.sqlite");
371 let source = onestore::create_section("notes.one", "Original", "Author").unwrap();
372 let replica = notebook::Replica::create(&cache, &source).unwrap();
373 let section = Section::resume(&file, replica, || {}).unwrap();
374 let space = section.pages().unwrap()[0].0;
375 let before = section.page(space).unwrap();
376 let after = edited(&before, "Local ");
377 let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else {
378 panic!()
379 };
380 wait(&section, |event| matches!(event, Event::Unreachable(_)));
381 section.close().unwrap();
382
383 let other = onestore::create_section("other.one", "Unrelated", "Other").unwrap();
384 std::fs::write(&file, &other).unwrap();
385 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap();
386 wait(&section, |event| matches!(event, Event::Failed(_)));
387 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
388 assert_same(section.page(space).unwrap(), &after);
389 assert_eq!(std::fs::read(&file).unwrap(), other);
390 assert!(section.close().is_err());
391 let replica = notebook::Replica::open(&cache).unwrap();
392 assert_eq!(replica.status(id).unwrap(), Some(EditStatus::Pending));
393 assert_eq!(std::fs::read(&file).unwrap(), other);
394}
395
396#[test]
397fn offline_save_process() {
398 let Some(root) = std::env::var_os("ONENOTE_SESSION_CHILD") else {
399 return;
400 };
401 let root = std::path::PathBuf::from(root);
402 let replica = notebook::Replica::open(root.join("replica.sqlite")).unwrap();
403 let section = Section::resume(root.join("absent.one"), replica, || {}).unwrap();
404 let space = section.pages().unwrap()[0].0;
405 let before = section.page(space).unwrap();
406 let after = edited(&before, "Durable ");
407 let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else {
408 panic!()
409 };
410 std::fs::write(root.join("acknowledged"), id.to_string()).unwrap();
411 // Terminate without running Session or SQLite destructors after acknowledgement.
412 std::process::exit(0);
413}
414
415#[test]
416fn an_acknowledged_offline_save_survives_process_exit_without_cleanup() {
417 let directory = tempfile::tempdir().unwrap();
418 let cache = directory.path().join("replica.sqlite");
419 let source = onestore::create_section("absent.one", "Original", "Author").unwrap();
420 drop(notebook::Replica::create(&cache, &source).unwrap());
421 let output = std::process::Command::new(std::env::current_exe().unwrap())
422 .args(["--exact", "offline_save_process", "--nocapture"])
423 .env("ONENOTE_SESSION_CHILD", directory.path())
424 .output()
425 .unwrap();
426 assert!(output.status.success(), "{output:?}");
427 let id: u64 = std::fs::read_to_string(directory.path().join("acknowledged"))
428 .unwrap()
429 .parse()
430 .unwrap();
431 let file = directory.path().join("absent.one");
432 assert!(!file.exists());
433 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), || {}).unwrap();
434 let space = section.pages().unwrap()[0].0;
435 let original = model_ops::page_of(&source, space);
436 let after = edited(&original, "Durable ");
437 assert_same(section.page(space).unwrap(), &after);
438 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
439 section.close().unwrap();
440}
441
442#[cfg(feature = "smb")]
443#[test]
444fn smb_connection_failure_keeps_the_session_locally_editable_and_retries() {
445 let directory = tempfile::tempdir().unwrap();
446 let cache = directory.path().join("replica.sqlite");
447 let source = onestore::create_section("notes.one", "Original", "Author").unwrap();
448 let replica = notebook::Replica::create(&cache, &source).unwrap();
449 let attempts = Arc::new(AtomicUsize::new(0));
450 let counter = Arc::clone(&attempts);
451 let section = Section::resume_smb(
452 "Folder/notes.one".into(),
453 replica,
454 1024 * 1024,
455 move || {
456 counter.fetch_add(1, Ordering::SeqCst);
457 Err(std::io::ErrorKind::PermissionDenied.into())
458 },
459 || {},
460 )
461 .unwrap();
462 wait(
463 &section,
464 |event| matches!(event, Event::Unreachable(error) if error.kind() == std::io::ErrorKind::PermissionDenied),
465 );
466 let space = section.pages().unwrap()[0].0;
467 let before = section.page(space).unwrap();
468 let after = edited(&before, "Offline SMB ");
469 let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else {
470 panic!()
471 };
472 section.wake();
473 wait(
474 &section,
475 |event| matches!(event, Event::Unreachable(error) if error.kind() == std::io::ErrorKind::PermissionDenied),
476 );
477 assert!(attempts.load(Ordering::SeqCst) >= 2);
478 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
479 assert_same(section.page(space).unwrap(), &after);
480 assert_eq!(section.file(), Path::new("Folder/notes.one"));
481 section.close().unwrap();
482 assert_eq!(
483 notebook::Replica::open(&cache).unwrap().status(id).unwrap(),
484 Some(EditStatus::Pending)
485 );
486}
487
488#[cfg(feature = "smb")]
489#[test]
490#[ignore = "requires ONESTORE_SESSION_SMB address and a disposable session.one on its agent share"]
491fn live_smb_session_save_publishes_and_reopens() {
492 use notebook::smb::{Client, Credentials};
493 let address = std::env::var("ONESTORE_SESSION_SMB").unwrap();
494 let connect = move || {
495 Client::connect(
496 &address,
497 "agent",
498 Credentials::default(),
499 Duration::from_secs(5),
500 )
501 };
502 let client = connect().unwrap();
503 let source = client.read("session.one", 1024 * 1024).unwrap();
504 let directory = tempfile::tempdir().unwrap();
505 let cache = directory.path().join("replica.sqlite");
506 let replica = notebook::Replica::create(&cache, &source).unwrap();
507 let section = Section::resume_smb(
508 "session.one".into(),
509 replica,
510 1024 * 1024,
511 connect.clone(),
512 || {},
513 )
514 .unwrap();
515 let space = section.pages().unwrap()[0].0;
516 let before = section.page(space).unwrap();
517 let after = edited(&before, "Session SMB ");
518 let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else {
519 panic!()
520 };
521 published(&section, id);
522 let remote = client.read("session.one", 1024 * 1024).unwrap();
523 assert_same(model_ops::page_of(&remote, space), &after);
524 section.close().unwrap();
525 let section = Section::resume_smb(
526 "session.one".into(),
527 notebook::Replica::open(&cache).unwrap(),
528 1024 * 1024,
529 connect,
530 || {},
531 )
532 .unwrap();
533 assert_same(section.page(space).unwrap(), &after);
534 assert!(section.pending().unwrap().is_empty());
535 assert!(matches!(
536 section.status(id).unwrap(),
537 Some(EditStatus::Published { .. })
538 ));
539 section.close().unwrap();
540}
541
542#[cfg(feature = "smb")]
543#[test]
544fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() {
545 use std::sync::mpsc;
546 let directory = tempfile::tempdir().unwrap();
547 let cache = directory.path().join("replica.sqlite");
548 let source = onestore::create_section("notes.one", "Original", "Author").unwrap();
549 let replica = notebook::Replica::create(&cache, &source).unwrap();
550 let (entered, connecting) = mpsc::channel();
551 let (release, stalled) = mpsc::channel();
552 let section = Section::resume_smb(
553 "notes.one".into(),
554 replica,
555 1024 * 1024,
556 move || {
557 entered.send(()).unwrap();
558 stalled.recv_timeout(Duration::from_secs(10)).unwrap();
559 Err(std::io::ErrorKind::TimedOut.into())
560 },
561 || {},
562 )
563 .unwrap();
564 connecting.recv_timeout(Duration::from_secs(5)).unwrap();
565 let space = section.pages().unwrap()[0].0;
566 let before = section.page(space).unwrap();
567 let after = edited(&before, "Saved during connection ");
568 let Save::Queued(id) = section.save(space, &before, &after, "Editor").unwrap() else {
569 panic!()
570 };
571 let (dropped, finished) = mpsc::channel();
572 let owner = std::thread::spawn(move || {
573 drop(section);
574 dropped.send(()).unwrap();
575 });
576 finished.recv_timeout(Duration::from_secs(2)).unwrap();
577 assert!(matches!(
578 notebook::Replica::open(&cache),
579 Err(notebook::Error::Database(error))
580 if error.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy)
581 ));
582 release.send(()).unwrap();
583 owner.join().unwrap();
584 let deadline = Instant::now() + Duration::from_secs(5);
585 let replica = loop {
586 match notebook::Replica::open(&cache) {
587 Ok(replica) => break replica,
588 Err(notebook::Error::Database(error))
589 if error.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy) =>
590 {
591 assert!(Instant::now() < deadline, "worker retained cache ownership");
592 std::thread::sleep(Duration::from_millis(10));
593 }
594 Err(error) => panic!("{error}"),
595 }
596 };
597 assert_eq!(replica.status(id).unwrap(), Some(EditStatus::Pending));
598 assert_same(
599 model_ops::page_of(&replica.snapshot().unwrap(), space),
600 &after,
601 );
602 assert!(connecting.try_recv().is_err());
603}
crates/onestore/src/commit.rs+11
......@@ -345,6 +345,17 @@ impl<'a> PreparedEdit<'a> {
345345 }
346346}
347347
348/// `confirm_snapshot` under the same whole-file exclusion `commit_file` uses.
349#[cfg(any(unix, windows))]
350pub fn confirm_file_snapshot(path: impl AsRef<Path>, source: &[u8]) -> Result<(), CommitError> {
351 let mut io = FileIo::open(path, true).map_err(|error| CommitError {
352 state: CommitState::NotCommitted,
353 error,
354 })?;
355 let result = confirm_snapshot(&mut io, source);
356 io.finish(result)
357}
358
348359/// Compares and flushes a snapshot, then refreshes its header version metadata.
349360/// No revision is added; reread before using the snapshot for another physical commit.
350361/// The caller must hold OneNote-compatible exclusion and independently establish which
crates/onestore/src/lib.rs+3-1
......@@ -29,7 +29,9 @@ pub use commit::{
2929 confirm_snapshot,
3030};
3131#[cfg(any(unix, windows))]
32pub use commit::{commit_file_property, commit_file_text, read_file, read_file_limited};
32pub use commit::{
33 commit_file_property, commit_file_text, confirm_file_snapshot, read_file, read_file_limited,
34};
3335pub use create::{create_section, create_table_of_contents};
3436pub use edit::replace_text;
3537pub use files::FileDataReference;