authorclover caruso <git@paperclover.net> 2026-10-15 13:00:06-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 15:21:41-07:00
logc345a48e944a431673a0e8b9d4a59e93dc507a58
tree50a0eb26f02cec8f68a9cba6c15d643ee3277e09
parentc780bc064c84cc299b01189b7b225c335dd00da7
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

feat(notebook): merge publications a provider lost

A document provider has no lock or compare-and-swap, so another device's upload can replace this one's and keep whichever lands last. Each section now keeps the images it publishes in the cache until the document is seen holding them, and a publication that was lost merges back into the document as an iCloud conflict version does, with conflict pages where edits clash, named for this device. A document still standing as it was before an upload gets a minute for reads to catch up before the upload counts as lost, and an upload a provider writing in place left torn is put back from the image this device kept. With lost publications protected, sections a provider only streams are no longer read-only: their edits publish by writing the whole section again. On Android the notebook passes the device's name along. Refs #24 Assisted-by: claude-opus-5.5

11 files changed, 816 insertions(+), 164 deletions(-)

apps/android/jni/snowbound.c+5-4
......@@ -247,12 +247,13 @@ JNIEXPORT void JNICALL PROVIDER(renamed)(JNIEnv *env, jclass cls, jlong renamed,
247247}
248248
249249JNIEXPORT jlong JNICALL JNI(provided)(JNIEnv *env, jclass cls, jstring root, jboolean folder, jboolean local,
250 jstring cache) {
250 jstring device, jstring cache) {
251251 (void)cls;
252 char *r = utf8(env, root), *c = r ? utf8(env, cache) : NULL, *error = NULL;
253 const Library *library = c ? sb_library_provider(r, folder, local, c, &error) : NULL;
252 char *r = utf8(env, root), *d = r ? utf8(env, device) : NULL, *c = d ? utf8(env, cache) : NULL;
253 char *error = NULL;
254 const Library *library = c ? sb_library_provider(r, folder, local, d, c, &error) : NULL;
254255 if (c && !library) fail(env, error, "The notebook can't be opened");
255 free(r), free(c);
256 free(r), free(d), free(c);
256257 return (jlong)(intptr_t)library;
257258}
258259
apps/android/src/net/paperclover/snowbound/Editor.java+11-2
......@@ -1,9 +1,11 @@
11package net.paperclover.snowbound;
22
33import android.content.Context;
4import android.os.Build;
45import android.os.Handler;
56import android.os.Looper;
67import android.util.Log;
8import android.provider.Settings;
79import android.view.Surface;
810import java.io.File;
911import java.text.DateFormat;
......@@ -62,7 +64,8 @@ final class Editor {
6264 if (picked != null && granted) {
6365 Provider.writable = picked.writable();
6466 try {
65 opened = provided(picked.root(), picked.folder, picked.local(), cache.getPath());
67 opened = provided(picked.root(), picked.folder, picked.local(), device(context),
68 cache.getPath());
6669 path = firstSection(opened);
6770 first = section(opened, path);
6871 } catch (IllegalStateException | JSONException error) {
......@@ -139,6 +142,12 @@ final class Editor {
139142 return false;
140143 }
141144
145 /** The name the user gave this device, as conflict pages name who made them. */
146 private static String device(Context context) {
147 String name = Settings.Global.getString(context.getContentResolver(), Settings.Global.DEVICE_NAME);
148 return name != null ? name : Build.MODEL;
149 }
150
142151 /** Called on the section's thread when it has events. */
143152 static void woken() {
144153 MAIN.post(Editor::poll);
......@@ -232,6 +241,6 @@ final class Editor {
232241 static native void reload(long view, boolean discard);
233242 static native void freeView(long view);
234243 static native boolean close(long library, long section, double seconds);
235 static native long provided(String root, boolean folder, boolean local, String cache);
244 static native long provided(String root, boolean folder, boolean local, String device, String cache);
236245 static native String syncStatus(long library);
237246}
crates/mobile/include/snowbound.h+3-2
......@@ -39,7 +39,7 @@ typedef void (*sb_document_found)(void *context, const char *id, const char *nam
3939typedef struct sb_provider {
4040 int32_t (*document)(const char *id, sb_document_found found, void *context);
4141 int32_t (*children)(const char *id, sb_document_found found, void *context);
42 // mode is "r" or "rw"; fd is a duplicate Snowbound owns and closes, handle the descriptor
42 // mode is "r", "rw", "wt" or "w"; fd is a duplicate Snowbound owns and closes, handle the descriptor
4343 // the host keeps open: detachFd would end the channel checkError reads. A nonzero status
4444 // sets neither; a negative fd with status 0 has its handle closed.
4545 int32_t (*open)(const char *id, const char *mode, int32_t *fd, uint64_t *handle);
......@@ -50,7 +50,8 @@ typedef struct sb_provider {
5050void sb_set_provider(const sb_provider *provider);
5151
5252const Library *sb_library_open(const char *path, const char *cache, bool local, char **error);
53const Library *sb_library_provider(const char *root, bool folder, bool local, const char *cache, char **error);
53const Library *sb_library_provider(const char *root, bool folder, bool local, const char *device, const char *cache,
54 char **error);
5455const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password,
5556 const char *domain, const char *root, const char *cache, char **error);
5657// Callback/token live until the blocking join returns; false cancels, approving reports host approval.
crates/mobile/src/library.rs+16-7
......@@ -512,7 +512,7 @@ impl Library {
512512 /// registered provider keeps (`sb_set_provider`). A provider `local` to this device
513513 /// publishes each edit at once; any other waits for a pause in typing. While the provider
514514 /// can't be reached, the folder opens as last listed and sections from their offline copies.
515 fn provider(root: &str, folder: bool, local: bool, cache: &Path) -> Result<Self> {
515 fn provider(root: &str, folder: bool, local: bool, device: &str, cache: &Path) -> Result<Self> {
516516 let host = crate::provider::Host::default();
517517 let name = (!folder)
518518 .then(|| {
......@@ -523,9 +523,9 @@ impl Library {
523523 )
524524 })
525525 .flatten();
526 let documents = Arc::new(Documents::new(host, root));
526 let documents = Arc::new(Documents::new(host, root, cache, device));
527527 let (notebook, place, background) = if folder {
528 let mut notebook = Notebook::open_provider(Arc::clone(&documents), cache)?;
528 let mut notebook = Notebook::open_provider(Arc::clone(&documents))?;
529529 let background = Background::provider(Arc::clone(&documents), sync_woken)?;
530530 background.watch(notebook.replicas());
531531 (Some(notebook), Place::Provider(documents), Some(background))
......@@ -824,7 +824,6 @@ impl Library {
824824 )?),
825825 Place::Document(documents, _) => Ok(session::Section::open_provider(
826826 Arc::clone(documents),
827 &self.cache,
828827 offline,
829828 notify,
830829 )?),
......@@ -2224,22 +2223,32 @@ pub unsafe extern "C" fn sb_library_flush(
22242223
22252224/// The notebook folder whose document is `root`, or with `folder` false the lone section
22262225/// `root` names, as the provider `sb_set_provider` registered keeps it; `local` as
2227/// `sb_library_open` takes it. While the provider can't be reached, a folder listed before
2226/// `sb_library_open` takes it, and `device` naming this device on the conflict pages its own
2227/// edits make when another device's upload replaced them. While the provider can't be reached, a folder listed before
22282228/// opens as last listed, and sections opened before open from their copies; a revoked grant
22292229/// refuses to open and keeps them.
22302230///
22312231/// # Safety
2232/// `root` and `cache` are NUL-terminated UTF-8; `error` is null or a place for a string.
2232/// `root`, `device` and `cache` are NUL-terminated UTF-8; `error` is null or a place for a
2233/// string.
22332234#[unsafe(no_mangle)]
22342235pub unsafe extern "C" fn sb_library_provider(
22352236 root: *const c_char,
22362237 folder: bool,
22372238 local: bool,
2239 device: *const c_char,
22382240 cache: *const c_char,
22392241 error: *mut *mut c_char,
22402242) -> *const Library {
2243 let cache = string(cache);
22412244 shared(
2242 Library::provider(&string(root), folder, local, Path::new(&string(cache))),
2245 Library::provider(
2246 &string(root),
2247 folder,
2248 local,
2249 &string(device),
2250 Path::new(&cache),
2251 ),
22432252 error,
22442253 )
22452254}
crates/mobile/src/provider.rs+1-1
......@@ -35,7 +35,7 @@ pub struct Table {
3535 pub document: extern "C" fn(id: *const c_char, found: Found, context: *mut c_void) -> i32,
3636 /// Calls `found` for each document in the folder `id`.
3737 pub children: extern "C" fn(id: *const c_char, found: Found, context: *mut c_void) -> i32,
38 /// Opens `id` in `ContentResolver` mode `r` or `rw`, setting `fd` to a descriptor the
38 /// Opens `id` in a `ContentResolver` mode, setting `fd` to a descriptor the
3939 /// caller owns and closes, and `handle` to what `close` finishes.
4040 pub open: extern "C" fn(
4141 id: *const c_char,
crates/mobile/src/tests.rs+9-2
......@@ -3752,9 +3752,16 @@ fn provided(root: &Path, folder: bool, cache: &Path) -> std::result::Result<Arc<
37523752 CString::new(cache.to_str().unwrap()).unwrap(),
37533753 );
37543754 let mut error = std::ptr::null_mut();
3755 // SAFETY: both strings are NUL-terminated and `error` a place for one.
3755 // SAFETY: every string is NUL-terminated and `error` a place for one.
37563756 let library = unsafe {
3757 library::sb_library_provider(root.as_ptr(), folder, true, cache.as_ptr(), &mut error)
3757 library::sb_library_provider(
3758 root.as_ptr(),
3759 folder,
3760 true,
3761 c"Test".as_ptr(),
3762 cache.as_ptr(),
3763 &mut error,
3764 )
37583765 };
37593766 if library.is_null() {
37603767 let message = string(error);
crates/notebook/README.md+20-7
......@@ -551,11 +551,11 @@ physical power-loss durability.
551551
552552`provider` opens notebooks a document provider keeps, as Android's Storage Access Framework
553553reaches them. The host implements `provider::Provider` (a document's description, a folder's
554children, `open` in `ContentResolver`'s `r` or `rw` as an owned `File`, `close` with the provider's
554children, `open` in a `ContentResolver` mode as an owned `File`, `close` with the provider's
555555verdict, `rename` returning the new id) and reports an unreachable provider as `NotConnected`,
556556a revoked grant as `PermissionDenied` and a gone document as `NotFound`.
557`provider::Documents::new(provider, root)` holds one root: a picked folder opens
558with `Notebook::open_provider(documents, cache)`, its sections with `Section::resume_provider`
557`provider::Documents::new(provider, root, cache, device)` holds one root, the app's
558cache and the device's name: a picked folder opens with `Notebook::open_provider(documents)`, its sections with `Section::resume_provider`
559559and the rest with `Background::provider`, as a Live Share guest's do; a picked document opens
560560as a lone section with `Section::open_provider`. Catalog paths resolve to ids by listing their
561561folders, again once when an id has gone, as a rename leaves it; of documents sharing a name the
......@@ -564,10 +564,23 @@ first by id lists and the rest are `unavailable`, named with their ids.
564564A publication resolves the document's id once, from the last listing, and writes nothing to
565565a document that took its name since. Without a write grant a document is only read, and
566566publication is refused as `ReadOnlyFilesystem`; where its `r` descriptor seeks, `rw` opens it
567and a commit checks the stamp and appends in place exactly as on a disk. A document that only
568streams is read whole for each stamp and is read-only too, its edits waiting, until lost
569publications merge back. No provider offers a lock or compare-and-swap: the check sees only
570this device's view of the document. An image that does not parse is never a base. An open
567and a commit checks the stamp and appends in place exactly as on a disk; a document that only
568streams is read whole for each stamp, and a commit reads it, checks its stamp, applies the
569transaction and writes the whole image with `wt`, or `w` where that is refused, images only
570growing. No provider offers a lock or compare-and-swap: the check sees only this device's view
571of the document, and another device's upload can replace this one's. So each image a section
572publishes is kept in the cache, beside its replica: once written in place, as the image it was
573published on (kept the same way) with the transaction applied, else read back; once a whole
574upload has opened, as uploaded. Once the document is seen otherwise it is listed as a
575`Remote::versions` version, until the document holds the image's revisions: a lost publication
576merges back as a conflict version does, conflict pages where it clashes, named for the device
577`Documents::new` was given. A document still standing as the image an upload was made on is
578given a minute for reads to catch up before the upload counts as lost. Only this device's
579publications are kept so, and one that cannot merge is set aside in the cache, as no new
580document can be made beside it. A document read torn as this device's own upload cut off on a
581provider that writes in place leaves it, the start of an image it kept, is written again from
582that image, once a step; any other image that does not parse is left as it is, and is never a
583base. An open
571584section checks its file every `Background::UNWATCHED`, the host's change reports waking it
572585between. While the provider can't be reached the notebook opens as its folders were last
573586listed and sections from their replicas; a revoked grant keeps them, and the notebook does not
crates/notebook/src/provider.rs+342-50
......@@ -12,6 +12,7 @@ use std::{
1212 io::{self, Read, Seek, SeekFrom, Write},
1313 path::PathBuf,
1414 sync::{Arc, Mutex, MutexGuard, PoisonError},
15 time::Duration,
1516};
1617
1718/// A document as its provider describes it.
......@@ -33,7 +34,7 @@ pub struct Document {
3334pub trait Provider: Send + Sync {
3435 fn document(&self, id: &str) -> io::Result<Document>;
3536 fn children(&self, id: &str) -> io::Result<Vec<Document>>;
36 /// Opens a document in a `ContentResolver` mode, `r` or `rw`.
37 /// Opens a document in a `ContentResolver` mode: `r`, `rw`, `wt` or `w`.
3738 fn open(&self, id: &str, mode: &str) -> io::Result<File>;
3839 /// Closes a file `open` gave, with the provider's verdict on what was written to it.
3940 fn close(&self, file: File) -> io::Result<()>;
......@@ -46,15 +47,26 @@ pub trait Provider: Send + Sync {
4647pub struct Documents {
4748 provider: Box<dyn Provider>,
4849 pub(crate) root: String,
50 /// The app's cache, holding the replicas and the notebook's listing.
51 pub(crate) cache: PathBuf,
52 /// This device as the conflict pages its merged-back publications make name it.
53 device: String,
4954 /// Each path's document as last listed; a stale one is listed again.
5055 ids: Mutex<BTreeMap<String, String>>,
5156}
5257
5358impl Documents {
54 pub fn new(provider: impl Provider + 'static, root: impl Into<String>) -> Self {
59 pub fn new(
60 provider: impl Provider + 'static,
61 root: impl Into<String>,
62 cache: impl Into<PathBuf>,
63 device: impl Into<String>,
64 ) -> Self {
5565 Self {
5666 provider: Box::new(provider),
5767 root: root.into(),
68 cache: cache.into(),
69 device: device.into(),
5870 ids: Mutex::default(),
5971 }
6072 }
......@@ -63,6 +75,12 @@ impl Documents {
6375 format!("provider:{}", self.root)
6476 }
6577
78 /// Where this device keeps the images it published until the documents are seen holding
79 /// them: a provider without compare-and-swap may let another device's upload replace one.
80 fn published(&self) -> PathBuf {
81 crate::location::folder(&self.cache, &self.location()).join("published")
82 }
83
6684 fn id(&self, path: &str) -> io::Result<String> {
6785 if path.is_empty() {
6886 return Ok(self.root.clone());
......@@ -150,9 +168,12 @@ impl Documents {
150168 self.opened(id, "r", |file| {
151169 if file.stream_position().is_err() {
152170 let image = whole(file, crate::MAX_FILE_BYTES)?;
153 let stamp = Stamp::of(&image)
154 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
155 return Ok((stamp, Some(image)));
171 // A torn image shorter than a header still stamps, as it stands.
172 let mut header = [0; 1024];
173 let start = &image[..image.len().min(1024)];
174 header[..start.len()].copy_from_slice(start);
175 let length = image.len() as u64;
176 return Ok((Stamp { header, length }, Some(image)));
156177 }
157178 let mut header = [0; 1024];
158179 file.read_exact(&mut header)?;
......@@ -162,42 +183,80 @@ impl Documents {
162183 })
163184 }
164185
165 /// Writes the document at `path` in place through `operation`, provided its grant allows
166 /// writing and it opens seekable. The id is the one the last listing gave, never listed
167 /// again, so that a document taking the name meanwhile is not the one written.
186 /// Writes the document at `path` through `operation`, provided its grant allows writing:
187 /// in place where its `r` descriptor seeks, else on the image read whole, which then
188 /// replaces it. `keep` takes what was written: the descriptor, once the commit landed, or
189 /// the image, once a write to the document opened. The id is the one the last listing gave,
190 /// never listed again, so that a document taking the name meanwhile is not the one written.
168191 fn write(
169192 &self,
170193 path: &str,
171 operation: impl FnOnce(&mut Seekable) -> std::result::Result<(), CommitError>,
194 operation: impl FnOnce(&mut Io) -> std::result::Result<(), CommitError>,
195 keep: impl FnOnce(Written<'_>) -> io::Result<()>,
172196 ) -> std::result::Result<(), CommitError> {
173 let refused = |error| CommitError {
174 state: CommitState::NotCommitted,
175 error,
176 };
177 let read_only = |reason| refused(io::Error::new(io::ErrorKind::ReadOnlyFilesystem, reason));
178197 let id = self.id(path).map_err(refused)?;
179 if !self.provider.document(&id).map_err(refused)?.writable {
180 return Err(read_only(
181 "The app keeping this section lets Snowbound only read it",
182 ));
183 }
198 self.writable(&id).map_err(refused)?;
184199 let seekable = self
185200 .opened(&id, "r", |file| Ok(file.stream_position().is_ok()))
186201 .map_err(refused)?;
187 // Streamed documents stay read-only until lost publications merge back (plan commit 2).
188 if !seekable {
189 return Err(read_only(
190 "The app keeping this section can't save changes in place yet; they wait on this device",
191 ));
192 }
193 let mut io = Seekable(self.provider.open(&id, "rw").map_err(refused)?);
202 let mut io = if seekable {
203 Io::File(self.provider.open(&id, "rw").map_err(refused)?)
204 } else {
205 let image = self.opened(&id, "r", |file| whole(file, crate::MAX_FILE_BYTES));
206 Io::Image(image.map_err(refused)?)
207 };
194208 let result = operation(&mut io);
195 let closed = self.provider.close(io.0);
196 result?;
197 closed.map_err(|error| CommitError {
198 state: CommitState::Unknown,
199 error,
200 })
209 match io {
210 Io::File(mut file) => {
211 // A copy it could not keep only leaves the publication unprotected.
212 if result.is_ok() {
213 let _ = keep(Written::InPlace(&mut file));
214 }
215 let closed = self.provider.close(file);
216 result?;
217 closed.map_err(uncertain)
218 }
219 Io::Image(image) => {
220 result?;
221 self.upload(&id, &image, keep)
222 }
223 }
224 }
225
226 /// Fails unless the grant allows writing the document `id`.
227 fn writable(&self, id: &str) -> io::Result<()> {
228 if self.provider.document(id)?.writable {
229 return Ok(());
230 }
231 Err(io::Error::new(
232 io::ErrorKind::ReadOnlyFilesystem,
233 "The app keeping this section lets Snowbound only read it",
234 ))
235 }
236
237 /// Writes `image` whole as the document `id`, with `wt`, or `w` where that is refused
238 /// (images only grow), having `keep` take it once a write opened.
239 fn upload(
240 &self,
241 id: &str,
242 image: &[u8],
243 keep: impl FnOnce(Written<'_>) -> io::Result<()>,
244 ) -> std::result::Result<(), CommitError> {
245 // A provider refusing `wt` may say so as any failure but a revoked grant.
246 let mut file = match self.provider.open(id, "wt") {
247 Err(error) if error.kind() != io::ErrorKind::PermissionDenied => {
248 self.provider.open(id, "w")
249 }
250 opened => opened,
251 }
252 .map_err(refused)?;
253 if let Err(error) = keep(Written::Image(image)) {
254 let _ = self.provider.close(file);
255 return Err(refused(error));
256 }
257 let written = file.write_all(image);
258 let closed = self.provider.close(file);
259 written.and(closed).map_err(uncertain)
201260 }
202261
203262 /// Renames the document at `path` within its folder.
......@@ -208,6 +267,20 @@ impl Documents {
208267 }
209268}
210269
270fn refused(error: io::Error) -> CommitError {
271 CommitError {
272 state: CommitState::NotCommitted,
273 error,
274 }
275}
276
277fn uncertain(error: io::Error) -> CommitError {
278 CommitError {
279 state: CommitState::Unknown,
280 error,
281 }
282}
283
211284fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
212285 mutex.lock().unwrap_or_else(PoisonError::into_inner)
213286}
......@@ -221,30 +294,116 @@ fn whole(file: &mut File, limit: usize) -> io::Result<Vec<u8>> {
221294 Ok(bytes)
222295}
223296
224/// A seekable descriptor as a commit writes it.
225struct Seekable(File);
297/// What a write wrote: the document in place, or its whole image.
298enum Written<'a> {
299 InPlace(&'a mut File),
300 Image(&'a [u8]),
301}
226302
227impl CommitIo for Seekable {
303/// A document as a commit writes it: its seekable descriptor, or its image to write whole.
304enum Io {
305 File(File),
306 Image(Vec<u8>),
307}
308
309impl CommitIo for Io {
228310 fn read_at(&mut self, offset: u64, bytes: &mut [u8]) -> io::Result<usize> {
229 self.0.seek(SeekFrom::Start(offset))?;
230 self.0.read(bytes)
311 match self {
312 Io::File(file) => {
313 file.seek(SeekFrom::Start(offset))?;
314 file.read(bytes)
315 }
316 Io::Image(image) => {
317 let rest = usize::try_from(offset)
318 .ok()
319 .and_then(|offset| image.get(offset..))
320 .unwrap_or_default();
321 let count = rest.len().min(bytes.len());
322 bytes[..count].copy_from_slice(&rest[..count]);
323 Ok(count)
324 }
325 }
231326 }
232327
233328 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> {
234 self.0.seek(SeekFrom::Start(offset))?;
235 self.0.write(bytes)
329 match self {
330 Io::File(file) => {
331 file.seek(SeekFrom::Start(offset))?;
332 file.write(bytes)
333 }
334 Io::Image(image) => {
335 let offset = usize::try_from(offset).map_err(|_| io::ErrorKind::FileTooLarge)?;
336 let end = offset + bytes.len();
337 if image.len() < end {
338 image.resize(end, 0);
339 }
340 image[offset..end].copy_from_slice(bytes);
341 Ok(bytes.len())
342 }
343 }
236344 }
237345
238346 fn flush(&mut self) -> io::Result<()> {
239 self.0.sync_all()
347 match self {
348 Io::File(file) => file.sync_all(),
349 Io::Image(_) => Ok(()),
350 }
240351 }
241352}
242353
243/// A section a document provider keeps, as its replica publishes to it.
354/// The hex of a stamp's digest, which names an image this device keeps.
355fn digest(stamp: &Stamp) -> String {
356 use sha2::Digest;
357 let digest = sha2::Sha256::new()
358 .chain_update(stamp.header)
359 .chain_update(stamp.length.to_le_bytes())
360 .finalize();
361 hex(&digest[..16])
362}
363
364fn hex(bytes: &[u8]) -> String {
365 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
366}
367
368fn file_of(header: &[u8]) -> Option<[u8; 16]> {
369 Some(onestore::Header::parse(header).ok()?.file_id)
370}
371
372/// An image this device keeps, as its name gives it: `<document>-<image>-<base>.one`, the
373/// document's identity, then the digests of the image's stamp and of the stamp of the image
374/// it was published on.
375struct Kept {
376 name: String,
377 document: String,
378 image: String,
379 base: String,
380}
381
382impl Kept {
383 fn named(name: String) -> Option<Self> {
384 let mut parts = name.strip_suffix(".one")?.split('-').map(str::to_owned);
385 let (document, image, base) = (parts.next()?, parts.next()?, parts.next()?);
386 Some(Self {
387 name,
388 document,
389 image,
390 base,
391 })
392 }
393}
394
395/// How long reads may lag this device's own upload before a document still standing as the
396/// image the upload was made on counts as having lost it.
397const LAG: Duration = Duration::from_secs(60);
398
399/// A section a document provider keeps, as its replica publishes to it. Each image it
400/// publishes is kept, and listed as a version once the document is seen otherwise, until the
401/// document holds it: an upload another device's replaced then merges back as a conflict
402/// version does (`resolve.rs`). Only this device's publications are kept so.
244403pub(crate) struct ProviderRemote {
245404 documents: Arc<Documents>,
246405 path: String,
247 /// The image the last stamp had to read whole, which the step's read takes.
406 /// The image the step read whole for a stamp, which its read takes.
248407 read: Option<Vec<u8>>,
249408}
250409
......@@ -256,6 +415,82 @@ impl ProviderRemote {
256415 read: None,
257416 }
258417 }
418
419 fn kept(&self) -> Vec<(Kept, std::time::SystemTime)> {
420 let entries = crate::fs::read_dir(self.documents.published())
421 .into_iter()
422 .flatten();
423 entries
424 .filter_map(|entry| {
425 let entry = entry.ok()?;
426 let kept = Kept::named(entry.file_name().into_string().ok()?)?;
427 Some((kept, entry.metadata().ok()?.modified().ok()?))
428 })
429 .collect()
430 }
431
432 /// Keeps the image `transaction` published, in place of the one it was published on:
433 /// that kept image with the transaction applied where this device kept it, else read back.
434 fn keep(&self, written: Written<'_>, transaction: &Transaction) -> io::Result<()> {
435 let folder = self.documents.published();
436 let base = digest(transaction.base());
437 let document = file_of(&transaction.base().header).ok_or(io::ErrorKind::InvalidData)?;
438 let on = (self.kept().into_iter())
439 .find(|(kept, _)| kept.document == hex(&document) && kept.image == base);
440 let image = match (written, &on) {
441 (Written::Image(image), _) => image.to_vec(),
442 (Written::InPlace(_), Some((kept, _))) => {
443 let mut image = crate::fs::read(folder.join(&kept.name))?;
444 transaction
445 .apply(&mut image)
446 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
447 image
448 }
449 (Written::InPlace(file), None) => {
450 file.seek(SeekFrom::Start(0))?;
451 whole(file, crate::MAX_FILE_BYTES)?
452 }
453 };
454 let name = format!(
455 "{}-{}-{base}.one",
456 hex(&document),
457 digest(&transaction.stamp())
458 );
459 crate::fs::create_dir_all(&folder)?;
460 let mut kept = tempfile::NamedTempFile::new_in(&folder)?;
461 kept.write_all(&image)?;
462 kept.as_file().sync_all()?;
463 kept.persist(folder.join(name))
464 .map_err(|error| error.error)?;
465 if let Some((kept, _)) = on {
466 crate::fs::remove_file(folder.join(kept.name))?;
467 }
468 Ok(())
469 }
470
471 /// This device's own upload cut off, as a provider that writes non-atomically leaves it,
472 /// put back and read again: the newest image kept that the torn bytes begin. Any other
473 /// document that does not parse is left as it is.
474 fn repair(&self, torn: &[u8]) -> io::Result<Vec<u8>> {
475 let mut newest = None;
476 for (kept, at) in self.kept() {
477 let image = crate::fs::read(self.documents.published().join(&kept.name))?;
478 if image.len() > torn.len() && image.starts_with(torn) {
479 newest = newest.max(Some((at, image)));
480 }
481 }
482 let (_, image) = newest.ok_or(io::ErrorKind::WouldBlock)?;
483 let id = self.documents.id(&self.path)?;
484 self.documents.writable(&id)?;
485 self.documents
486 .upload(&id, &image, |_| Ok(()))
487 .map_err(|error| error.error)?;
488 self.documents.read(&self.path, crate::MAX_FILE_BYTES)
489 }
490}
491
492fn parses(image: &[u8]) -> bool {
493 Store::parse(image).is_ok_and(|store| store.checksum_mismatches.is_empty())
259494}
260495
261496impl crate::Remote for ProviderRemote {
......@@ -265,7 +500,7 @@ impl crate::Remote for ProviderRemote {
265500 Some(image) => image,
266501 None => self.documents.read(&self.path, crate::MAX_FILE_BYTES)?,
267502 };
268 if !Store::parse(&image).is_ok_and(|store| store.checksum_mismatches.is_empty()) {
503 if !parses(&image) {
269504 return Err(io::Error::new(
270505 io::ErrorKind::WouldBlock,
271506 "The section was read mid-write",
......@@ -275,6 +510,10 @@ impl crate::Remote for ProviderRemote {
275510 }
276511
277512 fn stamp(&mut self) -> io::Result<Stamp> {
513 if let Some(image) = &self.read {
514 return Stamp::of(image)
515 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error));
516 }
278517 let (stamp, image) = self.documents.stamp(&self.path)?;
279518 self.read = image;
280519 Ok(stamp)
......@@ -282,14 +521,63 @@ impl crate::Remote for ProviderRemote {
282521
283522 fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> {
284523 self.read = None;
285 self.documents
286 .write(&self.path, |io| transaction.commit(io))
524 self.documents.write(
525 &self.path,
526 |io| transaction.commit(io),
527 |written| self.keep(written, transaction),
528 )
287529 }
288530
289531 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> {
290532 self.read = None;
291533 self.documents
292 .write(&self.path, |io| onestore::confirm(io, base))
534 .write(&self.path, |io| onestore::confirm(io, base), |_| Ok(()))
535 }
536
537 /// The images kept of this document other than the one it stands as; one whose upload
538 /// the document still stands before is listed only once reads have had `LAG` to catch up.
539 /// A document read torn is repaired first, once a step.
540 fn versions(&mut self) -> io::Result<Vec<crate::Version>> {
541 let (mut stamp, mut image) = self.documents.stamp(&self.path)?;
542 if let Some(torn) = image.as_ref().filter(|image| !parses(image))
543 && let Ok(repaired) = self.repair(torn)
544 && let Ok(repaired_stamp) = Stamp::of(&repaired)
545 {
546 (stamp, image) = (repaired_stamp, Some(repaired));
547 }
548 self.read = image;
549 // A header mid-write names no document; the step's read repairs it.
550 let Some(document) = file_of(&stamp.header) else {
551 return Ok(Vec::new());
552 };
553 let (document, current) = (hex(&document), digest(&stamp));
554 let lagging = |kept: &Kept, at: std::time::SystemTime| {
555 kept.base == current && at.elapsed().is_ok_and(|elapsed| elapsed < LAG)
556 };
557 Ok((self.kept().into_iter())
558 .filter(|(kept, at)| {
559 kept.document == document && kept.image != current && !lagging(kept, *at)
560 })
561 .map(|(kept, _)| crate::Version {
562 id: kept.name,
563 device: Some(self.documents.device.clone()),
564 })
565 .collect())
566 }
567
568 fn version(&mut self, id: &str) -> io::Result<Vec<u8>> {
569 crate::fs::read(self.documents.published().join(id))
570 }
571
572 /// One that cannot merge, of another section under the document's name, is set aside
573 /// whole, where `versions` lists it no more: no provider here takes a new document beside it.
574 fn retire(&mut self, id: &str, keep: bool) -> io::Result<()> {
575 let path = self.documents.published().join(id);
576 if keep {
577 crate::fs::rename(&path, path.with_extension("unmerged"))
578 } else {
579 crate::fs::remove_file(path)
580 }
293581 }
294582}
295583
......@@ -439,20 +727,24 @@ impl Storage for Tree {
439727 }
440728
441729 fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()> {
442 Ok(self.documents.write(path, |io| {
730 let place = |io: &mut Io| {
443731 onestore::place(io, ancestor, name).map_err(|error| CommitError {
444732 state: CommitState::Unknown,
445733 error,
446734 })
447 })?)
735 };
736 Ok(self.documents.write(path, place, |_| Ok(()))?)
448737 }
449738
450739 fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> {
451 Ok(self.documents.write(path, |io| transaction.commit(io))?)
740 Ok(self
741 .documents
742 .write(path, |io| transaction.commit(io), |_| Ok(()))?)
452743 }
453744
454745 fn confirm(&self, path: &str, base: &Stamp) -> std::result::Result<(), CommitError> {
455 self.documents.write(path, |io| onestore::confirm(io, base))
746 self.documents
747 .write(path, |io| onestore::confirm(io, base), |_| Ok(()))
456748 }
457749
458750 fn supersede(&self, _: &str, _: &Stamp, _: &str) -> Result<()> {
crates/notebook/src/provider/tests.rs+403-81
......@@ -12,9 +12,10 @@ use onestore::{
1212 page::{Page, PageObject},
1313};
1414use std::{
15 collections::HashMap,
1516 fs,
1617 ops::Range,
17 os::fd::OwnedFd,
18 os::fd::{AsRawFd, OwnedFd},
1819 path::Path,
1920 thread,
2021 time::{Duration, Instant},
......@@ -25,8 +26,10 @@ const TOC: &str = "Open Notebook.onetoc2";
2526#[derive(Clone, Copy, PartialEq)]
2627enum Kind {
2728 Seekable,
28 /// Pipes in every mode, `rw` included, with no metadata.
29 Stream,
29 /// Pipes in every mode, `rw` included, with no metadata; `truncate` where `wt` is taken.
30 Stream {
31 truncate: bool,
32 },
3033}
3134
3235struct Node {
......@@ -50,9 +53,19 @@ struct State {
5053 /// The next `rw` open finds its document renamed to `Moved.one` under a new id, and a
5154 /// document holding these bytes in its place.
5255 swap: Option<Vec<u8>>,
56 /// The next stream upload fails after this many bytes, leaving them as the document where
57 /// `.1`, as a provider that writes non-atomically does, or else the document as it was.
58 cut: Option<(u64, bool)>,
59 /// Streams refuse to open for writing, as a provider out of space does.
60 full: bool,
5361 modes: Vec<String>,
62 /// Each stream being uploaded, by descriptor: its document, whether `wt` truncates it, and
63 /// what arrived with the cut that stopped it, if one did.
64 uploads: HashMap<i32, (String, bool, Upload)>,
5465}
5566
67type Upload = thread::JoinHandle<(Vec<u8>, Option<(u64, bool)>)>;
68
5669struct Shared {
5770 folder: tempfile::TempDir,
5871 state: Mutex<State>,
......@@ -86,7 +99,10 @@ impl Fake {
8699 revoked: false,
87100 delayed: false,
88101 swap: None,
102 cut: None,
103 full: false,
89104 modes: Vec::new(),
105 uploads: HashMap::new(),
90106 };
91107 Self {
92108 store: Arc::new(Shared {
......@@ -105,7 +121,13 @@ impl Fake {
105121 }
106122
107123 fn documents(&self, root: &str) -> Arc<Documents> {
108 Arc::new(Documents::new(self.clone(), root))
124 let cache = self
125 .store
126 .folder
127 .path()
128 .join(format!("cache-{}", self.device));
129 let device = format!("Device {}", self.device);
130 Arc::new(Documents::new(self.clone(), root, cache, device))
109131 }
110132
111133 fn state(&self) -> MutexGuard<'_, State> {
......@@ -300,18 +322,59 @@ impl Provider for Fake {
300322 .truncate(mode == "wt")
301323 .open(file)
302324 }
303 (Kind::Stream, "r") => {
325 (Kind::Stream { .. }, "r") => {
304326 let (reader, mut writer) = io::pipe()?;
305327 let bytes = fs::read(self.file(id))?;
306328 thread::spawn(move || writer.write_all(&bytes));
307329 Ok(File::from(OwnedFd::from(reader)))
308330 }
309 (Kind::Stream, _) => Ok(File::from(OwnedFd::from(io::pipe()?.1))),
331 (Kind::Stream { .. }, "rw") => Ok(File::from(OwnedFd::from(io::pipe()?.1))),
332 // As Android's host reports a refused mode: unreachable, like most failures.
333 (Kind::Stream { truncate: false }, "wt") => Err(io::Error::new(
334 io::ErrorKind::NotConnected,
335 "Unsupported mode",
336 )),
337 (Kind::Stream { .. }, _) if state.full => Err(io::Error::other("No space left")),
338 (Kind::Stream { .. }, _) => {
339 let (reader, writer) = io::pipe()?;
340 let cut = state.cut.take();
341 let upload = thread::spawn(move || {
342 let mut received = Vec::new();
343 let at = cut.map_or(u64::MAX, |(at, _)| at);
344 let _ = reader.take(at).read_to_end(&mut received);
345 (received, cut)
346 });
347 let writer = File::from(OwnedFd::from(writer));
348 let upload = (id.to_owned(), mode == "wt", upload);
349 state.uploads.insert(writer.as_raw_fd(), upload);
350 Ok(writer)
351 }
310352 }
311353 }
312354
313 fn close(&self, _: File) -> io::Result<()> {
314 Ok(())
355 fn close(&self, file: File) -> io::Result<()> {
356 let upload = self.state().uploads.remove(&file.as_raw_fd());
357 drop(file);
358 let Some((id, truncate, upload)) = upload else {
359 return Ok(());
360 };
361 let (received, cut) = upload.join().unwrap();
362 if let Some((_, torn)) = cut {
363 if torn {
364 fs::write(self.file(&id), received)?;
365 }
366 return Err(io::Error::other("The upload failed"));
367 }
368 let mut bytes = if truncate {
369 Vec::new()
370 } else {
371 fs::read(self.file(&id))?
372 };
373 if bytes.len() < received.len() {
374 bytes.resize(received.len(), 0);
375 }
376 bytes[..received.len()].copy_from_slice(&received);
377 fs::write(self.file(&id), bytes)
315378 }
316379
317380 fn rename(&self, id: &str, name: &str) -> io::Result<String> {
......@@ -326,10 +389,33 @@ impl Provider for Fake {
326389 }
327390}
328391
392/// A section holding `text`, edited once since it was made, as a section OneNote has written
393/// is: the first edit of a page `create_section` makes starts a fresh revision chain, so its
394/// pages' later states trace back to that edit, not to the section as made.
395fn section_image(name: &str, text: &str) -> Vec<u8> {
396 let (first, rest) = text.split_at(1);
397 let mut image = onestore::create_section(name, rest, "Author").unwrap();
398 let arena = onestore::Arena::default();
399 let mut section = onestore::Section::open(&arena, image.clone()).unwrap();
400 let space = section.pages().unwrap()[0].0;
401 let op = PageOp::Text {
402 text: paragraph(&section.page(space).unwrap()).0,
403 range: 0..0,
404 with: first.into(),
405 };
406 let edit = Edit {
407 at: crate::now(),
408 ops: vec![Op::Page { space, op }],
409 };
410 section.apply("Author", &edit).unwrap();
411 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
412 image
413}
414
329415/// A notebook folder: its TOC and two sections it lists, placed under it.
330416fn notebook(fake: &Fake) {
331 let mut notes = onestore::create_section("Notes.one", "Original text", "Author").unwrap();
332 let mut ideas = onestore::create_section("Ideas.one", "First idea", "Author").unwrap();
417 let mut notes = section_image("Notes.one", "Original text");
418 let mut ideas = section_image("Ideas.one", "First idea");
333419 let file = |image: &[u8]| Store::parse(image).unwrap().header.file_id;
334420 let toc = onestore::create_table_of_contents(
335421 TOC,
......@@ -432,9 +518,8 @@ fn status(section: &session::Section, accept: impl Fn(&SyncStatus) -> bool) -> S
432518fn seekable_documents_take_commits_in_place_and_rebase_on_another_devices() {
433519 let fake = Fake::new(Kind::Seekable);
434520 notebook(&fake);
435 let cache = tempfile::tempdir().unwrap();
436521 let documents = fake.documents("root");
437 let notebook = Notebook::open_provider(Arc::clone(&documents), cache.path()).unwrap();
522 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
438523 assert_eq!(notebook.catalog().order, ["Notes.one", "Ideas.one"]);
439524 let section = open(&notebook, &documents, "Notes.one");
440525 let before = fake.bytes("Notes.one").len();
......@@ -446,8 +531,7 @@ fn seekable_documents_take_commits_in_place_and_rebase_on_another_devices() {
446531 assert!(fake.modes().iter().all(|mode| mode == "r" || mode == "rw"));
447532
448533 let elsewhere = fake.device(1).documents("root");
449 let other_cache = tempfile::tempdir().unwrap();
450 let other = Notebook::open_provider(Arc::clone(&elsewhere), other_cache.path()).unwrap();
534 let other = Notebook::open_provider(Arc::clone(&elsewhere)).unwrap();
451535 let other = open(&other, &elsewhere, "Notes.one");
452536 let id = typed(&other, 0..0, "World ");
453537 published(&other, id);
......@@ -467,44 +551,24 @@ fn seekable_documents_take_commits_in_place_and_rebase_on_another_devices() {
467551}
468552
469553#[test]
470fn stream_only_documents_are_read_and_never_opened_to_write() {
471 let fake = Fake::new(Kind::Stream);
472 notebook(&fake);
473 let cache = tempfile::tempdir().unwrap();
474 let documents = fake.documents("root");
475 let notebook = Notebook::open_provider(Arc::clone(&documents), cache.path()).unwrap();
476 let section = open(&notebook, &documents, "Notes.one");
477 let id = typed(&section, 13..13, " mine");
478 let waiting = status(&section, |status| status.error.is_some());
479 assert_eq!(waiting.state(), SyncState::ReadOnly);
480 assert_eq!(waiting.queued, 1);
481 assert!(fake.modes().iter().all(|mode| mode == "r"));
482
483 // Another device, which can write in place, changes the document.
484 let elsewhere = Fake::new(Kind::Seekable);
485 for name in [TOC, "Notes.one", "Ideas.one"] {
486 elsewhere.add(name, &fake.bytes(name));
554fn stream_only_documents_take_whole_images_and_never_open_to_write_in_place() {
555 for truncate in [true, false] {
556 let fake = Fake::new(Kind::Stream { truncate });
557 notebook(&fake);
558 let documents = fake.documents("root");
559 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
560 let section = open(&notebook, &documents, "Ideas.one");
561 let id = typed(&section, 0..0, "Second. ");
562 published(&section, id);
563 let id = typed(&section, 0..0, "Third. ");
564 published(&section, id);
565 assert_eq!(stored(fake.bytes("Ideas.one")), "Third. Second. First idea");
566 let modes = fake.modes();
567 let count = |wanted: &str| modes.iter().filter(|mode| *mode == wanted).count();
568 assert_eq!(count("rw"), 0, "{modes:?}");
569 assert_eq!((count("wt"), count("w")), (2, if truncate { 0 } else { 2 }));
570 section.close().unwrap();
487571 }
488 let other_documents = elsewhere.documents("root");
489 let other_cache = tempfile::tempdir().unwrap();
490 let other = Notebook::open_provider(Arc::clone(&other_documents), other_cache.path()).unwrap();
491 let other = open(&other, &other_documents, "Notes.one");
492 let theirs = typed(&other, 0..0, "Theirs ");
493 published(&other, theirs);
494 other.close().unwrap();
495 fs::write(
496 fake.file(&fake.id("Notes.one")),
497 elsewhere.bytes("Notes.one"),
498 )
499 .unwrap();
500 until("the change was read", || {
501 section.wake();
502 text(&section) == "Theirs Original text mine"
503 });
504 assert_eq!(section.pending().unwrap()[0].id, id);
505 assert_eq!(stored(fake.bytes("Notes.one")), "Theirs Original text");
506 assert!(fake.modes().iter().all(|mode| mode == "r"));
507 section.close().unwrap();
508572}
509573
510574#[test]
......@@ -512,9 +576,8 @@ fn documents_without_a_write_grant_are_only_read() {
512576 let fake = Fake::new(Kind::Seekable);
513577 notebook(&fake);
514578 fake.set(|state| state.writable = false);
515 let cache = tempfile::tempdir().unwrap();
516579 let documents = fake.documents("root");
517 let notebook = Notebook::open_provider(Arc::clone(&documents), cache.path()).unwrap();
580 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
518581 let section = open(&notebook, &documents, "Notes.one");
519582 let before = fake.bytes("Notes.one");
520583 typed(&section, 0..0, "Mine ");
......@@ -536,9 +599,8 @@ fn a_write_another_device_sees_late_rebases_it_once_visible() {
536599 notebook(&fake);
537600 fake.set(|state| state.delayed = true);
538601 let (writer, reader) = (fake.documents("root"), fake.device(1).documents("root"));
539 let caches = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
540 let writing = Notebook::open_provider(Arc::clone(&writer), caches.0.path()).unwrap();
541 let reading = Notebook::open_provider(Arc::clone(&reader), caches.1.path()).unwrap();
602 let writing = Notebook::open_provider(Arc::clone(&writer)).unwrap();
603 let reading = Notebook::open_provider(Arc::clone(&reader)).unwrap();
542604 let (writing, reading) = (
543605 open(&writing, &writer, "Notes.one"),
544606 open(&reading, &reader, "Notes.one"),
......@@ -567,9 +629,8 @@ fn a_write_another_device_sees_late_rebases_it_once_visible() {
567629fn a_cold_start_while_offline_opens_the_last_listing_and_queued_edits() {
568630 let fake = Fake::new(Kind::Seekable);
569631 notebook(&fake);
570 let cache = tempfile::tempdir().unwrap();
571632 let documents = fake.documents("root");
572 let mut notebook = Notebook::open_provider(Arc::clone(&documents), cache.path()).unwrap();
633 let mut notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
573634 let background = Background::provider(Arc::clone(&documents), || {}).unwrap();
574635 background.watch(notebook.replicas());
575636 until("every section has its offline copy", || {
......@@ -589,7 +650,7 @@ fn a_cold_start_while_offline_opens_the_last_listing_and_queued_edits() {
589650
590651 // A new process: the provider fails throughout.
591652 let documents = fake.documents("root");
592 let notebook = Notebook::open_provider(Arc::clone(&documents), cache.path()).unwrap();
653 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
593654 assert_eq!(notebook.catalog().order, ["Notes.one", "Ideas.one"]);
594655 let notes = open(&notebook, &documents, "Notes.one");
595656 assert_eq!(text(&notes), "Original text");
......@@ -608,9 +669,8 @@ fn a_cold_start_while_offline_opens_the_last_listing_and_queued_edits() {
608669fn a_revoked_grant_is_not_offline_and_keeps_the_replicas() {
609670 let fake = Fake::new(Kind::Seekable);
610671 notebook(&fake);
611 let cache = tempfile::tempdir().unwrap();
612672 let documents = fake.documents("root");
613 let notebook = Notebook::open_provider(Arc::clone(&documents), cache.path()).unwrap();
673 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
614674 let replica = notebook.replica_path("Notes.one").unwrap();
615675 let section = open(&notebook, &documents, "Notes.one");
616676 fake.set(|state| state.revoked = true);
......@@ -622,7 +682,7 @@ fn a_revoked_grant_is_not_offline_and_keeps_the_replicas() {
622682 );
623683 section.close().unwrap();
624684 drop(notebook);
625 let refused = Notebook::open_provider(fake.documents("root"), cache.path()).err();
685 let refused = Notebook::open_provider(fake.documents("root")).err();
626686 assert!(
627687 matches!(
628688 &refused,
......@@ -638,7 +698,7 @@ fn a_revoked_grant_is_not_offline_and_keeps_the_replicas() {
638698
639699 fake.set(|state| state.revoked = false);
640700 let documents = fake.documents("root");
641 let notebook = Notebook::open_provider(Arc::clone(&documents), cache.path()).unwrap();
701 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
642702 let section = open(&notebook, &documents, "Notes.one");
643703 published(&section, id);
644704 assert_eq!(stored(fake.bytes("Notes.one")), "Kept Original text");
......@@ -649,9 +709,8 @@ fn a_revoked_grant_is_not_offline_and_keeps_the_replicas() {
649709fn documents_renamed_to_new_ids_are_listed_again() {
650710 let fake = Fake::new(Kind::Seekable);
651711 notebook(&fake);
652 let cache = tempfile::tempdir().unwrap();
653712 let documents = fake.documents("root");
654 let mut notebook = Notebook::open_provider(Arc::clone(&documents), cache.path()).unwrap();
713 let mut notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
655714 let section = open(&notebook, &documents, "Notes.one");
656715 let id = typed(&section, 0..0, "One ");
657716 published(&section, id);
......@@ -691,9 +750,8 @@ fn of_documents_sharing_a_name_one_lists_and_the_rest_are_unavailable() {
691750 "Notes.one",
692751 &onestore::create_section("Notes.one", "A twin", "Author").unwrap(),
693752 );
694 let cache = tempfile::tempdir().unwrap();
695753 let documents = fake.documents("root");
696 let notebook = Notebook::open_provider(Arc::clone(&documents), cache.path()).unwrap();
754 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
697755 assert_eq!(notebook.catalog().order, ["Notes.one", "Ideas.one"]);
698756 let unavailable = &notebook.catalog().unavailable;
699757 assert_eq!(unavailable.len(), 1);
......@@ -707,9 +765,8 @@ fn of_documents_sharing_a_name_one_lists_and_the_rest_are_unavailable() {
707765fn a_document_taking_the_name_between_check_and_write_is_not_written() {
708766 let fake = Fake::new(Kind::Seekable);
709767 notebook(&fake);
710 let cache = tempfile::tempdir().unwrap();
711768 let documents = fake.documents("root");
712 let notebook = Notebook::open_provider(Arc::clone(&documents), cache.path()).unwrap();
769 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
713770 let section = open(&notebook, &documents, "Notes.one");
714771 let id = typed(&section, 0..0, "Checked ");
715772 published(&section, id);
......@@ -761,9 +818,8 @@ fn conflicting_edits_leave_a_conflict_page_in_the_document() {
761818 let fake = Fake::new(Kind::Seekable);
762819 notebook(&fake);
763820 let (mine, theirs) = (fake.documents("root"), fake.device(1).documents("root"));
764 let caches = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
765 let local = Notebook::open_provider(Arc::clone(&mine), caches.0.path()).unwrap();
766 let remote = Notebook::open_provider(Arc::clone(&theirs), caches.1.path()).unwrap();
821 let local = Notebook::open_provider(Arc::clone(&mine)).unwrap();
822 let remote = Notebook::open_provider(Arc::clone(&theirs)).unwrap();
767823 let (local, remote) = (
768824 open(&local, &mine, "Notes.one"),
769825 open(&remote, &theirs, "Notes.one"),
......@@ -793,10 +849,8 @@ fn a_lone_document_opens_offline_from_its_replica() {
793849 let fake = Fake::new(Kind::Seekable);
794850 let image = onestore::create_section("Lone.one", "Alone", "Author").unwrap();
795851 let lone = fake.add("Lone.one", &image);
796 let cache = tempfile::tempdir().unwrap();
797 let open = |fake: &Fake| {
798 session::Section::open_provider(fake.documents(&lone), cache.path(), false, || {}).unwrap()
799 };
852 let open =
853 |fake: &Fake| session::Section::open_provider(fake.documents(&lone), false, || {}).unwrap();
800854 let section = open(&fake);
801855 let id = typed(&section, 0..0, "Not ");
802856 published(&section, id);
......@@ -817,15 +871,13 @@ fn a_lone_document_opens_offline_from_its_replica() {
817871fn a_provider_that_lists_but_cannot_open_opens_sections_from_their_replicas() {
818872 let fake = Fake::new(Kind::Seekable);
819873 notebook(&fake);
820 let cache = tempfile::tempdir().unwrap();
821874 let documents = fake.documents("root");
822 let notebook = Notebook::open_provider(Arc::clone(&documents), cache.path()).unwrap();
875 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
823876 open(&notebook, &documents, "Notes.one").close().unwrap();
824877 drop(notebook);
825878 // Another device changes the section, so it lists otherwise than when last read.
826879 let elsewhere = fake.device(1).documents("root");
827 let other_cache = tempfile::tempdir().unwrap();
828 let other = Notebook::open_provider(Arc::clone(&elsewhere), other_cache.path()).unwrap();
880 let other = Notebook::open_provider(Arc::clone(&elsewhere)).unwrap();
829881 let other = open(&other, &elsewhere, "Notes.one");
830882 let id = typed(&other, 0..0, "Theirs ");
831883 published(&other, id);
......@@ -833,7 +885,7 @@ fn a_provider_that_lists_but_cannot_open_opens_sections_from_their_replicas() {
833885 fake.set(|state| state.unopenable = true);
834886
835887 let documents = fake.documents("root");
836 let notebook = Notebook::open_provider(Arc::clone(&documents), cache.path()).unwrap();
888 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
837889 assert_eq!(notebook.catalog().order, ["Notes.one", "Ideas.one"]);
838890 let section = open(&notebook, &documents, "Notes.one");
839891 assert_eq!(text(&section), "Original text");
......@@ -846,3 +898,273 @@ fn a_provider_that_lists_but_cannot_open_opens_sections_from_their_replicas() {
846898 assert_eq!(stored(fake.bytes("Notes.one")), "Theirs Original text mine");
847899 section.close().unwrap();
848900}
901
902/// The images a device keeps of what it published, for every notebook in its cache.
903fn kept_images(fake: &Fake) -> Vec<String> {
904 let cache = fake
905 .store
906 .folder
907 .path()
908 .join(format!("cache-{}", fake.device));
909 let folders = fs::read_dir(cache.join("replicas")).into_iter().flatten();
910 folders
911 .filter_map(|folder| fs::read_dir(folder.ok()?.path().join("published")).ok())
912 .flatten()
913 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
914 .filter(|name| name.ends_with(".one"))
915 .collect()
916}
917
918/// The images a device keeps that Notes.one does not stand as, still to merge.
919fn unmerged(fake: &Fake) -> Vec<String> {
920 let current = digest(&Stamp::of(&fake.bytes("Notes.one")).unwrap());
921 let mut images = kept_images(fake);
922 images.retain(|name| Kept::named(name.clone()).is_some_and(|kept| kept.image != current));
923 images
924}
925
926#[test]
927fn a_publication_another_devices_upload_replaced_merges_back() {
928 let fake = Fake::new(Kind::Seekable);
929 notebook(&fake);
930 fake.set(|state| state.delayed = true);
931 let (mine, theirs) = (fake.documents("root"), fake.device(1).documents("root"));
932 let (local, remote) = (
933 Notebook::open_provider(Arc::clone(&mine)).unwrap(),
934 Notebook::open_provider(Arc::clone(&theirs)).unwrap(),
935 );
936 let (local, remote) = (
937 open(&local, &mine, "Notes.one"),
938 open(&remote, &theirs, "Notes.one"),
939 );
940 let id = typed(&local, 0..0, "Alpha ");
941 published(&local, id);
942 let id = typed(&remote, 13..13, " beta");
943 published(&remote, id);
944 // Both uploads land; the later one, which never saw the first, is the document.
945 fake.publish_staged();
946 assert_eq!(stored(fake.bytes("Notes.one")), "Original text beta");
947 until("the lost publication merged back", || {
948 local.wake();
949 fake.publish_staged();
950 stored(fake.bytes("Notes.one")) == "Alpha Original text beta"
951 });
952 until("both devices show it", || {
953 local.wake();
954 remote.wake();
955 text(&local) == "Alpha Original text beta" && text(&remote) == text(&local)
956 });
957 assert!(local.conflicts().unwrap().is_empty());
958 assert!(unmerged(&fake).is_empty());
959 local.close().unwrap();
960 remote.close().unwrap();
961}
962
963/// Ages the images a device keeps past `LAG`, as if reads had long had time to catch up.
964fn aged(fake: &Fake) {
965 let cache = fake
966 .store
967 .folder
968 .path()
969 .join(format!("cache-{}", fake.device));
970 for folder in fs::read_dir(cache.join("replicas")).unwrap() {
971 for image in fs::read_dir(folder.unwrap().path().join("published"))
972 .into_iter()
973 .flatten()
974 {
975 let file = File::options()
976 .write(true)
977 .open(image.unwrap().path())
978 .unwrap();
979 let past = std::time::SystemTime::now() - LAG - Duration::from_secs(1);
980 file.set_modified(past).unwrap();
981 }
982 }
983}
984
985fn failed_attempt(section: &session::Section) {
986 until("the upload failed", || {
987 section.wake();
988 (section.events().iter()).any(|event| matches!(event, Event::Unreachable(_)))
989 });
990}
991
992#[test]
993fn an_upload_the_provider_dropped_merges_back_once_reads_could_have_caught_up() {
994 let fake = Fake::new(Kind::Stream { truncate: true });
995 notebook(&fake);
996 let documents = fake.documents("root");
997 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
998 let section = open(&notebook, &documents, "Notes.one");
999 fake.set(|state| state.cut = Some((2000, false)));
1000 let id = typed(&section, 0..0, "Cut ");
1001 failed_attempt(&section);
1002 // The document stands as the upload was made on: reads may lag it, so it waits.
1003 for _ in 0..5 {
1004 section.wake();
1005 thread::sleep(Duration::from_millis(50));
1006 }
1007 assert_eq!(kept_images(&fake).len(), 1);
1008 assert_eq!(stored(fake.bytes("Notes.one")), "Original text");
1009 aged(&fake);
1010 published(&section, id);
1011 assert_eq!(stored(fake.bytes("Notes.one")), "Cut Original text");
1012 section.close().unwrap();
1013}
1014
1015#[test]
1016fn a_torn_upload_on_a_provider_writing_in_place_is_put_back() {
1017 for at in [2000, 500] {
1018 let fake = Fake::new(Kind::Stream { truncate: true });
1019 notebook(&fake);
1020 let documents = fake.documents("root");
1021 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
1022 let section = open(&notebook, &documents, "Notes.one");
1023 fake.set(|state| state.cut = Some((at, true)));
1024 let id = typed(&section, 0..0, "Torn ");
1025 published(&section, id);
1026 assert_eq!(stored(fake.bytes("Notes.one")), "Torn Original text");
1027 until("the kept image was retired", || {
1028 section.wake();
1029 unmerged(&fake).is_empty()
1030 });
1031 section.close().unwrap();
1032 }
1033}
1034
1035#[test]
1036fn an_upload_refused_before_it_began_is_retried_not_merged() {
1037 let fake = Fake::new(Kind::Stream { truncate: true });
1038 notebook(&fake);
1039 let documents = fake.documents("root");
1040 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
1041 let section = open(&notebook, &documents, "Notes.one");
1042 fake.set(|state| state.full = true);
1043 let id = typed(&section, 0..0, "Once ");
1044 failed_attempt(&section);
1045 assert!(kept_images(&fake).is_empty());
1046 assert_eq!(section.pending().unwrap().len(), 1);
1047 fake.set(|state| state.full = false);
1048 published(&section, id);
1049 assert_eq!(stored(fake.bytes("Notes.one")), "Once Original text");
1050 section.close().unwrap();
1051}
1052
1053#[test]
1054fn devices_whose_uploads_replace_each_other_converge_with_every_string_once() {
1055 for seed in 1..=4u64 {
1056 let fake = Fake::new(Kind::Seekable);
1057 notebook(&fake);
1058 fake.set(|state| state.delayed = true);
1059 let devices: Vec<_> = (0..3)
1060 .map(|device| {
1061 let documents = fake.device(device).documents("root");
1062 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
1063 open(&notebook, &documents, "Notes.one")
1064 })
1065 .collect();
1066 let mut random = seed;
1067 let mut next = |bound: u64| {
1068 random = random
1069 .wrapping_mul(6364136223846793005)
1070 .wrapping_add(1442695040888963407);
1071 (random >> 33) % bound
1072 };
1073 let mut strings = Vec::new();
1074 for round in 0..6 {
1075 let device = next(3) as usize;
1076 let string = format!("s{seed}r{round}d{device}");
1077 typed(&devices[device], 0..0, &format!("{string} "));
1078 strings.push(string);
1079 devices[device].wake();
1080 thread::sleep(Duration::from_millis(next(150)));
1081 if next(2) == 0 {
1082 fake.publish_staged();
1083 }
1084 }
1085 until("every device converged", || {
1086 for device in &devices {
1087 device.wake();
1088 }
1089 thread::sleep(Duration::from_millis(100));
1090 fake.publish_staged();
1091 let file = stored(fake.bytes("Notes.one"));
1092 devices
1093 .iter()
1094 .all(|device| device.pending().unwrap().is_empty() && text(device) == file)
1095 && (0..3).all(|device| unmerged(&fake.device(device)).is_empty())
1096 });
1097 let arena = onestore::Arena::default();
1098 let mut section = onestore::Section::open(&arena, fake.bytes("Notes.one")).unwrap();
1099 let mut conflicts = String::new();
1100 for (_, pages) in section.conflicts().unwrap() {
1101 for conflict in pages {
1102 conflicts += &paragraph(&section.page(conflict.space).unwrap()).1;
1103 }
1104 }
1105 let file = stored(fake.bytes("Notes.one"));
1106 for string in &strings {
1107 let shown = file.matches(string.as_str()).count();
1108 assert!(
1109 shown <= 1,
1110 "seed {seed}: {string} shows {shown} times in {file}"
1111 );
1112 assert!(
1113 shown == 1 || conflicts.contains(string.as_str()),
1114 "seed {seed}: {string} is gone from {file}"
1115 );
1116 }
1117 for device in devices {
1118 device.close().unwrap();
1119 }
1120 }
1121}
1122
1123#[test]
1124fn a_document_this_device_did_not_write_that_does_not_parse_is_never_rewritten() {
1125 let fake = Fake::new(Kind::Stream { truncate: true });
1126 notebook(&fake);
1127 let documents = fake.documents("root");
1128 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
1129 let section = open(&notebook, &documents, "Notes.one");
1130 let id = typed(&section, 0..0, "Mine ");
1131 published(&section, id);
1132 // The same document as another writer took it on, cut off, then with checksums that fail.
1133 let mut theirs = fake.bytes("Notes.one");
1134 let arena = onestore::Arena::default();
1135 let mut section_there = onestore::Section::open(&arena, theirs.clone()).unwrap();
1136 let space = section_there.pages().unwrap()[0].0;
1137 let op = PageOp::Text {
1138 text: paragraph(&section_there.page(space).unwrap()).0,
1139 range: 0..0,
1140 with: "Theirs ".into(),
1141 };
1142 let edit = Edit {
1143 at: crate::now(),
1144 ops: vec![Op::Page { space, op }],
1145 };
1146 section_there.apply("Them", &edit).unwrap();
1147 let sealed = section_there.seal().unwrap().unwrap();
1148 sealed.apply(&mut theirs).unwrap();
1149 let mut damaged = theirs.clone();
1150 let at = (1024..damaged.len())
1151 .find(|at| {
1152 let mut flipped = damaged.clone();
1153 flipped[*at] ^= 0xff;
1154 Store::parse(&flipped).is_ok_and(|store| !store.checksum_mismatches.is_empty())
1155 })
1156 .unwrap();
1157 damaged[at] ^= 0xff;
1158 for unreadable in [theirs[..theirs.len() - 100].to_vec(), damaged] {
1159 fs::write(fake.file(&fake.id("Notes.one")), &unreadable).unwrap();
1160 let since = fake.modes().len();
1161 for _ in 0..5 {
1162 section.wake();
1163 thread::sleep(Duration::from_millis(50));
1164 }
1165 status(&section, |status| status.state() == SyncState::InUse);
1166 assert_eq!(fake.bytes("Notes.one"), unreadable);
1167 assert!(fake.modes()[since..].iter().all(|mode| mode == "r"));
1168 }
1169 section.close().unwrap();
1170}
crates/notebook/src/session.rs+5-7
......@@ -517,11 +517,9 @@ impl Notebook {
517517 /// Opens the notebook whose folder is the root of `documents`. Sections open through
518518 /// `Section::resume_provider` with the catalog's paths; while the provider can't be
519519 /// reached, the notebook opens as its folders were last listed.
520 pub fn open_provider(
521 documents: Arc<crate::provider::Documents>,
522 cache: impl AsRef<Path>,
523 ) -> Result<Self> {
524 let listed = listing(cache.as_ref(), &documents.location()).with_extension("entries.json");
520 pub fn open_provider(documents: Arc<crate::provider::Documents>) -> Result<Self> {
521 let cache = documents.cache.clone();
522 let listed = listing(&cache, &documents.location()).with_extension("entries.json");
525523 Self::with(
526524 Box::new(crate::provider::Tree { documents, listed }),
527525 None,
......@@ -2418,11 +2416,11 @@ impl Section {
24182416 /// its location has.
24192417 pub fn open_provider(
24202418 documents: Arc<crate::provider::Documents>,
2421 cache: impl AsRef<Path>,
24222419 offline: bool,
24232420 notify: impl Fn() + Send + Sync + 'static,
24242421 ) -> Result<Self> {
2425 let (cache, location) = (cache.as_ref(), documents.location());
2422 let (cache, location) = (documents.cache.clone(), documents.location());
2423 let cache = cache.as_path();
24262424 let file = PathBuf::from(&documents.root);
24272425 let remote = move || Ok(crate::provider::ProviderRemote::new(&documents, ""));
24282426 let notify = Arc::new(notify);
crates/notebook/src/sync.rs+1-1
......@@ -259,7 +259,7 @@ impl Replica {
259259 awaited(remote, |remote| remote.publish(&transaction)).await?;
260260 false
261261 }
262 // A version this cannot merge is kept whole rather than lost.
262 // A version this cannot merge is retired to be kept whole, never lost.
263263 Ok(Merged::Foreign) | Err(Error::Document(_) | Error::Rejected(_)) => true,
264264 Err(error) => return Err(error),
265265 };