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..fff4d5ac9 100644 --- a/docs/src/guides/server.md +++ b/docs/src/guides/server.md @@ -45,6 +45,31 @@ 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 `--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) +``` + +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 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..d1797b2ad 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,31 @@ 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 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} + if response isa Response{String} + return f(response) + elseif response isa Response{Vector{UInt8}} + 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}}} + return f(response) + 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 + _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. 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..d37450985 100644 --- a/src/http_handlers.jl +++ b/src/http_handlers.jl @@ -37,6 +37,8 @@ import ..peeraddr import .._server_error_status import ..@_spawn_interactive import ..@try_ignore +import ..EmptyBody +import .._TRIM_STRICT_BODIES using Logging: Logging, AbstractLogger, LogLevel, with_logger, @logmsg @@ -84,6 +86,51 @@ 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. +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[]) + 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 Stream{false, Request{EmptyBody}} + # Server streams keep live bytes separately from their bodyless metadata. + return f(x) + else + throw(ArgumentError("unsupported request type for a routed handler")) + 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 = @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 + +@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 +163,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 +185,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 +206,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 +225,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 +233,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 +285,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 +313,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 +361,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 +418,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/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" diff --git a/test/http_handlers_tests.jl b/test/http_handlers_tests.jl index 3865dc63c..f0f45b3a1 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) @@ -44,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 @@ -538,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) diff --git a/test/http_trim_router_server.jl b/test/http_trim_router_server.jl new file mode 100644 index 000000000..8052259a5 --- /dev/null +++ b/test/http_trim_router_server.jl @@ -0,0 +1,51 @@ +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)) + 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, 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\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 in (200, 206) && !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..226c44604 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,39 @@ 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)))" + # 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) totals = _parse_trim_verify_totals(output) @@ -200,6 +228,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"),