Skip to content

fix: trim-compatibility for Router registration, the precompile workload, and one error path#1330

Open
quinnj wants to merge 14 commits into
masterfrom
trim-verifier-fixes
Open

fix: trim-compatibility for Router registration, the precompile workload, and one error path#1330
quinnj wants to merge 14 commits into
masterfrom
trim-verifier-fixes

Conversation

@quinnj

@quinnj quinnj commented Jul 11, 2026

Copy link
Copy Markdown
Member

Summary

  • specialize router registration on concrete handler and middleware types
  • preserve concrete route segment storage
  • allow static builds to disable HTTP's runtime precompile workload
  • keep HTTP/2 proxy errors trim-resolvable
  • narrow client request copies and server response bodies at trim-sensitive boundaries
  • add focused client and server trim-compilation coverage
  • flush live SSE events before producer close

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=safe could 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=0 provides an explicit build-time opt-out.

Julia 1.13 RC1 does not yet preserve jl_new_task starter reachability during safe trimming. Task-backed fixtures therefore remain strict on Julia 1.12 and on patched Julia 1.13+ runtimes that set HTTP_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

  • the complete local HTTP.jl test suite passes, including all 78 trim tests
  • focused HTTP/1 and HTTP/2 SSE tests prove the first event arrives before the producer closes
  • HTTP client, handler, public API, HTTP/2, precompile-workload, and documentation tests pass locally
  • task-backed and strict-body trim fixtures pass locally on Julia 1.12.6
  • the compiler-limit gate behaves as expected on Julia 1.13 RC1
  • the exact PR head passes the full upstream Linux, macOS, Windows, Julia stable, and Julia prerelease matrix
  • Codecov project and patch checks pass
  • LeagueEasy's 30-plus-package server graph compiles with a patched Julia nightly and juliac --trim=safe with zero verifier errors attributable to HTTP
  • the resulting trimmed LeagueEasy binary is deployed and serving production traffic

Co-authored by Codex

… 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

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.14286% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.27%. Comparing base (25388ae) to head (550854c).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
src/http2_server.jl 73.91% 12 Missing ⚠️
src/http_handlers.jl 89.79% 5 Missing ⚠️
src/http_core.jl 95.55% 2 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

quinnj added 5 commits July 11, 2026 14:12
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.
@quinnj

quinnj commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

Both review points addressed in 26c9b73:

Consolidated narrowing helpers — the per-site isa chains are gone. Two @inline helpers in http_core are now the single source of truth for the covered type set:

  • _with_body_narrowed(f, body) — the body shapes (nothing/String/SubString{String}/Vector{UInt8}/EmptyBody/BytesBody{Vector{UInt8}}/AbstractBody residual/fallback). Each branch re-narrows so f's call is statically dispatched; sites pass a closure (or a multi-method function like the new _write_h2_body_shape! shapes) with their site-specific logic. Used by _response_has_body, the h1 chunked/exact writes, h2 emptiness + byte normalization + streaming writer, and _body_close_any!.
  • _with_response_narrowed(f, response) — the Response{B} equivalent, shared by both boundary dispatch shims.

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):

  • http_trim_server_response_shapes.jl: serves every supported body shape and verifies each over a raw exchange — this exercises the widened-response shims, both narrowing helpers, and the h1 write chains end-to-end.
  • http_trim_server_router_registration.jl: Router construction + every register! form (middleware wrap, wildcard/param paths, do-block form). Serving through the router is deliberately not covered yet — that's the handler-table dispatch frontier your router-dispatch design will address; the workload comment marks where the serving case should go once it lands.

Validation: both new workloads pass locally (HTTP_TRIM_ONLY=… runs them in ~10s each thanks to juliac caching), h1+h2 server testsets green, and the downstream full-server graph's verifier count is unchanged by the refactor.

🤖 Generated with Claude Code

quinnj and others added 8 commits July 13, 2026 08:40
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant