Skip to content

Add spawned server contexts, split raw peers and a two-way streaming case to base-http-fixtures. - #49

Merged
samuel-williams-shopify merged 7 commits into
mainfrom
spawned-fixtures
Nov 3, 2026
Merged

samuel-williams-shopify merged 7 commits into
mainfrom
spawned-fixtures

Conversation

@samuel-williams-shopify

@samuel-williams-shopify samuel-williams-shopify commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Adds a spawned mode to base-http-fixtures. The server runs on the scheduler's workers while the test drives the client from its own thread, so cross-thread wake-up bugs and races can show up. It also adds RawPeer::split for raw peers that upload and download at the same time, and a two-way streaming case in the shared protocol suite. This follows #48.

Spawned server context

  • ServerContext::run_spawned(test) is the only new public entry point. It creates its own two-worker Native scheduler, takes ownership of the application, binds the listener, then spawns base_http_server::serve or serve_tls, unchanged, as one task through base_scheduler::Spawn.
    • The test receives the same Context<'_, NativeScheduler, H> as run, and handler() returns &H. The Arcs that the server's 'static task needs, for both the scheduler and the application, stay inside the fixture. No spawned-server handle is exposed, so there is no shutdown for a test to forget and no drop path.
    • The application must be Handler + Send + Sync + 'static, which is what a server that spawned its own connection tasks would need.
    • The fixture assumes only that the server runs on the scheduler's workers. base-http-server is not touched, and nothing chooses how connections are spread across tasks or workers.
  • It keeps the guarantees of run:
    • the listener is bound before the test starts;
    • the test is bounded;
    • the client retires its connections before the server stops;
    • the server is cancelled and must stop within the teardown timeout;
    • a server error fails the test;
    • a server panic is resumed with its own payload;
    • a test failure is reported first, with any server failure printed beforehand.
  • The join deadline runs on its own thread rather than on the scheduler's clock. A Native timer registered from outside a worker is assigned to one worker and never fires while that worker is blocked. A unit test in crates/fixtures/src/context/tests.rs blocks the only worker of a one-worker scheduler and shows that the join still fails within 50 ms.

Raw peers

RawPeer::split() returns a RawReader and a RawWriter.

  • The reader keeps the peer's BufReader. The writer is a TcpStream::try_clone of the socket, so it shares the peer's timeouts.
  • a_split_raw_peer_keeps_its_buffered_input buffers a second line before splitting and reads it through the reader.
  • a_split_raw_peer_uploads_and_downloads_at_once streams 16 MiB up and down at the same time from two threads.
  • a_sequential_raw_upload_stalls_under_backpressure declares a 1 GiB upload, writes it on one thread without reading, and requires the write timeout to hit before the upload ends. 1 GiB is far beyond any socket buffering, so the result doesn't depend on the host's buffer sizes.

Suites

  • duplex_stream streams DUPLEX_BODY (16 MiB) to a new /duplex echo route and reads the echo at the same time. It runs in protocol_tests!.
    • The upload never runs more than DUPLEX_WINDOW (four 16 KiB chunks) ahead of the echo received. The case can finish only if the client delivers the response while still sending the request, and the server echoes the request while still receiving it.
    • the_duplex_stream_stalls_against_a_buffering_server shows that a server which collects the request before replying stalls the case. The same sabotage applied to App's /duplex fails all six runner variants.
    • The sabotage suite checks that a lost chunk fails at "the echoed stream".
  • a_sequential_duplex_stream_stalls uploads a 1 GiB request through the client before reading the echo. It samples the bytes written at 250 ms and 500 ms and requires that they are equal, so a client that keeps uploading fails it.
  • duplex_chunked_stream runs the same windowed exchange through /duplex-chunked, where the server echoes the request as Reply::Stream, a body with no length that may have trailers. Over HTTP/1.1 the response is therefore chunked while the request is still being received, and the case asserts that the client's response body may have trailers and has no length. Over HTTP/1.0 it asserts a close-delimited echo. A sabotaged route that echoes with the request's length fails it over both versions at "the echo's framing". duplex_stream likewise asserts that its echo carries the request's length and no trailers.
  • No wrapper is needed for HTTP/1.0. Following Drop trailers on HTTP/1.0, and never use the Trailer field to choose framing. #50, a known-length request is sent with Content-Length and its trailers are dropped, so both cases upload a plain BodyReader over either version. Neither case streams a chunked request.
  • duplex_data copies a constant 251-byte period instead of computing each byte, because per-byte work made the parallel two-way cases exceed the 10 s test timeout under coverage instrumentation.
  • crates/client/tests/protocol.rs runs the protocol and trailer suites in spawned mode, as well as scoped, over HTTP/1.1, HTTP/1.0, where the trailer cases check that trailers are dropped, and HTTPS.

design/fixtures.md gains a "Spawned server context" requirement and extends the raw peer and behaviour suite requirements.

Limitations

  • After the join fails, run_spawned shuts its scheduler down, and Native drains every task. The call therefore doesn't return until an overrunning server's current poll returns. A poll that never returns can't be preempted and hangs the test after the failure is reported. This is stated in the run_spawned docs and in design/fixtures.md.
  • The own-thread join deadline is a temporary workaround for Native stranding timers and I/O behind a busy worker. It will be removed once that is fixed; see Rescue timers stranded behind a stuck Native worker, propose readiness rescue, and leave worker placement out of the interface contract. base-scheduler#93. The test timeout and the client teardown still use the scheduler clock, so they are bounded only while its timers can run.
  • Development-only, so there is no releases.md entry.

🤖 Generated with Claude Code

…case to `base-http-fixtures`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rect what the fixtures documentation claims about buffering and scheduler drains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and make the two-way stream and its stall tests independent of socket buffer sizes.

`ServerContext::run_spawned` now owns its scheduler and the application, and the test receives the same `Context<'_, NativeScheduler, H>` as `run`. Spawning, joining and the shared scheduler are private, so `ServerContext::spawn`, `run_spawned_on`, `SpawnedServer` and `shared_native` are gone, along with the drop paths. A unit test runs a blocking handler on a one-worker scheduler and shows that the join still fails within its timeout.

`duplex_stream` keeps its upload within `DUPLEX_WINDOW` of the echo received, so a server which buffers the request instead of streaming it stalls the case, as a new sabotage test shows. The sequential stall tests declare 1 GiB and assert that the upload stopped short. A new test shows that a split raw peer keeps its buffered input. The documentation and design now say that a poll which never returns cannot be preempted, so it hangs the scheduler's shutdown.

Co-Authored-By: Claude Opus 5.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