| author | |
| committer | |
| log | c830e2971fbbcfeb96cfb5286d43d88e0edff1d5 |
| tree | 1629ab84ffd4ebf8e5e7b41359a66a83cb8f3d52 |
| parent | 0c15feff0434402e533f3d088940143f3bb4cfa5 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
A move reads the page together with its conflict pages and versions, which no copy carries,
and retires it only while all three are as read, so a conflict page kept or a version made
during the move is no longer deleted with the page. The API is named for moves:
Replica::moving reads the page, and Replica::retire deletes it, refusing with "The page
changed while it moved." otherwise.
Once the copy has landed, any failure to retire the original, whether a change or a cache
that cannot write, leaves the page in both sections. The desktop then shows "Page kept in
both sections" with the reason and records no step that cannot be taken back, and an Undo
of a move that ends this way shows the copy it brought back. iOS reports the same through
the copied field of sb_library_move_page and its own alert. A desktop move no longer fails
after its copy when the target section still has edits waiting for the cache: the section
is kept open, retrying them, rather than closed.
Refs #138
Assisted-by: claude-opus-5.513 files changed, 395 insertions(+), 126 deletions(-)
Cargo.lock+1| ... | ... | @@ -3649,6 +3649,7 @@ dependencies = [ |
| 3649 | 3649 | "png", |
| 3650 | 3650 | "pollster", |
| 3651 | 3651 | "ring", |
| 3652 | "rusqlite", | |
| 3652 | 3653 | "rustc-demangle", |
| 3653 | 3654 | "rustls-native-certs", |
| 3654 | 3655 | "sctk-adwaita", |
apps/ios/Snowbound/Arranging.swift+6-1| ... | ... | @@ -12,6 +12,8 @@ private struct MovedPage: Decodable { |
| 12 | 12 | let added: String? |
| 13 | 13 | let before: String? |
| 14 | 14 | let level: Int |
| 15 | /// Why the page also stayed where it was: the move only copied it. | |
| 16 | let copied: String? | |
| 15 | 17 | } |
| 16 | 18 | |
| 17 | 19 | /// The catalog path of the folder holding the section or group at `path`; "" for the |
| ... | ... | @@ -155,6 +157,7 @@ enum Arranging { |
| 155 | 157 | section.reloadRows() |
| 156 | 158 | } |
| 157 | 159 | if moved == nil { failed("Can’t Move Page") } |
| 160 | if let copied = moved?.copied { failed("Page Kept in Both Sections", copied) } | |
| 158 | 161 | } |
| 159 | 162 | } |
| 160 | 163 | |
| ... | ... | @@ -244,7 +247,9 @@ enum Arranging { |
| 244 | 247 | release() |
| 245 | 248 | source.reloadRows() |
| 246 | 249 | target.reloadRows() |
| 247 | if !succeeded { | |
| 250 | if let copied = reported.lazy.compactMap(\.copied).first { | |
| 251 | failed("Page Kept in Both Sections", copied) | |
| 252 | } else if !succeeded { | |
| 248 | 253 | failed("Can’t Move All Pages", reported.isEmpty |
| 249 | 254 | ? "Check both sections, then try moving the remaining pages again." |
| 250 | 255 | : "Check both sections. Use \(undoing ? "Redo" : "Undo") to reverse completed moves, or try moving the remaining pages again.") |
crates/mobile/src/library.rs+16-10| ... | ... | @@ -1387,9 +1387,9 @@ impl Library { |
| 1387 | 1387 | let at = (listed.iter().position(|(listed, ..)| *listed == space)) |
| 1388 | 1388 | .ok_or("The page is not listed")?; |
| 1389 | 1389 | let next = listed.get(at + 1).map(|(next, ..)| *next); |
| 1390 | Ok((section.page(space)?, (next, listed[at].2))) | |
| 1390 | Ok((section.replica().moving(space)?, (next, listed[at].2))) | |
| 1391 | 1391 | })?; |
| 1392 | let import = session::moved(&page, author)?; | |
| 1392 | let import = session::moved(page.page(), author)?; | |
| 1393 | 1393 | let Op::Section(SectionOp::Import { creation, .. }) = &import else { |
| 1394 | 1394 | unreachable!("notebook::session::moved imports"); |
| 1395 | 1395 | }; |
| ... | ... | @@ -1415,7 +1415,7 @@ impl Library { |
| 1415 | 1415 | }; |
| 1416 | 1416 | Ok(section.replica().apply(author, edit)?) |
| 1417 | 1417 | })?; |
| 1418 | let added = self.with_section(from, |section| { | |
| 1418 | let retired = self.with_section(from, |section| { | |
| 1419 | 1419 | let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))]; |
| 1420 | 1420 | let added = match section.pages()?.as_slice() { |
| 1421 | 1421 | [(only, ..)] if *only == space => { |
| ... | ... | @@ -1430,17 +1430,20 @@ impl Library { |
| 1430 | 1430 | at: filetime(), |
| 1431 | 1431 | ops, |
| 1432 | 1432 | }; |
| 1433 | section | |
| 1434 | .replica() | |
| 1435 | .apply_unchanged(author, edit, space, page)?; | |
| 1433 | section.replica().retire(author, edit, page)?; | |
| 1436 | 1434 | Ok(added) |
| 1437 | })?; | |
| 1435 | }); | |
| 1438 | 1436 | let id = |space: Option<ExGuid>| space.map(|space| space.to_string()); |
| 1437 | let (added, copied) = match retired { | |
| 1438 | Ok(added) => (id(added), None), | |
| 1439 | Err(error) => (None, Some(error.to_string())), | |
| 1440 | }; | |
| 1439 | 1441 | Ok(MovedPage { |
| 1440 | 1442 | page: arrived.to_string(), |
| 1441 | added: id(added), | |
| 1443 | added, | |
| 1442 | 1444 | before: id(was.0), |
| 1443 | 1445 | level: was.1, |
| 1446 | copied, | |
| 1444 | 1447 | }) |
| 1445 | 1448 | } |
| 1446 | 1449 | |
| ... | ... | @@ -1481,6 +1484,8 @@ pub(crate) struct MovedPage { |
| 1481 | 1484 | pub(crate) added: Option<String>, |
| 1482 | 1485 | pub(crate) before: Option<String>, |
| 1483 | 1486 | pub(crate) level: u32, |
| 1487 | /// Why the page also stayed where it was: the move only copied it. | |
| 1488 | pub(crate) copied: Option<String>, | |
| 1484 | 1489 | } |
| 1485 | 1490 | |
| 1486 | 1491 | #[derive(serde::Serialize)] |
| ... | ... | @@ -1937,8 +1942,9 @@ pub unsafe extern "C" fn sb_library_new_section( |
| 1937 | 1942 | /// before page `before` at `level`, or last with `before` null; `discard`, unless null, is |
| 1938 | 1943 | /// the page `to` gained when this page left it. Returns JSON with `page`, its id there, |
| 1939 | 1944 | /// `added`, the page titled `date` and `time` that `from` gained, or null, and `before` and |
| 1940 | /// `level`, where it was in `from`; null when it cannot move. Writes to both sections, so | |
| 1941 | /// call it off the main thread. | |
| 1945 | /// `level`, where it was in `from`, and `copied`, why it also stayed in `from` where the move | |
| 1946 | /// only copied it, or null; null when it cannot move. Writes to both sections, so call it | |
| 1947 | /// off the main thread. | |
| 1942 | 1948 | /// |
| 1943 | 1949 | /// # Safety |
| 1944 | 1950 | /// Every string is NUL-terminated UTF-8; `before` and `discard` may be null. |
crates/mobile/src/tests.rs+58-3| ... | ... | @@ -1813,7 +1813,7 @@ fn a_page_edited_while_it_moves_stays_with_its_edit() { |
| 1813 | 1813 | "Clover Test", |
| 1814 | 1814 | date, |
| 1815 | 1815 | ) |
| 1816 | .map(|_| ()) | |
| 1816 | .map(|moved| moved.copied) | |
| 1817 | 1817 | .map_err(|error| error.to_string()) |
| 1818 | 1818 | }); |
| 1819 | 1819 | holding.recv_timeout(Duration::from_secs(10)).unwrap(); |
| ... | ... | @@ -1830,8 +1830,8 @@ fn a_page_edited_while_it_moves_stays_with_its_edit() { |
| 1830 | 1830 | } |
| 1831 | 1831 | features.shared.section.written().unwrap(); |
| 1832 | 1832 | drop(release); |
| 1833 | let moved = move_page.join().unwrap(); | |
| 1834 | assert_eq!(moved.is_ok(), !edited, "{moved:?}"); | |
| 1833 | let copied = move_page.join().unwrap().unwrap(); | |
| 1834 | assert_eq!(copied.is_some(), edited, "{copied:?}"); | |
| 1835 | 1835 | drop(features); |
| 1836 | 1836 | library.close(Duration::from_secs(20)).unwrap(); |
| 1837 | 1837 | let arena = onestore::Arena::default(); |
| ... | ... | @@ -1853,6 +1853,58 @@ fn a_page_edited_while_it_moves_stays_with_its_edit() { |
| 1853 | 1853 | } |
| 1854 | 1854 | } |
| 1855 | 1855 | |
| 1856 | /// A page whose section cannot write as it leaves stays there beside the copy that arrived, | |
| 1857 | /// and the move says so; both files hold it once the notebook closes. | |
| 1858 | #[test] | |
| 1859 | fn a_page_its_section_cannot_retire_stays_beside_its_copy() { | |
| 1860 | vfs::install(); | |
| 1861 | let _exclusive = vfs::EXCLUSIVE | |
| 1862 | .lock() | |
| 1863 | .unwrap_or_else(std::sync::PoisonError::into_inner); | |
| 1864 | let (directory, root, library, empty) = notebook_open("Empty.one"); | |
| 1865 | let features = open(&library, "Features.one"); | |
| 1866 | let space = space(&features, "Task controls"); | |
| 1867 | drop(features); | |
| 1868 | let file = std::fs::read(root.join("Features.one")).unwrap(); | |
| 1869 | let identity = onestore::RevisionIndex::parse(&onestore::Store::parse(&file).unwrap()) | |
| 1870 | .unwrap() | |
| 1871 | .root | |
| 1872 | .guid; | |
| 1873 | let location = notebook::location::local(&root).unwrap(); | |
| 1874 | let replica = | |
| 1875 | notebook::location::folder(&directory.path().join("cache"), &location).join(format!( | |
| 1876 | "{}.sqlite", | |
| 1877 | (identity.iter()) | |
| 1878 | .map(|byte| format!("{byte:02x}")) | |
| 1879 | .collect::<String>() | |
| 1880 | )); | |
| 1881 | *vfs::FAILING.lock().unwrap() = Some(replica.canonicalize().unwrap()); | |
| 1882 | let date = ["Thursday, October 1, 2026", "9:00 AM"]; | |
| 1883 | let moved = library.move_page( | |
| 1884 | "Features.one", | |
| 1885 | space, | |
| 1886 | "Empty.one", | |
| 1887 | None, | |
| 1888 | 1, | |
| 1889 | None, | |
| 1890 | "Clover Test", | |
| 1891 | date, | |
| 1892 | ); | |
| 1893 | *vfs::FAILING.lock().unwrap() = None; | |
| 1894 | assert!(moved.unwrap().copied.is_some()); | |
| 1895 | drop(empty); | |
| 1896 | library.close(Duration::from_secs(20)).unwrap(); | |
| 1897 | let titles = |name: &str| { | |
| 1898 | let arena = onestore::Arena::default(); | |
| 1899 | let stored = onestore::read_file(root.join(name)).unwrap(); | |
| 1900 | let mut section = onestore::Section::open(&arena, stored).unwrap(); | |
| 1901 | (section.pages().unwrap().into_iter()) | |
| 1902 | .filter(|(_, title, _)| title == "Task controls") | |
| 1903 | .count() | |
| 1904 | }; | |
| 1905 | assert_eq!((titles("Features.one"), titles("Empty.one")), (1, 1)); | |
| 1906 | } | |
| 1907 | ||
| 1856 | 1908 | /// A page the section it moves to refuses stays where it was, and the move fails. |
| 1857 | 1909 | #[test] |
| 1858 | 1910 | fn a_page_the_other_section_refuses_stays() { |
| ... | ... | @@ -3386,6 +3438,9 @@ fn reader_dropped_and_synchronous_failed_apply_callbacks_never_release_the_reloa |
| 3386 | 3438 | |
| 3387 | 3439 | #[test] |
| 3388 | 3440 | fn reader_deferred_reload_preserves_rich_composition_after_real_local_write_failure() { |
| 3441 | let _exclusive = vfs::EXCLUSIVE | |
| 3442 | .lock() | |
| 3443 | .unwrap_or_else(std::sync::PoisonError::into_inner); | |
| 3389 | 3444 | for select in [false, true] { |
| 3390 | 3445 | let directory = tempfile::tempdir().unwrap(); |
| 3391 | 3446 | let file = directory.path().join("Reader fault.one"); |
crates/notebook/README.md+5-4| ... | ... | @@ -38,10 +38,11 @@ assert_eq!(reopened.pending()?.last().map(|edit| edit.id), Some(id)); |
| 38 | 38 | # } |
| 39 | 39 | ``` |
| 40 | 40 | |
| 41 | `apply_unchanged(author, edit, space, page)` applies the edit only while page `space` | |
| 42 | still holds `page`, checked on the section thread in order with every other edit, and | |
| 43 | refuses it as `Rejected` otherwise: a move across sections copies a page there, then retires | |
| 44 | it here this way, so an edit made since the copy keeps the page. | |
| 41 | A move across sections reads the page with `moving(space)`, together with its conflict pages | |
| 42 | and versions, which no copy carries; copies it there; then retires it here with | |
| 43 | `retire(author, edit, moving)`, which applies the edit only while all of them are as read, | |
| 44 | checked on the section thread in order with every other edit, and refuses it as `Rejected` | |
| 45 | otherwise, so an edit made since the copy keeps the page. | |
| 45 | 46 | |
| 46 | 47 | Page reads never wait for the sync thread: a rebase or reread of the queue runs on a |
| 47 | 48 | new thread while the current one keeps answering `page` and `pages` from the section as |
crates/notebook/src/lib.rs+31-21| ... | ... | @@ -88,6 +88,22 @@ type Result<T> = std::result::Result<T, Error>; |
| 88 | 88 | |
| 89 | 89 | const APPLICATION_ID: u32 = 0x4f4e454f; |
| 90 | 90 | |
| 91 | /// A page as a move copies it (`Replica::moving`), with its conflict pages and versions, which | |
| 92 | /// stay behind, read together. | |
| 93 | #[derive(Debug, PartialEq)] | |
| 94 | pub struct Moving { | |
| 95 | space: ExGuid, | |
| 96 | page: Page, | |
| 97 | conflicts: Vec<onestore::ConflictPage>, | |
| 98 | versions: Vec<onestore::PageVersion>, | |
| 99 | } | |
| 100 | ||
| 101 | impl Moving { | |
| 102 | pub fn page(&self) -> &Page { | |
| 103 | &self.page | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 91 | 107 | /// A locally durable edit; its ID remains stable across cache reopen. |
| 92 | 108 | #[derive(Debug, Clone, PartialEq)] |
| 93 | 109 | pub struct PendingEdit { |
| ... | ... | @@ -308,39 +324,33 @@ impl Replica { |
| 308 | 324 | edit, |
| 309 | 325 | reply, |
| 310 | 326 | waiting: None, |
| 311 | unchanged: None, | |
| 327 | moving: None, | |
| 312 | 328 | }) |
| 313 | 329 | }) |
| 314 | 330 | .await |
| 315 | 331 | } |
| 316 | 332 | |
| 317 | /// `apply`, provided page `space` still holds `page`; refused as `Rejected` otherwise. | |
| 318 | pub fn apply_unchanged( | |
| 319 | &self, | |
| 320 | author: &str, | |
| 321 | edit: Edit, | |
| 322 | space: ExGuid, | |
| 323 | page: Page, | |
| 324 | ) -> Result<u64> { | |
| 325 | task::blocking(self.apply_unchanged_async(author, edit, space, page))? | |
| 333 | /// Page `space` as a move copies it, read at once with what the copy leaves behind. | |
| 334 | pub fn moving(&self, space: ExGuid) -> Result<Moving> { | |
| 335 | self.ask(|reply| working::Request::Moving { space, reply }) | |
| 326 | 336 | } |
| 327 | 337 | |
| 328 | /// `apply_async`, provided page `space` still holds `page`, checked where edits apply in | |
| 329 | /// order: a move retires the page it copied this way, keeping any edit made since. | |
| 330 | pub async fn apply_unchanged_async( | |
| 331 | &self, | |
| 332 | author: &str, | |
| 333 | edit: Edit, | |
| 334 | space: ExGuid, | |
| 335 | page: Page, | |
| 336 | ) -> Result<u64> { | |
| 338 | /// `retire_async`, blocking. | |
| 339 | pub fn retire(&self, author: &str, edit: Edit, moving: Moving) -> Result<u64> { | |
| 340 | task::blocking(self.retire_async(author, edit, moving))? | |
| 341 | } | |
| 342 | ||
| 343 | /// Applies `edit`, which deletes the page `moving` read once its copy has landed elsewhere, | |
| 344 | /// only while the page and what it leaves behind are as read: checked in order with every | |
| 345 | /// other edit, so none made since is deleted with it. One changed is refused as `Rejected`. | |
| 346 | pub async fn retire_async(&self, author: &str, edit: Edit, moving: Moving) -> Result<u64> { | |
| 337 | 347 | self.ask_async(|reply| { |
| 338 | 348 | working::Request::Apply(working::Accepted { |
| 339 | 349 | author: author.to_owned(), |
| 340 | 350 | edit, |
| 341 | 351 | reply, |
| 342 | 352 | waiting: None, |
| 343 | unchanged: Some((space, page)), | |
| 353 | moving: Some(Box::new(moving)), | |
| 344 | 354 | }) |
| 345 | 355 | }) |
| 346 | 356 | .await |
| ... | ... | @@ -360,7 +370,7 @@ impl Replica { |
| 360 | 370 | edit, |
| 361 | 371 | reply, |
| 362 | 372 | waiting: Some(waiting), |
| 363 | unchanged: None, | |
| 373 | moving: None, | |
| 364 | 374 | })) |
| 365 | 375 | } |
| 366 | 376 |
crates/notebook/src/session.rs+1-2| ... | ... | @@ -2565,8 +2565,7 @@ impl Section { |
| 2565 | 2565 | } |
| 2566 | 2566 | |
| 2567 | 2567 | /// Removes pages or conflict pages permanently, queued like the user's own edits (a |
| 2568 | /// move across sections imports a page there, then retires it here with | |
| 2569 | /// `Replica::apply_unchanged`). | |
| 2568 | /// move across sections imports a page there, then retires it here with `Replica::retire`). | |
| 2570 | 2569 | pub fn delete_pages(&self, pages: &[ExGuid]) -> Result<u64> { |
| 2571 | 2570 | crate::task::blocking(self.delete_pages_async(pages))? |
| 2572 | 2571 | } |
crates/notebook/src/working.rs+30-7| ... | ... | @@ -4,7 +4,7 @@ |
| 4 | 4 | //! and, when the remote changed, to rebase the queue. In the browser, which has one thread, |
| 5 | 5 | //! the section is served as each request is sent, and a rebuild runs as it is asked for. |
| 6 | 6 | |
| 7 | use crate::{Result, base, lock, merge, queue, worker::Signal}; | |
| 7 | use crate::{Moving, Result, base, lock, merge, queue, worker::Signal}; | |
| 8 | 8 | use onestore::{ |
| 9 | 9 | Arena, ExGuid, Section, Transaction, |
| 10 | 10 | op::{Edit, OpError}, |
| ... | ... | @@ -42,6 +42,10 @@ pub(crate) enum Request { |
| 42 | 42 | version: ExGuid, |
| 43 | 43 | reply: Reply<Page>, |
| 44 | 44 | }, |
| 45 | Moving { | |
| 46 | space: ExGuid, | |
| 47 | reply: Reply<Moving>, | |
| 48 | }, | |
| 45 | 49 | /// Answers once the edits before it are written, optionally awaiting host durability. |
| 46 | 50 | Flush { |
| 47 | 51 | reply: Reply<()>, |
| ... | ... | @@ -89,6 +93,7 @@ impl Request { |
| 89 | 93 | Self::Conflicts { reply } => reply(Err(error())), |
| 90 | 94 | Self::Versions { reply } => reply(Err(error())), |
| 91 | 95 | Self::Version { reply, .. } => reply(Err(error())), |
| 96 | Self::Moving { reply, .. } => reply(Err(error())), | |
| 92 | 97 | Self::Flush { reply, .. } => reply(Err(error())), |
| 93 | 98 | Self::Seal { reply } => reply(Err(error())), |
| 94 | 99 | Self::Rebase { reply, .. } => reply(Err(error())), |
| ... | ... | @@ -482,7 +487,7 @@ pub(crate) struct Accepted { |
| 482 | 487 | /// Set where nobody blocks on the reply: a failed write keeps the edit and reports here. |
| 483 | 488 | pub waiting: Option<Notice>, |
| 484 | 489 | /// A page as a move copied it, which the edit applies only while it still holds. |
| 485 | pub unchanged: Option<(ExGuid, Page)>, | |
| 490 | pub moving: Option<Box<Moving>>, | |
| 486 | 491 | } |
| 487 | 492 | |
| 488 | 493 | struct Working<'a> { |
| ... | ... | @@ -553,6 +558,7 @@ impl<'a> Working<'a> { |
| 553 | 558 | version, |
| 554 | 559 | reply, |
| 555 | 560 | } => reply(self.section.version(space, version).map_err(Into::into)), |
| 561 | Request::Moving { space, reply } => reply(self.moving(space)), | |
| 556 | 562 | Request::Handover(to) => { |
| 557 | 563 | let mut waiting = held.take().unwrap_or_default(); |
| 558 | 564 | waiting.extend(burst.drain(..).chain(later.drain(..))); |
| ... | ... | @@ -613,6 +619,10 @@ impl<'a> Working<'a> { |
| 613 | 619 | reply(self.section.version(space, version).map_err(Into::into)); |
| 614 | 620 | false |
| 615 | 621 | } |
| 622 | Request::Moving { space, reply } => { | |
| 623 | reply(self.moving(space)); | |
| 624 | false | |
| 625 | } | |
| 616 | 626 | Request::Flush { reply, durable } => { |
| 617 | 627 | #[cfg(target_arch = "wasm32")] |
| 618 | 628 | if durable { |
| ... | ... | @@ -699,15 +709,28 @@ impl<'a> Working<'a> { |
| 699 | 709 | .is_ok() |
| 700 | 710 | } |
| 701 | 711 | |
| 712 | /// Page `space` as a move copies it, with the conflict pages and versions it leaves. | |
| 713 | fn moving(&mut self, space: ExGuid) -> Result<Moving> { | |
| 714 | fn of<T>(space: ExGuid, listed: Vec<(ExGuid, Vec<T>)>) -> Vec<T> { | |
| 715 | (listed.into_iter()) | |
| 716 | .find_map(|(page, of)| (page == space).then_some(of)) | |
| 717 | .unwrap_or_default() | |
| 718 | } | |
| 719 | Ok(Moving { | |
| 720 | space, | |
| 721 | page: self.section.page(space)?, | |
| 722 | conflicts: of(space, self.section.conflicts()?), | |
| 723 | versions: of(space, self.section.versions()?), | |
| 724 | }) | |
| 725 | } | |
| 726 | ||
| 702 | 727 | /// Applies an edit, keeping it to be written with the burst. A refused edit is answered |
| 703 | 728 | /// here; true when it failed part way and the section must be reread. |
| 704 | 729 | fn accept(&mut self, accepted: Accepted) -> bool { |
| 705 | if let Some((space, page)) = &accepted.unchanged | |
| 706 | && !self.section.page(*space).is_ok_and(|now| now == *page) | |
| 730 | if let Some(moving) = &accepted.moving | |
| 731 | && !self.moving(moving.space).is_ok_and(|now| now == **moving) | |
| 707 | 732 | { |
| 708 | let changed = OpError::StructureChanged( | |
| 709 | "The page changed while it moved. Both sections now have a copy.", | |
| 710 | ); | |
| 733 | let changed = OpError::StructureChanged("The page changed while it moved."); | |
| 711 | 734 | (accepted.reply)(Err(changed.into())); |
| 712 | 735 | return false; |
| 713 | 736 | } |
crates/notebook/tests/support/vfs.rs+2| ... | ... | @@ -34,6 +34,8 @@ static REAL: OnceLock<usize> = OnceLock::new(); |
| 34 | 34 | static WAL: OnceLock<(usize, usize, usize)> = OnceLock::new(); |
| 35 | 35 | /// While set, every write to this database's WAL fails, for WALs opened while it was set. |
| 36 | 36 | pub static FAILING: Mutex<Option<PathBuf>> = Mutex::new(None); |
| 37 | /// Held by a test for as long as it sets `FAILING`, which one test sets at a time. | |
| 38 | pub static EXCLUSIVE: Mutex<()> = Mutex::new(()); | |
| 37 | 39 | |
| 38 | 40 | fn fire(fault: u8) -> bool { |
| 39 | 41 | let fired = ARMED.fetch_and(!fault, Ordering::SeqCst) & fault != 0; |
crates/notebook/tests/sync_pages.rs+61-5| ... | ... | @@ -745,8 +745,9 @@ fn page_moves_and_a_removed_page_merge_as_onenote_merges_them() { |
| 745 | 745 | } |
| 746 | 746 | |
| 747 | 747 | /// A page a move copied leaves only as it was copied: an edit made here since, published or |
| 748 | /// not, or another writer's that a rebase took, keeps it; edits to other pages, published or | |
| 749 | /// rebased, do not. Either way the queue reopens as it was left and publishes it. | |
| 748 | /// not, another writer's that a rebase took, or a conflict page kept under it keeps it; edits | |
| 749 | /// to other pages, published or rebased, do not. Either way the queue reopens as it was left | |
| 750 | /// and publishes it. | |
| 750 | 751 | #[test] |
| 751 | 752 | fn a_moved_page_leaves_only_as_it_was_copied() { |
| 752 | 753 | // The first two pages holding text. |
| ... | ... | @@ -774,7 +775,7 @@ fn a_moved_page_leaves_only_as_it_was_copied() { |
| 774 | 775 | server.durable.clone_from(&server.visible); |
| 775 | 776 | } |
| 776 | 777 | type Between = fn(&Replica, &mut Server, [(ExGuid, ExGuid); 2]); |
| 777 | let schedules: [(&str, bool, Between); 5] = [ | |
| 778 | let schedules: [(&str, bool, Between); 6] = [ | |
| 778 | 779 | ("unrelated", true, |cache, server, [_, other]| { |
| 779 | 780 | here(cache, other); |
| 780 | 781 | cache.sync_once(server).unwrap(); |
| ... | ... | @@ -801,13 +802,33 @@ fn a_moved_page_leaves_only_as_it_was_copied() { |
| 801 | 802 | cache.sync_once(server).unwrap(); |
| 802 | 803 | }, |
| 803 | 804 | ), |
| 805 | ( | |
| 806 | "kept as a conflict page", | |
| 807 | false, | |
| 808 | |cache, _, [(moving, _), _]| { | |
| 809 | let page = cache.page(moving).unwrap(); | |
| 810 | let none = std::collections::BTreeSet::new(); | |
| 811 | let kept = | |
| 812 | notebook::session::conflict(moving, &page, &none, "Keeper", true).unwrap(); | |
| 813 | let at = 133_000_000_000_000_001; | |
| 814 | cache | |
| 815 | .apply( | |
| 816 | "Keeper", | |
| 817 | onestore::op::Edit { | |
| 818 | at, | |
| 819 | ops: vec![kept], | |
| 820 | }, | |
| 821 | ) | |
| 822 | .unwrap(); | |
| 823 | }, | |
| 824 | ), | |
| 804 | 825 | ]; |
| 805 | 826 | for (schedule, leaves, between) in schedules { |
| 806 | 827 | let directory = tempfile::tempdir().unwrap(); |
| 807 | 828 | let path = directory.path().join("moving.sqlite"); |
| 808 | 829 | let cache = Replica::create(&path, SOURCE).unwrap(); |
| 809 | 830 | let mut server = Server::new(SOURCE); |
| 810 | let copied = cache.page(moving).unwrap(); | |
| 831 | let copied = cache.moving(moving).unwrap(); | |
| 811 | 832 | between( |
| 812 | 833 | &cache, |
| 813 | 834 | &mut server, |
| ... | ... | @@ -818,7 +839,7 @@ fn a_moved_page_leaves_only_as_it_was_copied() { |
| 818 | 839 | at: 133_000_000_000_000_002, |
| 819 | 840 | ops: vec![Op::Section(SectionOp::Delete(vec![moving]))], |
| 820 | 841 | }; |
| 821 | let retired = cache.apply_unchanged("Mover", delete, moving, copied); | |
| 842 | let retired = cache.retire("Mover", delete, copied); | |
| 822 | 843 | assert_eq!(retired.is_ok(), leaves, "{schedule}: {retired:?}"); |
| 823 | 844 | assert!( |
| 824 | 845 | leaves || matches!(retired, Err(notebook::Error::Rejected(_))), |
| ... | ... | @@ -837,3 +858,38 @@ fn a_moved_page_leaves_only_as_it_was_copied() { |
| 837 | 858 | assert!(cache.pending().unwrap().is_empty(), "{schedule}"); |
| 838 | 859 | } |
| 839 | 860 | } |
| 861 | ||
| 862 | /// A version of a page deleted while the page moves keeps the page, which no copy carries | |
| 863 | /// the versions of. | |
| 864 | #[test] | |
| 865 | fn a_moved_page_leaves_only_with_the_versions_it_had() { | |
| 866 | let source = | |
| 867 | include_bytes!("../../../corpus/page-versions/native/step-02/notebook/History.one"); | |
| 868 | let directory = tempfile::tempdir().unwrap(); | |
| 869 | let cache = Replica::create(directory.path().join("history.sqlite"), source).unwrap(); | |
| 870 | let (moving, versions) = cache.versions().unwrap().remove(0); | |
| 871 | let copied = cache.moving(moving).unwrap(); | |
| 872 | section_op( | |
| 873 | &cache, | |
| 874 | SectionOp::DeleteVersions { | |
| 875 | page: moving, | |
| 876 | versions: vec![versions[0].context], | |
| 877 | }, | |
| 878 | ); | |
| 879 | let delete = onestore::op::Edit { | |
| 880 | at: 133_000_000_000_000_002, | |
| 881 | ops: vec![Op::Section(SectionOp::Delete(vec![moving]))], | |
| 882 | }; | |
| 883 | let retired = cache.retire("Mover", delete, copied); | |
| 884 | assert!( | |
| 885 | matches!(retired, Err(notebook::Error::Rejected(_))), | |
| 886 | "{retired:?}" | |
| 887 | ); | |
| 888 | assert!( | |
| 889 | cache | |
| 890 | .pages() | |
| 891 | .unwrap() | |
| 892 | .iter() | |
| 893 | .any(|(space, ..)| *space == moving) | |
| 894 | ); | |
| 895 | } |
crates/snowbound/Cargo.toml+2| ... | ... | @@ -126,3 +126,5 @@ x11rb = "0.13.2" |
| 126 | 126 | [dev-dependencies] |
| 127 | 127 | # Draws the pictures of the video recording the tests store. |
| 128 | 128 | image = { version = "0.25", default-features = false } |
| 129 | # Fails a replica's writes, as a full disk does (`notebook/tests/support/vfs.rs`). | |
| 130 | rusqlite = { version = "=0.40.2", features = ["bundled", "backup"] } |
crates/snowbound/src/main.rs+4| ... | ... | @@ -94,6 +94,10 @@ mod undo; |
| 94 | 94 | mod unpack; |
| 95 | 95 | mod unread; |
| 96 | 96 | mod update; |
| 97 | /// Fails a replica's writes on demand, for tests of what a full disk does. | |
| 98 | #[cfg(test)] | |
| 99 | #[path = "../../notebook/tests/support/vfs.rs"] | |
| 100 | mod vfs; | |
| 97 | 101 | #[cfg_attr(target_arch = "wasm32", path = "watch_web.rs")] |
| 98 | 102 | mod watch; |
| 99 | 103 |
crates/snowbound/src/undo.rs+178-73| ... | ... | @@ -351,7 +351,8 @@ struct Site { |
| 351 | 351 | |
| 352 | 352 | /// A change to pages done: the change taking it back, the page to show, and pages gone. |
| 353 | 353 | struct Applied { |
| 354 | undo: Change, | |
| 354 | /// What takes it back; where a move only copied its page, why the page also stayed. | |
| 355 | undo: Result<Change, String>, | |
| 355 | 356 | show: ExGuid, |
| 356 | 357 | gone: Vec<ExGuid>, |
| 357 | 358 | } |
| ... | ... | @@ -415,6 +416,19 @@ impl Site { |
| 415 | 416 | entry_of(self.library.catalog()?, identity).map(|(_, path)| path) |
| 416 | 417 | } |
| 417 | 418 | |
| 419 | /// Closes `section`, at catalog `path`, once its edits are written; while some wait to be, | |
| 420 | /// keeps it open, where they are tried again until they are. | |
| 421 | async fn release(&self, path: &str, section: notebook::session::Section) { | |
| 422 | match section.replica().written_async().await { | |
| 423 | Ok(()) => { | |
| 424 | if let Err(error) = section.close_async().await { | |
| 425 | eprintln!("Synchronization stopped: {error}"); | |
| 426 | } | |
| 427 | } | |
| 428 | Err(_) => self.library.keep(path, section), | |
| 429 | } | |
| 430 | } | |
| 431 | ||
| 418 | 432 | /// Does `change` to the section's pages, `shown` the page open; `None` where what it |
| 419 | 433 | /// changes is gone or holds what it did not make, which it leaves alone. |
| 420 | 434 | async fn change( |
| ... | ... | @@ -468,11 +482,11 @@ impl Site { |
| 468 | 482 | } |
| 469 | 483 | self.apply(ops).await?; |
| 470 | 484 | Applied { |
| 471 | undo: Change::Restore { | |
| 485 | undo: Ok(Change::Restore { | |
| 472 | 486 | pages: kept, |
| 473 | 487 | added, |
| 474 | 488 | created, |
| 475 | }, | |
| 489 | }), | |
| 476 | 490 | show, |
| 477 | 491 | gone: pages, |
| 478 | 492 | } |
| ... | ... | @@ -519,11 +533,11 @@ impl Site { |
| 519 | 533 | self.library = Arc::new(self.library.with(notebook)); |
| 520 | 534 | } |
| 521 | 535 | Applied { |
| 522 | undo: Change::Delete { | |
| 536 | undo: Ok(Change::Delete { | |
| 523 | 537 | pages: spaces.clone(), |
| 524 | 538 | show: Some(shown), |
| 525 | 539 | created, |
| 526 | }, | |
| 540 | }), | |
| 527 | 541 | show: spaces[0], |
| 528 | 542 | gone, |
| 529 | 543 | } |
| ... | ... | @@ -536,13 +550,13 @@ impl Site { |
| 536 | 550 | self.apply(vec![Op::Section(SectionOp::Pages(edits))]) |
| 537 | 551 | .await?; |
| 538 | 552 | Applied { |
| 539 | undo: Change::Arrange { | |
| 553 | undo: Ok(Change::Arrange { | |
| 540 | 554 | order: listed |
| 541 | 555 | .iter() |
| 542 | 556 | .map(|(space, _, level)| (*space, *level)) |
| 543 | 557 | .collect(), |
| 544 | 558 | moved, |
| 545 | }, | |
| 559 | }), | |
| 546 | 560 | show, |
| 547 | 561 | gone: Vec::new(), |
| 548 | 562 | } |
| ... | ... | @@ -554,11 +568,11 @@ impl Site { |
| 554 | 568 | let op = crate::rename::retitled(&self.replica.page(page)?, page, to.clone())?; |
| 555 | 569 | self.apply(vec![op]).await?; |
| 556 | 570 | Applied { |
| 557 | undo: Change::Retitle { | |
| 571 | undo: Ok(Change::Retitle { | |
| 558 | 572 | page, |
| 559 | 573 | from: to, |
| 560 | 574 | to: from, |
| 561 | }, | |
| 575 | }), | |
| 562 | 576 | show: page, |
| 563 | 577 | gone: Vec::new(), |
| 564 | 578 | } |
| ... | ... | @@ -567,8 +581,8 @@ impl Site { |
| 567 | 581 | let (Some(from), Some(path)) = (at(page), self.section_path(target)) else { |
| 568 | 582 | return Ok(None); |
| 569 | 583 | }; |
| 570 | let sent = self.replica.page(page)?; | |
| 571 | let import = notebook::session::moved(&sent, &self.author)?; | |
| 584 | let sent = self.replica.moving(page)?; | |
| 585 | let import = notebook::session::moved(sent.page(), &self.author)?; | |
| 572 | 586 | let arrived = imported(&import); |
| 573 | 587 | let before = listed[from + 1..].first().map(|(space, ..)| *space); |
| 574 | 588 | let level = listed[from].2; |
| ... | ... | @@ -585,24 +599,29 @@ impl Site { |
| 585 | 599 | }, |
| 586 | 600 | ) |
| 587 | 601 | .await?; |
| 588 | section.close_async().await?; | |
| 602 | self.release(&path, section).await; | |
| 589 | 603 | let edit = Edit { |
| 590 | 604 | at: crate::filetime(), |
| 591 | 605 | ops, |
| 592 | 606 | }; |
| 593 | self.replica | |
| 594 | .apply_unchanged_async(&self.author, edit, page, sent) | |
| 595 | .await?; | |
| 596 | Applied { | |
| 597 | undo: Change::Return { | |
| 598 | page: arrived, | |
| 599 | target, | |
| 600 | before, | |
| 601 | level, | |
| 602 | added, | |
| 607 | let retired = self.replica.retire_async(&self.author, edit, sent).await; | |
| 608 | match retired { | |
| 609 | Ok(_) => Applied { | |
| 610 | undo: Ok(Change::Return { | |
| 611 | page: arrived, | |
| 612 | target, | |
| 613 | before, | |
| 614 | level, | |
| 615 | added, | |
| 616 | }), | |
| 617 | show, | |
| 618 | gone: vec![page], | |
| 619 | }, | |
| 620 | Err(error) => Applied { | |
| 621 | undo: Err(crate::plain(&error, "page")), | |
| 622 | show: page, | |
| 623 | gone: Vec::new(), | |
| 603 | 624 | }, |
| 604 | show, | |
| 605 | gone: vec![page], | |
| 606 | 625 | } |
| 607 | 626 | } |
| 608 | 627 | Change::Return { |
| ... | ... | @@ -616,15 +635,12 @@ impl Site { |
| 616 | 635 | return Ok(None); |
| 617 | 636 | }; |
| 618 | 637 | let section = self.library.open_async(&path, || {}).await?; |
| 619 | let sent = match section.pages()?.iter().any(|(listed, ..)| *listed == page) { | |
| 620 | true => Some(section.page(page)?), | |
| 621 | false => None, | |
| 622 | }; | |
| 623 | let Some(sent) = sent else { | |
| 624 | section.close_async().await?; | |
| 638 | if !(section.pages()?.iter()).any(|(listed, ..)| *listed == page) { | |
| 639 | self.release(&path, section).await; | |
| 625 | 640 | return Ok(None); |
| 626 | }; | |
| 627 | let import = notebook::session::moved(&sent, &self.author)?; | |
| 641 | } | |
| 642 | let sent = section.replica().moving(page)?; | |
| 643 | let import = notebook::session::moved(sent.page(), &self.author)?; | |
| 628 | 644 | let back = imported(&import); |
| 629 | 645 | let mut ops = vec![ |
| 630 | 646 | import, |
| ... | ... | @@ -640,13 +656,14 @@ impl Site { |
| 640 | 656 | at: crate::filetime(), |
| 641 | 657 | ops: vec![Op::Section(SectionOp::Delete(vec![page]))], |
| 642 | 658 | }; |
| 643 | section | |
| 659 | let retired = section | |
| 644 | 660 | .replica() |
| 645 | .apply_unchanged_async(&self.author, delete, page, sent) | |
| 646 | .await?; | |
| 647 | section.close_async().await?; | |
| 661 | .retire_async(&self.author, delete, sent) | |
| 662 | .await; | |
| 663 | self.release(&path, section).await; | |
| 648 | 664 | Applied { |
| 649 | undo: Change::Send { page: back, target }, | |
| 665 | undo: (retired.map(|_| Change::Send { page: back, target })) | |
| 666 | .map_err(|error| crate::plain(&error, "page")), | |
| 650 | 667 | show: back, |
| 651 | 668 | gone, |
| 652 | 669 | } |
| ... | ... | @@ -1152,12 +1169,17 @@ impl State { |
| 1152 | 1169 | }; |
| 1153 | 1170 | }; |
| 1154 | 1171 | state.undo.forget(&applied.gone); |
| 1155 | let undo = Action { | |
| 1156 | notebook, | |
| 1157 | section: Some(section), | |
| 1158 | change: applied.undo, | |
| 1159 | }; | |
| 1160 | state.undo.note(taken.map(|(redo, _)| redo), undo); | |
| 1172 | match applied.undo { | |
| 1173 | Ok(change) => { | |
| 1174 | let undo = Action { | |
| 1175 | notebook, | |
| 1176 | section: Some(section), | |
| 1177 | change, | |
| 1178 | }; | |
| 1179 | state.undo.note(taken.map(|(redo, _)| redo), undo); | |
| 1180 | } | |
| 1181 | Err(copied) => crate::platform::alert("Page kept in both sections", &copied), | |
| 1182 | } | |
| 1161 | 1183 | state.edited(Vec::new()); |
| 1162 | 1184 | Ok(()) |
| 1163 | 1185 | }))); |
| ... | ... | @@ -1503,6 +1525,24 @@ mod tests { |
| 1503 | 1525 | ) |
| 1504 | 1526 | } |
| 1505 | 1527 | |
| 1528 | /// The titles of the section at `path`, opened again. | |
| 1529 | fn stored(&self, path: &str) -> Vec<(String, u32)> { | |
| 1530 | let section = self.library.open(path, || {}).unwrap(); | |
| 1531 | let titles = (section.pages().unwrap().into_iter()) | |
| 1532 | .map(|(_, title, level)| (title, level)) | |
| 1533 | .collect(); | |
| 1534 | section.close().unwrap(); | |
| 1535 | titles | |
| 1536 | } | |
| 1537 | ||
| 1538 | /// The replica of the section at `path`, which `vfs::FAILING` names. | |
| 1539 | fn replica(&self, path: &str) -> std::path::PathBuf { | |
| 1540 | let Ok(Some(notebook)) = &self.library.notebook else { | |
| 1541 | panic!("a notebook") | |
| 1542 | }; | |
| 1543 | notebook.replica_path(path).unwrap().canonicalize().unwrap() | |
| 1544 | } | |
| 1545 | ||
| 1506 | 1546 | /// Does `change`, as the app's thread does, returning what takes it back. |
| 1507 | 1547 | fn change(&self, change: Change) -> Option<Applied> { |
| 1508 | 1548 | let shown = self.pages()[0].0; |
| ... | ... | @@ -1554,7 +1594,7 @@ mod tests { |
| 1554 | 1594 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); |
| 1555 | 1595 | assert_eq!(deleted.show, fixture.space("Third")); |
| 1556 | 1596 | assert_eq!(fixture.binned(), ["Second"]); |
| 1557 | let restored = pollster::block_on(site.change(deleted.undo, deleted.show)) | |
| 1597 | let restored = pollster::block_on(site.change(deleted.undo.unwrap(), deleted.show)) | |
| 1558 | 1598 | .unwrap() |
| 1559 | 1599 | .unwrap(); |
| 1560 | 1600 | assert!(!Arc::ptr_eq(&site.library, &deleted_library)); |
| ... | ... | @@ -1562,19 +1602,19 @@ mod tests { |
| 1562 | 1602 | assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"])); |
| 1563 | 1603 | assert_eq!(restored.show, fixture.space("Second")); |
| 1564 | 1604 | assert!(fixture.binned().is_empty()); |
| 1565 | let again = pollster::block_on(site.change(restored.undo, restored.show)) | |
| 1605 | let again = pollster::block_on(site.change(restored.undo.unwrap(), restored.show)) | |
| 1566 | 1606 | .unwrap() |
| 1567 | 1607 | .unwrap(); |
| 1568 | 1608 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); |
| 1569 | 1609 | assert_eq!(fixture.binned(), ["Second"]); |
| 1570 | 1610 | |
| 1571 | 1611 | // Put back elsewhere first, the page stays as that device left it. |
| 1572 | let Change::Restore { pages, .. } = &again.undo else { | |
| 1612 | let Ok(Change::Restore { pages, .. }) = &again.undo else { | |
| 1573 | 1613 | panic!() |
| 1574 | 1614 | }; |
| 1575 | 1615 | let copy = notebook::session::moved(&pages[0].page, AUTHOR).unwrap(); |
| 1576 | 1616 | fixture.edit(vec![copy]); |
| 1577 | assert!(fixture.change(again.undo).is_none()); | |
| 1617 | assert!(fixture.change(again.undo.unwrap()).is_none()); | |
| 1578 | 1618 | assert_eq!(fixture.titles(), titled(&["First", "Third", "Second"])); |
| 1579 | 1619 | } |
| 1580 | 1620 | |
| ... | ... | @@ -1598,13 +1638,13 @@ mod tests { |
| 1598 | 1638 | assert_eq!(fixture.titles(), titled(&["First", "Second"])); |
| 1599 | 1639 | assert_eq!(removed.show, first); |
| 1600 | 1640 | assert!(fixture.binned().is_empty()); |
| 1601 | let back = fixture.change(removed.undo).unwrap(); | |
| 1641 | let back = fixture.change(removed.undo.unwrap()).unwrap(); | |
| 1602 | 1642 | assert_eq!(fixture.titles(), titled(&["First", "Second", ""])); |
| 1603 | let Change::Delete { pages, .. } = &back.undo else { | |
| 1643 | let Ok(Change::Delete { pages, .. }) = &back.undo else { | |
| 1604 | 1644 | panic!() |
| 1605 | 1645 | }; |
| 1606 | 1646 | fixture.retitle(pages[0], "Written elsewhere"); |
| 1607 | assert!(fixture.change(back.undo).is_none()); | |
| 1647 | assert!(fixture.change(back.undo.unwrap()).is_none()); | |
| 1608 | 1648 | assert_eq!( |
| 1609 | 1649 | fixture.titles(), |
| 1610 | 1650 | titled(&["First", "Second", "Written elsewhere"]) |
| ... | ... | @@ -1637,7 +1677,7 @@ mod tests { |
| 1637 | 1677 | let arranged = fixture.change(arrange).unwrap(); |
| 1638 | 1678 | assert_eq!(fixture.titles(), titled(&["A", "B", "C", "D"])); |
| 1639 | 1679 | assert_eq!(arranged.show, a); |
| 1640 | fixture.change(arranged.undo).unwrap(); | |
| 1680 | fixture.change(arranged.undo.unwrap()).unwrap(); | |
| 1641 | 1681 | fixture.edit(vec![Op::Section(SectionOp::Delete(vec![b]))]); |
| 1642 | 1682 | fixture |
| 1643 | 1683 | .change(Change::Arrange { |
| ... | ... | @@ -1673,23 +1713,23 @@ mod tests { |
| 1673 | 1713 | .unwrap(); |
| 1674 | 1714 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); |
| 1675 | 1715 | assert_eq!(in_b(&fixture), ["", "Second"]); |
| 1676 | let returned = fixture.change(sent.undo).unwrap(); | |
| 1716 | let returned = fixture.change(sent.undo.unwrap()).unwrap(); | |
| 1677 | 1717 | assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"])); |
| 1678 | 1718 | assert_eq!(in_b(&fixture), [""]); |
| 1679 | let sent = fixture.change(returned.undo).unwrap(); | |
| 1680 | let Change::Return { page, .. } = &sent.undo else { | |
| 1719 | let sent = fixture.change(returned.undo.unwrap()).unwrap(); | |
| 1720 | let Ok(Change::Return { page, .. }) = &sent.undo else { | |
| 1681 | 1721 | panic!() |
| 1682 | 1722 | }; |
| 1683 | 1723 | let section = fixture.library.open("B.one", || {}).unwrap(); |
| 1684 | 1724 | section.delete_pages(&[*page]).unwrap(); |
| 1685 | 1725 | section.close().unwrap(); |
| 1686 | assert!(fixture.change(sent.undo).is_none()); | |
| 1726 | assert!(fixture.change(sent.undo.unwrap()).is_none()); | |
| 1687 | 1727 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); |
| 1688 | 1728 | } |
| 1689 | 1729 | |
| 1690 | 1730 | /// A page edited while it is on its way to another section stays, with the edit, beside |
| 1691 | /// the copy that arrived there; an edit to another page meanwhile lets it go. Both hold | |
| 1692 | /// once each section is opened again. | |
| 1731 | /// the copy that arrived there, with nothing for Undo to take back; an edit to another page | |
| 1732 | /// meanwhile lets it go. Both hold once each section is opened again. | |
| 1693 | 1733 | #[test] |
| 1694 | 1734 | fn a_page_edited_while_it_is_sent_stays_with_its_edit() { |
| 1695 | 1735 | struct Unreached; |
| ... | ... | @@ -1731,39 +1771,104 @@ mod tests { |
| 1731 | 1771 | let shown = fixture.pages()[0].0; |
| 1732 | 1772 | let sending = std::thread::spawn(move || { |
| 1733 | 1773 | pollster::block_on(site.change(Change::Send { page, target }, shown)) |
| 1734 | .map(|applied| applied.is_some()) | |
| 1735 | 1774 | .map_err(|error| error.to_string()) |
| 1736 | 1775 | }); |
| 1776 | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); | |
| 1737 | 1777 | while !(arrived.pages().unwrap().iter()).any(|(_, title, _)| title == "Second") { |
| 1778 | assert!( | |
| 1779 | std::time::Instant::now() < deadline, | |
| 1780 | "the copy never arrived" | |
| 1781 | ); | |
| 1738 | 1782 | std::thread::sleep(std::time::Duration::from_millis(5)); |
| 1739 | 1783 | } |
| 1740 | 1784 | fixture.retitle(fixture.space(edited), &format!("{edited} edited")); |
| 1741 | 1785 | drop(release); |
| 1742 | 1786 | drop(arrived); |
| 1743 | let sent = sending.join().unwrap(); | |
| 1787 | let sent = sending.join().unwrap().unwrap().unwrap(); | |
| 1744 | 1788 | |
| 1745 | 1789 | let stayed = match edited { |
| 1746 | 1790 | "Second" => { |
| 1747 | assert!(sent.is_err(), "{sent:?}"); | |
| 1791 | assert!(sent.undo.is_err()); | |
| 1748 | 1792 | titled(&["First", "Second edited", "Third"]) |
| 1749 | 1793 | } |
| 1750 | 1794 | _ => { |
| 1751 | assert_eq!(sent, Ok(true)); | |
| 1795 | assert!(sent.undo.is_ok()); | |
| 1752 | 1796 | titled(&["First", "Third edited"]) |
| 1753 | 1797 | } |
| 1754 | 1798 | }; |
| 1755 | 1799 | fixture.section.take().unwrap().close().unwrap(); |
| 1756 | let titles = |path: &str| { | |
| 1757 | let section = fixture.library.open(path, || {}).unwrap(); | |
| 1758 | let titles: Vec<(String, u32)> = (section.pages().unwrap().into_iter()) | |
| 1759 | .map(|(_, title, level)| (title, level)) | |
| 1760 | .collect(); | |
| 1761 | section.close().unwrap(); | |
| 1762 | titles | |
| 1763 | }; | |
| 1764 | assert_eq!(titles("New Section 1.one"), stayed); | |
| 1765 | assert_eq!(titles("B.one"), titled(&["", "Second"])); | |
| 1800 | assert_eq!(fixture.stored("New Section 1.one"), stayed); | |
| 1801 | assert_eq!(fixture.stored("B.one"), titled(&["", "Second"])); | |
| 1802 | } | |
| 1803 | } | |
| 1804 | ||
| 1805 | use crate::vfs; | |
| 1806 | ||
| 1807 | /// A page Undo brings back stays in the section it was sent to as well where that one | |
| 1808 | /// cannot write, with nothing left for Redo; both hold once each is opened again. | |
| 1809 | #[test] | |
| 1810 | fn a_page_brought_back_where_the_cache_fails_stays_in_both_sections() { | |
| 1811 | vfs::install(); | |
| 1812 | let _exclusive = vfs::EXCLUSIVE | |
| 1813 | .lock() | |
| 1814 | .unwrap_or_else(std::sync::PoisonError::into_inner); | |
| 1815 | let mut fixture = Fixture::new("return-fault", &["First", "Second", "Third"]); | |
| 1816 | let target = fixture.library.section_identity("B.one").unwrap(); | |
| 1817 | let sent = (fixture.change(Change::Send { | |
| 1818 | page: fixture.space("Second"), | |
| 1819 | target, | |
| 1820 | })) | |
| 1821 | .unwrap(); | |
| 1822 | *vfs::FAILING.lock().unwrap() = Some(fixture.replica("B.one")); | |
| 1823 | let back = fixture.change(sent.undo.unwrap()); | |
| 1824 | *vfs::FAILING.lock().unwrap() = None; | |
| 1825 | assert!(back.unwrap().undo.is_err()); | |
| 1826 | assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"])); | |
| 1827 | fixture.section.take().unwrap().close().unwrap(); | |
| 1828 | let stayed = titled(&["First", "Second", "Third"]); | |
| 1829 | assert_eq!(fixture.stored("New Section 1.one"), stayed); | |
| 1830 | assert_eq!(fixture.stored("B.one"), titled(&["", "Second"])); | |
| 1831 | } | |
| 1832 | ||
| 1833 | /// A section a move leaves with edits waiting for the cache stays open, writing them once | |
| 1834 | /// it can, rather than closing without them. | |
| 1835 | #[test] | |
| 1836 | fn a_section_left_with_edits_waiting_keeps_them() { | |
| 1837 | vfs::install(); | |
| 1838 | let _exclusive = vfs::EXCLUSIVE | |
| 1839 | .lock() | |
| 1840 | .unwrap_or_else(std::sync::PoisonError::into_inner); | |
| 1841 | let fixture = Fixture::new("release", &["First"]); | |
| 1842 | fixture | |
| 1843 | .library | |
| 1844 | .open("B.one", || {}) | |
| 1845 | .unwrap() | |
| 1846 | .close() | |
| 1847 | .unwrap(); | |
| 1848 | *vfs::FAILING.lock().unwrap() = Some(fixture.replica("B.one")); | |
| 1849 | let b = fixture.library.open("B.one", || {}).unwrap(); | |
| 1850 | let space = b.pages().unwrap()[0].0; | |
| 1851 | let op = crate::rename::retitled(&b.page(space).unwrap(), space, "Waiting".into()); | |
| 1852 | let edit = Edit { | |
| 1853 | at: crate::filetime(), | |
| 1854 | ops: vec![op.unwrap()], | |
| 1855 | }; | |
| 1856 | b.apply(AUTHOR, edit).unwrap(); | |
| 1857 | let replica = Arc::clone(b.replica()); | |
| 1858 | assert!(replica.written().is_err()); | |
| 1859 | pollster::block_on(fixture.site().release("B.one", b)); | |
| 1860 | *vfs::FAILING.lock().unwrap() = None; | |
| 1861 | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); | |
| 1862 | while replica.written().is_err() { | |
| 1863 | assert!( | |
| 1864 | std::time::Instant::now() < deadline, | |
| 1865 | "the edit was never written" | |
| 1866 | ); | |
| 1867 | std::thread::sleep(std::time::Duration::from_millis(50)); | |
| 1766 | 1868 | } |
| 1869 | drop(replica); | |
| 1870 | fixture.library.close_kept(); | |
| 1871 | assert_eq!(fixture.stored("B.one"), titled(&["Waiting"])); | |
| 1767 | 1872 | } |
| 1768 | 1873 | |
| 1769 | 1874 | /// A title given in the page list goes back while the page still has it. |
| ... | ... | @@ -1780,7 +1885,7 @@ mod tests { |
| 1780 | 1885 | .unwrap(); |
| 1781 | 1886 | assert_eq!(fixture.titles(), titled(&["First", "Second"])); |
| 1782 | 1887 | fixture.retitle(page, "Changed elsewhere"); |
| 1783 | assert!(fixture.change(back.undo).is_none()); | |
| 1888 | assert!(fixture.change(back.undo.unwrap()).is_none()); | |
| 1784 | 1889 | } |
| 1785 | 1890 | |
| 1786 | 1891 | /// Carries out `change` as `manage::State::restructure` does. |
| ... | ... | @@ -1886,7 +1991,7 @@ mod tests { |
| 1886 | 1991 | let mut fixture = Fixture::new("export", &["First", "Second", "Third", "Fourth"]); |
| 1887 | 1992 | let back = |fixture: &Fixture, change| { |
| 1888 | 1993 | let done = fixture.change(change).unwrap(); |
| 1889 | fixture.change(done.undo).unwrap() | |
| 1994 | fixture.change(done.undo.unwrap()).unwrap() | |
| 1890 | 1995 | }; |
| 1891 | 1996 | back( |
| 1892 | 1997 | &fixture, |