Skip to content

Fix core transport: body-error replay, pure-Rust serve early-stop, and stale-pool race #362

Description

@momics

Workstream A of the PR #350 follow-up re-review. Groups the core-transport correctness blockers found against head a77e9c9. Tracked under #361.

Summary

Three defects in iroh-http-core's client/serve/pool paths let requests be silently mis-handled: (1) a request body that errors before yielding data is misclassified as a connection failure and transparently retried as an empty request reported as success; (2) the pure-Rust serve/serve_with_events path never advances the serve-cycle generation that the early-stop fix depends on, so a stop_serve() that wins the race leaves the server running; (3) the ordinary stale-connection cleanup path still has an unscoped get → invalidate window that defeats single-flight and churns connections.

Evidence

Head a77e9c9.

  • Connection statistics missing from peerStats — no bandwidth, stream count, or latency data #3 (P1) — zero-data body error retried as successful empty request. http/client.rs:124-149,177-213,343-352. body_resendable = size_hint().exact() == Some(0) (client.rs:136) is treated as "replayable", but an http_body::Body can yield an error/trailers without data. Every client-stack error maps to ConnectionFailed (client.rs:187), so an idempotent request on a warmed pooled connection whose body errors is evicted and retried with Body::empty() (client.rs:212). Reviewer repro against exact head (idempotent PUT, exact-zero body that immediately errors): failing_body_result=ok server_calls=2 — the caller lost its body error and got success for a different, empty request.
  • Incoming request URL used http:// scheme instead of httpi:// [FIXED] #2 (P1) — pure-Rust serve never advances serve_started_gen. endpoint/mod.rs:96-119, endpoint/lifecycle.rs:99-142, http/server/mod.rs:102-170. serve_started_gen is bumped only in set_local_service, which is called only from the FFI dispatcher (ffi/dispatcher.rs:381). The public Rust serve/serve_with_events path spawns the accept loop without it, so its generation stays 0. When stop_serve() wins before handle registration it records generation 0, and set_serve_handle() requires stopped_gen > 0 to shut the late handle — so the handle is left serving. Reviewer repro: serve_done_after_early_stop=false (needed a second stop_serve() to finish). Scope note: all JS/Tauri adapters go through the FFI path that does bump the generation, so this affects direct Rust-API consumers only — but it is a real correctness hole in the public core API.
  • F-007: CI workflow does not gate regressions by default (manual-only trigger) #13 (P2) — unscoped stale-pool get → invalidate. http/transport/pool.rs:120-160,276-305. The new compare_and_remove scopes explicit-failure eviction by dial_seq, but the ordinary stale-entry path still does cache.get() then an unconditional invalidate(key). Two callers observing stale S can have A invalidate S and single-flight fresh F, then delayed B invalidate F and dial G — avoidable churn that defeats single-flight and can disturb concurrency limits.

Impact

Remediation

  1. Connection statistics missing from peerStats — no bandwidth, stream count, or latency data #3: require body.is_end_stream() (not SizeHint) before treating a body as replayable; preserve user/body errors as a distinct error class from transport ConnectionFailed; a general replay mechanism should take an explicit body factory rather than infer replayability from SizeHint.
  2. Incoming request URL used http:// scheme instead of httpi:// [FIXED] #2: allocate a serve-cycle token in the common serve path and associate both local-service registration and handle registration with it, so early-stop is detected regardless of entry point. Add a deterministic early-stop test through the public Rust API, not just the FFI wrapper.
  3. F-007: CI workflow does not gate regressions by default (manual-only trigger) #13: use the existing atomic compare-and-remove with the observed dial_seq; on comparison failure consume the current fresh entry instead of dialing again. Apply the same scoping to the "closed immediately after connect" branch.

Acceptance criteria

  1. A regression test (Rust core) drives an idempotent request whose exact-zero body errors and asserts the body error is surfaced and the request is not silently replayed empty; server_calls == 1.
  2. A regression test through the public Rust serve/serve_with_events API asserts an early stop_serve() shuts the server down in one call (serve_done_after_early_stop == true).
  3. The stale-pool path uses dial_seq-scoped compare-and-remove; a concurrency test shows no double-dial when two callers observe the same stale entry.
  4. cargo test -p iroh-http-core green; cargo clippy -p iroh-http-core -- -D warnings clean.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High prioritybugSomething isn't workingconnectivityPeer discovery and connectionrustPull requests that update rust code

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions