From c96e0d2667865430b1eec67ea5782d248b28bba6 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 8 Oct 2026 12:42:59 -0600 Subject: [PATCH 1/6] Restore trim-safe routing and server response dispatch --- Project.toml | 5 +- docs/src/guides/server.md | 26 ++++++++ src/http2_server.jl | 22 +++---- src/http_core.jl | 28 +++++++++ src/http_handlers.jl | 102 +++++++++++++++++++++++++++----- src/http_server.jl | 29 +++++++-- src/precompile.jl | 4 +- test/http_handlers_tests.jl | 31 ++++++++++ test/http_trim_router_server.jl | 49 +++++++++++++++ test/trim_compile_tests.jl | 27 +++++++++ 10 files changed, 286 insertions(+), 37 deletions(-) create mode 100644 test/http_trim_router_server.jl diff --git a/Project.toml b/Project.toml index 22be937f0..a6128b318 100644 --- a/Project.toml +++ b/Project.toml @@ -10,6 +10,7 @@ Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" EnumX = "4e289a0a-7415-4d19-859d-a7e5c4648b56" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +Preferences = "21216c6a-2e73-6563-6e65-726566657250" PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a" Reseau = "802f3686-a58f-41ce-bb0c-3c43c75bba36" SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" @@ -20,6 +21,7 @@ Zlib_jll = "83775a58-1f1d-513f-b197-d71354ab007a" [compat] CodecZlib = "0.7" EnumX = "1" +Preferences = "1" PrecompileTools = "1.2.1" Reseau = "1.4.3" URIs = "1.6.1" @@ -31,6 +33,7 @@ Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" JuliaC = "acedd4c2-ced6-4a15-accc-2607eb759ba2" Reseau = "802f3686-a58f-41ce-bb0c-3c43c75bba36" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" [targets] -test = ["Base64", "JuliaC", "Reseau", "Test"] +test = ["Base64", "JuliaC", "Reseau", "Test", "TOML"] diff --git a/docs/src/guides/server.md b/docs/src/guides/server.md index 0ebdf6e28..3d3e2229a 100644 --- a/docs/src/guides/server.md +++ b/docs/src/guides/server.md @@ -45,6 +45,32 @@ A non-yielding handler can still monopolize the interactive pool. The separate pool protects HTTP work from compute tasks assigned to `:default`; it cannot make non-yielding handler code cooperative. +## Native Builds + +For JuliaC builds with `--trim=safe`, add `Preferences` to the build project and +enable the constrained server dispatch before compiling: + +```julia +using HTTP, Preferences +Preferences.set_preferences!(HTTP, "trim_strict_bodies" => true) +``` + +This preference closes dispatch over buffered response bodies to `String`, +`SubString{String}`, `Vector{UInt8}`, `Nothing`, `HTTP.EmptyBody`, and +`HTTP.BytesBody` backed by a byte vector or `codeunits(::String)`. Other response +body types raise `ArgumentError` in this mode. Routers accept the request and +stream body types constructed by HTTP's server. Custom request bodies passed +directly to a router are unsupported in this mode. + +The built-in precompile workload is disabled in this mode because it includes +custom streaming file responses. + +The default is `false`, which retains ordinary dispatch for custom handlers and +body types. Changing the preference invalidates the relevant precompile cache. +Applications must still pass JuliaC's verifier and validate their executable on +the target Julia runtime; this preference does not guarantee that arbitrary +application code can be trimmed. + ## Request Handlers Use `HTTP.serve!` or `HTTP.serve` when your application naturally maps diff --git a/src/http2_server.jl b/src/http2_server.jl index 829d8d57d..3983362a6 100644 --- a/src/http2_server.jl +++ b/src/http2_server.jl @@ -87,16 +87,6 @@ end return ProtocolError("HTTP/2 connection is closed") end -mutable struct _H2ServerConnControl - @atomic shutdown_requested::Bool - @atomic goaway_sent::Bool - @atomic graceful_last_stream_id::UInt32 -end - -function _H2ServerConnControl() - return _H2ServerConnControl(false, false, UInt32(0)) -end - mutable struct _H2ServerBody <: AbstractBody conn::Union{TCP.Conn,TLS.Conn} write_lock::ReentrantLock @@ -1541,12 +1531,18 @@ function _handle_h2_stream!( end response_obj = response::Response try - _check_response_body_unsent(response_obj, handler_request) + let buffered_request = handler_request + _with_response_narrowed(r -> _check_response_body_unsent(r, buffered_request), response_obj) + end catch err @error "h2 server handler returned an unusable response body" exception = err response_obj = Response(500; proto_major=2, proto_minor=0, request=handler_request) end - _write_h2_response!(conn, write_lock, send_state, stream_id, handler_request, response_obj, _server_write_deadline_ns(server)) + let buffered_request = handler_request + _with_response_narrowed(response_obj) do r + _write_h2_response!(conn, write_lock, send_state, stream_id, buffered_request, r, _server_write_deadline_ns(server)) + end + end end _request_body_fully_consumed(request) || body_close!(request.body) catch err @@ -1743,7 +1739,7 @@ function _serve_h2_conn!(server::Server, tracked::_ServerConn, reader_source)::N _write_frame_h2_server_threadsafe!(write_lock, conn, WindowUpdateFrame(UInt32(0), UInt32(server.http2_settings.connection_window_size - _H2_DEFAULT_WINDOW_SIZE)), _server_write_deadline_ns(server)) end _clear_deadlines!(conn) - _set_conn_shutdown_hook!(tracked, () -> _request_h2_conn_shutdown!(conn, write_lock, conn_control)) + _set_conn_shutdown_hook!(tracked, _H2ServerShutdownHook(write_lock, conn_control)) _set_conn_state!(tracked, _ConnState.IDLE) while true _server_shutting_down(server) && return nothing diff --git a/src/http_core.jl b/src/http_core.jl index f10900a2d..579cf22c1 100644 --- a/src/http_core.jl +++ b/src/http_core.jl @@ -1,4 +1,5 @@ # Core HTTP request/response/header/body types and errors. +using Preferences: @load_preference using Reseau.TCP using Reseau.TLS using Reseau.IOPoll @@ -1974,6 +1975,33 @@ mutable struct Response{B} redirect_count::Int end +# Native builds cannot dispatch over an open set of Response{B} types after a +# handler or middleware returns an inference-widened response. This preference +# closes that boundary to buffered body types; ordinary builds retain the fallback. +const _TRIM_STRICT_BODIES = @load_preference("trim_strict_bodies", false)::Bool + +@inline function _with_response_narrowed(f::F, @nospecialize(response::Response)) where {F} + if response isa Response{String} + return f(response) + elseif response isa Response{SubString{String}} + return f(response) + elseif response isa Response{Vector{UInt8}} + return f(response) + elseif response isa Response{Nothing} + return f(response) + elseif response isa Response{EmptyBody} + return f(response) + elseif response isa Response{BytesBody{Vector{UInt8}}} + return f(response) + elseif response isa Response{BytesBody{Base.CodeUnits{UInt8, String}}} + # Response(status, ::String) stores the body as codeunits + return f(response) + else + _TRIM_STRICT_BODIES && throw(ArgumentError("unhandled response body type in strict trim mode")) + return f(response) + end +end + # Check single-use bodies before committing a response head to the wire. function _check_response_body_unsent(response::Response, request=response.request)::Nothing _body_allowed_for_status(response.status) || return nothing diff --git a/src/http_handlers.jl b/src/http_handlers.jl index 6108e55e1..9fa4c5a62 100644 --- a/src/http_handlers.jl +++ b/src/http_handlers.jl @@ -37,6 +37,10 @@ import ..peeraddr import .._server_error_status import ..@_spawn_interactive import ..@try_ignore +import ..EmptyBody +import ..H1Body +import .._H2ServerBody +import .._TRIM_STRICT_BODIES using Logging: Logging, AbstractLogger, LogLevel, with_logger, @logmsg @@ -84,6 +88,70 @@ function _route_variable_matches(pattern::Regex, segment::AbstractString) return m !== nothing && m.match == segment end +# A router stores unrelated callable types in one trie. In strict native builds, +# call through a per-callable C entrypoint instead of runtime specialization. +# Ref boxes carry arguments/results across a primitive-only C signature. +struct _HandlerCallWrapper <: Function end + +function (::_HandlerCallWrapper)(f::F, xptr::Ptr{Cvoid}, outptr::Ptr{Cvoid}) where {F} + xref = unsafe_pointer_to_objref(xptr)::Base.RefValue{Any} + out = unsafe_pointer_to_objref(outptr)::Base.RefValue{Any} + out[] = _call_handler_narrowed(f, xref[]) + return nothing +end + +function _call_handler_narrowed(f::F, @nospecialize(x)) where {F} + if x isa Request{EmptyBody} + return f(x) + elseif x isa Request{BytesBody{Vector{UInt8}}} + return f(x) + elseif x isa Request{H1Body} + return f(x) + elseif x isa Request{_H2ServerBody} + return f(x) + elseif x isa Stream{false, Request{EmptyBody}} + return f(x) + elseif x isa Stream{false, Request{BytesBody{Vector{UInt8}}}} + return f(x) + elseif x isa Stream{false, Request{H1Body}} + return f(x) + elseif x isa Stream{false, Request{_H2ServerBody}} + return f(x) + else + throw(ArgumentError("unsupported request type for a routed handler")) + end +end + +@generated function _handler_gen_fptr(::Type{F}) where F + quote + @cfunction($(_HandlerCallWrapper()), Cvoid, (Ref{$F}, Ptr{Cvoid}, Ptr{Cvoid})) + end +end + +struct _HandlerFn + ptr::Ptr{Cvoid} # @cfunction pointer (specialized per callable type F) + objptr::Ptr{Cvoid} # pointer to the callable object + _root::Any # Retain the callable storage throughout the leaf lifetime. +end + +function _HandlerFn(callable::F) where F + ptr = _handler_gen_fptr(F) + objref = Base.cconvert(Ref{F}, callable) + objptr = Ptr{Cvoid}(Base.unsafe_convert(Ref{F}, objref)) + return _HandlerFn(ptr, objptr, objref) +end + +_HandlerFn(callable::_HandlerFn) = callable + +@inline function (h::_HandlerFn)(x) + xref = Ref{Any}(x) + out = Ref{Any}(nothing) + GC.@preserve h xref out begin + ccall(h.ptr, Cvoid, (Ptr{Cvoid}, Ptr{Cvoid}, Ptr{Cvoid}), h.objptr, pointer_from_objref(xref), pointer_from_objref(out)) + end + return out[] +end + struct Leaf{H} method::String variables::Vector{Tuple{Int,String}} @@ -116,7 +184,7 @@ segment(x) = isvariable(x) ? Variable(x) : String(x) Node(x) = Node(x, Node[], Node[], nothing, nothing, Leaf[]) Node() = Node("*") -function find(y, itr; by=identity, eq=(==)) +function find(y, itr; by::B=identity, eq::E=(==)) where {B,E} for (i, x) in enumerate(itr) eq(by(x), y) && return i end @@ -138,19 +206,19 @@ function insert!(node::Node, leaf, segments, i) if segment_value isa Variable push!(leaf.variables, (i, segment_value.name)) end - if segment_value == "*" || (segment_value isa Variable && segment_value.pattern === nothing) + if (segment_value isa String && segment_value == "*") || (segment_value isa Variable && segment_value.pattern === nothing) if node.wildcard === nothing node.wildcard = Node(segment_value) end return insert!(node.wildcard::Node, leaf, segments, i + 1) - elseif segment_value == "**" + elseif segment_value isa String && segment_value == "**" if node.doublestar === nothing node.doublestar = Node(segment_value) end i < length(segments) && error("/** double wildcard must be last segment in path") return insert!(node.doublestar::Node, leaf, segments, i + 1) elseif segment_value isa Variable - j = find(segment_value.pattern, node.conditional; by=x -> x.segment.pattern) + j = find(segment_value.pattern, node.conditional; by=x -> (x.segment::Variable).pattern) if j === nothing n = Node(segment_value) push!(node.conditional, n) @@ -159,11 +227,11 @@ function insert!(node::Node, leaf, segments, i) end return insert!(n, leaf, segments, i + 1) else - j = find(segment_value, node.exact; by=x -> x.segment) + j = find(segment_value, node.exact; by=x -> (x.segment::String)) if j === nothing n = Node(segment_value) push!(node.exact, n) - sort!(node.exact; by=x -> x.segment) + sort!(node.exact; by=x -> (x.segment::String)) return insert!(n, leaf, segments, i + 1) end return insert!(node.exact[j], leaf, segments, i + 1) @@ -178,7 +246,7 @@ function match(node::Node, method, segments, i) end segment_value = segments[i] anymissing = false - j = find(segment_value, node.exact; by=x -> x.segment) + j = find(segment_value, node.exact; by=x -> (x.segment::String)) if j !== nothing m = match(node.exact[j], method, segments, i + 1) anymissing = m === missing @@ -186,7 +254,7 @@ function match(node::Node, method, segments, i) m !== nothing && return m end for conditional_node in node.conditional - if _route_variable_matches(conditional_node.segment.pattern, segment_value) + if _route_variable_matches((conditional_node.segment::Variable).pattern::Regex, segment_value) m = match(conditional_node, method, segments, i + 1) anymissing = m === missing m = coalesce(m, nothing) @@ -238,7 +306,7 @@ default405(::Request) = Response(405) default404(stream::Stream) = setstatus(stream, 404) default405(stream::Stream) = setstatus(stream, 405) -Router(_404=default404, _405=default405, middleware=nothing) = Router(_404, _405, Node(), middleware) +Router(_404::T=default404, _405::S=default405, middleware::F=nothing) where {T,S,F} = Router(_404, _405, Node(), middleware) """ register!(router, method, path, handler) -> Nothing @@ -266,19 +334,19 @@ end """ function register! end -function register!(r::Router, method, path, handler) - segments = map(segment, split(path, '/'; keepempty=false)) +function register!(r::Router, method, path, handler::F) where {F} + segments = Union{String,Variable}[segment(part) for part in split(path, '/'; keepempty=false)] if r.middleware !== nothing handler = r.middleware(handler) end - insert!(r.routes, Leaf(method, Tuple{Int,String}[], path, handler), segments, 1) + insert!(r.routes, Leaf(method, Tuple{Int,String}[], path, _TRIM_STRICT_BODIES ? _HandlerFn(handler) : handler), segments, 1) return nothing end -register!(r::Router, path, handler) = register!(r, "*", path, handler) +register!(r::Router, path, handler::F) where {F} = register!(r, "*", path, handler) -register!(handler, r::Router, method, path) = register!(r, method, path, handler) -register!(handler, r::Router, path) = register!(r, "*", path, handler) +register!(handler::F, r::Router, method, path) where {F} = register!(r, method, path, handler) +register!(handler::F, r::Router, path) where {F} = register!(r, "*", path, handler) const Params = Dict{String,String} @@ -314,7 +382,8 @@ function gethandler(r::Router, req::Request) params[v] = segments[i] end end - return leaf.handler, leaf.path, params + handler = _TRIM_STRICT_BODIES ? (leaf::Leaf{_HandlerFn}).handler : leaf.handler + return handler, leaf.path, params end return leaf, "", params end @@ -370,6 +439,7 @@ Retrieve a matched path parameter with name `name` from request context. function getparam(req, name, default=nothing) params = getparams(req) params === nothing && return default + _TRIM_STRICT_BODIES && (params = params::Params) return get(params, name, default) end diff --git a/src/http_server.jl b/src/http_server.jl index 78c81e3b1..713ec06d3 100644 --- a/src/http_server.jl +++ b/src/http_server.jl @@ -30,10 +30,27 @@ end IDENTITY = 4 end +mutable struct _H2ServerConnControl + @atomic shutdown_requested::Bool + @atomic goaway_sent::Bool + @atomic graceful_last_stream_id::UInt32 +end + +function _H2ServerConnControl() + return _H2ServerConnControl(false, false, UInt32(0)) +end + +# HTTP/2 is the only protocol with a shutdown notification. Store its concrete +# state rather than an erased closure so shutdown is callable in native builds. +struct _H2ServerShutdownHook + write_lock::ReentrantLock + control::_H2ServerConnControl +end + mutable struct _ServerConn conn::Union{TCP.Conn,TLS.Conn} lock::ReentrantLock - shutdown_hook::Union{Nothing,Function} + shutdown_hook::Union{Nothing,_H2ServerShutdownHook} @atomic state::_ConnState.T @atomic state_unix_sec::Int64 end @@ -425,7 +442,7 @@ end return nothing end -function _set_conn_shutdown_hook!(conn::_ServerConn, hook::Union{Nothing,Function})::Nothing +function _set_conn_shutdown_hook!(conn::_ServerConn, hook::Union{Nothing,_H2ServerShutdownHook})::Nothing lock(conn.lock) try conn.shutdown_hook = hook @@ -444,7 +461,7 @@ function _notify_conn_shutdown!(conn::_ServerConn)::Nothing unlock(conn.lock) end hook === nothing && return nothing - hook() + _request_h2_conn_shutdown!(conn.conn, hook.write_lock, hook.control) return nothing end @@ -1248,9 +1265,9 @@ function _serve_h1_conn!(server::Server, tracked::_ServerConn, reader_source)::N # serialized again. Answer 500 before any bytes go out instead # of failing mid-response and dropping the connection (#1333). try - _check_response_body_unsent(response_obj) + _with_response_narrowed(_check_response_body_unsent, response_obj) catch err - @error "server handler returned a response whose body was already sent or closed" exception = err + @error "server handler returned an unusable response body" exception = err _try_write_server_error!(tracked.conn, request, 500) return nothing end @@ -1261,7 +1278,7 @@ function _serve_h1_conn!(server::Server, tracked::_ServerConn, reader_source)::N end end _set_write_deadline!(server, tracked.conn) - _write_all_response!(tracked.conn, response_obj) + _with_response_narrowed(r -> _write_all_response!(tracked.conn, r), response_obj) _clear_deadlines!(server, tracked.conn) _server_shutting_down(server) && return nothing if _request_wants_close(handler_request) || _response_wants_close(response_obj) diff --git a/src/precompile.jl b/src/precompile.jl index c4ab0a67e..45b75d5fe 100644 --- a/src/precompile.jl +++ b/src/precompile.jl @@ -433,7 +433,9 @@ function _precompile_host_resolver_available()::Bool end end -if _precompile_workload_enabled() +# The built-in workload includes file responses with custom streaming bodies, +# which are outside the buffered response set enabled by strict native builds. +if _precompile_workload_enabled() && !_TRIM_STRICT_BODIES try _precompile_trace("enabled") @setup_workload begin diff --git a/test/http_handlers_tests.jl b/test/http_handlers_tests.jl index 3865dc63c..bc309f434 100644 --- a/test/http_handlers_tests.jl +++ b/test/http_handlers_tests.jl @@ -30,6 +30,37 @@ end _router_hello_request(req) = _response_with_text("hello:" * HT.getparam(req, "name")) +function _captured_router_handler() + value = Ref("captured") + handler = function (request::HT.Request) + GC.gc() + return value[] + end + return handler, WeakRef(value) +end + +@testset "HTTP router retains captured handlers and propagates failures" begin + router, captured = let + handler, captured = _captured_router_handler() + router = HT.Router() + HT.register!(router, "GET", "/captured", handler) + router, captured + end + GC.gc() + @test captured.value !== nothing + @test router(HT.Request("GET", "/captured")) == "captured" + HT.register!(router, "GET", "/throw", _ -> error("routed failure")) + @test_throws ErrorException("routed failure") router(HT.Request("GET", "/throw")) + HT.register!(router, "GET", "/throw-value", _ -> throw(:routed_failure)) + failure = try + router(HT.Request("GET", "/throw-value")) + nothing + catch err + err + end + @test failure === :routed_failure +end + function _router_echo_request(req) payload = String(_read_all_handler_bytes(req.body)) return _response_with_text("echo:" * HT.getparam(req, "name") * ":" * payload) diff --git a/test/http_trim_router_server.jl b/test/http_trim_router_server.jl new file mode 100644 index 000000000..563ef04fd --- /dev/null +++ b/test/http_trim_router_server.jl @@ -0,0 +1,49 @@ +include("trim_workload_common.jl") + +function _trim_router_middleware(handler::F) where {F} + return function (request::HT.Request) + response = handler(request) + HT.setheader(response, "X-Routed", "yes") + return response + end +end + +function run_http_trim_router_server()::Nothing + router = HT.Router(HT.Handlers.default404, HT.Handlers.default405, _trim_router_middleware) + greeting = "hello:" + HT.register!(router, "GET", "/hello/{name:[a-z]+}", request -> HT.Response(200; body=greeting * HT.getparam(request, "name"))) + HT.register!(router, "POST", "/echo/*", request -> trim_text_response(String(request.body))) + HT.register!(router, "GET", "/bytes/**", _ -> HT.Response(200; body=UInt8[0x6f, 0x6b])) + HT.register!(router, "GET", "/empty", _ -> HT.Response(204)) + GC.gc() + server = HT.serve!(router, "127.0.0.1", 0; listenany=true) + try + port = HT.port(server) + for (method, path, payload, status, expected) in ( + ("GET", "/hello/world", "", 200, "hello:world"), + ("POST", "/echo/value", "echo", 200, "echo"), + ("GET", "/bytes/a/b", "", 200, "ok"), + ("GET", "/empty", "", 204, ""), + ("GET", "/missing", "", 404, ""), + ("POST", "/empty", "", 405, ""), + ) + wire = "$method $path HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: $(ncodeunits(payload))\r\n\r\n$payload" + response = trim_raw_http_exchange(port, wire) + startswith(response, "HTTP/1.1 $status ") || error("unexpected response: $response") + endswith(response, "\r\n\r\n$expected") || error("unexpected body: $response") + status == 200 && !occursin("X-Routed: yes", response) && error("middleware did not run") + end + finally + close(server) + trim_close_http_server(server) + trim_shutdown_runtime() + end + return nothing +end + +function @main(args::Vector{String})::Cint + run_http_trim_router_server() + return 0 +end + +Base.Experimental.entrypoint(main, (Vector{String},)) diff --git a/test/trim_compile_tests.jl b/test/trim_compile_tests.jl index f89821f97..4a46c698c 100644 --- a/test/trim_compile_tests.jl +++ b/test/trim_compile_tests.jl @@ -1,6 +1,7 @@ using Test using HTTP using Reseau +using TOML const _TRIM_SUPPORTED = VERSION >= v"1.12.0-rc1" const _JULIAC_ENTRYPOINT_EXPR = "using JuliaC; if isdefined(JuliaC, :main); JuliaC.main(ARGS); else JuliaC._main_cli(ARGS); end" @@ -118,12 +119,37 @@ function _trim_run_task_backed_executables()::Bool return get(ENV, "HTTP_TRIM_RUN_TASK_EXECUTABLES", "0") == "1" end +function _strict_trim_project(project_path::String, tmpdir::String)::String + strict_project = joinpath(tmpdir, "strict-project") + mkpath(strict_project) + cp(joinpath(project_path, "Project.toml"), joinpath(strict_project, "Project.toml")) + manifest = TOML.parsefile(joinpath(project_path, "Manifest.toml")) + for entries in values(manifest["deps"]), entry in entries + haskey(entry, "path") || continue + entry["path"] = abspath(joinpath(project_path, entry["path"])) + end + open(joinpath(strict_project, "Manifest.toml"), "w") do io + TOML.print(io, manifest) + end + write(joinpath(strict_project, "LocalPreferences.toml"), "[HTTP]\ntrim_strict_bodies = true\n") + return strict_project +end + function _run_trim_case(project_path::String, script_file::String, output_name::String; run_executable = _run_trim_executable) script_path = joinpath(@__DIR__, script_file) @test isfile(script_path) println("[trim] compile START $(script_file)") mktempdir() do tmpdir cd(tmpdir) do + if script_file == "http_trim_router_server.jl" + project_path = _strict_trim_project(project_path, tmpdir) + # Exercise the same public router tests in the isolated strict + # project before compiling. Keep the parent suite's mode intact. + julia_exe = joinpath(Sys.BINDIR, Base.julia_exename()) + tests = joinpath(@__DIR__, "http_handlers_tests.jl") + expr = "using HTTP; @assert HTTP._TRIM_STRICT_BODIES; include($(repr(tests)))" + @test success(`$julia_exe --startup-file=no --project=$project_path -e $expr`) + end bundle_dir = _trim_use_bundle() ? joinpath(tmpdir, "bundle") : nothing exit_code, output = _run_trim_compile(project_path, script_path, output_name; bundle_dir = bundle_dir) totals = _parse_trim_verify_totals(output) @@ -200,6 +226,7 @@ end ("http_trim_client_h2_tcp_roundtrip.jl", "http_trim_client_h2_tcp_roundtrip"), ("http_trim_client_h2_roundtrip.jl", "http_trim_client_h2_roundtrip"), ("http_trim_client_server.jl", "http_trim_client_server"), + ("http_trim_router_server.jl", "http_trim_router_server"), ("http_trim_cookies.jl", "http_trim_cookies"), ("http_trim_open_fileserver.jl", "http_trim_open_fileserver"), ("http_trim_http2.jl", "http_trim_http2"), From 14f9c8ad61d1fe99c033ba0bdebe8ee0be731d16 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 8 Oct 2026 12:45:41 -0600 Subject: [PATCH 2/6] Include TOML in the standalone test environment --- test/Project.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Project.toml b/test/Project.toml index 2d21ad717..7c8729ac3 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -8,3 +8,4 @@ Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Reseau = "802f3686-a58f-41ce-bb0c-3c43c75bba36" Sockets = "6462fe0b-24de-5631-8697-dd941f90decc" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" From 7642b192e527bcd68b318852b27b707282c40027 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 8 Oct 2026 13:02:01 -0600 Subject: [PATCH 3/6] Collect coverage from strict-mode handler tests --- test/trim_compile_tests.jl | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/trim_compile_tests.jl b/test/trim_compile_tests.jl index 4a46c698c..226c44604 100644 --- a/test/trim_compile_tests.jl +++ b/test/trim_compile_tests.jl @@ -148,7 +148,9 @@ function _run_trim_case(project_path::String, script_file::String, output_name:: julia_exe = joinpath(Sys.BINDIR, Base.julia_exename()) tests = joinpath(@__DIR__, "http_handlers_tests.jl") expr = "using HTTP; @assert HTTP._TRIM_STRICT_BODIES; include($(repr(tests)))" - @test success(`$julia_exe --startup-file=no --project=$project_path -e $expr`) + # Coverage must include the strict mode exercised in the child. + coverage = Base.JLOptions().code_coverage == 0 ? "none" : "user" + @test success(`$julia_exe --startup-file=no --code-coverage=$coverage --project=$project_path -e $expr`) end bundle_dir = _trim_use_bundle() ? joinpath(tmpdir, "bundle") : nothing exit_code, output = _run_trim_compile(project_path, script_path, output_name; bundle_dir = bundle_dir) From 11b1f2f81261be4afca6c50af59f369f9e67de0f Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 8 Oct 2026 13:29:01 -0600 Subject: [PATCH 4/6] test: cover strict routing constraints and live stream protocols --- docs/src/guides/server.md | 11 ++++-- src/http_core.jl | 4 -- src/http_handlers.jl | 13 +------ test/http_handlers_tests.jl | 77 ++++++++++++++++++++++++++++++++++--- 4 files changed, 80 insertions(+), 25 deletions(-) diff --git a/docs/src/guides/server.md b/docs/src/guides/server.md index 3d3e2229a..bab00d795 100644 --- a/docs/src/guides/server.md +++ b/docs/src/guides/server.md @@ -56,11 +56,14 @@ Preferences.set_preferences!(HTTP, "trim_strict_bodies" => true) ``` This preference closes dispatch over buffered response bodies to `String`, -`SubString{String}`, `Vector{UInt8}`, `Nothing`, `HTTP.EmptyBody`, and -`HTTP.BytesBody` backed by a byte vector or `codeunits(::String)`. Other response +`Vector{UInt8}`, `HTTP.EmptyBody`, and `HTTP.BytesBody` backed by a byte vector or +`codeunits(::String)`. Public constructors normalize substring and `nothing` +arguments to `String` and `HTTP.EmptyBody`, respectively. Other response body types raise `ArgumentError` in this mode. Routers accept the request and -stream body types constructed by HTTP's server. Custom request bodies passed -directly to a router are unsupported in this mode. +stream types delivered by `serve!`, `streamhandler`, and `listen!`: requests +with an empty body or a buffered byte vector, and server streams with bodyless +request metadata. Custom or unbuffered request bodies passed directly to a +router are unsupported in this mode. The built-in precompile workload is disabled in this mode because it includes custom streaming file responses. diff --git a/src/http_core.jl b/src/http_core.jl index 579cf22c1..67493451a 100644 --- a/src/http_core.jl +++ b/src/http_core.jl @@ -1983,12 +1983,8 @@ const _TRIM_STRICT_BODIES = @load_preference("trim_strict_bodies", false)::Bool @inline function _with_response_narrowed(f::F, @nospecialize(response::Response)) where {F} if response isa Response{String} return f(response) - elseif response isa Response{SubString{String}} - return f(response) elseif response isa Response{Vector{UInt8}} return f(response) - elseif response isa Response{Nothing} - return f(response) elseif response isa Response{EmptyBody} return f(response) elseif response isa Response{BytesBody{Vector{UInt8}}} diff --git a/src/http_handlers.jl b/src/http_handlers.jl index 9fa4c5a62..1dc18f534 100644 --- a/src/http_handlers.jl +++ b/src/http_handlers.jl @@ -38,8 +38,6 @@ import .._server_error_status import ..@_spawn_interactive import ..@try_ignore import ..EmptyBody -import ..H1Body -import .._H2ServerBody import .._TRIM_STRICT_BODIES using Logging: Logging, AbstractLogger, LogLevel, with_logger, @logmsg @@ -105,17 +103,8 @@ function _call_handler_narrowed(f::F, @nospecialize(x)) where {F} return f(x) elseif x isa Request{BytesBody{Vector{UInt8}}} return f(x) - elseif x isa Request{H1Body} - return f(x) - elseif x isa Request{_H2ServerBody} - return f(x) elseif x isa Stream{false, Request{EmptyBody}} - return f(x) - elseif x isa Stream{false, Request{BytesBody{Vector{UInt8}}}} - return f(x) - elseif x isa Stream{false, Request{H1Body}} - return f(x) - elseif x isa Stream{false, Request{_H2ServerBody}} + # Server streams keep live bytes separately from their bodyless metadata. return f(x) else throw(ArgumentError("unsupported request type for a routed handler")) diff --git a/test/http_handlers_tests.jl b/test/http_handlers_tests.jl index bc309f434..f0f45b3a1 100644 --- a/test/http_handlers_tests.jl +++ b/test/http_handlers_tests.jl @@ -75,6 +75,71 @@ function _router_stream_request(stream) return nothing end +@testset "HTTP router custom request body support" begin + router = HT.Router() + HT.register!(router, "POST", "/echo/{name}", _router_echo_request) + request = HT.Request("POST", "/echo/raw"; body = "ping") + if HT._TRIM_STRICT_BODIES + @test_throws ArgumentError("unsupported request type for a routed handler") router(request) + else + @test String(_read_all_handler_bytes(router(request).body)) == "echo:raw:ping" + end +end + +@testset "HTTP router unbuffered request body support" begin + router = HT.Router() + HT.register!(router, "POST", "/echo/{name}", _router_echo_request) + # A stream handler can forward its live request body to a request router + # without the buffering performed by serve! or streamhandler. + server = HT.listen!("127.0.0.1", 0; listenany = true) do stream + metadata = HT.startread(stream) + request = HT.Request(metadata.method, metadata.target; + body = stream.request_body, context = HT.get_request_context(metadata)) + response = router(request) + HT.setstatus(stream, response.status) + write(stream, _read_all_handler_bytes(response.body)) + return nothing + end + client = HT.Client() + try + for protocol in (:h1, :h2) + response = HT.post(client, "http://$(HT.server_addr(server))/echo/raw"; + body = "ping", protocol = protocol, status_exception = false, retry = false) + if HT._TRIM_STRICT_BODIES + @test response.status == 500 + else + @test response.status == 200 + @test String(_read_all_handler_bytes(response.body)) == "echo:raw:ping" + end + end + finally + close(client) + HT.forceclose(server) + wait(server) + end +end + +@testset "HTTP server custom response body support" begin + server = HT.serve!("127.0.0.1", 0; listenany = true) do request + data = IOBuffer("callback") + body = HT.CallbackBody(buf -> readbytes!(data, buf), () -> nothing) + return HT.Response(200, body; content_length = 8) + end + try + response = HT.get("http://$(HT.server_addr(server))/"; + status_exception = false, retry = false) + if HT._TRIM_STRICT_BODIES + @test response.status == 500 + else + @test response.status == 200 + @test String(_read_all_handler_bytes(response.body)) == "callback" + end + finally + HT.forceclose(server) + wait(server) + end +end + @testset "HTTP handlers router direct matching" begin called = Ref(false) middle = handler -> req -> begin @@ -569,12 +634,14 @@ end server = HT.listen!(router, "127.0.0.1", 0; listenany = true) address = HT.server_addr(server) try - resp = HT.post("http://$(address)/stream/sam"; body = "pong") - @test resp.status == 200 - @test String(_read_all_handler_bytes(resp.body)) == "stream:sam:pong" + for protocol in (:h1, :h2) + resp = HT.post("http://$(address)/stream/sam"; body = "pong", protocol = protocol) + @test resp.status == 200 + @test String(_read_all_handler_bytes(resp.body)) == "stream:sam:pong" - wrong_method = HT.get("http://$(address)/stream/sam"; status_exception = false) - @test wrong_method.status == 405 + wrong_method = HT.get("http://$(address)/stream/sam"; status_exception = false, protocol = protocol) + @test wrong_method.status == 405 + end finally HT.forceclose(server) wait(server) From 67d73d66bfcb1481df67fdc77b52c8f880efa7e1 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 8 Oct 2026 23:06:09 -0600 Subject: [PATCH 5/6] Accept servecontent range bodies in strict trim mode `servecontent` over a byte vector answers a Range request with a `BytesBody` over a contiguous view. Strict mode rejected that body type, so a natively built server answered byte-range requests (video seeking) with a 500. Add it to the strict body set and cover it in the native router workload. Default builds now skip the narrowing entirely and dispatch exactly as before. Build the handler C entrypoint with a plain `@cfunction` over the callable's type instead of a generated function and wrapper struct, and shorten the native-build guide. Co-Authored-By: Claude Opus 5.5 --- docs/src/guides/server.md | 36 +++++++++++++++------------------ src/http_core.jl | 11 +++++----- src/http_handlers.jl | 14 ++----------- test/http_trim_router_server.jl | 20 +++++++++--------- 4 files changed, 35 insertions(+), 46 deletions(-) diff --git a/docs/src/guides/server.md b/docs/src/guides/server.md index bab00d795..fff4d5ac9 100644 --- a/docs/src/guides/server.md +++ b/docs/src/guides/server.md @@ -47,32 +47,28 @@ make non-yielding handler code cooperative. ## Native Builds -For JuliaC builds with `--trim=safe`, add `Preferences` to the build project and -enable the constrained server dispatch before compiling: +For JuliaC `--trim=safe` builds of a server, enable the `trim_strict_bodies` +preference before compiling: ```julia using HTTP, Preferences Preferences.set_preferences!(HTTP, "trim_strict_bodies" => true) ``` -This preference closes dispatch over buffered response bodies to `String`, -`Vector{UInt8}`, `HTTP.EmptyBody`, and `HTTP.BytesBody` backed by a byte vector or -`codeunits(::String)`. Public constructors normalize substring and `nothing` -arguments to `String` and `HTTP.EmptyBody`, respectively. Other response -body types raise `ArgumentError` in this mode. Routers accept the request and -stream types delivered by `serve!`, `streamhandler`, and `listen!`: requests -with an empty body or a buffered byte vector, and server streams with bodyless -request metadata. Custom or unbuffered request bodies passed directly to a -router are unsupported in this mode. - -The built-in precompile workload is disabled in this mode because it includes -custom streaming file responses. - -The default is `false`, which retains ordinary dispatch for custom handlers and -body types. Changing the preference invalidates the relevant precompile cache. -Applications must still pass JuliaC's verifier and validate their executable on -the target Julia runtime; this preference does not guarantee that arbitrary -application code can be trimmed. +With it enabled: + +- Handler responses must have a `String`, `Vector{UInt8}`, or `HTTP.EmptyBody` + body, or an `HTTP.BytesBody` over a byte vector, `codeunits(::String)`, or a + contiguous view of a byte vector (what `servecontent` returns for a range of a + byte vector). Constructors convert substring and `nothing` bodies to `String` + and `HTTP.EmptyBody`. The server answers other body types, including + `servecontent` over an `IO`, with a 500. +- `Router` handlers accept the requests and streams that `serve!`, + `streamhandler`, and `listen!` deliver. Calling a router directly with another + request body type throws `ArgumentError`. +- HTTP's precompile workload is skipped. + +The default, `false`, places no limits on handler or body types. ## Request Handlers diff --git a/src/http_core.jl b/src/http_core.jl index 67493451a..16c2ae199 100644 --- a/src/http_core.jl +++ b/src/http_core.jl @@ -1977,10 +1977,11 @@ end # Native builds cannot dispatch over an open set of Response{B} types after a # handler or middleware returns an inference-widened response. This preference -# closes that boundary to buffered body types; ordinary builds retain the fallback. +# closes that boundary to buffered body types; ordinary builds dispatch normally. const _TRIM_STRICT_BODIES = @load_preference("trim_strict_bodies", false)::Bool @inline function _with_response_narrowed(f::F, @nospecialize(response::Response)) where {F} + _TRIM_STRICT_BODIES || return f(response) if response isa Response{String} return f(response) elseif response isa Response{Vector{UInt8}} @@ -1989,13 +1990,13 @@ const _TRIM_STRICT_BODIES = @load_preference("trim_strict_bodies", false)::Bool return f(response) elseif response isa Response{BytesBody{Vector{UInt8}}} return f(response) - elseif response isa Response{BytesBody{Base.CodeUnits{UInt8, String}}} - # Response(status, ::String) stores the body as codeunits + elseif response isa Response{BytesBody{Base.CodeUnits{UInt8,String}}} return f(response) - else - _TRIM_STRICT_BODIES && throw(ArgumentError("unhandled response body type in strict trim mode")) + elseif response isa Response{BytesBody{SubArray{UInt8,1,Vector{UInt8},Tuple{UnitRange{Int}},true}}} + # `servecontent` range responses over a byte vector return f(response) end + throw(ArgumentError("unsupported response body type with trim_strict_bodies enabled")) end # Check single-use bodies before committing a response head to the wire. diff --git a/src/http_handlers.jl b/src/http_handlers.jl index 1dc18f534..d37450985 100644 --- a/src/http_handlers.jl +++ b/src/http_handlers.jl @@ -89,9 +89,7 @@ end # A router stores unrelated callable types in one trie. In strict native builds, # call through a per-callable C entrypoint instead of runtime specialization. # Ref boxes carry arguments/results across a primitive-only C signature. -struct _HandlerCallWrapper <: Function end - -function (::_HandlerCallWrapper)(f::F, xptr::Ptr{Cvoid}, outptr::Ptr{Cvoid}) where {F} +function _handler_entry(f::F, xptr::Ptr{Cvoid}, outptr::Ptr{Cvoid}) where {F} xref = unsafe_pointer_to_objref(xptr)::Base.RefValue{Any} out = unsafe_pointer_to_objref(outptr)::Base.RefValue{Any} out[] = _call_handler_narrowed(f, xref[]) @@ -111,12 +109,6 @@ function _call_handler_narrowed(f::F, @nospecialize(x)) where {F} end end -@generated function _handler_gen_fptr(::Type{F}) where F - quote - @cfunction($(_HandlerCallWrapper()), Cvoid, (Ref{$F}, Ptr{Cvoid}, Ptr{Cvoid})) - end -end - struct _HandlerFn ptr::Ptr{Cvoid} # @cfunction pointer (specialized per callable type F) objptr::Ptr{Cvoid} # pointer to the callable object @@ -124,14 +116,12 @@ struct _HandlerFn end function _HandlerFn(callable::F) where F - ptr = _handler_gen_fptr(F) + ptr = @cfunction(_handler_entry, Cvoid, (Ref{F}, Ptr{Cvoid}, Ptr{Cvoid})) objref = Base.cconvert(Ref{F}, callable) objptr = Ptr{Cvoid}(Base.unsafe_convert(Ref{F}, objref)) return _HandlerFn(ptr, objptr, objref) end -_HandlerFn(callable::_HandlerFn) = callable - @inline function (h::_HandlerFn)(x) xref = Ref{Any}(x) out = Ref{Any}(nothing) diff --git a/test/http_trim_router_server.jl b/test/http_trim_router_server.jl index 563ef04fd..8052259a5 100644 --- a/test/http_trim_router_server.jl +++ b/test/http_trim_router_server.jl @@ -15,23 +15,25 @@ function run_http_trim_router_server()::Nothing HT.register!(router, "POST", "/echo/*", request -> trim_text_response(String(request.body))) HT.register!(router, "GET", "/bytes/**", _ -> HT.Response(200; body=UInt8[0x6f, 0x6b])) HT.register!(router, "GET", "/empty", _ -> HT.Response(204)) + HT.register!(router, "GET", "/range", request -> HT.servecontent(request, collect(codeunits("range")); content_type = "text/plain")) GC.gc() server = HT.serve!(router, "127.0.0.1", 0; listenany=true) try port = HT.port(server) - for (method, path, payload, status, expected) in ( - ("GET", "/hello/world", "", 200, "hello:world"), - ("POST", "/echo/value", "echo", 200, "echo"), - ("GET", "/bytes/a/b", "", 200, "ok"), - ("GET", "/empty", "", 204, ""), - ("GET", "/missing", "", 404, ""), - ("POST", "/empty", "", 405, ""), + for (method, path, extra, payload, status, expected) in ( + ("GET", "/hello/world", "", "", 200, "hello:world"), + ("POST", "/echo/value", "", "echo", 200, "echo"), + ("GET", "/bytes/a/b", "", "", 200, "ok"), + ("GET", "/empty", "", "", 204, ""), + ("GET", "/range", "Range: bytes=1-3\r\n", "", 206, "ang"), + ("GET", "/missing", "", "", 404, ""), + ("POST", "/empty", "", "", 405, ""), ) - wire = "$method $path HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: $(ncodeunits(payload))\r\n\r\n$payload" + wire = "$method $path HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n$(extra)Content-Length: $(ncodeunits(payload))\r\n\r\n$payload" response = trim_raw_http_exchange(port, wire) startswith(response, "HTTP/1.1 $status ") || error("unexpected response: $response") endswith(response, "\r\n\r\n$expected") || error("unexpected body: $response") - status == 200 && !occursin("X-Routed: yes", response) && error("middleware did not run") + status in (200, 206) && !occursin("X-Routed: yes", response) && error("middleware did not run") end finally close(server) From 5db12f116ac312810a65f26e90fc4f3b48ba193c Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 8 Oct 2026 23:36:11 -0600 Subject: [PATCH 6/6] Narrow response bodies in default builds too Keep one narrowing path for both modes so the whole suite exercises the strict body set; strict mode only adds the final throw. Co-Authored-By: Claude Opus 5.5 --- src/http_core.jl | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/http_core.jl b/src/http_core.jl index 16c2ae199..d1797b2ad 100644 --- a/src/http_core.jl +++ b/src/http_core.jl @@ -1977,11 +1977,11 @@ end # Native builds cannot dispatch over an open set of Response{B} types after a # handler or middleware returns an inference-widened response. This preference -# closes that boundary to buffered body types; ordinary builds dispatch normally. +# closes that boundary to buffered body types; ordinary builds fall back to +# dynamic dispatch for other body types. const _TRIM_STRICT_BODIES = @load_preference("trim_strict_bodies", false)::Bool @inline function _with_response_narrowed(f::F, @nospecialize(response::Response)) where {F} - _TRIM_STRICT_BODIES || return f(response) if response isa Response{String} return f(response) elseif response isa Response{Vector{UInt8}} @@ -1996,7 +1996,8 @@ const _TRIM_STRICT_BODIES = @load_preference("trim_strict_bodies", false)::Bool # `servecontent` range responses over a byte vector return f(response) end - throw(ArgumentError("unsupported response body type with trim_strict_bodies enabled")) + _TRIM_STRICT_BODIES && throw(ArgumentError("unsupported response body type with trim_strict_bodies enabled")) + return f(response) end # Check single-use bodies before committing a response head to the wire.