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}