Skip to content

Resume a failed flush instead of re-sending the accepted prefix - #3

Merged
hellerve merged 1 commit into
mainfrom
claude/flush-partial-write
Aug 17, 2026
Merged

Resume a failed flush instead of re-sending the accepted prefix#3
hellerve merged 1 commit into
mainfrom
claude/flush-partial-write

Conversation

@carpentry-agent

Copy link
Copy Markdown

BufReader_flush_ returned -1 with wbuf_len untouched, 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, flush again) re-transmitted the accepted prefix, duplicating data in the peer's stream. clear-write was 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 sets wbuf_len to what is left:

  int remaining = total < br->wbuf_len ? br->wbuf_len - total : 0;
  if (total > 0 && remaining > 0) memmove(br->wbuf, br->wbuf + total, remaining);
  br->wbuf_len = remaining;
  return remaining == 0 ? 0 : -1;

remaining is floored at zero rather than written as wbuf_len - total: a write_fn that over-reports would otherwise leave a negative wbuf_len, which the next BufReader_write_ turns into a memcpy before the buffer. The old code collapsed that case to 0 and this keeps it that way.

n == 0 vs n < 0

I kept them collapsed, deliberately. bufio_write_fn is int (*)(void*, const char*, int) with no errno channel, so:

  • a negative return cannot be read as fatal — on a non-blocking socket EAGAIN arrives as -1, which is exactly the retryable case;
  • a zero return cannot be read as retryable either — it is a stream that accepted nothing and said nothing about why.

Splitting them would encode a distinction the callback signature cannot actually deliver. Both stop the loop (looping on 0 spins 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. flush returning (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 into flush's doc string instead, which is where a caller will look for it.

Tests

mock_stream.h gains three write-side knobs set through one registered helper — mock-set-write-limits max-per-write budget fail-code (max-per-write 0 and budget < 0 mean unlimited) — plus mock-buffered-write-len, a test-only peek at wbuf_len. Defaults are unlimited/no-failure, so all 17 existing assertions pass unchanged.

Six new assertions:

assertion teeth-checked against
flush keeps writing until the buffer is drained — regression guard for the multi-write success path
a flush that fails partway buffers only the unwritten remainder main
retrying a failed flush sends every byte exactly once main
a flush that fails partway reports an error — pins that the fix does not turn failure into success
a flush that writes nothing keeps the whole buffer mutation (see below)
a stream accepting zero bytes fails the flush instead of spinning — pins the n == 0 decision; it hangs if the loop ever retries on 0

Teeth-check 1 — src/bufio.h reverted to main, new tests kept:

Test 'a flush that fails partway buffers only the unwritten remainder' failed:
	Expected value: '6', actual value: '11'
Test 'retrying a failed flush sends every byte exactly once' failed:
	Expected value: 'hello world', actual value: 'hellohello world'
	Passed: 21	Failed: 2

hellohello world is 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, and carp -x gendocs.carp are green (regenerated docs/BufReader.html is included — 2 lines, the new flush doc). 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-binding findings main already has (first-line in test/bufio.carp at the read-line and clear-read assertions). Neither is mine — I confirmed angler exits 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 touch test/bufio.carp; whichever lands second will need a small conflict resolved. They append to different ends of the deftest (#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:

  • Unchecked CARP_REALLOC in bufreader_fill, BufReader_write_, BufReader_write_MINUS_bytes_, BufReader_read_MINUS_append_ — on failure the old pointer is overwritten with NULL and the following memcpy dereferences it. Same shape as the sockets Tell a failed read from a closed stream #10 fix.
  • cap * 2 int overflowrbuf_cap *= 2 and (wbuf_len + len) * 2 are int arithmetic; past INT_MAX/2 the new capacity goes negative and is passed to CARP_REALLOC as a huge size_t. Capacities are int throughout, which is the underlying issue.
  • BufReader_copy duplicates inner and close_fn — deleting both copies calls close_fn on the same stream twice. It also copies wbuf_cap bytes of capacity but only memcpys wbuf_len, which is correct but easy to misread.

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

@carpentry-agent

Copy link
Copy Markdown
Author

CI came back red; recording what the failure actually is so nobody has to dig.

Run tests passed on both runners. The only failing step is Lint, with exactly the two findings the PR body predicted:

./test/bufio.carp:31:4:  [unused-let-binding] let-do binding 'first-line' is never used
./test/bufio.carp:149:6: [unused-let-binding] let-do binding 'first-line' is never used

Both are on main already — git grep -n first-line origin/main -- test/bufio.carp puts them at lines 30 and 148, and this branch only shifts them by the four lines it adds at the top for the new mock registrations. The two hunks this branch makes in that file are @@ -5,6 +5,10 @@ and @@ -175,4 +179,76 @@, so neither touches them. The 77 new test lines introduce no new findings of their own.

#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.

@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

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 (not memcpy) is right — source and destination overlap whenever total < remaining, and <string.h> is already included at src/bufio.h:4.
  • The total > 0 && remaining > 0 guard means the "wrote nothing" path correctly skips the move rather than doing a no-op memmove(wbuf, wbuf, len); data is already at offset 0.
  • remaining floored at zero: an over-reporting write_fn yields remaining == 0, so wbuf_len cannot go negative and the next BufReader_write_ cannot memcpy before the buffer. That path returns 0/success, which is what main did too, so it is not a new behaviour.
  • The success path is byte-for-byte the old one: total == wbuf_lenremaining == 0wbuf_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.

@carpentry-agent
carpentry-agent Bot marked this pull request as ready for review August 17, 2026 05:40
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.
@hellerve
hellerve force-pushed the claude/flush-partial-write branch from 0129ccf to da11a9b Compare August 17, 2026 09:15
@hellerve
hellerve merged commit 6289412 into main Aug 17, 2026
2 checks passed
This was referenced Aug 17, 2026
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.

1 participant