Repository navigation
Akka.IO TCP: hold back WriteAck until the output pipe's flush completes - #8646
Merged
Aaronontheweb merged 11 commits intoSep 29, 2026
Merged
Conversation
These fail on dev: every Write is acked as soon as its bytes are copied into the output pipe, even past the pipe's pause threshold. Moves BlockingWriteStream and ConnectedSocketPair out of TcpConnectionBatchingSpec so both specs can share them, and adds an EOF switch to the stream.
akkadotnet#8617) TcpConnection threw away the ValueTask<FlushResult> from WriteAsync and acked every Write as soon as its bytes were copied into the output pipe, so the pipe grew without bound when the socket drained slowly. Now at most one flush is awaited at a time. If a flush doesn't complete synchronously, its write's ack waits for it and later writes queue in the actor (the pre-Register queue, reused) until it completes. A flush that reports the output completed or canceled fails the write and closes the connection. Close/ConfirmedClose/PeerClosed still enter Closing right away, but start CloseAsync/ShutdownAsync only once the queue is drained. ResumeWriting now replies WritingResumed once writes drain. The output pipe keeps PipeOptions.Default (64 KB pause) unless Inet.SO.PipeBufferSize is set, in which case it uses the same watermarks as the input pipe (Artery sets 1 MiB).
…otnet#8617) Should_send_ErrorClosed_to_Close_sender_When_a_queued_write_fails_while_closing fails here: a queued write that fails while Closing drains goes through HandleIoError, so the Close sender never gets a close event.
…t them (akkadotnet#8617) A write that failed while Closing drained its queue called HandleIoError, which notifies only the Register-time handler, so the Close sender (and a ConfirmedClose-upgrading Close sender) got no close event. Both failure branches in WriteToPipe now fail the write and tell Self FlushFailed, whose per-behaviour handler closes the connection. FlushFailed also fails the pending ack's write with the real cause. Also: fold HandleGracefulClose/HandleConfirmedClose into HandleClose, make the queued-bytes counter a long, shorten the old comments, and correct the ledger row.
Aaronontheweb
force-pushed
the
feature/tcp-write-backpressure
branch
from
September 25, 2026 12:33
dff290a to
875534f
Compare
akkadotnet#8617) The pre-Register spec checked the bytes written after Abort, which can stop the write pump first. Check them before Abort instead. Count queued bytes only before Register (the only place they're read), share one FlushFailed handler between Open and PeerSentEof, and shorten HandleStreamEof's comment.
Member
Author
Benchmark results: no regressionPerformance looks good. Before ( RemotePingPong (Artery, 5 interleaved runs per commit, msgs/sec)
TcpOperationsBenchmarks (BenchmarkDotNet, LongRun)All 16 cases (10 B and 100 B messages, 1–40 clients) are within ±2% and inside the error bars. Allocations are unchanged. |
This was referenced Sep 28, 2026
…dotnet#8617) Writes now copy into the output pipe unconditionally and ride whatever flush is next, instead of queueing raw bytes in the actor while one is in flight. _pendingWrites/_flushWaiter are gone; _pendingAcks holds only the ack/failure bookkeeping for writes already in the pipe, and a single StartFlush/AckCovered/FailAllOwed path handles both the immediate and batched-after-a-pending-flush cases. This also batches several writes that arrive mid-flush into one follow-up flush instead of one each. ITransportConnection gains Write(ReadOnlySequence<byte>) - a write-only, non-flushing copy into the pipe - alongside the existing WriteAsync/FlushAsync. Approved the new member in the API baseline.
…rage on failure, shorter comments
…dingRegistration* names
…backpressure # Conflicts: # BREAKING_CHANGES_V1.6.md
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.
Closes #8617
What was wrong
TcpConnection.EnqueueWritethrew away theValueTask<FlushResult>fromWriteAsyncand acked everyWriteas soon as its bytes were copied into the output pipe. Nobody waited on the pipe's pause threshold, so when the socket drained slowly the pipe grew without bound andWriteAckpromised nothing.The fix
ITransportConnectiongains a write-onlyWrite(ReadOnlySequence<byte>)that copies bytes into the output pipe without flushing, alongside the existingWriteAsync/FlushAsync. EveryWrite(past the existing per-writewrite-commands-queue-max-sizecap) copies into the pipe and is disposed immediately; only its ack/failure waits for a flush to cover it. The actor no longer buffers raw write bytes for the open/registered path.FlushCompleted/FlushFailedself-message. Writes that arrive while a flush is already in flight are still copied into the pipe right away; their acks just ride the next flush - so several small writes landing mid-flush get acked together off one follow-up flush instead of one flush each.CommandFailed, and closes the connection throughHandleIoError. None of them are acked.Close,ConfirmedCloseand thePeerClosedpath still enterClosingBehaviourright away and reject new writes. They startCloseAsync/ShutdownAsynconly once nothing is owed and no flush is pending, because both complete the pipe. AClosethat upgrades a pendingConfirmedClose(IO: handle Tcp.Close while a ConfirmedClose is draining #8636) goes through the existing upgrade handler and still drains first.Abortand handler death cancel the flush; every write still owed an ack getsCommandFailedfromPostStop.Registerare unchanged - there's no transport yet, so they still buffer in a FIFO queue and replay through the same write path onceRegisterarrives.PipeOptions.Default, pause at 64 KB). WhenInet.SO.PipeBufferSizeis set, the output pipe uses the same watermarks as the input pipe, so Artery (1 MiB) pauses at 2 MiB.Out of scope: changing what
write-commands-queue-max-sizemeans (it still caps a single write).ResumeWritingstays a no-op as on dev; the unimplemented NACK-mode API (ResumeWriting,WritingResumed,useResumeWriting) is removed in a separate PR.Breaking change
A
WriteAcknow means the output pipe is back below its resume mark (32 KB, orPipeBufferSize) after this write, so acks can arrive later under load. A single large write may overshoot the pause mark. While a flush is pending, later writes are copied into the pipe unflushed and their acks wait for the next flush, batching whatever arrived meanwhile. Recorded inBREAKING_CHANGES_V1.6.md.Tests
New
TcpConnectionWriteBackpressureSpec. It stalls the write pump withBlockingWriteStream, moved out ofTcpConnectionBatchingSpecso both specs share it. No real-socket never-reading peers: Linux auto-tunes send buffers, which makes those flaky.CompoundWritepartsIsCompleted(fake transport):CommandFailed, no ack,ErrorClosedClosesender getsErrorClosedkeepOpenOnPeerClosedthenConfirmedClosewith a flush pendingRegisterthat pass the pause thresholdClose,ConfirmedClose,PeerClosedand aCloseupgrading a pendingConfirmedClosewith a flush pending and writes owed: every ack before the close event, all bytes written, no extra acksAbortand handler death with a flush pending and writes owed:CommandFailedfor each writeAbortfails it insteadThe first 12 fail on
dev(the first commit adds only the tests). The Closing write-failure test fails on the second commit and passes after the third. A later commit reworked the write path to copy into the pipe immediately instead of queueing raw bytes in the actor while a flush is pending (same observable behavior, plus the batching above); all of these tests still pass against it.Validation
dotnet build src/core/Akka/Akka.csproj -c Release -warnaserror: cleanAkka.Tests.IO, 3 consecutive runs: 101/101 each (includes the new mid-flush batching test)~Tcp: 23 passed, 3 skipped (existingSkips)~Artery: 286/286ITransportConnection/TcpTransportConnectiongainWrite(ReadOnlySequence<byte>), approved in the baselineBenchmark
Measured before the follow-up commit, which changes only failure and close paths. Predates the later write-path rework described above (write-into-the-pipe-immediately plus batching); not re-measured since, so treat these numbers as dated.
TcpOperationsBenchmarks, short run (1 warmup, 3 iterations, 1 launch), mean ns/op. Caveat: the machine was loaded (load average about 9 on 8 cores) and the error bars are wide, so treat these as a smoke test, not a measurement.With this PR, most 100-byte runs with 5 or more clients are faster and most 10-byte runs are slower. Most differences fall inside the error bars (StdDev up to 280 ns). This echo benchmark rarely goes past 64 KB unflushed, so it mostly exercises the synchronous-flush path, which doesn't allocate. It is worth a rerun on a quiet machine before merging.