Problem
A modeled operation on HEAD returns its serialized body on the wire, which RFC 9110 §9.3.2 forbids and which desynchronizes a keep-alive connection: the client reads those bytes as the beginning of the next response.
ToWireResponse (runtime/src/http/beast_transport.cc:114, at 83a4281) copies the body and frames it without ever seeing the request method:
bhttp::response<bhttp::string_body> ToWireResponse(HttpResponse response, bool keep_alive) {
...
wire.body() = std::move(response.body);
wire.keep_alive(keep_alive);
wire.prepare_payload(); // sets content-length
return wire;
}
grep -rn HEAD runtime/src/http/ finds nothing in beast_transport.cc, http1.cc, or socket_transport.cc — no response path inspects the method.
Routing is not the issue. Router::Add buckets by method and checks conflicts only within a bucket (runtime/src/server/router.cc:147-150), so @http(method: "HEAD", uri: "/x/{id}") registers cleanly beside the GET on the same URI and dispatches natively. It is only the framing that comes out wrong.
Why the handler can't fix it
The body is produced by the generated serializer, and the transport declares itself authoritative for framing — its own comment above IsFramingHeader explains that a handler-set content-length is stripped precisely to avoid the request-smuggling pair (#46). So a service that models HEAD has no way to suppress the body from inside its handler.
Expected
The transport drops the body for a HEAD request while keeping Content-Length at the value the equivalent GET would report, so a modeled HEAD operation is correct on the wire.
The repo already treats this as the right behavior one layer up: HealthEndpoint answers GET or HEAD and writes the body only for GET (runtime/src/server/middleware.cc:125,143). This asks for the same rule where framing is decided, so every operation gets it rather than each handler reimplementing it.
Impact
Downstream, MoonBase's iili shortener wanted HEAD /iili/v1/r/{slug} to return the 302 and Location that unfurlers and link checkers lead with. Modeling it would have put a 2-byte {} body on a HEAD response, so it ships a service-local middleware that re-dispatches HEAD as GET and clears the body (muchq/MoonBase#1433, muchq/MoonBase#1435). That workaround keeps the contract out of the model, where it belongs — it would be deleted once the transport handles this.
Notes
A Send("HEAD", ...) loopback test asserting an empty body with Content-Length intact would pin it. Worth checking socket_transport.cc on the same terms, since it strips the same framing header set.
Problem
A modeled operation on
HEADreturns its serialized body on the wire, which RFC 9110 §9.3.2 forbids and which desynchronizes a keep-alive connection: the client reads those bytes as the beginning of the next response.ToWireResponse(runtime/src/http/beast_transport.cc:114, at83a4281) copies the body and frames it without ever seeing the request method:grep -rn HEAD runtime/src/http/finds nothing inbeast_transport.cc,http1.cc, orsocket_transport.cc— no response path inspects the method.Routing is not the issue.
Router::Addbuckets by method and checks conflicts only within a bucket (runtime/src/server/router.cc:147-150), so@http(method: "HEAD", uri: "/x/{id}")registers cleanly beside theGETon the same URI and dispatches natively. It is only the framing that comes out wrong.Why the handler can't fix it
The body is produced by the generated serializer, and the transport declares itself authoritative for framing — its own comment above
IsFramingHeaderexplains that a handler-setcontent-lengthis stripped precisely to avoid the request-smuggling pair (#46). So a service that models HEAD has no way to suppress the body from inside its handler.Expected
The transport drops the body for a HEAD request while keeping
Content-Lengthat the value the equivalent GET would report, so a modeled HEAD operation is correct on the wire.The repo already treats this as the right behavior one layer up:
HealthEndpointanswersGETorHEADand writes the body only forGET(runtime/src/server/middleware.cc:125,143). This asks for the same rule where framing is decided, so every operation gets it rather than each handler reimplementing it.Impact
Downstream, MoonBase's iili shortener wanted
HEAD /iili/v1/r/{slug}to return the 302 andLocationthat unfurlers and link checkers lead with. Modeling it would have put a 2-byte{}body on a HEAD response, so it ships a service-local middleware that re-dispatches HEAD as GET and clears the body (muchq/MoonBase#1433, muchq/MoonBase#1435). That workaround keeps the contract out of the model, where it belongs — it would be deleted once the transport handles this.Notes
A
Send("HEAD", ...)loopback test asserting an empty body withContent-Lengthintact would pin it. Worth checkingsocket_transport.ccon the same terms, since it strips the same framing header set.