Tell a failed read from a closed stream - #10
Conversation
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.
There was a problem hiding this comment.
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-identical — git 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 failed — read-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 failed — read-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.
|
@/tmp/claude-1000/-home-hellerve-claude-carpentry/2c2a1e71-401d-4fe4-9043-18d637ce6618/scratchpad/bufio10_comment.md |
|
Correction to my previous comment on this PR: it posted a local file path instead of the file's The point was about downstream impact, and @carpentry-reviewer's measurement of it is the accurate Nothing in the code changes as a result; only the framing does. The PR still stands on the bug |
Closes #7.
The bug
bufreader_fillhas told end of stream (0) apart from failure (-1) since #4, but every caller collapsed both intoif (r <= 0). So:read-untilhanded the buffered prefix back as a complete result, andread-linereturned a fragment that looks like a whole line;read-nbroke out of its loop and returned a short array that looks like a short EOF read;"connection closed"whether the stream had closed or blown up;read-appendreturnedread_fn's value straight through, so0was aSuccess(correct),-1anError(correct), and any other negative silently aSuccesscarrying a negative byte count.The C→Carp boundary: a status out-parameter
The three read shims now take an
int* status:with
BUFIO_OK/BUFIO_EOF/BUFIO_ERR, and the Carp wrappers pass a localstatus— the shape sockets #9 adopted forTcpStream/UnixStream, which is whatbufiosits on top of.I considered a field on
BufReaderwith a registered accessor instead, and rejected it:BufReader_copywould have to decide whether a copy inherits the original's last error.src/bufio.h; an out-parameter changes only the three functions that need it.private/hiddenshims.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 advancingrbuf_pos, andbufreader_fill's compaction only ever discards whatrbuf_poshas 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 failedflush. 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 aresultarray thatResult.Errorcannot carry. It could be made resumable by buffering allnbytes inrbufbefore 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
read-line, delimiter foundSuccess lineread-line, stream ends mid-lineSuccess remainderread-line, stream ends, nothing bufferedError "connection closed"read-line, stream fails mid-lineSuccess fragmentError "read error", prefix keptread-line, stream fails, nothing bufferedError "connection closed"Error "read error"read-n, short at EOFSuccessshort arrayread-n, stream failsSuccessshort arrayError "read error"read-append, stream endedSuccess 0read-append, negative other than -1Success -5Error "read error"Every public signature is unchanged and the three changed registrations are
private+hidden, sosockets(onbufio@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.hgained the read-side mirror of the write knobs it already had:mock_set_read_limits(budget, fail_code), where the stream hands outbudgetbytes and then returnsfail_codefrom 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):read-untilcollapses the error path back into EOF (r <= 0)read-nreports a failure as EOFread-untilconsumes the buffered prefix on errorread-appendonly treats-1as a failureread-appendtreats end of stream as a failureread-nchecks emptiness before statusread-linechecks emptiness before statusread-appendnever treats a stream code as a failureread-untilreports a clean end of stream as a failureM9 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-treeagainst this branch's head.#9 (
claude/next-cap-long-shim) — no conflict. It touchestest/mock_stream.habove my hunk and thebufio-next-capassertions at the tail oftest/bufio.carp; I touch neither. I ran the merged tree locally: 39 passing.#8 (
fix/read-n-negative-count, @ethanhawkes-gif) — one conflict, insrc/bufio.honly;test/bufio.carpauto-merges. Both PRs edit the prologue ofBufReader_read_MINUS_n_immediately afterresult.len = 0;: #8 inserts the non-positive-nguard 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'sif (!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.