std.http.Server: requests that take the process down #
two behaviours of a server built on std.http.Server and IpAddress.listen
that no test of the handlers will show. both hold on 0.16.0 and 0.17.0.
a body method with no framing panics on respond #
curl -X POST http://host/any-path sends POST with neither Content-Length
nor Transfer-Encoding. if the handler answers without reading the body, the
process panics with reached unreachable code inside request.respond.
the mechanism: before a keep-alive response, the server discards whatever body the client sent so the connection can carry the next request. for a method that may have a body it asserts the request said how long that body is:
// std/http/Server.zig, discardBody
if (request.head.method.requestHasBody()) {
assert(request.head.transfer_encoding != .none or request.head.content_length != null);
an assert is unreachable when false, so a ReleaseSafe build panics and a
ReleaseFast build has undefined behaviour. the request is legal on the wire
and needs no authentication, so any route on a public listener is enough,
including a 404.
a server whose handlers never read a body cannot rely on them to notice.
reject the request right after receiveHead, before routing:
fn rejectUnframedBody(request: *http.Server.Request) bool {
const head = &request.head;
if (!head.method.requestHasBody()) return false;
if (head.transfer_encoding != .none or head.content_length != null) return false;
request.respond("", .{ .status = .length_required, .keep_alive = false }) catch {};
return true;
}
.keep_alive = false is what makes the 411 safe: the discard step is skipped
when the connection will not be reused. the caller then closes the connection.
the guard is testable without a socket, because http.Server takes any
Io.Reader and Io.Writer:
var in: std.Io.Reader = .fixed("POST /search HTTP/1.1\r\nHost: x\r\n\r\n");
var out_buf: [512]u8 = undefined;
var out: std.Io.Writer = .fixed(&out_buf);
var server = http.Server.init(&in, &out);
var request = try server.receiveHead();
try std.testing.expect(rejectUnframedBody(&request));
try std.testing.expect(std.mem.startsWith(u8, out.buffered(), "HTTP/1.1 411 "));
a proxy in front of the server may add Content-Length: 0 to such a request
and hide the problem; that was not measured here, so do not count on it.
reuse_address lets a second process share the port #
listen(io, .{ .reuse_address = true }) sets SO_REUSEPORT as well as
SO_REUSEADDR wherever the platform defines it. a second copy of the server
started on the same port does not fail with AddressInUse: both bind, and
the kernel spreads incoming connections across them.
the place this bites is testing. a server left running from an earlier
attempt keeps answering a share of the requests, so results flip between old
and new behaviour from one request to the next. before trusting a local
comparison, confirm one listener per port (ss -ltnp, lsof -i :PORT).
sources #
lib/std/http/Server.zig(discardBody) andlib/std/Io/Threaded.zig(listen socket options) in 0.16.0 and 0.17.0- pub-search, typeahead — panic reproduced on 0.16.0 and 0.17.0 builds