Skip to content

Fix silent truncation of large command responses in BedrockServer::_reply()#2661

Open
roryabraham wants to merge 10 commits into
mainfrom
rory-bedrock-response-send-drain-fix
Open

Fix silent truncation of large command responses in BedrockServer::_reply()#2661
roryabraham wants to merge 10 commits into
mainfrom
rory-bedrock-response-send-drain-fix

Conversation

@roryabraham

@roryabraham roryabraham commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Details

BedrockServer::_reply() sent a command's response with a single blocking socket->send() call and then immediately shut the socket down, without checking whether the entire serialized response was actually queued to the kernel first. If that one send() call queued fewer bytes than the full response (e.g. because a signal interrupts the blocking wait), the remainder sat in the socket's in-process sendBuffer and was silently discarded — the client would receive a truncated response with no error indication.

This PR adds STCPManager::Socket::sendAll(), which loops send() until the socket's sendBuffer is fully drained (or a hard error occurs), and switches _reply() to use it instead of a single send() call — mirroring the drain pattern already used in SQLiteClusterMessenger and STCPManager::postPoll.

The other Socket::send() call sites (plugins/MySQL.cpp, SQLitePeer::sendMessage, SHTTPSManager's outbound request) are all driven by the ongoing prePoll/postPoll loop rather than a one-shot blocking thread, so a single send() there is correct as-is — the poll loop keeps draining sendBuffer on subsequent iterations, and switching those to sendAll() would risk busy-spinning the shared poll thread on a non-blocking socket instead. Left those alone.

sendAll() only avoids busy-spinning because command-port sockets are accepted as blocking (S_accept(..., true)), so the kernel itself blocks send() until it can accept more data instead of returning EAGAIN. To make sure this doesn't quietly regress if sendAll() is ever called on a non-blocking socket in the future, it now asserts the fd is blocking before looping.

Fixed Issues

N/A — this is a proactively-identified correctness bug, not filed as a separate GitHub issue.

Tests

Covered by existing unit and integration tests that exercise the command port response path (e.g. CommandPortTest).

roryabraham and others added 3 commits July 1, 2026 17:12
…ncation

BedrockServer::_reply() sends a command response with a single blocking
socket->send() call, then immediately shuts the socket down, with no loop
to confirm the whole response was actually queued to the kernel. If that
one send() queues fewer bytes than the full response (e.g. because a
signal interrupts the blocking wait), the remainder is silently dropped
instead of retried.

Add SocketSendDrainTest, which forces this exact condition deterministically:
it wraps a real loopback socket in the actual STCPManager::Socket class,
interrupts a large (20MB) blocking send() mid-transfer with a targeted
SIGALRM (via pthread_kill, not a mock), and asserts the client receives the
complete response. This currently fails (truncated at ~100KB), reproducing
the bug.

Co-authored-by: Cursor <cursoragent@cursor.com>
…eply()

A single socket->send() call can queue fewer bytes than the whole
serialized response (e.g. if a signal interrupts the blocking wait), and
_reply() shut the socket down immediately afterward regardless, silently
discarding whatever was left in the in-process sendBuffer. Loop send()
until sendBufferEmpty() (or a hard error) before shutting down, mirroring
the pattern already used in SQLiteClusterMessenger and STCPManager::postPoll.

Updates SocketSendDrainTest to exercise this drain-loop pattern instead of
the old single-send-then-shutdown one; it now passes reliably (verified
5/5 runs) under the same forced-interruption fault injection that
previously reproduced the truncation.

Co-authored-by: Cursor <cursoragent@cursor.com>
Adds LargeCommandResponseTest, which drives a real BedrockServer over the
real command port with a query that returns a 5MB response, and verifies
the client receives it complete and byte-for-byte correct.

This complements SocketSendDrainTest: it exercises the full real pipeline
(plugin -> BedrockCommand -> serialization -> _reply() -> command port)
end-to-end, whereas SocketSendDrainTest exercises STCPManager::Socket
directly under a forced fault (an interrupted send()) to reliably
reproduce/guard against the truncation bug itself.

Co-authored-by: Cursor <cursoragent@cursor.com>
roryabraham and others added 7 commits July 2, 2026 13:22
Empty function bodies need their brace on its own line per .uncrustify.cfg.

Co-authored-by: Cursor <cursoragent@cursor.com>
It only confirms a large response is delivered correctly over a healthy
loopback connection, which was never in question -- a single blocking
send() call typically blocks internally until fully queued under those
conditions. It doesn't force the actual triggering condition for the bug
this branch fixes (a signal interrupting the blocking send()), so it
wouldn't catch a regression back to the old single-send-then-shutdown
pattern in _reply(). SocketSendDrainTest is the real regression guard;
this test was redundant coverage without a concrete bug it protects
against.

Co-authored-by: Cursor <cursoragent@cursor.com>
No behavior change.

Co-authored-by: Cursor <cursoragent@cursor.com>
Moves the drain loop out of BedrockServer::_reply() and into a new
STCPManager::Socket::sendAll(), which loops send() until sendBuffer is
empty (or a hard error occurs). Keeps the fix in one place instead of
duplicated between production code and any test that wants to exercise it.

Co-authored-by: Cursor <cursoragent@cursor.com>
The drain loop it exercised is now factored out into
STCPManager::Socket::sendAll() and already covered indirectly by every
existing test that sends a response over the command port (CommandPort,
Query, Read, Write, the Jobs tests, etc.), all of which go through
BedrockServer::_reply() -> sendAll().

Co-authored-by: Cursor <cursoragent@cursor.com>
buildCommandFromRequest() sends the "202 Successfully queued" ack with
a single send() call and then immediately shuts the socket down. Same
truncation risk as the bug fixed in _reply(): a partial send() here
would leave bytes stranded in sendBuffer, which shutdown(CLOSED) then
discards without ever reaching the wire.

Co-authored-by: Cursor <cursoragent@cursor.com>
sendAll() loops send() until the buffer drains, which relies on the
kernel blocking send() when its buffer is full. On a non-blocking
socket, send() would instead return immediately on a full buffer,
turning the loop into a CPU-burning busy-spin. Assert against that
misuse instead of silently spinning.

Co-authored-by: Cursor <cursoragent@cursor.com>
@roryabraham
roryabraham marked this pull request as ready for review July 2, 2026 21:24

@tylerkaraszewski tylerkaraszewski left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the wrong fix for this. STCPManager::Socket::Send

Comment thread BedrockServer.cpp
socket.send(response.serialize());
// sendAll() loops until the whole response is queued to the kernel (or a hard error occurs); we're about to
// shut down the socket, so we won't get another chance to send anything left behind by a partial send().
socket.sendAll(response.serialize());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fix is "correct" because we actually shut down the socket here but is sort of pointless because there is no message being sent. We are literally sending 202 Successfully queued and close.

Comment thread BedrockServer.cpp
if (!command->socket->send(command->response.serialize())) {
// Otherwise we send the standard response. sendAll() loops until the whole response is queued to
// the kernel (or a hard error occurs), rather than assuming a single send() call queued everything.
if (!command->socket->sendAll(command->response.serialize())) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this really isn't correct. If there's more data than we can send here we don't want to block, we want this to get sent on the next loop.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a better way to handle this (maybe it is already handled, I'm not sure) is to check if the sendbuffer is empty when we attempt to close a socket, and then refuse to until it's emptied.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants