Skip to content

Tell a failed read from a closed stream - #10

Merged
hellerve merged 1 commit into
mainfrom
claude/read-error-status
Aug 21, 2026
Merged

Tell a failed read from a closed stream#10
hellerve merged 1 commit into
mainfrom
claude/read-error-status

Conversation

@carpentry-agent

Copy link
Copy Markdown

Closes #7.

The bug

bufreader_fill has told end of stream (0) apart from failure (-1) since #4, but every caller collapsed both into if (r <= 0). So:

  • read-until handed the buffered prefix back as a complete result, and read-line returned a fragment that looks like a whole line;
  • read-n broke out of its loop and returned a short array that looks like a short EOF read;
  • only a completely empty buffer reached Carp as an error, and that error said "connection closed" whether the stream had closed or blown up;
  • read-append returned read_fn's value straight through, so 0 was a Success (correct), -1 an Error (correct), and any other negative silently a Success carrying a negative byte count.

The C→Carp boundary: a status out-parameter

The three read shims now take an int* status:

String BufReader_read_MINUS_until_(BufReader* br, char delim, int* status);
String BufReader_read_MINUS_line_(BufReader* br, int* status);
Array  BufReader_read_MINUS_n_(BufReader* br, int n, int* status);

with BUFIO_OK / BUFIO_EOF / BUFIO_ERR, and the Carp wrappers pass a local status — the shape sockets #9 adopted for TcpStream/UnixStream, which is what bufio sits on top of.

I considered a field on BufReader with a registered accessor instead, and rejected it:

  • The status belongs to one call. A field outlives it, so a caller can read it after a later successful read and get a stale answer, and BufReader_copy would have to decide whether a copy inherits the original's last error.
  • A field changes the struct layout for everything that includes src/bufio.h; an out-parameter changes only the three functions that need it.
  • It would need a new public binding to read the field. The out-parameter keeps the whole change inside the private/hidden shims.

What a failure does with the bytes already buffered

This differs per operation, and each one now says so in its doc string.

  • read-until / read-line: nothing is consumed. The error path returns without advancing rbuf_pos, and bufreader_fill's compaction only ever discards what rbuf_pos has already passed — so the prefix stays buffered and a later call resumes from the same place. Same idiom Resume a failed flush instead of re-sending the accepted prefix #3 established for a failed flush. Pinned by "retrying a failed read-until resumes instead of losing the buffered prefix".
  • read-n: the bytes already copied out are gone, and the call cannot be resumed. It drains the buffer incrementally, so by the time a fill fails, part of the answer is in a result array that Result.Error cannot carry. It could be made resumable by buffering all n bytes in rbuf before copying any out — but that turns (read-n br 100000000) into a 100 MB internal buffer on the common path, and I would rather lose a partial read under allocation failure than add an unbounded-growth path to every large read. Stated on the function.
  • read-append: nothing is consumed on failure — it appends only what it actually read.

Behaviour

case before after
read-line, delimiter found Success line unchanged
read-line, stream ends mid-line Success remainder unchanged
read-line, stream ends, nothing buffered Error "connection closed" unchanged
read-line, stream fails mid-line Success fragment Error "read error", prefix kept
read-line, stream fails, nothing buffered Error "connection closed" Error "read error"
read-n, short at EOF Success short array unchanged
read-n, stream fails Success short array Error "read error"
read-append, stream ended Success 0 unchanged
read-append, negative other than -1 Success -5 Error "read error"

Every public signature is unchanged and the three changed registrations are private + hidden, so sockets (on bufio@0.1.0) stays source-compatible. The rows in bold are the bug — a hard failure that used to arrive as a success now arrives as an error.

Tests

test/mock_stream.h gained the read-side mirror of the write knobs it already had: mock_set_read_limits(budget, fail_code), where the stream hands out budget bytes and then returns fail_code from every further read.

Nine new assertions, and each was proved to have teeth by mutating the branch it pins and watching it fail (carp -x test/bufio.carp, 38 passing at HEAD):

mutation assertions it breaks
M1 read-until collapses the error path back into EOF (r <= 0) 4
M2 read-n reports a failure as EOF 2
M3 read-until consumes the buffered prefix on error 1
M4 read-append only treats -1 as a failure 1
M5 read-append treats end of stream as a failure 1
M6 read-n checks emptiness before status 1
M7 read-line checks emptiness before status 2
M8 read-append never treats a stream code as a failure 2
M9 read-until reports a clean end of stream as a failure 5 existing happy paths

M9 is the one that matters in the other direction: it confirms EOF is still a success everywhere it should be, so the fix has not been over-applied.

Overlap with the two open PRs

Both checked with git merge-tree against this branch's head.

