authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 21:11:21-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 21:38:18-07:00
logc574cadb7f1d18457fb810169f3ec9cd230b9be6
treee6d31451fbcbeebcd0f85a9aa9c9b24ad6461883
parentd8e439ce5e7a3acbcc02653af384400b9a5052de
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: Stop Sharing ends a Live Share instead of starting it again

Stop let the host go but left the share among those kept for the next launch, so the next frame took it up again with the same code and secret, and its guests came straight back. Stopping now forgets it there and on disk, and Host::stop no longer needs the last handle to the host, so every guest hears the share stopped even while the dialog still shows it. Sharing again makes a new code and secret. Assisted-by: claude-opus-5.5

4 files changed, 103 insertions(+), 18 deletions(-)

crates/notebook/src/live/share.rs+22-13
......@@ -215,8 +215,9 @@ pub struct Host {
215215 reach: Option<Reach>,
216216 relay: Option<String>,
217217 sharing: Mutex<Sharing>,
218 room: Live,
219 pairing: Mutex<Live>,
218 /// The share's room and the code's, until it stops.
219 room: Mutex<Option<Live>>,
220 pairing: Mutex<Option<Live>>,
220221 served: Arc<Served>,
221222 events: Arc<dyn Fn() + Send + Sync>,
222223}
......@@ -269,20 +270,21 @@ impl Host {
269270 notebook: notebook.to_owned(),
270271 reach,
271272 relay: relay.map(str::to_owned),
272 pairing: Mutex::new(pair(&me, &sharing, notebook, reach, relay, &events)?),
273 pairing: Mutex::new(Some(pair(&me, &sharing, notebook, reach, relay, &events)?)),
273274 me,
274275 sharing: Mutex::new(sharing),
275 room,
276 room: Mutex::new(Some(room)),
276277 served,
277278 events,
278279 };
279280 Ok(host)
280281 }
281282
282 /// The code guests type, once it has its number. A code with too many wrong tries is
283 /// replaced by one with new words.
283 /// The code guests type, once it has its number, and none once stopped. A code with too
284 /// many wrong tries is replaced by one with new words.
284285 pub fn code(&self) -> Option<String> {
285286 let mut pairing = self.pairing.lock().unwrap();
287 let pairing = pairing.as_mut()?;
286288 if pairing.burned() {
287289 let mut sharing = self.sharing.lock().unwrap();
288290 sharing.code = words().ok()?;
......@@ -312,16 +314,20 @@ impl Host {
312314
313315 /// How the relay last answered the code's room.
314316 pub fn relayed(&self) -> Relayed {
315 self.pairing.lock().unwrap().relayed()
317 let pairing = self.pairing.lock().unwrap();
318 pairing.as_ref().map_or(Relayed::Unknown, Live::relayed)
316319 }
317320
318321 /// The peers in the share's room.
319322 pub fn guests(&self) -> Vec<Peer> {
320 self.room.peers()
323 let room = self.room.lock().unwrap();
324 room.as_ref().map(Live::peers).unwrap_or_default()
321325 }
322326
323327 pub fn set_presence(&self, presence: Presence) {
324 self.room.set_presence(presence);
328 if let Some(room) = &*self.room.lock().unwrap() {
329 room.set_presence(presence);
330 }
325331 }
326332
327333 /// Tells every guest the files at these catalog paths changed.
......@@ -329,10 +335,13 @@ impl Host {
329335 self.served.tell(paths);
330336 }
331337
332 /// Stops sharing: every guest hears so and is let go.
333 pub fn stop(self) {
334 drop(self.pairing);
335 self.room.leave(STOPPED);
338 /// Stops sharing: no one new is welcomed, and every guest hears so and is let go.
339 pub fn stop(&self) {
340 drop(self.pairing.lock().unwrap().take());
341 let room = self.room.lock().unwrap().take();
342 if let Some(room) = room {
343 room.leave(STOPPED);
344 }
336345 }
337346}
338347
crates/notebook/tests/live_share.rs+25
......@@ -506,3 +506,28 @@ fn a_flooding_guest_is_hung_up_on() {
506506 std::fs::read(folder.join("Garden.one")).unwrap()
507507 );
508508}
509
510/// Stopping a share lets every guest go, saying so, and leaves its code and secret for no one:
511/// the code no longer opens anything, and sharing again makes new ones.
512#[test]
513fn stopping_lets_every_guest_go_and_retires_the_code() {
514 let directory = tempfile::tempdir().unwrap();
515 let folder = notebook(directory.path());
516 let url = relay(Default::default());
517 let sharing = Sharing::new("").unwrap();
518 let host = host(&folder, &directory.path().join("host"), &sharing, &url);
519 let code = code(&host);
520 let (guest, _notebook) = guest("Grace", &code, &url, &directory.path().join("grace"));
521 host.stop();
522 until("the guest never heard the host stop", || {
523 guest.stopped() && guest.host().is_none()
524 });
525 assert!(host.code().is_none() && host.guests().is_empty());
526 assert!(matches!(
527 share::join(hello("Alan"), &code, "", None, Some(&url)).unwrap_err(),
528 Refusal::NoOne | Refusal::TimedOut
529 ));
530 let again = Sharing::new("").unwrap();
531 assert!(again.secret != sharing.secret && again.share != sharing.share);
532 assert_ne!(again.code, sharing.code);
533}
crates/snowbound/src/live.rs+8-5
......@@ -474,11 +474,14 @@ impl State {
474474 background.on_touched(None);
475475 }
476476 if let Some(host) = self.peers.hosts.remove(location) {
477 crate::spawn(move || {
478 if let Some(host) = Arc::into_inner(host) {
479 host.stop();
480 }
481 });
477 crate::spawn(move || host.stop());
478 }
479 // Kept, the share would start again on the next frame, as after a relaunch.
480 if let Some(sharing) = &mut self.peers.sharing
481 && sharing.remove(location).is_some()
482 && let Err(error) = keep(&kept(&self.cache, HOSTING), sharing)
483 {
484 eprintln!("Keeping what this computer shares: {error}");
482485 }
483486 }
484487
crates/snowbound/tests/replay.rs+48
......@@ -33,6 +33,16 @@ impl Drop for Scratch {
3333/// returns the trees each
3434/// `accessibility` step named by the trailing word wrote.
3535fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<String> {
36 replay_with(scratch, notebook, steps, &[])
37}
38
39/// `replay` with these variables set for the app.
40fn replay_with(
41 scratch: &Scratch,
42 notebook: Option<&Path>,
43 steps: &[&str],
44 variables: &[(&str, &str)],
45) -> Vec<String> {
3646 let dir = &scratch.0;
3747 if let Some(source) = notebook {
3848 for entry in std::fs::read_dir(source).unwrap() {
......@@ -59,6 +69,7 @@ fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<Str
5969 command.env("SNOWBOUND_ICLOUD_FOLDER", dir.join("icloud"));
6070 }
6171 command
72 .envs(variables.iter().copied())
6273 .env("HOME", dir.join("home"))
6374 .env("SNOWBOUND_REPLAY", dir.join("script"))
6475 .arg("--notebook")
......@@ -501,3 +512,40 @@ fn a_replay_opens_only_the_notebooks_and_icloud_folder_it_is_given() {
501512 listed.sort_unstable();
502513 assert_eq!(listed, ["Cloudy", "notebook"], "{tree}");
503514}
515
516/// Stop Sharing ends the share: Live Share offers Start Sharing again, and nothing is kept to
517/// share the notebook again on the next launch.
518#[cfg(feature = "live")]
519#[test]
520fn stop_sharing_ends_the_share() {
521 let scratch = Scratch::new("stop-sharing");
522 let notebook =
523 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
524 let mut steps = vec!["modifiers command shift", "key p", "modifiers", "settle"];
525 steps.extend(["type Live Share", "settle", "key Enter", "settle"]);
526 steps.extend(["key Enter", "wait 1500", "accessibility shared"]);
527 // Copy, then Stop Sharing.
528 steps.extend([
529 "key Tab",
530 "key Tab",
531 "key Enter",
532 "wait 1500",
533 "accessibility stopped",
534 ]);
535 // Off every network, so that the test reaches no one.
536 let variables = [("SNOWBOUND_LIVE", "off"), ("SNOWBOUND_LIVE_RELAY", "off")];
537 let [shared, stopped] = replay_with(&scratch, Some(&notebook), &steps, &variables)
538 .try_into()
539 .unwrap();
540 assert!(
541 shared.contains("Stop Sharing") && shared.contains("No one has joined yet."),
542 "{shared}"
543 );
544 assert!(
545 stopped.contains("Start Sharing") && !stopped.contains("Stop Sharing"),
546 "{stopped}"
547 );
548 let kept = scratch.0.join("cache/live/hosting.json");
549 let kept = std::fs::read_to_string(kept).unwrap_or_default();
550 assert!(!kept.contains("code"), "still kept: {kept}");
551}