Skip to content

Latest commit

 

History

History
267 lines (204 loc) · 9.77 KB

File metadata and controls

267 lines (204 loc) · 9.77 KB

Wire protocol

hyper-uds speaks a strict subset of HTTP/1.1 over an AF_UNIX SOCK_STREAM socket. The on-the-wire bytes are ordinary HTTP, so curl --unix-socket and socat work for debugging. The only hyper-uds-specific element is the X-FD header, which is paired with SCM_RIGHTS ancillary data on the underlying sendmsg / recvmsg syscalls.

This document is the normative description of what the server accepts and emits.


1. Transport

  • Socket family: AF_UNIX.
  • Socket type: SOCK_STREAM.
  • Byte stream: HTTP/1.1.
  • Ancillary: SCM_RIGHTS (see fd-passing.md).

TCP/IP and SOCK_DGRAM are not supported and never will be — the whole point is SCM_RIGHTS, which only AF_UNIX SOCK_STREAM (and SOCK_SEQPACKET) carry.

2. Requests

2.1 Request line

METHOD SP request-target SP HTTP-version CRLF
  • METHOD — any token accepted by http::Method::from_bytes. Routing is the user Service's job; the framework does not special-case any method.
  • request-target — any path/query accepted by http::Uri::from_str. Authority-form (used by HTTP CONNECT) is parsed but has no special meaning here.
  • HTTP-versionHTTP/1.0 or HTTP/1.1. Anything else is rejected with Error::Parse("unsupported http version"). See §5 for the asymmetry: 1.0 input is accepted, but the server always responds with HTTP/1.1.

2.2 Headers

  • Up to 64 headers per request (MAX_HEADERS in codec.rs). Exceeding this returns Error::HeadTooLarge.
  • Total head size — the request line plus the entire header block plus the terminating CRLF CRLF — must not exceed Builder::max_head_size (default 16 KiB). Exceeding this also returns Error::HeadTooLarge.
  • Header names and values must be valid per RFC 9110; the framework rejects malformed headers with Error::Parse.

The framework reads three headers specially. All other headers are preserved in Request::headers() verbatim and are the user's to interpret.

Content-Length

  • If present, must be a valid base-10 unsigned integer.
  • Defines the exact body length the server will read.
  • If Content-Length > Builder::max_body_size (default 16 MiB) the server returns Error::BodyTooLarge and closes the connection before reading the body.
  • If absent, the request body is treated as empty (length 0). This diverges from RFC 9112 (which would imply chunked or close-delimited); see §3 below.

Connection

  • Comma-separated tokens; matched case-insensitively.
  • close token — keep-alive is disabled for this connection. After responding, the server closes the socket.
  • keep-alive token — only meaningful with HTTP/1.0 input; explicitly opts in to keep-alive. With HTTP/1.1 input, keep-alive is the default unless close is present.
  • Builder::keep_alive(false) overrides everything: every connection is one-shot regardless of what the request says.

X-FD

  • Comma-separated list of logical descriptor names.
  • Each name is trim()ed of ASCII whitespace; empty names (e.g. an extra comma) yield Error::InvalidXFdHeader.
  • The order of names defines the order of expected descriptors in the ancillary SCM_RIGHTS cmsg. fd_names[i]fds[i].
  • The header must be valid UTF-8.

If X-FD names N descriptors but fewer than N descriptors arrived on the cmsg, the request is rejected with Error::InvalidXFdHeader.

If more than N descriptors arrived, the surplus is silently closed. This lets clients keep a stable header layout while attaching extra context (e.g. a debug pidfd) that the server isn't expected to know about. To receive all attached fds, the client must list every one in X-FD.

If more than Builder::max_fds_per_request (default 16) descriptors arrived in total — including the surplus — the connection is aborted with Error::TooManyFds. This bound is enforced before the surplus trim; clients cannot bypass the cap by leaving fds out of the header.

2.3 Body

  • Length is determined exclusively by Content-Length.
  • No chunked encoding. A request that arrives with Transfer-Encoding: chunked will not be parsed correctly; the bytes after the head will be interpreted as the start of the next request and almost certainly produce an Error::Parse or Error::HeadTooLarge. This is by design — see "Why no chunked" below.
  • The body is buffered in memory in full before the Service is called. There is no streaming-body API.

