Claude Opus 4.8 detected a small problem with the ring_buffer.
click me to show claude output
ring_buffer: iovec helpers under-report free/used space by one byte in wrap-around states
Summary
In src/libcrun/ring_buffer.c, ring_buffer_get_read_iov() and
ring_buffer_get_write_iov() compute the reserved "sentinel" byte as if it
always lived at the physical end of the buffer. It does not — logically the
sentinel sits at index head - 1. As a result, in every wrap-around state
(head != 0) both helpers expose one byte fewer than
ring_buffer_get_space_available() / ring_buffer_get_data_available() report.
This does not corrupt data and is not a security issue (see "Impact"
below), but the reported free/used space no longer matches what a single
readv() / writev() can actually transfer, and the buffer wastes one byte of
capacity whenever it is wrapped.
Details
The buffer uses the standard "one reserved slot" scheme: ring_buffer_make()
allocates size + 1 bytes so that head == tail means empty and
(tail + 1) % size == head means full. That part is correct.
The problem is in the wrap-around (else) branch of the two iovec helpers:
/* ring_buffer_get_read_iov(), head > tail */
iov[iov_count].iov_len = rb->size - rb->head - 1; /* Don't read from reserved byte */
/* ring_buffer_get_write_iov(), tail >= head */
iov[iov_count].iov_len = rb->size - rb->tail - 1; /* Don't write to reserved byte */
The - 1 assumes the reserved slot is the last physical byte (size - 1). But
the reserved slot is the single free slot immediately before head, i.e. index
head - 1. Those two only coincide when head == 0. For any head != 0:
- the read side skips the last physical byte even though it may hold data, and
- the write side refuses the last physical byte even though it is free,
so each helper returns a total that is one less than the advertised
space/data.
Reproduction (white-box)
For any wrap-around state the sum of the iovec lengths disagrees with the
advertised counts. Example for a buffer created with ring_buffer_make(8)
(internal size 9):
| head |
tail |
data_available |
space_available |
read iov total |
write iov total |
| 1 |
1 |
0 |
8 |
0 |
7 |
| 1 |
2 |
1 |
7 |
1 |
6 |
| 2 |
0 |
7 |
1 |
6 |
1 |
| 1 |
8 |
7 |
1 |
7 |
0 |
The last row is the sharpest case: space_available reports 1, but a single
ring_buffer_read() transfers 0 bytes and returns EAGAIN — a stall while
space is still advertised.
The existing unit test (test_ring_buffer_read_write) does not catch this
because it fully drains the buffer every cycle, and ring_buffer_write() resets
head/tail to 0 whenever the buffer empties — so head is always 0 when
the accounting is checked, i.e. the wrap-around states are never exercised.
Impact
- No data corruption. The implementation is internally self-consistent: it
never writes the last physical byte, so it never needs to read it either.
A round-trip stress test kept in wrap-around states (100k+ bytes) passes
clean, including under ASan/UBSan.
- Capacity is under-utilized by one byte whenever the buffer is wrapped.
ring_buffer_get_space_available() over-reports the space usable in a
single readv() by one byte in wrap-around states.
The only in-tree consumer is channel_fd_pair_process() in utils.c, which
uses the accessors only as > 0 guards before calling read/write. There the
practical effect is minor — an occasional no-op readv() and/or a spurious
EPOLLIN wakeup — but a caller that trusts get_space_available() to mean
"I can buffer N bytes right now" would be off by one, and the head == 1, tail == size - 1 state produces a real (if narrow) read stall while space is
advertised.
Suggested fix
Reserve the sentinel at its true position (head - 1), which is the physical
end of the buffer only when head == 0:
/* read side: data is never stored in the reserved slot, so read to the end */
iov[iov_count].iov_len = rb->size - rb->head;
/* write side: reserve the last byte only when the sentinel is actually there */
iov[iov_count].iov_len = rb->size - rb->tail - (rb->head == 0 ? 1 : 0);
With this change the invariant write_iov_total == space_available and
read_iov_total == data_available holds for every (head, tail) combination,
the wasted byte is reclaimed, and the round-trip/ASan/UBSan tests still pass.
I have a patch ready that includes this fix plus a white-box regression test
(iterating every (head, tail) for several sizes and asserting the invariant);
happy to open a PR if this looks right.
Environment
- Component:
src/libcrun/ring_buffer.c (ring_buffer_get_read_iov,
ring_buffer_get_write_iov)
- Affects: current
main
Claude Opus 4.8 detected a small problem with the ring_buffer.
click me to show claude output
ring_buffer: iovec helpers under-report free/used space by one byte in wrap-around statesSummary
In
src/libcrun/ring_buffer.c,ring_buffer_get_read_iov()andring_buffer_get_write_iov()compute the reserved "sentinel" byte as if italways lived at the physical end of the buffer. It does not — logically the
sentinel sits at index
head - 1. As a result, in every wrap-around state(
head != 0) both helpers expose one byte fewer thanring_buffer_get_space_available()/ring_buffer_get_data_available()report.This does not corrupt data and is not a security issue (see "Impact"
below), but the reported free/used space no longer matches what a single
readv()/writev()can actually transfer, and the buffer wastes one byte ofcapacity whenever it is wrapped.
Details
The buffer uses the standard "one reserved slot" scheme:
ring_buffer_make()allocates
size + 1bytes so thathead == tailmeans empty and(tail + 1) % size == headmeans full. That part is correct.The problem is in the wrap-around (
else) branch of the two iovec helpers:The
- 1assumes the reserved slot is the last physical byte (size - 1). Butthe reserved slot is the single free slot immediately before
head, i.e. indexhead - 1. Those two only coincide whenhead == 0. For anyhead != 0:so each helper returns a total that is one less than the advertised
space/data.
Reproduction (white-box)
For any wrap-around state the sum of the iovec lengths disagrees with the
advertised counts. Example for a buffer created with
ring_buffer_make(8)(internal size 9):
data_availablespace_availableThe last row is the sharpest case:
space_availablereports1, but a singlering_buffer_read()transfers0bytes and returnsEAGAIN— a stall whilespace is still advertised.
The existing unit test (
test_ring_buffer_read_write) does not catch thisbecause it fully drains the buffer every cycle, and
ring_buffer_write()resetshead/tailto0whenever the buffer empties — soheadis always0whenthe accounting is checked, i.e. the wrap-around states are never exercised.
Impact
never writes the last physical byte, so it never needs to read it either.
A round-trip stress test kept in wrap-around states (100k+ bytes) passes
clean, including under ASan/UBSan.
ring_buffer_get_space_available()over-reports the space usable in asingle
readv()by one byte in wrap-around states.The only in-tree consumer is
channel_fd_pair_process()inutils.c, whichuses the accessors only as
> 0guards before calling read/write. There thepractical effect is minor — an occasional no-op
readv()and/or a spuriousEPOLLINwakeup — but a caller that trustsget_space_available()to mean"I can buffer N bytes right now" would be off by one, and the
head == 1, tail == size - 1state produces a real (if narrow) read stall while space isadvertised.
Suggested fix
Reserve the sentinel at its true position (
head - 1), which is the physicalend of the buffer only when
head == 0:With this change the invariant
write_iov_total == space_availableandread_iov_total == data_availableholds for every(head, tail)combination,the wasted byte is reclaimed, and the round-trip/ASan/UBSan tests still pass.
I have a patch ready that includes this fix plus a white-box regression test
(iterating every
(head, tail)for several sizes and asserting the invariant);happy to open a PR if this looks right.
Environment
src/libcrun/ring_buffer.c(ring_buffer_get_read_iov,ring_buffer_get_write_iov)mainI also asked Claude for a minimal reproducer:
click me to show claude output
Drop this in
src/libcrun/, thengcc -DHAVE_CONFIG_H -I../.. -I../../libocispec/src -I/usr/include/json-c repro.c -o repro:Output: