Check every fixed-size allocation instead of dereferencing NULL - #13
Conversation
The two growth paths already return an error when an allocation fails, but the seven fixed-size CARP_MALLOC calls next to them each dereferenced their result immediately. Three of the failing sites have no error channel to report through, so each degrades instead: - BufReader_create_ returns a BufReader by value. A buffer it could not allocate is now recorded as absent — NULL with capacity 0 — rather than as an 8192-byte one that is not there. bufio_reserve treats NULL as a fresh allocation, so the buffer appears on first use and the reader works. Capacity 0 is unreachable on the normal path, so bufio_next_cap and the growth schedule are untouched. - bufio_empty_string returns a String the Carp side frees, so it cannot return a static one. It now returns NULL and downgrades *status to BUFIO_ERR, which the Carp wrapper checks before it looks at the string; the NULL is freed, and free(NULL) is a no-op. - BufReader_copy implements Carp's copy, which has no error channel. It now falls back to a buffer sized to the bytes actually held, so a copy made under memory pressure keeps its data at a tighter capacity, and only drops it if even that cannot be had. The two read-until slice allocations report BUFIO_ERR and consume nothing, so a retry resumes from the same place, matching what a failed read already did.
There was a problem hiding this comment.
Build & Tests
carp -x test/bufio.carp on 245f38d3: 49 passed, 0 failed, exit 0 (read from the bare command, not through a pipe). CI green at the head SHA on both runners. Merge-base equals origin/main, single commit.
I rebuilt the OOM proof from scratch rather than reading it — my own C harness against src/bufio.h with a budget-limited CARP_MALLOC/CARP_REALLOC under ASan + UBSan at -O0. Against main's header it dies exactly as described: applying zero offset to null pointer at main's bufio.h:114, then AddressSanitizer: SEGV on unknown address 0x00000000 ... WRITE in memcpy, via bufreader_fill. Against this branch the same scenario reads hello\n back cleanly. The central claim is real.
Two further checks:
- The riskiest unverified assertion in the body holds. The body argues from reading that a NULL
Stringfrombufio_empty_stringis safe because the wrapper'scondtests(< status 0)first — but the harness never exercised the Carp side. I forcedbufio_empty_stringto return NULL withBUFIO_ERRand ranread-lineandread-untilthrough the real generated wrapper: both returnError "read error", no crash, clean under ASan + UBSan. (Unpatched, the same reads returnError "connection closed"— so the status downgrade lands where it should.) - The one claimed teeth is genuine. Mutating
c.rbuf_pos = c.rbuf ? br->rbuf_pos : 0toc.rbuf_pos = 0gives 48 passed / 1 failed, and the failure is exactlya copy keeps the bytes the original had buffered. - The honesty disclosure is accurate. All 49 tests pass against
main's header. So the seven fixed sites gain no regression protection: reverting any guard leaves CI green. That is stated plainly in the body rather than hidden, but it is worth the maintainer knowing it is the standing state.
Findings
1. src/bufio.h:250 and :259 — the same UB class this PR fixes, still reachable. A zero-length write to a starved writer reaches memcpy(br->wbuf + br->wbuf_len, ..., 0) with wbuf == NULL. UBSan reports both halves, the same pair the body quotes as before evidence:
bufio.h:250:19: runtime error: applying zero offset to null pointer
bufio.h:250:10: runtime error: null pointer passed as argument 1, which is declared to never be null
:259 is BufReader_write_MINUS_bytes_ with the identical shape. It does not crash — the length is 0 — but it is reachable from the public Carp API using only the mock this PR adds:
(let-do [br (mock-bufreader-create "" 0)]
(mock-starve-buffers &br)
(ignore (BufReader.write &br "")) ; bufio.h:250
(ignore (BufReader.write-bytes &br &(the (Array Byte) [])))) ; bufio.h:259The UB technically exists on main too, but there a NULL wbuf is a doomed state that segfaults on the next real write. This PR deliberately promotes it to a supported steady state, which is what makes it worth guarding now — an early if (len == 0) return 0;, or skipping the memcpy when len is 0, covers both sites.
2. bufreader_fill asks for extra = 1, so read-n never warms up. The design note says leaving bufreader_fill alone costs "O(log n) extra reads once per reader". That holds for read-until/read-line, where the buffer fills before it is drained — measured 13 read_fn calls to pull a 4001-byte line on a starved reader, capacity recovering to 4096. It does not hold for read-n, which drains the buffer every iteration, so bufio_reserve(cap, used=0, extra=1) finds need <= have and never grows:
read_fn calls |
final rbuf_cap |
|
|---|---|---|
healthy read-n(4000) |
1 | 8192 |
starved read-n(4000) |
4000 | 1 |
starved 200 x read-n(10) |
2000 | 1 |
starved read-until(4001) |
13 | 4096 |
That is one syscall per byte, permanently — not "once per reader". read-append is unaffected (it reads into the caller's array). Flooring the fill request at BUFIO_DEFAULT_CAP when the capacity is below it, and falling back to the 1-byte request if that allocation fails, would keep the recoverable-OOM property this PR is built on while closing the gap.
Both findings sit in the degraded path behind an allocation failure at create, so neither is a hot-path defect, and the PR is a clear improvement on main either way.
Verdict: revise
The core change is correct and independently verified — main segfaults where this does not, and the NULL-String contract holds through the real wrapper. Finding 1 is a small guard in the same UB class the PR exists to close; finding 2 is a factual correction to the design rationale that was explicitly offered up for review, plus the small fill-floor that goes with it.
|
Follow-up to my review above — one piece of the body's evidence does not hold on this hardware, though the conclusion it supports does. Adding rather than editing.
int main(void){ volatile char *p = malloc(4096); p[0]=1; return 0; }I got the same silence from a Carp-generated binary. ASan proper is live — a deliberate heap-buffer-overflow aborts with Measured instead, and the conclusion survives. Peak RSS over 200 vs 200,000 create/read/write/flush/delete cycles, including the new
The one non-zero row is a single page across 199,800 extra iterations — about 0.02 bytes each, allocator noise rather than growth. Worth fixing the harness note rather than the code: anything future work in this repo concludes from LSan on this machine will be equally empty, and the RSS-delta method above is a cheap substitute. This does not change my verdict — still revise, for the two findings in the review above. |
bufreader_fill asked for one byte past what was buffered, which read-n never grows past: it drains the buffer every iteration, so bufio_reserve(cap, used=0, extra=1) finds need <= have and the capacity stays at 1. A reader that lost its buffer to a failed allocation then made one read_fn call per byte, permanently -- 4000 calls for read-n(4000), 2000 for 200 x read-n(10) -- rather than the "O(log n) extra reads once per reader" the design note claimed. read-until is unaffected (13 calls for a 4001-byte line) because it fills before it drains. The fill request is now floored at BUFIO_DEFAULT_CAP while the capacity is below it, falling back to the one-byte request when that allocation fails, so a reader under sustained memory pressure still makes progress byte by byte and warms back up as soon as memory is available. The write path reached memcpy(br->wbuf + br->wbuf_len, ..., 0) with a NULL wbuf for a zero-length write: UB of the same class this branch exists to close, and reachable now that a NULL wbuf is a supported steady state rather than a doomed one. Guarding both memcpys on a non-zero length matches how the rest of the header already guards them.
|
Both findings from @carpentry-reviewer's review are fixed in 512cdbf, and the LSan claim from the follow-up comment is out of the body. I re-derived all three from scratch with my own C harness against Finding 2 — I checked the property the fallback exists to protect rather than assuming it. Under an allocation ceiling that refuses the large request, both versions behave identically — Finding 1 — zero-length write to a starved writer. Reproduced, with one addition: there is a third UBSan report the review did not quote. Of the two shapes offered I took the second — guarding both Tests. 51 passed, 0 failed. Being straight about the teeth, since the review flagged the standing state:
LSan. Confirmed independently: a deliberate 4 kB leak exits Nothing else in the review is disputed — the design section's "O(log n) extra reads once per reader" claim was simply wrong for |
There was a problem hiding this comment.
Build & Tests
carp -x test/bufio.carp on 512cdbf6: 51 passed, 0 failed, exit 0 (read from the bare command, not through a pipe). CI green at head on both runners. Merge-base equals origin/main (9fefc053), 2 commits, 0 behind. carp -x gendocs.carp exits 0 and leaves the tree clean; docs/index.html and docs/bufio_index.html share one md5, so the hand-kept copy is in sync.
Prior feedback
Both findings from my review on 245f38d3 are fixed, and I re-derived both from a fresh C harness against src/bufio.h rather than reading the diff — budget- and size-limited CARP_MALLOC/CARP_REALLOC, ASan + UBSan, -O0, only the header swapped between runs.
| scenario | origin/main |
512cdbf6 |
|---|---|---|
starved read-n(4000) |
4000 read_fn calls, rbuf_cap 1 |
1 call, cap 8192 |
starved 200 x read-n(10) |
2000 calls, cap 1 | 1 call, cap 8192 |
starved read-until(4001) |
13 calls, cap 4096 | 1 call, cap 8192 |
zero-length write + write-bytes to a starved writer |
5 UBSan reports | 0 |
| UBSan reports over the whole run | 6 | 0 |
The write reports on main are :221:19 and :221:10 in BufReader_write_, and :230:19, :230:10 and :230:35 in BufReader_write_MINUS_bytes_ — including the null pointer passed as argument 2 that the follow-up comment says my review did not quote. It is there, and it is gone on the branch. main's run then dies with an ASan SEGV in BufReader_copy; the branch runs to completion with rc=0 under -fno-sanitize-recover=undefined. Positive control (a deliberate heap-buffer-overflow) aborts with rc=1, so the sanitizers are live rather than silently absent.
The fallback costs what the body says it costs. Under a hard 64-byte allocation ceiling, read-n(4) behaves identically on both headers — 4 reads, final capacity 1 — so the recoverable-OOM property this branch is built on is untouched. The price is one extra failed malloc per fill while the capacity is below the default: 6 allocations of which 4 fail, against main's 2 and 0.
Both teeth claims check out, including the negative one. Reverting only the fill floor gives 50 passed / 1 failed, and the single failure is a reader with no read buffer refills at the default capacity, not one byte per call. Reverting both len > 0 guards leaves the suite at 51 / 0 — so the disclosure that the write-guard test has no teeth without UBSan is accurate rather than a hedge. Both mutants restored byte-identical (md5 checked).
The leak claim, measured better than RSS. LSan really is inert here, so rather than repeat an RSS delta I instrumented CARP_MALLOC/CARP_REALLOC/CARP_FREE to count live blocks exactly. After 200,000 create / read / write / flush / copy / delete cycles: 0 live blocks on the normal path, the starved path, the forced tight-fallback tier, and starved + tight together. That is a stronger statement than the body's RSS table and it agrees with it.
Findings
None. What I went after and could not break:
src/bufio.h:133—BUFIO_DEFAULT_CAP - br->rbuf_lenwould wrap ifrbuf_lencould exceed the default while the capacity is below it. It cannot:rbuf_len <= rbuf_capholds at every site that moves either (fill clamps tospace,copy's tight tier setscap_out = used), and the branch is guarded onrbuf_cap < BUFIO_DEFAULT_CAP. Hand-built a reader at cap 12 / len 12 / pos 0 and filled it: capacity goes 12 → 8192 → 16384, 9012 bytes read, no report. Worth noting the floor lands on total capacity rather than on additional space, which is why the full-buffer case comes out at exactly 8192.- The two-call reserve.
bufio_reserveleaves*bufand*capuntouched on failure, so the one-byte retry after a failed floored request starts from a consistent state, andCARP_REALLOCfailing does not free the original block. mock_read_callsmeasures the fill, not the mock.mock_bufreader_create(..., 0)leaveschunk_sizeat 0 andmock_stream_readonly clamps whenchunk_size > 0, so the counted call really is one 8192-byte request.mock_starve_buffersfrees before nulling and reproduces exactly the stateBufReader_create_now leaves behind.- Every
CARP_MALLOC/CARP_REALLOCin the final header is checked, and the fourmemcpy/memmovesites that can see a NULL buffer are all length-guarded.
The NULL-String contract through the real Carp wrapper, and the copy-keeps-buffered-bytes teeth, I verified last round; this delta touches neither bufio_empty_string, nor the wrapper, nor BufReader_copy.
Verdict: merge
Both findings are fixed rather than claimed fixed — I reproduced every number in the body's table from my own harness, the before values included, and the honesty disclosures survive checking in both directions. The one known gap that remains, that BufReader.copy still cannot report having dropped buffered input under memory pressure, is a design question for the maintainer and not something this PR should grow to cover.
bufio_reserve,bufio_reserve_arrayandread-ncheck their allocations andreturn an error — the convention #4 and #12 set. The seven fixed-size
CARP_MALLOCcalls next to them did not, and every one dereferenced its resulton the next line, so an allocation failure was a NULL dereference:
BufReader_create_rbufbufreader_fillhandsNULL + 0toread_fnBufReader_create_wbufBufReader_write_'smemcpybufio_empty_stringCARP_MALLOC(1)thens[0] = '\0'read-untildelimited sliceCARP_MALLOC(len + 1)thenmemcpyread-untilend-of-stream remainderCARP_MALLOC(avail + 1)thenmemcpyBufReader_copyrbufCARP_MALLOC(cap)thenmemcpyBufReader_copywbufDesign
Four sites just needed a guard. Three had no error channel, and each degrades
differently — this is the part worth arguing about.
BufReader_create_returns aBufReaderby value. A buffer it could notallocate is now recorded as absent: NULL with capacity 0, holding nothing.
bufio_reservealready treats a NULL buffer as a fresh allocation(
CARP_REALLOC(NULL, n)is a malloc), so the buffer appears on first use andthe reader keeps working — an OOM at create becomes a recoverable state instead
of a crash. I did not touch
bufio_next_capand the growth schedule isunchanged, because capacity 0 is unreachable on the normal path: on success the
capacity is still
BUFIO_DEFAULT_CAP, and from there it only doubles. Thedegraded path does not warm back up on its own everywhere, which corrects
what this section said before.
read-untildoes grow geometrically, because itfills before it drains.
read-ndrains the buffer every iteration, sobufio_reserve(cap, used=0, extra=1)findsneed <= have, never grows, and thecapacity stays at 1 — one
read_fncall per byte, permanently, not "O(log n)extra reads once per reader":
read_fncallsrbuf_capread-n(4000)read-n(4000), one-byte requestread-n(4000), floored requestread-n(10), one-byte requestread-n(10), floored requestread-until(4001), one-byte requestread-until(4001), floored requestbufreader_filltherefore floors its request atBUFIO_DEFAULT_CAPwhile thecapacity is below it, and falls back to the one-byte request when that
allocation fails. Under an allocation ceiling that refuses the large request the
two versions behave identically —
read-n(4)takes 4 reads at capacity 1 — sothe recoverable-OOM property this branch is built on is untouched. The
difference is only what happens once memory is available again: the floored
version recovers to 8192 on the next fill, the one-byte version never does.
bufio_empty_stringreturns aStringthe Carp side frees, so it cannotreturn a static
""and cannot return NULL blindly either —read-until'swrapper calls
String.empty?on it. It now returns NULL and downgrades*statustoBUFIO_ERR. The wrapper'scondtests(< status 0)first, sothe NULL is never dereferenced; it is dropped as a dead binding and
String_deleteisfree, which is a no-op on NULL. An allocation failuretherefore surfaces as
Error "read error", which is what it is.BufReader_copyimplements Carp'scopy, which also has no error channel.Recording the copy's buffers as absent would silently lose the bytes the
original had buffered, so it first retries at a capacity sized to the bytes
actually held: a copy made under memory pressure keeps its data at a tighter
capacity and grows back normally, and only drops it if even that fails. The
full-capacity request is still tried first so a copy on the normal path is
byte-for-byte what it was.
Zero-length writes reached
memcpy(br->wbuf + br->wbuf_len, ..., 0)with aNULL
wbuf, which UBSan reports as applying zero offset to null pointer andnull pointer passed as argument 1 — the same pair quoted as before evidence
above, plus argument 2 for
write-bytes, whose emptyArrayhas a NULLdata. It never crashed, because the length is 0, and it is UB onmaintoo.But
maintreats a NULLwbufas a doomed state that segfaults on the nextreal write, while this branch promotes it to a supported steady state — so it is
this branch's to guard. Both
memcpys are now guarded on a non-zero length, theway the rest of the header already guards its
memmoveandbufio_dup_buf'scopy.
The two
read-untilslice failures reportBUFIO_ERRwithout advancingrbuf_pos, so a retry resumes from the same place — the same contract a failedread already had, and the wrapper's doc string already describes it.
Proof
src/bufio.hcompiles standalone, so I drove each site into failure from athrowaway C harness with
CARP_MALLOC/CARP_REALLOCpointing at abudget-limited allocator (the shape of
mock_set_read_limits), under ASan at-O0. The harness is not committed.-O0matters: at-O1clang usesmemcpy'snonnullattribute to concludethe NULL branch is unreachable and deletes it, so four of the seven sites
silently pass an optimised build while segfaulting for real at
-O0.Before — every site a NULL write:
After — clean under ASan and UBSan:
Sites 9 and 10 use a size-limited allocator rather than a budget-limited one, so
the large request fails and the tight one succeeds — that is the only way to
reach
bufio_dup_buf's fallback tier.Correction: LeakSanitizer does not run on this machine, so an earlier version
of this section claimed a result the hardware cannot produce. It said the work
was clean under LSan, with a deliberate leak run first to confirm LSan reports
here. It does not report: these are 32-bit ARM binaries, where LSan is a silent
no-op. A deliberate 4 kB leak exits
rc=0and prints nothing. The ASan positivecontrol — a deliberate heap-buffer-overflow — does abort with
rc=1, so thebefore/after SEGV evidence above stands; it was only the leak half that measured
nothing. Peak RSS (self-reported
VmHWM) over 200 vs 200,000create/read/write/flush/delete cycles instead:
copycopy, tight tier forcedNo growth on any path, including the new tight-fallback tier. The one non-zero
row moves a single page downward across 199,800 extra iterations, which is
allocator noise rather than a measurement of anything.
Tests
carp -x test/bufio.carpis green at 51 passed, 0 failed (was 44).Five tests are added for what is observable from Carp. A failing allocator
cannot be reached from the suite —
CARP_MALLOCis a macro already expandedinside
bufio.hby the timemock_stream.his included, and overriding itearlier would redirect Carp's own allocations too — so these pin the invariant
the create fix produces rather than reproducing the crash.
mock_starve_buffersputs a reader in exactly the state
BufReader_create_now leaves behind whenneither buffer could be allocated, and the tests show it still reads a line,
still writes and flushes, still grows back past the default capacity (30000
bytes through a buffer that started at 0), and still copies.
Being straight about their strength: these five pass against
main's headertoo. They are not regression tests for the NULL dereferences — the ASan
harness above is that. What they do have teeth against is this PR's own
copyrewrite: dropping the carried-over read position turns a copy keeps the bytes
the original had buffered red (48 passed, 1 failed), which I checked.
Two more tests come with the fill floor and the write guard, and they are not
equally strong:
per call counts
read_fncalls through a new mock counter, and hasteeth: reverting the floor alone gives 50 passed, 1 failed, and the single
failure is this test.
usable does not. Reverting both
len > 0guards leaves the suite at 51passed, 0 failed, because the UB is invisible without UBSan and CI does not
run it. That test pins the behavioural contract; the UBSan output is the
actual evidence.
anglerandcarp-fmt --checkpass ontest/bufio.carp, andgendocs.carpproduces no diff. No doc string changed: an allocation failure in
read-untilnow behaves exactly like the stream failure the doc string already describes.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.