Skip to main content

pmcp/shared/
http.rs

1//! HTTP/SSE transport implementation for MCP.
2
3use crate::error::Result;
4use crate::shared::sse_parser::SseParser;
5use crate::shared::{Transport, TransportMessage};
6use async_trait::async_trait;
7use bytes::Bytes;
8use http_body_util::{BodyExt, Full, LengthLimitError, Limited};
9use hyper::{Method, Request, StatusCode};
10use hyper_util::client::legacy::Client;
11use hyper_util::rt::TokioExecutor;
12use parking_lot::RwLock;
13use std::sync::Arc;
14use std::time::Duration;
15#[cfg(not(target_arch = "wasm32"))]
16use tokio::sync::mpsc;
17#[cfg(not(target_arch = "wasm32"))]
18use tokio::sync::Mutex as AsyncMutex;
19#[cfg(not(target_arch = "wasm32"))]
20use tokio::time::timeout;
21use tracing::{debug, error, info, warn};
22use url::Url;
23
24/// HTTP transport configuration.
25#[derive(Debug, Clone)]
26pub struct HttpConfig {
27    /// Base URL for HTTP requests
28    pub base_url: Url,
29    /// SSE endpoint for receiving notifications
30    pub sse_endpoint: Option<String>,
31    /// Request timeout
32    pub timeout: Duration,
33    /// Additional headers to include in requests
34    pub headers: Vec<(String, String)>,
35    /// Enable connection pooling
36    pub enable_pooling: bool,
37    /// Maximum idle connections in pool
38    pub max_idle_per_host: usize,
39}
40
41impl Default for HttpConfig {
42    fn default() -> Self {
43        Self {
44            base_url: "http://localhost:8080".parse().expect("Valid default URL"),
45            sse_endpoint: Some("/events".to_string()),
46            timeout: Duration::from_secs(30),
47            headers: vec![],
48            enable_pooling: true,
49            max_idle_per_host: 10,
50        }
51    }
52}
53
54/// HTTP/SSE transport implementation.
55pub struct HttpTransport {
56    config: HttpConfig,
57    client: Client<hyper_util::client::legacy::connect::HttpConnector, Full<Bytes>>,
58    message_queue: Arc<AsyncMutex<mpsc::Receiver<TransportMessage>>>,
59    message_tx: mpsc::Sender<TransportMessage>,
60    connected: Arc<RwLock<bool>>,
61    /// In-flight ceiling for [`Self::connect_sse`]'s reader task, defaulted from
62    /// [`DEFAULT_HTTP_SSE_BUFFERED_BYTES`] and overridable through
63    /// [`Self::with_sse_buffered_bytes`].
64    ///
65    /// A PRIVATE field on the transport rather than a `pub` field on
66    /// [`HttpConfig`]: `HttpConfig` is externally constructible, so adding a
67    /// field to it fails `cargo semver-checks`'s `constructible_struct_adds_field`
68    /// and would force pmcp to a MAJOR version. Measured, not assumed — see plan
69    /// 113-17's `<config_surface_decision>`. Every field of this struct is
70    /// already private, so adding one here is invisible to semver.
71    sse_buffered_bytes: usize,
72    /// Cap on ONE fully-collected response body — the POST response
73    /// [`Self::send_request`] reads — defaulted from
74    /// [`DEFAULT_HTTP_COLLECTED_BODY_BYTES`] and overridable through
75    /// [`Self::with_max_collected_body_bytes`].
76    ///
77    /// A PRIVATE field for the same measured semver reason as
78    /// [`Self::sse_buffered_bytes`].
79    max_collected_body_bytes: usize,
80}
81
82impl std::fmt::Debug for HttpTransport {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("HttpTransport")
85            .field("config", &self.config)
86            .field("connected", &self.connected)
87            .field("sse_buffered_bytes", &self.sse_buffered_bytes)
88            .field("max_collected_body_bytes", &self.max_collected_body_bytes)
89            .finish_non_exhaustive()
90    }
91}
92
93// The DEFINITION moved to `crate::shared::http_constants` in plan 113.1-03 so
94// `sse_optimized.rs` can reach it: this module is gated on `feature = "http"`,
95// which `feature = "sse"` does NOT enable, while `http_constants` is ungated.
96// Re-exported here so the existing public path
97// `pmcp::shared::http::DEFAULT_HTTP_SSE_BUFFERED_BYTES` is preserved
98// byte-for-byte and every unqualified reference in this file keeps resolving.
99pub use crate::shared::http_constants::DEFAULT_HTTP_SSE_BUFFERED_BYTES;
100
101/// Default cap on ONE fully-collected response body on this transport, in bytes
102/// (16 MiB).
103///
104/// `HttpTransport::send_request` reads its POST response with
105/// `Full`-body semantics: the whole thing lands in memory before it is parsed,
106/// and the PEER chooses how many bytes it sends. Without a cap that read was
107/// unbounded — the same defect class 113-17 fixed on this file's sibling SSE
108/// reader and 113-20 fixed on `StreamableHttpTransport`'s three whole-body reads
109/// (review CR-03).
110///
111/// # Deliberately NOT the same quantity as the SSE in-flight ceiling
112///
113/// [`DEFAULT_HTTP_SSE_BUFFERED_BYTES`] bounds INCREMENTAL retention inside the
114/// long-lived `connect_sse` reader — a running total across many chunks. This
115/// bounds a ONE-SHOT collected body. They happen to share a number; they are not
116/// the same knob and must not be unified.
117///
118/// # What breaks at this boundary
119///
120/// A response larger than the configured cap now fails with
121/// [`TransportError::Request`](crate::error::TransportError::Request) instead of
122/// being delivered. Base64 `image`/`audio` content expands by ~4/3, so a 12 MiB
123/// binary is already 16 MiB encoded and does NOT fit under this default;
124/// [`HttpTransport::with_max_collected_body_bytes`] is the escape hatch.
125pub const DEFAULT_HTTP_COLLECTED_BODY_BYTES: usize = 16 * 1024 * 1024;
126
127/// Build the parser [`HttpTransport::connect_sse`]'s reader task feeds, bounded
128/// at the transport's CONFIGURED ceiling.
129///
130/// Named rather than inlined, and taking the configured value rather than
131/// reading a constant, so a test can assert on the bound this transport ACTUALLY
132/// uses. Asserting on a separately-constructed parser would pass no matter what
133/// the reader task were changed to.
134fn sse_reader_parser(sse_buffered_bytes: usize) -> SseParser {
135    SseParser::with_max_buffer_size(sse_buffered_bytes)
136}
137
138/// Report an SSE in-flight overflow, returning whether the reader task must end.
139///
140/// [`HttpTransport::connect_sse`] is the SECOND incremental feeder of the shared
141/// [`SseParser`] (the `subscriptions/listen` client is the other): it holds ONE
142/// parser for the lifetime of its spawned reader task and feeds it frame by
143/// frame. The parser BOUNDS what it retains, so without this observation the
144/// discarded bytes would vanish SILENTLY here and the task would carry on as if
145/// nothing had happened — strictly worse than the unbounded-but-correct
146/// behaviour it replaced (T-113-78).
147///
148/// What trips the bound is the parser's RETAINED state plus the chunk being fed,
149/// not one line and not one event: retained state is an unterminated line PLUS
150/// every `data:` line accumulated into an event the peer has not yet ended with a
151/// blank line, and one chunk carrying many small complete events can exceed the
152/// limit on its total alone (T-113-86). The log message says exactly that.
153///
154/// The ceiling itself is [`DEFAULT_HTTP_SSE_BUFFERED_BYTES`] unless overridden
155/// through [`HttpTransport::with_sse_buffered_bytes`].
156///
157/// A free function rather than an inline `if` so the condition is reachable from
158/// a test — the reader task owns a live `hyper::body::Incoming`, which cannot be
159/// constructed outside hyper.
160fn report_sse_overflow(parser: &SseParser) -> bool {
161    if !parser.overflowed() {
162        return false;
163    }
164    error!(
165        "an SSE chunk pushed the buffered stream state past the {}-byte parser \
166         bound; the buffered bytes were discarded, so the stream is corrupt and \
167         the connection is being closed",
168        parser.max_buffer_size()
169    );
170    true
171}
172
173impl HttpTransport {
174    /// Create a new HTTP transport with the given configuration.
175    pub fn new(config: HttpConfig) -> Self {
176        let connector = hyper_util::client::legacy::connect::HttpConnector::new();
177        let client = Client::builder(TokioExecutor::new())
178            .pool_idle_timeout(Duration::from_secs(30))
179            .pool_max_idle_per_host(config.max_idle_per_host)
180            .build(connector);
181
182        let (tx, rx) = mpsc::channel(100);
183
184        Self {
185            config,
186            client,
187            message_queue: Arc::new(AsyncMutex::new(rx)),
188            message_tx: tx,
189            connected: Arc::new(RwLock::new(false)),
190            sse_buffered_bytes: DEFAULT_HTTP_SSE_BUFFERED_BYTES,
191            max_collected_body_bytes: DEFAULT_HTTP_COLLECTED_BODY_BYTES,
192        }
193    }
194
195    /// Create a new HTTP transport with default configuration.
196    pub fn with_url(url: impl Into<Url>) -> Result<Self> {
197        Ok(Self::new(HttpConfig {
198            base_url: url.into(),
199            ..Default::default()
200        }))
201    }
202
203    /// Override how many SSE bytes [`Self::connect_sse`]'s reader task may hold
204    /// in flight, in bytes.
205    ///
206    /// Defaults to [`DEFAULT_HTTP_SSE_BUFFERED_BYTES`] (16 MiB). Raise it for a
207    /// deployment whose JSON-RPC results are legitimately larger — base64
208    /// `image`/`audio` content expands by ~4/3, so a 12 MiB binary alone is
209    /// already 16 MiB encoded, before the JSON envelope and the `data: ` prefix,
210    /// and a payload past the ceiling is DISCARDED and ends the reader task
211    /// (T-113-85). Lower it for a client talking to an untrusted peer whose
212    /// payloads are known to be small.
213    ///
214    /// An inherent builder method rather than an [`HttpConfig`] field: that
215    /// struct is externally constructible, so a new field on it is a MAJOR
216    /// semver break (plan 113-17 `<config_surface_decision>`), while an added
217    /// method is additive.
218    ///
219    /// # Examples
220    ///
221    /// ```rust,no_run
222    /// use pmcp::shared::http::{HttpConfig, HttpTransport};
223    ///
224    /// let transport =
225    ///     HttpTransport::new(HttpConfig::default()).with_sse_buffered_bytes(64 * 1024 * 1024);
226    /// ```
227    #[must_use]
228    pub fn with_sse_buffered_bytes(mut self, sse_buffered_bytes: usize) -> Self {
229        self.sse_buffered_bytes = sse_buffered_bytes;
230        self
231    }
232
233    /// Override the cap on ONE fully-collected POST response body, in bytes.
234    ///
235    /// Defaults to [`DEFAULT_HTTP_COLLECTED_BODY_BYTES`] (16 MiB). Raise it for a
236    /// deployment whose responses are legitimately larger — base64 `image` /
237    /// `audio` content expands by ~4/3, so a 12 MiB binary does NOT fit under the
238    /// default once encoded.
239    ///
240    /// An inherent builder method rather than an [`HttpConfig`] field, for the
241    /// same measured semver reason as [`Self::with_sse_buffered_bytes`].
242    ///
243    /// # Examples
244    ///
245    /// ```rust,no_run
246    /// use pmcp::shared::http::{HttpConfig, HttpTransport};
247    ///
248    /// let transport = HttpTransport::new(HttpConfig::default())
249    ///     .with_max_collected_body_bytes(64 * 1024 * 1024);
250    /// ```
251    #[must_use]
252    pub fn with_max_collected_body_bytes(mut self, max_collected_body_bytes: usize) -> Self {
253        self.max_collected_body_bytes = max_collected_body_bytes;
254        self
255    }
256
257    /// Collect a POST response body, refusing anything over `max_bytes`.
258    ///
259    /// The sibling of `StreamableHttpTransport::collect_body_within_cap`, with
260    /// the same two independently-sufficient refusals: a declared
261    /// `Content-Length` over the cap is refused before a byte is read, and the
262    /// bytes actually delivered are read through `Limited`, which stops at the
263    /// cap — so a peer that understates or omits `Content-Length` gains nothing.
264    ///
265    /// A body of exactly `max_bytes` is admitted; one byte over is refused.
266    async fn collect_body_within_cap(
267        response: hyper::Response<hyper::body::Incoming>,
268        max_bytes: usize,
269    ) -> Result<Bytes> {
270        let declared = response
271            .headers()
272            .get(hyper::header::CONTENT_LENGTH)
273            .and_then(|value| value.to_str().ok())
274            .and_then(|value| value.parse::<usize>().ok());
275        if let Some(declared) = declared {
276            if declared > max_bytes {
277                return Err(crate::error::Error::Transport(
278                    crate::error::TransportError::Request(format!(
279                        "response body declares Content-Length {declared}, over this transport's \
280                         {max_bytes}-byte collected-body cap (DEFAULT_HTTP_COLLECTED_BODY_BYTES); \
281                         raise it with HttpTransport::with_max_collected_body_bytes"
282                    )),
283                ));
284            }
285        }
286        match Limited::new(response.into_body(), max_bytes)
287            .collect()
288            .await
289        {
290            Ok(collected) => Ok(collected.to_bytes()),
291            Err(error) if error.is::<LengthLimitError>() => Err(crate::error::Error::Transport(
292                crate::error::TransportError::Request(format!(
293                    "response body delivered more than this transport's {max_bytes}-byte \
294                     collected-body cap (Content-Length absent or understated); raise it with \
295                     HttpTransport::with_max_collected_body_bytes"
296                )),
297            )),
298            Err(error) => Err(crate::error::Error::Transport(
299                crate::error::TransportError::Request(error.to_string()),
300            )),
301        }
302    }
303
304    /// Connect to SSE endpoint for receiving notifications.
305    pub async fn connect_sse(&self) -> Result<()> {
306        if let Some(sse_path) = &self.config.sse_endpoint {
307            let sse_url = self
308                .config
309                .base_url
310                .join(sse_path)
311                .map_err(|e| crate::error::TransportError::InvalidMessage(e.to_string()))?;
312            info!("Connecting to SSE endpoint: {}", sse_url);
313
314            let req = Request::builder()
315                .method(Method::GET)
316                .uri(sse_url.as_str())
317                .header("Accept", "text/event-stream")
318                .header("Cache-Control", "no-cache")
319                .body(Full::new(Bytes::new()))
320                .map_err(|e| crate::error::TransportError::InvalidMessage(e.to_string()))?;
321
322            let response = self
323                .client
324                .request(req)
325                .await
326                .map_err(|e| crate::error::TransportError::InvalidMessage(e.to_string()))?;
327
328            if response.status() != StatusCode::OK {
329                return Err(crate::error::Error::Transport(
330                    crate::error::TransportError::InvalidMessage(format!(
331                        "SSE connection failed with status: {}",
332                        response.status()
333                    )),
334                ));
335            }
336
337            // Spawn SSE reader task
338            let message_tx = self.message_tx.clone();
339            let connected = self.connected.clone();
340            let sse_buffered_bytes = self.sse_buffered_bytes;
341
342            tokio::spawn(async move {
343                *connected.write() = true;
344
345                let mut body = response.into_body();
346                let mut sse_parser = sse_reader_parser(sse_buffered_bytes);
347                // Bytes received but not yet decodable as complete UTF-8. A body
348                // frame boundary can fall in the MIDDLE of a multi-byte
349                // character, so decoding each chunk with `from_utf8_lossy` would
350                // corrupt any non-ASCII payload that straddles two frames. The
351                // shared incremental decoder retains the (≤3 byte) tail instead.
352                let mut undecoded: Vec<u8> = Vec::new();
353
354                while let Some(chunk) = body.frame().await {
355                    match chunk {
356                        Ok(frame) => {
357                            if let Some(data) = frame.data_ref() {
358                                undecoded.extend_from_slice(data);
359                                let text =
360                                    crate::shared::sse_parser::take_utf8_prefix(&mut undecoded);
361                                let events = sse_parser.feed(&text);
362
363                                // Observed BEFORE the events are drained, ENDED
364                                // after: the events this chunk completed are
365                                // legitimate and already decoded, so discarding
366                                // them would lose good frames on top of the ones
367                                // the parser discarded. Same order the
368                                // `subscriptions/listen` client uses, which
369                                // drains its `pending` queue before honouring the
370                                // latch.
371                                let overflowed = report_sse_overflow(&sse_parser);
372
373                                for event in events {
374                                    // Process SSE event data as JSON-RPC message
375                                    match crate::shared::stdio::StdioTransport::parse_message(
376                                        event.data.as_bytes(),
377                                    ) {
378                                        Ok(msg) => {
379                                            if message_tx.send(msg).await.is_err() {
380                                                error!("Failed to send SSE message");
381                                                break;
382                                            }
383                                        },
384                                        Err(e) => {
385                                            error!("Failed to parse SSE message: {}", e);
386                                        },
387                                    }
388                                }
389
390                                if overflowed {
391                                    // The parser DISCARDED buffered bytes, so the
392                                    // byte stream is no longer trustworthy — stop
393                                    // reading a peer already established as
394                                    // hostile or broken.
395                                    break;
396                                }
397                            }
398                        },
399                        Err(e) => {
400                            error!("SSE stream error: {}", e);
401                            break;
402                        },
403                    }
404                }
405
406                *connected.write() = false;
407                warn!("SSE connection closed");
408            });
409        } else {
410            // No SSE endpoint configured, mark as connected for request/response only
411            *self.connected.write() = true;
412        }
413        Ok(())
414    }
415
416    async fn send_request(&self, message: &TransportMessage) -> Result<()> {
417        let json_bytes = crate::shared::stdio::StdioTransport::serialize_message(message)?;
418        let json = String::from_utf8(json_bytes).map_err(|e| {
419            crate::error::Error::Transport(crate::error::TransportError::InvalidMessage(format!(
420                "Invalid UTF-8: {}",
421                e
422            )))
423        })?;
424
425        let req = Request::builder()
426            .method(Method::POST)
427            .uri(self.config.base_url.as_str())
428            .header("Content-Type", "application/json")
429            .body(Full::new(Bytes::from(json)))
430            .map_err(|e| crate::error::TransportError::InvalidMessage(e.to_string()))?;
431
432        let response = timeout(self.config.timeout, self.client.request(req))
433            .await
434            .map_err(|_| crate::error::Error::Timeout(self.config.timeout.as_secs() * 1000))?
435            .map_err(|e| {
436                crate::error::Error::Transport(crate::error::TransportError::InvalidMessage(
437                    e.to_string(),
438                ))
439            })?;
440
441        if response.status() != StatusCode::OK {
442            return Err(crate::error::Error::Transport(
443                crate::error::TransportError::InvalidMessage(format!(
444                    "HTTP request failed with status: {}",
445                    response.status()
446                )),
447            ));
448        }
449
450        // Collect the response body under this transport's collected-body cap.
451        //
452        // The PEER chooses how many bytes it sends and this read buffers all of
453        // them before parsing, so an uncapped `collect()` here was the one
454        // unbounded whole-body read left on this transport — the same defect
455        // class 113-17 fixed on the sibling `connect_sse` reader in this very
456        // file (review CR-03). See `DEFAULT_HTTP_COLLECTED_BODY_BYTES`.
457        let body_bytes = Self::collect_body_within_cap(response, self.max_collected_body_bytes)
458            .await
459            .map_err(|e| {
460                crate::error::Error::Transport(crate::error::TransportError::InvalidMessage(
461                    e.to_string(),
462                ))
463            })?;
464        let response_msg = crate::shared::stdio::StdioTransport::parse_message(&body_bytes)?;
465
466        // Send response through message queue
467        self.message_tx.send(response_msg).await.map_err(|_| {
468            crate::error::Error::Transport(crate::error::TransportError::ConnectionClosed)
469        })?;
470
471        Ok(())
472    }
473}
474
475#[async_trait]
476impl Transport for HttpTransport {
477    async fn send(&mut self, message: TransportMessage) -> Result<()> {
478        debug!("Sending HTTP message: {:?}", message);
479        self.send_request(&message).await
480    }
481
482    async fn receive(&mut self) -> Result<TransportMessage> {
483        let mut rx = self.message_queue.lock().await;
484        rx.recv().await.ok_or_else(|| {
485            crate::error::Error::Transport(crate::error::TransportError::ConnectionClosed)
486        })
487    }
488
489    async fn close(&mut self) -> Result<()> {
490        *self.connected.write() = false;
491        info!("HTTP transport closed");
492        Ok(())
493    }
494
495    fn is_connected(&self) -> bool {
496        *self.connected.read()
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use crate::types::{ClientRequest, Request, RequestId};
504
505    #[test]
506    fn test_http_config_default() {
507        let config = HttpConfig::default();
508        assert!(config.enable_pooling);
509        assert_eq!(config.timeout, Duration::from_secs(30));
510        assert_eq!(config.sse_endpoint, Some("/events".to_string()));
511        assert_eq!(config.max_idle_per_host, 10);
512        assert_eq!(config.headers.len(), 0);
513    }
514
515    #[test]
516    fn test_http_config_custom() {
517        let config = HttpConfig {
518            base_url: "http://example.com:3000".parse().unwrap(),
519            sse_endpoint: None,
520            timeout: Duration::from_mins(1),
521            headers: vec![("X-Custom".to_string(), "value".to_string())],
522            enable_pooling: false,
523            max_idle_per_host: 5,
524        };
525        assert_eq!(config.base_url.as_str(), "http://example.com:3000/");
526        assert!(config.sse_endpoint.is_none());
527        assert_eq!(config.timeout, Duration::from_mins(1));
528        assert_eq!(config.headers.len(), 1);
529        assert!(!config.enable_pooling);
530        assert_eq!(config.max_idle_per_host, 5);
531    }
532
533    #[test]
534    fn test_http_transport_creation() {
535        let config = HttpConfig::default();
536        let transport = HttpTransport::new(config);
537        assert!(!transport.is_connected());
538    }
539
540    #[test]
541    fn test_http_transport_with_url() {
542        let transport =
543            HttpTransport::with_url("http://localhost:9000".parse::<Url>().unwrap()).unwrap();
544        assert!(!transport.is_connected());
545        assert_eq!(transport.config.base_url.as_str(), "http://localhost:9000/");
546    }
547
548    #[test]
549    fn test_http_transport_debug() {
550        let config = HttpConfig::default();
551        let transport = HttpTransport::new(config);
552        let debug_str = format!("{:?}", transport);
553        assert!(debug_str.contains("HttpTransport"));
554        assert!(debug_str.contains("config"));
555        assert!(debug_str.contains("connected"));
556    }
557
558    #[tokio::test]
559    async fn test_http_transport_close() {
560        let config = HttpConfig::default();
561        let mut transport = HttpTransport::new(config);
562
563        // Mark as connected first
564        *transport.connected.write() = true;
565        assert!(transport.is_connected());
566
567        // Close should mark as disconnected
568        transport.close().await.unwrap();
569        assert!(!transport.is_connected());
570    }
571
572    #[tokio::test]
573    async fn test_connect_sse_no_endpoint() {
574        let config = HttpConfig {
575            base_url: "http://localhost:8080".parse().unwrap(),
576            sse_endpoint: None,
577            ..Default::default()
578        };
579        let transport = HttpTransport::new(config);
580
581        // Should mark as connected even without SSE endpoint
582        transport.connect_sse().await.unwrap();
583        assert!(transport.is_connected());
584    }
585
586    #[tokio::test]
587    async fn test_send_request_not_connected() {
588        let config = HttpConfig::default();
589        let mut transport = HttpTransport::new(config);
590
591        let message = TransportMessage::Request {
592            id: RequestId::from(1i64),
593            request: Request::Client(Box::new(ClientRequest::Ping)),
594        };
595
596        // This will fail since we're not connected to a real server
597        let result = transport.send(message).await;
598        assert!(result.is_err());
599    }
600
601    #[test]
602    fn test_http_config_with_headers() {
603        let config = HttpConfig {
604            base_url: "http://localhost:8080".parse().unwrap(),
605            headers: vec![
606                ("Authorization".to_string(), "Bearer token".to_string()),
607                ("X-API-Key".to_string(), "secret".to_string()),
608            ],
609            ..Default::default()
610        };
611        assert_eq!(config.headers.len(), 2);
612        assert_eq!(config.headers[0].0, "Authorization");
613        assert_eq!(config.headers[0].1, "Bearer token");
614    }
615
616    #[test]
617    fn test_http_config_clone() {
618        let config = HttpConfig::default();
619        let cloned = config.clone();
620        assert_eq!(config.base_url, cloned.base_url);
621        assert_eq!(config.timeout, cloned.timeout);
622        assert_eq!(config.enable_pooling, cloned.enable_pooling);
623    }
624
625    /// One SSE frame of exactly `len` bytes, carrying a complete event.
626    fn sse_frame_of_len(len: usize) -> String {
627        // "data: " + payload + "\n\n" — the 8 fixed bytes of framing. Asserted
628        // rather than subtracted blind: `len - 8` underflow-PANICS for any
629        // caller that picks a smaller ceiling, which would report a bound bug as
630        // an arithmetic crash inside the helper.
631        assert!(
632            len >= 8,
633            "an SSE frame cannot be shorter than its 8 bytes of framing (asked for {len})"
634        );
635        format!("data: {}\n\n", "A".repeat(len - 8))
636    }
637
638    /// The reader task's overflow arm, exercised on the predicate it actually
639    /// calls. A deliberately tiny parser stands in for the 16 MiB production
640    /// ceiling so the test allocates bytes rather than megabytes.
641    #[test]
642    fn an_oversized_sse_line_ends_the_reader_task() {
643        let mut parser = SseParser::with_max_buffer_size(64);
644        assert!(
645            !report_sse_overflow(&parser),
646            "a fresh parser has lost nothing, so the task keeps reading"
647        );
648
649        assert!(
650            parser.feed(&"x".repeat(256)).is_empty(),
651            "an unterminated line completes no event"
652        );
653        assert!(
654            report_sse_overflow(&parser),
655            "the discarded bytes end the task instead of being silently swallowed"
656        );
657    }
658
659    /// The realistic flood, on the input class every other bound test in this
660    /// module avoids (review IN-03): perfectly ordinary NEWLINE-TERMINATED
661    /// `data:` lines that the peer simply never ends with a blank line.
662    ///
663    /// Drives `report_sse_overflow`, the predicate the reader task calls, not a
664    /// reconstruction of it.
665    #[test]
666    fn a_newline_carrying_flood_ends_the_reader_task_too() {
667        let mut parser = sse_reader_parser(64);
668        let mut ended = false;
669        for _ in 0..1_000 {
670            assert!(
671                parser.feed("data: AAAAAAAA\n").is_empty(),
672                "a `data:` line with no blank line after it completes no event"
673            );
674            if report_sse_overflow(&parser) {
675                ended = true;
676                break;
677            }
678        }
679        assert!(ended, "accumulated `data:` lines must end the reader task");
680    }
681
682    /// `connect_sse` bounds its reader at its OWN named, configurable ceiling.
683    ///
684    /// The tripwire that used to guard "this site keeps the shared 1 MiB
685    /// default" now guards the named constant instead: it asserts on
686    /// `sse_reader_parser`, the function the reader task actually calls, and on
687    /// the value `HttpTransport` actually passes it, so ANY future change to
688    /// either still fails here.
689    #[test]
690    fn connect_sse_uses_its_own_named_bound() {
691        let transport = HttpTransport::new(HttpConfig::default());
692        assert_eq!(
693            transport.sse_buffered_bytes, DEFAULT_HTTP_SSE_BUFFERED_BYTES,
694            "the transport defaults its ceiling from the named constant"
695        );
696
697        let mut parser = sse_reader_parser(transport.sse_buffered_bytes);
698        assert_eq!(parser.max_buffer_size(), DEFAULT_HTTP_SSE_BUFFERED_BYTES);
699        let _ = parser.feed(&"x".repeat(256));
700        assert!(
701            !report_sse_overflow(&parser),
702            "256 bytes is nowhere near the {DEFAULT_HTTP_SSE_BUFFERED_BYTES}-byte default"
703        );
704    }
705
706    /// Where the ceiling cuts, pinned on both sides and ON it — the comparison
707    /// is `>`, so a payload of EXACTLY the ceiling is admitted (review HIGH-4).
708    ///
709    /// Uses a small configured ceiling so the test costs bytes rather than the
710    /// 16 MiB the production default would.
711    #[test]
712    fn the_configured_ceiling_admits_up_to_and_including_itself() {
713        let ceiling = 256;
714
715        let mut under = sse_reader_parser(ceiling);
716        let events = under.feed(&sse_frame_of_len(ceiling - 1));
717        assert_eq!(events.len(), 1, "one byte under the ceiling parses");
718        assert!(!under.overflowed());
719
720        let mut exact = sse_reader_parser(ceiling);
721        let events = exact.feed(&sse_frame_of_len(ceiling));
722        assert_eq!(events.len(), 1, "exactly the ceiling parses");
723        assert!(!exact.overflowed(), "the comparison is `>`, not `>=`");
724
725        let mut over = sse_reader_parser(ceiling);
726        assert!(
727            over.feed(&sse_frame_of_len(ceiling + 1)).is_empty(),
728            "one byte over the ceiling is refused whole"
729        );
730        assert!(over.overflowed(), "and the refusal is observable");
731        assert!(report_sse_overflow(&over), "so the reader task ends");
732    }
733
734    /// The escape hatch is WIRED, not decorative: the same payload that the
735    /// lower ceiling refuses parses once the ceiling is raised through the
736    /// public builder method, and the raised value reaches the reader's parser.
737    ///
738    /// Expressed at a scaled-down ceiling rather than at the 16 MiB default so
739    /// the test does not allocate 16 MiB to prove a wiring property.
740    #[test]
741    fn raising_the_ceiling_admits_a_payload_the_lower_one_refuses() {
742        let base = 256;
743        let payload = sse_frame_of_len(base + 1);
744
745        let mut low = sse_reader_parser(base);
746        assert!(low.feed(&payload).is_empty());
747        assert!(report_sse_overflow(&low), "refused at the lower ceiling");
748
749        let raised = HttpTransport::new(HttpConfig::default()).with_sse_buffered_bytes(base * 4);
750        assert_eq!(
751            raised.sse_buffered_bytes,
752            base * 4,
753            "the builder overrides the default"
754        );
755
756        let mut parser = sse_reader_parser(raised.sse_buffered_bytes);
757        let events = parser.feed(&payload);
758        assert_eq!(events.len(), 1, "the same bytes now parse");
759        assert!(!report_sse_overflow(&parser));
760    }
761
762    /// base64 expands by ~4/3, which is exactly why a FIXED 16 MiB ceiling is
763    /// indefensible and why "16 MiB comfortably fits a 12 MiB image" is false.
764    ///
765    /// Scaled down by 2^10 from the real numbers — 12 KiB of raw binary against
766    /// a 16 KiB ceiling stands in for 12 MiB against 16 MiB — so the arithmetic
767    /// is identical and the test allocates kilobytes. A future reader who wants
768    /// to re-introduce the "media is unaffected" claim has to delete this first.
769    #[test]
770    fn base64_expansion_puts_a_12_to_16_binary_over_the_ceiling() {
771        use base64::Engine as _;
772
773        let raw_len = 12 * 1024;
774        let ceiling = 16 * 1024;
775
776        let encoded = base64::engine::general_purpose::STANDARD.encode(vec![0u8; raw_len]);
777
778        // The ~4/3 expansion, asserted rather than assumed: 3 raw bytes become
779        // 4 encoded characters, rounded up to a whole group.
780        assert_eq!(
781            encoded.len(),
782            raw_len.div_ceil(3) * 4,
783            "base64 expands 3 raw bytes into 4"
784        );
785        assert_eq!(
786            encoded.len(),
787            ceiling,
788            "a '12 MiB' binary is ALREADY the whole '16 MiB' ceiling once encoded, \
789             with nothing left for JSON, the `data: ` prefix or the MIME type"
790        );
791
792        // And so the SSE framing alone pushes it over.
793        let frame = format!("data: {encoded}\n\n");
794        assert!(frame.len() > ceiling, "the envelope is what tips it");
795
796        let mut parser = sse_reader_parser(ceiling);
797        assert!(
798            parser.feed(&frame).is_empty(),
799            "so the payload is refused at a ceiling sized for its RAW bytes"
800        );
801        assert!(parser.overflowed());
802    }
803
804    #[tokio::test]
805    async fn test_message_queue_receive_closed() {
806        let config = HttpConfig::default();
807        let transport = HttpTransport::new(config);
808
809        // Create a new receiver that's already closed
810        let (_, rx) = mpsc::channel::<TransportMessage>(1);
811        let mut transport = HttpTransport {
812            config: transport.config,
813            client: transport.client,
814            message_queue: Arc::new(AsyncMutex::new(rx)),
815            message_tx: transport.message_tx,
816            connected: transport.connected,
817            sse_buffered_bytes: transport.sse_buffered_bytes,
818            max_collected_body_bytes: transport.max_collected_body_bytes,
819        };
820
821        // Receive should error with ConnectionClosed
822        let result = transport.receive().await;
823        assert!(result.is_err());
824        if let Err(crate::error::Error::Transport(e)) = result {
825            assert!(matches!(e, crate::error::TransportError::ConnectionClosed));
826        }
827    }
828}