fix(streaming): count Streaming_PutSample capacity in samples, not bytes - #508
Open
nicolas-rabault wants to merge 1 commit into
Open
fix(streaming): count Streaming_PutSample capacity in samples, not bytes#508nicolas-rabault wants to merge 1 commit into
nicolas-rabault wants to merge 1 commit into
Conversation
Streaming_PutSample compared a sample count against the ring buffer's byte
span:
LUOS_ASSERT((Streaming_GetAvailableSampleNB(stream) + size)
<= (end_ring_buffer - ring_buffer));
The left side is samples, the right side is bytes, so the guard was too
permissive by a factor of data_size and the memcpy that follows could run
past the end of the ring buffer. Streaming_AddAvailableSampleNB already
divided that span by data_size, but Streaming_PutSample advances data_ptr
itself and never reaches that check.
Every existing streaming test used a 1 byte data size, where a sample count
and a byte count are the same number, which is why this stayed hidden.
On a 16 sample float channel with data_ptr on the last slot, putting 32
samples passed the guard and wrote 60 bytes past the end of the buffer.
Luos_ReceiveStreaming turns a MAX_DATA_MSG_SIZE message into exactly those
32 samples, so a remote node could reach it.
Compute the capacity in samples once, in Streaming_GetSampleCapacity, and
use it on both paths. Luos_ReceiveStreaming now drops a chunk the channel
cannot hold instead of asserting, since its size comes off the wire, and
also drops a chunk shorter than one sample, which used to trip the
size > 0 assert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
The bug
Streaming_PutSampleguards its capacity with a comparison whose two sides are in different units:Streaming_GetAvailableSampleNB()returns samples andsizeis samples, butend_ring_buffer - ring_bufferis a byte span. The guard is therefore too permissive by a factor ofdata_size, and thememcpythat follows can run past the end of the ring buffer.Streaming_AddAvailableSampleNBalready did the same check correctly, dividing that span bydata_size— butStreaming_PutSampleadvancesdata_ptritself and never calls it, so the correct check was never reached on that path.This stayed hidden because every existing streaming test uses
data_size == 1, where a sample count and a byte count are the same number.Verified
Measured with a temporary probe on unpatched
main, using a 16-sample float channel (64 bytes) with a 64-byte guard region placed immediately after it:data_ptron the last slotThe 60-byte overrun is the decisive case. Arithmetic matches exactly:
chunk1 = 64 - 60 = 4,chunk2 = 128 - 4 = 124, and 124 bytes written atring_bufferin a 64-byte ring is 60 past the end. The first byte after the buffer helddata[17], exactly as predicted.Reachable from the network.
Luos_ReceiveStreamingclamps toMAX_DATA_MSG_SIZE(128) and divides bydata_size, yielding up to 32 samples on a 4-byte channel. Any channel with fewer than ~32 samples of capacity can be driven out of bounds by a remote node's message. That is precisely the second row above.Refuted
One detail of the original report does not hold. Putting 20 samples into a fresh 16-sample float channel does not write past the end of the buffer. The wrap-around branch splits the copy:
chunk1 = 64fills the ring, thenchunk2 = 16wraps and lands at the start of the ring. The guard region was untouched (0 of 64 bytes).What actually happens there is silent in-buffer data loss: 16 of the 20 samples are destroyed, and the channel afterwards reports 4 samples available. The out-of-bounds write needs
data_ptrto be somewhere other than the start of the ring — which is the normal steady state of a running channel, so this is not an exotic condition.The fix
Compute the capacity in samples once and use it on both paths:
Streaming_AddAvailableSampleNBnow uses it too — that is a pure de-duplication of an expression it already computed inline, with no behaviour change.Blast radius — please read before merging
Today this overruns silently. A correct check converts silent memory corruption into a firing
LUOS_ASSERT. Any existing caller that has been quietly overrunning its channel will now assert instead. That is the right trade, but it is a behaviour change and it may surface as new asserts in code that appeared to work.Because of that,
Luos_ReceiveStreamingis handled differently: its size comes straight off the wire, so letting it assert would hand any remote node a reliable way to halt a peer. It now drops a chunk the channel cannot hold and returnsFAILEDrather than asserting. It also drops a chunk shorter than one sample —chunk_size / data_sizewas 0 there, which tripped thesize > 0assert inStreaming_PutSample. Both were remotely triggerable.Note this reuses the existing
FAILEDreturn, which on this function already means "more chunks coming" rather than "error". Rejecting is the safe minimum; if you would rather it clamp and keep the samples that fit, say so and I will change it.Sweep — found but NOT fixed here
Two further problems in the same file, both out of scope for a minimal fix. Happy to open separate PRs.
Luos_SendStreamingSizehas the mirror-image unit bug (verified). Line 268 setsmsg->header.size = data_size, where that local holds a sample count, whileLuos_ReceiveStreamingreadsheader.sizeas bytes and divides bydata_size. Round-trip only works whendata_size == 1. Measured on a 4-byte channel: sending 10 samples putheader.size = 10on the wire, and the receiver stored 2 samples. Correction: I first flagged this as a wire-format change and therefore out of scope. That was wrong. The wire is byte-oriented by design, so writing a sample count intoheader.sizeis simply a bug, and fixing it restores the intended semantics rather than changing them. Now fixed in fix(streaming): send the streaming chunk size in bytes, not in samples #509, stacked on this PR.Streaming_GetAvailableSampleNBUntilEndBufferloop detection is unreliable on 32-bit (by inspection, not measured). Line 150 divides the pointer difference before narrowing toint32_t, unlikeStreaming_GetAvailableSampleNBat line 132 which casts first. On a 32-bit target a wrapped buffer gives(2^32 - k)/data_size, which stays positive, so the< 0branch never runs. The native tests are 64-bit, where truncation happens to yield -1, so this is invisible there. I did not verify this on hardware.Also noted, not changed:
Streaming_AddAvailableSampleNBrejects a request that would exactly fill the channel (> 0), whileStreaming_PutSampleallows it (<= capacity, and an existing test asserts that filling exactly must not fire). The two functions disagree by one; I preserved both behaviours rather than pick one.Tests
Added
unittest_Streaming_PutSample_capacityandunittest_Luos_ReceiveStreaming_oversized— the first streaming tests to use adata_size > 1, with an explicit guard region to catch overruns.Both new tests fail on unpatched
main. No existing test changed.clang-formatclean.Based on
origin/main. Does not touch #506 or #507.