Skip to content

Lingering close for HTTP and WebSocket servers; half-close API (#21) - #23

Merged
proggeramlug merged 4 commits into
mainfrom
lane/linger-close
Sep 15, 2026
Merged

proggeramlug merged 4 commits into
mainfrom
lane/linger-close

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Fixes #21. Report: docs/lanes/linger-close.md.

Root cause

Servers closed the socket while the peer still had unread bytes, which sends RST instead of FIN.

API

  • turnloop AsyncIo::poll_shutdown: flushes, then shuts down the write side without closing the handle; reads continue until EOF.
    • TCP half-closes on every backend (SHUT_WR, SD_SEND, WASI 0.2 shutdown(send), WASI 0.3 closing the send stream).
    • Unsupported: UDP, Windows named pipes and sync handles, Unix ttys, FIFOs and files.
  • turnloop-io:
    • HalfClose trait for AsyncIo, TlsStream and Transport, plus a shutdown() helper.
    • linger_close(stream, scratch, deadline): half-close, discard until EOF or the deadline, close. It reports Lingered { end, reads, discarded }.
  • turnloop-tls: shutdown sends close_notify, then half-closes TCP. Writes after close_notify are refused. This also fixes a bug where a rejected record blocked later reads.
  • turnloop-http: server::Options { linger_timeout } (default 5 s, like nginx), Server::bind_with, Shutdown::with_options and Shutdown::stop_by(deadline).

Behaviour

  • HTTP/1: lingers after a connection-ending response, and when shutdown reaches an idle keep-alive connection.
  • HTTP/2: lingers once drained, after GOAWAY is flushed.
  • WebSocket: close lingers within its existing close deadline.
  • Discard: reuses the connection's input buffer; no per-read allocation and no spin.

Tests (each fails with the old close)

  • Deterministic reset: the peer sends a pipelined HTTP/1 request, or HTTP/2 SETTINGS ACK/WINDOW_UPDATE/PING after the drain, and reads only after the server closes.
    • Plain TCP: reset before EOF.
    • TLS: Broken pipe on the client's close_notify.
  • Idle keep-alive shutdown: the old code hangs.
  • WebSocket server close: ConnectionReset before.
  • No-spin: linger timeout and stop_by tests catch an injected self-wake.
  • Allocation gates: turnloop-io and the new turnloop-http/tests/server_allocations.rs both catch an injected allocation.
  • Existing interop: Node and curl tests are unchanged and pass.

Verification

Local: macOS native (266 tests default, 333 all-features), all 9 interop suites in each mode, WASI 0.2 runtime, strict clippy for native, Linux, Windows, wasip2 and web, no-tokio and soak. Linux, Windows, WASI 0.3, web and h2spec run in this PR's CI.

…urnloop-io linger_close

AsyncIo gains poll_shutdown (flush, then the backend's stream shutdown without
closing the handle), executor() and is_write_shut(). Writes after a half-close fail.
Unix rejects shutdown on non-socket descriptors with Unsupported; IOCP already
did for pipes and synchronous handles.

turnloop-io adds the HalfClose trait (AsyncIo, TlsStream, Transport), a shutdown
helper next to the unchanged close, and linger_close: half-close, read and discard
into caller scratch until peer EOF or a deadline, then close. It waits on one read
and one executor timer, allocates nothing per read, and bounds the half-close by
the same deadline. TlsStream refuses writes after close_notify.

Tests: half-close semantics, EOF-driven and deadline-driven lingering (no-spin
turn bound), a stalled half-close, UDP Unsupported, TLS half-close, and a real-TCP
allocation gate for the discard path (issue #21).
server::http1 and server::http2 no longer close a drained connection while peer
bytes are unread, which sent RST and on macOS and Windows could discard the
peer's unread final response (Node HTTP/2 client: read ECONNRESET). After the
final response and GOAWAY are flushed they half-close, discard peer input until
EOF or the linger deadline, then close. Idle HTTP/1 keep-alive connections at
shutdown linger too. server::Options::linger_timeout (default 5 s) configures the
bound; Shutdown::stop_by(deadline) closes lingering connections by an overall
shutdown deadline, re-arming ones already lingering. WebSocketStream::close lingers
under its close deadline.

Deterministic tests hold the peer's reads until the server has closed and make
its late bytes (a pipelined request; SETTINGS ACK, WINDOW_UPDATE, PING) unread at
that moment, over TCP and TLS; with the old close they fail with ECONNRESET or
EPIPE every run. Plus linger timeout and stop_by no-spin tests, an idle keep-alive
shutdown test, a WebSocket server close test and an HTTP server allocation gate for
the discard phase.
The settle turns before the no-spin measurement must not overrun the linger
deadline on a slow runner. docs/lanes/linger-close.md records the root cause,
API, per-platform behaviour, the no-fix and mutation evidence and every
verification command.
@coderabbitai

coderabbitai Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 32 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: d2c54b00-4682-434b-b523-f82240b44701

📥 Commits

Reviewing files that changed from the base of the PR and between f6fc128 and c78c806.

📒 Files selected for processing (20)
  • crates/turnloop-io/README.md
  • crates/turnloop-io/src/lib.rs
  • crates/turnloop-io/tests/allocations.rs
  • crates/turnloop-io/tests/streams.rs
  • crates/turnloop/README.md
  • crates/turnloop/src/backend/unix.rs
  • crates/turnloop/src/driver.rs
  • crates/turnloop/src/executor.rs
  • docs/lanes/linger-close.md
  • protocols/turnloop-http/Cargo.toml
  • protocols/turnloop-http/README.md
  • protocols/turnloop-http/src/asynchronous/server.rs
  • protocols/turnloop-http/tests/asynchronous.rs
  • protocols/turnloop-http/tests/server_allocations.rs
  • protocols/turnloop-tls/README.md
  • protocols/turnloop-tls/src/asynchronous.rs
  • protocols/turnloop-tls/tests/asynchronous.rs
  • protocols/turnloop-websocket/README.md
  • protocols/turnloop-websocket/src/asynchronous.rs
  • protocols/turnloop-websocket/tests/asynchronous.rs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…e acknowledgement

server_allocations assumed paced 200 us writes would arrive as mostly separate
reads; Linux coalesced them (fewer than 128 of 256). Both discard allocation gates
now lock-step the peer on the server's published discard progress, so every chunk
is its own read on any kernel: reads >= 256, discarded == 1024, allocations == 0.

On WASI 0.3 the backend closes the send stream (the peer reads EOF) and completes
Shutdown only when the stream's result future resolves in a later wait-set step,
so the peer can finish before the server observes its own half-close. The HTTP
silent-peer helper and the turnloop-io silent-peer test now turn until the server
has observed its half-close before settling and measuring; the no-spin bounds are
unchanged.
@proggeramlug
proggeramlug merged commit 3cce439 into main Sep 15, 2026
39 checks passed
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.

turnloop-http servers close with unread peer bytes (RST drops the final response on macOS/Windows)

1 participant