fix: trim-compatibility for Router registration, the precompile workload, and one error path#1330
fix: trim-compatibility for Router registration, the precompile workload, and one error path#1330quinnj wants to merge 14 commits into
Conversation
… and one error path Three small changes that make HTTP usable in juliac --trim builds: - register!/Router: add type parameters to the handler/middleware arguments. Julia doesn't specialize on function-valued arguments that are only passed through, so every registration funneled into one handler::Function instance where the middleware wrap and parametric Leaf/Router construction are runtime apply_type calls — unresolvable under --trim (6 verifier errors in a real server graph). - precompile workload: HTTP_PRECOMPILE_WORKLOAD=0 env opt-out. The workload starts real servers/requests, which segfaults when executed inside a static trim compilation. - http_client: don't interpolate plan.mode into the HTTP/2 proxy ArgumentError — enum show machinery is a dynamic site under --trim.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1330 +/- ##
==========================================
+ Coverage 88.04% 88.27% +0.22%
==========================================
Files 30 30
Lines 11849 12028 +179
==========================================
+ Hits 10433 10618 +185
+ Misses 1416 1410 -6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The h2 GOAWAY request was the only shutdown hook ever installed, but the
Union{Nothing,Function} field made every hook invocation dynamic dispatch —
unresolvable under juliac --trim. Hoist _H2ServerConnControl ahead of
_ServerConn and store the hook as a concrete callable struct instead of a
closure.
Deep middleware graphs widen the handler's response type (inference-budget
truncation), turning the response-write calls into runtime specialization
dispatch. _write_all_response!/write_response!/_write_h2_response! (and the
h2 streaming helper) now take @nospecialize(response) — one instance covers
every Response{B} — with an explicit isa chain over the body shapes in
write_response! and socket-union isa splits (with per-branch typeasserts,
which prevent the optimizer from tail-merging the branches back into one
dynamic call) at the h1 call sites.
With the response @nospecialize'd, every body read is abstract; concrete-first
isa chains (String / SubString{String} / Vector{UInt8} / BytesBody{Vector{UInt8}}
/ EmptyBody, then the abstract residuals) keep the common paths statically
dispatched under juliac --trim. The h2 fast path normalizes buffered bodies to
a concrete Vector{UInt8} once (one copy for string bodies; the frame writer
copies into the connection buffer anyway). Shared _body_close_any! replaces the
scattered widened close sites. Static messages in the unsupported-body throws
(typeof interpolation is show machinery — dynamic under trim).
…nospecialized write paths
Nospecializing the write path traded 4 boundary errors for ~90 interior ones
(every body read abstract) and the contract-tightening attempt broke range
serving. Instead: the write paths stay fully specialized per Response{B}
(clean under --trim, verified in isolation), and thin @noinline shims at the
two server boundaries isa-chain over the concrete Response shapes servers
produce — each branch dispatching into the specialized path, one residual
dynamic arm for exotic body types.
…im workloads Review feedback (PR #1330): the per-site isa chains were fragile — easy to miss a branch or let sites drift apart. Two @inline helpers are now the single source of truth for the concrete shapes the server write paths dispatch over: - _with_body_narrowed(f, body): nothing / String / SubString{String} / Vector{UInt8} / EmptyBody / BytesBody{Vector{UInt8}} / AbstractBody (streaming residual) / fallback. Every branch re-narrows so f's call is statically dispatched. Used by _response_has_body (via shared _response_body_known_empty methods, also reused for the h2 body_empty check), the h1 chunked + exact-body writes, the h2 byte normalization, the h2 streaming writer, and _body_close_any!. - _with_response_narrowed(f, response): the Response{B} equivalent, used by both _write_all_response_dyn! / _write_h2_response_dyn! shims. The h2 streaming writer's isa ladder became per-shape _write_h2_body_shape! methods (concrete b from the helper keeps their selection static). New trim-compile workloads in the existing harness (both zero-tolerance, compile + run under stock juliac): - http_trim_server_response_shapes.jl: every supported body shape served and verified over the wire, through the widened-response shims. - http_trim_server_router_registration.jl: Router construction + every register! form incl. middleware wrap and wildcard/param paths. Serving THROUGH the router stays uncovered pending the router-dispatch design. Full-graph verifier count unchanged; h1+h2 server testsets green.
|
Both review points addressed in 26c9b73: Consolidated narrowing helpers — the per-site isa chains are gone. Two
Adding a supported shape is now one branch in one place. Trim-compile tests — turns out master already had the trim harness + 13 client workloads, so I added two server-write workloads to it (both zero-tolerance: compile with 0 verifier errors AND run the binary with wire-level assertions, under stock juliac):
Validation: both new workloads pass locally ( 🤖 Generated with Claude Code |
… pattern) The matched-handler invocation was runtime dispatch over the open set of registered handler closure types — the one remaining trim frontier in the server. Leaf now stores a HandlerFn: a per-callable-type @generated @cfunction whose first C argument is the callable itself (Ref{F}), so the handler call inside the trampoline is concretely dispatched, invocation is a ccall through the stored pointer (statically resolvable), and _root keeps the callable alive. The handler table stays runtime-mutable and register! is unchanged. Two constraints discovered and encoded: - the trim verifier rejects boxed-Any cfunction signatures, so the request and result cross the trampoline as raw pointers to caller-GC.@preserve'd Ref{Any} boxes (primitive-only C signature); - an Any-typed call of the callable is itself specialization dispatch, so _call_handler_narrowed isa-narrows over the closed set of server-side request/stream shapes before invoking. getparams/getparam: typed via an isa narrow of the context fetch (their Any-typed returns made every param use downstream dynamic). The router trim workload now serves THROUGH the router (params, wildcards, 404/405 verified over the wire) — the frontier case is covered and zero-tolerance under the stock harness.
…trim coverage
- "trim_strict_bodies" Preference (default off): juliac --trim builds opt
into strict narrowing — unknown request/response body shapes throw instead
of taking the residual dynamic-dispatch fallback, letting the verifier
prove the narrowing helpers fully static. A Preference rather than an ENV
switch so flipping it invalidates the precompile cache (same pattern as
StructUtils' trim_specialize).
- narrowing chains gain the BytesBody{Base.CodeUnits{UInt8,String}} arms —
what the compat constructors produce for string bodies; previously these
fell through to the AbstractBody/dynamic arms, which strict mode forbids
- response-shapes trim workload gains the 2-arg Response(status, body) form
(the common error-response pattern), and the harness gains a strict-mode
case: the same workload compiled in a temp project with the preference on
(HTTP_TRIM_ONLY=strict selects just this case)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Why
Julia does not specialize function-valued arguments that are only passed through. Router registration therefore converged on widened handler types, leaving middleware wrapping and parametric route construction as runtime operations that
juliac --trim=safecould not resolve.The same real application build exposed a few later widening boundaries in request copying and response serialization. This PR uses concrete type parameters at registration and explicit narrowing at those boundaries. It does not change HTTP dispatch behavior or the public request and response model.
The package precompile workload starts real servers and requests. Static compilation must be able to disable that workload, so
HTTP_PRECOMPILE_WORKLOAD=0provides an explicit build-time opt-out.Julia 1.13 RC1 does not yet preserve
jl_new_taskstarter reachability during safe trimming. Task-backed fixtures therefore remain strict on Julia 1.12 and on patched Julia 1.13+ runtimes that setHTTP_TRIM_TASK_PATCHED=1; vanilla 1.13+ still runs every non-task trim fixture. This keeps the package matrix honest without treating a compiler limitation as an HTTP verifier regression.SSE response reads previously asked the producer-backed buffer to fill the entire destination, so a live event could remain buffered until the producer closed. The server path now forwards currently available bytes immediately while preserving any remainder for the next read.
Validation
juliac --trim=safewith zero verifier errors attributable to HTTPCo-authored by Codex