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 {...@@ -215,8 +215,9 @@ pub struct Host {
215 reach: Option<Reach>,215 reach: Option<Reach>,
216 relay: Option<String>,216 relay: Option<String>,
217 sharing: Mutex<Sharing>,217 sharing: Mutex<Sharing>,
218 room: Live,218 /// The share's room and the code's, until it stops.
219 pairing: Mutex<Live>,219 room: Mutex<Option<Live>>,
220 pairing: Mutex<Option<Live>>,
220 served: Arc<Served>,221 served: Arc<Served>,
221 events: Arc<dyn Fn() + Send + Sync>,222 events: Arc<dyn Fn() + Send + Sync>,
222}223}
...@@ -269,20 +270,21 @@ impl Host {...@@ -269,20 +270,21 @@ impl Host {
269 notebook: notebook.to_owned(),270 notebook: notebook.to_owned(),
270 reach,271 reach,
271 relay: relay.map(str::to_owned),272 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)?)),
273 me,274 me,
274 sharing: Mutex::new(sharing),275 sharing: Mutex::new(sharing),
275 room,276 room: Mutex::new(Some(room)),
276 served,277 served,
277 events,278 events,
278 };279 };
279 Ok(host)280 Ok(host)
280 }281 }
281282
282 /// The code guests type, once it has its number. A code with too many wrong tries is283 /// The code guests type, once it has its number, and none once stopped. A code with too
283 /// replaced by one with new words.284 /// many wrong tries is replaced by one with new words.
284 pub fn code(&self) -> Option<String> {285 pub fn code(&self) -> Option<String> {
285 let mut pairing = self.pairing.lock().unwrap();286 let mut pairing = self.pairing.lock().unwrap();
287 let pairing = pairing.as_mut()?;
286 if pairing.burned() {288 if pairing.burned() {
287 let mut sharing = self.sharing.lock().unwrap();289 let mut sharing = self.sharing.lock().unwrap();
288 sharing.code = words().ok()?;290 sharing.code = words().ok()?;
...@@ -312,16 +314,20 @@ impl Host {...@@ -312,16 +314,20 @@ impl Host {
312314
313 /// How the relay last answered the code's room.315 /// How the relay last answered the code's room.
314 pub fn relayed(&self) -> Relayed {316 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)
316 }319 }
317320
318 /// The peers in the share's room.321 /// The peers in the share's room.
319 pub fn guests(&self) -> Vec<Peer> {322 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()
321 }325 }
322326
323 pub fn set_presence(&self, presence: Presence) {327 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 }
325 }331 }
326332
327 /// Tells every guest the files at these catalog paths changed.333 /// Tells every guest the files at these catalog paths changed.
...@@ -329,10 +335,13 @@ impl Host {...@@ -329,10 +335,13 @@ impl Host {
329 self.served.tell(paths);335 self.served.tell(paths);
330 }336 }
331337
332 /// Stops sharing: every guest hears so and is let go.338 /// Stops sharing: no one new is welcomed, and every guest hears so and is let go.
333 pub fn stop(self) {339 pub fn stop(&self) {
334 drop(self.pairing);340 drop(self.pairing.lock().unwrap().take());
335 self.room.leave(STOPPED);341 let room = self.room.lock().unwrap().take();
342 if let Some(room) = room {
343 room.leave(STOPPED);
344 }
336 }345 }
337}346}
338347
crates/notebook/tests/live_share.rs+25
...@@ -506,3 +506,28 @@ fn a_flooding_guest_is_hung_up_on() {...@@ -506,3 +506,28 @@ fn a_flooding_guest_is_hung_up_on() {
506 std::fs::read(folder.join("Garden.one")).unwrap()506 std::fs::read(folder.join("Garden.one")).unwrap()
507 );507 );
508}508}
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 {...@@ -474,11 +474,14 @@ impl State {
474 background.on_touched(None);474 background.on_touched(None);
475 }475 }
476 if let Some(host) = self.peers.hosts.remove(location) {476 if let Some(host) = self.peers.hosts.remove(location) {
477 crate::spawn(move || {477 crate::spawn(move || host.stop());
478 if let Some(host) = Arc::into_inner(host) {478 }
479 host.stop();479 // Kept, the share would start again on the next frame, as after a relaunch.
480 }480 if let Some(sharing) = &mut self.peers.sharing
481 });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}");
482 }485 }
483 }486 }
484487
crates/snowbound/tests/replay.rs+48
...@@ -33,6 +33,16 @@ impl Drop for Scratch {...@@ -33,6 +33,16 @@ impl Drop for Scratch {
33/// returns the trees each33/// returns the trees each
34/// `accessibility` step named by the trailing word wrote.34/// `accessibility` step named by the trailing word wrote.
35fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<String> {35fn 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> {
36 let dir = &scratch.0;46 let dir = &scratch.0;
37 if let Some(source) = notebook {47 if let Some(source) = notebook {
38 for entry in std::fs::read_dir(source).unwrap() {48 for entry in std::fs::read_dir(source).unwrap() {
...@@ -59,6 +69,7 @@ fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<Str...@@ -59,6 +69,7 @@ fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<Str
59 command.env("SNOWBOUND_ICLOUD_FOLDER", dir.join("icloud"));69 command.env("SNOWBOUND_ICLOUD_FOLDER", dir.join("icloud"));
60 }70 }
61 command71 command
72 .envs(variables.iter().copied())
62 .env("HOME", dir.join("home"))73 .env("HOME", dir.join("home"))
63 .env("SNOWBOUND_REPLAY", dir.join("script"))74 .env("SNOWBOUND_REPLAY", dir.join("script"))
64 .arg("--notebook")75 .arg("--notebook")
...@@ -501,3 +512,40 @@ fn a_replay_opens_only_the_notebooks_and_icloud_folder_it_is_given() {...@@ -501,3 +512,40 @@ fn a_replay_opens_only_the_notebooks_and_icloud_folder_it_is_given() {
501 listed.sort_unstable();512 listed.sort_unstable();
502 assert_eq!(listed, ["Cloudy", "notebook"], "{tree}");513 assert_eq!(listed, ["Cloudy", "notebook"], "{tree}");
503}514}
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}