Skip to main content

protocol_droid/domain/session/
registry.rs

1//! The in-flight request table.
2//!
3//! # What the table is for
4//!
5//! A peer may have several requests outstanding at once, and it may cancel any
6//! of them, or ask for progress on any of them, from a different task than the
7//! one running the handler. The table is the one place that answers "is that id
8//! still running, and what do we know about it".
9//!
10//! # Two rules the specification fixes
11//!
12//! Cancelling a request that is unknown, or one that has already finished, is a
13//! **no-op**, not an error. The schema is explicit that the notification "MAY
14//! arrive after the request has already finished" and that the result "will be
15//! unused", so answering with an error would be reporting a race as a fault.
16//! [`CancelOutcome::Ignored`] is that case, and it is deliberately not an error
17//! type.
18//!
19//! Registering an id that is already in flight is the opposite: a duplicate
20//! in-flight id is a malformed session, and it is reported as
21//! [`DuplicateRequest`] rather than silently displacing the first request.
22//!
23//! # Why a hash map and not a tree
24//!
25//! The only ordered traversal is [`expire`](RequestRegistry::expire), which
26//! collects into a `Vec` and runs on a tick rather than per frame. The per-frame
27//! costs that matter are register and cancel, and both hash a `RequestId` — an
28//! integer or an `Arc<str>` pointer, cheap to hash either way.
29
30use core::fmt;
31use std::collections::HashMap;
32use std::sync::Arc;
33use std::time::Duration;
34
35use crate::domain::RequestId;
36
37use super::cancellation::{AbortReason, CancelReason, CancellationToken};
38use super::progress::{ProgressCursor, ProgressToken, ProgressUpdate};
39use super::state::Instant;
40
41/// Which side issued a request.
42///
43/// Both directions go through one table because both need the same three
44/// things: uniqueness of the id, a cancellation handle, and a deadline. A
45/// server that samples from a client has exactly the bookkeeping a server has
46/// when a client calls a tool, and two tables would be two places for one rule
47/// to be wrong in.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub enum RequestOrigin {
50    /// The peer asked us.
51    Inbound,
52    /// We asked the peer — sampling, elicitation, roots.
53    Outbound,
54}
55
56/// One request that has not finished.
57#[derive(Debug)]
58pub struct InFlight {
59    origin: RequestOrigin,
60    method: Arc<str>,
61    token: Option<ProgressToken>,
62    cursor: ProgressCursor,
63    budget: Option<Duration>,
64    started_at: Instant,
65    cancel: CancellationToken,
66}
67
68impl InFlight {
69    /// A newly registered request.
70    ///
71    /// `budget` is how long this side is willing to wait. `None` means the
72    /// caller decided the request is not time-bounded — which is a decision,
73    /// not an oversight, and is why it is an `Option` rather than a zero that
74    /// would expire on the next tick.
75    #[must_use]
76    pub fn new(
77        origin: RequestOrigin,
78        method: impl AsRef<str>,
79        token: Option<ProgressToken>,
80        budget: Option<Duration>,
81        started_at: Instant,
82    ) -> Self {
83        Self {
84            origin,
85            method: Arc::from(method.as_ref()),
86            token,
87            cursor: ProgressCursor::new(),
88            budget,
89            started_at,
90            cancel: CancellationToken::new(),
91        }
92    }
93
94    /// Which side issued it.
95    #[must_use]
96    pub const fn origin(&self) -> RequestOrigin {
97        self.origin
98    }
99
100    /// The method it names.
101    #[must_use]
102    pub fn method(&self) -> &str {
103        &self.method
104    }
105
106    /// The progress token the request carried, if it asked for progress.
107    #[must_use]
108    pub const fn progress_token(&self) -> Option<&ProgressToken> {
109        self.token.as_ref()
110    }
111
112    /// How long this side is willing to wait, if it decided.
113    #[must_use]
114    pub const fn budget(&self) -> Option<Duration> {
115        self.budget
116    }
117
118    /// When it was registered.
119    #[must_use]
120    pub const fn started_at(&self) -> Instant {
121        self.started_at
122    }
123
124    /// The handle a handler awaits to learn it was cancelled.
125    #[must_use]
126    pub const fn cancellation(&self) -> &CancellationToken {
127        &self.cancel
128    }
129
130    /// A clone of the cancellation handle, safe to hold across an await.
131    ///
132    /// The registry hands out `&InFlight` only while its lock is taken, so a
133    /// handler that wants to wait has to own the token rather than borrow it
134    /// out of the table.
135    #[must_use]
136    pub fn cancellation_handle(&self) -> CancellationToken {
137        self.cancel.clone()
138    }
139
140    /// Whether the budget is spent at `now`.
141    #[must_use]
142    pub fn has_expired(&self, now: Instant) -> bool {
143        self.budget
144            .is_some_and(|budget| now.since(self.started_at) >= budget)
145    }
146
147    /// Records a progress value and says whether it should go on the wire.
148    ///
149    /// `false` for a request that carried no token, because there is nothing
150    /// to correlate the notification to, and for a value that does not move the
151    /// cursor forward. Delegates to this entry's own cursor, so a caller cannot
152    /// accidentally check monotonicity against another request's last value.
153    #[must_use]
154    pub fn advance_progress(&mut self, progress: f64, message: Option<String>) -> bool {
155        let Some(token) = self.token.clone() else {
156            return false;
157        };
158        let mut update = ProgressUpdate::new(token, progress);
159        if let Some(message) = message {
160            update = update.with_message(message);
161        }
162        self.cursor.advance(&update)
163    }
164
165    /// The furthest progress reported for this request.
166    #[must_use]
167    pub const fn furthest_progress(&self) -> Option<f64> {
168        self.cursor.furthest()
169    }
170
171    /// Aborts the request.
172    ///
173    /// Returns whether this call is the one that published the reason.
174    pub fn abort(&self, reason: AbortReason) -> bool {
175        self.cancel.cancel(reason)
176    }
177}
178
179/// What a cancellation did.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum CancelOutcome {
182    /// The request was in flight and has been aborted.
183    Cancelled,
184    /// Nothing was cancelled: the id was never issued, had already finished, or
185    /// was already cancelled.
186    ///
187    /// Not an error. The specification says a cancellation notification may
188    /// arrive after its request has finished, so this is a race the sender is
189    /// entitled to lose and the notification is defined to be harmless.
190    Ignored,
191}
192
193impl CancelOutcome {
194    /// Whether a request was actually aborted.
195    #[must_use]
196    pub const fn cancelled(self) -> bool {
197        matches!(self, Self::Cancelled)
198    }
199}
200
201/// An id that is already in flight.
202///
203/// Carries the real id rather than a redacted copy, because the caller already
204/// has it; [`Display`](fmt::Display) redacts so that a logged message cannot
205/// disclose a peer-chosen string.
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct DuplicateRequest {
208    /// The offending id.
209    pub id: RequestId,
210}
211
212impl fmt::Display for DuplicateRequest {
213    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
214        write!(
215            formatter,
216            "request id {} is already in flight",
217            self.id.redacted()
218        )
219    }
220}
221
222impl std::error::Error for DuplicateRequest {}
223
224/// A request id that is not in flight.
225///
226/// Redacted in [`Display`](fmt::Display) for the same reason as
227/// [`DuplicateRequest`].
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct UnknownRequest {
230    /// The id that was not found.
231    pub id: RequestId,
232}
233
234impl fmt::Display for UnknownRequest {
235    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
236        write!(
237            formatter,
238            "request id {} is not in flight",
239            self.id.redacted()
240        )
241    }
242}
243
244impl std::error::Error for UnknownRequest {}
245
246/// Every request that has not finished.
247#[derive(Debug, Default)]
248pub struct RequestRegistry {
249    entries: HashMap<RequestId, InFlight>,
250}
251
252impl RequestRegistry {
253    /// An empty table.
254    #[must_use]
255    pub fn new() -> Self {
256        Self {
257            entries: HashMap::new(),
258        }
259    }
260
261    /// Records a request as in flight.
262    ///
263    /// # Errors
264    ///
265    /// [`DuplicateRequest`] if the id is already in flight. The existing entry
266    /// is left alone: the first request owns the id, and displacing it would
267    /// make a later cancellation abort the wrong work.
268    pub fn register(&mut self, id: RequestId, entry: InFlight) -> Result<(), DuplicateRequest> {
269        if self.entries.contains_key(&id) {
270            return Err(DuplicateRequest { id });
271        }
272        self.entries.insert(id, entry);
273        Ok(())
274    }
275
276    /// The entry for `id`, if it is still in flight.
277    #[must_use]
278    pub fn get(&self, id: &RequestId) -> Option<&InFlight> {
279        self.entries.get(id)
280    }
281
282    /// The entry for `id`, mutably, if it is still in flight.
283    #[must_use]
284    pub fn get_mut(&mut self, id: &RequestId) -> Option<&mut InFlight> {
285        self.entries.get_mut(id)
286    }
287
288    /// Cancels a request.
289    ///
290    /// Never fails. See [`CancelOutcome`].
291    pub fn cancel(&mut self, id: &RequestId, reason: Option<CancelReason>) -> CancelOutcome {
292        let Some(entry) = self.entries.get(id) else {
293            return CancelOutcome::Ignored;
294        };
295        if entry.abort(AbortReason::Cancelled(reason)) {
296            CancelOutcome::Cancelled
297        } else {
298            // Already aborted, by a peer or by a deadline. Aborting again would
299            // overwrite the first reason, so it is ignored exactly as an
300            // unknown id is.
301            CancelOutcome::Ignored
302        }
303    }
304
305    /// Aborts and removes every request whose budget is spent at `now`.
306    ///
307    /// The returned ids are the ones the caller should report, in no particular
308    /// order: this runs on a tick rather than per frame, and sorting to make
309    /// the output deterministic would be work whose result nothing reads.
310    pub fn expire(&mut self, now: Instant) -> Vec<RequestId> {
311        let mut expired = Vec::new();
312        self.entries.retain(|id, entry| {
313            if entry.has_expired(now) {
314                entry.abort(AbortReason::Expired);
315                expired.push(id.clone());
316                false
317            } else {
318                true
319            }
320        });
321        expired
322    }
323
324    /// Removes a finished request and returns what it was.
325    #[must_use]
326    pub fn complete(&mut self, id: &RequestId) -> Option<InFlight> {
327        self.entries.remove(id)
328    }
329
330    /// The ids of every request still in flight.
331    pub fn ids(&self) -> impl Iterator<Item = &RequestId> {
332        self.entries.keys()
333    }
334
335    /// How many requests are in flight.
336    #[must_use]
337    pub fn len(&self) -> usize {
338        self.entries.len()
339    }
340
341    /// Whether nothing is in flight.
342    #[must_use]
343    pub fn is_empty(&self) -> bool {
344        self.entries.is_empty()
345    }
346
347    /// Forgets every request, aborting each so no handler is left waiting.
348    ///
349    /// Used when a session closes. The requests are not going to finish, and a
350    /// handler awaiting a signal that can never arrive is a leaked task rather
351    /// than a slow one.
352    pub fn drain(&mut self) -> Vec<RequestId> {
353        let mut ids = Vec::with_capacity(self.entries.len());
354        for (id, entry) in &self.entries {
355            entry.abort(AbortReason::SessionClosed);
356            ids.push(id.clone());
357        }
358        self.entries.clear();
359        ids
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use std::time::Duration;
366
367    use super::{
368        CancelOutcome, DuplicateRequest, InFlight, RequestOrigin, RequestRegistry, UnknownRequest,
369    };
370    use crate::domain::RequestId;
371    use crate::domain::session::cancellation::{AbortReason, CancelReason, CancellationToken};
372    use crate::domain::session::progress::ProgressToken;
373    use crate::domain::session::state::Instant;
374
375    /// Why a test could not set up what it meant to.
376    ///
377    /// Every call in here is fallible — a duplicate id, or an entry that was
378    /// expected to still be in flight and was not. Propagating that through
379    /// `?` keeps the failure in the test result, where the harness prints it,
380    /// rather than in a panic raised by an unwrap that says nothing about what
381    /// the test was checking.
382    #[derive(Debug)]
383    #[expect(dead_code, reason = "the payloads are read by the derived Debug")]
384    enum Failure {
385        /// The registry refused a registration.
386        Duplicate(DuplicateRequest),
387        /// An entry the test had just registered was not there.
388        Missing(&'static str),
389    }
390
391    impl From<DuplicateRequest> for Failure {
392        fn from(duplicate: DuplicateRequest) -> Self {
393            Self::Duplicate(duplicate)
394        }
395    }
396
397    fn at(millis: u64) -> Instant {
398        Instant::from_duration(Duration::from_millis(millis))
399    }
400
401    fn entry(token: Option<ProgressToken>, budget: Option<Duration>) -> InFlight {
402        InFlight::new(RequestOrigin::Inbound, "tools/call", token, budget, at(0))
403    }
404
405    /// The cancellation handle for `id`, or a failure naming what was missing.
406    fn handle(registry: &RequestRegistry, id: &RequestId) -> Result<CancellationToken, Failure> {
407        registry
408            .get(id)
409            .map(InFlight::cancellation_handle)
410            .ok_or(Failure::Missing("the request that was just registered"))
411    }
412
413    #[test]
414    fn a_request_is_found_until_it_completes() -> Result<(), Failure> {
415        let mut registry = RequestRegistry::new();
416        let id = RequestId::number(1);
417        registry.register(id.clone(), entry(None, None))?;
418        assert_eq!(registry.len(), 1);
419        assert_eq!(registry.get(&id).map(InFlight::method), Some("tools/call"));
420
421        assert!(registry.complete(&id).is_some());
422        assert!(registry.is_empty());
423        // Completing twice is the same no-op a late cancellation is.
424        assert!(registry.complete(&id).is_none());
425        Ok(())
426    }
427
428    #[test]
429    fn a_duplicate_in_flight_id_is_refused_and_the_first_entry_survives() -> Result<(), Failure> {
430        let mut registry = RequestRegistry::new();
431        let id = RequestId::number(1);
432        registry.register(id.clone(), entry(None, None))?;
433
434        let refused = registry.register(
435            id.clone(),
436            InFlight::new(RequestOrigin::Inbound, "resources/read", None, None, at(0)),
437        );
438        assert_matches::assert_matches!(
439            refused,
440            Err(DuplicateRequest { id: ref reported }) if *reported == RequestId::number(1)
441        );
442        // The displacement a silently-overwriting implementation would have
443        // done is exactly what makes a later cancellation abort the wrong work.
444        assert_eq!(registry.get(&id).map(InFlight::method), Some("tools/call"));
445        Ok(())
446    }
447
448    /// The schema says a cancellation notification "MAY arrive after the
449    /// request has already finished" — so this is a case to absorb, not report.
450    #[test]
451    fn cancelling_a_finished_or_unknown_request_is_a_no_op() -> Result<(), Failure> {
452        let mut registry = RequestRegistry::new();
453        let id = RequestId::number(7);
454        registry.register(id.clone(), entry(None, None))?;
455        assert!(
456            registry.complete(&id).is_some(),
457            "the entry was registered a line ago"
458        );
459
460        assert_eq!(registry.cancel(&id, None), CancelOutcome::Ignored);
461        assert_eq!(
462            registry.cancel(&RequestId::number(99), None),
463            CancelOutcome::Ignored
464        );
465        Ok(())
466    }
467
468    #[test]
469    fn cancelling_twice_reports_only_the_first() -> Result<(), Failure> {
470        let mut registry = RequestRegistry::new();
471        let id = RequestId::number(1);
472        registry.register(id.clone(), entry(None, None))?;
473
474        let first = registry.cancel(&id, Some(CancelReason::new("user")));
475        let second = registry.cancel(&id, Some(CancelReason::new("timeout")));
476
477        assert_eq!(first, CancelOutcome::Cancelled);
478        assert_eq!(second, CancelOutcome::Ignored);
479        // The first reason stands: a deadline landing after a peer's
480        // cancellation must not rewrite why the request actually stopped.
481        assert_eq!(
482            registry
483                .get(&id)
484                .and_then(|entry| entry.cancellation().reason()),
485            Some(&AbortReason::Cancelled(Some(CancelReason::new("user"))))
486        );
487        Ok(())
488    }
489
490    #[test]
491    fn a_zero_budget_expires_immediately_and_an_absent_budget_never_expires() -> Result<(), Failure>
492    {
493        let mut registry = RequestRegistry::new();
494        let bounded = RequestId::number(1);
495        let unbounded = RequestId::number(2);
496        registry.register(
497            bounded.clone(),
498            entry(None, Some(Duration::from_millis(100))),
499        )?;
500        registry.register(unbounded.clone(), entry(None, None))?;
501
502        assert!(
503            registry.expire(at(99)).is_empty(),
504            "expired a millisecond early"
505        );
506        assert_eq!(registry.expire(at(100)), vec![bounded]);
507        assert_eq!(registry.len(), 1);
508        // Unbounded is still there, and still not expired, a long time later.
509        assert!(registry.expire(at(1_000_000)).is_empty());
510        assert!(registry.get(&unbounded).is_some());
511        Ok(())
512    }
513
514    #[test]
515    fn an_expired_request_is_aborted_so_its_handler_wakes() -> Result<(), Failure> {
516        let mut registry = RequestRegistry::new();
517        let id = RequestId::number(1);
518        registry.register(id.clone(), entry(None, Some(Duration::from_secs(1))))?;
519        let token = handle(&registry, &id)?;
520
521        registry.expire(at(1_001));
522        assert!(token.is_cancelled());
523        assert_eq!(token.reason(), Some(&AbortReason::Expired));
524        Ok(())
525    }
526
527    #[test]
528    fn progress_needs_a_token_and_moves_forward_only() -> Result<(), Failure> {
529        let mut registry = RequestRegistry::new();
530        let without = RequestId::number(1);
531        let with = RequestId::number(2);
532        registry.register(without.clone(), entry(None, None))?;
533        registry.register(with.clone(), entry(Some(ProgressToken::text("job")), None))?;
534
535        // No token means nothing to correlate to, so nothing is ever emitted.
536        let advanced = registry
537            .get_mut(&without)
538            .is_some_and(|entry| entry.advance_progress(1.0, None));
539        assert!(!advanced, "a request with no progress token never emits");
540
541        let entry = registry
542            .get_mut(&with)
543            .ok_or(Failure::Missing("the request carrying a progress token"))?;
544        assert!(entry.advance_progress(1.0, Some(String::from("reading"))));
545        assert!(!entry.advance_progress(1.0, None));
546        assert!(entry.advance_progress(2.0, None));
547        assert_eq!(entry.furthest_progress(), Some(2.0));
548        assert_eq!(entry.progress_token(), Some(&ProgressToken::text("job")));
549        Ok(())
550    }
551
552    #[test]
553    fn draining_aborts_everything_it_forgets() -> Result<(), Failure> {
554        let mut registry = RequestRegistry::new();
555        for n in 0..3 {
556            registry.register(RequestId::number(n), entry(None, None))?;
557        }
558        let tokens: Vec<_> = registry
559            .ids()
560            .map(|id| registry.get(id).map(InFlight::cancellation_handle))
561            .collect();
562
563        let drained = registry.drain();
564        assert_eq!(drained.len(), 3);
565        assert!(registry.is_empty());
566        assert_eq!(
567            tokens.iter().filter(|token| token.is_none()).count(),
568            0,
569            "every registered id had an entry before the drain"
570        );
571        for token in tokens.into_iter().flatten() {
572            assert_eq!(token.reason(), Some(&AbortReason::SessionClosed));
573        }
574        Ok(())
575    }
576
577    /// A peer-chosen string id must not reach a log line through an error
578    /// message. This is the boundary the redaction claim is made at.
579    #[test]
580    fn an_error_message_redacts_the_id() {
581        let secret = RequestId::text("super-secret-credential");
582        let message = DuplicateRequest { id: secret.clone() }.to_string();
583        assert!(!message.contains("super-secret-credential"), "{message}");
584        assert!(message.contains(&secret.redacted()), "{message}");
585
586        let unknown = UnknownRequest { id: secret }.to_string();
587        assert!(!unknown.contains("super-secret-credential"), "{unknown}");
588    }
589
590    #[test]
591    fn both_origins_share_one_table() -> Result<(), Failure> {
592        assert_ne!(RequestOrigin::Inbound, RequestOrigin::Outbound);
593        let mut registry = RequestRegistry::new();
594        registry.register(
595            RequestId::number(1),
596            InFlight::new(
597                RequestOrigin::Outbound,
598                "sampling/createMessage",
599                None,
600                None,
601                at(0),
602            ),
603        )?;
604        assert_eq!(
605            registry.get(&RequestId::number(1)).map(InFlight::origin),
606            Some(RequestOrigin::Outbound)
607        );
608        Ok(())
609    }
610}