Skip to main content

protocol_droid/application/session/
engine.rs

1//! The engine: a lifecycle, a state machine, and a table of what is running.
2//!
3//! # What this owns
4//!
5//! A [`Session`] and a [`RequestRegistry`], plus the negotiated
6//! [`Resolution`] a [`Lifecycle`] produced. Everything protocol-specific is a
7//! call *into* the lifecycle; the two associated types are stored opaquely and
8//! handed back whenever a judgement is needed. Nothing here names
9//! `ProtocolVersion`, `FeatureProfile`, or a method of any protocol.
10//!
11//! # Single-writer, and why that is a decision
12//!
13//! Every method here takes `&mut self`. The engine is single-writer on
14//! purpose: the session's phase, the negotiated profile, and the in-flight
15//! table have to move together, and a lock would let two of them move
16//! separately. A transport that reads frames on one task and runs handlers on
17//! others puts the engine behind a `Mutex` and calls the `&mut` methods
18//! between polls — which is why [`dispatch`](SessionEngine::dispatch) hands the
19//! handler its own [`CancellationToken`] *before* it awaits, so a canceller
20//! never needs the engine to stay unlocked across the wait.
21//!
22//! What the engine refuses to be is a `Send`-and-`Sync` bag of locks. The
23//! concurrency belongs to the adapter that knows how many tasks it has.
24
25use core::fmt;
26use std::sync::Arc;
27use std::time::Duration;
28
29use thiserror::Error;
30
31use crate::domain::RequestId;
32use crate::domain::session::{
33    AbortReason, CancelOutcome, CancelReason, CancellationToken, DuplicateRequest, InFlight,
34    LifecycleError, LifecycleEvent, ProgressToken, RequestOrigin, RequestRegistry, Session,
35    SessionId, SessionState,
36};
37
38use super::clock::Clock;
39use super::event_log::{EventLog, SessionEvent};
40use super::id_source::IdSource;
41use super::lifecycle::{Classification, Declaration, DeclarationError, Lifecycle, Resolution};
42
43/// Why a session could not do what was asked.
44#[derive(Debug, Clone, PartialEq, Eq, Error)]
45pub enum EngineError {
46    /// The protocol's declaration could not be turned into a profile.
47    #[error("the peer could not be answered: {0}")]
48    Declaration(#[from] DeclarationError),
49    /// The lifecycle refused the transition.
50    #[error(transparent)]
51    Lifecycle(#[from] LifecycleError),
52}
53
54/// A message the session would not admit, and why.
55///
56/// Separate from [`EngineError`] because nothing failed: the message was
57/// understood, the session simply is not in a position to take it, and the
58/// peer is owed a JSON-RPC error rather than a dropped connection. Every
59/// variant carries the state that produced the refusal, because that is the
60/// one fact an operator needs and the one a peer's bug report will not have.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum Refusal {
63    /// The session is closing or closed, so nothing is admitted at all.
64    ///
65    /// Checked before the lifecycle is consulted, so no protocol has to
66    /// remember to re-implement the terminal case.
67    SessionNotAccepting {
68        /// Where the session was.
69        state: SessionState,
70    },
71    /// The protocol said this method is not permitted here.
72    NotPermitted {
73        /// Where the session was.
74        state: SessionState,
75    },
76    /// The method is ordinary work and the session has not finished its
77    /// handshake.
78    HandshakeIncomplete {
79        /// Where the session was.
80        state: SessionState,
81    },
82    /// Nothing has been negotiated, so there is no profile to gate against.
83    ///
84    /// Distinct from [`HandshakeIncomplete`](Self::HandshakeIncomplete) because
85    /// the lifecycle may well have *permitted* the method and had no profile
86    /// to check it against. Reporting "not permitted" there would blame the
87    /// peer for a session that has not started.
88    Unnegotiated {
89        /// Where the session was.
90        state: SessionState,
91    },
92}
93
94impl Refusal {
95    /// The state that produced the refusal.
96    #[must_use]
97    pub const fn state(&self) -> SessionState {
98        match self {
99            Self::SessionNotAccepting { state }
100            | Self::NotPermitted { state }
101            | Self::HandshakeIncomplete { state }
102            | Self::Unnegotiated { state } => *state,
103        }
104    }
105}
106
107impl fmt::Display for Refusal {
108    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109        let state = self.state();
110        match self {
111            Self::SessionNotAccepting { .. } => {
112                write!(formatter, "the session is {state} and admits nothing")
113            }
114            Self::NotPermitted { .. } => {
115                write!(formatter, "the method is not permitted while {state}")
116            }
117            Self::HandshakeIncomplete { .. } => {
118                write!(
119                    formatter,
120                    "the session is {state} and the handshake is unfinished"
121                )
122            }
123            Self::Unnegotiated { .. } => {
124                write!(
125                    formatter,
126                    "nothing is negotiated yet and the session is {state}"
127                )
128            }
129        }
130    }
131}
132
133impl std::error::Error for Refusal {}
134
135/// What a handler produced, and what happened to it.
136///
137/// Not `Copy`: [`AbortReason`] carries the peer's own words for why it withdrew
138/// a request, and duplicating them would mean two copies of a string nobody
139/// asked for.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum Dispatch<T> {
142    /// The handler returned, and the request is no longer in flight.
143    Completed(T),
144    /// The handler returned, but the request had already been aborted.
145    ///
146    /// The value is discarded because a response to an aborted request is not
147    /// a response the specification defines: the peer has withdrawn the work,
148    /// and answering anyway is how a bridge ends up delivering a result to a
149    /// peer that has moved on.
150    Aborted {
151        /// What the handler produced before it noticed.
152        discarded: T,
153        /// Why it was stopped.
154        reason: AbortReason,
155    },
156}
157
158impl<T> Dispatch<T> {
159    /// The handler's value, whether or not it will be sent.
160    #[must_use]
161    pub fn into_inner(self) -> T {
162        match self {
163            Self::Completed(value)
164            | Self::Aborted {
165                discarded: value, ..
166            } => value,
167        }
168    }
169
170    /// Whether the result will be written to the peer.
171    #[must_use]
172    pub const fn is_completed(&self) -> bool {
173        matches!(self, Self::Completed(_))
174    }
175}
176
177/// A session, its protocol, and everything in flight on it.
178///
179/// `L` is the protocol, `C` the clock, `I` the id source, `G` the event log.
180/// All four are consumed by `impl Trait` at the call site so a server with one
181/// of each monomorphises once.
182///
183/// # Examples
184///
185/// ```
186/// use std::time::Duration;
187///
188/// use protocol_droid::application::session::{
189///     Clock, Declaration, DeclarationError, EngineError, IdSource, Lifecycle, NullEventLog,
190///     Resolution, SessionEngine,
191/// };
192/// use protocol_droid::domain::session::{Instant, SessionId, SessionState};
193///
194/// struct Fixed;
195///
196/// impl Clock for Fixed {
197///     fn now(&self) -> Instant {
198///         Instant::from_duration(Duration::ZERO)
199///     }
200/// }
201///
202/// struct Counting(std::sync::atomic::AtomicU64);
203///
204/// impl IdSource for Counting {
205///     fn next_session_id(&self) -> SessionId {
206///         SessionId::new(self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
207///     }
208/// }
209///
210/// /// A protocol that agrees to whatever it is asked for.
211/// struct Echo;
212///
213/// impl Lifecycle for Echo {
214///     type Profile = ();
215///     type Version = u16;
216///
217///     fn classify(
218///         &self,
219///         state: SessionState,
220///         _profile: Option<()>,
221///         method: &str,
222///     ) -> protocol_droid::application::session::Classification {
223///         use protocol_droid::application::session::Classification;
224///         match method {
225///             "handshake/start" => Classification::HandshakeStart,
226///             "handshake/confirm" => Classification::HandshakeConfirm,
227///             _ if state.admits_ordinary_work() => Classification::Ordinary,
228///             _ => Classification::Refused,
229///         }
230///     }
231///
232///     fn resolve(
233///         &self,
234///         declaration: Declaration<'_>,
235///     ) -> Result<Resolution<(), u16>, DeclarationError> {
236///         let Declaration::Handshake { version, .. } = declaration else {
237///             return Err(DeclarationError::NoVersion);
238///         };
239///         Ok(Resolution {
240///             profile: (),
241///             version: u16::try_from(version.len()).unwrap_or(u16::MAX),
242///             echoed: true,
243///         })
244///     }
245/// }
246///
247/// let mut engine = SessionEngine::open(
248///     Echo,
249///     Fixed,
250///     &Counting(std::sync::atomic::AtomicU64::new(0)),
251///     NullEventLog,
252/// );
253/// assert_eq!(engine.state(), SessionState::Uninitialised);
254/// # Ok::<(), EngineError>(())
255/// ```
256pub struct SessionEngine<L, C, G>
257where
258    L: Lifecycle,
259{
260    lifecycle: L,
261    clock: C,
262    log: G,
263    session: Session,
264    registry: RequestRegistry,
265    resolved: Option<Resolution<L::Profile, L::Version>>,
266}
267
268impl<L, C, G> fmt::Debug for SessionEngine<L, C, G>
269where
270    L: Lifecycle,
271    C: fmt::Debug,
272    G: fmt::Debug,
273{
274    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
275        // The ports are named but not printed: a `dyn` inside one of them has
276        // no useful `Debug`, and the session's own state is the fact a reader
277        // came for. `finish_non_exhaustive` says so to a reader who diffs two
278        // renderings, rather than letting them assume the struct has no other
279        // fields.
280        formatter
281            .debug_struct("SessionEngine")
282            .field("id", &self.session.id())
283            .field("state", &self.session.state())
284            .field("in_flight", &self.registry.len())
285            .field("negotiated", &self.resolved.is_some())
286            .finish_non_exhaustive()
287    }
288}
289
290impl<L, C, G> SessionEngine<L, C, G>
291where
292    L: Lifecycle,
293    C: Clock,
294    G: EventLog,
295{
296    /// Opens a session and records that it exists.
297    ///
298    /// The identity comes from the injected source rather than from a counter
299    /// in here, so a deployment that needs ids unique across processes seeds
300    /// its own source and this constructor does not change.
301    #[must_use]
302    pub fn open<J: IdSource>(lifecycle: L, clock: C, ids: &J, log: G) -> Self {
303        let session = Session::new(ids.next_session_id(), clock.now());
304        log.record(SessionEvent::Opened {
305            session: session.id(),
306            at: session.opened_at(),
307        });
308        Self {
309            lifecycle,
310            clock,
311            log,
312            session,
313            registry: RequestRegistry::new(),
314            resolved: None,
315        }
316    }
317
318    /// Opens a session that negotiates per message and retains nothing.
319    ///
320    /// The 2026-07-28 shape: there is no handshake to wait for, and the
321    /// session exists only so a request has something to be negotiated
322    /// against.
323    #[must_use]
324    pub fn open_stateless<J: IdSource>(lifecycle: L, clock: C, ids: &J, log: G) -> Self {
325        let session = Session::stateless(ids.next_session_id(), clock.now());
326        log.record(SessionEvent::Opened {
327            session: session.id(),
328            at: session.opened_at(),
329        });
330        Self {
331            lifecycle,
332            clock,
333            log,
334            session,
335            registry: RequestRegistry::new(),
336            resolved: None,
337        }
338    }
339
340    /// Which session this is.
341    #[must_use]
342    pub const fn id(&self) -> SessionId {
343        self.session.id()
344    }
345
346    /// Where it is in its lifecycle.
347    #[must_use]
348    pub const fn state(&self) -> SessionState {
349        self.session.state()
350    }
351
352    /// The negotiated profile, if anything has been negotiated.
353    #[must_use]
354    pub const fn resolution(&self) -> Option<Resolution<L::Profile, L::Version>> {
355        self.resolved
356    }
357
358    /// The protocol this session speaks.
359    #[must_use]
360    pub const fn lifecycle(&self) -> &L {
361        &self.lifecycle
362    }
363
364    /// How many requests are in flight.
365    #[must_use]
366    pub fn in_flight(&self) -> usize {
367        self.registry.len()
368    }
369
370    /// The ids of everything in flight, in no particular order.
371    pub fn in_flight_ids(&self) -> impl Iterator<Item = &RequestId> {
372        self.registry.ids()
373    }
374
375    /// The entry for `id`, if it is still running.
376    #[must_use]
377    pub fn request(&self, id: &RequestId) -> Option<&InFlight> {
378        self.registry.get(id)
379    }
380
381    /// Decides whether a message naming `method` may run.
382    ///
383    /// The order of the checks is the contract, and it is not arbitrary:
384    /// terminal first, so a closing session is closed to the lifecycle's
385    /// opinion of it; then the protocol's own classification; then the
386    /// handshake gate; then the profile gate. A lifecycle that answers
387    /// `Ordinary` for a method on an uninitialised session is describing its
388    /// own vocabulary, not overriding the state machine.
389    ///
390    /// # Errors
391    ///
392    /// A [`Refusal`], carrying the state that produced it.
393    pub fn admit(&self, method: &str, id: Option<&RequestId>) -> Result<Classification, Refusal> {
394        let state = self.session.state();
395        if state.is_terminal() || state == SessionState::Closing {
396            return Err(self.refuse(Refusal::SessionNotAccepting { state }, method, id));
397        }
398        let profile = self.resolved.map(|resolved| resolved.profile);
399        match self.lifecycle.classify(state, profile, method) {
400            Classification::Refused => {
401                Err(self.refuse(Refusal::NotPermitted { state }, method, id))
402            }
403            classification
404            @ (Classification::HandshakeStart | Classification::HandshakeConfirm) => {
405                Ok(classification)
406            }
407            Classification::Ordinary if !state.admits_ordinary_work() => {
408                Err(self.refuse(Refusal::HandshakeIncomplete { state }, method, id))
409            }
410            Classification::Ordinary if self.resolved.is_none() => {
411                Err(self.refuse(Refusal::Unnegotiated { state }, method, id))
412            }
413            Classification::Ordinary => Ok(Classification::Ordinary),
414        }
415    }
416
417    /// Turns what a peer declared into a profile, and moves the session on.
418    ///
419    /// The event applied is chosen by the session's current state rather than
420    /// by what was declared: a stateless session stays stateless, and a
421    /// stateful one starts its handshake. That keeps "went stateless" a
422    /// property of how the session was opened, which is the only place the
423    /// distinction is actually known.
424    ///
425    /// # Errors
426    ///
427    /// [`EngineError::Declaration`] when the declaration is not something the
428    /// protocol can answer, and [`EngineError::Lifecycle`] when the resulting
429    /// transition is not legal from here — a second handshake on a session
430    /// that already has one.
431    pub fn negotiate(
432        &mut self,
433        declaration: Declaration<'_>,
434    ) -> Result<Resolution<L::Profile, L::Version>, EngineError> {
435        let resolution = self.lifecycle.resolve(declaration)?;
436        // Only apply a transition when the session is actually going to move.
437        // A session opened with `open_stateless` is already `Stateless`, and
438        // `WentStateless` is only legal from `Uninitialised`, so re-applying it
439        // would turn the second message of a stateless exchange into a fatal
440        // transition error.
441        if !self.session.state().is_stateless() {
442            self.transition(LifecycleEvent::HandshakeStarted)?;
443        }
444        let version = resolution.version.to_string();
445        self.log.record(SessionEvent::Negotiated {
446            session: self.session.id(),
447            at: self.clock.now(),
448            version,
449            echoed: resolution.echoed,
450        });
451        self.resolved = Some(resolution);
452        Ok(resolution)
453    }
454
455    /// Records that the peer confirmed the handshake.
456    ///
457    /// Separate from [`negotiate`](Self::negotiate) because the specification
458    /// has two messages here and only the first one carries a version: a peer
459    /// that sends the second is saying "I have it", and there is nothing left
460    /// to parse.
461    ///
462    /// # Errors
463    ///
464    /// [`EngineError::Lifecycle`] when the session is not waiting for a
465    /// confirmation.
466    pub fn confirm(&mut self) -> Result<(), EngineError> {
467        self.transition(LifecycleEvent::HandshakeConfirmed)?;
468        self.log.record(SessionEvent::Operating {
469            session: self.session.id(),
470            at: self.clock.now(),
471        });
472        Ok(())
473    }
474
475    /// Starts closing, and aborts everything that was still running.
476    ///
477    /// The aborts happen here rather than at [`finish`](Self::finish) because
478    /// a handler awaiting a signal that will never arrive is a leaked task,
479    /// and the whole point of entering `Closing` is that nothing more will be
480    /// admitted.
481    ///
482    /// # Errors
483    ///
484    /// [`EngineError::Lifecycle`] from a session that is already closing or
485    /// closed.
486    pub fn close(&mut self) -> Result<Vec<RequestId>, EngineError> {
487        self.transition(LifecycleEvent::CloseRequested)?;
488        Ok(self.abandon(&AbortReason::SessionClosed))
489    }
490
491    /// Records that the session finished. Terminal.
492    ///
493    /// # Errors
494    ///
495    /// [`EngineError::Lifecycle`] from a session that is not closing.
496    pub fn finish(&mut self) -> Result<(), EngineError> {
497        self.transition(LifecycleEvent::Finished)
498    }
499
500    /// Records a request as in flight and hands back its cancellation handle.
501    ///
502    /// The handle is returned rather than left in the table because a handler
503    /// that has to await it cannot hold the engine's borrow across the wait,
504    /// and this engine is single-writer. Everything a canceller needs is on
505    /// the token; everything an observer needs is on
506    /// [`request`](Self::request).
507    ///
508    /// # Errors
509    ///
510    /// [`DuplicateRequest`] when the id is already in flight. The existing
511    /// entry is untouched — the first request owns the id.
512    pub fn register(
513        &mut self,
514        id: RequestId,
515        origin: RequestOrigin,
516        method: &str,
517        token: Option<ProgressToken>,
518        budget: Option<Duration>,
519    ) -> Result<CancellationToken, DuplicateRequest> {
520        let entry = InFlight::new(origin, method, token, budget, self.clock.now());
521        let handle = entry.cancellation_handle();
522        let method = Arc::from(method);
523        self.registry.register(id.clone(), entry)?;
524        self.log.record(SessionEvent::Registered {
525            session: self.session.id(),
526            at: self.clock.now(),
527            id,
528            method,
529            origin,
530        });
531        Ok(handle)
532    }
533
534    /// The cancellation handle for `id`, without taking the engine mutably.
535    ///
536    /// The read-only half of [`register`](Self::register), for a task that is
537    /// already running and wants to stop waiting when the peer says so.
538    #[must_use]
539    pub fn cancellation(&self, id: &RequestId) -> Option<CancellationToken> {
540        self.registry.get(id).map(InFlight::cancellation_handle)
541    }
542
543    /// Cancels a request.
544    ///
545    /// Never fails. See [`CancelOutcome`], and the specification's reason for
546    /// it: a cancellation notification may arrive after its request has
547    /// finished, and reporting that as an error would turn a race the sender
548    /// is entitled to lose into a fault.
549    pub fn cancel(&mut self, id: &RequestId, reason: Option<CancelReason>) -> CancelOutcome {
550        // Cloned because the reason is recorded twice: the registry takes it
551        // as the reason the token was aborted, and the event log takes it as
552        // the reason the operator will read. `CancelReason` is the peer's own
553        // words, so there is no cheaper spelling of "keep it".
554        let outcome = self.registry.cancel(id, reason.clone());
555        if outcome.cancelled() {
556            self.log_abort(id, AbortReason::Cancelled(reason));
557        }
558        outcome
559    }
560
561    /// Records progress for `id` and says whether it should go on the wire.
562    ///
563    /// `false` for an unknown id, for one that carried no progress token, and
564    /// for a value that does not move that request's cursor forward.
565    pub fn progress(&mut self, id: &RequestId, progress: f64, message: Option<String>) -> bool {
566        self.registry
567            .get_mut(id)
568            .is_some_and(|entry| entry.advance_progress(progress, message))
569    }
570
571    /// Aborts and forgets every request whose budget is spent.
572    ///
573    /// Returns the ids that were expired, in no particular order. This is a
574    /// tick rather than a per-frame cost: the arithmetic is a subtraction and
575    /// the table walk is proportional to what is in flight, which is bounded
576    /// by what the peer asked for.
577    pub fn expire(&mut self) -> Vec<RequestId> {
578        let expired = self.registry.expire(self.clock.now());
579        for id in &expired {
580            self.log_abort(id, AbortReason::Expired);
581        }
582        expired
583    }
584
585    /// Forgets a finished request, and says whether it may be answered.
586    ///
587    /// `None` for an id that is not in flight, which is the case a transport
588    /// has to treat as "already reported" rather than as a fault. `Some(reason)`
589    /// means the request was aborted while it ran and **no response is due** —
590    /// the peer withdrew it, or its budget ran out.
591    pub fn complete(&mut self, id: &RequestId) -> Option<Option<AbortReason>> {
592        let entry = self.registry.complete(id)?;
593        let reason = entry.cancellation().reason().cloned();
594        let method = Arc::from(entry.method());
595        let at = self.clock.now();
596        // Matched on a reference so the reason reaches the log without being
597        // moved out from under the return value the caller needs to decide
598        // whether a response is still owed.
599        match reason.as_ref() {
600            Some(reason) => self.log.record(SessionEvent::Aborted {
601                session: self.session.id(),
602                at,
603                id: id.clone(),
604                method,
605                reason: reason.clone(),
606            }),
607            None => self.log.record(SessionEvent::Completed {
608                session: self.session.id(),
609                at,
610                id: id.clone(),
611                method,
612            }),
613        }
614        Some(reason)
615    }
616
617    /// Runs one request from registration to completion.
618    ///
619    /// The three steps are the specification's ordering and the engine's
620    /// whole reason for existing: register so a cancellation has something to
621    /// find, run the handler with a handle it owns, then remove the entry and
622    /// report what happened. A handler that returns after its request was
623    /// aborted produces [`Dispatch::Aborted`], and the caller writes nothing —
624    /// that is the invariant the cancellation test pins.
625    ///
626    /// The handler is an [`AsyncFnOnce`] rather than a boxed future so the
627    /// closure is monomorphised with the call rather than erased behind a
628    /// `Pin<Box<dyn Future>>`; it is called once, so `async_call_once`'s
629    /// owning-capture future is the right shape.
630    ///
631    /// # Errors
632    ///
633    /// [`DuplicateRequest`] when the id is already in flight. Nothing is run
634    /// and the existing entry is left alone.
635    pub async fn dispatch<H, T>(
636        &mut self,
637        id: RequestId,
638        origin: RequestOrigin,
639        method: &str,
640        token: Option<ProgressToken>,
641        budget: Option<Duration>,
642        handler: H,
643    ) -> Result<Dispatch<T>, DuplicateRequest>
644    where
645        H: AsyncFnOnce(CancellationToken) -> T,
646    {
647        let handle = self.register(id.clone(), origin, method, token, budget)?;
648        let produced: T = handler(handle).await;
649        Ok(match self.complete(&id) {
650            // Nothing in flight: something else already removed it. The
651            // handler's value cannot be attributed to a live request, so it is
652            // reported as aborted rather than sent.
653            None => Dispatch::Aborted {
654                discarded: produced,
655                reason: AbortReason::SessionClosed,
656            },
657            Some(None) => Dispatch::Completed(produced),
658            Some(Some(reason)) => Dispatch::Aborted {
659                discarded: produced,
660                reason,
661            },
662        })
663    }
664
665    /// Applies a lifecycle event, logging the state it moved to.
666    fn transition(&mut self, event: LifecycleEvent) -> Result<(), EngineError> {
667        self.session = self.session.apply(event)?;
668        self.log.record(SessionEvent::StateChanged {
669            session: self.session.id(),
670            at: self.clock.now(),
671            state: self.session.state(),
672        });
673        Ok(())
674    }
675
676    /// Records a refusal against the event log.
677    fn refuse(&self, refusal: Refusal, method: &str, id: Option<&RequestId>) -> Refusal {
678        self.log.record(SessionEvent::Refused {
679            session: self.session.id(),
680            at: self.clock.now(),
681            id: id.cloned(),
682            method: Arc::from(method),
683            state: refusal.state(),
684        });
685        refusal
686    }
687
688    /// Records why an id was aborted, reading the reason back off the entry.
689    ///
690    /// The reason is read rather than passed in because a deadline and a
691    /// peer's notification can land in the same instant, and the one the
692    /// request actually acted on is the one a post-mortem needs.
693    fn log_abort(&self, id: &RequestId, fallback: AbortReason) {
694        let Some(entry) = self.registry.get(id) else {
695            return;
696        };
697        let reason = entry.cancellation().reason().cloned().unwrap_or(fallback);
698        self.log.record(SessionEvent::Aborted {
699            session: self.session.id(),
700            at: self.clock.now(),
701            id: id.clone(),
702            method: Arc::from(entry.method()),
703            reason,
704        });
705    }
706
707    /// Aborts everything in flight and returns the ids.
708    ///
709    /// `&mut self` because [`RequestRegistry::drain`] takes the table and
710    /// leaves it empty. The reasons are read off each entry *before* the
711    /// drain, because afterwards there is no entry left to read them from.
712    fn abandon(&mut self, reason: &AbortReason) -> Vec<RequestId> {
713        let recorded: Vec<(RequestId, Arc<str>)> = self
714            .registry
715            .ids()
716            .map(|id| {
717                let method = self.registry.get(id).map_or("", InFlight::method);
718                (id.clone(), Arc::from(method))
719            })
720            .collect();
721        let ids = self.registry.drain();
722        let at = self.clock.now();
723        let session = self.session.id();
724        for (id, method) in recorded {
725            self.log.record(SessionEvent::Aborted {
726                session,
727                at,
728                id,
729                method,
730                reason: reason.clone(),
731            });
732        }
733        ids
734    }
735}
736
737#[cfg(test)]
738mod tests {
739    use core::sync::atomic::{AtomicU64, Ordering};
740    use core::time::Duration;
741
742    use serde_json::json;
743
744    use super::*;
745    use crate::application::session::lifecycle::MetaView;
746    use crate::domain::session::{Instant, SessionId};
747
748    /// Everything the engine can be asked to do, in one error.
749    ///
750    /// The engine's own methods answer three unrelated error types, and a
751    /// test that had to unwrap each one would be testing its fixtures rather
752    /// than the engine. Collapsing them here lets every test body be a plain
753    /// `?`, so a failure surfaces as the assertion that noticed it. The
754    /// payloads are carried rather than discarded so that a failing test
755    /// prints the engine's own account of what it refused.
756    #[derive(Debug)]
757    #[expect(dead_code, reason = "the payloads are read by the derived Debug")]
758    enum Failure {
759        /// A declaration or transition the engine refused.
760        Engine(EngineError),
761        /// A message the engine would not admit.
762        Refusal(Refusal),
763        /// A request id that was already in flight.
764        Duplicate(DuplicateRequest),
765        /// A test future suspended. None of these do, so reaching it means a
766        /// handler grew a `.await` the harness cannot drive.
767        Suspended,
768        /// A task on a real runtime did not finish inside the guard. The
769        /// guard is deliberately far larger than any real budget, so reaching
770        /// it means the task was never woken at all rather than that it was
771        /// slow.
772        TimedOut(&'static str),
773        /// The dispatched request was answered when it should not have been.
774        /// Carries what came back so the failure names the wrong variant.
775        NotAborted(String),
776    }
777
778    impl From<EngineError> for Failure {
779        fn from(error: EngineError) -> Self {
780            Self::Engine(error)
781        }
782    }
783
784    impl From<Refusal> for Failure {
785        fn from(refusal: Refusal) -> Self {
786            Self::Refusal(refusal)
787        }
788    }
789
790    impl From<DuplicateRequest> for Failure {
791        fn from(duplicate: DuplicateRequest) -> Self {
792            Self::Duplicate(duplicate)
793        }
794    }
795
796    /// A lifecycle that answers `Ordinary` for everything the handshake is not,
797    /// and so leaves the engine's own gates as the only thing refusing. It
798    /// still has to name the two handshake methods, because a real protocol
799    /// knows them by name and the engine is not allowed to.
800    struct Open;
801
802    impl Lifecycle for Open {
803        type Profile = ();
804        type Version = u16;
805
806        fn classify(
807            &self,
808            _state: SessionState,
809            _profile: Option<()>,
810            method: &str,
811        ) -> Classification {
812            match method {
813                "initialize" => Classification::HandshakeStart,
814                "notifications/initialized" => Classification::HandshakeConfirm,
815                _ => Classification::Ordinary,
816            }
817        }
818
819        fn resolve(
820            &self,
821            declaration: Declaration<'_>,
822        ) -> Result<Resolution<(), u16>, DeclarationError> {
823            let version = match declaration {
824                Declaration::Handshake { version, .. } => {
825                    u16::try_from(version.len()).unwrap_or(u16::MAX)
826                }
827                Declaration::Stateless { .. } => 1,
828            };
829            Ok(Resolution {
830                profile: (),
831                version,
832                echoed: true,
833            })
834        }
835    }
836
837    /// A lifecycle that narrows what its own phase would permit: it refuses
838    /// until it has a profile to answer with. Nothing else in these tests
839    /// exercises the protocol's veto, only the engine's gates.
840    struct Strict;
841
842    impl Lifecycle for Strict {
843        type Profile = bool;
844        type Version = u16;
845
846        fn classify(
847            &self,
848            _state: SessionState,
849            profile: Option<bool>,
850            _method: &str,
851        ) -> Classification {
852            match profile {
853                Some(true) => Classification::Ordinary,
854                _ => Classification::Refused,
855            }
856        }
857
858        fn resolve(
859            &self,
860            declaration: Declaration<'_>,
861        ) -> Result<Resolution<bool, u16>, DeclarationError> {
862            let Declaration::Handshake { version, .. } = declaration else {
863                return Err(DeclarationError::NoVersion);
864            };
865            Ok(Resolution {
866                profile: true,
867                version: u16::try_from(version.len()).unwrap_or(u16::MAX),
868                echoed: true,
869            })
870        }
871    }
872
873    struct Fixed(Instant);
874
875    impl Clock for Fixed {
876        fn now(&self) -> Instant {
877            self.0
878        }
879    }
880
881    /// A clock a test can move, so a budget can expire without a sleep.
882    struct Stepping {
883        now: AtomicU64,
884    }
885
886    /// Borrowed so the same clock outlives the engine it was moved into and
887    /// the test can still advance it afterwards.
888    impl Clock for &Stepping {
889        fn now(&self) -> Instant {
890            <Stepping as Clock>::now(self)
891        }
892    }
893
894    impl Stepping {
895        fn new() -> Self {
896            Self {
897                now: AtomicU64::new(0),
898            }
899        }
900
901        fn advance(&self, by: Duration) {
902            let millis = u64::try_from(by.as_millis()).unwrap_or(u64::MAX);
903            self.now.fetch_add(millis, Ordering::Relaxed);
904        }
905    }
906
907    impl Clock for Stepping {
908        fn now(&self) -> Instant {
909            Instant::from_duration(Duration::from_millis(self.now.load(Ordering::Relaxed)))
910        }
911    }
912
913    struct Counting;
914
915    impl IdSource for Counting {
916        fn next_session_id(&self) -> SessionId {
917            SessionId::new(1)
918        }
919    }
920
921    /// Keeps every event, so a test can assert on what the engine reported
922    /// rather than only on what it returned.
923    #[derive(Default)]
924    struct Recording {
925        events: core::cell::RefCell<Vec<SessionEvent>>,
926    }
927
928    impl EventLog for Recording {
929        fn record(&self, event: SessionEvent) {
930            self.events.borrow_mut().push(event);
931        }
932    }
933
934    impl Recording {
935        fn kinds(&self) -> Vec<&'static str> {
936            self.events
937                .borrow()
938                .iter()
939                .map(|event| match event {
940                    SessionEvent::Opened { .. } => "opened",
941                    SessionEvent::Negotiated { .. } => "negotiated",
942                    SessionEvent::Operating { .. } => "operating",
943                    SessionEvent::Registered { .. } => "registered",
944                    SessionEvent::Completed { .. } => "completed",
945                    SessionEvent::Aborted { .. } => "aborted",
946                    SessionEvent::Refused { .. } => "refused",
947                    SessionEvent::StateChanged { .. } => "state-changed",
948                })
949                .collect()
950        }
951    }
952
953    fn engine() -> SessionEngine<Open, Fixed, Recording> {
954        SessionEngine::open(
955            Open,
956            Fixed(Instant::from_duration(Duration::ZERO)),
957            &Counting,
958            Recording::default(),
959        )
960    }
961
962    /// An engine that has completed its handshake.
963    fn negotiated() -> Result<SessionEngine<Open, Fixed, Recording>, Failure> {
964        let mut engine = engine();
965        engine.negotiate(Declaration::Handshake {
966            version: "2025-06-18",
967            capabilities: Some(&json!({})),
968        })?;
969        engine.confirm()?;
970        Ok(engine)
971    }
972
973    #[test]
974    fn a_new_session_admits_nothing_until_the_handshake_settles() {
975        let engine = engine();
976        assert_eq!(engine.state(), SessionState::Uninitialised);
977        assert_eq!(
978            engine.admit("tools/list", None),
979            Err(Refusal::HandshakeIncomplete {
980                state: SessionState::Uninitialised
981            })
982        );
983        assert!(engine.resolution().is_none());
984    }
985
986    /// A handshake is the one thing admitted before the handshake is done —
987    /// the engine cannot gate the gate on itself.
988    #[test]
989    fn the_handshake_messages_pass_the_gate_that_ordinary_work_fails() -> Result<(), Failure> {
990        let mut engine = engine();
991        assert_eq!(
992            engine.admit("initialize", None)?,
993            Classification::HandshakeStart,
994            "the handshake is always admitted"
995        );
996        engine.negotiate(Declaration::Handshake {
997            version: "2025-06-18",
998            capabilities: None,
999        })?;
1000        assert_eq!(
1001            engine.admit("notifications/initialized", None)?,
1002            Classification::HandshakeConfirm,
1003            "the confirmation is admitted too"
1004        );
1005        Ok(())
1006    }
1007
1008    #[test]
1009    fn the_handshake_moves_the_session_to_operating() -> Result<(), Failure> {
1010        let engine = negotiated()?;
1011        assert_eq!(engine.state(), SessionState::Operating);
1012        assert_eq!(engine.admit("tools/list", None)?, Classification::Ordinary);
1013        Ok(())
1014    }
1015
1016    #[test]
1017    fn the_lifecycle_decides_admission_not_just_the_phase() -> Result<(), Failure> {
1018        let mut engine = SessionEngine::open(
1019            Strict,
1020            Fixed(Instant::from_duration(Duration::ZERO)),
1021            &Counting,
1022            Recording::default(),
1023        );
1024        // No profile yet, so the protocol itself says no — a different
1025        // refusal from the one an uninitialised phase would produce.
1026        assert_eq!(
1027            engine.admit("tools/list", None),
1028            Err(Refusal::NotPermitted {
1029                state: SessionState::Uninitialised
1030            }),
1031            "the protocol's own veto, not the phase gate"
1032        );
1033
1034        engine.negotiate(Declaration::Handshake {
1035            version: "2025-06-18",
1036            capabilities: None,
1037        })?;
1038        engine.confirm()?;
1039        assert_eq!(engine.admit("tools/list", None)?, Classification::Ordinary);
1040        Ok(())
1041    }
1042
1043    #[test]
1044    fn a_stateless_session_never_waits_for_a_handshake() -> Result<(), Failure> {
1045        let mut engine = SessionEngine::open_stateless(
1046            Open,
1047            Fixed(Instant::from_duration(Duration::ZERO)),
1048            &Counting,
1049            Recording::default(),
1050        );
1051        assert!(engine.state().is_stateless());
1052        engine.negotiate(Declaration::Stateless {
1053            meta: MetaView::new(None),
1054        })?;
1055        // Still stateless: nothing moved it into the stateful lifecycle.
1056        assert!(engine.state().is_stateless());
1057        Ok(())
1058    }
1059
1060    #[test]
1061    fn a_duplicate_in_flight_id_is_refused_and_leaves_the_first_alone() -> Result<(), Failure> {
1062        let mut engine = negotiated()?;
1063        let id = RequestId::text("a");
1064        let Ok(_) = engine.register(id.clone(), RequestOrigin::Inbound, "tools/call", None, None)
1065        else {
1066            return Err(Failure::Suspended);
1067        };
1068        assert_eq!(
1069            engine
1070                .register(id.clone(), RequestOrigin::Inbound, "tools/call", None, None)
1071                .map(|_| ()),
1072            Err(DuplicateRequest { id }),
1073            "the id is already in flight"
1074        );
1075        assert_eq!(engine.in_flight(), 1, "the original entry survives");
1076        Ok(())
1077    }
1078
1079    #[test]
1080    fn cancelling_an_unknown_or_finished_request_is_a_no_op() -> Result<(), Failure> {
1081        let mut engine = negotiated()?;
1082        let outcome = engine.cancel(&RequestId::text("never-seen"), None);
1083        assert!(!outcome.cancelled());
1084        assert_eq!(engine.in_flight(), 0);
1085        Ok(())
1086    }
1087
1088    #[test]
1089    fn cancelling_an_in_flight_request_is_visible_to_its_handler() -> Result<(), Failure> {
1090        let mut engine = negotiated()?;
1091        let id = RequestId::text("a");
1092        let Ok(token) =
1093            engine.register(id.clone(), RequestOrigin::Inbound, "tools/call", None, None)
1094        else {
1095            return Err(Failure::Suspended);
1096        };
1097        assert!(!token.is_cancelled());
1098        let outcome = engine.cancel(&id, None);
1099        assert!(outcome.cancelled());
1100        assert!(token.is_cancelled(), "the handler observes the abort");
1101        // Still in flight: cancelling marks the token, it does not remove the
1102        // entry. The entry goes when the handler returns, so that a handler
1103        // which is mid-poll keeps a row it can be reported against.
1104        assert_eq!(engine.in_flight(), 1);
1105        assert_eq!(
1106            engine.complete(&id),
1107            Some(Some(AbortReason::Cancelled(None)))
1108        );
1109        assert_eq!(engine.in_flight(), 0);
1110        Ok(())
1111    }
1112
1113    #[test]
1114    fn a_request_that_ran_to_completion_is_reported_as_completed() -> Result<(), Failure> {
1115        let mut engine = negotiated()?;
1116        let id = RequestId::text("a");
1117        let _ = engine.register(id.clone(), RequestOrigin::Inbound, "tools/call", None, None)?;
1118        assert_eq!(engine.complete(&id), Some(None), "no abort reason");
1119        assert_eq!(engine.complete(&id), None, "already finished");
1120        Ok(())
1121    }
1122
1123    #[test]
1124    fn a_cancelled_request_reports_an_abort_rather_than_a_completion() -> Result<(), Failure> {
1125        let mut engine = negotiated()?;
1126        let id = RequestId::text("a");
1127        let _ = engine.register(id.clone(), RequestOrigin::Inbound, "tools/call", None, None)?;
1128        engine.cancel(&id, None);
1129        assert!(matches!(
1130            engine.complete(&id),
1131            Some(Some(AbortReason::Cancelled(_)))
1132        ));
1133        Ok(())
1134    }
1135
1136    /// A cancelled request produces a value and still answers nothing.
1137    ///
1138    /// The handler returns normally, so the only thing standing between the
1139    /// peer and a response is `dispatch`'s own check against the abort. This
1140    /// runs on a real scheduler rather than the single-poll harness because
1141    /// the handler and the canceller have to interleave.
1142    ///
1143    /// It deliberately does **not** claim the wake. Stopping `Cancelled::poll`
1144    /// from registering its waker does not fail this test: the runtime re-polls
1145    /// the handler for unrelated reasons once the spawned canceller finishes.
1146    /// `a_spent_budget_wakes_a_handler_parked_on_its_handle` is the test that
1147    /// fails on that mutation, and it is the one that carries the claim.
1148    #[tokio::test(start_paused = true)]
1149    async fn a_cancelled_request_answers_nothing_even_when_the_handler_returns()
1150    -> Result<(), Failure> {
1151        use crate::domain::session::CancelReason;
1152
1153        let mut engine = negotiated()?;
1154        let id = RequestId::text("a");
1155
1156        let dispatched = engine.dispatch(
1157            id,
1158            RequestOrigin::Inbound,
1159            "tools/call",
1160            None,
1161            None,
1162            |signal: CancellationToken| async move {
1163                // The peer withdraws the request while this handler is parked
1164                // on the signal below.
1165                let peer = signal.clone();
1166                let canceller = tokio::spawn(async move {
1167                    peer.cancel(AbortReason::Cancelled(Some(CancelReason::new("peer"))));
1168                });
1169                signal.cancelled().await;
1170                let _ = canceller.await;
1171                "a value the peer will never receive"
1172            },
1173        );
1174
1175        let outcome = tokio::time::timeout(Duration::from_secs(5), dispatched)
1176            .await
1177            .map_err(|_| Failure::TimedOut("the handler was parked, not woken"))??;
1178
1179        // The handler ran to the end and produced a value, and the answer is
1180        // still not sent: `Aborted` carries what was discarded rather than
1181        // what is returned.
1182        let Dispatch::Aborted { discarded, reason } = outcome else {
1183            return Err(Failure::NotAborted(format!("{outcome:?}")));
1184        };
1185        assert_eq!(discarded, "a value the peer will never receive");
1186        assert!(matches!(reason, AbortReason::Cancelled(_)), "{reason:?}");
1187        Ok(())
1188    }
1189
1190    /// The deadline half of the same race, charged to the engine's own clock.
1191    ///
1192    /// This one sits at the registry rather than at `dispatch` on purpose.
1193    /// `dispatch` takes `&mut self` for the whole handler, so a test cannot
1194    /// call `expire` on the engine while a handler is parked — the borrow is
1195    /// held for the handler's lifetime. What the registry can do, and what is
1196    /// the whole contract between a deadline and a handler, is prove that
1197    /// expiring an entry wakes a real task waiting on that entry's handle.
1198    #[tokio::test(start_paused = true)]
1199    async fn a_spent_budget_wakes_a_handler_parked_on_its_handle() -> Result<(), Failure> {
1200        let clock = &Stepping::new();
1201        let mut engine = SessionEngine::open(Open, clock, &Counting, Recording::default());
1202        engine.negotiate(Declaration::Handshake {
1203            version: "2025-06-18",
1204            capabilities: None,
1205        })?;
1206        engine.confirm()?;
1207
1208        let id = RequestId::text("a");
1209        let handle = engine.register(
1210            id.clone(),
1211            RequestOrigin::Inbound,
1212            "tools/call",
1213            None,
1214            Some(Duration::from_millis(50)),
1215        )?;
1216
1217        // Park a real task on the handle and let it poll, so its waker is
1218        // actually registered. Without the yield there is nothing to wake.
1219        let waiter = tokio::spawn({
1220            let handle = handle.clone();
1221            async move {
1222                handle.cancelled().await;
1223                handle.reason().cloned()
1224            }
1225        });
1226        tokio::task::yield_now().await;
1227
1228        // Half the budget must not expire it.
1229        clock.advance(Duration::from_millis(25));
1230        assert!(
1231            engine.expire().is_empty(),
1232            "a budget spent only half way is not spent"
1233        );
1234
1235        clock.advance(Duration::from_millis(25));
1236        assert_eq!(engine.expire(), vec![id], "the budget is now spent");
1237
1238        let reason = tokio::time::timeout(Duration::from_secs(5), waiter)
1239            .await
1240            .map_err(|_| Failure::TimedOut("the expiry never woke the parked task"))?
1241            .map_err(|panic| Failure::NotAborted(format!("the parked task panicked: {panic}")))?;
1242        assert_eq!(
1243            reason,
1244            Some(AbortReason::Expired),
1245            "the handler woke for the reason the engine recorded"
1246        );
1247        Ok(())
1248    }
1249
1250    #[test]
1251    fn a_spent_budget_expires_and_nothing_else_does() -> Result<(), Failure> {
1252        let clock = &Stepping::new();
1253        let mut engine = SessionEngine::open(Open, clock, &Counting, Recording::default());
1254        engine.negotiate(Declaration::Handshake {
1255            version: "2025-06-18",
1256            capabilities: None,
1257        })?;
1258        engine.confirm()?;
1259
1260        let quick = RequestId::text("quick");
1261        let slow = RequestId::text("slow");
1262        let _ = engine.register(
1263            quick.clone(),
1264            RequestOrigin::Inbound,
1265            "tools/call",
1266            None,
1267            Some(Duration::from_millis(50)),
1268        )?;
1269        let _ = engine.register(
1270            slow.clone(),
1271            RequestOrigin::Inbound,
1272            "tools/call",
1273            None,
1274            Some(Duration::from_millis(5_000)),
1275        )?;
1276
1277        clock.advance(Duration::from_millis(100));
1278        let expired = engine.expire();
1279        assert_eq!(expired, vec![quick], "only the spent budget expires");
1280        assert!(
1281            engine.request(&slow).is_some(),
1282            "the other is still running"
1283        );
1284        Ok(())
1285    }
1286
1287    #[test]
1288    fn closing_aborts_everything_still_in_flight() -> Result<(), Failure> {
1289        let mut engine = negotiated()?;
1290        let _ = engine.register(
1291            RequestId::text("a"),
1292            RequestOrigin::Outbound,
1293            "sampling/createMessage",
1294            None,
1295            None,
1296        )?;
1297        let _ = engine.register(
1298            RequestId::text("b"),
1299            RequestOrigin::Outbound,
1300            "elicitation/create",
1301            None,
1302            None,
1303        )?;
1304        assert_eq!(engine.in_flight(), 2);
1305        engine.close()?;
1306        assert_eq!(engine.in_flight(), 0, "nothing is left to leak");
1307        assert_eq!(
1308            engine.state(),
1309            SessionState::Closing,
1310            "closing is not yet terminal — the peer still has to finish"
1311        );
1312        engine.finish()?;
1313        assert!(engine.state().is_terminal());
1314        assert!(
1315            engine.admit("tools/list", None).is_err(),
1316            "a finished session admits nothing"
1317        );
1318        Ok(())
1319    }
1320
1321    #[test]
1322    fn dispatch_registers_runs_and_completes() -> Result<(), Failure> {
1323        let mut engine = negotiated()?;
1324        let outcome = futures_lite_block_on(engine.dispatch(
1325            RequestId::text("a"),
1326            RequestOrigin::Inbound,
1327            "tools/call",
1328            None,
1329            None,
1330            |_token| async { "ok" },
1331        ))
1332        .ok_or(Failure::Suspended)??;
1333        assert!(outcome.is_completed(), "the handler ran to the end");
1334        assert_eq!(outcome.into_inner(), "ok");
1335        assert_eq!(engine.in_flight(), 0);
1336        assert_eq!(
1337            engine.log.kinds(),
1338            [
1339                "opened",
1340                "state-changed",
1341                "negotiated",
1342                "state-changed",
1343                "operating",
1344                "registered",
1345                "completed",
1346            ],
1347            "the log is a transcript: every transition, then the work"
1348        );
1349        Ok(())
1350    }
1351
1352    #[test]
1353    fn every_recorded_event_names_the_session_and_when_it_happened() {
1354        let engine = engine();
1355        let events = engine.log.events.borrow();
1356        // Checked across every event rather than just the first: the invariant
1357        // is that no event escapes without them, so sampling one would not
1358        // test it.
1359        for event in events.iter() {
1360            assert_eq!(event.session(), engine.id());
1361            assert_eq!(event.at(), Instant::from_duration(Duration::ZERO));
1362        }
1363        assert!(!events.is_empty(), "opening records at least one event");
1364    }
1365
1366    #[test]
1367    fn a_refusal_is_recorded_before_it_is_returned() {
1368        let engine = engine();
1369        let id = RequestId::text("a");
1370        assert!(engine.admit("tools/call", Some(&id)).is_err());
1371        assert_eq!(engine.log.kinds(), ["opened", "refused"]);
1372    }
1373
1374    #[test]
1375    fn a_cancelled_dispatch_sends_no_response() -> Result<(), Failure> {
1376        let mut engine = negotiated()?;
1377        let outcome = futures_lite_block_on(engine.dispatch(
1378            RequestId::text("a"),
1379            RequestOrigin::Inbound,
1380            "tools/call",
1381            None,
1382            None,
1383            |token| async move {
1384                // The peer withdrew the request while the handler was running.
1385                let _ = engine_cancel(&token);
1386                "should not be sent"
1387            },
1388        ))
1389        .ok_or(Failure::Suspended)??;
1390        assert!(
1391            matches!(outcome, Dispatch::Aborted { .. }),
1392            "an aborted request is never answered"
1393        );
1394        assert!(!outcome.is_completed(), "an abort is not a completion");
1395        assert_eq!(
1396            outcome.into_inner(),
1397            "should not be sent",
1398            "the value is still handed back, for the log rather than the wire"
1399        );
1400        Ok(())
1401    }
1402
1403    /// Drives a token's cancel path from inside a handler.
1404    fn engine_cancel(token: &CancellationToken) -> bool {
1405        token.cancel(AbortReason::Cancelled(None))
1406    }
1407
1408    /// A no-op executor: every future these tests build is immediately ready,
1409    /// because nothing in the engine's own contract sleeps. A `Pending` here
1410    /// would be a bug, so it is reported rather than retried forever.
1411    fn futures_lite_block_on<F: core::future::Future>(future: F) -> Option<F::Output> {
1412        use core::task::{Context, Poll};
1413        use std::task::Waker;
1414
1415        let mut context = Context::from_waker(Waker::noop());
1416        let mut future = Box::pin(future);
1417        match future.as_mut().poll(&mut context) {
1418            Poll::Ready(value) => Some(value),
1419            Poll::Pending => None,
1420        }
1421    }
1422}