Skip to content

fix(fs): name the path and the cause in fs.read / fs.read_binary errors - #1469

Merged
paul-hammant merged 4 commits into
mainfrom
fix/fs-read-error-detail
Aug 9, 2026
Merged

fix(fs): name the path and the cause in fs.read / fs.read_binary errors#1469
paul-hammant merged 4 commits into
mainfrom
fix/fs-read-error-detail

Conversation

@paul-hammant

Copy link
Copy Markdown
Collaborator

Implements asks/fs-read-binary-loses-error-reason.md (from the aeb line; the ask is included in this PR).

The problem

fs.read_binary returned 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.read had 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 file with no path, making an intermittent content-hash failure undiagnosable.

Before / after

Running the ask's own repro:

case before after
missing cannot read file /tmp/not-here: No such file or directory
directory cannot read file /tmp: Is a directory
no perms cannot read file /etc/shadow: Permission denied
sandbox denial cannot 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 testing string.length(err) > 0 keep 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.
  • Not done, per the ask's own scoping: no (bytes, length, kind, err) arity break, no raw errno exposed to Aether, no change to the success path or its @heap ownership contract.

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.

A second bug, found while testing this

fs.read("/tmp") returned ("", "") — success, no content, no error.

The seekable fast path in file_read_all_raw never checked ferror, so a failed read returned an empty string as success. Reading a directory is the everyday trigger: fopen("/tmp","r") succeeds on Linux and ftell reports a positive size, so control lands there and fread fails with EISDIR.

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._2 contract (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

Test

tests/regression/test_fs_read_error_detail.ae asserts 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 — strerror text varies by platform and locale, and pinning it would make the test a portability trap.

🤖 Generated with Claude Code

paul-hammant and others added 4 commits August 9, 2026 11:11
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>
@paul-hammant
paul-hammant merged commit 19693ae into main Aug 9, 2026
24 of 25 checks passed
@paul-hammant
paul-hammant deleted the fix/fs-read-error-detail branch August 9, 2026 12:46
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