Skip to content

perf(serve): build tower stack per-connection, not per-bistream #199

Description

@momics

Summary

The accept loop rebuilds a StackConfig and runs the full build_stack() tower composition (5–6 apply_* factories each calling boxed_clone()) per-bistream rather than per-connection or per-serve-call. Since all stack parameters are fixed for the lifetime of the serve loop, this is unnecessary allocation on the hot path.

Additionally, CompressionOptions is cloned twice per bistream (stack_compression_conn = cfg.stack_compression.clone() per-connection, then req_compression = stack_compression_conn.clone() per-bistream) when it never mutates.

Evidence

crates/iroh-http-core/src/http/server/accept.rs#L202-L237 — inside the per-bistream tokio::spawn:

let req_compression = stack_compression_conn.clone(); // clone per-bistream

// ... later in the spawn:
let stack_cfg = StackConfig {
    timeout: ...,
    max_request_body_wire_bytes,
    max_request_body_decoded_bytes,
    load_shed: load_shed_enabled,
    compression: req_compression,     // moved in
    decompression: cfg_decompression,
};
super::pipeline::serve_bistream(io, svc, effective_header_limit, &stack_cfg).await;

serve_bistream then calls build_stack(svc, cfg) which runs apply_body_limit → apply_decompression → apply_compression → apply_timeout → apply_load_shed, each calling .boxed_clone().

In axum's reference shape, the full ServiceBuilder chain is composed once from the user's service before the accept loop. The per-connection task only wraps AddExtensionLayer (for RemoteNodeId) and hands the pre-built stack to serve_connection.

Impact

  • Per-bistream: 5–6 heap allocations (boxed_clone) + 1 CompressionOptions clone.
  • Under high request throughput this is measurable allocator pressure. On a single peer opening many bistreams (multiplexed HTTP/1.1 pipelining over QUIC) the cost multiplies.
  • Functionally correct — no bugs, just waste.

Remediation

  1. Build the StackConfig once at the top of the connection task (outside the loop { accept_bi } loop). All fields come from AcceptConfig which is immutable.
  2. Wrap CompressionOptions in Arc<Option<CompressionOptions>> — clone the Arc instead of the struct.
  3. Ideally, call build_stack(conn_svc, &stack_cfg) once per connection and clone the resulting ServeService (which is already BoxCloneService) per-bistream. This mirrors the axum shape where the stack is pre-built and the per-request cost is a single Arc::clone.

Step 3 requires verifying that no per-bistream state leaks into the stack (currently none does — StackConfig fields are all stateless layer toggles).

Acceptance criteria

  1. StackConfig is constructed once per connection task, not per bistream.
  2. CompressionOptions is Arc-wrapped or built once — no Clone per bistream.
  3. build_stack() is called at most once per connection (or once at serve-start if the RemoteNodeId extension can be injected differently).
  4. Existing tests pass; no behaviour change.
  5. npm run ci green.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Low priorityapiAPI design / ergonomicsenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions