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.
Summary
When ioxide is serving and a client half-closes its send side (
shutdown(SHUT_WR)) right aftersending 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 separateRingSocket/OnClientCompletiontransport 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:OnTcpRecvCompletionSharedandOnTcpRecvCompletionIncremental: onres <= 0(peer EOF) they callCloseFromRecv(conn, fd)→MarkClosed()+close(fd).src/ioxide/Connection/Tcp/TcpConnection.Write.Flush.cs:FlushAsync()early-returns withoutsending once
_closed == 1.So the FIN-triggered teardown races — and usually beats — the handler's
Reply+FlushAsync: theresponse 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
Or the
Ioxide.Tests.ChaosTCP case "a half-closing client is torn down cleanly, server keepsserving": it writes a request,
shutdown(SHUT_WR), then reads — the read frequently returns EOF withno 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, Gonet/http, wrk/h2load) keep thesocket 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
ReadAsyncseesIsClosed) but deferMarkClosed/close(fd)until the handler returns and the flush drains — the refcount already tracksthe 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.Chaossuite.