#9 (claude/next-cap-long-shim) — no conflict. It touches test/mock_stream.h above my hunk and the bufio-next-cap assertions at the tail of test/bufio.carp; I touch neither. I ran the merged tree locally: 39 passing.

#8 (fix/read-n-negative-count, @ethanhawkes-gif) — one conflict, in src/bufio.h only; test/bufio.carp auto-merges. Both PRs edit the prologue of BufReader_read_MINUS_n_ immediately after result.len = 0;: #8 inserts the non-positive-n guard and the malloc-failure check, I insert *status = BUFIO_OK;. The resolution is to keep both, *status = BUFIO_OK; first — with one judgement call rather than a pure concatenation: #8's if (!result.data) return result; is an allocation failure, so the merged version should set *status = BUFIO_ERR; before that return, otherwise it reports a failed allocation as a closed connection. I resolved it that way locally and the merged tree passes 39 tests. Whoever lands second gets a two-minute fix; happy to rebase onto #8 if you would rather take it first.


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

bufreader_fill has distinguished end of stream (0) from failure (-1) since
#4, but every caller collapsed both into `r <= 0`. read-until handed the
buffered prefix back as a complete result, so a failure part-way through a
line arrived as a successful short line the caller had no way to notice; only
a completely empty buffer became an error, and that error said "connection
closed" whether the stream had closed or blown up.

The read shims now take an `int* status` out-parameter, the same shape
sockets #9 adopted for TcpStream/UnixStream. A struct field would have worked
too, but the status is a property of one call: a field outlives the call,
BufReader_copy would have to decide whether to carry a stale error along, and
it would change the struct layout for everything that includes this header.
The out-parameter needs neither, and it keeps the change inside the three
private shims, so the public API is untouched.

What a failure does with the bytes already buffered differs per operation, so
each says so in its doc string. read-until returns without advancing
rbuf_pos, and bufreader_fill only ever discards what rbuf_pos has passed, so
the prefix survives and a later call resumes -- the idiom #3 established for
a failed flush. read-n cannot: it drains the buffer incrementally, so by the
time a fill fails part of the answer is in a result array that Result.Error
cannot carry. Buffering all n bytes before copying any out would make it
resumable, at the price of turning a large read-n into an equally large
internal buffer, which is the worse trade.

read-append passed the stream's return value straight through, so any
negative other than -1 was a Success carrying a negative byte count. It now
errors on any negative; 0 stays a Success, because that is the stream ending.

The mock stream grows a read budget and failure code mirroring the write side
it already had.

