plaza_lobby 0.7.3

Single-server lobby and room management for the Plaza real-time shared-state framework.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
//! One-use tickets, so a room learns who connected without asking them.
//!
//! Fills [`JoinRoomOutcomePayload::player_game_token`](crate::op_payloads::JoinRoomOutcomePayload),
//! which otherwise has nothing to put in it. The lobby already knows who it just
//! admitted; without carrying that across, a room's only source for the
//! connecting player's identity is the client, and a client that can name its
//! own id can name somebody else's.
//!
//! ```ignore
//! // In the lobby, after a successful admission:
//! let token = tickets.issue(player, room_id);
//! let endpoint = format!("{}?t={token}", handle.session_endpoint_info());
//!
//! // In the room's connect route:
//! let Some(ticket) = tickets.redeem(&token, &room_id) else { return unauthorized() };
//! session.handle_connection(&req, stream, Agent::new_human(ticket.player))
//! ```
//!
//! # Which store
//!
//! [`TicketStore`] is the trait. Two implementations ship; they make the same
//! guarantees and differ in what drives expiry:
//!
//! - [`MapTicketRegistry`] is a `HashMap` behind a mutex, with no dependency
//!   beyond what the crate already has. Expiry is swept from `issue`, which is
//!   the operation that grows the map.
//! - [`CachedTicketRegistry`] (feature `cache`) is `fibre_cache`, whose janitor
//!   sweeps on its own and whose shards replace the single mutex. Off by
//!   default, so nothing downstream pays for it unless it enables the feature.
//!
//! A room in another process needs a third: verify a signed token and build
//! the [`Ticket`] from its claims, storing nothing. That case is why this is a
//! trait rather than a mode of either type here.
//!
//! # Tickets and authentication
//!
//! [`issue`](TicketStore::issue) mints a counter, which is guessable in one try.
//! It stops a client *naming* another player, which is what it is for, but it
//! is not a credential. Anything facing untrusted clients
//! should mint its own signed, expiring value and hand it to
//! [`issue_with`](TicketStore::issue_with); no implementation here cares what
//! the string is.
//!
//! Plaza verifies no credential itself: a room checks one in a
//! `plaza_session` `ConnectionAdmitter`. So this crate provides the
//! bookkeeping and leaves the secret to the application.
//!
//! # Ticket and reservation expiry
//!
//! A ticket outliving its [`SeatReservations`](crate::reservations) entry lands
//! a placed player as a spectator with a spent ticket and the reverse orphans a
//! seat. Redemption is two steps in two places, the route spending the ticket
//! and the room's logic consuming the reservation, so a window here must be
//! **shorter** than the reservation's by at least the time a session takes to
//! come up.

use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};

use parking_lot::Mutex;
use plaza::agent::AgentId;

use crate::types::RoomId;

/// What one ticket entitles its bearer to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ticket<ID: AgentId> {
  pub player: ID,
  pub room: RoomId,
}

/// Outstanding tickets: where the lobby records an admission and the room's
/// route reads it back.
///
/// Object-safe, so a route can hold `Arc<dyn TicketStore<ID>>` and be written
/// once against whichever store a deployment picked.
pub trait TicketStore<ID: AgentId>: Send + Sync {
  /// Mints a ticket with a generated token. See the module docs: the token is a
  /// counter and is not secret.
  fn issue(&self, player: ID, room: RoomId) -> String;

  /// Records a ticket under a token you minted, for anything that needs a real
  /// credential. Replaces any existing entry for that token.
  fn issue_with(&self, token: String, player: ID, room: RoomId);

  /// Spends a ticket for `room`, or `None` if it was never issued, has been
  /// used, has expired or was issued for somewhere else.
  ///
  /// One use, so a token that leaks cannot be replayed into a second connection
  /// alongside the one that already holds it.
  ///
  /// **The room is checked before the ticket is spent.** Spending
  /// first and comparing afterwards burns a ticket that the room had no claim
  /// on, so under a guessable token anyone could destroy anyone's placement by
  /// presenting it at the wrong door.
  fn redeem(&self, token: &str, room: &RoomId) -> Option<Ticket<ID>>;

  /// Drops a ticket without spending it, for an admission the lobby has since
  /// cancelled.
  fn revoke(&self, token: &str) -> bool;

