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,...@@ -247,12 +247,13 @@ JNIEXPORT void JNICALL PROVIDER(renamed)(JNIEnv *env, jclass cls, jlong renamed,
247}247}
248248
249JNIEXPORT jlong JNICALL JNI(provided)(JNIEnv *env, jclass cls, jstring root, jboolean folder, jboolean local,249JNIEXPORT jlong JNICALL JNI(provided)(JNIEnv *env, jclass cls, jstring root, jboolean folder, jboolean local,
250 jstring cache) {250 jstring device, jstring cache) {
251 (void)cls;251 (void)cls;
252 char *r = utf8(env, root), *c = r ? utf8(env, cache) : NULL, *error = NULL;252 char *r = utf8(env, root), *d = r ? utf8(env, device) : NULL, *c = d ? utf8(env, cache) : NULL;
253 const Library *library = c ? sb_library_provider(r, folder, local, c, &error) : NULL;253 char *error = NULL;
254 const Library *library = c ? sb_library_provider(r, folder, local, d, c, &error) : NULL;
254 if (c && !library) fail(env, error, "The notebook can't be opened");255 if (c && !library) fail(env, error, "The notebook can't be opened");
255 free(r), free(c);256 free(r), free(d), free(c);
256 return (jlong)(intptr_t)library;257 return (jlong)(intptr_t)library;
257}258}
258259
apps/android/src/net/paperclover/snowbound/Editor.java+11-2
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1package net.paperclover.snowbound;1package net.paperclover.snowbound;
22
3import android.content.Context;3import android.content.Context;
4import android.os.Build;
4import android.os.Handler;5import android.os.Handler;
5import android.os.Looper;6import android.os.Looper;
6import android.util.Log;7import android.util.Log;
8import android.provider.Settings;
7import android.view.Surface;9import android.view.Surface;
8import java.io.File;10import java.io.File;
9import java.text.DateFormat;11import java.text.DateFormat;
...@@ -62,7 +64,8 @@ final class Editor {...@@ -62,7 +64,8 @@ final class Editor {
62 if (picked != null && granted) {64 if (picked != null && granted) {
63 Provider.writable = picked.writable();65 Provider.writable = picked.writable();
64 try {66 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());
66 path = firstSection(opened);69 path = firstSection(opened);
67 first = section(opened, path);70 first = section(opened, path);
68 } catch (IllegalStateException | JSONException error) {71 } catch (IllegalStateException | JSONException error) {
...@@ -139,6 +142,12 @@ final class Editor {...@@ -139,6 +142,12 @@ final class Editor {
139 return false;142 return false;
140 }143 }
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
142 /** Called on the section's thread when it has events. */151 /** Called on the section's thread when it has events. */
143 static void woken() {152 static void woken() {
144 MAIN.post(Editor::poll);153 MAIN.post(Editor::poll);
...@@ -232,6 +241,6 @@ final class Editor {...@@ -232,6 +241,6 @@ final class Editor {
232 static native void reload(long view, boolean discard);241 static native void reload(long view, boolean discard);
233 static native void freeView(long view);242 static native void freeView(long view);
234 static native boolean close(long library, long section, double seconds);243 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);
236 static native String syncStatus(long library);245 static native String syncStatus(long library);
237}246}
crates/mobile/include/snowbound.h+3-2
...@@ -39,7 +39,7 @@ typedef void (*sb_document_found)(void *context, const char *id, const char *nam...@@ -39,7 +39,7 @@ typedef void (*sb_document_found)(void *context, const char *id, const char *nam
39typedef struct sb_provider {39typedef struct sb_provider {
40 int32_t (*document)(const char *id, sb_document_found found, void *context);40 int32_t (*document)(const char *id, sb_document_found found, void *context);
41 int32_t (*children)(const char *id, sb_document_found found, void *context);41 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 descriptor42 // mode is "r", "rw", "wt" or "w"; fd is a duplicate Snowbound owns and closes, handle the descriptor
43 // the host keeps open: detachFd would end the channel checkError reads. A nonzero status43 // the host keeps open: detachFd would end the channel checkError reads. A nonzero status
44 // sets neither; a negative fd with status 0 has its handle closed.44 // sets neither; a negative fd with status 0 has its handle closed.
45 int32_t (*open)(const char *id, const char *mode, int32_t *fd, uint64_t *handle);45 int32_t (*open)(const char *id, const char *mode, int32_t *fd, uint64_t *handle);
...@@ -50,7 +50,8 @@ typedef struct sb_provider {...@@ -50,7 +50,8 @@ typedef struct sb_provider {
50void sb_set_provider(const sb_provider *provider);50void sb_set_provider(const sb_provider *provider);
5151
52const Library *sb_library_open(const char *path, const char *cache, bool local, char **error);52const 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);
54const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password,55const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password,
55 const char *domain, const char *root, const char *cache, char **error);56 const char *domain, const char *root, const char *cache, char **error);
56// Callback/token live until the blocking join returns; false cancels, approving reports host approval.57// 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 {...@@ -512,7 +512,7 @@ impl Library {
512 /// registered provider keeps (`sb_set_provider`). A provider `local` to this device512 /// registered provider keeps (`sb_set_provider`). A provider `local` to this device
513 /// publishes each edit at once; any other waits for a pause in typing. While the provider513 /// publishes each edit at once; any other waits for a pause in typing. While the provider
514 /// can't be reached, the folder opens as last listed and sections from their offline copies.514 /// 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> {
516 let host = crate::provider::Host::default();516 let host = crate::provider::Host::default();
517 let name = (!folder)517 let name = (!folder)
518 .then(|| {518 .then(|| {
...@@ -523,9 +523,9 @@ impl Library {...@@ -523,9 +523,9 @@ impl Library {
523 )523 )
524 })524 })
525 .flatten();525 .flatten();
526 let documents = Arc::new(Documents::new(host, root));526 let documents = Arc::new(Documents::new(host, root, cache, device));
527 let (notebook, place, background) = if folder {527 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))?;
529 let background = Background::provider(Arc::clone(&documents), sync_woken)?;529 let background = Background::provider(Arc::clone(&documents), sync_woken)?;
530 background.watch(notebook.replicas());530 background.watch(notebook.replicas());
531 (Some(notebook), Place::Provider(documents), Some(background))531 (Some(notebook), Place::Provider(documents), Some(background))
...@@ -824,7 +824,6 @@ impl Library {...@@ -824,7 +824,6 @@ impl Library {
824 )?),824 )?),
825 Place::Document(documents, _) => Ok(session::Section::open_provider(825 Place::Document(documents, _) => Ok(session::Section::open_provider(
826 Arc::clone(documents),826 Arc::clone(documents),
827 &self.cache,
828 offline,827 offline,
829 notify,828 notify,
830 )?),829 )?),
...@@ -2224,22 +2223,32 @@ pub unsafe extern "C" fn sb_library_flush(...@@ -2224,22 +2223,32 @@ pub unsafe extern "C" fn sb_library_flush(
22242223
2225/// The notebook folder whose document is `root`, or with `folder` false the lone section2224/// The notebook folder whose document is `root`, or with `folder` false the lone section
2226/// `root` names, as the provider `sb_set_provider` registered keeps it; `local` as2225/// `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 before2226/// `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
2228/// opens as last listed, and sections opened before open from their copies; a revoked grant2228/// opens as last listed, and sections opened before open from their copies; a revoked grant
2229/// refuses to open and keeps them.2229/// refuses to open and keeps them.
2230///2230///
2231/// # Safety2231/// # 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.
2233#[unsafe(no_mangle)]2234#[unsafe(no_mangle)]
2234pub unsafe extern "C" fn sb_library_provider(2235pub unsafe extern "C" fn sb_library_provider(
2235 root: *const c_char,2236 root: *const c_char,
2236 folder: bool,2237 folder: bool,
2237 local: bool,2238 local: bool,
2239 device: *const c_char,
2238 cache: *const c_char,2240 cache: *const c_char,
2239 error: *mut *mut c_char,2241 error: *mut *mut c_char,
2240) -> *const Library {2242) -> *const Library {
2243 let cache = string(cache);
2241 shared(2244 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 ),
2243 error,2252 error,
2244 )2253 )
2245}2254}
crates/mobile/src/provider.rs+1-1
...@@ -35,7 +35,7 @@ pub struct Table {...@@ -35,7 +35,7 @@ pub struct Table {
35 pub document: extern "C" fn(id: *const c_char, found: Found, context: *mut c_void) -> i32,35 pub document: extern "C" fn(id: *const c_char, found: Found, context: *mut c_void) -> i32,
36 /// Calls `found` for each document in the folder `id`.36 /// Calls `found` for each document in the folder `id`.
37 pub children: extern "C" fn(id: *const c_char, found: Found, context: *mut c_void) -> i32,37 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 the38 /// Opens `id` in a `ContentResolver` mode, setting `fd` to a descriptor the
39 /// caller owns and closes, and `handle` to what `close` finishes.39 /// caller owns and closes, and `handle` to what `close` finishes.
40 pub open: extern "C" fn(40 pub open: extern "C" fn(
41 id: *const c_char,41 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<...@@ -3752,9 +3752,16 @@ fn provided(root: &Path, folder: bool, cache: &Path) -> std::result::Result<Arc<
3752 CString::new(cache.to_str().unwrap()).unwrap(),3752 CString::new(cache.to_str().unwrap()).unwrap(),
3753 );3753 );
3754 let mut error = std::ptr::null_mut();3754 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.
3756 let library = unsafe {3756 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 )
3758 };3765 };
3759 if library.is_null() {3766 if library.is_null() {
3760 let message = string(error);3767 let message = string(error);
crates/notebook/README.md+20-7
...@@ -551,11 +551,11 @@ physical power-loss durability....@@ -551,11 +551,11 @@ physical power-loss durability.
551551
552`provider` opens notebooks a document provider keeps, as Android's Storage Access Framework552`provider` opens notebooks a document provider keeps, as Android's Storage Access Framework
553reaches them. The host implements `provider::Provider` (a document's description, a folder's553reaches 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's554children, `open` in a `ContentResolver` mode as an owned `File`, `close` with the provider's
555verdict, `rename` returning the new id) and reports an unreachable provider as `NotConnected`,555verdict, `rename` returning the new id) and reports an unreachable provider as `NotConnected`,
556a revoked grant as `PermissionDenied` and a gone document as `NotFound`.556a revoked grant as `PermissionDenied` and a gone document as `NotFound`.
557`provider::Documents::new(provider, root)` holds one root: a picked folder opens557`provider::Documents::new(provider, root, cache, device)` holds one root, the app's
558with `Notebook::open_provider(documents, cache)`, its sections with `Section::resume_provider`558cache and the device's name: a picked folder opens with `Notebook::open_provider(documents)`, its sections with `Section::resume_provider`
559and the rest with `Background::provider`, as a Live Share guest's do; a picked document opens559and the rest with `Background::provider`, as a Live Share guest's do; a picked document opens
560as a lone section with `Section::open_provider`. Catalog paths resolve to ids by listing their560as a lone section with `Section::open_provider`. Catalog paths resolve to ids by listing their
561folders, again once when an id has gone, as a rename leaves it; of documents sharing a name the561folders, 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....@@ -564,10 +564,23 @@ first by id lists and the rest are `unavailable`, named with their ids.
564A publication resolves the document's id once, from the last listing, and writes nothing to564A publication resolves the document's id once, from the last listing, and writes nothing to
565a document that took its name since. Without a write grant a document is only read, and565a document that took its name since. Without a write grant a document is only read, and
566publication is refused as `ReadOnlyFilesystem`; where its `r` descriptor seeks, `rw` opens it566publication 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 only567and 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 lost568streams is read whole for each stamp, and a commit reads it, checks its stamp, applies the
569publications merge back. No provider offers a lock or compare-and-swap: the check sees only569transaction and writes the whole image with `wt`, or `w` where that is refused, images only
570this device's view of the document. An image that does not parse is never a base. An open570growing. 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
571section checks its file every `Background::UNWATCHED`, the host's change reports waking it584section checks its file every `Background::UNWATCHED`, the host's change reports waking it
572between. While the provider can't be reached the notebook opens as its folders were last585between. While the provider can't be reached the notebook opens as its folders were last
573listed and sections from their replicas; a revoked grant keeps them, and the notebook does not586listed 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::{...@@ -12,6 +12,7 @@ use std::{
12 io::{self, Read, Seek, SeekFrom, Write},12 io::{self, Read, Seek, SeekFrom, Write},
13 path::PathBuf,13 path::PathBuf,
14 sync::{Arc, Mutex, MutexGuard, PoisonError},14 sync::{Arc, Mutex, MutexGuard, PoisonError},
15 time::Duration,
15};16};
1617
17/// A document as its provider describes it.18/// A document as its provider describes it.
...@@ -33,7 +34,7 @@ pub struct Document {...@@ -33,7 +34,7 @@ pub struct Document {
33pub trait Provider: Send + Sync {34pub trait Provider: Send + Sync {
34 fn document(&self, id: &str) -> io::Result<Document>;35 fn document(&self, id: &str) -> io::Result<Document>;
35 fn children(&self, id: &str) -> io::Result<Vec<Document>>;36 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`.
37 fn open(&self, id: &str, mode: &str) -> io::Result<File>;38 fn open(&self, id: &str, mode: &str) -> io::Result<File>;
38 /// Closes a file `open` gave, with the provider's verdict on what was written to it.39 /// Closes a file `open` gave, with the provider's verdict on what was written to it.
39 fn close(&self, file: File) -> io::Result<()>;40 fn close(&self, file: File) -> io::Result<()>;
...@@ -46,15 +47,26 @@ pub trait Provider: Send + Sync {...@@ -46,15 +47,26 @@ pub trait Provider: Send + Sync {
46pub struct Documents {47pub struct Documents {
47 provider: Box<dyn Provider>,48 provider: Box<dyn Provider>,
48 pub(crate) root: String,49 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,
49 /// Each path's document as last listed; a stale one is listed again.54 /// Each path's document as last listed; a stale one is listed again.
50 ids: Mutex<BTreeMap<String, String>>,55 ids: Mutex<BTreeMap<String, String>>,
51}56}
5257
53impl Documents {58impl 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 {
55 Self {65 Self {
56 provider: Box::new(provider),66 provider: Box::new(provider),
57 root: root.into(),67 root: root.into(),
68 cache: cache.into(),
69 device: device.into(),
58 ids: Mutex::default(),70 ids: Mutex::default(),
59 }71 }
60 }72 }
...@@ -63,6 +75,12 @@ impl Documents {...@@ -63,6 +75,12 @@ impl Documents {
63 format!("provider:{}", self.root)75 format!("provider:{}", self.root)
64 }76 }
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
66 fn id(&self, path: &str) -> io::Result<String> {84 fn id(&self, path: &str) -> io::Result<String> {
67 if path.is_empty() {85 if path.is_empty() {
68 return Ok(self.root.clone());86 return Ok(self.root.clone());
...@@ -150,9 +168,12 @@ impl Documents {...@@ -150,9 +168,12 @@ impl Documents {
150 self.opened(id, "r", |file| {168 self.opened(id, "r", |file| {
151 if file.stream_position().is_err() {169 if file.stream_position().is_err() {
152 let image = whole(file, crate::MAX_FILE_BYTES)?;170 let image = whole(file, crate::MAX_FILE_BYTES)?;
153 let stamp = Stamp::of(&image)171 // A torn image shorter than a header still stamps, as it stands.
154 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;172 let mut header = [0; 1024];
155 return Ok((stamp, Some(image)));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)));
156 }177 }
157 let mut header = [0; 1024];178 let mut header = [0; 1024];
158 file.read_exact(&mut header)?;179 file.read_exact(&mut header)?;
...@@ -162,42 +183,80 @@ impl Documents {...@@ -162,42 +183,80 @@ impl Documents {
162 })183 })
163 }184 }
164185
165 /// Writes the document at `path` in place through `operation`, provided its grant allows186 /// Writes the document at `path` through `operation`, provided its grant allows writing:
166 /// writing and it opens seekable. The id is the one the last listing gave, never listed187 /// in place where its `r` descriptor seeks, else on the image read whole, which then
167 /// again, so that a document taking the name meanwhile is not the one written.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.
168 fn write(191 fn write(
169 &self,192 &self,
170 path: &str,193 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<()>,
172 ) -> std::result::Result<(), CommitError> {196 ) -> 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));
178 let id = self.id(path).map_err(refused)?;197 let id = self.id(path).map_err(refused)?;
179 if !self.provider.document(&id).map_err(refused)?.writable {198 self.writable(&id).map_err(refused)?;
180 return Err(read_only(
181 "The app keeping this section lets Snowbound only read it",
182 ));
183 }
184 let seekable = self199 let seekable = self
185 .opened(&id, "r", |file| Ok(file.stream_position().is_ok()))200 .opened(&id, "r", |file| Ok(file.stream_position().is_ok()))
186 .map_err(refused)?;201 .map_err(refused)?;
187 // Streamed documents stay read-only until lost publications merge back (plan commit 2).202 let mut io = if seekable {
188 if !seekable {203 Io::File(self.provider.open(&id, "rw").map_err(refused)?)
189 return Err(read_only(204 } else {
190 "The app keeping this section can't save changes in place yet; they wait on this device",205 let image = self.opened(&id, "r", |file| whole(file, crate::MAX_FILE_BYTES));
191 ));206 Io::Image(image.map_err(refused)?)
192 }207 };
193 let mut io = Seekable(self.provider.open(&id, "rw").map_err(refused)?);
194 let result = operation(&mut io);208 let result = operation(&mut io);
195 let closed = self.provider.close(io.0);209 match io {
196 result?;210 Io::File(mut file) => {
197 closed.map_err(|error| CommitError {211 // A copy it could not keep only leaves the publication unprotected.
198 state: CommitState::Unknown,212 if result.is_ok() {
199 error,213 let _ = keep(Written::InPlace(&mut file));
200 })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)
201 }260 }
202261
203 /// Renames the document at `path` within its folder.262 /// Renames the document at `path` within its folder.
...@@ -208,6 +267,20 @@ impl Documents {...@@ -208,6 +267,20 @@ impl Documents {
208 }267 }
209}268}
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
211fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {284fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
212 mutex.lock().unwrap_or_else(PoisonError::into_inner)285 mutex.lock().unwrap_or_else(PoisonError::into_inner)
213}286}
...@@ -221,30 +294,116 @@ fn whole(file: &mut File, limit: usize) -> io::Result<Vec<u8>> {...@@ -221,30 +294,116 @@ fn whole(file: &mut File, limit: usize) -> io::Result<Vec<u8>> {
221 Ok(bytes)294 Ok(bytes)
222}295}
223296
224/// A seekable descriptor as a commit writes it.297/// What a write wrote: the document in place, or its whole image.
225struct Seekable(File);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 {
228 fn read_at(&mut self, offset: u64, bytes: &mut [u8]) -> io::Result<usize> {310 fn read_at(&mut self, offset: u64, bytes: &mut [u8]) -> io::Result<usize> {
229 self.0.seek(SeekFrom::Start(offset))?;311 match self {
230 self.0.read(bytes)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 }
231 }326 }
232327
233 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> {328 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> {
234 self.0.seek(SeekFrom::Start(offset))?;329 match self {
235 self.0.write(bytes)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 }
236 }344 }
237345
238 fn flush(&mut self) -> io::Result<()> {346 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 }
240 }351 }
241}352}
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.
244pub(crate) struct ProviderRemote {403pub(crate) struct ProviderRemote {
245 documents: Arc<Documents>,404 documents: Arc<Documents>,
246 path: String,405 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.
248 read: Option<Vec<u8>>,407 read: Option<Vec<u8>>,
249}408}
250409
...@@ -256,6 +415,82 @@ impl ProviderRemote {...@@ -256,6 +415,82 @@ impl ProviderRemote {
256 read: None,415 read: None,
257 }416 }
258 }417 }
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())
259}494}
260495
261impl crate::Remote for ProviderRemote {496impl crate::Remote for ProviderRemote {
...@@ -265,7 +500,7 @@ impl crate::Remote for ProviderRemote {...@@ -265,7 +500,7 @@ impl crate::Remote for ProviderRemote {
265 Some(image) => image,500 Some(image) => image,
266 None => self.documents.read(&self.path, crate::MAX_FILE_BYTES)?,501 None => self.documents.read(&self.path, crate::MAX_FILE_BYTES)?,
267 };502 };
268 if !Store::parse(&image).is_ok_and(|store| store.checksum_mismatches.is_empty()) {503 if !parses(&image) {
269 return Err(io::Error::new(504 return Err(io::Error::new(
270 io::ErrorKind::WouldBlock,505 io::ErrorKind::WouldBlock,
271 "The section was read mid-write",506 "The section was read mid-write",
...@@ -275,6 +510,10 @@ impl crate::Remote for ProviderRemote {...@@ -275,6 +510,10 @@ impl crate::Remote for ProviderRemote {
275 }510 }
276511
277 fn stamp(&mut self) -> io::Result<Stamp> {512 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 }
278 let (stamp, image) = self.documents.stamp(&self.path)?;517 let (stamp, image) = self.documents.stamp(&self.path)?;
279 self.read = image;518 self.read = image;
280 Ok(stamp)519 Ok(stamp)
...@@ -282,14 +521,63 @@ impl crate::Remote for ProviderRemote {...@@ -282,14 +521,63 @@ impl crate::Remote for ProviderRemote {
282521
283 fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> {522 fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> {
284 self.read = None;523 self.read = None;
285 self.documents524 self.documents.write(
286 .write(&self.path, |io| transaction.commit(io))525 &self.path,
526 |io| transaction.commit(io),
527 |written| self.keep(written, transaction),
528 )
287 }529 }
288530
289 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> {531 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> {
290 self.read = None;532 self.read = None;
291 self.documents533 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 }
293 }581 }
294}582}
295583
...@@ -439,20 +727,24 @@ impl Storage for Tree {...@@ -439,20 +727,24 @@ impl Storage for Tree {
439 }727 }
440728
441 fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()> {729 fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()> {
442 Ok(self.documents.write(path, |io| {730 let place = |io: &mut Io| {
443 onestore::place(io, ancestor, name).map_err(|error| CommitError {731 onestore::place(io, ancestor, name).map_err(|error| CommitError {
444 state: CommitState::Unknown,732 state: CommitState::Unknown,
445 error,733 error,
446 })734 })
447 })?)735 };
736 Ok(self.documents.write(path, place, |_| Ok(()))?)
448 }737 }
449738
450 fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> {739 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(()))?)
452 }743 }
453744
454 fn confirm(&self, path: &str, base: &Stamp) -> std::result::Result<(), CommitError> {745 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(()))
456 }748 }
457749
458 fn supersede(&self, _: &str, _: &Stamp, _: &str) -> Result<()> {750 fn supersede(&self, _: &str, _: &Stamp, _: &str) -> Result<()> {
crates/notebook/src/provider/tests.rs+403-81
...@@ -12,9 +12,10 @@ use onestore::{...@@ -12,9 +12,10 @@ use onestore::{
12 page::{Page, PageObject},12 page::{Page, PageObject},
13};13};
14use std::{14use std::{
15 collections::HashMap,
15 fs,16 fs,
16 ops::Range,17 ops::Range,
17 os::fd::OwnedFd,18 os::fd::{AsRawFd, OwnedFd},
18 path::Path,19 path::Path,
19 thread,20 thread,
20 time::{Duration, Instant},21 time::{Duration, Instant},
...@@ -25,8 +26,10 @@ const TOC: &str = "Open Notebook.onetoc2";...@@ -25,8 +26,10 @@ const TOC: &str = "Open Notebook.onetoc2";
25#[derive(Clone, Copy, PartialEq)]26#[derive(Clone, Copy, PartialEq)]
26enum Kind {27enum Kind {
27 Seekable,28 Seekable,
28 /// Pipes in every mode, `rw` included, with no metadata.29 /// Pipes in every mode, `rw` included, with no metadata; `truncate` where `wt` is taken.
29 Stream,30 Stream {
31 truncate: bool,
32 },
30}33}
3134
32struct Node {35struct Node {
...@@ -50,9 +53,19 @@ struct State {...@@ -50,9 +53,19 @@ struct State {
50 /// The next `rw` open finds its document renamed to `Moved.one` under a new id, and a53 /// The next `rw` open finds its document renamed to `Moved.one` under a new id, and a
51 /// document holding these bytes in its place.54 /// document holding these bytes in its place.
52 swap: Option<Vec<u8>>,55 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,
53 modes: Vec<String>,61 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)>,
54}65}
5566
67type Upload = thread::JoinHandle<(Vec<u8>, Option<(u64, bool)>)>;
68
56struct Shared {69struct Shared {
57 folder: tempfile::TempDir,70 folder: tempfile::TempDir,
58 state: Mutex<State>,71 state: Mutex<State>,
...@@ -86,7 +99,10 @@ impl Fake {...@@ -86,7 +99,10 @@ impl Fake {
86 revoked: false,99 revoked: false,
87 delayed: false,100 delayed: false,
88 swap: None,101 swap: None,
102 cut: None,
103 full: false,
89 modes: Vec::new(),104 modes: Vec::new(),
105 uploads: HashMap::new(),
90 };106 };
91 Self {107 Self {
92 store: Arc::new(Shared {108 store: Arc::new(Shared {
...@@ -105,7 +121,13 @@ impl Fake {...@@ -105,7 +121,13 @@ impl Fake {
105 }121 }
106122
107 fn documents(&self, root: &str) -> Arc<Documents> {123 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))
109 }131 }
110132
111 fn state(&self) -> MutexGuard<'_, State> {133 fn state(&self) -> MutexGuard<'_, State> {
...@@ -300,18 +322,59 @@ impl Provider for Fake {...@@ -300,18 +322,59 @@ impl Provider for Fake {
300 .truncate(mode == "wt")322 .truncate(mode == "wt")
301 .open(file)323 .open(file)
302 }324 }
303 (Kind::Stream, "r") => {325 (Kind::Stream { .. }, "r") => {
304 let (reader, mut writer) = io::pipe()?;326 let (reader, mut writer) = io::pipe()?;
305 let bytes = fs::read(self.file(id))?;327 let bytes = fs::read(self.file(id))?;
306 thread::spawn(move || writer.write_all(&bytes));328 thread::spawn(move || writer.write_all(&bytes));
307 Ok(File::from(OwnedFd::from(reader)))329 Ok(File::from(OwnedFd::from(reader)))
308 }330 }
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 }
310 }352 }
311 }353 }
312354
313 fn close(&self, _: File) -> io::Result<()> {355 fn close(&self, file: File) -> io::Result<()> {
314 Ok(())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)
315 }378 }
316379
317 fn rename(&self, id: &str, name: &str) -> io::Result<String> {380 fn rename(&self, id: &str, name: &str) -> io::Result<String> {
...@@ -326,10 +389,33 @@ impl Provider for Fake {...@@ -326,10 +389,33 @@ impl Provider for Fake {
326 }389 }
327}390}
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
329/// A notebook folder: its TOC and two sections it lists, placed under it.415/// A notebook folder: its TOC and two sections it lists, placed under it.
330fn notebook(fake: &Fake) {416fn notebook(fake: &Fake) {
331 let mut notes = onestore::create_section("Notes.one", "Original text", "Author").unwrap();417 let mut notes = section_image("Notes.one", "Original text");
332 let mut ideas = onestore::create_section("Ideas.one", "First idea", "Author").unwrap();418 let mut ideas = section_image("Ideas.one", "First idea");
333 let file = |image: &[u8]| Store::parse(image).unwrap().header.file_id;419 let file = |image: &[u8]| Store::parse(image).unwrap().header.file_id;
334 let toc = onestore::create_table_of_contents(420 let toc = onestore::create_table_of_contents(
335 TOC,421 TOC,
...@@ -432,9 +518,8 @@ fn status(section: &session::Section, accept: impl Fn(&SyncStatus) -> bool) -> S...@@ -432,9 +518,8 @@ fn status(section: &session::Section, accept: impl Fn(&SyncStatus) -> bool) -> S
432fn seekable_documents_take_commits_in_place_and_rebase_on_another_devices() {518fn seekable_documents_take_commits_in_place_and_rebase_on_another_devices() {
433 let fake = Fake::new(Kind::Seekable);519 let fake = Fake::new(Kind::Seekable);
434 notebook(&fake);520 notebook(&fake);
435 let cache = tempfile::tempdir().unwrap();
436 let documents = fake.documents("root");521 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();
438 assert_eq!(notebook.catalog().order, ["Notes.one", "Ideas.one"]);523 assert_eq!(notebook.catalog().order, ["Notes.one", "Ideas.one"]);
439 let section = open(&notebook, &documents, "Notes.one");524 let section = open(&notebook, &documents, "Notes.one");
440 let before = fake.bytes("Notes.one").len();525 let before = fake.bytes("Notes.one").len();
...@@ -446,8 +531,7 @@ fn seekable_documents_take_commits_in_place_and_rebase_on_another_devices() {...@@ -446,8 +531,7 @@ fn seekable_documents_take_commits_in_place_and_rebase_on_another_devices() {
446 assert!(fake.modes().iter().all(|mode| mode == "r" || mode == "rw"));531 assert!(fake.modes().iter().all(|mode| mode == "r" || mode == "rw"));
447532
448 let elsewhere = fake.device(1).documents("root");533 let elsewhere = fake.device(1).documents("root");
449 let other_cache = tempfile::tempdir().unwrap();534 let other = Notebook::open_provider(Arc::clone(&elsewhere)).unwrap();
450 let other = Notebook::open_provider(Arc::clone(&elsewhere), other_cache.path()).unwrap();
451 let other = open(&other, &elsewhere, "Notes.one");535 let other = open(&other, &elsewhere, "Notes.one");
452 let id = typed(&other, 0..0, "World ");536 let id = typed(&other, 0..0, "World ");
453 published(&other, id);537 published(&other, id);
...@@ -467,44 +551,24 @@ fn seekable_documents_take_commits_in_place_and_rebase_on_another_devices() {...@@ -467,44 +551,24 @@ fn seekable_documents_take_commits_in_place_and_rebase_on_another_devices() {
467}551}
468552
469#[test]553#[test]
470fn stream_only_documents_are_read_and_never_opened_to_write() {554fn stream_only_documents_take_whole_images_and_never_open_to_write_in_place() {
471 let fake = Fake::new(Kind::Stream);555 for truncate in [true, false] {
472 notebook(&fake);556 let fake = Fake::new(Kind::Stream { truncate });
473 let cache = tempfile::tempdir().unwrap();557 notebook(&fake);
474 let documents = fake.documents("root");558 let documents = fake.documents("root");
475 let notebook = Notebook::open_provider(Arc::clone(&documents), cache.path()).unwrap();559 let notebook = Notebook::open_provider(Arc::clone(&documents)).unwrap();
476 let section = open(&notebook, &documents, "Notes.one");560 let section = open(&notebook, &documents, "Ideas.one");
477 let id = typed(&section, 13..13, " mine");561 let id = typed(&section, 0..0, "Second. ");
478 let waiting = status(&section, |status| status.error.is_some());562 published(&section, id);
479 assert_eq!(waiting.state(), SyncState::ReadOnly);563 let id = typed(&section, 0..0, "Third. ");
480 assert_eq!(waiting.queued, 1);564 published(&section, id);
481 assert!(fake.modes().iter().all(|mode| mode == "r"));565 assert_eq!(stored(fake.bytes("Ideas.one")), "Third. Second. First idea");
482566 let modes = fake.modes();
483 // Another device, which can write in place, changes the document.567 let count = |wanted: &str| modes.iter().filter(|mode| *mode == wanted).count();
484 let elsewhere = Fake::new(Kind::Seekable);568 assert_eq!(count("rw"), 0, "{modes:?}");
485 for name in [TOC, "Notes.one", "Ideas.one"] {569 assert_eq!((count("wt"), count("w")), (2, if truncate { 0 } else { 2 }));
486 elsewhere.add(name, &fake.bytes(name));570 section.close().unwrap();
487 }571 }
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();
508}572}
509573
510#[test]574#[test]
...@@ -512,9 +576,8 @@ fn documents_without_a_write_grant_are_only_read() {...@@ -512,9 +576,8 @@ fn documents_without_a_write_grant_are_only_read() {
512 let fake = Fake::new(Kind::Seekable);576 let fake = Fake::new(Kind::Seekable);
513 notebook(&fake);577 notebook(&fake);
514 fake.set(|state| state.writable = false);578 fake.set(|state| state.writable = false);
515 let cache = tempfile::tempdir().unwrap();
516 let documents = fake.documents("root");579 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();
518 let section = open(&notebook, &documents, "Notes.one");581 let section = open(&notebook, &documents, "Notes.one");
519 let before = fake.bytes("Notes.one");582 let before = fake.bytes("Notes.one");
520 typed(&section, 0..0, "Mine ");583 typed(&section, 0..0, "Mine ");
...@@ -536,9 +599,8 @@ fn a_write_another_device_sees_late_rebases_it_once_visible() {...@@ -536,9 +599,8 @@ fn a_write_another_device_sees_late_rebases_it_once_visible() {
536 notebook(&fake);599 notebook(&fake);
537 fake.set(|state| state.delayed = true);600 fake.set(|state| state.delayed = true);
538 let (writer, reader) = (fake.documents("root"), fake.device(1).documents("root"));601 let (writer, reader) = (fake.documents("root"), fake.device(1).documents("root"));
539 let caches = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());602 let writing = Notebook::open_provider(Arc::clone(&writer)).unwrap();
540 let writing = Notebook::open_provider(Arc::clone(&writer), caches.0.path()).unwrap();603 let reading = Notebook::open_provider(Arc::clone(&reader)).unwrap();
541 let reading = Notebook::open_provider(Arc::clone(&reader), caches.1.path()).unwrap();
542 let (writing, reading) = (604 let (writing, reading) = (
543 open(&writing, &writer, "Notes.one"),605 open(&writing, &writer, "Notes.one"),
544 open(&reading, &reader, "Notes.one"),606 open(&reading, &reader, "Notes.one"),
...@@ -567,9 +629,8 @@ fn a_write_another_device_sees_late_rebases_it_once_visible() {...@@ -567,9 +629,8 @@ fn a_write_another_device_sees_late_rebases_it_once_visible() {
567fn a_cold_start_while_offline_opens_the_last_listing_and_queued_edits() {629fn a_cold_start_while_offline_opens_the_last_listing_and_queued_edits() {
568 let fake = Fake::new(Kind::Seekable);630 let fake = Fake::new(Kind::Seekable);
569 notebook(&fake);631 notebook(&fake);
570 let cache = tempfile::tempdir().unwrap();
571 let documents = fake.documents("root");632 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();
573 let background = Background::provider(Arc::clone(&documents), || {}).unwrap();634 let background = Background::provider(Arc::clone(&documents), || {}).unwrap();
574 background.watch(notebook.replicas());635 background.watch(notebook.replicas());
575 until("every section has its offline copy", || {636 until("every section has its offline copy", || {
...@@ -589,7 +650,7 @@ fn a_cold_start_while_offline_opens_the_last_listing_and_queued_edits() {...@@ -589,7 +650,7 @@ fn a_cold_start_while_offline_opens_the_last_listing_and_queued_edits() {
589650
590 // A new process: the provider fails throughout.651 // A new process: the provider fails throughout.
591 let documents = fake.documents("root");652 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();
593 assert_eq!(notebook.catalog().order, ["Notes.one", "Ideas.one"]);654 assert_eq!(notebook.catalog().order, ["Notes.one", "Ideas.one"]);
594 let notes = open(&notebook, &documents, "Notes.one");655 let notes = open(&notebook, &documents, "Notes.one");
595 assert_eq!(text(&notes), "Original text");656 assert_eq!(text(&notes), "Original text");
...@@ -608,9 +669,8 @@ fn a_cold_start_while_offline_opens_the_last_listing_and_queued_edits() {...@@ -608,9 +669,8 @@ fn a_cold_start_while_offline_opens_the_last_listing_and_queued_edits() {
608fn a_revoked_grant_is_not_offline_and_keeps_the_replicas() {669fn a_revoked_grant_is_not_offline_and_keeps_the_replicas() {
609 let fake = Fake::new(Kind::Seekable);670 let fake = Fake::new(Kind::Seekable);
610 notebook(&fake);671 notebook(&fake);
611 let cache = tempfile::tempdir().unwrap();
612 let documents = fake.documents("root");672 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();
614 let replica = notebook.replica_path("Notes.one").unwrap();674 let replica = notebook.replica_path("Notes.one").unwrap();
615 let section = open(&notebook, &documents, "Notes.one");675 let section = open(&notebook, &documents, "Notes.one");
616 fake.set(|state| state.revoked = true);676 fake.set(|state| state.revoked = true);
...@@ -622,7 +682,7 @@ fn a_revoked_grant_is_not_offline_and_keeps_the_replicas() {...@@ -622,7 +682,7 @@ fn a_revoked_grant_is_not_offline_and_keeps_the_replicas() {
622 );682 );
623 section.close().unwrap();683 section.close().unwrap();
624 drop(notebook);684 drop(notebook);
625 let refused = Notebook::open_provider(fake.documents("root"), cache.path()).err();685 let refused = Notebook::open_provider(fake.documents("root")).err();
626 assert!(686 assert!(
627 matches!(687 matches!(
628 &refused,688 &refused,
...@@ -638,7 +698,7 @@ fn a_revoked_grant_is_not_offline_and_keeps_the_replicas() {...@@ -638,7 +698,7 @@ fn a_revoked_grant_is_not_offline_and_keeps_the_replicas() {
638698
639 fake.set(|state| state.revoked = false);699 fake.set(|state| state.revoked = false);
640 let documents = fake.documents("root");700 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();
642 let section = open(&notebook, &documents, "Notes.one");702 let section = open(&notebook, &documents, "Notes.one");
643 published(&section, id);703 published(&section, id);
644 assert_eq!(stored(fake.bytes("Notes.one")), "Kept Original text");704 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() {...@@ -649,9 +709,8 @@ fn a_revoked_grant_is_not_offline_and_keeps_the_replicas() {
649fn documents_renamed_to_new_ids_are_listed_again() {709fn documents_renamed_to_new_ids_are_listed_again() {
650 let fake = Fake::new(Kind::Seekable);710 let fake = Fake::new(Kind::Seekable);
651 notebook(&fake);711 notebook(&fake);
652 let cache = tempfile::tempdir().unwrap();
653 let documents = fake.documents("root");712 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();
655 let section = open(&notebook, &documents, "Notes.one");714 let section = open(&notebook, &documents, "Notes.one");
656 let id = typed(&section, 0..0, "One ");715 let id = typed(&section, 0..0, "One ");
657 published(&section, id);716 published(&section, id);
...@@ -691,9 +750,8 @@ fn of_documents_sharing_a_name_one_lists_and_the_rest_are_unavailable() {...@@ -691,9 +750,8 @@ fn of_documents_sharing_a_name_one_lists_and_the_rest_are_unavailable() {
691 "Notes.one",750 "Notes.one",
692 &onestore::create_section("Notes.one", "A twin", "Author").unwrap(),751 &onestore::create_section("Notes.one", "A twin", "Author").unwrap(),
693 );752 );
694 let cache = tempfile::tempdir().unwrap();
695 let documents = fake.documents("root");753 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();
697 assert_eq!(notebook.catalog().order, ["Notes.one", "Ideas.one"]);755 assert_eq!(notebook.catalog().order, ["Notes.one", "Ideas.one"]);
698 let unavailable = &notebook.catalog().unavailable;756 let unavailable = &notebook.catalog().unavailable;
699 assert_eq!(unavailable.len(), 1);757 assert_eq!(unavailable.len(), 1);
...@@ -707,9 +765,8 @@ fn of_documents_sharing_a_name_one_lists_and_the_rest_are_unavailable() {...@@ -707,9 +765,8 @@ fn of_documents_sharing_a_name_one_lists_and_the_rest_are_unavailable() {
707fn a_document_taking_the_name_between_check_and_write_is_not_written() {765fn a_document_taking_the_name_between_check_and_write_is_not_written() {
708 let fake = Fake::new(Kind::Seekable);766 let fake = Fake::new(Kind::Seekable);
709 notebook(&fake);767 notebook(&fake);
710 let cache = tempfile::tempdir().unwrap();
711 let documents = fake.documents("root");768 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();
713 let section = open(&notebook, &documents, "Notes.one");770 let section = open(&notebook, &documents, "Notes.one");
714 let id = typed(&section, 0..0, "Checked ");771 let id = typed(&section, 0..0, "Checked ");
715 published(&section, id);772 published(&section, id);
...@@ -761,9 +818,8 @@ fn conflicting_edits_leave_a_conflict_page_in_the_document() {...@@ -761,9 +818,8 @@ fn conflicting_edits_leave_a_conflict_page_in_the_document() {
761 let fake = Fake::new(Kind::Seekable);818 let fake = Fake::new(Kind::Seekable);
762 notebook(&fake);819 notebook(&fake);
763 let (mine, theirs) = (fake.documents("root"), fake.device(1).documents("root"));820 let (mine, theirs) = (fake.documents("root"), fake.device(1).documents("root"));
764 let caches = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());821 let local = Notebook::open_provider(Arc::clone(&mine)).unwrap();
765 let local = Notebook::open_provider(Arc::clone(&mine), caches.0.path()).unwrap();822 let remote = Notebook::open_provider(Arc::clone(&theirs)).unwrap();
766 let remote = Notebook::open_provider(Arc::clone(&theirs), caches.1.path()).unwrap();
767 let (local, remote) = (823 let (local, remote) = (
768 open(&local, &mine, "Notes.one"),824 open(&local, &mine, "Notes.one"),
769 open(&remote, &theirs, "Notes.one"),825 open(&remote, &theirs, "Notes.one"),
...@@ -793,10 +849,8 @@ fn a_lone_document_opens_offline_from_its_replica() {...@@ -793,10 +849,8 @@ fn a_lone_document_opens_offline_from_its_replica() {
793 let fake = Fake::new(Kind::Seekable);849 let fake = Fake::new(Kind::Seekable);
794 let image = onestore::create_section("Lone.one", "Alone", "Author").unwrap();850 let image = onestore::create_section("Lone.one", "Alone", "Author").unwrap();
795 let lone = fake.add("Lone.one", &image);851 let lone = fake.add("Lone.one", &image);
796 let cache = tempfile::tempdir().unwrap();852 let open =
797 let open = |fake: &Fake| {853 |fake: &Fake| session::Section::open_provider(fake.documents(&lone), false, || {}).unwrap();
798 session::Section::open_provider(fake.documents(&lone), cache.path(), false, || {}).unwrap()
799 };
800 let section = open(&fake);854 let section = open(&fake);
801 let id = typed(&section, 0..0, "Not ");855 let id = typed(&section, 0..0, "Not ");
802 published(&section, id);856 published(&section, id);
...@@ -817,15 +871,13 @@ fn a_lone_document_opens_offline_from_its_replica() {...@@ -817,15 +871,13 @@ fn a_lone_document_opens_offline_from_its_replica() {
817fn a_provider_that_lists_but_cannot_open_opens_sections_from_their_replicas() {871fn a_provider_that_lists_but_cannot_open_opens_sections_from_their_replicas() {
818 let fake = Fake::new(Kind::Seekable);872 let fake = Fake::new(Kind::Seekable);
819 notebook(&fake);873 notebook(&fake);
820 let cache = tempfile::tempdir().unwrap();
821 let documents = fake.documents("root");874 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();
823 open(&notebook, &documents, "Notes.one").close().unwrap();876 open(&notebook, &documents, "Notes.one").close().unwrap();
824 drop(notebook);877 drop(notebook);
825 // Another device changes the section, so it lists otherwise than when last read.878 // Another device changes the section, so it lists otherwise than when last read.
826 let elsewhere = fake.device(1).documents("root");879 let elsewhere = fake.device(1).documents("root");
827 let other_cache = tempfile::tempdir().unwrap();880 let other = Notebook::open_provider(Arc::clone(&elsewhere)).unwrap();
828 let other = Notebook::open_provider(Arc::clone(&elsewhere), other_cache.path()).unwrap();
829 let other = open(&other, &elsewhere, "Notes.one");881 let other = open(&other, &elsewhere, "Notes.one");
830 let id = typed(&other, 0..0, "Theirs ");882 let id = typed(&other, 0..0, "Theirs ");
831 published(&other, id);883 published(&other, id);
...@@ -833,7 +885,7 @@ fn a_provider_that_lists_but_cannot_open_opens_sections_from_their_replicas() {...@@ -833,7 +885,7 @@ fn a_provider_that_lists_but_cannot_open_opens_sections_from_their_replicas() {
833 fake.set(|state| state.unopenable = true);885 fake.set(|state| state.unopenable = true);
834886
835 let documents = fake.documents("root");887 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();
837 assert_eq!(notebook.catalog().order, ["Notes.one", "Ideas.one"]);889 assert_eq!(notebook.catalog().order, ["Notes.one", "Ideas.one"]);
838 let section = open(&notebook, &documents, "Notes.one");890 let section = open(&notebook, &documents, "Notes.one");
839 assert_eq!(text(&section), "Original text");891 assert_eq!(text(&section), "Original text");
...@@ -846,3 +898,273 @@ fn a_provider_that_lists_but_cannot_open_opens_sections_from_their_replicas() {...@@ -846,3 +898,273 @@ fn a_provider_that_lists_but_cannot_open_opens_sections_from_their_replicas() {
846 assert_eq!(stored(fake.bytes("Notes.one")), "Theirs Original text mine");898 assert_eq!(stored(fake.bytes("Notes.one")), "Theirs Original text mine");
847 section.close().unwrap();899 section.close().unwrap();
848}900}
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 {...@@ -517,11 +517,9 @@ impl Notebook {
517 /// Opens the notebook whose folder is the root of `documents`. Sections open through517 /// Opens the notebook whose folder is the root of `documents`. Sections open through
518 /// `Section::resume_provider` with the catalog's paths; while the provider can't be518 /// `Section::resume_provider` with the catalog's paths; while the provider can't be
519 /// reached, the notebook opens as its folders were last listed.519 /// reached, the notebook opens as its folders were last listed.
520 pub fn open_provider(520 pub fn open_provider(documents: Arc<crate::provider::Documents>) -> Result<Self> {
521 documents: Arc<crate::provider::Documents>,521 let cache = documents.cache.clone();
522 cache: impl AsRef<Path>,522 let listed = listing(&cache, &documents.location()).with_extension("entries.json");
523 ) -> Result<Self> {
524 let listed = listing(cache.as_ref(), &documents.location()).with_extension("entries.json");
525 Self::with(523 Self::with(
526 Box::new(crate::provider::Tree { documents, listed }),524 Box::new(crate::provider::Tree { documents, listed }),
527 None,525 None,
...@@ -2418,11 +2416,11 @@ impl Section {...@@ -2418,11 +2416,11 @@ impl Section {
2418 /// its location has.2416 /// its location has.
2419 pub fn open_provider(2417 pub fn open_provider(
2420 documents: Arc<crate::provider::Documents>,2418 documents: Arc<crate::provider::Documents>,
2421 cache: impl AsRef<Path>,
2422 offline: bool,2419 offline: bool,
2423 notify: impl Fn() + Send + Sync + 'static,2420 notify: impl Fn() + Send + Sync + 'static,
2424 ) -> Result<Self> {2421 ) -> 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();
2426 let file = PathBuf::from(&documents.root);2424 let file = PathBuf::from(&documents.root);
2427 let remote = move || Ok(crate::provider::ProviderRemote::new(&documents, ""));2425 let remote = move || Ok(crate::provider::ProviderRemote::new(&documents, ""));
2428 let notify = Arc::new(notify);2426 let notify = Arc::new(notify);
crates/notebook/src/sync.rs+1-1
...@@ -259,7 +259,7 @@ impl Replica {...@@ -259,7 +259,7 @@ impl Replica {
259 awaited(remote, |remote| remote.publish(&transaction)).await?;259 awaited(remote, |remote| remote.publish(&transaction)).await?;
260 false260 false
261 }261 }
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.
263 Ok(Merged::Foreign) | Err(Error::Document(_) | Error::Rejected(_)) => true,263 Ok(Merged::Foreign) | Err(Error::Document(_) | Error::Rejected(_)) => true,
264 Err(error) => return Err(error),264 Err(error) => return Err(error),
265 };265 };