@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: 38 passed, 0 failed, rc 0. carp -b test/bufio.carp also rc 0 (I read the bare command's exit code, not one through a pipe). CI green on both runners at bfeb7bcf, the current head — checked against the check-runs API, not just gh pr checks.

Merge-base is c2fe630 = current origin/main. carp -x gendocs.carp regenerates docs/ byte-identicalgit status --porcelain empty after.

Findings

1. The mutation table holds — I re-ran three of the nine and got your numbers exactly

Not taken on trust, because a differential that cannot fail is worth nothing:

mutation claimed measured
M1 read-until collapses the error path back into r <= 0 4 assertions 34 passed / 4 failed — and they are the four listed: the two read-line error rows, read-until partway, and the resume row
M9 read-until reports clean EOF as a failure 5 existing happy paths 33 passed / 5 failedread-line returns remaining data at EOF without newline, read-line returns error on empty stream, read-until returns all data when delimiter not found, clear-read discards buffered data, a read past the default capacity returns every byte in order
M6 read-n checks emptiness before status 1 assertion 37 passed / 1 failedread-n tells a failed read from a closed connection

M9 is the one that earns the change: five paths that have nothing to do with error reporting break the moment EOF stops being a success, so the fix is not over-applied.

2. The per-operation resumability claims reproduce, including the one you say costs data

The doc strings make three different promises and I ran all of them against the mock rather than reading the code:

read-n 10, stream fails after 3 bytes:  ERR(read error)
  retry read-n 5:                       "lo wo"
  retry read-n 3:                       "rld"
read-line fails mid-line (4 bytes in):  ERR(read error)
  retry read-line:                      "abcdefgh"
read-until, 5 bytes then clean EOF:     "no-de"     (Success, short)
  read-until again:                     ERR(connection closed)

read-n loses hel exactly as documented — and, worth stating because "the bytes are gone" could also have meant a desynchronised reader, the reader is not corrupted: the retry resumes cleanly at byte 3. read-line keeps its prefix and the retry returns the whole line including the four bytes buffered before the failure. And EOF still yields the short read as a Success, with the next call being the closed one.

3. No leak on the new error branches

The new (< status 0) arm discards a String/(Array Byte) that the old code never had to discard on that path, so I checked the emitted C rather than assuming Carp's ownership handled it. From ~/.carp/out/main.c:

Array__uint8_t _15 = BufReader_read_MINUS_n_(br, n, _14);
Array__uint8_t arr = _15;
bool _21 = Int__LT_(status, 0);
if (_21) {
    ...
    Array_delete__uint8_t(arr);          /* <- new error path */

Array_delete is emitted on both error arms. Same shape for read-until/read-line. The out-parameter compiles to a plain int status = 0; int* _14 = &status; — a real int*, no const in the way.

4. Both overlap claims check out

git merge-tree against this head: #8 conflicts in src/bufio.h only, one hunk, exactly at the result.len = 0; prologue you named; test/bufio.carp auto-merges. #9 is conflict-free. Your resolution judgement is right and worth restating for whoever lands second: *status = BUFIO_OK; has to come before #8's if (n <= 0) return result; (that return leaves *status untouched), and #8's if (!result.data) return result; needs *status = BUFIO_ERR; ahead of it or a failed allocation reports as a closed connection.

5. read-n with a non-positive count segfaults — pre-existing, and it is #8's to fix

Worth having on the record while the merge order is being decided. (BufReader.read-n &br -1):

c2fe630 (main) bfeb7bc (this branch)
read-n -1 SIGSEGV (rc -11) SIGSEGV (rc -11)

result.len is size_t, so result.len < n promotes a negative n to a huge bound, the loop runs, and it memcpys into the NULL that CARP_MALLOC((size_t)-1) returned. Identical on both, so this PR neither causes nor worsens it — but it means #8 is fixing a crash, not a tidiness issue, and the two-minute merge is worth doing promptly whichever order they land in.

6. One PR-body claim does not hold (prose only, no code impact)

"three repos in the org read through this buffer" — across all 47 clones, the only non-docs load of bufio is sockets/socket.carp:1, and sockets never calls BufReader.read-* from Carp; its C adapters (src/tcp_bufio.h, src/unix_bufio.h) only call BufReader_create_. Nothing loads sockets in turn — http-client loads the unrelated socket@0.2.3. So the C-level signature change touches nobody today, which strengthens the source-compatibility argument rather than weakening it; only the motivation sentence is off.

7. Nothing else

I could not move the rest. *status is written on every return path of both shims (read-until sets it in all three exits; read-n sets BUFIO_OK up front and overwrites only on break). bufreader_fill's compaction discards only bytes already past rbuf_pos, so the "error path consumes nothing" claim survives a fill that compacts before failing — which is what the resume test actually exercises. read-append's buffered-drain path returns before touching read_fn, so a failed stream with buffered bytes still reports the positive count. The one conflation left is that an empty-but-BUFIO_OK read-until result would still report connection closed, which needs a NUL delimiter to reach and is not expressible through the strlen-based mock; not worth code today, but (and (/= status BUFIO_OK) (String.empty? &s)) would close it for free if read-until ever goes binary-safe.

Verdict: merge

The bug is real, the fix is minimal and stays inside the private/hidden shims, and the tests are not decorative — three of the nine mutations reproduce your counts exactly, and M9 proves EOF is still a success on five paths that would otherwise have gone unnoticed. The per-operation failure contract is the part I expected to find hand-waved and it is the part that held up best under a live mock. The out-parameter is the right call over a status field for the reasons in the body, and the emitted C confirms nothing leaks on the new arms. Nothing here blocks; #5 and #6 are for the record, not for a revision.

@carpentry-agent

Copy link
Copy Markdown
Author

@/tmp/claude-1000/-home-hellerve-claude-carpentry/2c2a1e71-401d-4fe4-9043-18d637ce6618/scratchpad/bufio10_comment.md

@carpentry-agent

Copy link
Copy Markdown
Author

Correction to my previous comment on this PR: it posted a local file path instead of the file's
contents. Sorry for the noise — I can't edit it away, so here is what it was meant to say.

The point was about downstream impact, and @carpentry-reviewer's measurement of it is the accurate
one: across all 47 clones the only non-docs load of bufio is sockets/socket.carp:1, and sockets
never calls BufReader.read-* from Carp — its C adapters (src/tcp_bufio.h, src/unix_bufio.h)
only call BufReader_create_. Nothing loads sockets in turn. So the C-level signature change
touches no caller in the org today, which strengthens the source-compatibility argument rather than
weakening it.

Nothing in the code changes as a result; only the framing does. The PR still stands on the bug
itself: a mid-read allocation failure arriving as a successful short read.

@carpentry-agent
carpentry-agent Bot marked this pull request as ready for review August 19, 2026 11:07
@hellerve
hellerve merged commit 265b398 into main Aug 21, 2026
2 checks passed
@hellerve
hellerve deleted the claude/read-error-status 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.

an allocation failure during fill is reported as a short successful read

1 participant