authorclover caruso <git@paperclover.net> 2026-10-15 08:10:05-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 08:34:53-07:00
log0641110e7a154fd20595e7fd290ef572c8a36235
tree09d6eeab9cf54d9281cbdff1fe9fec290171b022
parentf2e89c094937c86ace1a3078d7d48d6056a5d933
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(live): a burned code's number is not given out again at once

Once a burned code's owner left, the relay forgot the room, so the host's replacement code could draw the same number from the thousand on offer. The old code then led to the new one: a guest typing it was told its code was wrong rather than expired, and spent a try of the new code. A burned room now outlives its members by a minute, during which its number is neither claimed nor joined, and anyone typing the old code hears it expired. The relay test checks this by ending a polled owner, which answers only once it has left, so it needs no waiting. Refs #150 Assisted-by: claude-opus-5.5

3 files changed, 85 insertions(+), 16 deletions(-)

crates/notebook/tests/live_share.rs+3-4
......@@ -424,11 +424,10 @@ fn wrong_codes_are_refused_and_counted() {
424424 .is_some_and(|now| now != code && share::code(&now).is_some())
425425 });
426426 let fresh = host.code().unwrap();
427 assert!(matches!(
428 join(&code).unwrap_err(),
429 Refusal::Expired | Refusal::NoOne
430 ));
427 assert_eq!(join(&code).unwrap_err(), Refusal::Expired);
428 let burned = number;
431429 let (number, secret) = notebook::live::code::parse(&fresh).unwrap();
430 assert_ne!(number, burned);
432431 let wrong = notebook::live::code::format(number, &mistaken(&secret)).unwrap();
433432 assert_eq!(join(&wrong).unwrap_err(), Refusal::Wrong);
434433 // A third wrong code in a minute locks this network out, even from the right code.
crates/relay/src/server.rs+26-12
......@@ -151,7 +151,9 @@ struct Room {
151151 joins: Bucket,
152152 bytes: Bucket,
153153 failures: u32,
154 burned: bool,
154 /// When wrong codes burned it, then when it emptied: its number stays out of use for a
155 /// minute after, so an old code never leads to its owner's next one.
156 burned: Option<Instant>,
155157}
156158
157159struct Member {
......@@ -429,7 +431,7 @@ impl Relay {
429431 let free = |number: u32| {
430432 rooms
431433 .get(&code(number))
432 .is_none_or(|room| room.owner.is_none() && !room.burned)
434 .is_none_or(|room| room.owner.is_none() && room.burned.is_none())
433435 };
434436 let number = back
435437 .filter(|number| NAMEPLATES.contains(number) && free(*number))
......@@ -447,13 +449,13 @@ impl Relay {
447449 (code(number), Some(number))
448450 }
449451 Ask::Room(tag) if tag.starts_with("code-") => {
450 let room = rooms
451 .get(&tag)
452 .filter(|room| room.owner.is_some())
453 .ok_or(Refusal::NotFound)?;
454 if room.burned {
452 let room = rooms.get(&tag).ok_or(Refusal::NotFound)?;
453 if room.burned.is_some() {
455454 return Err(Refusal::Gone);
456455 }
456 if room.owner.is_none() {
457 return Err(Refusal::NotFound);
458 }
457459 if client.failures.len() + client.pending >= config.failures_per_minute as usize {
458460 let wait = client.failures.front().map_or(config.pending, |first| {
459461 (*first + MINUTE).saturating_duration_since(now)
......@@ -474,7 +476,7 @@ impl Relay {
474476 joins: Bucket::full(f64::from(config.room_joins_per_minute), now),
475477 bytes: Bucket::full(config.room_bytes_per_second as f64, now),
476478 failures: 0,
477 burned: false,
479 burned: None,
478480 });
479481 let per_minute = f64::from(config.room_joins_per_minute);
480482 let admitted = room.members.len() < config.max_room_peers;
......@@ -649,7 +651,8 @@ impl Relay {
649651 }
650652 }
651653
652 /// Fails the peers that waited too long to meet a code's owner, and forgets quiet addresses.
654 /// Fails the peers that waited too long to meet a code's owner, and forgets burned rooms
655 /// empty a minute and quiet addresses.
653656 fn sweep(&self, now: Instant) {
654657 let config = &self.config;
655658 let mut state = self.state.lock().unwrap();
......@@ -676,6 +679,12 @@ impl Relay {
676679 depart(&mut state, config, &poll.tag, poll.slot, now);
677680 }
678681 }
682 state.rooms.retain(|_, room| {
683 !room.members.is_empty()
684 || room
685 .burned
686 .is_some_and(|since| now.saturating_duration_since(since) < MINUTE)
687 });
679688 state.addresses.retain(|_, address| {
680689 address.refresh(now);
681690 !address.quiet(config, now)
......@@ -714,8 +723,8 @@ fn depart(state: &mut State, config: &Config, tag: &str, slot: u32, now: Instant
714723 }
715724 if member.pending.is_some() {
716725 room.failures += 1;
717 if room.failures >= config.burn_after && !room.burned {
718 room.burned = true;
726 if room.failures >= config.burn_after && room.burned.is_none() {
727 room.burned = Some(now);
719728 eprintln!("{tag}: burned after {} wrong codes", room.failures);
720729 if let Some(owner) = room.owner.and_then(|owner| room.members.get(&owner)) {
721730 owner.outbox.push(notice(Notice::Burned));
......@@ -734,7 +743,12 @@ fn depart(state: &mut State, config: &Config, tag: &str, slot: u32, now: Instant
734743 }
735744 }
736745 if room.members.is_empty() {
737 rooms.remove(tag);
746 match &mut room.burned {
747 Some(since) => *since = now,
748 None => {
749 rooms.remove(tag);
750 }
751 }
738752 }
739753}
740754
crates/relay/tests/relay.rs+56
......@@ -40,6 +40,27 @@ impl Relay {
4040 response
4141 }
4242
43 /// Asks `method` on `path` with `body` on a connection of its own: the status line and
44 /// the answer.
45 fn ask(&self, method: &str, path: &str, body: &[u8]) -> (String, Vec<u8>) {
46 let mut stream = TcpStream::connect(self.address).unwrap();
47 write!(
48 stream,
49 "{method} {path} HTTP/1.1\r\nHost: relay\r\nContent-Length: {}\r\n\r\n",
50 body.len()
51 )
52 .unwrap();
53 stream.write_all(body).unwrap();
54 let head = ws::head(&mut stream).unwrap();
55 let length = ws::header(&head, "Content-Length")
56 .unwrap()
57 .parse()
58 .unwrap();
59 let mut answer = vec![0; length];
60 stream.read_exact(&mut answer).unwrap();
61 (head.lines().next().unwrap().to_owned(), answer)
62 }
63
4364 fn join(&self, path: &str) -> Result<Peer, String> {
4465 self.join_from(path, None)
4566 }
......@@ -299,6 +320,41 @@ fn wrong_codes_burn_a_code() {
299320 assert_eq!(status(relay.join(&path)), "HTTP/1.1 410 Gone");
300321}
301322
323/// A burned code's number stays out of use once its owner leaves, so the old code can't lead
324/// to the owner's next one.
325#[test]
326fn a_burned_number_is_not_given_out_again() {
327 let relay = Relay::start(&["--burn-after", "1"]);
328 // A polled owner, whose leaving is answered once it has left.
329 let (_, session) = relay.ask("GET", "/v1/claim?poll=1", b"");
330 let session = String::from_utf8(session).unwrap();
331 let session = format!("/v1/poll/{}", session.strip_prefix("session ").unwrap());
332 let say = |opcode: u8, text: &str| {
333 let frame = ws::frame(opcode, text.as_bytes(), Some([1, 2, 3, 4]));
334 relay.ask("POST", &session, &frame).0
335 };
336 let (_, heard) = relay.ask("GET", &session, b"");
337 let Ok(Message::Text(nameplate)) = ws::Reader::new(&heard[..], 1 << 20, false).read() else {
338 panic!("heard {heard:?}");
339 };
340 let number: u32 = nameplate
341 .strip_prefix("nameplate ")
342 .unwrap()
343 .parse()
344 .unwrap();
345 let path = format!("/v1/room/code-{number}");
346 let mut guess = relay.join(&path).unwrap();
347 assert_eq!(guess.text(), "welcome 2 1");
348 assert_eq!(say(ws::TEXT, "failed 2"), "HTTP/1.1 200 OK");
349 assert!(guess.closed(Duration::from_secs(5)));
350 assert_eq!(say(ws::CLOSE, ""), "HTTP/1.1 410 Gone");
351 let mut next = relay
352 .join(&format!("/v1/claim?nameplate={number}"))
353 .unwrap();
354 assert_ne!(next.text(), format!("nameplate {number}"));
355 assert_eq!(status(relay.join(&path)), "HTTP/1.1 410 Gone");
356}
357
302358/// An owner coming back asks for its code's number again, and gets it while no one else
303359/// has claimed it.
304360#[test]