Skip to content

Register bufio_next_cap through a Long-typed shim - #9

Merged
hellerve merged 2 commits into
mainfrom
claude/next-cap-long-shim
Aug 21, 2026
Merged

Register bufio_next_cap through a Long-typed shim#9
hellerve merged 2 commits into
mainfrom
claude/next-cap-long-shim

Conversation

@carpentry-agent

@carpentry-agent carpentry-agent Bot commented Aug 17, 2026

Copy link
Copy Markdown

Fixes #6.

test/bufio.carp registered

(register bufio-next-cap (Fn [Long Long] Long) "bufio_next_cap")

against static size_t bufio_next_cap(size_t have, size_t need). Both headers
are 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_t width.

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.h directly:

sizeof(size_t)  = 4
SIZE_MAX        = 4294967295
BUFIO_MAX_CAP   = 2147483647

test arg need   = 2147483648
(size_t)need    = 2147483648          <- fits; no truncation
bufio_next_cap(1073741824, 2147483648) = 0   (test expects 0)

The issue's premise is off by one power of two. 2147483648 is 0x80000000,
which a 32-bit unsigned size_t represents exactly, so nothing truncates and
need > BUFIO_MAX_CAP holds. a request past the maximum is refused passes
here, and it passes for the right reason. The value that wraps to 0 is 2^32:

(size_t)4294967296 = 0
bufio_next_cap(1073741824, 4294967296) = 1073741824   <- returns `have`, not 0

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_cap and the shim instantiated over a 32- and a 64-bit size_t,
called with identical Long arguments (! marks a disagreement):

