Skip to main content

http_proxy/
http_proxy.rs

1//! An **HTTP/1.1 reverse proxy** on a single compio runtime, pooling and reusing
2//! keep-alive connections to the origin.
3//!
4//! This is the pool's headline use made concrete. The proxy accepts client
5//! connections (a [`compio::runtime::spawn`] task each — no OS threads), and for
6//! every request it leases one upstream connection from a [`Pool`], forwards the
7//! request, reads the response, and *returns the upstream to the pool* instead of
8//! closing it. A handful of pooled, kept-alive upstreams therefore serve an
9//! unbounded stream of client requests — which is the entire reason a connection
10//! pool exists.
11//!
12//! To make the reuse visible without any external service, the example also runs
13//! an in-process **origin** on an ephemeral port (unless `ORIGIN=host:port` is
14//! set). That origin stamps every response with two headers:
15//!
16//! * `x-upstream-conn:` — an id unique to each upstream TCP connection, and
17//! * `x-upstream-reqs:` — how many requests that one connection has served.
18//!
19//! Hit the proxy repeatedly and you will see only a few distinct `x-upstream-conn`
20//! ids, each with a climbing `x-upstream-reqs` — the pool handing the same warm
21//! connections back out, request after request, across *different* client
22//! connections.
23//!
24//! The message framing here is deliberately small: request/response heads are
25//! read to the blank line, bodies are taken from `Content-Length` (the bundled
26//! origin always sets it). That is enough to forward real `curl` traffic and to
27//! show the pool working; it is not a conformant HTTP implementation.
28//!
29//! ```text
30//! cargo run --release --example http_proxy -- [LISTEN] [CAPACITY]
31//!   LISTEN     proxy bind address          default 127.0.0.1:8080
32//!   CAPACITY   max pooled upstreams         default 16
33//!   ORIGIN=host:port  proxy to a real origin instead of the built-in one
34//!
35//! # watch the same upstream connections get reused:
36//! for i in $(seq 5); do curl -si http://127.0.0.1:8080/ | grep -i x-upstream; echo; done
37//! ```
38
39use std::{
40    io,
41    net::SocketAddr,
42    sync::atomic::{AtomicU64, Ordering},
43};
44
45use compio::{
46    BufResult,
47    io::{AsyncRead, AsyncWriteExt},
48    net::{TcpListener, TcpStream},
49    runtime::Runtime,
50};
51use compio_pool::{LocalPool, ManageConnection, Pool};
52
53/// Gives each in-process origin connection a distinct id, so reuse is visible.
54static ORIGIN_CONN_ID: AtomicU64 = AtomicU64::new(0);
55
56/// A TCP stream plus a read buffer and a health flag: one `io_uring`-bound
57/// connection framed as HTTP messages. Used for both ends and, when pooled, as
58/// the upstream the pool hands out.
59struct Framed {
60    stream: TcpStream,
61    buf: Vec<u8>,
62    healthy: bool,
63}
64
65impl Framed {
66    fn new(stream: TcpStream) -> Self {
67        Self {
68            stream,
69            buf: Vec::with_capacity(8 * 1024),
70            healthy: true,
71        }
72    }
73
74    /// Append one `read` into the buffer. `Ok(false)` means the peer closed.
75    async fn read_more(&mut self) -> io::Result<bool> {
76        // compio reads into the buffer's spare capacity and advances its length,
77        // so a read with no room would come back as 0 bytes — reserve first.
78        if self.buf.len() == self.buf.capacity() {
79            self.buf.reserve(8 * 1024);
80        }
81        let taken = std::mem::take(&mut self.buf);
82        let BufResult(res, b) = self.stream.read(taken).await;
83        self.buf = b;
84        match res {
85            Ok(0) => Ok(false),
86            Ok(_) => Ok(true),
87            Err(e) => Err(e),
88        }
89    }
90
91    /// Read one whole HTTP message — head to the blank line, then `Content-Length`
92    /// bytes of body — and drain it out of the buffer. `Ok(None)` at a clean
93    /// message boundary means the peer is done.
94    async fn read_message(&mut self) -> io::Result<Option<Vec<u8>>> {
95        // 1. Read until the end of the header block.
96        let head_end = loop {
97            if let Some(pos) = find(&self.buf, b"\r\n\r\n") {
98                break pos + 4;
99            }
100            if !self.read_more().await? {
101                // Clean close at a boundary -> done; mid-head close -> give up too.
102                return Ok(None);
103            }
104        };
105
106        // 2. Read the body named by Content-Length (0 when the header is absent).
107        let total = head_end + content_length(&self.buf[..head_end]);
108        while self.buf.len() < total {
109            if !self.read_more().await? {
110                return Ok(None); // truncated body
111            }
112        }
113
114        // 3. Hand back exactly one message; anything after it stays for the next.
115        Ok(Some(self.buf.drain(..total).collect()))
116    }
117
118    /// Write a full buffer of bytes.
119    async fn write_all_bytes(&mut self, bytes: Vec<u8>) -> io::Result<()> {
120        let BufResult(res, _) = self.stream.write_all(bytes).await;
121        res
122    }
123}
124
125/// Opens pooled upstream connections to the origin. A real deployment would point
126/// this at an app server; `is_valid`/`has_broken` retire an upstream the proxy
127/// flagged after a failed round trip.
128struct Origin {
129    addr: SocketAddr,
130}
131
132impl ManageConnection for Origin {
133    type Connection = Framed;
134    type Error = io::Error;
135
136    async fn connect(&self) -> io::Result<Framed> {
137        let stream = TcpStream::connect(self.addr).await?;
138        stream.set_nodelay(true)?;
139        Ok(Framed::new(stream))
140    }
141
142    async fn is_valid(&self, conn: &mut Framed) -> io::Result<()> {
143        if conn.healthy {
144            Ok(())
145        } else {
146            Err(io::Error::new(io::ErrorKind::BrokenPipe, "upstream marked dead"))
147        }
148    }
149
150    fn has_broken(&self, conn: &mut Framed) -> bool {
151        !conn.healthy
152    }
153}
154
155fn main() -> io::Result<()> {
156    let listen: SocketAddr = std::env::args()
157        .nth(1)
158        .unwrap_or_else(|| "127.0.0.1:8080".into())
159        .parse()
160        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, format!("bad listen addr: {e}")))?;
161    let capacity: u32 = std::env::args()
162        .nth(2)
163        .and_then(|s| s.parse().ok())
164        .unwrap_or(16);
165
166    let runtime = Runtime::builder().build().expect("build compio runtime");
167    runtime.block_on(async move {
168        // The origin: an external one via ORIGIN, or a built-in loopback one.
169        let origin_addr = match std::env::var("ORIGIN") {
170            Ok(s) => s.parse().map_err(|e| {
171                io::Error::new(io::ErrorKind::InvalidInput, format!("bad ORIGIN: {e}"))
172            })?,
173            Err(_) => {
174                let any: SocketAddr = "127.0.0.1:0".parse().unwrap();
175                let origin = TcpListener::bind(any).await?;
176                let addr = origin.local_addr()?;
177                compio::runtime::spawn(origin_loop(origin)).detach();
178                addr
179            }
180        };
181
182        let pool = Pool::builder()
183            .max_size(capacity)
184            .build(Origin { addr: origin_addr });
185        let local = pool.local();
186
187        let proxy = TcpListener::bind(listen).await?;
188        println!(
189            "http_proxy on {listen} -> origin {origin_addr}: 1 runtime, up to {capacity} pooled \
190             upstreams"
191        );
192        println!("try: for i in $(seq 5); do curl -si http://{listen}/ | grep -i x-upstream; done");
193
194        loop {
195            let Ok((stream, _peer)) = proxy.accept().await else {
196                continue;
197            };
198            let _ = stream.set_nodelay(true);
199            compio::runtime::spawn(handle_client(local.clone(), stream)).detach();
200        }
201    })
202}
203
204/// Serve one client connection: for each request, lease an upstream, forward the
205/// bytes, read the response, hand the upstream back, and reply — keeping the
206/// client connection open for the next request.
207async fn handle_client(local: LocalPool<Origin>, client_stream: TcpStream) {
208    let mut client = Framed::new(client_stream);
209    loop {
210        let request = match client.read_message().await {
211            Ok(Some(req)) => req,
212            _ => return, // client closed or sent a malformed head
213        };
214
215        // Lease one upstream for this round trip. The same few upstreams are
216        // reused across every request and every client — the point of the pool.
217        let mut up = match local.get().await {
218            Ok(up) => up,
219            Err(_) => {
220                let _ = client.write_all_bytes(bad_gateway("no upstream available")).await;
221                return;
222            }
223        };
224
225        // Forward the request and read one response back.
226        let response = if up.write_all_bytes(request).await.is_ok() {
227            up.read_message().await
228        } else {
229            Err(io::Error::new(io::ErrorKind::BrokenPipe, "upstream write failed"))
230        };
231
232        match response {
233            Ok(Some(resp)) => {
234                drop(up); // healthy: return it to the pool before touching the client
235                if client.write_all_bytes(resp).await.is_err() {
236                    return;
237                }
238            }
239            _ => {
240                up.healthy = false; // dropping now discards it instead of pooling it
241                let _ = client.write_all_bytes(bad_gateway("upstream error")).await;
242                return;
243            }
244        }
245    }
246}
247
248/// The built-in origin: accept forever, each connection served on its own task
249/// with a distinct id so the proxy's clients can see which upstream served them.
250async fn origin_loop(listener: TcpListener) {
251    loop {
252        let Ok((stream, _peer)) = listener.accept().await else {
253            continue;
254        };
255        let _ = stream.set_nodelay(true);
256        let id = ORIGIN_CONN_ID.fetch_add(1, Ordering::Relaxed);
257        compio::runtime::spawn(origin_conn(stream, id)).detach();
258    }
259}
260
261/// One origin connection: reply to every request, reporting its id and how many
262/// requests it has now served so reuse is visible end to end.
263async fn origin_conn(stream: TcpStream, id: u64) {
264    let mut conn = Framed::new(stream);
265    let mut served: u64 = 0;
266    loop {
267        match conn.read_message().await {
268            Ok(Some(_req)) => {
269                served += 1;
270                let body = format!("served by upstream connection #{id}, request #{served}\n");
271                let resp = format!(
272                    "HTTP/1.1 200 OK\r\n\
273                     content-type: text/plain\r\n\
274                     content-length: {}\r\n\
275                     x-upstream-conn: {id}\r\n\
276                     x-upstream-reqs: {served}\r\n\
277                     connection: keep-alive\r\n\
278                     \r\n\
279                     {body}",
280                    body.len()
281                );
282                if conn.write_all_bytes(resp.into_bytes()).await.is_err() {
283                    return;
284                }
285            }
286            _ => return,
287        }
288    }
289}
290
291/// A minimal `502` response for when the upstream is unavailable or fails.
292fn bad_gateway(why: &str) -> Vec<u8> {
293    let body = format!("502 Bad Gateway: {why}\n");
294    format!(
295        "HTTP/1.1 502 Bad Gateway\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
296        body.len()
297    )
298    .into_bytes()
299}
300
301/// The `Content-Length` of an HTTP head, or 0 if it has none. Header names are
302/// case-insensitive, so match on a lowercased copy.
303fn content_length(head: &[u8]) -> usize {
304    let text = String::from_utf8_lossy(head).to_ascii_lowercase();
305    for line in text.split("\r\n") {
306        if let Some(value) = line.strip_prefix("content-length:") {
307            return value.trim().parse().unwrap_or(0);
308        }
309    }
310    0
311}
312
313/// Find the first occurrence of `needle` in `hay`.
314fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
315    if needle.is_empty() || hay.len() < needle.len() {
316        return None;
317    }
318    hay.windows(needle.len()).position(|w| w == needle)
319}