Skip to main content

armature_core/
vectored_io.rs

1//! Vectored I/O for HTTP Responses
2//!
3//! This module provides vectored write support using `writev()` to send
4//! multiple buffers (headers + body) in a single syscall, reducing overhead.
5//!
6//! ## Performance Benefits
7//!
8//! Traditional approach:
9//! ```text
10//! write(headers)  → syscall + context switch
11//! write(body)     → syscall + context switch
12//! ```
13//!
14//! Vectored I/O:
15//! ```text
16//! writev([headers, body]) → single syscall
17//! ```
18//!
19//! This can provide +2-3% throughput improvement by:
20//! - Reducing syscall overhead
21//! - Better TCP segment utilization
22//! - Avoiding Nagle's algorithm delays
23//!
24//! ## Usage
25//!
26//! ```rust,ignore
27//! use armature_core::vectored_io::{VectoredResponse, ResponseChunks};
28//!
29//! // Build response with separate header and body buffers
30//! let response = VectoredResponse::new(200)
31//!     .header("Content-Type", "application/json")
32//!     .body(json_bytes);
33//!
34//! // Get IoSlices for writev
35//! let chunks = response.into_io_slices();
36//! ```
37
38use bytes::{BufMut, Bytes, BytesMut};
39use std::borrow::Cow;
40use std::collections::HashMap;
41use std::io::IoSlice;
42
43/// Maximum number of IoSlice buffers for vectored writes.
44/// Most responses have: status line + headers + body = 3 parts.
45/// We allow up to 16 for chunked responses with multiple parts.
46pub const MAX_IO_SLICES: usize = 16;
47
48/// Pre-computed common HTTP status lines (avoids formatting on hot path)
49static STATUS_200: &[u8] = b"HTTP/1.1 200 OK\r\n";
50static STATUS_201: &[u8] = b"HTTP/1.1 201 Created\r\n";
51static STATUS_204: &[u8] = b"HTTP/1.1 204 No Content\r\n";
52static STATUS_301: &[u8] = b"HTTP/1.1 301 Moved Permanently\r\n";
53static STATUS_302: &[u8] = b"HTTP/1.1 302 Found\r\n";
54static STATUS_304: &[u8] = b"HTTP/1.1 304 Not Modified\r\n";
55static STATUS_400: &[u8] = b"HTTP/1.1 400 Bad Request\r\n";
56static STATUS_401: &[u8] = b"HTTP/1.1 401 Unauthorized\r\n";
57static STATUS_403: &[u8] = b"HTTP/1.1 403 Forbidden\r\n";
58static STATUS_404: &[u8] = b"HTTP/1.1 404 Not Found\r\n";
59static STATUS_405: &[u8] = b"HTTP/1.1 405 Method Not Allowed\r\n";
60static STATUS_500: &[u8] = b"HTTP/1.1 500 Internal Server Error\r\n";
61static STATUS_502: &[u8] = b"HTTP/1.1 502 Bad Gateway\r\n";
62static STATUS_503: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n";
63
64/// Header separator
65static HEADER_SEP: &[u8] = b": ";
66/// Line ending
67static CRLF: &[u8] = b"\r\n";
68
69/// Get the status line for a status code.
70///
71/// Common status codes return a borrowed pre-computed line; other codes
72/// are formatted on the fly via [`format_status_line`].
73#[inline]
74pub fn status_line(status: u16) -> Cow<'static, [u8]> {
75    match status {
76        200 => Cow::Borrowed(STATUS_200),
77        201 => Cow::Borrowed(STATUS_201),
78        204 => Cow::Borrowed(STATUS_204),
79        301 => Cow::Borrowed(STATUS_301),
80        302 => Cow::Borrowed(STATUS_302),
81        304 => Cow::Borrowed(STATUS_304),
82        400 => Cow::Borrowed(STATUS_400),
83        401 => Cow::Borrowed(STATUS_401),
84        403 => Cow::Borrowed(STATUS_403),
85        404 => Cow::Borrowed(STATUS_404),
86        405 => Cow::Borrowed(STATUS_405),
87        500 => Cow::Borrowed(STATUS_500),
88        502 => Cow::Borrowed(STATUS_502),
89        503 => Cow::Borrowed(STATUS_503),
90        _ => {
91            let mut buf = BytesMut::with_capacity(32);
92            format_status_line(status, &mut buf);
93            Cow::Owned(buf.to_vec())
94        }
95    }
96}
97
98/// Check if status code has a pre-computed status line.
99#[inline]
100pub fn has_precomputed_status(status: u16) -> bool {
101    matches!(
102        status,
103        200 | 201 | 204 | 301 | 302 | 304 | 400 | 401 | 403 | 404 | 405 | 500 | 502 | 503
104    )
105}
106
107/// Format a status line for non-common status codes.
108#[inline]
109pub fn format_status_line(status: u16, buf: &mut BytesMut) {
110    buf.extend_from_slice(b"HTTP/1.1 ");
111    // Write status code directly
112    let mut n = status;
113    let d2 = (n % 10) as u8 + b'0';
114    n /= 10;
115    let d1 = (n % 10) as u8 + b'0';
116    n /= 10;
117    let d0 = n as u8 + b'0';
118    buf.put_u8(d0);
119    buf.put_u8(d1);
120    buf.put_u8(d2);
121    buf.extend_from_slice(b" ");
122    buf.extend_from_slice(status_reason(status).as_bytes());
123    buf.extend_from_slice(CRLF);
124}
125
126/// Get reason phrase for status code.
127#[inline]
128fn status_reason(status: u16) -> &'static str {
129    match status {
130        100 => "Continue",
131        101 => "Switching Protocols",
132        200 => "OK",
133        201 => "Created",
134        202 => "Accepted",
135        204 => "No Content",
136        206 => "Partial Content",
137        301 => "Moved Permanently",
138        302 => "Found",
139        303 => "See Other",
140        304 => "Not Modified",
141        307 => "Temporary Redirect",
142        308 => "Permanent Redirect",
143        400 => "Bad Request",
144        401 => "Unauthorized",
145        403 => "Forbidden",
146        404 => "Not Found",
147        405 => "Method Not Allowed",
148        408 => "Request Timeout",
149        409 => "Conflict",
150        410 => "Gone",
151        413 => "Payload Too Large",
152        415 => "Unsupported Media Type",
153        422 => "Unprocessable Entity",
154        429 => "Too Many Requests",
155        500 => "Internal Server Error",
156        501 => "Not Implemented",
157        502 => "Bad Gateway",
158        503 => "Service Unavailable",
159        504 => "Gateway Timeout",
160        _ => "Unknown",
161    }
162}
163
164/// Response chunks for vectored I/O.
165///
166/// This holds all the buffers that make up an HTTP response,
167/// ready to be written with a single `writev()` call.
168#[derive(Debug)]
169pub struct ResponseChunks {
170    /// Status line buffer (either static or formatted)
171    status_line: StatusLine,
172    /// Headers buffer (formatted as "Name: Value\r\n...")
173    headers: BytesMut,
174    /// Header terminator (\r\n)
175    header_end: &'static [u8],
176    /// Body buffer
177    body: Bytes,
178}
179
180#[derive(Debug)]
181enum StatusLine {
182    Static(&'static [u8]),
183    Dynamic(BytesMut),
184}
185
186impl StatusLine {
187    fn as_slice(&self) -> &[u8] {
188        match self {
189            StatusLine::Static(s) => s,
190            StatusLine::Dynamic(b) => b,
191        }
192    }
193}
194
195impl ResponseChunks {
196    /// Create new response chunks from status, headers, and body.
197    pub fn new(status: u16, headers: &HashMap<String, String>, body: Bytes) -> Self {
198        Self::with_cookies(status, headers, &[], body)
199    }
200
201    pub fn with_cookies(
202        status: u16,
203        headers: &HashMap<String, String>,
204        cookies: &[String],
205        body: Bytes,
206    ) -> Self {
207        // Status line
208        let status_line = match status_line(status) {
209            Cow::Borrowed(s) => StatusLine::Static(s),
210            Cow::Owned(s) => StatusLine::Dynamic(BytesMut::from(&s[..])),
211        };
212
213        // Headers - estimate size: avg 30 bytes per header
214        let mut headers_buf = BytesMut::with_capacity((headers.len() + cookies.len()) * 30 + 32);
215        for (name, value) in headers {
216            headers_buf.extend_from_slice(name.as_bytes());
217            headers_buf.extend_from_slice(HEADER_SEP);
218            headers_buf.extend_from_slice(value.as_bytes());
219            headers_buf.extend_from_slice(CRLF);
220        }
221        for cookie_value in cookies {
222            headers_buf.extend_from_slice(b"Set-Cookie");
223            headers_buf.extend_from_slice(HEADER_SEP);
224            headers_buf.extend_from_slice(cookie_value.as_bytes());
225            headers_buf.extend_from_slice(CRLF);
226        }
227
228        // Add Content-Length so keep-alive clients know the response framing.
229        // 204, 304, and 1xx responses must not carry a body (RFC 9110), so
230        // they get no Content-Length; every other response gets one even
231        // when the body is empty.
232        //
233        // Only auto-emit when the caller hasn't already set a Content-Length
234        // header (matched case-insensitively): emitting a second one produces
235        // two Content-Length headers, which is a message-framing violation.
236        let caller_set_content_length = headers
237            .keys()
238            .any(|k| k.eq_ignore_ascii_case("content-length"));
239        if caller_set_content_length {
240            // Respect the caller-provided Content-Length verbatim.
241        } else if !body.is_empty() {
242            headers_buf.extend_from_slice(b"Content-Length: ");
243            // Format number without allocation
244            let len = body.len();
245            let mut num_buf = [0u8; 20];
246            let num_str = format_usize(len, &mut num_buf);
247            headers_buf.extend_from_slice(num_str);
248            headers_buf.extend_from_slice(CRLF);
249        } else if status != 204 && status != 304 && !(100..200).contains(&status) {
250            headers_buf.extend_from_slice(b"Content-Length: 0\r\n");
251        }
252
253        Self {
254            status_line,
255            headers: headers_buf,
256            header_end: CRLF,
257            body,
258        }
259    }
260
261    /// Get the total size of the response.
262    #[inline]
263    pub fn total_len(&self) -> usize {
264        self.status_line.as_slice().len()
265            + self.headers.len()
266            + self.header_end.len()
267            + self.body.len()
268    }
269
270    /// Get IoSlices for vectored write.
271    ///
272    /// Returns slices that can be passed to `writev()`.
273    #[inline]
274    pub fn as_io_slices(&self) -> [IoSlice<'_>; 4] {
275        [
276            IoSlice::new(self.status_line.as_slice()),
277            IoSlice::new(&self.headers),
278            IoSlice::new(self.header_end),
279            IoSlice::new(&self.body),
280        ]
281    }
282
283    /// Get number of chunks.
284    #[inline]
285    pub fn chunk_count(&self) -> usize {
286        if self.body.is_empty() { 3 } else { 4 }
287    }
288
289    /// Serialize to a single contiguous buffer.
290    ///
291    /// Use this when vectored I/O is not available.
292    pub fn to_bytes(&self) -> Bytes {
293        let mut buf = BytesMut::with_capacity(self.total_len());
294        buf.extend_from_slice(self.status_line.as_slice());
295        buf.extend_from_slice(&self.headers);
296        buf.extend_from_slice(self.header_end);
297        buf.extend_from_slice(&self.body);
298        buf.freeze()
299    }
300}
301
302/// Format usize to bytes without allocation.
303#[inline]
304fn format_usize(n: usize, buf: &mut [u8; 20]) -> &[u8] {
305    if n == 0 {
306        buf[19] = b'0';
307        return &buf[19..];
308    }
309
310    let mut n = n;
311    let mut pos = 20;
312    while n > 0 && pos > 0 {
313        pos -= 1;
314        buf[pos] = (n % 10) as u8 + b'0';
315        n /= 10;
316    }
317    &buf[pos..]
318}
319
320/// A response builder optimized for vectored I/O.
321///
322/// Builds responses with separate buffers for efficient `writev()`.
323#[derive(Debug)]
324pub struct VectoredResponse {
325    status: u16,
326    headers: Vec<(String, String)>,
327    body: Option<Bytes>,
328}
329
330impl VectoredResponse {
331    /// Create a new response with status code.
332    #[inline]
333    pub fn new(status: u16) -> Self {
334        Self {
335            status,
336            headers: Vec::with_capacity(8),
337            body: None,
338        }
339    }
340
341    /// Create a 200 OK response.
342    #[inline]
343    pub fn ok() -> Self {
344        Self::new(200)
345    }
346
347    /// Set status code.
348    #[inline]
349    pub fn status(mut self, status: u16) -> Self {
350        self.status = status;
351        self
352    }
353
354    /// Add a header.
355    #[inline]
356    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
357        self.headers.push((name.into(), value.into()));
358        self
359    }
360
361    /// Set Content-Type header.
362    #[inline]
363    pub fn content_type(self, value: impl Into<String>) -> Self {
364        self.header("Content-Type", value)
365    }
366
367    /// Set body from Bytes.
368    #[inline]
369    pub fn body(mut self, body: impl Into<Bytes>) -> Self {
370        self.body = Some(body.into());
371        self
372    }
373
374    /// Set body from JSON.
375    #[inline]
376    pub fn body_json<T: serde::Serialize>(self, value: &T) -> Result<Self, crate::Error> {
377        let json =
378            crate::json::to_vec(value).map_err(|e| crate::Error::Serialization(e.to_string()))?;
379        Ok(self
380            .content_type("application/json")
381            .body(Bytes::from(json)))
382    }
383
384    /// Build into ResponseChunks for vectored I/O.
385    #[inline]
386    pub fn build(self) -> ResponseChunks {
387        let headers: HashMap<String, String> = self.headers.into_iter().collect();
388        ResponseChunks::new(self.status, &headers, self.body.unwrap_or_default())
389    }
390
391    /// Build and serialize to contiguous bytes.
392    #[inline]
393    pub fn build_bytes(self) -> Bytes {
394        self.build().to_bytes()
395    }
396}
397
398impl Default for VectoredResponse {
399    fn default() -> Self {
400        Self::ok()
401    }
402}
403
404// ============================================================================
405// Conversion from HttpResponse
406// ============================================================================
407
408impl From<crate::HttpResponse> for ResponseChunks {
409    fn from(response: crate::HttpResponse) -> Self {
410        let status = response.status;
411        let headers = response.headers.to_hashmap();
412        let cookies = response.cookies.clone();
413        let body = response.into_body_bytes();
414        Self::with_cookies(status, &headers, &cookies, body)
415    }
416}
417
418impl crate::HttpResponse {
419    /// Convert to vectored response chunks.
420    ///
421    /// Use this for efficient vectored writes when you have
422    /// direct socket access.
423    #[inline]
424    pub fn into_chunks(self) -> ResponseChunks {
425        ResponseChunks::from(self)
426    }
427
428    /// Get IoSlices for vectored write.
429    ///
430    /// This is a convenience method that creates ResponseChunks
431    /// and returns the slices. For repeated use, prefer `into_chunks()`.
432    #[inline]
433    pub fn to_vectored(&self) -> ResponseChunks {
434        let headers = self.headers.to_hashmap();
435        ResponseChunks::with_cookies(self.status, &headers, &self.cookies, self.body_bytes())
436    }
437}
438
439// ============================================================================
440// Statistics
441// ============================================================================
442
443use std::sync::atomic::{AtomicU64, Ordering};
444
445/// Statistics for vectored I/O operations.
446#[derive(Debug, Default)]
447pub struct VectoredIoStats {
448    /// Total vectored writes
449    writes: AtomicU64,
450    /// Total bytes written
451    bytes_written: AtomicU64,
452    /// Responses with precomputed status lines
453    precomputed_status: AtomicU64,
454    /// Responses with dynamic status lines
455    dynamic_status: AtomicU64,
456}
457
458impl VectoredIoStats {
459    /// Create new stats.
460    pub fn new() -> Self {
461        Self::default()
462    }
463
464    /// Record a write.
465    #[inline]
466    pub fn record_write(&self, bytes: usize, precomputed: bool) {
467        self.writes.fetch_add(1, Ordering::Relaxed);
468        self.bytes_written
469            .fetch_add(bytes as u64, Ordering::Relaxed);
470        if precomputed {
471            self.precomputed_status.fetch_add(1, Ordering::Relaxed);
472        } else {
473            self.dynamic_status.fetch_add(1, Ordering::Relaxed);
474        }
475    }
476
477    /// Get total writes.
478    pub fn writes(&self) -> u64 {
479        self.writes.load(Ordering::Relaxed)
480    }
481
482    /// Get total bytes written.
483    pub fn bytes_written(&self) -> u64 {
484        self.bytes_written.load(Ordering::Relaxed)
485    }
486
487    /// Get precomputed status line percentage.
488    pub fn precomputed_percentage(&self) -> f64 {
489        let total = self.writes();
490        if total == 0 {
491            return 0.0;
492        }
493        (self.precomputed_status.load(Ordering::Relaxed) as f64 / total as f64) * 100.0
494    }
495}
496
497/// Global vectored I/O statistics.
498static VECTORED_STATS: VectoredIoStats = VectoredIoStats {
499    writes: AtomicU64::new(0),
500    bytes_written: AtomicU64::new(0),
501    precomputed_status: AtomicU64::new(0),
502    dynamic_status: AtomicU64::new(0),
503};
504
505/// Get global vectored I/O statistics.
506pub fn vectored_stats() -> &'static VectoredIoStats {
507    &VECTORED_STATS
508}
509
510// ============================================================================
511// Tests
512// ============================================================================
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn test_status_line_precomputed() {
520        assert_eq!(status_line(200).as_ref(), b"HTTP/1.1 200 OK\r\n");
521        assert_eq!(status_line(404).as_ref(), b"HTTP/1.1 404 Not Found\r\n");
522        assert_eq!(
523            status_line(500).as_ref(),
524            b"HTTP/1.1 500 Internal Server Error\r\n"
525        );
526    }
527
528    #[test]
529    fn test_status_line_unlisted_is_formatted() {
530        // Unlisted codes must format their own status line, not fall back to 200
531        assert_eq!(
532            status_line(429).as_ref(),
533            b"HTTP/1.1 429 Too Many Requests\r\n"
534        );
535        assert!(status_line(418).starts_with(b"HTTP/1.1 418"));
536    }
537
538    #[test]
539    fn test_format_status_line() {
540        let mut buf = BytesMut::with_capacity(64);
541        format_status_line(418, &mut buf);
542        assert!(buf.starts_with(b"HTTP/1.1 418"));
543    }
544
545    #[test]
546    fn test_format_usize() {
547        let mut buf = [0u8; 20];
548        assert_eq!(format_usize(0, &mut buf), b"0");
549        assert_eq!(format_usize(123, &mut buf), b"123");
550        assert_eq!(format_usize(1000000, &mut buf), b"1000000");
551    }
552
553    #[test]
554    fn test_response_chunks_basic() {
555        let mut headers = HashMap::new();
556        headers.insert("Content-Type".to_string(), "text/plain".to_string());
557
558        let chunks = ResponseChunks::new(200, &headers, Bytes::from_static(b"Hello"));
559
560        assert!(chunks.total_len() > 0);
561        assert_eq!(chunks.chunk_count(), 4);
562    }
563
564    #[test]
565    fn test_response_chunks_io_slices() {
566        let mut headers = HashMap::new();
567        headers.insert("X-Test".to_string(), "value".to_string());
568
569        let chunks = ResponseChunks::new(200, &headers, Bytes::from_static(b"body"));
570        let slices = chunks.as_io_slices();
571
572        assert_eq!(slices.len(), 4);
573        assert!(!slices[0].is_empty()); // Status line
574        assert!(!slices[1].is_empty()); // Headers
575    }
576
577    #[test]
578    fn test_empty_body_gets_content_length_zero() {
579        // Keep-alive clients need explicit framing even for empty bodies
580        let headers = HashMap::new();
581        let chunks = ResponseChunks::new(200, &headers, Bytes::new());
582        let s = String::from_utf8_lossy(&chunks.to_bytes()).to_string();
583        assert!(s.contains("Content-Length: 0\r\n"), "response was: {s:?}");
584
585        // 204, 304, and 1xx must NOT carry Content-Length
586        for status in [204, 304, 100, 101] {
587            let chunks = ResponseChunks::new(status, &headers, Bytes::new());
588            let s = String::from_utf8_lossy(&chunks.to_bytes()).to_string();
589            assert!(
590                !s.contains("Content-Length"),
591                "status {status} must not have Content-Length: {s:?}"
592            );
593        }
594    }
595
596    #[test]
597    fn test_unlisted_status_response_has_correct_status_line() {
598        let headers = HashMap::new();
599        let chunks = ResponseChunks::new(429, &headers, Bytes::new());
600        let s = String::from_utf8_lossy(&chunks.to_bytes()).to_string();
601        assert!(s.starts_with("HTTP/1.1 429 Too Many Requests\r\n"));
602    }
603
604    #[test]
605    fn test_response_chunks_to_bytes() {
606        let mut headers = HashMap::new();
607        headers.insert("Content-Type".to_string(), "text/plain".to_string());
608
609        let chunks = ResponseChunks::new(200, &headers, Bytes::from_static(b"Hello"));
610        let bytes = chunks.to_bytes();
611
612        let s = String::from_utf8_lossy(&bytes);
613        assert!(s.contains("HTTP/1.1 200 OK"));
614        assert!(s.contains("Content-Type: text/plain"));
615        assert!(s.contains("Hello"));
616    }
617
618    #[test]
619    fn test_vectored_response_builder() {
620        let chunks = VectoredResponse::ok()
621            .header("X-Custom", "test")
622            .body(Bytes::from_static(b"body data"))
623            .build();
624
625        let bytes = chunks.to_bytes();
626        let s = String::from_utf8_lossy(&bytes);
627        assert!(s.contains("X-Custom: test"));
628        assert!(s.contains("body data"));
629    }
630
631    #[test]
632    fn test_vectored_response_json() {
633        #[derive(serde::Serialize)]
634        struct Data {
635            status: &'static str,
636        }
637
638        let chunks = VectoredResponse::ok()
639            .body_json(&Data { status: "ok" })
640            .unwrap()
641            .build();
642
643        let bytes = chunks.to_bytes();
644        let s = String::from_utf8_lossy(&bytes);
645        assert!(s.contains("application/json"));
646        assert!(s.contains(r#""status":"ok""#));
647    }
648
649    #[test]
650    fn test_http_response_to_chunks() {
651        let response = crate::HttpResponse::ok()
652            .with_header("X-Test".to_string(), "value".to_string())
653            .with_body(b"test body".to_vec());
654
655        let chunks = response.into_chunks();
656        let bytes = chunks.to_bytes();
657        let s = String::from_utf8_lossy(&bytes);
658
659        assert!(s.contains("HTTP/1.1 200 OK"));
660        assert!(s.contains("X-Test: value"));
661        assert!(s.contains("test body"));
662    }
663
664    #[test]
665    fn test_empty_body() {
666        let chunks = ResponseChunks::new(204, &HashMap::new(), Bytes::new());
667        assert_eq!(chunks.chunk_count(), 3); // No body chunk
668    }
669
670    #[test]
671    fn test_caller_content_length_not_duplicated() {
672        // When the caller already set a Content-Length header, the auto-emit
673        // logic must not add a second one (a framing violation), regardless of
674        // header-name casing or whether the body is empty or non-empty.
675        for (name, body) in [
676            ("Content-Length", Bytes::from_static(b"12345")),
677            ("content-length", Bytes::from_static(b"12345")),
678            ("Content-Length", Bytes::new()),
679        ] {
680            let mut headers = HashMap::new();
681            headers.insert(name.to_string(), "5".to_string());
682            let chunks = ResponseChunks::new(200, &headers, body);
683            let s = String::from_utf8_lossy(&chunks.to_bytes()).to_lowercase();
684            assert_eq!(
685                s.matches("content-length:").count(),
686                1,
687                "exactly one Content-Length expected, got: {s:?}"
688            );
689        }
690    }
691
692    #[test]
693    fn test_content_length_header() {
694        let chunks = ResponseChunks::new(200, &HashMap::new(), Bytes::from_static(b"12345"));
695        let bytes = chunks.to_bytes();
696        let s = String::from_utf8_lossy(&bytes);
697        assert!(s.contains("Content-Length: 5"));
698    }
699}