Handle incomplete MSG_WAITALL when receiving from the game - #273
Handle incomplete MSG_WAITALL when receiving from the game#273SakhnevichKirill wants to merge 1 commit into
Conversation
Are you sure LuaSocket is doing this or is it the OS? |
|
LuaSocket, and it is checkable on both ends. Sender side. BeamNG ships LuaSocket 3.0-rc1 ( #define STEPSIZE 8192
...
while (total < count && err == IO_DONE) {
size_t step = (count-total <= STEPSIZE)? count-total: STEPSIZE;
err = io->send(io->ctx, data+total, step, &done, tm);
total += done;
}So a 10511-byte frame leaves the game as an 8192-byte write plus the remainder, and the launcher can wake up in between. Receiver side. This kicked players on our server 15 times over the last few days. In all 15 cases the JSON that arrived at the server was cut in exactly the same place: 8183 bytes of JSON plus the 5-byte That said, the answer does not change the fix. Whoever splits the data — luasocket's stepping, TCP segmentation, or wine returning early from One more detail I should have put in the description: I can attach the small reproduction program if it is useful — it feeds the same frame to both the current and the patched implementation over a socketpair and interrupts the read once. |
|
Worth adding, since "this looks like a wine problem" is the natural next thought: in practice it is wine-specific, and the reason is concrete. Wine does not implement the flag at all. if (params.msg_flags & AFD_MSG_OOB)
unix_flags |= MSG_OOB;
if (params.msg_flags & AFD_MSG_PEEK)
unix_flags |= MSG_PEEK;
if (params.msg_flags & AFD_MSG_WAITALL)
FIXME( "MSG_WAITALL is not supported\n" );So under wine the call behaves like a plain It also explains why our boundary is so repeatable rather than random: the game hands the frame to the socket in 8192-byte steps, and the launcher returns after the first one. That said, it is not purely a wine problem: #185 was filed on Windows and reproduced by suspending the launcher process, the Linux build can return early on |
| /// Receives exactly `size` bytes, looping until the buffer is full. | ||
| /// MSG_WAITALL alone does not guarantee this: it returns early when the | ||
| /// call is interrupted after some data has already been received, which | ||
| /// happens frequently in wine and can also happen natively when the OS | ||
| /// pauses the execution. RecvWaitAll() already handles this for the | ||
| /// server-facing socket; the game-facing socket needs the same treatment. |
There was a problem hiding this comment.
Could this be removed? It's pretty obvious it receives exactly. The rest is better suited for a commit message.
There was a problem hiding this comment.
Done — trimmed to /// Throws!!! like the neighbouring helpers; the rationale now lives in the commit message.
| /// pauses the execution. RecvWaitAll() already handles this for the | ||
| /// server-facing socket; the game-facing socket needs the same treatment. | ||
| /// Throws!!! | ||
| inline void RecvExactly(SOCKET socket, char* buffer, size_t size, const char* what) { |
There was a problem hiding this comment.
The final parameter what seems a bit inefficient, especially since this function is only used twice. I know the compiler will probably optimize it out because of inline but still.
There was a problem hiding this comment.
Removed. Both callers now throw the same recv() failed: <strerror>; the old of header / of data wording is gone. Happy to bring it back as two thin wrappers if you'd rather keep it.
ReceiveFromGame() and RecvHeader() read a packet with a single recv(MSG_WAITALL) and never check how many bytes arrived. MSG_WAITALL does not guarantee a full buffer: the call returns early when it is interrupted after some data has already been received, which happens frequently under wine (which does not implement the flag) and can also happen natively when the OS pauses the process. When it does, the unread tail of the buffer is left zeroed and forwarded to the server as NULs, and the bytes still queued on the socket are read as the length of the next packet, which desynchronises the stream. Loop until the buffer is full, the way RecvWaitAll() already does for the server-facing socket, and retry on EINTR so an interrupted read is not reported as a failure.
12d9aad to
ff32e13
Compare
The problem
When the game sends the launcher a packet larger than about 8 KB, the launcher can pass a damaged copy of it to the server and then lose track of where the next packet begins.
Every packet from the game arrives as a 4-byte length followed by that many bytes.
ReceiveFromGame()asks for the whole packet in onerecv()withMSG_WAITALL, but never checks how many bytes actually came back.MSG_WAITALLis allowed to return early — exactly the caseRecvWaitAll()insrc/Network/VehicleEvent.cppwas written for ("happens frequently in wine, and can also happen natively when the OS pauses the execution"). That helper is used for the socket facing the server; the socket facing the game still uses a plainrecv().When the read does return early, two things go wrong at once:
What this looks like in practice
Any vehicle whose spawn data is bigger than 8188 bytes — large modded cars, trailers with many parts — breaks the session. On our server (launcher 2.8.1, server 3.9.3, BeamNG 0.39.4, Wine on macOS) attaching a trailer kicked the player every time. The server logs
Failed to parse vehicle data as json, and the connection is terminated shortly after.Two lines from the launcher log that show what is happening. First, a packet that leaves the launcher compressed far better than a healthy packet of the same size, because most of its tail is zeros:
Second, a packet the game never sent — it is a slice out of the middle of the previous vehicle config, produced after the framing was lost:
Where the 8188 comes from: the game writes the length and the data with a single
socket:send(), and LuaSocket splits that into 8192-byte writes. So the launcher can wake up after the first chunk (4 bytes of length + 8188 bytes of data) while the rest is still on the way. In two separate incidents the data that reached the server was cut at exactly that boundary.This is the same kind of failure as #185, on the other socket.
The change
RecvExactly()keeps reading until the buffer is full, the wayRecvWaitAll()already does for the server-facing socket.RecvHeader()andReceiveFromGame()now use it. The error messages and the exceptions thrown are unchanged.EINTRis retried instead of being treated as a failure. Without that, a read interrupted before any data has arrived turns into arecv() faileddisconnect — the corruption would simply be traded for a dropped connection.Testing
A small standalone program was used to compare the old and the new behaviour: it sends the same 10507-byte packet through a socket pair in 8192-byte chunks and interrupts the read once, then checks what each version assembled.
The NUL tail on the first line is the same symptom seen on the live server. Happy to attach that test program if it is useful.
By creating this pull request, I understand that code that is AI generated or otherwise automatically generated may be rejected without further discussion.
I declare that I fully understand all code I pushed into this PR, and wrote all this code myself and own the rights to this code.