From 8c7f3dfef48f3d2050ce7f0e31b8a1475daf0bce Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 5 Oct 2026 22:30:12 -0700 Subject: [PATCH] fix: reject missing sections before allocating Live Share writers Validate a new section path before creating its writer. Authenticated invalid requests allocate no retained threads, and later creation and duplicate delivery still work. fixes #99 Assisted-by: gpt-6.1-sol --- crates/notebook/src/live/share/batch.rs | 118 ++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/crates/notebook/src/live/share/batch.rs b/crates/notebook/src/live/share/batch.rs index 9304ac807248712fe0a6bf0f0d0468f485c7ed72..c7571f9595cfccb882ced3aad7a9b030e92749e4 100644 --- a/crates/notebook/src/live/share/batch.rs +++ b/crates/notebook/src/live/share/batch.rs @@ -57,6 +57,9 @@ fn retry(message: &str) -> Error { impl Served { pub(super) fn batch(self: &Arc, peer: &[u8; 16], request: Request) -> Result { let path = request.path.clone(); + if !self.writers.lock().unwrap().contains_key(&path) { + self.image(&path)?; + } let mut writers = self.writers.lock().unwrap(); let writer = writers.entry(path.clone()).or_insert_with(|| { let (send, receive) = mpsc::sync_channel::(64); @@ -302,6 +305,121 @@ mod tests { page::{Attachment, PageObject}, }; + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn missing_sections_do_not_retain_writers_and_can_be_created_later() { + let directory = tempfile::tempdir().unwrap(); + let folder = directory.path().join("notebook"); + std::fs::create_dir(&folder).unwrap(); + let served = Arc::new(Served { + storage: crate::session::Notebook::open(&folder, directory.path().join("cache")) + .unwrap() + .into_storage(), + images: Mutex::default(), + snapshots: Mutex::default(), + puts: Mutex::default(), + guests: Mutex::default(), + writers: Mutex::default(), + host: Mutex::default(), + room: Mutex::default(), + }); + let peer = [1; 16]; + let (line, _outbound) = mpsc::channel(); + let (queue, _requests) = mpsc::sync_channel(1); + served.guests.lock().unwrap().insert( + peer, + Admitted { + line: crate::live::Line(line), + queue, + starts: BURST, + counted: Instant::now(), + held: Vec::new(), + }, + ); + for at in 0..128 { + let reply = served.handle( + &peer, + kind::EDITS, + &minicbor::to_vec(Request { + path: format!("Missing{at}.one"), + ..Request::default() + }) + .unwrap(), + ); + assert!(reply.failure.is_some()); + } + assert!(served.writers.lock().unwrap().is_empty()); + + let path = "Missing0.one"; + let image = onestore::create_section(path, "Original text", "Fixture").unwrap(); + std::fs::write(folder.join(path), &image).unwrap(); + let store = Store::parse(&image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = onestore::document::Document::parse(&index).unwrap(); + let (space, text) = document + .spaces + .iter() + .find_map(|(space, object)| { + object.revisions[&object.contexts[&ExGuid::default()]] + .nodes + .iter() + .find_map(|(id, node)| { + matches!(node.kind, onestore::document::Kind::RichText { .. }) + .then_some((*space, *id)) + }) + }) + .unwrap(); + let edit = Edit { + at: 134_000_000_000_000_000, + ops: vec![Op::Page { + space, + op: PageOp::Text { + text, + range: 13..13, + with: "X".into(), + }, + }], + }; + let arena = Arena::default(); + let mut section = Section::open(&arena, image).unwrap(); + section.apply("Alice", &edit).unwrap(); + let transaction = section.seal().unwrap().unwrap(); + let request = Request { + path: path.into(), + stamp: Some(transaction.base().into()), + bytes: Some( + encode( + &Edits { + edits: vec![("Alice".into(), edit)], + revisions: section.newest().filter(|(at, _)| *at == space).collect(), + }, + LIMIT, + ) + .unwrap(), + ), + ..Request::default() + }; + let first = served.batch(&peer, request.clone()).unwrap(); + assert!(first.failure.is_none(), "{:?}", first.failure); + let committed = std::fs::read(folder.join(path)).unwrap(); + let stamp = Stamp::of(&committed).unwrap(); + assert_eq!( + Stamp::try_from(first.stamp.as_ref().unwrap()).unwrap(), + stamp + ); + let read_arena = Arena::default(); + let reread = Section::open(&read_arena, committed.clone()).unwrap(); + assert_eq!(reread.page(space).unwrap(), section.page(space).unwrap()); + let repeated = served.batch(&peer, request).unwrap(); + assert!(repeated.failure.is_none(), "{:?}", repeated.failure); + let replayed = std::fs::read(folder.join(path)).unwrap(); + assert_eq!(&replayed[1024..], &committed[1024..]); + let replay_arena = Arena::default(); + let replay = Section::open(&replay_arena, replayed).unwrap(); + assert_eq!(replay.page(space).unwrap(), section.page(space).unwrap()); + assert_eq!(served.writers.lock().unwrap().len(), 1); + } + #[test] fn attachment_encoding_stops_at_the_upload_budget() { let id = ExGuid { -- 2.54.0