Summary
Kemal::HeadRequestHandler swallows the body a HEAD request generates and counts the bytes so it can set Content-Length. The counter is an Int32, and Crystal checks integer overflow, so the 2 147 483 648th byte raises OverflowError. Kemal::ExceptionHandler sits below HeadRequestHandler in the chain and turns that into a 500: HEAD on a file of 2 GiB or more answers 500 where the matching GET answers 200. The threshold is exactly Int32::MAX.
Where
src/kemal/head_request_handler.cr:11 — @out_count : Int32
src/kemal/head_request_handler.cr:25 — @out_count += slice.bytesize
src/kemal/head_request_handler.cr:39 — @response.content_length = @out_count
HTTP::Server::Response#content_length already returns Int64? and content_length= takes any Int (http/server/response.cr:82-90), so the counter is the only Int32 in the path.
Reproduction
public_folder with three sparse files (truncate -s 2148000000 big.bin, -s 2147483647 atmax.bin, -s 1048576 small.bin), plus a route that writes 2048 × 1 MiB itself. Crystal 1.21.0, master 91ff784:
GET /big.bin -> 200, 2148000000 bytes delivered, 0.34s
HEAD /small.bin -> 200 OK, Content-Length: 1048576
HEAD /atmax.bin -> 200 OK, Content-Length: 2147483647
HEAD /big.bin -> 500 Internal Server Error, Content-Length: 2148000000
HEAD /dyn -> 500 Internal Server Error, Content-Length: 2146435629
One Arithmetic overflow (OverflowError) in the log per failing request. The server stays up — a control request afterwards returns 200.
No filesystem needed to hit it: 2048 writes of 1 MiB through the handler raise the same error.
The 500 carries a Content-Length
Two shapes, depending on who set the header:
send_file sets Content-Length before the copy and NullIO#close does not overwrite an existing one, so HEAD /big.bin is a 500 announcing a 2 GB body.
- A route writing its own body has no explicit length, so
NullIO#close sets it from the counter, frozen wherever the overflow interrupted it. GET /dyn delivers 2147483648 bytes; HEAD /dyn reports 2146435629, 1 048 019 short. A client sizing a follow-up Range from that gets it wrong.
Separate: the body is read in full to be discarded
Widening the counter leaves the other half of the design alone. Measured on a real 200 MiB file via /proc/self/io:
file size = 209715200 bytes
HEAD -> status=200, read from FS = 209790165 bytes (1.0x)
So HEAD on a 20 GiB file does 20 GiB of filesystem reads. The counting can't simply be dropped — spec/head_request_handler_spec.cr:25 pins that a HEAD reports the gzip-compressed length, which isn't knowable without producing the body — and skipping the copy is only safe where the length is already known. Different change, so it's out of scope here. #798 fixed the SSE-specific case of the same discard-and-count design.
Summary
Kemal::HeadRequestHandlerswallows the body aHEADrequest generates and counts the bytes so it can setContent-Length. The counter is anInt32, and Crystal checks integer overflow, so the 2 147 483 648th byte raisesOverflowError.Kemal::ExceptionHandlersits belowHeadRequestHandlerin the chain and turns that into a500:HEADon a file of 2 GiB or more answers500where the matchingGETanswers200. The threshold is exactlyInt32::MAX.Where
src/kemal/head_request_handler.cr:11—@out_count : Int32src/kemal/head_request_handler.cr:25—@out_count += slice.bytesizesrc/kemal/head_request_handler.cr:39—@response.content_length = @out_countHTTP::Server::Response#content_lengthalready returnsInt64?andcontent_length=takes anyInt(http/server/response.cr:82-90), so the counter is the onlyInt32in the path.Reproduction
public_folderwith three sparse files (truncate -s 2148000000 big.bin,-s 2147483647 atmax.bin,-s 1048576 small.bin), plus a route that writes 2048 × 1 MiB itself. Crystal 1.21.0, master91ff784:One
Arithmetic overflow (OverflowError)in the log per failing request. The server stays up — a control request afterwards returns200.No filesystem needed to hit it: 2048 writes of 1 MiB through the handler raise the same error.
The 500 carries a Content-Length
Two shapes, depending on who set the header:
send_filesetsContent-Lengthbefore the copy andNullIO#closedoes not overwrite an existing one, soHEAD /big.binis a500announcing a 2 GB body.NullIO#closesets it from the counter, frozen wherever the overflow interrupted it.GET /dyndelivers 2147483648 bytes;HEAD /dynreports2146435629, 1 048 019 short. A client sizing a follow-upRangefrom that gets it wrong.Separate: the body is read in full to be discarded
Widening the counter leaves the other half of the design alone. Measured on a real 200 MiB file via
/proc/self/io:So
HEADon a 20 GiB file does 20 GiB of filesystem reads. The counting can't simply be dropped —spec/head_request_handler_spec.cr:25pins that aHEADreports the gzip-compressed length, which isn't knowable without producing the body — and skipping the copy is only safe where the length is already known. Different change, so it's out of scope here. #798 fixed the SSE-specific case of the same discard-and-count design.