Never abandon a partially-written buffer in UnixSocketHandler::write - #796
Closed
marchaase wants to merge 1 commit into
Closed
Never abandon a partially-written buffer in UnixSocketHandler::write#796marchaase wants to merge 1 commit into
marchaase wants to merge 1 commit into
Conversation
marchaase
marked this pull request as draft
August 25, 2026 19:16
UnixSocketHandler::write is a partial-write loop that accumulates bytesWritten across iterations, but its five-second EAGAIN give-up never consulted it. A write that had already put a prefix of the buffer on the wire could therefore be abandoned, returning -1 to the caller. That -1 is ambiguous: it cannot be told apart from "nothing was sent", so the caller has no way to know how much of the buffer the peer actually holds. send() returns a short count whenever the socket has room for some but not all of the buffer, so this is an ordinary state under backpressure rather than a rare race. Reproduced standalone on Linux 6.16.1 and macOS 25.6.0: both return -1 with 0 < committed < count (31250/320000 and 11572/320000). ET's callers already survive the ambiguity, and the stream is not corrupted: BackedWriter maps -1 to WROTE_WITH_FAILURE, and Connection turns that into closeSocketAndMaybeReconnect(), replaying unacknowledged messages from the backup buffer keyed by sequence number. The truncated bytes die with the socket. What it costs instead is a reconnect. The give-up path leaves errno as EAGAIN, which isSkippableError() accepts, so ordinary backpressure lands in the "connection is severed" branch and tears the connection down at VLOG(1) -- silently. The fix is therefore about not forcing a spurious reconnect, not about stream integrity. Apply the five-second budget only while nothing has been committed, where -1 is unambiguous and giving up is cheap. Once a byte is on the wire the budget becomes SOCKET_WRITE_COMMITTED_TIMEOUT (60s), so the write is allowed to finish rather than force a teardown. If that ceiling is reached, log at ERROR with the committed count and the total, so the ambiguous case is never silent. A real error from send() still returns immediately through the existing else branch. Adds three cases to test/unit_tests/UnixSocketHandlerTest.cpp: WriteCompletesOnSlowlyDrainingSocket (fails on the unpatched code), WriteStillGivesUpWhenNothingWasSent, and WriteFailsFastOnClosedPeer. Follow-up to MisterTea#792.
marchaase
force-pushed
the
fix-partial-write-truncation
branch
from
August 25, 2026 19:18
8ab5434 to
f6598bb
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #796 +/- ##
==========================================
+ Coverage 87.14% 87.26% +0.12%
==========================================
Files 75 75
Lines 6462 6532 +70
Branches 610 615 +5
==========================================
+ Hits 5631 5700 +69
Misses 831 831
- Partials 0 1 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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.
Never abandon a partially-written buffer in
UnixSocketHandler::writeThe bug
UnixSocketHandler::writeis a partial-write loop —bytesWrittenaccumulatesacross iterations — but its five-second EAGAIN give-up never consults it:
So a write that has already put a prefix of the buffer on the wire can be
abandoned. The peer keeps that prefix — it cannot be recalled — while the caller
receives
-1, which is indistinguishable from "nothing was sent". The callercannot tell how much of the buffer the peer actually holds.
sendreturns a short count whenever the socket has room for some but not allof the buffer, so this is an ordinary state under backpressure, not a rare race.
Reproduction
Standalone, no ET code involved — it reimplements the loop above against a real
socket. Saturate a loopback TCP connection until
sendraises EAGAIN, have thepeer drain a small amount so a partial write becomes possible, then write a
payload much larger than the send buffer.
Linux figures are three consecutive trials, identical every time.
The load-bearing result is
0 < committed < counttogether withrc == -1:the write both sent data and reported total failure. That is the whole defect,
and it does not depend on what the bytes happened to be.
A note for anyone reproducing this: do not trust
SO_SNDBUF. Requesting2048 yields 4608 on Linux and 65328 on Darwin. Payloads smaller than the real
buffer fit entirely and reproduce nothing.
Withdrawn claim: no stream corruption
An earlier revision of this description argued the truncation corrupts the
terminal byte stream. It does not, and ET's design says so explicitly.
BackedWriter::write(src/base/BackedWriter.cpp:76) anticipates exactly thisambiguity:
On
-1it does not assume nothing was written. It returnsWROTE_WITH_FAILURE;Connection.cpp:216turns that intocloseSocketAndMaybeReconnect(); andrecover(lastValidSequenceNumber)replaysevery unacknowledged message from
backupBufferkeyed by sequence number. Thetruncated bytes die with the socket. Stream integrity is preserved at the
protocol layer.
This was confirmed end to end: a real
etserver/etsession over acontrollable tunnel, 60000 numbered markers each followed by
ESC[?1003l, withthe tunnel stalled 8s — well past the 5s deadline — on both arms:
Identical. No corruption either way.
What it actually costs
Tracing the failure path rather than inferring it:
Connection.cpp:202is the only caller ofwriter->write().WROTE_WITH_FAILUREreachescloseSocketAndMaybeReconnect()on bothbranches at
Connection.cpp:216.errnoasEAGAINfrom the lastsend, andisSkippableError(Connection.cpp:23) acceptsEAGAIN— so it takes the"connection is severed" branch and tears the connection down at
VLOG(1).So the present behaviour under ordinary backpressure is a silent reconnect.
Correct, but not free: a reconnect is exactly the kind of hiccup a user notices.
Reconnect counts with and without the fix are being measured now, and this
section will be updated with them. If the fix does not reduce them, then it is
hygiene — a genuinely ambiguous return value made unambiguous — and this
description will say so plainly rather than claim an effect it cannot show.
The fix
The five-second budget is only safe while nothing has been committed; at that
point
-1is unambiguous and giving up is cheap. Once a byte is on the wire thebudget becomes
SOCKET_WRITE_COMMITTED_TIMEOUT(60s), so the write is allowedto finish rather than force a teardown. A real error from
sendstill returnsimmediately through the existing
elsebranch.If the committed budget does expire, the write still returns
-1— but it nowlogs at
ERRORwith the fd, the committed count and the total, so the ambiguouscase is never silent.
Sixty seconds is a judgement call rather than a measured value.
A randomised soak over varying payload sizes and drain rates puts truncations at
29.2% before the change and 5.7% after — a large reduction, not zero. A
socket draining more slowly than the ceiling still truncates, and no choice of
ceiling removes that: the loop cannot un-send a prefix, and blocking forever is
worse.
Tests
Three cases added to
test/unit_tests/UnixSocketHandlerTest.cpp(the existingcases are untouched):
WriteCompletesOnSlowlyDrainingSocket— a peer that drains slowly enoughto hold the write blocked past five seconds. Asserts the write returns
countrather than
-1, and separately asserts it took longer than five seconds, soa too-fast drain cannot make the test green without exercising the regression.
This case fails on the unpatched code.
WriteStillGivesUpWhenNothingWasSent— the opposite guarantee. A fullsocket with a peer that never reads still gives up at ~5s, and asserts elapsed
is under 30s so the committed budget cannot silently apply to everything.
WriteFailsFastOnClosedPeer— a real error is not backpressure; mustreturn in under two seconds without spinning out either budget.
Two take ~5-7s by construction — holding the socket blocked across the old
deadline is the whole point — and are tagged
[slow].They sit inside the file's existing
#ifndef WIN32block: they needsocketpair(AF_UNIX, …)andfcntl(O_NONBLOCK), so they are POSIX-only. Theproduction change itself is platform-independent and applies to the Windows
::sendpath too.Test run
On macOS 25.6.0 / arm64, AppleClang 21, Catch2 v3.15.2.
Full suite with the fix applied:
The
[UnixSocketHandler]cases, showing the two[slow]ones spending theirtime as intended rather than short-circuiting:
Reverting only
src/base/UnixSocketHandler.cpptomasterand rebuilding,with the new tests left in place — the regression reproduces:
524288 bytes were asked for, a prefix went out, and the caller was handed
-1.The other two cases pass unpatched, which is the point of including them — they
pin down the behaviour the fix must not change.
bash format.sh(clang-format 18, Google style) produces no diff.Relationship to #792
#792 (
SO_LINGER) removed the process-wide five-second stalls that drovesockets into backpressure, which made this defect far rarer without removing it.
The two are independent; this one was noted as a follow-up at the time.