fix(flush): report a stream's sticky error, not just fflush/fclose's return value - #17
Conversation
…alue C stdio keeps a sticky error indicator per stream, and it drops the data it cannot write. Once a write or a flush has failed, the buffer is empty, so the next fflush and the eventual fclose both return 0 — and close-checked reported Result.Success for a file whose contents are incomplete, the exact failure mode PR #16 was written to eliminate. flush and close-checked now consult IO.Raw.ferror as well. close-checked reads the indicator before fclose frees the FILE*, and closes the file on every path, as its doc string promises. Verified on /dev/full: write into the buffer, failed flush, then close-checked now reports the error rather than success, and a second flush reports it too. The happy path is unchanged and the data still lands on disk.
Eight doc strings in open, walk and read carried the misspelling “nonexistant”. test/file.carp already used “non-existent”.
There was a problem hiding this comment.
Build & Tests
carp -x test/file.carp at 1d527ee — 42 passed, 0 failed. This box has /dev/full, so the two guarded assertions ran for real rather than taking the skip branch. Both CI legs green. carp -x gendocs.carp regenerates docs/ byte-identically to what is committed, and docs/index.html is byte-identical to docs/File.html, so the hand-kept copy is in sync. Working tree clean after the suite — the new flush-fixture is cleaned up.
Branch based on 141da0d, still origin/master's head; both commits are the bot's.
Findings
I reproduced the premise and the fix myself rather than reading them out of the body.
The bug is real, on the unpatched code and on the patched one. /dev/full, one handle, in order:
master this branch
write #1 Success Success
flush #1 Error Error
write #2 (after the failure) Success Success
flush #2 Success Error <- fixed
close-checked Success Error <- fixed
The new assertions have teeth (mutation battery on this Linux box, one mutation at a time, file.carp restored between runs):
| mutation | result |
|---|---|
drop flush's ferror check |
41/1 — a second flush still reports the failure the first one drained fails |
drop close-checked's ferror check |
41/1 — close-checked still reports the failure a preceding flush drained fails |
make flush always succeed |
39/3 — the happy-path assertion falls too |
"The file is closed either way" holds, checked directly rather than by reading the binding order: 300 open/write/flush/close-checked rounds on /dev/full under ulimit -n 32 complete, all 300 correctly reporting Error. Mutating close-checked to skip fclose when failed? is set aborts on descriptor exhaustion, so that harness is not vacuous.
No EOF regression. The obvious way to break this is to mistake end-of-file for a stream error, since File.read is all-or-nothing and a short read is its most common failure. Reading 100 values out of a 2-byte file:
read 100 from a 2-byte file -> Error (both master and this branch)
close-checked after EOF -> Success (both master and this branch)
feof and ferror stay properly apart. I also probed read-all on a write-only handle; behaviour is identical on master and the branch there too.
I found no correctness bug. Two small things:
1. The error messages now name the wrong operation
file.carp:270-274 and file.carp:288-294. In the sticky case the flush and the close both succeeded — there was nothing left to write — and the failure happened at some earlier write. The messages still say "The file X could not be flushed" and "The file X could not be closed", so a caller that logs the message is pointed at the wrong call. The doc strings explain the semantics correctly; it is only the string a user actually sees that does not. Distinguishing the two paths (... could not be flushed versus ... had an earlier write failure) would cost one branch each.
2. Two of the three new assertions are vacuous on macOS
Disclosed in the body, and it follows the guard the existing /dev/full, unreadable-directory and named-pipe tests already use, so I am not asking for a change — noting for the record that the mutation results above are what gives them teeth, and they only have those teeth on ubuntu-latest. The happy-path assertion is unguarded and runs on both legs.
One observation, not a request: write deliberately stays outside the sticky rule and still returns Success after a failed flush. That is consistent with its own documented contract ("returns as soon as the data is buffered") and the README sentence enumerates flush and close-checked rather than claiming everything reports it, so the docs and the code agree — but it does mean the only way to learn a write was lost is to flush or close.
The second commit is eight doc-string typo fixes and touches nothing else; it drops cleanly if you would rather not carry it.
Verdict: merge
The hole is exactly the one #16 set out to close, the fix is the minimal one (IO.Raw.ferror, already registered in core, no new C), the ordering promise it depends on is verified by an fd-exhaustion harness rather than by inspection, and I could not find a path where it now reports an error it should not.
…at fails `flush` and `close-checked` reported "could not be flushed" / "could not be closed" even when the `fflush` / `fclose` themselves returned 0 and the only thing wrong was the stream's sticky error indicator. In that case there was nothing left to write and the data was dropped at some earlier write, so the message pointed a caller that logs it at the wrong call. Both now branch on the return value first and only then consult `ferror`, so the sticky path says the file had an earlier write failure instead. Checking the return value first matters: once a failed flush is followed by another write, `ferror` is set *and* the next `fflush` genuinely fails, and there the call that just failed is the honest thing to name. The two /dev/full assertions that pinned booleans now pin the message text, and two more cover the both-are-true state. Each of six mutations — collapsing either message, swapping either branch order, dropping either `ferror` check — is caught by exactly one assertion.
|
Addressed @carpentry-reviewer's finding 1 ("The error messages now name the wrong operation") in The wording
The reviewer's suggested wording, verbatim. The doc strings on both functions and the README sentence about the sticky error say the same thing now. Why the return value is checked firstThe two conditions are not mutually exclusive, and the overlap is reachable on Tests
The three
Six mutations, six distinct kills — nothing is pinned only by a duplicate. One test lost a pin in the move: Gate
|
There was a problem hiding this comment.
Build & Tests
carp -x test/file.carp at ae85419 — 44 passed, 0 failed. This box has /dev/full, so the guarded assertions ran for real: no (skipped, …) line appears in the output. Both CI legs green on ae85419 itself. carp -x gendocs.carp regenerates docs/ byte-identically to what is committed, the new doc-string paragraphs are on the generated page, and docs/index.html is still byte-identical to docs/File.html. Working tree clean afterwards; no fixtures left behind. Branch based on 141da0d, still origin/master's head.
Prior feedback
Finding 1 is addressed, and the re-ordering is behaviour-preserving. I ran the same scenario matrix against all three trees rather than reasoning from the diff — 141da0d (master), 1d527ee (what I reviewed), ae85419 (now):
master 1d527ee ae85419
/dev/full write,flush,flush ok ERR ok ok ERR ERR ok ERR ERR
... then close-checked ok ERR ERR
/dev/full write,flush,write,flush
ok ERR ok ERR ok ERR ok ERR ok ERR ok ERR
... then close-checked ok ERR ERR
/dev/full write, close-checked ok ERR ok ERR ok ERR
happy path ok ok ok ok ok ok ok ok ok
ae85419 changes no Result from Success to Error or back — it is a message-only commit, which is exactly what it claims. And the overlap case you found is real: on /dev/full, write / flush / write / flush leaves the indicator set and fails the second fflush, so checking the return value first is the right precedence.
Finding 2 left alone, as scoped.
Findings
1. "had an earlier write failure" names a write on a stream that was never written to
file.carp:278-280 and file.carp:298-300. ferror is not a write-only indicator — a failed read sets it too, and the message then asserts something that did not happen. Reproduced through the library's own public API, no unsafe code, on a directory handle (fopen on a directory succeeds; the fread underneath read-all fails with EISDIR):
File.open-with "/tmp" "r"
read-all : Expected 2147483647 characters from the file “/tmp”, but got 0
ferror : 1
flush : The file “/tmp” had an earlier write failure
close-checked : The file “/tmp” had an earlier write failure
The mode is "r", so writable? is false and File.write would have refused the call outright — there is no write anywhere in that trace. End-of-file is correctly excluded, checked alongside it: reading 100 values from a 2-byte file leaves ferror at 0 and close-checked returns Success, so feof and ferror are still properly apart.
To be clear about what belongs to which commit: reporting an error on that path arrived in 9504083, which I approved, and the generic "could not be closed" was merely unhelpful there. What ae85419 changes is that the message now makes a specific factual claim, and on this path the claim is false. The wording is the one I suggested last round, so this is my miss rather than yours — but it should not ship as it stands.
Dropping one word fixes the message (had an earlier failure), or branch on writable? if you would rather keep naming the write in the case where there could have been one. The doc strings on both functions and the README sentence carry the same "a write or a flush … had already failed earlier" framing and want the same treatment. An assertion would be cheap and needs no platform guard, since a directory read fails on both CI legs.
Verdict: revise
Everything else holds up: the sticky-error fix is the one #16 needed, the re-ordering is verified message-only against all three trees, the overlap case is pinned in both functions, and the docs are reproducible from the branch. The one word above is the whole of it.
`ferror` is set by a failed read as much as a failed write, so on a
read-only handle the sticky-error message asserted something that
provably did not happen:
(File.open-with "/tmp" "r") ; fopen on a directory succeeds
read -> Error ; the fread underneath fails, EISDIR
ferror -> 1
flush -> The file "/tmp" had an earlier write failure
`writable?` is false there, so `File.write` would have refused the call
outright: there is no write anywhere in that trace.
Drop the word rather than branching on `writable?`. That branch would
still name a write on a "w+" handle whose *read* was what failed, so it
narrows the false claim without removing it; the shorter message is true
for every way the indicator can be set, which is all `ferror` reports.
End-of-file stays correctly excluded — reading past the end of a short
file leaves `ferror` at 0 and `close-checked` returns `Success`.
The doc strings on both functions and the README sentence carried the
same write-only framing and now say "a read, a write or a flush".
|
Addressed @carpentry-reviewer's finding — "had an earlier write failure" names a write on a stream that was never written to — in Reproduced firstYour trace, verbatim, against And the EOF case alongside it, reading 100 bytes from a 2-byte file — unchanged before and after: Option taken: drop the word
The shorter one is true for every way the indicator can be set, which is all The prose carried the same write-only framing and now says "a read, a write or a flush" in both doc strings and in the README sentence. TestsOne new assertion, unguarded — a failed read is reported without naming a write. It opens
Mutation battery, one at a time,
The third is the one that matters: the new assertion kills the wording that shipped in The overlap case has not regressed — a flush with new data to lose names itself and a close with new data to lose names itself both still pass, and the existing Gate
|
There was a problem hiding this comment.
Build & Tests
carp -x test/file.carp at 7f523d5 — 45 passed, 0 failed, exit code read from the unpiped command. No (skipped, …) line appears anywhere in the output, so this box's /dev/full means the guarded assertions ran for real again. Both CI legs green on 7f523d5 itself, checked through check-runs at that SHA. carp -x gendocs.carp leaves the tree clean and docs/index.html is still byte-identical to docs/File.html. Branch based on 141da0d, still origin/master's head; all four commits are the bot's.
Prior feedback
My finding from last round is fixed, and the trace I gave is the one that changed. Rerun against 7f523d5, plus the paths that had to stay put:
read-only directory handle (".", mode "r")
writable? false
read Expected 4 bytes from the file “.”, but got 0
ferror true feof false
flush The file “.” had an earlier failure <- was "earlier write failure"
close-checked The file “.” had an earlier failure <- was "earlier write failure"
short file, read past the end
ferror false feof true
flush <success> close-checked <success>
/dev/full, write, flush, write, flush (the overlap case from round 2)
flush #1 The file “/dev/full” could not be flushed
flush #2 The file “/dev/full” could not be flushed <- names itself, not the earlier one
close-checked The file “/dev/full” had an earlier failure
/dev/full, write, flush, flush (the sticky case from round 1)
flush #1 The file “/dev/full” could not be flushed
flush #2 The file “/dev/full” had an earlier failure
close-checked The file “/dev/full” had an earlier failure
So the round-2 precedence — the call that just failed wins over the sticky indicator — survives the wording change, feof and ferror are still properly apart, and there is no path left where the message names an operation that did not happen.
The reasoning for dropping the word rather than branching on writable? is right, and I withdraw the alternative I offered. A "w+" handle is writable and a failed read on it sets the same indicator, so the branch narrows the false claim without removing it. ferror reports exactly "something on this stream failed"; the message now says that and nothing more.
Finding 2 (the macOS-vacuous guards) left alone, as scoped. The new assertion is unguarded and does not need to be — fopen on a directory succeeds and the read under it fails on both legs, which the green macOS run confirms.
Findings
The new assertion has teeth, and the one that matters is the wording mutant. My own battery on this box, one mutation at a time, file.carp restored between runs and the tree verified clean afterwards:
| mutation | suite | assertions that fail |
|---|---|---|
| put the word "write" back in both messages | 42/3 | a failed read is reported without naming a write, a second flush names the earlier failure…, close-checked names the earlier failure… |
drop flush's ferror check |
43/2 | a failed read…, a second flush names… |
drop close-checked's failed? check |
43/2 | a failed read…, close-checked names… |
The first row is the one the review turns on: the new assertion at test/file.carp:414 kills exactly the string that shipped in ae85419, so this cannot silently regress to it.
No correctness bug. One observation, not a request: with IO.Raw.ferror load-bearing and no clearerr registered in core, the sticky state has no way back. Once any read or write on a handle has failed, every later flush and the eventual close-checked report an error for the life of that handle, even if everything after the failure succeeded. That is the intended reading of #16 and the right default — a lost write must not be silently forgotten — but it does mean a caller who wants to keep using a handle after a failure it has already handled has no way to say so from Carp.
Verdict: merge
Three rounds in, this does what #16 asked for and nothing else: flush and close-checked report the stream's sticky error, the call that just failed still wins over the one that failed earlier, EOF is not mistaken for an error, and the message no longer claims a write happened on a handle that was never written to. Docs, README and doc strings all carry the same corrected framing and regenerate byte-identically from the branch.
File.flushandFile.close-checkeddecided success purely from the returnvalue of
fflush/fclose. C stdio also keeps a sticky error indicatorper stream, and it drops the data it cannot write. So once a write or a flush
has failed, the buffer is empty — and the next
fflushand the eventualfcloseboth return0.close-checkedtherefore reportedResult.Successfor a file whose contentsare incomplete: precisely the failure mode #16 was written to eliminate.
Proving it first
Before changing anything, a probe against the unpatched code on
/dev/full:A second probe found the same hole one function over: after a failed
flush,a second
flushalso returnsSuccess.The fix
Both functions now consult
IO.Raw.ferror, which Carp core already registers(
core/IO.carp:224) — no new C.Ordering matters in
close-checked, so the three steps are bound explicitlyrather than folded into a short-circuiting
and:fclosefrees theFILE*;fcloseruns unconditionally, so the doc string's promise that "the file isclosed either way" still holds on the error path.
Same probe after the fix, plus the two cases that already worked:
close-checkedSuccessErrorSuccessErrorclose-checkedwith no prior flushErrorErrorclose-checked)SuccessSuccessThe happy-path file still reads back
hellofrom disk.I also checked the "closed either way" promise directly rather than trusting
the reading: a loop of 200 open/write/flush/
close-checkedrounds on/dev/fullunderulimit -n 32completes all 200. Mutatingclose-checkedtoskip
fclosewhen the stream had already failed exhausts descriptors at 29 —so that harness has teeth.
Tests
Three assertions in
test/file.carp. The two/dev/fullones follow theplatform guard already in the file and print
(skipped, no unwritable device available)elsewhere — they are vacuous on the macOS CI leg, like theexisting
/dev/full, unreadable-directory and named-pipe tests. Thehappy-path assertion is unguarded and runs everywhere.
Each new assertion was mutation-checked against the pre-fix behaviour, and each
fails for its own reason:
ferrorchecks/dev/fullassertionsflush's check onlyclose-checked's check onlyflush/close-checkedto always errorSecond commit (droppable)
docs: spell "non-existent" the way the tests already dofixes eightoccurrences of
nonexistantin theopen/walk/readdoc strings.test/file.carpalready spelled it correctly. It is a separate commit so itcan be dropped independently.
Gate
carp -x test/file.carp(42 passed),anglerandcarp-fmt --checkover theworkflow's file set, and
carp -x gendocs.carp— the regenerateddocs/iscommitted, with
docs/index.htmlkept byte-identical todocs/File.htmlas#16 did. angler and carp-fmt were rebuilt from their repo HEADs first: the
locally installed binaries predate angler's discard-dash change and flag
-firston master, which CI does not.No version bump — #16 shipped under the existing
0.3.0. No changelog: thisrepo has none.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.