  /// Tickets handed out and not yet dialled or expired. A count that keeps
  /// climbing means placements are being issued and abandoned.
  ///
  /// A diagnostic rather than a hot path: both shipped implementations walk
  /// their contents to answer it.
  fn outstanding(&self) -> usize;
}

struct Held<ID: AgentId> {
  ticket: Ticket<ID>,
  issued_at: Instant,
}

struct Inner<ID: AgentId> {
  held: HashMap<String, Held<ID>>,
  last_swept: Instant,
}

/// A [`TicketStore`] over a `HashMap`, with expiry the caller opts into.
///
/// Internally synchronised rather than `&mut`, because the lobby that issues and
/// the route that redeems are different callers.
///
/// Without [`with_expiry`](MapTicketRegistry::with_expiry) a ticket is held
/// until the process ends, which is unbounded growth under a client that places
/// and never dials.
#[derive(Debug)]
pub struct MapTicketRegistry<ID: AgentId> {
  inner: Mutex<Inner<ID>>,
  next: AtomicU64,
  window: Option<Duration>,
}

impl<ID: AgentId> std::fmt::Debug for Inner<ID> {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    f.debug_struct("Inner").field("held", &self.held.len()).finish()
  }
}

impl<ID: AgentId> std::fmt::Debug for Held<ID> {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    f.debug_struct("Held").field("ticket", &self.ticket).finish()
  }
}

impl<ID: AgentId> Default for MapTicketRegistry<ID> {
  fn default() -> Self {
    Self::new()
  }
}

impl<ID: AgentId> MapTicketRegistry<ID> {
  /// A registry that never expires anything.
  pub fn new() -> Self {
    Self::build(None)
  }

  /// A registry that refuses a ticket older than `window` and drops it.
  ///
  /// Sweeping happens on [`issue`](TicketStore::issue), and at most once per
  /// `window`, so the map holds at most one window's worth of dead tickets
  /// however fast placements arrive. Nothing is spawned and nothing ticks.
  pub fn with_expiry(window: Duration) -> Self {
    Self::build(Some(window))
  }

  fn build(window: Option<Duration>) -> Self {
    Self {
      inner: Mutex::new(Inner {
        held: HashMap::new(),
        last_swept: Instant::now(),
      }),
      next: AtomicU64::new(0),
      window,
    }
  }

  /// The window a ticket stays valid for, or `None` if it never expires.
  pub fn expiry(&self) -> Option<Duration> {
    self.window
  }

  fn lapsed(&self, issued_at: Instant, now: Instant) -> bool {
    self.window.is_some_and(|w| now.duration_since(issued_at) >= w)
  }

  fn issue_with_at(&self, token: String, player: ID, room: RoomId, now: Instant) {
    let mut inner = self.inner.lock();
    if let Some(window) = self.window
      && now.duration_since(inner.last_swept) >= window
    {
      inner.held.retain(|_, held| now.duration_since(held.issued_at) < window);
      inner.last_swept = now;
    }
    inner.held.insert(
      token,
      Held {
        ticket: Ticket { player, room },
        issued_at: now,
      },
    );
  }

  fn redeem_at(&self, token: &str, room: &RoomId, now: Instant) -> Option<Ticket<ID>> {
    let mut inner = self.inner.lock();
    if inner.held.get(token).is_none_or(|held| held.ticket.room != *room) {
      return None;
    }
    let held = inner.held.remove(token)?;
    (!self.lapsed(held.issued_at, now)).then_some(held.ticket)
  }

  fn outstanding_at(&self, now: Instant) -> usize {
    self
      .inner
      .lock()
      .held
      .values()
      .filter(|held| !self.lapsed(held.issued_at, now))
      .count()
  }
}

impl<ID: AgentId> TicketStore<ID> for MapTicketRegistry<ID> {
  fn issue(&self, player: ID, room: RoomId) -> String {
    let token = format!("t{}", self.next.fetch_add(1, Ordering::Relaxed));
    self.issue_with(token.clone(), player, room);
    token
  }

  fn issue_with(&self, token: String, player: ID, room: RoomId) {
    self.issue_with_at(token, player, room, Instant::now());
  }

  fn redeem(&self, token: &str, room: &RoomId) -> Option<Ticket<ID>> {
    self.redeem_at(token, room, Instant::now())
  }

  fn revoke(&self, token: &str) -> bool {
    self.inner.lock().held.remove(token).is_some()
  }