Why no chunked

For local IPC, payload sizes are virtually always known up front by the client (RPC arguments are not unbounded streams). Chunked encoding doubles the framing-state-machine surface and complicates the "one head + one body, then maybe loop" flow that makes the connection state machine trivially auditable. curl -d ... defaults to Content-Length for buffered bodies, so debug compatibility is preserved for the common case.

If chunked-style streaming becomes necessary later, the right answer is a separate response body API on Response, not retrofitting it through the existing single-Bytes shape.

3. Responses

3.1 Status line

HTTP/1.1 SP status-code SP reason-phrase CRLF
  • The framework always writes HTTP/1.1 regardless of request version. (Downgrading to HTTP/1.0 is not implemented; a real 1.0 client surfacing this would be the trigger to add it.)
  • status-code is Response::status().as_str().
  • reason-phrase is the canonical IANA reason for the status, or empty string if unknown.

3.2 Headers

The framework owns three response headers and strips any user-set copies of them before writing user headers. This prevents duplicates:

  • Content-Length — always emitted, computed from Response.body.len(). Bodies are fully buffered, so the length is always known.
  • Connection — emitted as Connection: close if and only if the computed keep-alive state for this exchange is false. Otherwise omitted (HTTP/1.1's default is keep-alive).
  • X-FD — emitted if and only if Response.fd_names is non-empty. The names are joined with , and emitted in the same order as the attached descriptors (see fd-passing.md).

All other headers from Response::headers() are written through unchanged.

3.3 Body

  • Always Content-Length-framed.
  • Written to the socket immediately after the head's terminating CRLF CRLF.

4. Keep-alive

The effective keep-alive state for a request/response cycle is the boolean AND of:

  1. Builder::keep_alive(...) — server policy. Default true.
  2. The wire policy:
    • HTTP/1.1: keep-alive unless the request had Connection: close.
    • HTTP/1.0: connection-close unless the request had Connection: keep-alive.

If keep-alive is false, the server emits Connection: close in the response and closes the socket after writing.

Connection::graceful_shutdown() causes the next iteration of the read loop to return Ok(()) instead of reading another request. The in-flight request, if any, completes normally. There is no abrupt mid-request cancellation API.

5. Version handling

Request version Accepted? Response version Default keep-alive
HTTP/1.0 yes HTTP/1.1 off
HTTP/1.1 yes HTTP/1.1 on
HTTP/2.0 etc. no — Error::Parse n/a n/a

The asymmetric "accept 1.0, respond 1.1" stance is a debug-affordance choice: many old curl-style toolchains default to 1.0, and rejecting them entirely is unfriendly; but emitting 1.0 responses requires implementing the 1.0 keep-alive grammar more carefully than is worth it for this crate's use-case.

6. Error mapping

The framework currently does not translate parse / size / fd errors into HTTP error responses. Any failure in the read or fd-handoff phases returns an Error from the Connection future, and the underlying socket is dropped. The rationale is:

  • An IPC peer who sends a malformed head is very likely a bug, not a recoverable HTTP client. Returning 400 Bad Request would let the same bug recur on the next iteration.
  • Returning a body that mentions a structured framework error leaks internal taxonomy across a process boundary that callers should not depend on.

If a service wants to return 4xx/5xx for application-level problems, it does so by returning an Ok(Response { status: ... }) from Service::call, exactly like hyper. Returning Err(...) from Service::call wraps the error in Error::Service and aborts the connection.

7. Worked example

A request with one attached fd named input and an empty body:

POST /open HTTP/1.1
host: localhost
content-length: 0
x-fd: input

Sent as one sendmsg whose cmsghdr carries SCM_RIGHTS [<input_fd>].

A response that returns one fd named reply with a 2-byte body:

HTTP/1.1 200 OK
content-length: 2
x-fd: reply

ok

Sent as one or more sendmsg calls; the first carries SCM_RIGHTS [<reply_fd>], subsequent retries (after partial writes) carry no cmsg.

This exact handshake is what tests/e2e.rs::fd_passing_round_trip exercises end-to-end against a hand-rolled recvmsg/sendmsg client.