about things notes.zzstoatzz.io
notes languages ziglang std io http-server.md
3.3 kB · 74 lines
Markdown
at main

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) and lib/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