  fn outstanding(&self) -> usize {
    self.outstanding_at(Instant::now())
  }
}

#[cfg(feature = "cache")]
mod cached {
  use std::sync::Arc;

  use super::*;
  use fibre_cache::{Cache, CacheBuilder};

  /// A [`TicketStore`] over `fibre_cache`, expiring on a TTL its janitor
  /// enforces.
  ///
  /// Reads judge expiry on a clock the janitor refreshes once per tick, 1 s by
  /// default, so a ticket can still redeem for up to one tick past its window.
  ///
  /// No capacity is set, so nothing is ever evicted for pressure and a ticket
  /// leaves only by being spent, revoked, or reaching its TTL. The shards
  /// replace [`MapTicketRegistry`]'s single mutex, which the lobby and every
  /// room route on a host otherwise share.
  pub struct CachedTicketRegistry<ID: AgentId> {
    held: Cache<String, Ticket<ID>>,
    next: AtomicU64,
  }

  impl<ID: AgentId> std::fmt::Debug for CachedTicketRegistry<ID> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
      f.debug_struct("CachedTicketRegistry").finish_non_exhaustive()
    }
  }

  impl<ID: AgentId> CachedTicketRegistry<ID> {
    /// A registry whose tickets expire `window` after they are issued.
    pub fn with_expiry(window: Duration) -> Self {
      Self {
        held: CacheBuilder::default()
          .time_to_live(window)
          .build()
          .expect("an unbounded cache with a ttl and no loader is always buildable"),
        next: AtomicU64::new(0),
      }
    }

    /// Runs the expiry pass the janitor would otherwise run on its own
    /// schedule.
    ///
    /// It removes what the janitor's clock already counts as expired and does
    /// not shorten the one-tick bound on reads.
    pub fn run_maintenance(&self) {
      self.held.run_maintenance();
    }
  }

  impl<ID: AgentId> TicketStore<ID> for CachedTicketRegistry<ID> {
    fn issue(&self, player: ID, room: RoomId) -> String {
      let token = format!("t{}", self.next.fetch_add(1, Ordering::Relaxed));
      self.issue_with(token.clone(), player, room);
      token
    }

    fn issue_with(&self, token: String, player: ID, room: RoomId) {
      self.held.insert(token, Ticket { player, room }, 1);
    }

    fn redeem(&self, token: &str, room: &RoomId) -> Option<Ticket<ID>> {
      // `fetch` filters expired entries and `remove` does not, so an entry the
      // janitor has not reached yet would otherwise still redeem.
      if self.held.fetch(token)?.room != *room {
        return None;
      }
      let spent: Arc<Ticket<ID>> = self.held.remove(token)?;
      Some(Arc::try_unwrap(spent).unwrap_or_else(|shared| (*shared).clone()))
    }

    fn revoke(&self, token: &str) -> bool {
      self.held.invalidate(token)
    }

    fn outstanding(&self) -> usize {
      self.held.iter().count()
    }
  }
}

#[cfg(feature = "cache")]
pub use cached::CachedTicketRegistry;

#[cfg(test)]
mod tests {
  use super::*;
  use uuid::Uuid;

  #[test]
  fn a_ticket_resolves_to_who_it_was_issued_for() {
    let tickets = MapTicketRegistry::new();
    let room = Uuid::new_v4();
    let token = tickets.issue(7u32, room);
    let ticket = tickets.redeem(&token, &room).expect("issued");
    assert_eq!(ticket.player, 7);
    assert_eq!(ticket.room, room);
  }

  #[test]
  fn a_ticket_spends_once() {
    let tickets = MapTicketRegistry::new();
    let room = Uuid::new_v4();
    let token = tickets.issue(7u32, room);
    assert!(tickets.redeem(&token, &room).is_some());
    assert!(tickets.redeem(&token, &room).is_none(), "replay is refused");
    assert_eq!(tickets.outstanding(), 0);
  }

  #[test]
  fn an_unissued_token_resolves_to_nobody() {
    let tickets: MapTicketRegistry<u32> = MapTicketRegistry::new();
    assert!(tickets.redeem("t999", &Uuid::new_v4()).is_none());
  }

  #[test]
  fn tickets_are_distinct_per_issue() {
    let tickets = MapTicketRegistry::new();
    let room = Uuid::new_v4();
    assert_ne!(tickets.issue(1u32, room), tickets.issue(1u32, room));
  }

