Skip to main content

behavior_actors/discovery/
presence.rs

1//! Versioned presence evidence with generation-safe expiry.
2
3use std::time::Duration;
4
5#[cfg(test)]
6use behavior::Recipient;
7use behavior::{
8    Actions, Address, Behavior, BehaviorActed, BehaviorBase, EventLayer, InterpreterRequests,
9    Never, NoBirths, SendEffects, User,
10};
11use thiserror::Error;
12
13use crate::DeliveryRoute;
14use crate::{ScheduleAfter, TimedEvent, TimerGeneration, TimerId};
15
16/// Version within one participant's presence-evidence stream.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
18pub struct PresenceVersion(pub u64);
19
20/// Complete retained phase for one known participant.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum PresencePhase {
23    /// Participant is present until matching timer evidence arrives.
24    Present {
25        /// Latest evidence version.
26        version: PresenceVersion,
27        /// Current expiry generation.
28        generation: TimerGeneration,
29        /// Declared relative lifetime.
30        lifetime: Duration,
31    },
32    /// Matching expiry was committed and retained as a tombstone.
33    Expired {
34        /// Evidence version that established the expired incarnation.
35        version: PresenceVersion,
36        /// Expired timer generation.
37        generation: TimerGeneration,
38    },
39}
40
41/// One known participant and its timer namespace data.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct PresenceEntry<K> {
44    /// Application-defined participant identity.
45    pub participant: K,
46    /// Actor-local timer key chosen by the supplied pure mapping.
47    pub timer_id: TimerId,
48    /// Complete presence phase.
49    pub phase: PresencePhase,
50}
51
52/// Typed rejection of presence evidence.
53#[derive(Debug, Error, Clone, PartialEq, Eq)]
54pub enum PresenceError<K> {
55    /// Evidence predates the retained participant version.
56    #[error("presence evidence is stale")]
57    Stale {
58        /// Participant.
59        participant: K,
60        /// Rejected version.
61        observed: PresenceVersion,
62        /// Current version.
63        current: PresenceVersion,
64        /// Exact rejected lifetime.
65        lifetime: Duration,
66    },
67    /// Evidence reuses a version with a different lifetime or phase.
68    #[error("presence evidence conflicts at the committed version")]
69    ConflictingVersion {
70        /// Participant.
71        participant: K,
72        /// Reused version.
73        version: PresenceVersion,
74        /// Exact rejected lifetime.
75        lifetime: Duration,
76    },
77    /// A currently present different participant owns the mapped timer key.
78    #[error("presence timer key collides with a live participant")]
79    TimerCollision {
80        /// Rejected participant.
81        participant: K,
82        /// Existing participant owning the timer key.
83        existing: K,
84        /// Colliding timer key.
85        timer_id: TimerId,
86        /// Exact rejected evidence version.
87        version: PresenceVersion,
88        /// Exact rejected lifetime.
89        lifetime: Duration,
90    },
91    /// No fresh timer generation is representable.
92    #[error("presence timer generation is exhausted")]
93    GenerationExhausted {
94        /// Participant whose refresh was rejected.
95        participant: K,
96        /// Exact rejected evidence version.
97        version: PresenceVersion,
98        /// Exact rejected lifetime.
99        lifetime: Duration,
100    },
101}
102
103/// Factual presence transition result.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum PresenceOutcome<K> {
106    /// A previously unknown participant became present.
107    Announced {
108        /// Participant.
109        participant: K,
110        /// Committed evidence version.
111        version: PresenceVersion,
112        /// Committed timer generation.
113        generation: TimerGeneration,
114    },
115    /// A known participant committed newer evidence.
116    Refreshed {
117        /// Participant.
118        participant: K,
119        /// Committed evidence version.
120        version: PresenceVersion,
121        /// Fresh timer generation.
122        generation: TimerGeneration,
123    },
124    /// Identical evidence was accepted idempotently without rescheduling.
125    Unchanged {
126        /// Participant.
127        participant: K,
128        /// Existing evidence version.
129        version: PresenceVersion,
130        /// Existing timer generation.
131        generation: TimerGeneration,
132    },
133    /// Matching timer evidence committed expiry.
134    Expired {
135        /// Participant.
136        participant: K,
137        /// Evidence version of the expired incarnation.
138        version: PresenceVersion,
139        /// Expired generation.
140        generation: TimerGeneration,
141    },
142    /// Evidence was rejected without mutation.
143    Rejected(PresenceError<K>),
144}
145
146/// Point-in-time retained presence report.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct PresenceReport<K> {
149    /// Participants in first-observation order, including tombstones.
150    pub entries: Vec<PresenceEntry<K>>,
151}
152
153/// Closed reply protocol emitted by [`Presence`].
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum PresenceReply<K> {
156    /// One transition result.
157    Outcome(PresenceOutcome<K>),
158    /// One query result.
159    Report(PresenceReport<K>),
160}
161
162/// User commands accepted by [`Presence`].
163pub enum PresenceMessage<K, Route> {
164    /// Announce versioned presence for a relative lifetime.
165    Announce {
166        /// Participant.
167        participant: K,
168        /// Evidence version.
169        version: PresenceVersion,
170        /// Relative lifetime.
171        lifetime: Duration,
172        /// Outcome and later-expiry recipient.
173        reply_to: Route,
174    },
175    /// Return every retained present or expired state.
176    Query {
177        /// Typed report recipient.
178        reply_to: Route,
179    },
180}
181
182/// Named effect lanes emitted by [`Presence`].
183#[derive(behavior_macros::SendProduct)]
184pub struct PresenceSends<ReplySends, Schedules> {
185    /// Transition and query facts.
186    pub replies: ReplySends,
187    /// Relative expiry requests.
188    pub schedules: Schedules,
189}
190
191struct Record<K, Route> {
192    entry: PresenceEntry<K>,
193    notify: Route,
194}
195
196/// Versioned, generation-safe membership-presence behavior.
197///
198/// The first announcement commits generation zero before requesting expiry.
199/// Greater evidence versions refresh with a checked fresh generation; identical
200/// evidence is idempotent and does not reschedule. Lower or contradictory
201/// evidence is rejected atomically. A timer-key collision with another live
202/// participant is rejection, never replacement. Matching elapsed evidence
203/// commits an explicit tombstone and reports expiry. Generations advance per
204/// timer key across every retained participant tombstone, so reuse by a later
205/// participant cannot reinterpret delayed evidence from an earlier owner;
206/// stale and unknown timer evidence is inert. Initialization is empty, no actors are created, and the
207/// host never terminates by policy. Versioning, first-observation ordering,
208/// timer mapping, and tombstone retention are Bombay policy. Scheduling belongs
209/// to Timers; release/cancellation is not part of this announcement protocol.
210/// No transition has a semantic panic condition.
211pub struct Presence<
212    A: Address,
213    K,
214    Route: DeliveryRoute<Protocol: behavior::Protocol<Addr = A, Msg = PresenceReply<K>>>,
215> {
216    timer_id: fn(&K) -> TimerId,
217    records: Vec<Record<K, Route>>,
218    marker: core::marker::PhantomData<fn() -> A>,
219}
220type PresenceActions<A, ReplySends> =
221    Actions<A, Never, PresenceSends<ReplySends, InterpreterRequests<ScheduleAfter>>, NoBirths>;
222impl<A, K, Route> Presence<A, K, Route>
223where
224    A: Address,
225    K: Clone + Eq,
226    Route: DeliveryRoute<Protocol: behavior::Protocol<Addr = A, Msg = PresenceReply<K>>> + Clone,
227{
228    /// Construct an empty presence table with a pure actor-local timer mapping.
229    #[must_use]
230    pub const fn new(timer_id: fn(&K) -> TimerId) -> Self {
231        Self {
232            timer_id,
233            records: Vec::new(),
234            marker: core::marker::PhantomData,
235        }
236    }
237    /// Snapshot complete present and tombstoned state.
238    #[must_use]
239    pub fn report(&self) -> PresenceReport<K> {
240        PresenceReport {
241            entries: self
242                .records
243                .iter()
244                .map(|record| record.entry.clone())
245                .collect(),
246        }
247    }
248    fn reply(reply_to: Route, reply: PresenceReply<K>) -> PresenceActions<A, Route::Sends> {
249        Actions::send(PresenceSends {
250            replies: reply_to.deliver(reply),
251            schedules: InterpreterRequests::empty(),
252        })
253    }
254
255    fn next_generation(&self, timer_id: TimerId) -> Option<TimerGeneration> {
256        self.records
257            .iter()
258            .filter(|record| record.entry.timer_id == timer_id)
259            .map(|record| match record.entry.phase {
260                PresencePhase::Present { generation, .. }
261                | PresencePhase::Expired { generation, .. } => generation,
262            })
263            .max_by_key(|generation| generation.0)
264            .map_or(Some(TimerGeneration(0)), |generation| {
265                generation.0.checked_add(1).map(TimerGeneration)
266            })
267    }
268    fn announce(
269        &mut self,
270        participant: K,
271        version: PresenceVersion,
272        lifetime: Duration,
273        reply_to: Route,
274    ) -> PresenceActions<A, Route::Sends> {
275        let timer_id = (self.timer_id)(&participant);
276        if let Some(existing) = self.records.iter().find(|record| {
277            record.entry.participant != participant
278                && record.entry.timer_id == timer_id
279                && matches!(record.entry.phase, PresencePhase::Present { .. })
280        }) {
281            return Self::reply(
282                reply_to,
283                PresenceReply::Outcome(PresenceOutcome::Rejected(PresenceError::TimerCollision {
284                    participant,
285                    existing: existing.entry.participant.clone(),
286                    timer_id,
287                    version,
288                    lifetime,
289                })),
290            );
291        }
292        let existing = self
293            .records
294            .iter()
295            .position(|record| record.entry.participant == participant);
296        match existing {
297            Some(index) => self.refresh(index, participant, version, lifetime, reply_to, timer_id),
298            None => self.introduce(participant, version, lifetime, reply_to, timer_id),
299        }
300    }
301
302    fn introduce(
303        &mut self,
304        participant: K,
305        version: PresenceVersion,
306        lifetime: Duration,
307        reply_to: Route,
308        timer_id: TimerId,
309    ) -> PresenceActions<A, Route::Sends> {
310        let Some(generation) = self.next_generation(timer_id) else {
311            return Self::reply(
312                reply_to,
313                PresenceReply::Outcome(PresenceOutcome::Rejected(
314                    PresenceError::GenerationExhausted {
315                        participant,
316                        version,
317                        lifetime,
318                    },
319                )),
320            );
321        };
322        self.records.push(Record {
323            entry: PresenceEntry {
324                participant: participant.clone(),
325                timer_id,
326                phase: PresencePhase::Present {
327                    version,
328                    generation,
329                    lifetime,
330                },
331            },
332            notify: reply_to.clone(),
333        });
334        Actions::send(PresenceSends {
335            replies: reply_to.deliver(PresenceReply::Outcome(PresenceOutcome::Announced {
336                participant,
337                version,
338                generation,
339            })),
340            schedules: InterpreterRequests::one(ScheduleAfter::new(timer_id, generation, lifetime)),
341        })
342    }
343
344    fn refresh(
345        &mut self,
346        index: usize,
347        participant: K,
348        version: PresenceVersion,
349        lifetime: Duration,
350        reply_to: Route,
351        timer_id: TimerId,
352    ) -> PresenceActions<A, Route::Sends> {
353        let current_version = match self.records[index].entry.phase {
354            PresencePhase::Present { version, .. } | PresencePhase::Expired { version, .. } => {
355                version
356            }
357        };
358        if version < current_version {
359            return Self::reply(
360                reply_to,
361                PresenceReply::Outcome(PresenceOutcome::Rejected(PresenceError::Stale {
362                    participant,
363                    observed: version,
364                    current: current_version,
365                    lifetime,
366                })),
367            );
368        }
369        if version == current_version {
370            if let PresencePhase::Present {
371                generation,
372                lifetime: current,
373                ..
374            } = self.records[index].entry.phase
375                && current == lifetime
376            {
377                return Self::reply(
378                    reply_to,
379                    PresenceReply::Outcome(PresenceOutcome::Unchanged {
380                        participant,
381                        version,
382                        generation,
383                    }),
384                );
385            }
386            return Self::reply(
387                reply_to,
388                PresenceReply::Outcome(PresenceOutcome::Rejected(
389                    PresenceError::ConflictingVersion {
390                        participant,
391                        version,
392                        lifetime,
393                    },
394                )),
395            );
396        }
397        let Some(generation) = self.next_generation(timer_id) else {
398            return Self::reply(
399                reply_to,
400                PresenceReply::Outcome(PresenceOutcome::Rejected(
401                    PresenceError::GenerationExhausted {
402                        participant,
403                        version,
404                        lifetime,
405                    },
406                )),
407            );
408        };
409        self.records[index].entry.phase = PresencePhase::Present {
410            version,
411            generation,
412            lifetime,
413        };
414        self.records[index].notify = reply_to.clone();
415        Actions::send(PresenceSends {
416            replies: reply_to.deliver(PresenceReply::Outcome(PresenceOutcome::Refreshed {
417                participant,
418                version,
419                generation,
420            })),
421            schedules: InterpreterRequests::one(ScheduleAfter::new(timer_id, generation, lifetime)),
422        })
423    }
424}
425impl<A, K, Route> BehaviorBase for Presence<A, K, Route>
426where
427    A: Address,
428    Route: DeliveryRoute<Protocol: behavior::Protocol<Addr = A, Msg = PresenceReply<K>>>,
429{
430    type Base = Self;
431    fn base(&self) -> &Self {
432        self
433    }
434}
435impl<A, K, Route> behavior::Protocol for Presence<A, K, Route>
436where
437    A: Address,
438    Route: DeliveryRoute<Protocol: behavior::Protocol<Addr = A, Msg = PresenceReply<K>>>,
439{
440    type Addr = A;
441    type Msg = PresenceMessage<K, Route>;
442}
443
444impl<A, K, Route> Behavior for Presence<A, K, Route>
445where
446    A: Address,
447    K: Clone + Eq,
448    Route: DeliveryRoute<Protocol: behavior::Protocol<Addr = A, Msg = PresenceReply<K>>> + Clone,
449    Route::Sends: behavior::SendsFor<TimedEvent<User<A, PresenceMessage<K, Route>>>>,
450{
451    type Protocol = Self;
452    type Event = TimedEvent<User<A, behavior::BehaviorMessage<Self>>>;
453    type Sends = PresenceSends<Route::Sends, InterpreterRequests<ScheduleAfter>>;
454    type Ph = Never;
455    type Error = Never;
456    type Birth = NoBirths;
457    fn transition(&mut self, _: behavior::ActiveTurn, event: Self::Event) -> BehaviorActed<Self> {
458        Ok(match event {
459            EventLayer::Inner(event) => match event.message {
460                PresenceMessage::Announce {
461                    participant,
462                    version,
463                    lifetime,
464                    reply_to,
465                } => self.announce(participant, version, lifetime, reply_to),
466                PresenceMessage::Query { reply_to } => {
467                    Self::reply(reply_to, PresenceReply::Report(self.report()))
468                }
469            },
470            EventLayer::Owned(elapsed) => {
471                let Some(index)=self.records.iter().position(|record|record.entry.timer_id==elapsed.id&&matches!(record.entry.phase,PresencePhase::Present{generation,..}if generation==elapsed.generation))else{return Ok(Actions::cont());};
472                let (version, generation) = match self.records[index].entry.phase {
473                    PresencePhase::Present {
474                        version,
475                        generation,
476                        ..
477                    } => (version, generation),
478                    PresencePhase::Expired { .. } => return Ok(Actions::cont()),
479                };
480                let participant = self.records[index].entry.participant.clone();
481                let notify = self.records[index].notify.clone();
482                self.records[index].entry.phase = PresencePhase::Expired {
483                    version,
484                    generation,
485                };
486                Self::reply(
487                    notify,
488                    PresenceReply::Outcome(PresenceOutcome::Expired {
489                        participant,
490                        version,
491                        generation,
492                    }),
493                )
494            }
495        })
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use crate::{Activate as _, TimerElapsed};
503    use behavior::MailAddr;
504    #[derive(Debug, Clone, PartialEq, Eq)]
505    struct Participant(u8);
506    struct Reply;
507    impl behavior::Protocol for Reply {
508        type Addr = MailAddr;
509        type Msg = PresenceReply<Participant>;
510    }
511
512    impl Behavior for Reply {
513        type Protocol = Self;
514        type Event = User<MailAddr, behavior::BehaviorMessage<Self>>;
515        type Sends = Vec<Never>;
516        type Ph = Never;
517        type Error = Never;
518        type Birth = NoBirths;
519        fn transition(&mut self, _: behavior::ActiveTurn, _: Self::Event) -> BehaviorActed<Self> {
520            Ok(Actions::cont())
521        }
522    }
523    type Subject = Presence<MailAddr, Participant, Recipient<Reply>>;
524    fn reply() -> Recipient<Reply> {
525        Recipient::global(MailAddr(9))
526    }
527    fn duration() -> Duration {
528        Duration::from_secs(1)
529    }
530    fn timer(key: &Participant) -> TimerId {
531        TimerId(u64::from(key.0))
532    }
533    #[test]
534    fn refresh_and_expiry_are_version_and_generation_safe() {
535        let mut s = (Subject::new(timer)).initialize().unwrap().behavior;
536        let announced = s
537            .receive(
538                MailAddr(0),
539                PresenceMessage::Announce {
540                    participant: Participant(1),
541                    version: PresenceVersion(1),
542                    lifetime: duration(),
543                    reply_to: reply(),
544                },
545            )
546            .unwrap();
547        assert_eq!(announced.sends.schedules.len(), 1);
548        assert_eq!(announced.sends.replies.len(), 1);
549        assert!(announced.creates.is_empty());
550        assert_eq!(announced.become_, behavior::Step::Continue);
551        let refreshed = s
552            .receive(
553                MailAddr(0),
554                PresenceMessage::Announce {
555                    participant: Participant(1),
556                    version: PresenceVersion(2),
557                    lifetime: duration(),
558                    reply_to: reply(),
559                },
560            )
561            .unwrap();
562        assert_eq!(
563            refreshed.sends.schedules.as_slice()[0].generation,
564            TimerGeneration(1)
565        );
566        assert_eq!(refreshed.sends.replies.len(), 1);
567        assert!(refreshed.creates.is_empty());
568        assert_eq!(refreshed.become_, behavior::Step::Continue);
569        assert!(
570            s.on_path(TimerElapsed::new(TimerId(1), TimerGeneration(0)))
571                .unwrap()
572                .sends
573                .replies
574                .is_empty()
575        );
576        let expired = s
577            .on_path(TimerElapsed::new(TimerId(1), TimerGeneration(1)))
578            .unwrap();
579        assert!(matches!(
580            expired.sends.replies[0].message,
581            PresenceReply::Outcome(PresenceOutcome::Expired {
582                participant: Participant(1),
583                ..
584            })
585        ));
586        assert!(expired.sends.schedules.is_empty());
587        assert!(expired.creates.is_empty());
588        assert_eq!(expired.become_, behavior::Step::Continue);
589    }
590    #[test]
591    fn collision_and_stale_evidence_are_atomic() {
592        fn collision(_: &Participant) -> TimerId {
593            TimerId(1)
594        }
595        let mut s = (Subject::new(collision)).initialize().unwrap().behavior;
596        let announced = s
597            .receive(
598                MailAddr(0),
599                PresenceMessage::Announce {
600                    participant: Participant(1),
601                    version: PresenceVersion(2),
602                    lifetime: duration(),
603                    reply_to: reply(),
604                },
605            )
606            .unwrap();
607        assert_eq!(announced.sends.schedules.len(), 1);
608        assert_eq!(announced.sends.replies.len(), 1);
609        assert!(announced.creates.is_empty());
610        assert_eq!(announced.become_, behavior::Step::Continue);
611        let collision = s
612            .receive(
613                MailAddr(0),
614                PresenceMessage::Announce {
615                    participant: Participant(2),
616                    version: PresenceVersion(1),
617                    lifetime: duration(),
618                    reply_to: reply(),
619                },
620            )
621            .unwrap();
622        assert!(matches!(
623            collision.sends.replies[0].message,
624            PresenceReply::Outcome(PresenceOutcome::Rejected(PresenceError::TimerCollision {
625                participant: Participant(2),
626                existing: Participant(1),
627                ..
628            }))
629        ));
630        let stale = s
631            .receive(
632                MailAddr(0),
633                PresenceMessage::Announce {
634                    participant: Participant(1),
635                    version: PresenceVersion(1),
636                    lifetime: duration(),
637                    reply_to: reply(),
638                },
639            )
640            .unwrap();
641        assert!(matches!(
642            stale.sends.replies[0].message,
643            PresenceReply::Outcome(PresenceOutcome::Rejected(PresenceError::Stale { .. }))
644        ));
645        assert_eq!(s.report().entries.len(), 1);
646    }
647
648    #[test]
649    fn timer_key_reuse_cannot_reinterpret_an_earlier_participants_expiry() {
650        fn shared(_: &Participant) -> TimerId {
651            TimerId(1)
652        }
653        let mut subject = (Subject::new(shared)).initialize().unwrap().behavior;
654        let announce = |participant| PresenceMessage::Announce {
655            participant,
656            version: PresenceVersion(1),
657            lifetime: duration(),
658            reply_to: reply(),
659        };
660
661        let first = subject
662            .receive(MailAddr(0), announce(Participant(1)))
663            .unwrap();
664        assert_eq!(
665            first.sends.schedules.as_slice()[0].generation,
666            TimerGeneration(0)
667        );
668        let expired = subject
669            .on_path(TimerElapsed::new(TimerId(1), TimerGeneration(0)))
670            .unwrap();
671        assert!(expired.sends.schedules.is_empty());
672        assert_eq!(expired.sends.replies.len(), 1);
673        assert!(expired.creates.is_empty());
674        assert_eq!(expired.become_, behavior::Step::Continue);
675
676        let second = subject
677            .receive(MailAddr(0), announce(Participant(2)))
678            .unwrap();
679        assert_eq!(
680            second.sends.schedules.as_slice()[0].generation,
681            TimerGeneration(1)
682        );
683        let delayed_duplicate = subject
684            .on_path(TimerElapsed::new(TimerId(1), TimerGeneration(0)))
685            .unwrap();
686        assert!(delayed_duplicate.sends.replies.is_empty());
687        assert!(matches!(
688            subject.report().entries[1].phase,
689            PresencePhase::Present {
690                generation: TimerGeneration(1),
691                ..
692            }
693        ));
694    }
695
696    #[test]
697    fn identical_evidence_is_idempotent_without_rescheduling() {
698        let mut s = (Subject::new(timer)).initialize().unwrap().behavior;
699        let message = || PresenceMessage::Announce {
700            participant: Participant(1),
701            version: PresenceVersion(1),
702            lifetime: duration(),
703            reply_to: reply(),
704        };
705        let announced = s.receive(MailAddr(0), message()).unwrap();
706        assert_eq!(announced.sends.schedules.len(), 1);
707        assert_eq!(announced.sends.replies.len(), 1);
708        assert!(announced.creates.is_empty());
709        assert_eq!(announced.become_, behavior::Step::Continue);
710        let unchanged = s.receive(MailAddr(0), message()).unwrap();
711        assert!(unchanged.sends.schedules.as_slice().is_empty());
712        assert!(matches!(
713            unchanged.sends.replies[0].message,
714            PresenceReply::Outcome(PresenceOutcome::Unchanged {
715                generation: TimerGeneration(0),
716                ..
717            })
718        ));
719    }
720
721    #[test]
722    fn exhausted_generation_rejects_without_mutation() {
723        let mut definition = Subject::new(timer);
724        definition.records.push(Record {
725            entry: PresenceEntry {
726                participant: Participant(1),
727                timer_id: TimerId(1),
728                phase: PresencePhase::Present {
729                    version: PresenceVersion(1),
730                    generation: TimerGeneration(u64::MAX),
731                    lifetime: duration(),
732                },
733            },
734            notify: reply(),
735        });
736        let mut s = (definition).initialize().unwrap().behavior;
737        let rejected = s
738            .receive(
739                MailAddr(0),
740                PresenceMessage::Announce {
741                    participant: Participant(1),
742                    version: PresenceVersion(2),
743                    lifetime: duration(),
744                    reply_to: reply(),
745                },
746            )
747            .unwrap();
748        assert!(matches!(
749            rejected.sends.replies[0].message,
750            PresenceReply::Outcome(PresenceOutcome::Rejected(
751                PresenceError::GenerationExhausted { .. }
752            ))
753        ));
754        assert!(matches!(
755            s.report().entries[0].phase,
756            PresencePhase::Present {
757                version: PresenceVersion(1),
758                generation: TimerGeneration(u64::MAX),
759                ..
760            }
761        ));
762    }
763}