Minimal HTTP/1.1-on-UDS RPC framework with SCM_RIGHTS file-descriptor
passing.
hyper-uds is not a general-purpose HTTP server. It is a deliberately
small framework for building RPC services that run inside a single host,
talk over a Unix-domain socket, and need to hand each other open file
descriptors as part of their request/response payloads.
It speaks just enough HTTP/1.1 to be debuggable with curl --unix-socket
and to feel familiar to anyone who has used hyper, while shedding
everything that does not serve the IPC use-case (TLS, h2, chunked
encoding, pipelining, multi-host concerns).
Two common needs in container/sandbox/agent tooling do not have a clean intersection in the existing ecosystem:
- Structured RPC — request routing, headers, status codes, per-request bodies — all things HTTP gives you essentially for free.
- Transferring open file descriptors — pipes, memfds, sockets,
pidfds — across process boundaries viaSCM_RIGHTSancillary messages on a Unix-domain socket.
Hand-rolled protocols on top of a UDS get fd passing, but you re-invent
framing, headers and content negotiation. hyper (or tonic, axum,
…) gives you the framing but treats the underlying transport as an
opaque byte stream — there is no first-class place to put a received
OwnedFd.
hyper-uds keeps the HTTP framing and lets requests/responses carry
file descriptors directly, addressed by a logical name through an
X-FD header.
HTTP/1.1overAF_UNIX— request line, headers,Content-Lengthbody, status line, response headers. Wire-compatible withcurl --unix-socket.- First-class fd passing —
Request::fds()/Response::builder().fd(...). Descriptors are matched to logical names via anX-FD: name1,name2header so callers do not rely on positional ordering. - Direct
recvmsg(2)/sendmsg(2)through [rustix], driven by tokio'sAsyncFd. No intermediate framing layer touches the bytes. - Bounded everything — head size, body size, fds-per-request are all
configurable on the [
Builder]. The defaults are conservative enough for IPC; raise them deliberately. - Single allocation per response head, single
Vec<u8>reused across keep-alive iterations. Dominant cost on the hot path is the syscalls themselves. - No
unsafein the crate's own logic;unsafe_op_in_unsafe_fnis forbidden at the crate root. The transport relies onrustixandtokio::io::unix::AsyncFdfor the unsafe bits.
- TLS, HTTP/2, HTTP/3, websockets.
- Chunked transfer encoding (request bodies must declare a
Content-Length; responses are alwaysContent-Length-framed). - HTTP/1 request pipelining. The dispatcher serializes requests on a
connection so that ancillary fds can be unambiguously paired with the
request that carried them. See
docs/fd-passing.md. - TCP / IP transports. Non-
AF_UNIXsockets cannot carrySCM_RIGHTS.
# Cargo.toml
[dependencies]
hyper-uds = "0.1"
tokio = { version = "1", features = ["rt", "net", "macros"] }
http = "1"
bytes = "1"use std::os::unix::net::UnixListener as StdUnixListener;
use http::StatusCode;
use hyper_uds::{Builder, Request, Response, service_fn};
use tokio::task::LocalSet;
#[tokio::main(flavor = "current_thread")]
async fn main() -> std::io::Result<()> {
let _ = std::fs::remove_file("/tmp/hyper-uds.sock");
let listener = StdUnixListener::bind("/tmp/hyper-uds.sock")?;
listener.set_nonblocking(true)?;
let listener = tokio::net::UnixListener::from_std(listener)?;
let local = LocalSet::new();
local
.run_until(async move {
loop {
let (stream, _) = listener.accept().await?;
let std_stream = stream.into_std()?;
let svc = service_fn(|req: Request| async move {
let body = req.into_body();
Ok::<_, std::convert::Infallible>(
Response::builder()
.status(StatusCode::OK)
.body(body)
.build(),
)
});
tokio::task::spawn_local(async move {
let _ = Builder::new()
.keep_alive(true)
.serve_connection(std_stream, svc)
.await;
});
}
#[allow(unreachable_code)]
Ok::<_, std::io::Error>(())
})
.await
}Talk to it with curl:
$ curl --unix-socket /tmp/hyper-uds.sock -d "hello" http://localhost/echo
hello
A client attaches a descriptor as SCM_RIGHTS ancillary data on the
same sendmsg that carries the request head, and lists its logical
name in the X-FD request header:
POST /open HTTP/1.1
host: localhost
content-length: 0
x-fd: input
(+ ancillary cmsg: SCM_RIGHTS [<fd>])
The handler then sees:
fn handle(mut req: hyper_uds::Request) {
assert_eq!(req.fd_names(), &["input".to_string()]);
let fd = req.take_fds().remove(0); // OwnedFd
// ... use fd ...
}Responses can attach fds the same way via
Response::builder().fd("name", owned_fd).
A working end-to-end example using a hand-rolled recvmsg/sendmsg
client lives in tests/e2e.rs.
docs/architecture.md— layered design and why each layer exists.docs/protocol.md— the HTTP/1.1 subset on the wire, including theX-FDheader.docs/fd-passing.md—SCM_RIGHTSmechanics, fd lifetime, and the pipelining caveat.
API documentation: cargo doc --open.
# Build
cargo build
# Unit + integration tests (drives a real UDS in tempdir)
cargo test
# Single-connection ping-pong latency benchmark
cargo bench --bench ping_pong
cargo bench --bench ping_pong -- --iters 200000 --warmup 20000
The benchmark reports min / p50 / p90 / p99 / p99.9 / max latencies for 4-byte requests on a single keep-alive connection. See the bench source for what is and isn't included in the measurement.
Edition 2024 — currently targets stable Rust ≥ 1.85.
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.