  #[test]
  fn a_caller_can_supply_its_own_token() {
    let tickets = MapTicketRegistry::new();
    let room = Uuid::new_v4();
    tickets.issue_with("signed.jwt.value".to_string(), 7u32, room);
    assert_eq!(tickets.redeem("signed.jwt.value", &room).unwrap().player, 7);
  }

  #[test]
  fn a_revoked_ticket_cannot_be_redeemed() {
    let tickets = MapTicketRegistry::new();
    let room = Uuid::new_v4();
    let token = tickets.issue(7u32, room);
    assert!(tickets.revoke(&token));
    assert!(tickets.redeem(&token, &room).is_none());
  }

  #[test]
  fn outstanding_tracks_what_was_issued_and_not_dialled() {
    let tickets = MapTicketRegistry::new();
    let room = Uuid::new_v4();
    let a = tickets.issue(1u32, room);
    tickets.issue(2u32, room);
    tickets.redeem(&a, &room);
    assert_eq!(tickets.outstanding(), 1);
  }

  #[test]
  fn a_ticket_presented_at_the_wrong_room_survives_being_refused() {
    // Refusing after spending would let anyone holding a guess destroy a
    // placement they have no claim on, and the token is a counter.
    let tickets = MapTicketRegistry::new();
    let mine = Uuid::new_v4();
    let token = tickets.issue(7u32, mine);

    assert!(tickets.redeem(&token, &Uuid::new_v4()).is_none(), "wrong room is refused");
    assert_eq!(tickets.outstanding(), 1, "and the ticket is still there");
    assert_eq!(tickets.redeem(&token, &mine).unwrap().player, 7);
  }

  #[test]
  fn a_registry_without_a_window_never_expires() {
    let tickets = MapTicketRegistry::new();
    let room = Uuid::new_v4();
    let token = tickets.issue(7u32, room);
    let ages_later = Instant::now() + Duration::from_secs(86_400);
    assert!(tickets.redeem_at(&token, &room, ages_later).is_some(), "today's behaviour");
  }

  #[test]
  fn a_ticket_past_its_window_is_refused() {
    let tickets = MapTicketRegistry::with_expiry(Duration::from_secs(30));
    let room = Uuid::new_v4();
    let token = tickets.issue(7u32, room);
    let now = Instant::now();
    assert!(tickets.redeem_at(&token, &room, now + Duration::from_secs(29)).is_some());

    let token = tickets.issue(7u32, room);
    assert!(tickets.redeem_at(&token, &room, now + Duration::from_secs(31)).is_none());
  }

  #[test]
  fn a_refused_ticket_is_dropped_rather_than_left_behind() {
    let tickets = MapTicketRegistry::with_expiry(Duration::from_secs(30));
    let room = Uuid::new_v4();
    let token = tickets.issue(7u32, room);
    let expired = Instant::now() + Duration::from_secs(31);
    assert!(tickets.redeem_at(&token, &room, expired).is_none());
    assert_eq!(tickets.inner.lock().held.len(), 0, "refusing also collects");
  }

  #[test]
  fn issuing_sweeps_what_the_window_has_passed() {
    let tickets = MapTicketRegistry::with_expiry(Duration::from_secs(30));
    let room = Uuid::new_v4();
    let start = Instant::now();
    for player in 0..5u32 {
      tickets.issue_with_at(format!("abandoned{player}"), player, room, start);
    }
    assert_eq!(tickets.inner.lock().held.len(), 5);

    tickets.issue_with_at("fresh".to_string(), 9, room, start + Duration::from_secs(31));
    assert_eq!(
      tickets.inner.lock().held.len(),
      1,
      "the abandoned five go, the one being issued stays"
    );
  }

  #[test]
  fn a_sweep_runs_at_most_once_per_window() {
    let tickets = MapTicketRegistry::with_expiry(Duration::from_secs(30));
    let room = Uuid::new_v4();
    let start = Instant::now();
    tickets.issue_with_at("old".to_string(), 1u32, room, start);

    tickets.issue_with_at("a".to_string(), 2, room, start + Duration::from_secs(10));
    assert_eq!(tickets.inner.lock().held.len(), 2, "too soon to sweep");

    tickets.issue_with_at("b".to_string(), 3, room, start + Duration::from_secs(31));
    assert_eq!(tickets.inner.lock().held.len(), 2, "old swept, a and b remain minus old");
  }

