feat: add streamReadChunk to StreamIO to unblock perf download throughput - #277
Merged
Conversation
Every consumer of StreamIO read one byte at a time, which bounded the perf test app's download direction at under 5 Mbps (issue #276) while uploads ran at 0.49-0.80 Gbps. Add a chunk-level read to StreamIO: streamReadChunk :: Int -> IO ByteString returning between 1 and n bytes (whatever is buffered or arrives next), with EOF surfacing as an IOException exactly like streamReadByte. The max-length argument (not in the issue's sketch) is what lets readExactBounded use it safely: with no push-back mechanism, an unbounded chunk read would consume bytes past a message boundary. Implemented in the yamux adapter and Noise session wrapper (hand back the buffered chunk / decrypted frame), the TCP socket (recv n), the in-memory test pairs, and a mkByteStreamIO helper that derives a one-byte-per-call chunk read for byte-queue test mocks. readExactBounded now reads chunks, which moves the whole receive path off byte-at-a-time reads: Noise frame reads from the raw socket, the yamux read callback, and every length-delimited protocol reader. The relay's forwardWithLimit also forwards at chunk granularity, still never consuming a byte beyond the circuit's limit. Two DCUtR upgrade tests asserted that no direct connection exists 500ms after the circuit dial; the faster relayed path now lets the automatic DCUtR upgrade pool a direct connection inside that window (on loopback the handler-side dial is an ordinary client dial and succeeds). Those tests now run with the automatic upgrade disabled via zero-length timeout windows, making their pool preconditions deterministic.
Switch perf's drainUntilEof and discardExactly from streamReadByte to streamReadChunk, completing the bulk-reader migration issue #276 lists. Deferred from the previous commit only because the perf app lived on PR #275's branch; now that #275 is merged the switch happens here. discardExactly caps each chunk request at the bytes still owed, so it still never consumes past the requested download size.
adust09
force-pushed
the
feat/issue-276-stream-read-chunk
branch
from
August 26, 2026 07:35
a3755d0 to
9b73985
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
streamReadChunk :: Int -> IO ByteStringtoStreamIO: returns between 1 and n bytes (whatever is buffered or arrives next), EOF viaIOExceptionas withstreamReadByte. The max-length argument is a deliberate refinement over the issue'sIO ByteStringsketch —StreamIOhas no push-back, so an unbounded chunk read would consume bytes past a message boundary and corrupt back-to-back length-delimited messages.recv n),mkMemoryStreamPair, and the EOF test pair. Byte reads are now derived from the shared buffer logic in each adapter.readExactBoundedonto chunk reads. Since Noise frame reads, the yamux read callback, and every length-delimited protocol reader funnel through it, the entire receive path moves off byte-at-a-time reads in one place. The relay'sforwardWithLimitalso forwards at chunk granularity (never consuming beyond the circuit limit).drainUntilEof/discardExactlyontostreamReadChunk(rebased onto feat: implement /perf/1.0.0 protocol and unified-testing perf test app #275, which merged while this PR was open).mkByteStreamIOfor byte-queue test mocks and migrate ~20 test construction sites onto it.Why
Closes #276. The perf test app (#130, PR #275) drains multi-megabyte payloads through per-byte reads: download nim→hs measured < 5 Mbps vs 0.49–0.80 Gbps upload, blocking the upstream
perf/images.yamlsubmission.Test plan
mkByteStreamIOfallback, EOF-pair drain-then-EOF,readExactBoundedno-overread with two adjacent payloadsstreamReadChunkreturns a whole decrypted frame; 300 KB bulk payload (larger than the yamux window) drained viastreamReadChunkthrough the full upgrade pipelinehaskell:9.10-slim-bookworm);cabal build all(incl. the perf interop binary) passesDCUtR test fix
Two
DCUtR/UpgradeSpectests assumed no direct connection exists 500 ms after the circuit dial. That held only because the relayed byte path was slow: on loopback the DCUtR handler-side dial is an ordinary client dial that lands on a real listener and succeeds, and with chunked reads the automatic upgrade now finishes inside the settle window (verified: both tests pass on unmodifiedmain, fail with only the src/ changes applied). They now run with the automatic upgrade disabled via zero-length timeout windows (noPunchConfig), making their pool preconditions deterministic. As a side effect the DCUtR suite runs in 5.6 s vs 10.6 s onmain.