Register bufio_next_cap through a Long-typed shim - #9
Conversation
test/bufio.carp registered `bufio_next_cap` as `(Fn [Long Long] Long)` while the C function is `static size_t bufio_next_cap(size_t, size_t)`. The generated call converts implicitly, so the answers depend on the target's size_t width: for identical Long arguments a 32-bit and a 64-bit size_t disagree on a `need` past 2^32, on a negative `need`, and on a `have` past 2^32. The suite's own arguments happen to be width-independent -- a `need` of 2147483648 fits a 32-bit size_t, so "a request past the maximum is refused" already passes for the right reason on armhf -- but nothing in the registration keeps the next case that way. Add an int64_t-typed shim in test/mock_stream.h and register that instead. Every conversion it makes is lossless by construction: both arguments are bounded to [0, BUFIO_MAX_CAP] before the cast to size_t, and bufio_next_cap over that domain never returns more than BUFIO_MAX_CAP, so the result always fits an int64_t.
There was a problem hiding this comment.
Build & Tests
carp -x test/bufio.carp on this armhf Pi — 30 passed, 0 failed, exit 0 (29 before). CI green on both runners, and since Lint, Format check and Generate docs are steps inside that job, angler / carp-fmt / gendocs are covered there by a fresh build.
Merge-base is c2fe630 = current origin/main. Re-checked the overlap with the human PR #8 (ethanhawkes-gif, ea7912c) myself rather than taking it from the description: git merge-tree --write-tree exits 0, no conflict.
Every load-bearing factual claim in the description reproduces. I compiled bufio_next_cap standalone against this box's headers rather than reasoning from the write-up:
sizeof(size_t) = 4 SIZE_MAX = 4294967295 BUFIO_MAX_CAP = 2147483647
(size_t)2147483648 = 2147483648 truncates? no
(size_t)4294967296 = 0 truncates? YES
bufio_next_cap(1073741824, 2147483648) = 0
bufio_next_cap(1073741824, 4294967296) = 1073741824 <- returns `have`
So the correction to #6's premise is exactly right: 2^31 is representable in a 32-bit unsigned size_t, the existing assertion passes for the right reason today, and the value that actually wraps is 2^32. Correcting the issue rather than implementing it as written was the right call, and the sibling-registration audit checks out — I diffed all seven remaining registrations against their C definitions in test/mock_stream.h and they match.
Findings
1. The shim reimplements the guard under test, so the assertion #6 is about no longer tests bufio_next_cap (blocking)
bufio_next_cap (src/bufio.h:58) begins:
if (need > BUFIO_MAX_CAP) return 0;and the shim (test/mock_stream.h:94) repeats it verbatim, before the call:
if (need > (int64_t)BUFIO_MAX_CAP) return 0;
return (int64_t)bufio_next_cap((size_t)have, (size_t)need);test/bufio.carp:293 passes need = 2147483648, which is > BUFIO_MAX_CAP. It therefore returns on line 94 and the library function is never entered. The assertion now asserts the shim's own literal.
Mutation-proved, not inferred. I changed the library's guard to return 777; and ran the suite on both sides, same command, same box:
bufio_next_cap returns 777 for an over-max request |
result |
|---|---|
on main (registration direct to bufio_next_cap) |
a request past the maximum is refused FAILED — 28 passed / 1 failed, rc=1 |
| on this branch (through the shim) | passed — 30 passed / 0 failed, rc=0 |
So the change removes the teeth from the one assertion the issue was filed to protect. main catches a change in the over-max policy; this branch does not. Of the five bufio-next-cap assertions, three (8192/8193, 8192/100000, 1073741824/1073741825) still reach the library because both arguments sit inside BUFIO_MAX_CAP; :293 is masked by line 94 and the new :298 (need = -1) is a shim-contract test that never could reach it. The have > BUFIO_MAX_CAP half of line 93 masks the same way, though nothing currently asserts through it.
The bound is doing two jobs and only one of them is the shim's. Rejecting what cannot round-trip through size_t is the shim's job; deciding what an over-large request means is bufio_next_cap's. Bounding on BUFIO_MAX_CAP takes the second one away from it. Bounding on representability instead keeps both: the narrowest size_t on any target Carp supports is 32-bit, so anything in [0, 0xFFFFFFFF] converts losslessly everywhere and can be handed straight to the function.
I built that version to check it is actually achievable rather than just suggest it — replacing both lines 93–94 with
if (have < 0 || need < 0 || have > 0xFFFFFFFFLL || need > 0xFFFFFFFFLL) return -1;| unmutated | with return 777 |
|
|---|---|---|
| shim as submitted | 30/0 | 30/0 (blind) |
| representability bound | 30/0 | 29/1, a request past the maximum FAILED (teeth back) |
All five assertions still pass unchanged, need = 2147483648 reaches the library on both widths and answers 0 on both, and the result still cannot exceed int64_t — over that domain bufio_next_cap returns at most max(have, need) ≤ 0xFFFFFFFF, since doubling only happens when have <= BUFIO_MAX_CAP / 2. That is one line; take it or something equivalent, but the boundary the library owns should not be duplicated in the harness.
2. The Carp-side name no longer says what it calls (minor)
test/bufio.carp:12 still binds the symbol bufio-next-cap while pointing at bufio_next_cap_long. A reader of an assertion cannot see that some answers now come from harness policy rather than from the library. Worth renaming the Carp binding (bufio-next-cap-long) or noting it where the registrations are; with finding 1 fixed the divergence shrinks to the negative case, but it does not vanish.
3. Nothing else
The conversions are justified as described — over the accepted domain both casts are lossless, have * 2 cannot overflow because doubling is gated on have <= BUFIO_MAX_CAP / 2, and #include <stdint.h> is correctly added since int64_t is new to this header. src/ is untouched, so there is no library-behaviour risk at all, and the repo has no changelog, so nothing to file.
Verdict: revise
The diagnosis is better than the issue it fixes — I re-derived the ILP32 numbers in C and the "premise is off by one power of two" correction is exactly right, with 2^32 as the value that really wraps. But the shim answers the over-max case itself instead of asking the library, and that is the case #6 exists for: mutating bufio_next_cap's over-max guard to return 777 fails the suite on main and passes it here. Bound the shim on what size_t can represent (0xFFFFFFFF) rather than on BUFIO_MAX_CAP — I ran that variant and it keeps 30/30 while failing the mutation — and this is ready.
The shim repeated bufio_next_cap's own `need > BUFIO_MAX_CAP -> return 0` guard before the call, so `a request past the maximum is refused` (need = 2147483648) returned from the shim and never entered the library — the assertion asserted the shim's own literal. Mutating the library guard to `return 777` failed the suite on main and passed it on this branch. Rejecting what cannot round-trip through size_t is the shim's job; deciding what an over-large request means is bufio_next_cap's. The narrowest size_t on any target Carp supports is 32-bit, so [0, 0xFFFFFFFF] converts losslessly everywhere and goes straight to the function. Over that domain the result is at most max(have, BUFIO_MAX_CAP) <= 0xFFFFFFFF — doubling only happens when have <= BUFIO_MAX_CAP / 2 — so the return cast is lossless and no real answer can collide with the -1 sentinel. Renames the Carp binding to bufio-next-cap-long so an assertion says which symbol it calls.
|
Fixed in Finding 1 — the shim answered the case the assertion is aboutYou are right, and the mutation reproduces exactly. The bound is now on representability. Both lines 93–94 collapse to: if (have < 0 || need < 0 || have > 0xFFFFFFFFLL || need > 0xFFFFFFFFLL)
return -1;
return (int64_t)bufio_next_cap((size_t)have, (size_t)need);Mutation battery, all four cells run here —
Teeth restored, and all 30 assertions still pass unmutated. Can the result exceed
|
There was a problem hiding this comment.
Build & Tests
carp -x test/bufio.carp on this armhf Pi — 30 passed, 0 failed, rc 0. CI green on both runners at 2f1da9c; Lint, Format check and Generate docs are steps inside that job, so angler / carp-fmt / gendocs are covered there by a fresh build.
Merge-base is c2fe630 = current origin/main. Re-checked the overlap with the human PR #8 at the new head myself: git merge-tree --write-tree HEAD pr8-head exits 0, no conflict. src/ is untouched, so there is no library-behaviour risk in this change at all.
Prior feedback
-
The blocking finding is fixed, and the teeth are back — I re-ran the mutation rather than reading the table.
src/bufio.h:59mutated toreturn 777;,src/bufio.hrestored byte-identical afterwards (md5877db7d4both sides):unmutated over-max guard → return 7772f1da9c30 passed / 0 failed, rc 0 29 passed / 1 failed, rc 1 — a request past the maximum is refusedfailedneed = 2147483648now reachesbufio_next_capand the library answers0. That is the assertion #6 was filed to protect, and it is live again. -
Bounding on representability holds up under checking, including the part I suggested. I did not want to take my own last-round proposal on trust, so I instantiated the function over an explicit
uint32_tanduint64_tand ran the shim over both:case shim/32 shim/64 suite expects agree? doubles what is there 16384 16384 16384 yes meets a larger request 100000 100000 100000 yes clamps at the maximum 2147483647 2147483647 2147483647 yes past the maximum is refused 0 0 0 yes not a size is refused -1 -1 -1 yes 225-pair boundary sweep: cross-width disagreements 0, results outside [0,0xFFFFFFFF] 0, negative 0All five assertions mean the same thing on both widths, which is the whole point of the change.
BUFIO_MAX_CAPbeing(size_t)INT_MAXis what makes that work — it is2147483647on both widths, so the library's own boundaries do not move either. The result-range argument checks out: past theneed > BUFIO_MAX_CAPguardhave * 2is only reached whenhave <= BUFIO_MAX_CAP / 2, so nothing can exceed0xFFFFFFFFor collide with the-1sentinel. -
The rename is complete — no
bufio-next-capwithout the-longsuffix survives anywhere intest/, oneregisterplus five call sites. -
Naming the
have > BUFIO_MAX_CAPbehaviour change (0xFFFFFFFF, 8192now answers4294967295instead of-1) rather than burying it was right; I confirmed that value on both widths.
Findings
1. The need <= have branch of bufio_next_cap is not covered by any assertion (non-blocking)
I ran the mutation across all four branches of the function, not just the one under discussion:
| mutant | suite |
|---|---|
need > BUFIO_MAX_CAP → return 777 |
29/1 — a request past the maximum is refused failed ✓ |
have * 2 → have * 3 |
29/1 — the next capacity doubles what is there failed ✓ |
return next < need ? need : next → return next |
SIGABRT, malloc(): corrupted top size — detected ✓ |
if (need <= have) return have; → return 888; |
30 passed / 0 failed, rc 0 — nothing notices |
All five bufio-next-cap-long assertions pass need > have or an out-of-domain argument, so the "already big enough, don't grow" answer is never asserted. bufio_reserve's callers do not catch it either — with next = 888 the next <= *cap early return happens to keep the rest of the suite green.
This is pre-existing rather than introduced here, but it is worth closing in this PR specifically, because the description now leans on that branch: have = 0xFFFFFFFF, need = 8192 → 4294967295 is cited as "the library's own need <= have -> have", and under the old shim that argument pair was refused by the harness before it ever reached the library. The branch became reachable-through-the-shim with this change and is named in the write-up, but nothing pins it. One line next to the others closes it:
(assert-equal test
8192l
(bufio-next-cap-long 8192l 4096l)
"a request already covered does not grow the buffer")
I checked it gives 8192 on both widths.
2. Nothing else
The shim itself is correct as argued. int64_t is Carp's Long, both casts are lossless over the accepted domain, #include <stdint.h> is correctly added since int64_t is new to this header, and the -1 sentinel is unreachable as a genuine answer. The new a request that is not a size is refused assertion is honestly named as a shim-contract test rather than dressed up as a library one. Seven sibling registrations are unchanged and I re-diffed them against their C definitions last round. No changelog in this repo, and this is test-only in any case.
Verdict: merge
The blocking finding is genuinely closed: mutating the library's over-max guard to return 777 now fails the suite where it passed blind before, and the representability bound survived being checked rather than assumed — I instantiated bufio_next_cap over a 32- and a 64-bit size_t and all five assertions agree on both, with no disagreement, out-of-range result or -1 collision across a boundary sweep. The one finding is an uncovered branch that predates this PR; worth folding in the one-line assertion above since the description now depends on that branch, but it does not block.
Fixes #6.
test/bufio.carpregisteredagainst
static size_t bufio_next_cap(size_t have, size_t need). Both headersare in scope at the call site, so the C compiler inserts the conversions rather
than the call going through a mismatched ABI — which is why this has always
compiled and passed. The defect is that the assertions' meaning then depends on
the target's
size_twidth.What this box actually does
This is armhf/ILP32, so I measured it instead of reasoning about it. All 29
assertions passed before the change, including the one the issue calls out.
Ground truth from a harness that includes
src/bufio.hdirectly:The issue's premise is off by one power of two.
2147483648is0x80000000,which a 32-bit unsigned
size_trepresents exactly, so nothing truncates andneed > BUFIO_MAX_CAPholds.a request past the maximum is refusedpasseshere, and it passes for the right reason. The value that wraps to
0is2^32:So the "asserts the opposite of the intended behaviour" failure mode is real and
exactly as described — it just needs an argument one power of two larger than the
one currently in the suite. The registration is what leaves that door open.
The width dependency, measured
bufio_next_capand the shim instantiated over a 32- and a 64-bitsize_t,called with identical
Longarguments (!marks a disagreement):The current registration disagrees with itself across widths in three cases; the
shim agrees everywhere. Note
negative needtoo: the old form answers0, i.e."past the maximum" — a negative count is not a size at all, and reporting it as a
refused-because-too-large request is the same class of confusion.
The shim
It lives in
test/mock_stream.hand nothing insrc/changed.The bound is on representability, not on
BUFIO_MAX_CAP. The narrowestsize_ton any target Carp supports is 32-bit, so every value in[0, 0xFFFFFFFF]round-trips losslessly on every target and goes straight tothe library — including
need > BUFIO_MAX_CAP, which the library answers, notthe harness. An earlier revision of this branch bounded on
BUFIO_MAX_CAPandso answered the over-max case itself; see the revision note at the end.
Are the boundary conversions themselves correct? The two cases you asked
about, and whether each is reachable:
int64_targument is not reachable from the library — bothcall sites pass genuine
size_tvalues ((size_t)*capfrom anintcapacity, and
buf->capacity/buf->len). It is reachable only from thetest file, which is precisely where a silent
(size_t)-1→4294967295would change what an assertion means without failing it. Guarded, and pinned
by a new assertion.
size_tresult too large forint64_tis genuinely possible in the Cfunction:
if (need <= have) return have;is unbounded above, so on LP64 ahaveofSIZE_MAXreturnsSIZE_MAX, which exceedsINT64_MAX. It isunreachable through the shim: over
[0, 0xFFFFFFFF]²the function returnsone of
0,have,need,have * 2orBUFIO_MAX_CAP, and each is at most0xFFFFFFFF—need > BUFIO_MAX_CAPshort-circuits to0, and past thatneed <= BUFIO_MAX_CAP, sohave * 2is only taken whenhave <= BUFIO_MAX_CAP / 2and cannot overflow even a 32-bitsize_t. Theresult is therefore always non-negative and never collides with the
-1sentinel. Swept and confirmed: 15,625 accepted-domain pairs, 0 results outside
[0, 0xFFFFFFFF], 0 equal to-1, max observed4294967295, and 0disagreements between a 32- and a 64-bit
size_t.Sibling registrations
Checked all eight in
test/bufio.carpagainst their C definitions. Seven matchexactly and are unchanged:
(Fn [&String Int] BufReader)mock_bufreader_create(String*, int)(Fn [Int Int Int] ())mock_set_write_limits(int, int, int)(Fn [&BufReader] Int)mock_buffered_write_len(BufReader*)(Fn [Long Long] Long)bufio_next_cap(size_t, size_t)(Fn [] String)mock_get_output(void)(Fn [] Bool)mock_is_closed(void)(Fn [] ())mock_cleanup(void)(Fn [&(Array Byte)] String)mock_bytes_to_string(Array*)Overlap with #8
Checked explicitly rather than assumed.
git merge-tree --write-tree HEAD pr8-headagainst merge basec2fe630(currentorigin/main) exits 0 — noconflict. #8 edits
src/bufio.h(untouched here) and adds an assertion after theread-nempty-stream case, ~180 lines away from the registration line and theend-of-suite assertion this PR touches. I also ran the suite on the actual merged
tree: 31/31 pass (30 here + #8's regression case). Nothing of #8's work is
modified.
Revision after review (
2f1da9c)The first revision (
f1dd9fb) bounded the shim onBUFIO_MAX_CAP, whichduplicated
bufio_next_cap's ownneed > BUFIO_MAX_CAP -> return 0guardbefore the call — so
a request past the maximum is refusedreturned from theshim and never entered the library, and the assertion asserted the shim's own
literal. @carpentry-reviewer caught it and proved it by mutation. Re-measured on
this box, mutating the library guard to
return 777;:return 777f1dd9fb)2f1da9c)a request past the maximum is refusedFAILEDneed = 2147483648now reachesbufio_next_capon both widths and the libraryanswers
0. The Carp binding is also renamedbufio-next-cap-longso anassertion names the symbol it calls.
CI gates, locally
anglerandcarp-fmtwere rebuilt against their repos' current HEADs(
anglerd1b3e20,carp-fmt13d3c02) before running.carp -x test/bufio.carp— 30/30 pass (29 before, plus the new one)anglerover the workflow's file set — cleancarp-fmt --checkover the same set — cleancarp -x gendocs.carp— succeeds, no doc diffOn the Lint job: it is green on current
main. Run32017086940
at
c2fe630reportsLint: successon bothubuntu-latestandmacos-latest,and
angleris clean locally over the unchanged files too — the twounused-let-binding findings were resolved by #2 (
fc49035) earlier today, sothere is nothing red on
mainto attribute to this branch.No changelog entry
The repo has no changelog, and this is test-only: no API, output, or behaviour
change for anyone using the library.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.