You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
A caller that half-closes its QUIC stream loses the provider's reply on a client_stream #145
Purpose (reliability): a caller of a client_stream procedure receives the provider's reply, instead of the stream dying with EOF, when the caller sends its STREAM_END and then half-closes its QUIC write side.
Found by the release A0 cross-SDK interop in macula-io/macula-go#43, running against macula f1aea93 (refusal_names_key on).
What happens. A macula-go caller (and any stack that closes its QUIC write direction after its STREAM_END, which is the usual half-close) writes its chunks, sends STREAM_END role: send, then half-closes. The provider reads the chunks and the end, and answers with a STREAM_REPLY — but the reply never leaves. The caller sees its QUIC stream end with no frame at all.
Where.macula_station_link.erl ends the session on the peer's transport close: quic_event({quic, peer_send_shutdown, Stream, _}, ...) → end_sessions_on_stream(Stream, peer_send_shutdown, S). The comment above it says a half-close is a STREAM_END frame, never a QUIC close — so the QUIC close is treated as the stream's death, and a STREAM_REPLY set afterwards has nowhere to go.
Evidence.
Against a provider that answers only after the caller's end (counted: recv until eof, then set_reply + close), an Erlang caller (macula:call_stream(..., #{mode => client_stream}), close_send with no QUIC close) gets {ok, {text, "x1,x2"}}.
The same provider, the same station, a macula-go caller that half-closes after its STREAM_END: the provider reads the chunks and the eof, calls set_reply, returns — and the caller gets EOF with nothing.
Suppressing the caller's half-close makes the identical macula-go run pass, with the copy replayed in order. That is the whole difference.
Why it matters now. Release A0's §8.1 interop asks for exactly this shape: "a clear client stream to a provider that answers only after the caller's end ... and gets the answer". The other three A0 programs (clear call, clear server-stream open, clear bidi stream) pass.
Replay of the failing run: MACULA_LOCAL_ERL=1 MACULA_BUILD=<macula checkout at f1aea93b> scripts/interop/sealed.sh pq_pure in macula-go on branch titan/43-revendor, commit 2cce8fb.
Purpose met: a client_stream caller that half-closes its QUIC write side after its STREAM_END gets the provider's
STREAM_REPLY, on every SDK that closes the write direction that way.
What was wrong
macula_station_link:quic_event/2 answered the peer's {quic, peer_send_shutdown, Stream, _} by ending every
session the stream carried (end_sessions_on_stream/3). That event is the transport's FIN — the peer's write-side
half-close — and macula-go's caller sends it right after its STREAM_END frame. The provider's session was therefore
aborted while its handler was still about to answer, and the routing a reply needs was dropped with it, so the
STREAM_REPLY the handler set afterwards went nowhere. A traced run shows exactly that: the handler calls set_reply, close, and nothing is written; the caller sees its stream end with no frame at all.
Fix
A FIN says only that no more of the peer's frames will arrive on that stream. It says nothing about a session that
may still write.
macula_station_link: the FIN is now passed to each session the stream carries
({stream_read_closed, Sid, peer_send_shutdown}) instead of ending it. A stream that carries no session still
closes there, as before.
macula_stream: a session that has already had the peer's last frame (an end, an error, a reply) has everything
it was waiting for and goes on writing. One still waiting for that frame will never get it, and ends as a lost
link ends it — the disconnected error it got before — so the "a reader never waits for ever" guarantee the
removed clause existed for still holds, and now holds for a stream_closed reset as well.
Evidence
Erlang, before the fix: a_callers_quic_half_close_after_its_stream_end_keeps_the_reply and a_session_that_had_the_peers_last_frame_writes_on_after_its_quic_fin both fail (no write at all / send_closed);
after it, both pass, and the two modules are 52/52.
Cross-SDK, the A0 program that found it (MACULA_A0=1 MACULA_LOCAL_ERL=1 scripts/interop/sealed.sh pq_pure in
macula-go at titan/43-revendor): the client-stream program fails against macula f1aea93b and passes against
this change.
Activity
rgfaber commentedon Oct 28, 2026
Purpose met: a
client_streamcaller that half-closes its QUIC write side after its STREAM_END gets the provider'sSTREAM_REPLY, on every SDK that closes the write direction that way.
What was wrong
macula_station_link:quic_event/2answered the peer's{quic, peer_send_shutdown, Stream, _}by ending everysession the stream carried (
end_sessions_on_stream/3). That event is the transport's FIN — the peer's write-sidehalf-close — and macula-go's caller sends it right after its STREAM_END frame. The provider's session was therefore
aborted while its handler was still about to answer, and the routing a reply needs was dropped with it, so the
STREAM_REPLY the handler set afterwards went nowhere. A traced run shows exactly that: the handler calls
set_reply,close, and nothing is written; the caller sees its stream end with no frame at all.Fix
A FIN says only that no more of the peer's frames will arrive on that stream. It says nothing about a session that
may still write.
macula_station_link: the FIN is now passed to each session the stream carries(
{stream_read_closed, Sid, peer_send_shutdown}) instead of ending it. A stream that carries no session stillcloses there, as before.
macula_stream: a session that has already had the peer's last frame (an end, an error, a reply) has everythingit was waiting for and goes on writing. One still waiting for that frame will never get it, and ends as a lost
link ends it — the
disconnectederror it got before — so the "a reader never waits for ever" guarantee theremoved clause existed for still holds, and now holds for a
stream_closedreset as well.Evidence
a_callers_quic_half_close_after_its_stream_end_keeps_the_replyanda_session_that_had_the_peers_last_frame_writes_on_after_its_quic_finboth fail (no write at all /send_closed);after it, both pass, and the two modules are 52/52.
MACULA_A0=1 MACULA_LOCAL_ERL=1 scripts/interop/sealed.sh pq_pureinmacula-go at
titan/43-revendor): the client-stream program fails against maculaf1aea93band passes againstthis change.
mainasce067c47eea3b432d2f91d72ebe36be2911478a1: Test run40612493218 success (eunit, ct, xref, lint,
dialyzer, vectors, docs, advisories), docs 40612493043
and PDFs 40612492466 success; PR fix: a caller's QUIC half-close keeps the provider's reply (Refs #145) #146 merged on that
sha.
CHANGELOG.mdcarries the### Fixedentry for 17.2.0.