Skip to content

Reassemble socket API messages across reads - #61

Open
toxicphreAK wants to merge 1 commit into
opencloud-eu:mainfrom
toxicphreAK:split/socket-framing
Open

Reassemble socket API messages across reads#61
toxicphreAK wants to merge 1 commit into
opencloud-eu:mainfrom
toxicphreAK:split/socket-framing

Conversation

@toxicphreAK

@toxicphreAK toxicphreAK commented Aug 27, 2026

Copy link
Copy Markdown

Split out of #54.

readSocket() read at most 1023 bytes and left reassembly to a FIXME. The socket is a SOCK_STREAM, so a reply longer than the buffer — or just split across two reads — was parsed as two messages, both failed, and the reply was lost. Once the stream desyncs it stays desynced, so one long arguments.error string breaks hydration until restart.

processSocketInput() now drains into a persistent buffer, dispatches only complete newline-terminated messages and keeps the remainder. handleReceivedMsg() handles one message and no longer splits on newlines itself. The buffer is bounded so a peer that never sends a newline can't grow it without limit.

readSocket() read at most 1023 bytes per call and left message
reassembly to a FIXME. The socket is a SOCK_STREAM and carries no
message boundaries, so any reply longer than the buffer -- or merely
split across two reads by the kernel -- was parsed as two independent
messages. Both halves then failed to parse, the original reply was lost,
and the waiting open() sat out its full backoff.

Worse, the effect is self-sustaining: once the stream desynchronises
every subsequent message on the connection is misparsed, so a single
long reply broke hydration until restart. A V2/HYDRATE_FILE_RESULT
carrying a path plus a free-form arguments.error string clears 1 KB
without trying.

processSocketInput() now drains the socket into a persistent buffer,
dispatches only complete newline terminated messages, and keeps the
remainder for the next read. handleReceivedMsg() correspondingly handles
a single message and no longer splits on newlines itself, which also
retires its trailing-empty-fragment case. The buffer is bounded so a
peer that never sends a newline cannot grow it without limit.

@dragotin dragotin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Some minor remarks, please check what you can / want to fix...

/// Upper bound for the receive buffer. A message from the socket API is a
/// single JSON line and stays far below this; anything larger means the peer
/// is not speaking the protocol.
constexpr size_t MaxRxBufferSize = 1024 * 1024;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Isn't that 1 MB? I think that is very generous for this usecase. I dont think that a full message will ever exceed 50 kB ...

// The socket is a SOCK_STREAM and carries no message boundaries: a single
// read may return a fragment of a message, several messages at once, or
// both. Accumulate into _rxBuffer and only dispatch complete lines.
char buf[4096];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hm, you defined the size of the Rx buffer nicely in a namespace above, but here we go with a bluntly hardcoded value - not a blocker, but why not also define it in the namespace?

// EAGAIN on the non-blocking socket simply means there is nothing more
// to read right now. (EWOULDBLOCK is an alias for it on Linux and macOS.)
if (errno != EAGAIN) {
perror("read");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Seems to be a pretty normal situation that a message is done and nothing more to read. I would suggest to not log that.

}
return;
}
if (errno == EINTR) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is this specific errno handled here?

// to read right now. (EWOULDBLOCK is an alias for it on Linux and macOS.)
if (errno != EAGAIN) {
perror("read");
return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why don't we try to interpret the message in this case?

// without bound.
if (_rxBuffer.size() > MaxRxBufferSize) {
std::cerr << "Discarding " << _rxBuffer.size() << " bytes of unterminated message from the socket API" << std::endl;
_rxBuffer.clear();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This would be a harder error condition. We should probably stop reading the socket rather than just cleaning and go for more...

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