Reassemble socket API messages across reads - #61
Conversation
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.
8ea28d2 to
04da3d7
Compare
dragotin
left a comment
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
This would be a harder error condition. We should probably stop reading the socket rather than just cleaning and go for more...
Split out of #54.
readSocket()read at most 1023 bytes and left reassembly to a FIXME. The socket is aSOCK_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 longarguments.errorstring 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.