  #[test]
  fn outstanding_does_not_count_the_expired() {
    let tickets = MapTicketRegistry::with_expiry(Duration::from_secs(30));
    let room = Uuid::new_v4();
    let start = Instant::now();
    tickets.issue_with_at("stale".to_string(), 1u32, room, start);
    tickets.issue_with_at("live".to_string(), 2, room, start + Duration::from_secs(25));
    assert_eq!(tickets.outstanding_at(start + Duration::from_secs(31)), 1);
  }

  #[test]
  fn a_store_is_usable_behind_a_trait_object() {
    // A cross-host implementation depends on this: a route can be written
    // against the trait rather than a concrete registry.
    let tickets: std::sync::Arc<dyn TicketStore<u32>> = std::sync::Arc::new(MapTicketRegistry::new());
    let room = Uuid::new_v4();
    let token = tickets.issue(7, room);
    assert_eq!(tickets.redeem(&token, &room).unwrap().player, 7);
  }

  #[cfg(feature = "cache")]
  mod cached {
    use super::*;

    #[test]
    fn a_cached_ticket_resolves_to_who_it_was_issued_for() {
      let tickets = CachedTicketRegistry::with_expiry(Duration::from_secs(30));
      let room = Uuid::new_v4();
      let token = tickets.issue(7u32, room);
      let ticket = tickets.redeem(&token, &room).expect("issued");
      assert_eq!(ticket.player, 7);
      assert_eq!(ticket.room, room);
    }

    #[test]
    fn a_cached_ticket_spends_once() {
      let tickets = CachedTicketRegistry::with_expiry(Duration::from_secs(30));
      let room = Uuid::new_v4();
      let token = tickets.issue(7u32, room);
      assert!(tickets.redeem(&token, &room).is_some());
      assert!(tickets.redeem(&token, &room).is_none(), "replay is refused");
    }

    #[test]
    fn a_cached_ticket_can_be_revoked() {
      let tickets = CachedTicketRegistry::with_expiry(Duration::from_secs(30));
      let room = Uuid::new_v4();
      let token = tickets.issue(7u32, room);
      assert!(tickets.revoke(&token));
      assert!(tickets.redeem(&token, &room).is_none());
    }

    #[test]
    fn a_cached_caller_can_supply_its_own_token() {
      let tickets = CachedTicketRegistry::with_expiry(Duration::from_secs(30));
      let room = Uuid::new_v4();
      tickets.issue_with("signed.jwt.value".to_string(), 7u32, room);
      assert_eq!(tickets.redeem("signed.jwt.value", &room).unwrap().player, 7);
    }

    #[test]
    fn a_cached_ticket_at_the_wrong_room_survives_being_refused() {
      let tickets = CachedTicketRegistry::with_expiry(Duration::from_secs(30));
      let mine = Uuid::new_v4();
      let token = tickets.issue(7u32, mine);

      assert!(tickets.redeem(&token, &Uuid::new_v4()).is_none(), "wrong room is refused");
      assert_eq!(tickets.outstanding(), 1, "and the ticket is still there");
      assert_eq!(tickets.redeem(&token, &mine).unwrap().player, 7);
    }

    #[test]
    fn a_cached_ticket_past_its_window_is_refused() {
      let tickets = CachedTicketRegistry::with_expiry(Duration::from_millis(20));
      let room = Uuid::new_v4();
      let token = tickets.issue(7u32, room);
      // Past the window and one janitor tick, since reads judge expiry on
      // the janitor's clock.
      std::thread::sleep(Duration::from_millis(1300));
      tickets.run_maintenance();
      assert!(tickets.redeem(&token, &room).is_none());
      assert_eq!(tickets.outstanding(), 0);
    }

    #[test]
    fn a_cached_store_is_usable_behind_a_trait_object() {
      let tickets: std::sync::Arc<dyn TicketStore<u32>> =
        std::sync::Arc::new(CachedTicketRegistry::with_expiry(Duration::from_secs(30)));
      let room = Uuid::new_v4();
      let token = tickets.issue(7, room);
      assert_eq!(tickets.redeem(&token, &room).unwrap().player, 7);
    }
  }
}