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}