case                                    old/32      old/64      new/32      new/64
doubles what is there                    16384       16384       16384       16384
meets a larger request                  100000      100000      100000      100000
clamps at the maximum               2147483647  2147483647  2147483647  2147483647
past the maximum (the test's case)           0           0           0           0
past a 32-bit size_t                1073741824           0 !        -1          -1
INT64_MAX                                    0           0          -1          -1
negative need                                0           0          -1          -1
negative have                       4294967295          -1 !        -1          -1
have past a 32-bit size_t                    8  4294967296 !        -1          -1

The current registration disagrees with itself across widths in three cases; the
shim agrees everywhere. Note negative need too: the old form answers 0, 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

static int64_t bufio_next_cap_long(int64_t have, int64_t need) {
  if (have < 0 || need < 0 || have > 0xFFFFFFFFLL || need > 0xFFFFFFFFLL)
    return -1;
  return (int64_t)bufio_next_cap((size_t)have, (size_t)need);
}

It lives in test/mock_stream.h and nothing in src/ changed.

The bound is on representability, not on BUFIO_MAX_CAP. The narrowest
size_t on any target Carp supports is 32-bit, so every value in
[0, 0xFFFFFFFF] round-trips losslessly on every target and goes straight to
the library — including need > BUFIO_MAX_CAP, which the library answers, not
the harness. An earlier revision of this branch bounded on BUFIO_MAX_CAP and
so 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:

  • A negative int64_t argument is not reachable from the library — both
    call sites pass genuine size_t values ((size_t)*cap from an int
    capacity, and buf->capacity / buf->len). It is reachable only from the
    test file, which is precisely where a silent (size_t)-14294967295
    would change what an assertion means without failing it. Guarded, and pinned
    by a new assertion.
  • A size_t result too large for int64_t is genuinely possible in the C
    function: if (need <= have) return have; is unbounded above, so on LP64 a
    have of SIZE_MAX returns SIZE_MAX, which exceeds INT64_MAX. It is
    unreachable through the shim: over [0, 0xFFFFFFFF]² the function returns
    one of 0, have, need, have * 2 or BUFIO_MAX_CAP, and each is at most
    0xFFFFFFFFneed > BUFIO_MAX_CAP short-circuits to 0, and past that
    need <= BUFIO_MAX_CAP, so have * 2 is only taken when
    have <= BUFIO_MAX_CAP / 2 and cannot overflow even a 32-bit size_t. The
    result is therefore always non-negative and never collides with the -1
    sentinel. Swept and confirmed: 15,625 accepted-domain pairs, 0 results outside
    [0, 0xFFFFFFFF], 0 equal to -1, max observed 4294967295, and 0
    disagreements between a 32- and a 64-bit size_t.

Sibling registrations

Checked all eight in test/bufio.carp against their C definitions. Seven match
exactly and are unchanged:

Carp C
(Fn [&String Int] BufReader) mock_bufreader_create(String*, int) ok
(Fn [Int Int Int] ()) mock_set_write_limits(int, int, int) ok
(Fn [&BufReader] Int) mock_buffered_write_len(BufReader*) ok
(Fn [Long Long] Long) bufio_next_cap(size_t, size_t) mismatch
(Fn [] String) mock_get_output(void) ok
(Fn [] Bool) mock_is_closed(void) ok
(Fn [] ()) mock_cleanup(void) ok
(Fn [&(Array Byte)] String) mock_bytes_to_string(Array*) ok

Overlap with #8

Checked explicitly rather than assumed. git merge-tree --write-tree HEAD pr8-head against merge base c2fe630 (current origin/main) exits 0 — no
conflict. #8 edits src/bufio.h (untouched here) and adds an assertion after the
read-n empty-stream case, ~180 lines away from the registration line and the
end-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 on BUFIO_MAX_CAP, which
duplicated bufio_next_cap's own need > BUFIO_MAX_CAP -> return 0 guard
before the call — so a request past the maximum is refused returned from the
shim 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;:

unmutated library guard return 777
shim as first submitted (f1dd9fb) 30 passed / 0 failed, rc 0 30 passed / 0 failed, rc 0 — blind
representability bound (2f1da9c) 30 passed / 0 failed, rc 0 29 passed / 1 failed, rc 1 — a request past the maximum is refused FAILED

need = 2147483648 now reaches bufio_next_cap on both widths and the library
answers 0. The Carp binding is also renamed bufio-next-cap-long so an
assertion names the symbol it calls.

CI gates, locally

angler and carp-fmt were rebuilt against their repos' current HEADs
(angler d1b3e20, carp-fmt 13d3c02) before running.

  • carp -x test/bufio.carp30/30 pass (29 before, plus the new one)
  • angler over the workflow's file set — clean
  • carp-fmt --check over the same set — clean
  • carp -x gendocs.carp — succeeds, no doc diff

On the Lint job: it is green on current main. Run
32017086940
at c2fe630 reports Lint: success on both ubuntu-latest and macos-latest,
and angler is clean locally over the unchanged files too — the two
unused-let-binding findings were resolved by #2 (fc49035) earlier today, so
there is nothing red on main to 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.

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.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@carpentry-agent

Copy link
Copy Markdown
Author

Fixed in 2f1da9c. Both findings; I re-ran the mutation battery myself on both sides of the change rather than taking your numbers.

Finding 1 — the shim answered the case the assertion is about

You are right, and the mutation reproduces exactly. test/mock_stream.h:94 repeated bufio_next_cap's own need > BUFIO_MAX_CAP -> return 0 before the call, so test/bufio.carp:293 (need = 2147483648) returned from the shim and the library was never entered.

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 herecarp -x test/bufio.carp on this armhf Pi, library guard at src/bufio.h:59 mutated to return 777;, src/bufio.h restored byte-identical after each run (md5 checked):

unmutated bufio_next_cap over-max guard → return 777
shim as submitted (f1dd9fb) 30 passed / 0 failed, rc 0 30 passed / 0 failed, rc 0 — blind
representability bound (2f1da9c) 30 passed / 0 failed, rc 0 29 passed / 1 failed, rc 1 — a request past the maximum is refused FAILED

Teeth restored, and all 30 assertions still pass unmutated.

Can the result exceed int64_t, or collide with -1?

Checked by argument and then swept, since this is the part that goes wrong quietly. Over [0, 0xFFFFFFFF]² bufio_next_cap returns one of 0, have, need, have * 2 or BUFIO_MAX_CAP:

  • need > BUFIO_MAX_CAP short-circuits to 0;
  • past that guard need <= BUFIO_MAX_CAP, so next < need ? need : next is at most BUFIO_MAX_CAP;
  • have * 2 is only taken when have <= BUFIO_MAX_CAP / 2, i.e. at most 2147483646 — no overflow even in a 32-bit size_t;
  • need <= have returns have, at most 0xFFFFFFFF.

So the result is in [0, 0xFFFFFFFF] — representable in int64_t, always non-negative, hence never -1. The -1 sentinel cannot be confused with a real answer, and no assertion can be satisfied by the wrong one.

Swept rather than left as reasoning — a probe compiled against this box's headers, running the function over the platform size_t and over a modelled 64-bit one:

sizeof(size_t) = 4   BUFIO_MAX_CAP = 2147483647
accepted-domain pairs checked: 15625
32/64 disagreements:           0
results outside [0,0xFFFFFFFF]: 0
results equal to -1 sentinel:  0
max result observed:           4294967295 (0xFFFFFFFF)
rejected args all answer -1:   yes      (-1, -2, INT64_MIN, 2^32, 2^32+1, INT64_MAX, in either slot)

Points swept: the boundary neighbourhoods (0, 1, 2, 8191/8192/8193, 100000, BUFIO_MAX_CAP/2 ± 1, BUFIO_MAX_CAP - 1, BUFIO_MAX_CAP, 2^31, 2^31 + 1, 0xFFFFFFFF - 1, 0xFFFFFFFF) plus a stride across the whole domain — 125 points, all 15,625 pairs.

One behavioural difference worth naming: have > BUFIO_MAX_CAP is no longer rejected by the harness, so e.g. have = 0xFFFFFFFF, need = 8192 now answers 4294967295 (the library's need <= have -> return have) instead of -1. That is the library's answer, it is the same on both widths, and it is what makes max result observed 0xFFFFFFFF above. No assertion passes such a have; it just stops being the harness's call.

The description's cross-width table is updated for the two rows that moved — past a 32-bit size_t and INT64_MAX now answer -1 on both widths rather than 0, since 2^32 and INT64_MAX are outside what a 32-bit size_t represents. Measured, not adjusted by hand.

Finding 2 — the Carp name

Renamed: (register bufio-next-cap-long (Fn [Long Long] Long) "bufio_next_cap_long"), and all five call sites with it. An assertion now names the symbol it actually calls, so the one answer that is still harness policy (-1, the a request that is not a size is refused case) is visible as such at the call site.

Re-checks


Posted by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:59 mutated to return 777;, src/bufio.h restored byte-identical afterwards (md5 877db7d4 both sides):

    unmutated over-max guard → return 777
    2f1da9c 30 passed / 0 failed, rc 0 29 passed / 1 failed, rc 1a request past the maximum is refused failed

    need = 2147483648 now reaches bufio_next_cap and the library answers 0. 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_t and uint64_t and 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 0
    

    All five assertions mean the same thing on both widths, which is the whole point of the change. BUFIO_MAX_CAP being (size_t)INT_MAX is what makes that work — it is 2147483647 on both widths, so the library's own boundaries do not move either. The result-range argument checks out: past the need > BUFIO_MAX_CAP guard have * 2 is only reached when have <= BUFIO_MAX_CAP / 2, so nothing can exceed 0xFFFFFFFF or collide with the -1 sentinel.

  • The rename is complete — no bufio-next-cap without the -long suffix survives anywhere in test/, one register plus five call sites.

  • Naming the have > BUFIO_MAX_CAP behaviour change (0xFFFFFFFF, 8192 now answers 4294967295 instead 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_CAPreturn 777 29/1 — a request past the maximum is refused failed ✓
have * 2have * 3 29/1 — the next capacity doubles what is there failed ✓
return next < need ? need : nextreturn 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.

@hellerve
hellerve merged commit 2752316 into main Aug 21, 2026
2 checks passed
@hellerve
hellerve deleted the claude/next-cap-long-shim branch August 21, 2026 02:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test registers bufio_next_cap with Long where C takes size_t

1 participant