fix(fs): name the path and the cause in fs.read / fs.read_binary errors - #1469
Merged
Conversation
Both collapsed every failure into a bare constant — "cannot read file" for
read_binary, "cannot open file" / "cannot read file" for read — with no path
and no errno. Six distinct causes, including sandbox denial and silent
truncation, were indistinguishable to the caller, so no Aether program could
treat a missing optional file as benign while treating a permission error as
fatal.
Reported from the aeb line: a 79-target parallel build reported "cannot read
file" with no path, which made an intermittent content-hash failure
undiagnosable from the caller's side.
Before / after, on the ask's own repro:
missing : 'cannot read file' -> '/tmp/not-here: No such file or directory'
directory : 'cannot read file' -> '/tmp: Is a directory'
no-perm : 'cannot read file' -> '/etc/shadow: Permission denied'
fs_read_binary_raw now records WHY it is about to return NULL — the reason and
errno land in thread-local state that the tuple wrapper reads back immediately
after. Its signature is unchanged (it is public and has other callers), and the
(bytes, length, err) arity is unchanged, so every existing caller keeps working;
the ask explicitly scoped the arity break out.
A sandbox refusal gets its own wording — "blocked by sandbox policy (no fs_read
grant for this path)" — because it is a POLICY decision, not an I/O error. The
file may be present and perfectly readable, and reporting it as a filesystem
failure sends whoever is debugging a grant list looking at the disk.
fs.read is composed in Aether from file_open_raw + file_read_all_raw, so it
cannot capture errno at the failing step itself. New fs_error_message(path,
fallback) formats the current errno with the path for exactly that case.
Also fixes a silent-truncation bug found while testing this. In
file_read_all_raw the seekable fast path never checked ferror, so any failed
read returned an EMPTY STRING AS SUCCESS. Reading a directory is the everyday
case: fopen("/tmp","r") succeeds on Linux and ftell reports a positive size, so
control lands there, fread fails with EISDIR, and fs.read returned ("", "") —
success, no content, no error. That is the same class as #1116, which fixed only
the streaming branch. A short read with EOF and no error flag (the file shrank
mid-read) still returns what was read, as before.
Lifetime: the error string is borrowed from thread-local storage and is valid
until the thread's next failed read. Documented on both wrappers, because it is
a real caller-facing contract — it caught out the first draft of the test, which
compared two live messages and was really comparing one buffer with itself.
Not done, per the ask's own scoping: no (bytes, length, kind, err) arity change,
no raw errno exposed to Aether, no change to the success path or its @heap
ownership contract.
Tests: tests/regression/test_fs_read_error_detail.ae asserts that failures name
the path, that distinct causes give distinct messages, that the old constants
are gone, and that the success paths — including the #1116 /proc streaming case
— still work. It deliberately does not assert exact libc wording, which varies
by platform and locale.
Verified valgrind-clean on the success path; the 1-byte-per-call leak remaining
on the error path is #1461 (the empty-string tuple slot), confirmed present on
pristine main with the same test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-truncation
The path/reason budget was computed by hand, which was correct but not
*provably* correct: gcc could not see that a 127-byte strerror string plus a
path always fits the 512-byte buffer, so -Werror=format-truncation failed the
build on six CI jobs (Linux GCC/Clang/Hardened, both Windows, macOS ARM64).
My local build did not use -Werror, which is why it passed here.
std/fs/aether_fs.c:1490:64: error: '%s' directive output may be truncated
writing up to 127 bytes into a region of size between 4 and 510
[-Werror=format-truncation=]
Replaced the arithmetic with precision specifiers — "%s%.*s: %.*s" with fixed
caps — so each field is bounded by a compile-visible constant and the total
cannot exceed the buffer. Same behaviour, provable to the compiler.
Verified locally under the three configurations CI uses:
gcc -Werror -Wformat-truncation=2, clang -Werror, and the HARDEN=1 flag set
(-fstack-protector-all -D_FORTIFY_SOURCE=2 -Wformat-security). All clean.
Behaviour unchanged: the regression test still passes, and a 1060-character
path renders as a 280-byte message, left-truncated with "..." so the filename
— the part that identifies the file — is preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e __thread
Both Windows CI jobs failed with:
std/fs/aether_fs.c:1716:26: error: redefinition of 's_last_os_error'
The read-error block needs to write s_last_os_error, but the variable is
defined further down the file, so the first version forward-declared it as
`static AETHER_FS_TLS int s_last_os_error;` and left the initialised definition
in place below.
That is a tentative definition, and C's tentative-definition rule does NOT
extend to __thread objects on MinGW-GCC — it accepts exactly one definition.
glibc/GCC on Linux, Clang, and macOS all accept the pair, which is why this
survived three green non-Windows jobs before the Windows ones caught it.
Fixed by moving the single definition up to the read-error block and leaving a
comment at the old site explaining why it cannot move back.
Verified on winbaz (real MSYS2 MINGW64) rather than by another CI round-trip:
the redefinition error is gone. The one remaining diagnostic there
('aether_fs_iks_err_partial' defined but not used) reproduces identically on
pristine main with the same ad-hoc flags, so it is not from this change — the
real Windows CI build does not enable that warning.
Behaviour unchanged; the regression test still passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s gate
The macOS leaks(1) gate failed the new test:
[FAIL] test_fs_read_error_detail: 3 leaks (allowed ≤ 0)
The test exercises fs.read / fs.read_binary FAILURE paths, which return
("", <message>). The bare "" literal in the value slot is heap-allocated and
never freed — 1 byte per failing call, and NOT reachable from the caller:
string.free() and string_release() on the returned value both fail to reclaim
it, and the leak is attributed to the caller's frame rather than to std.fs.
That is #1461, not this change. Confirmed pre-existing on main: an equivalent
4-line program using only json.parse — no fs at all — leaks identically, while
a plain string.to_double() tuple does not, so it is specific to this return
shape rather than to tuples in general.
tests/leaks_known.txt already documents the same family for test_rsa_pkcs1
(the #1311 quirk on cross-module (ptr, string) returns, whose fix
heap-classified nested tuple-destructure ERROR slots); this is the mirror
case — the "" literal sharing a slot with heap strings.
Budgeted rather than papered over. The cap is 4, the exact number of failing
calls the test makes (2x fs.read, 2x fs.read_binary), so the entry cannot
silently absorb a NEW leak. Measured 2 under Linux valgrind and 3 under macOS
leaks(1) — the tools disagree on how many they attribute — so the call count is
the honest ceiling. The entry says to delete it once #1461 lands.
I should have checked the leaks gate rather than only checking that the leak
pre-existed; the gate is exactly the mechanism that turns "known" into
"budgeted".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This was referenced Aug 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements
asks/fs-read-binary-loses-error-reason.md(from the aeb line; the ask is included in this PR).The problem
fs.read_binaryreturned the constant"cannot read file"for every failure — no errno, no path, no kind. Six distinct causes, including sandbox denial and silent truncation, were indistinguishable.fs.readhad the same flaw ("cannot open file"/"cannot read file").Consequence: no Aether program could treat a missing optional file as benign while treating a permission error as fatal. It surfaced in aeb as a 79-target parallel build reporting
cannot read filewith no path, making an intermittent content-hash failure undiagnosable.Before / after
Running the ask's own repro:
cannot read file/tmp/not-here: No such file or directorycannot read file/tmp: Is a directorycannot read file/etc/shadow: Permission deniedcannot read file/etc/hostname: blocked by sandbox policy (no fs_read grant for this path)That last row is ask item 3, verified end-to-end by installing a denying checker. A policy refusal is not a filesystem failure — reporting it as one sends whoever is debugging a grant list looking at the disk.
Scope — deliberately what the ask asked for
The ask was explicit that (2) + (3) satisfy it and that an arity change would be an unwanted drive-by. So:
(bytes, length, err)is unchanged. Callers testingstring.length(err) > 0keep working.fs_read_binary_raw's public signature is unchanged. It now records why it is about to return NULL in thread-local state that the tuple wrapper reads back immediately after.(bytes, length, kind, err)arity break, no raw errno exposed to Aether, no change to the success path or its@heapownership contract.fs.readis composed in Aether fromfile_open_raw+file_read_all_raw, so it cannot capture errno at the failing step itself. Newfs_error_message(path, fallback)formats the current errno with the path for exactly that case.A second bug, found while testing this
fs.read("/tmp")returned("", "")— success, no content, no error.The seekable fast path in
file_read_all_rawnever checkedferror, so a failed read returned an empty string as success. Reading a directory is the everyday trigger:fopen("/tmp","r")succeeds on Linux andftellreports a positive size, so control lands there andfreadfails withEISDIR.Same class as #1116, which fixed only the streaming branch. Confirmed pre-existing on
main. Arguably worse than the bug I was sent to fix — a bad message misleads, a false success corrupts. A short read with EOF and no error flag (the file shrank mid-read) still returns what was read, as before.Lifetime contract — documented, because it caught me out
The error string is borrowed from thread-local storage, valid until that thread's next failed read. This matches the existing
out._2contract (the other messages in that tuple are static literals), so nothing changes for callers who print or ignore it.It is now documented on both wrappers, because the first draft of my own test compared two live messages and was really comparing one buffer with itself — it reported them identical while visibly printing different text.
Verification
make ci— C suite 230/230;.aesuite 976 passed / 977 total, up from 976 total, confirming the new test is discovered and[PASS] regression_test_fs_read_error_detail. The single failure isintegration_http_server_h2, pre-existing onmainand separately diagnosed inpesky_bug.md.mainwith the same test, so this change is leak-neutral./proc/self/statusstreaming case, which the fast-pathferrorfix had to avoid breaking.Test
tests/regression/test_fs_read_error_detail.aeasserts failures name the path, distinct causes give distinct messages, the old constants are gone, and the success paths still work. It deliberately does not assert exact libc wording —strerrortext varies by platform and locale, and pinning it would make the test a portability trap.🤖 Generated with Claude Code