Skip to content

Stream receives report the end of the stream - #113

Open
rnro wants to merge 3 commits into
apple:mainfrom
rnro:stream-end-of-stream-reporting
Open

Stream receives report the end of the stream#113
rnro wants to merge 3 commits into
apple:mainfrom
rnro:stream-end-of-stream-reporting

Conversation

@rnro

@rnro rnro commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

At the moment Message.isComplete is always false for a stream receive, whatever the sender did. The flag travels on a frame as connectionComplete and the socket bottom sets it, but the stream read path accumulated only the bytes and dropped the flag before any consumer could see it. That leaves a consumer inferring half-closure from a receive completing with no content, which is a different mechanism and an unreliable one. It works only when the FIN arrives in a read event of its own; when it arrives alongside data drainArray hands the sentinel over with the payload, so there is no empty receive either and the next one never completes.

Three commits, in this order:

  • FrameArray.connectionComplete now reads the final frame, where it previously reported true if any frame carried the flag. This is a semantics change to a public property. I think this correctly matches the intended semantics though and matches my expectations from common protocols e.g. QUIC and TCP which both put the marker on the last byte and allow nothing after it. It also closes a latent premature release in ProtocolStreamHandlers.receiveStreamData, which gates on the array flag to hand over data below the caller's minimum.
  • drainArray keeps the flag with the tail it retains when it splits. Previously the flag went the wrong way.
  • The socket bottom reports the end of the stream from inputFinished, on the last queued frame or a synthesized zero-length one, exactly once; read(minimumBytes:maximumBytes:) and EndpointFlow carry it up to Message.isComplete.

rnro added 3 commits August 24, 2026 11:24
The property reported true if *any* frame carried `connectionComplete`, which makes it a
question about the array's contents rather than about the stream. A stream ends after its last
byte, so a flag on a frame with bytes behind it does not describe a stream that has ended, and
reading it that way reports the end early. This matches QUIC and TCP, which both put the
end-of-stream marker on the last byte and allow nothing after it.

Nothing was relying on the any-frame reading; there was simply nothing stating where the flag
may sit. The only place that marks an inbound frame already puts it on the last one.

Reading the final frame states the rule, and makes a stray or stale mark irrelevant rather than
harmful, so no writer has to be policed. `markEndOfStream` no longer needs to explain itself,
and `ProtocolStreamHandlers.receiveStreamData`, which gates on the array flag to release data
below the caller's minimum, can no longer be tricked into releasing early.
With the array reporting the flag from its final frame, whatever writes the flag has to put it
there, and a byte-limited drain has to keep it there.

`markEndOfStream()` marks the final frame; the socket bottom previously marked every queued
frame, which reported the end of the stream while bytes were still queued. `drainArray` moves
the flag to the tail it retains when it splits a frame: one branch swaps the original out as
the returned prefix, and since `swap` exchanges whole frame values the flag would otherwise
leave with the prefix and be lost from the bytes that remain.

Which frame carries the flag is an invariant of the container rather than of whatever fills
it, and it is the invariant `drainArray` already relies on, so both belong here.
At the moment `Message.isComplete` is always false for a stream receive, whatever the sender
did: the flag travels on a frame as `connectionComplete` and the socket bottom sets it, but
the stream read path accumulated only the bytes and dropped the flag before any consumer
could see it. That leaves a consumer inferring half-closure from a receive completing with no
content, which is a different mechanism and an unreliable one. It works only when the FIN
arrives in a read event of its own; when it arrives alongside data, `drainArray` hands the
sentinel over with the payload, so there is no empty receive either and the next one never
completes. Nothing in the library reads the flag, which is why no test caught it.

`read(minimumBytes:maximumBytes:)` now returns whether the stream ended alongside the
content, and the socket bottom marks the queued bytes rather than appending a sentinel. That
means no frame has to be allocated at EOF, and keying the decision on the half-closed state
rather than on a queued frame is what lets the marker be synthesized once the queue has
already drained. Delivery is recorded from the drained result as well, so the common path
where the flag rides out on real data does not then report a second, empty end of stream.
@rnro rnro added the 🆕 semver/minor Adds new public API. label Aug 24, 2026
@agnosticdev
agnosticdev requested a review from kkuk24 August 24, 2026 20:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🆕 semver/minor Adds new public API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant