Skip to content

A caller that half-closes its QUIC stream loses the provider's reply on a client_stream #145

Closed

Description

@rgfaber

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.

Activity

  1. rgfaber commented on Oct 28, 2026

    @rgfaber
    ContributorAuthor

    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.
    • On main as ce067c47eea3b432d2f91d72ebe36be2911478a1: Test run
      40612493218 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.md carries the ### Fixed entry for 17.2.0.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions