Skip to main content

protocol_droid/adapters/primary/transport/http/
bridge.rs

1//! The bridge's listen side, over Streamable HTTP.
2//!
3//! ## One session, one conversation, one upstream
4//!
5//! On `stdio` the bridge is one process, one client, one upstream. A server
6//! outlives the clients that connect to it, so here every `initialize` opens a
7//! [`BridgeSession`], and each session is a separate conversation answered on
8//! its own `mcp-session-id` and served by its own
9//! [`Upstream`](crate::application::bridge::Upstream) — opened as the session
10//! is created, which is where the per-client rule stops being an idea and
11//! becomes a spawn.
12//!
13//! ## The two halves
14//!
15//! A `POST` carrying a message is not answered by the `POST`. It is answered
16//! `202` and no body, and the reply arrives on the session's SSE stream — the
17//! Streamable HTTP contract, and it falls out of the existing pieces:
18//! [`EventTail`] is already the per-session buffer the `GET` route streams, and
19//! it becomes the bridge's sink unchanged.
20//!
21//! The other half has no existing piece, because the router is
22//! request-and-response shaped and a bridge's loop is a pull.
23//! [`HttpFrameSource`] is the seam: one channel per session, the handler
24//! pushes into it, the loop pulls from it. A `POST` costs the handler one send
25//! and nothing else, because the reply is the tail's job rather than the
26//! handler's.
27//!
28//! ## What the handler deliberately does not do
29//!
30//! It parses no message beyond the `method` it needs to answer
31//! [`RequestHandler::unsessioned`], decides nothing about methods, and has no
32//! idea a bridge is involved. A frame arriving before the handshake is refused
33//! by the *loop*, not here, and that refusal reaches the client through the
34//! tail like any other answer — one path, whatever the reason.
35
36use std::net::SocketAddr;
37use std::num::NonZeroUsize;
38use std::sync::Arc;
39
40use serde_json::Value;
41use tokio::sync::mpsc;
42
43use crate::adapters::primary::transport::http::router::{
44    EventSource, Inbound, RequestHandler, Unsessioned,
45};
46use crate::adapters::primary::transport::http::{
47    EventTail, McpRouter, Refusal, ServerConfig, SessionHandle, SessionStore,
48};
49use crate::application::bridge::{
50    BridgeClientError, DownstreamConfig, DownstreamError, ToolResultCodec, Upstream, run_client,
51};
52use crate::application::jsonrpc::{FrameSource, SourceError};
53use crate::application::session::{FrameSink, SinkError};
54use crate::domain::{DegradePolicy, McpSessionId};
55
56/// Large enough to cover a reconnect, small enough that an idle client is not
57/// holding a megabyte of `Value`s nobody will read. The same value the MCP
58/// server's own sessions use, and deliberately so: a bridge and a server differ
59/// in who answers, not in how much a reconnecting client may have missed.
60const TAIL_CAPACITY: NonZeroUsize = match NonZeroUsize::new(64) {
61    Some(capacity) => capacity,
62    // A const context cannot call `ok_or`, and `None` here would mean a zero,
63    // which is not a capacity. Unreachable for any value above zero.
64    None => NonZeroUsize::MIN,
65};
66
67/// One client's conversation with the bridge.
68///
69/// The tail and the inbox are one type because the router needs the tail to
70/// answer a `GET` and the loop needs both, and a session whose sink and source
71/// could be swapped for another client's is the sharing bug
72/// [`Upstream`](crate::application::bridge::Upstream) exists to prevent, one
73/// layer down.
74#[derive(Debug)]
75pub struct BridgeSession {
76    tail: EventTail<Value>,
77    /// Held exactly as long as the session. The loop owns the other end, and
78    /// dropping this is what ends its `next_frame` with `None` — a task
79    /// parked forever on a channel nobody will write to is the alternative.
80    inbox: mpsc::UnboundedSender<String>,
81}
82
83impl EventSource for BridgeSession {
84    fn tail(&self) -> &EventTail<Value> {
85        &self.tail
86    }
87}
88
89impl BridgeSession {
90    /// A session, and the source its loop will pull from.
91    ///
92    /// Both halves come from one call because they are one channel: a session
93    /// that handed out a source it had not made an inbox for would drop every
94    /// frame on the floor, and the type system is the one place that can say
95    /// so once rather than at every call site.
96    fn new() -> (Self, HttpFrameSource) {
97        let (inbox, frames) = mpsc::unbounded_channel();
98        let session = Self {
99            tail: EventTail::new(TAIL_CAPACITY),
100            inbox,
101        };
102        let source = HttpFrameSource {
103            frames: Arc::new(tokio::sync::Mutex::new(frames)),
104        };
105        (session, source)
106    }
107
108    /// Hands one of the client's messages to the loop.
109    ///
110    /// A closed channel means the loop has ended, and that is this `POST`
111    /// arriving after its conversation finished — which a client does when it
112    /// retries a message whose reply it never saw.
113    fn push(&self, frame: String) -> Result<(), ()> {
114        self.inbox.send(frame).map_err(|_| ())
115    }
116
117    /// The sink this session's replies go out on.
118    fn sink(&self) -> HttpFrameSink {
119        HttpFrameSink {
120            tail: self.tail.clone(),
121        }
122    }
123}
124
125/// Pulls one session's messages, one `POST` at a time.
126struct HttpFrameSource {
127    frames: Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<String>>>,
128}
129
130impl core::fmt::Debug for HttpFrameSource {
131    /// By hand, because a receiver's contents are the frames a client is
132    /// waiting on and reading them out to print them would be a data race and
133    /// a privacy problem. The channel's state is the only thing worth showing.
134    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
135        f.debug_struct("HttpFrameSource")
136            .field("frames", &"<one session's inbound messages>")
137            .finish()
138    }
139}
140
141impl FrameSource for HttpFrameSource {
142    fn next_frame(&self) -> impl Future<Output = Result<Option<String>, SourceError>> + Send {
143        let frames = Arc::clone(&self.frames);
144        async move {
145            // Held across the await deliberately: `recv` needs `&mut self`,
146            // and the loop is the only reader, so a second concurrent
147            // `next_frame` would be a bug rather than a case to serve.
148            let mut receiver = frames.lock().await;
149            // The channel's own close is the end of the conversation — every
150            // sender gone means the session was terminated. An unbounded
151            // channel reports that as `None` rather than an error, which is
152            // right: a client hanging up is not a fault, and `None` is the
153            // loop's cue to finish.
154            Ok(receiver.recv().await)
155        }
156    }
157}
158
159/// Writes one session's replies onto its tail, for the `GET` route to stream.
160#[derive(Debug)]
161struct HttpFrameSink {
162    tail: EventTail<Value>,
163}
164
165impl FrameSink for HttpFrameSink {
166    fn send(&self, frame: &str) -> impl Future<Output = Result<(), SinkError>> + Send {
167        // Parsed here rather than trusted: the tail holds `Value`, and a frame
168        // that is not JSON cannot be streamed. Refusing on the way out means
169        // there is still a caller to tell.
170        let pushed = serde_json::from_str::<Value>(frame)
171            .map(|value| self.tail.push(value))
172            .map_err(|_| SinkError);
173        std::future::ready(pushed.map(|_| ()))
174    }
175}
176
177/// Serves a bridge on the `POST` route, one loop per session.
178///
179/// Generic over the upstream factory and the wire codec rather than holding a
180/// `dyn` of either: [`Upstream`] returns an `impl Future`, so it is not
181/// dyn-safe, and a codec is a zero-sized type that storing per handler would
182/// imply carries state between clients.
183#[derive(Debug)]
184struct BridgeHandler<U, C> {
185    sessions: Arc<SessionStore<BridgeSession>>,
186    upstream: Arc<U>,
187    config: DownstreamConfig,
188    policy: DegradePolicy,
189    codec: C,
190}
191
192impl<U, C> BridgeHandler<U, C> {
193    /// A handler that opens `upstream` once per client.
194    fn new(
195        sessions: Arc<SessionStore<BridgeSession>>,
196        upstream: U,
197        config: DownstreamConfig,
198        policy: DegradePolicy,
199        codec: C,
200    ) -> Self {
201        Self {
202            sessions,
203            upstream: Arc::new(upstream),
204            config,
205            policy,
206            codec,
207        }
208    }
209}
210
211/// What a `POST` carrying a message is answered with.
212///
213/// `202` and no body, always — including for the handshake. The reply is on the
214/// session's stream, and a client reading the handshake out of the `POST`
215/// would be reading it from somewhere the specification does not put it.
216fn accepted() -> axum::response::Response {
217    axum::response::Response::builder()
218        .status(axum::http::StatusCode::ACCEPTED)
219        .body(axum::body::Body::empty())
220        .unwrap_or_else(|_| axum::response::Response::new(axum::body::Body::empty()))
221}
222
223/// `accepted`, carrying the id a client needs to address its own stream.
224fn accepted_with(id: McpSessionId) -> Result<axum::response::Response, Refusal> {
225    let value = axum::http::HeaderValue::from_str(&id.to_string())
226        .map_err(|error| Refusal::Handler(format!("the id is not a header: {error}")))?;
227    let mut response = accepted();
228    response.headers_mut().insert("mcp-session-id", value);
229    Ok(response)
230}
231
232/// The `method` of a body, when it is a single JSON object naming one.
233///
234/// The one thing the handler looks at, and only to answer `unsessioned`:
235/// whether this `POST` is the one that opens a conversation.
236fn method_of(body: &[u8]) -> Option<String> {
237    serde_json::from_slice::<Value>(body)
238        .ok()?
239        .get("method")?
240        .as_str()
241        .map(String::from)
242}
243
244impl<U, C> RequestHandler<BridgeSession> for BridgeHandler<U, C>
245where
246    U: Upstream + 'static,
247    C: ToolResultCodec + Clone + Send + Sync + 'static,
248{
249    fn unsessioned(&self, body: &[u8], _headers: &axum::http::HeaderMap) -> Unsessioned {
250        match method_of(body).as_deref() {
251            Some("initialize") => Unsessioned::Establishing,
252            _ => Unsessioned::NeedsSession,
253        }
254    }
255
256    fn dispatch(
257        &self,
258        call: Inbound<'_, BridgeSession>,
259    ) -> impl Future<Output = Result<axum::response::Response, Refusal>> + Send {
260        // Copied out before the future, because `Inbound` borrows the body and
261        // the router's `Arc`, and neither outlives this function.
262        let frame = String::from_utf8(call.body.to_vec()).map_err(|_| Refusal::MalformedBody {
263            reason: "a request body is not UTF-8".to_owned(),
264        });
265        let session = match &call.session {
266            SessionHandle::Live { session, .. } => Ok(Some(Arc::clone(session))),
267            SessionHandle::Establishing => Ok(None),
268            // A bridge is a conversation; the stateless lifecycle has none, and
269            // answering a self-describing request as though it had a session
270            // to join would invent one.
271            SessionHandle::Stateless => Err(Refusal::Handler(
272                "a bridge is a conversation, and the stateless lifecycle has \
273                 none; open a session"
274                    .to_owned(),
275            )),
276        };
277        let sessions = Arc::clone(&self.sessions);
278        let upstream = Arc::clone(&self.upstream);
279        let config = self.config.clone();
280        let policy = self.policy;
281        let codec = self.codec.clone();
282
283        async move {
284            let frame = frame?;
285            match session? {
286                Some(session) => {
287                    session
288                        .push(frame)
289                        .map_err(|()| Refusal::Handler("this session has ended".to_owned()))?;
290                    Ok(accepted())
291                }
292                None => Self::open(&sessions, upstream, config, policy, codec, frame),
293            }
294        }
295    }
296}
297
298impl<U, C> BridgeHandler<U, C>
299where
300    U: Upstream + 'static,
301    C: ToolResultCodec + Clone + Send + Sync + 'static,
302{
303    /// Opens a session, its upstream, and its loop, then queues the `POST`
304    /// that caused all three.
305    ///
306    /// Not `async`, and deliberately: a store insert, a `tokio::spawn` and a
307    /// channel send are all synchronous, and an `async` body that never yields
308    /// is a future that cannot be interrupted — which is the wrong shape for
309    /// the one call that starts work on a client's behalf.
310    fn open(
311        sessions: &SessionStore<BridgeSession>,
312        upstream: Arc<U>,
313        config: DownstreamConfig,
314        policy: DegradePolicy,
315        codec: C,
316        frame: String,
317    ) -> Result<axum::response::Response, Refusal> {
318        let (session, source) = BridgeSession::new();
319        let session = Arc::new(session);
320        let sink = session.sink();
321
322        // Stored before the loop starts, so a client's very next `POST` — which
323        // can arrive before the loop has been polled once — resolves.
324        let id = sessions
325            .open(Arc::clone(&session))
326            .map_err(|error| Refusal::Handler(format!("no session for the client: {error}")))?;
327
328        tokio::spawn(async move {
329            // A client hanging up ends the loop, and that is not a fault worth
330            // reporting. Anything else is dropped here, and that is a real gap
331            // rather than a design choice: this crate has no logger, so there
332            // is nowhere to put it, and the client learns the same thing by
333            // watching its stream close. The operator does not. Closing that
334            // wants a `LoopReport` the `GET` route can answer on, not a
335            // print.
336            let _ = run_client(upstream.as_ref(), policy, config, codec, source, sink).await;
337        });
338
339        session
340            .push(frame)
341            .map_err(|()| Refusal::Handler("the new session has ended".to_owned()))?;
342        accepted_with(id)
343    }
344}
345
346/// Why a bridge's HTTP side could not be built.
347#[derive(Debug, thiserror::Error)]
348pub enum BridgeServeError {
349    /// The router refused to build.
350    ///
351    /// In practice a configured security header that is not a legal HTTP
352    /// value, or the issue #11 fail-open combination of a wired
353    /// authenticator and the default `AuthPolicy::Public` policy.
354    #[error("the router could not be built: {0}")]
355    Router(#[from] crate::adapters::primary::transport::http::router::RouterError),
356    /// The downstream leg could not be built.
357    #[error(transparent)]
358    Downstream(#[from] DownstreamError),
359    /// A client's loop ended.
360    #[error(transparent)]
361    Client(#[from] BridgeClientError),
362    /// The address could not be bound, or the accept loop failed.
363    #[error("could not serve on {addr}: {source}")]
364    Bind {
365        /// The address the bridge was asked to take.
366        addr: SocketAddr,
367        /// What the socket reported.
368        source: std::io::Error,
369    },
370}
371
372/// A bridge served over Streamable HTTP.
373#[derive(Debug)]
374pub struct HttpBridge<U, C> {
375    config: ServerConfig,
376    sessions: Arc<SessionStore<BridgeSession>>,
377    handler: BridgeHandler<U, C>,
378}
379
380impl<U, C> HttpBridge<U, C>
381where
382    U: Upstream + 'static,
383    C: ToolResultCodec + Clone + Send + Sync + 'static,
384{
385    /// A bridge that opens `upstream` once per client.
386    ///
387    /// # Errors
388    ///
389    /// [`DownstreamError::UnnegotiableVersion`] if the build speaks no
390    /// revision a client could negotiate. That list is a build-time constant
391    /// and the specification has defined at least one since 2024, so it is a
392    /// formality — but a caller building this at startup deserves a real error
393    /// rather than a panic later.
394    pub fn new(
395        config: ServerConfig,
396        sessions: Arc<SessionStore<BridgeSession>>,
397        upstream: U,
398        identity: crate::application::bridge::Identity,
399        policy: DegradePolicy,
400        codec: C,
401    ) -> Result<Self, BridgeServeError> {
402        let versions = crate::domain::SupportedVersions::all()
403            .map_err(|_| DownstreamError::UnnegotiableVersion)?;
404        Ok(Self {
405            config,
406            sessions: Arc::clone(&sessions),
407            handler: BridgeHandler::new(
408                sessions,
409                upstream,
410                DownstreamConfig::with(
411                    versions,
412                    identity,
413                    crate::domain::TransportKind::StreamableHttp,
414                ),
415                policy,
416                codec,
417            ),
418        })
419    }
420
421    /// The router, for a caller that wants to serve it itself.
422    ///
423    /// # Errors
424    ///
425    /// [`RouterError`](crate::adapters::primary::transport::http::router::RouterError)
426    /// — a configured security header that is not a legal HTTP value, or
427    /// the issue #11 fail-open combination of a wired authenticator and
428    /// the default `AuthPolicy::Public` policy. The same errors the MCP
429    /// server's own builder reports, surfaced through
430    /// `BridgeServeError::Router` on the bind path.
431    pub fn into_router(
432        self,
433    ) -> Result<axum::Router, crate::adapters::primary::transport::http::router::RouterError> {
434        McpRouter::new(self.config, self.sessions, self.handler).into_router()
435    }
436
437    /// Binds the address and reports what it got, without serving.
438    ///
439    /// Split from serving for the same reason
440    /// [`HttpBuilder`](crate::adapters::primary::transport::http::HttpBuilder)
441    /// splits it: a caller that passed port `0` has to learn the real address,
442    /// and has to learn it *before* the accept loop starts rather than after.
443    ///
444    /// # Errors
445    ///
446    /// [`BridgeServeError::Bind`] when the address is taken or unusable, and
447    /// `BridgeServeError::Router` for an illegal configured header.
448    pub async fn bind(self, addr: SocketAddr) -> Result<(Bound, SocketAddr), BridgeServeError> {
449        let router = self.into_router()?;
450        let listener = tokio::net::TcpListener::bind(addr)
451            .await
452            .map_err(|source| BridgeServeError::Bind { addr, source })?;
453        let bound = listener
454            .local_addr()
455            .map_err(|source| BridgeServeError::Bind { addr, source })?;
456        Ok((
457            Bound {
458                router,
459                listener,
460                addr: bound,
461            },
462            bound,
463        ))
464    }
465
466    /// Binds the address and serves until the accept loop fails.
467    ///
468    /// The one-call path, for a caller that already knows its address.
469    ///
470    /// # Errors
471    ///
472    /// As [`HttpBridge::bind`], and whatever the accept loop itself reported.
473    pub async fn serve(self, addr: SocketAddr) -> Result<SocketAddr, BridgeServeError> {
474        let (bound, actual) = self.bind(addr).await?;
475        bound.serve().await?;
476        Ok(actual)
477    }
478}
479
480/// A bound socket and the route table that will answer on it.
481///
482/// One type rather than a `(TcpListener, Router)` tuple, because the two are
483/// only ever used together and a tuple is two things a caller can swap the
484/// halves of — the same argument
485/// [`OpenedUpstream`](crate::application::bridge::OpenedUpstream) makes about
486/// a leg and a peer.
487#[derive(Debug)]
488pub struct Bound {
489    router: axum::Router,
490    listener: tokio::net::TcpListener,
491    addr: SocketAddr,
492}
493
494impl Bound {
495    /// The address this actually took, which is not the one asked for when the
496    /// caller passed port `0`.
497    #[must_use]
498    pub const fn addr(&self) -> SocketAddr {
499        self.addr
500    }
501
502    /// Serves until the accept loop fails.
503    ///
504    /// # Errors
505    ///
506    /// [`BridgeServeError::Bind`] if the socket fails while serving.
507    pub async fn serve(self) -> Result<(), BridgeServeError> {
508        axum::serve(self.listener, self.router)
509            .await
510            .map_err(|source| BridgeServeError::Bind {
511                addr: self.addr,
512                source,
513            })
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::{BridgeSession, HttpBridge, HttpFrameSource};
520    use crate::adapters::primary::transport::http::{SecurityHeaders, ServerConfig, SessionStore};
521    use crate::application::bridge::SendFuture;
522    use crate::application::bridge::{BridgeError, BridgePeer, Identity, OpenedUpstream, Upstream};
523    use crate::application::jsonrpc::FrameSource;
524    use crate::application::session::{FrameSink, IdSource};
525    use crate::domain::{DegradePolicy, Feature, FeatureProfile, Leg, ProtocolVersion};
526    use serde_json::{Map, Value, json};
527    use std::net::{Ipv4Addr, SocketAddr};
528    use std::sync::Arc;
529
530    /// Hands out a fixed sequence, so the test can name the id it expects.
531    struct Counting(std::sync::atomic::AtomicU64);
532
533    impl IdSource for Counting {
534        fn next_session_id(&self) -> crate::domain::session::SessionId {
535            crate::domain::session::SessionId::new(
536                self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1,
537            )
538        }
539    }
540
541    /// A far end that answers nothing, because these tests are about the bind.
542    struct Idle;
543
544    struct IdlePeer;
545
546    impl Upstream for Idle {
547        fn open(
548            &self,
549        ) -> impl std::future::Future<
550            Output = Result<OpenedUpstream, crate::application::bridge::UpstreamError>,
551        > + Send {
552            let version = ProtocolVersion::V2025_11_25;
553            let tools = FeatureProfile::empty().with(Feature::Tools);
554            std::future::ready(Ok(OpenedUpstream::new(
555                Leg::new(version, tools),
556                Arc::new(IdlePeer),
557            )))
558        }
559    }
560
561    impl BridgePeer for IdlePeer {
562        fn send(
563            &self,
564            envelope: Map<String, Value>,
565        ) -> SendFuture<'_, Result<Map<String, Value>, BridgeError>> {
566            Box::pin(async move { Ok(envelope) })
567        }
568
569        fn notify(&self, envelope: Map<String, Value>) -> SendFuture<'_, Result<(), BridgeError>> {
570            Box::pin(async move {
571                let _ = envelope;
572                Ok(())
573            })
574        }
575
576        fn close(&self) -> SendFuture<'_, Result<(), BridgeError>> {
577            Box::pin(async { Ok(()) })
578        }
579    }
580
581    fn bridge() -> Result<
582        HttpBridge<Idle, crate::adapters::secondary::codec::result::WireToolResults>,
583        Box<dyn std::error::Error>,
584    > {
585        let ids: Arc<dyn IdSource + Send + Sync> =
586            Arc::new(Counting(std::sync::atomic::AtomicU64::new(0)));
587        let sessions: Arc<SessionStore<BridgeSession>> = Arc::new(SessionStore::new(ids));
588        Ok(HttpBridge::new(
589            ServerConfig::new()?.with_security(SecurityHeaders::default()),
590            sessions,
591            Idle,
592            Identity::new("unit", "0.0.0"),
593            DegradePolicy::default(),
594            crate::adapters::secondary::codec::result::WireToolResults,
595        )?)
596    }
597
598    #[tokio::test]
599    async fn a_bridge_binds_and_reports_the_address_it_took()
600    -> Result<(), Box<dyn std::error::Error>> {
601        // Port `0` is the case that makes the split worth having: the caller
602        // asked for *a* port and has to learn which one, before anything
603        // connects to it.
604        let loopback = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
605        let (bound, actual) = bridge()?.bind(loopback).await?;
606
607        if actual.port() == 0 {
608            return Err("the bridge reported port 0 after binding".into());
609        }
610        if bound.addr() != actual {
611            return Err(format!(
612                "the bound socket says {} and the report said {actual}",
613                bound.addr()
614            )
615            .into());
616        }
617        Ok(())
618    }
619
620    #[tokio::test]
621    async fn an_agreement_the_peer_cannot_hold_still_binds()
622    -> Result<(), Box<dyn std::error::Error>> {
623        // Binding is a transport decision and the handshake is a protocol one.
624        // A bridge that refused to listen until it had reached a server would
625        // be unusable in exactly the way this whole increment exists to fix.
626        let loopback = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
627        let (bound, _actual) = bridge()?.bind(loopback).await?;
628        drop(bound);
629        Ok(())
630    }
631
632    /// A session and the source its loop pulls from, which is how a caller
633    /// gets one. Built here so the unit tests and the wire test construct the
634    /// same pair rather than two shapes that agree today.
635    fn session() -> (BridgeSession, HttpFrameSource) {
636        BridgeSession::new()
637    }
638
639    #[tokio::test]
640    async fn a_frame_pushed_comes_back_out_in_order() -> Result<(), Box<dyn std::error::Error>> {
641        let (session, source) = session();
642        session
643            .push("first".to_owned())
644            .map_err(|()| "the loop had ended")?;
645        session
646            .push("second".to_owned())
647            .map_err(|()| "the loop had ended")?;
648
649        // Two clients' frames cannot be told apart here, because the source
650        // only ever belongs to one session. That is the point: there is no
651        // second session's source to confuse it with.
652        let first = source.next_frame().await?;
653        let second = source.next_frame().await?;
654        if (first.as_deref(), second.as_deref()) != (Some("first"), Some("second")) {
655            return Err(format!("the source yielded {first:?} then {second:?}").into());
656        }
657        Ok(())
658    }
659
660    #[tokio::test]
661    async fn a_dropped_session_ends_the_source_rather_than_parking_it()
662    -> Result<(), Box<dyn std::error::Error>> {
663        let (session, source) = session();
664        drop(session);
665        // A task blocked on a channel nobody will write to is the failure this
666        // rules out, so the assertion is that it *returns* — it is what tells
667        // the loop to finish.
668        if source.next_frame().await?.is_some() {
669            return Err("a session that was dropped still produced a frame".into());
670        }
671        Ok(())
672    }
673
674    #[tokio::test]
675    async fn a_reply_is_queued_on_the_tail_the_get_route_streams()
676    -> Result<(), Box<dyn std::error::Error>> {
677        let (session, _source) = session();
678        let sink = session.sink();
679        if !sink.tail.is_empty() {
680            return Err("a fresh session already had a reply queued".into());
681        }
682        sink.send(&json!({"jsonrpc": "2.0", "id": 7, "result": {"ok": true}}).to_string())
683            .await?;
684
685        // Counted rather than read back: the tail's own reader is the SSE
686        // route, and asserting through it would mean asserting the router
687        // tested itself. `http_bridge_wire.rs` drives a real client against a
688        // real socket and reads the reply off the stream, which is the claim
689        // that the queue and the route agree — this one is here so a sink that
690        // wrote nowhere at all is caught without a socket.
691        if sink.tail.is_empty() {
692            return Err("the reply was sent but nothing was queued".into());
693        }
694        Ok(())
695    }
696
697    #[tokio::test]
698    async fn a_frame_that_is_not_json_is_refused_rather_than_queued()
699    -> Result<(), Box<dyn std::error::Error>> {
700        let (session, _source) = session();
701        let sink = session.sink();
702        // A frame the tail cannot hold would panic on its way in, so the parse
703        // is the only thing standing between a bad loop and a broken server.
704        if sink.send("not json at all").await.is_ok() {
705            return Err("a frame that is not JSON was accepted".into());
706        }
707        Ok(())
708    }
709}