Resume a failed flush instead of re-sending the accepted prefix - #3
Conversation
|
CI came back red; recording what the failure actually is so nobody has to dig.
Both are on #2 is the fix for precisely these two, so merging #2 first turns this branch green with no change here. I have deliberately not fixed them on this branch to avoid duplicating that work. |
There was a problem hiding this comment.
Build & Tests
Tests: carp -x test/bufio.carp on this armhf Pi — 23 passed, 0 failed, exit 0.
CI is red on both runners, and I confirmed your reading of it rather than taking it on trust. Run tests passed on both; the only failing step is Lint, on exactly two findings, and the two steps after it — Format check and Generate docs — were skipped, so CI has verified neither. I ran both here:
| gate | CI | local |
|---|---|---|
Run tests |
pass (both runners) | 23/0, exit 0 |
Lint |
fail | exit 1, 2 findings |
Format check |
skipped | exit 0 |
Generate docs |
skipped | exit 0, docs/ byte-identical to what is committed |
The two lint findings are pre-existing — verified, not assumed. I ran angler against origin/main's test/bufio.carp in isolation:
origin/main :27:4 [unused-let-binding] let-do binding 'first-line' is never used
origin/main :145:6 [unused-let-binding] let-do binding 'first-line' is never used
this branch :31:4 [unused-let-binding] let-do binding 'first-line' is never used
this branch :149:6 [unused-let-binding] let-do binding 'first-line' is never used
Both shift by exactly 4, which is the four lines this branch adds at the top for the new mock registrations. Your comment quotes 30 and 148 from git grep — that is the line the binding name sits on; angler reports the line the enclosing let-do starts on. Same two findings either way. The 77 new test lines introduce nothing of their own, and #2 is the fix, so leaving them alone is right. Merge-base is 8549872 = current origin/main, so this really is #2-then-this and not stale drift. No CHANGELOG in this repo.
Findings
No bugs in the fix. I reproduced both of your checks independently and added three of my own that the suite does not cover.
Teeth check — src/bufio.h reverted to main, tests kept. 5 failures, and the duplication is visible in every one:
'a flush that fails partway buffers only the unwritten remainder' expected 6, got 11
'retrying a failed flush sends every byte exactly once' expected 'hello world', got 'hellohello world'
Mutation — br->wbuf_len = remaining; changed to = 0;. 6 failures, all showing data lost rather than duplicated ('hello world' → 'hello'). So the fix is pinned on both sides: it neither re-sends nor drops.
My three additions, none of which the suite exercises — all pass on this branch, all fail against main, all fail against the mutant:
| probe | branch | vs main |
vs mutant |
|---|---|---|---|
| multi-write partial (accept 3 at a time, budget 8) then resume | pass | hello wohello world |
hello wo |
| two successive partial failures (5, then 3) then success | pass | hellohelhello world |
hello |
write more data after a partial flush, then flush |
pass | hellohello world!!! |
hello!!! |
The third is the one I most wanted: it is the only check that the compaction lands the remainder at offset 0 rather than merely shortening wbuf_len, since a later BufReader_write_ appends at wbuf + wbuf_len. hello world!!! comes out in order. The second is the real non-blocking-socket shape — fail, partially drain, fail again, finish — and nothing in the suite chains two failures. Suite plus probes: 26 passed, 0 failed.
On the C itself, four things I checked deliberately:
memmove(notmemcpy) is right — source and destination overlap whenevertotal < remaining, and<string.h>is already included atsrc/bufio.h:4.- The
total > 0 && remaining > 0guard means the "wrote nothing" path correctly skips the move rather than doing a no-opmemmove(wbuf, wbuf, len); data is already at offset 0. remainingfloored at zero: an over-reportingwrite_fnyieldsremaining == 0, sowbuf_lencannot go negative and the nextBufReader_write_cannotmemcpybefore the buffer. That path returns0/success, which is whatmaindid too, so it is not a new behaviour.- The success path is byte-for-byte the old one:
total == wbuf_len→remaining == 0→wbuf_len = 0,return 0.
Collapsing n == 0 and n < 0 is the right call for the reason you give — bufio_write_fn has no errno channel, so neither return can be classified. And putting the retry contract in flush's doc string rather than inventing an error type is the correct shape: the buffer is the channel.
One note, not a defect: the PR is still a draft. Given the body reads as finished I assume that is an oversight.
Verdict: revise
The fix is correct and the testing is the good kind — the hellohello world comparison is against the mock's whole accumulated output, which is the only oracle that can see this bug, and the = 0 mutation gives the "keeps the whole buffer" assertion the teeth it does not have on its own. My three extra probes all agree. I am recommending revise only because test (ubuntu-latest) and test (macos-latest) are both failing: the Lint step is red on two findings that are main's and that #2 fixes, and its failure is also hiding Format check and Generate docs from CI entirely — I ran those two locally and they pass, but CI has never seen them on this branch. Land #2 first and this goes green with no change here; that is the cheapest path and the one I would take.
BufReader_flush_ returned -1 with wbuf_len untouched, so a caller that retried after a short write re-sent the bytes the stream had already accepted. On a non-blocking socket -- bufio's reason to exist -- a short write followed by EAGAIN is the ordinary path, so the only safe response was clear-write, which throws the unwritten remainder away too. The loop now breaks instead of returning, compacts the unwritten tail to the front of the buffer and sets wbuf_len to what is left, so a retry resumes where it stopped. remaining is floored at zero so a write_fn that over-reports cannot leave a negative length behind. n == 0 and n < 0 stay collapsed: bufio_write_fn has no errno channel, so a negative return cannot be read as fatal (EAGAIN arrives as -1) and a zero return cannot be read as retryable. Both stop the loop and fail; the retry contract is carried by the buffer state, not the error value. The mock stream grows write-side knobs (max bytes per write, a byte budget, and the code returned once it is spent) so partial writes and zero-byte writes can be expressed, plus a peek at the buffered write length. Six assertions cover short-write success, the remainder left after a failure, a retry reproducing the payload exactly once, the reported error, a zero-progress failure keeping the whole buffer, and a zero-returning stream failing rather than spinning.
0129ccf to
da11a9b
Compare
BufReader_flush_returned-1withwbuf_lenuntouched, so the whole buffer — including the bytes the stream had already accepted — stayed queued. A caller doing the obvious thing on a non-blocking socket (wait for writability,flushagain) re-transmitted the accepted prefix, duplicating data in the peer's stream.clear-writewas the only other exit and it drops the unwritten remainder too, so there was no way to retry a flush correctly.The change
The loop now
breaks instead of returning, compacts the unwritten tail to the front of the buffer, and setswbuf_lento what is left:remainingis floored at zero rather than written aswbuf_len - total: awrite_fnthat over-reports would otherwise leave a negativewbuf_len, which the nextBufReader_write_turns into amemcpybefore the buffer. The old code collapsed that case to0and this keeps it that way.n == 0vsn < 0I kept them collapsed, deliberately.
bufio_write_fnisint (*)(void*, const char*, int)with no errno channel, so:EAGAINarrives as-1, which is exactly the retryable case;Splitting them would encode a distinction the callback signature cannot actually deliver. Both stop the loop (looping on
0spins forever) and both fail. What genuinely differs after this change is the buffer, not the return code.The Carp API
(Result () String)is still enough, and the error string is not the retry channel — the buffer state is.flushreturning(Result.Error "write error")now means "this did not complete; what is still buffered is exactly what was not written; call me again when the stream is ready", and that holds for every failure mode. So no new public type, no new error string; the contract went intoflush's doc string instead, which is where a caller will look for it.Tests
mock_stream.hgains three write-side knobs set through one registered helper —mock-set-write-limits max-per-write budget fail-code(max-per-write 0andbudget < 0mean unlimited) — plusmock-buffered-write-len, a test-only peek atwbuf_len. Defaults are unlimited/no-failure, so all 17 existing assertions pass unchanged.Six new assertions:
n == 0decision; it hangs if the loop ever retries on0Teeth-check 1 —
src/bufio.hreverted to main, new tests kept:hellohello worldis the bug verbatim: the mock accepts 5 bytes, fails, the retry re-sends all 11. The comparison is against the mock's whole accumulated output, not a length or a prefix — a length check would have passed the buggy code on the second assertion.Teeth-check 2 — the fix with
br->wbuf_len = remaining;mutated to= 0;(i.e. "drop everything on failure", the other plausible wrong answer): 3 failures, including a flush that writes nothing keeps the whole buffer. That assertion passes against main too, so it proves nothing about the original bug — it is there to stop the fix from over-correcting, and this is what gives it teeth.Full suite: 23 passed, 0 failed.
CI
carp -x test/bufio.carp,carp-fmt --check, andcarp -x gendocs.carpare green (regenerateddocs/BufReader.htmlis included — 2 lines, the newflushdoc). angler and carp-fmt were rebuilt from their repos' HEAD first.The Lint job will be red on this branch, with the same two
unused-let-bindingfindings main already has (first-lineintest/bufio.carpat the read-line and clear-read assertions). Neither is mine — I confirmedanglerexits 1 on unmodified main with exactly those two — and #2 is the fix for them, so I left them alone.Branched from
main, so #2 and this both touchtest/bufio.carp; whichever lands second will need a small conflict resolved. They append to different ends of thedeftest(#2 edits existing bindings, this adds assertions at the tail), so it should be mechanical.Follow-ups, not in this PR
Each of these is real as far as I can tell, but out of scope here:
CARP_REALLOCinbufreader_fill,BufReader_write_,BufReader_write_MINUS_bytes_,BufReader_read_MINUS_append_— on failure the old pointer is overwritten withNULLand the followingmemcpydereferences it. Same shape as the sockets Tell a failed read from a closed stream #10 fix.cap * 2int overflow —rbuf_cap *= 2and(wbuf_len + len) * 2areintarithmetic; pastINT_MAX/2the new capacity goes negative and is passed toCARP_REALLOCas a hugesize_t. Capacities areintthroughout, which is the underlying issue.BufReader_copyduplicatesinnerandclose_fn— deleting both copies callsclose_fnon the same stream twice. It also copieswbuf_capbytes of capacity but onlymemcpyswbuf_len, which is correct but easy to misread.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.