Skip to content

tcp: a client half-close after the request is treated as teardown, dropping the in-flight response #164

Description

@MDA2AV

Summary

When ioxide is serving and a client half-closes its send side (shutdown(SHUT_WR)) right after
sending its request — signalling "I'm done sending, now give me the response" — the reactor reads the
resulting FIN as connection EOF and tears the whole connection down, including its own send side. The
response the handler was about to write is dropped, so the client sees the connection close before any
bytes of the response arrive.

Server-side only. It lives in the accepted-connection recv path; the ring-native HTTP client
(ioxide.httpclient) uses a separate RingSocket / OnClientCompletion transport and is unaffected.
For a client, receiving the upstream's FIN after the response is the normal end-of-message anyway.

Where

src/ioxide/Reactor/Transport/Tcp/Reactor.Tcp.cs, both recv completions:

  • OnTcpRecvCompletionShared and OnTcpRecvCompletionIncremental: on res <= 0 (peer EOF) they call
    CloseFromRecv(conn, fd)MarkClosed() + close(fd).
  • src/ioxide/Connection/Tcp/TcpConnection.Write.Flush.cs: FlushAsync() early-returns without
    sending once _closed == 1.

So the FIN-triggered teardown races — and usually beats — the handler's Reply + FlushAsync: the
response SEND is only queued to the SQ when the handler resumes, but the EOF CQE that runs close(fd)
is processed in the same completion batch, so the fd is gone before the send is submitted.

Repro

printf 'GET / HTTP/1.1\r\nHost: x\r\n\r\n' | nc -N 127.0.0.1 8080
# -N = shutdown(SHUT_WR) on stdin EOF; the response is frequently empty

Or the Ioxide.Tests.Chaos TCP case "a half-closing client is torn down cleanly, server keeps
serving"
: it writes a request, shutdown(SHUT_WR), then reads — the read frequently returns EOF with
no response. That test deliberately pins only the achievable invariant (the reactor never wedges and
still serves fresh connections), not that this particular response arrives.

Impact

Narrow. Keep-alive clients (browsers, curl, .NET HttpClient, Go net/http, wrk/h2load) keep the
socket fully open to read the response and never half-close, so normal traffic and benchmarks are
unaffected. It only bites clients that deliberately half-close after the request: nc -N, socat,
minimal HTTP/1.0 "send, half-close, read-until-EOF" clients, and the occasional one-shot health check.

Not a spec violation — HTTP/1.1 does not require a server to support half-close. But mainstream servers
(nginx, Kestrel, Go) handle it defensively: they distinguish "peer closed its read side" from
"connection dead" and still flush the in-flight response.

Possible direction

On recv EOF, don't hard-close a connection that still has a running handler or a pending/in-flight
write. Mark the read side done (so the handler's next ReadAsync sees IsClosed) but defer
MarkClosed / close(fd) until the handler returns and the flush drains — the refcount already tracks
the handler's ref, so the send half can stay open until then. Needs care around the multishot-recv
cancel and the fd-reuse generation guard.

Severity

Hardening gap, low urgency — an uncommon client behaviour, by-design today, no crash. Surfaced by the
new Ioxide.Tests.Chaos suite.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions