Skip to content

VFS: IO#read(0) answers "" at end of file, not nil - #63

Open
sam-xif wants to merge 4 commits into
nori/vfs-step2-file-openfrom
nori/vfs-read-zero-at-eof
Open

sam-xif wants to merge 4 commits into
nori/vfs-step2-file-openfrom
nori/vfs-read-zero-at-eof

Conversation

@sam-xif

@sam-xif sam-xif commented Oct 10, 2026

Copy link
Copy Markdown
Owner

Summary

IO#read(0) — a descriptor read with an explicit zero length — answered nil at end of file, where CRuby answers "". This is a wrong answer, not a refusal: CRuby's io_read returns an empty String for a zero length before it ever consults the stream for data.

io.c (io_read):

len = NUM2LEN(length);
if (len < 0) { rb_raise(rb_eArgError, "negative length %"PRIdLEN" given", len); }
...
rb_io_check_byte_readable(fptr);
if (len == 0) { io_set_read_length(str, 0, shrinkable); return str; }   /* "" */
...
n = io_fread(str, 0, len, fptr);
if (n == 0) return Qnil;                                                /* nil, only n > 0 */

So the len == 0 early return precedes the if (n == 0) return Qnil; EOF check. The step-2 descriptor read collapsed both cases into "no bytes available → nil", so read(0) at (or past) end of file disagreed with CRuby. The fix returns an empty String (with the inode's encoding tag) for n == 0, leaving the descriptor position untouched, and keeps nil for n > 0 at end of file. read(0) at the start of a stream already answered ""; only the EOF case was wrong.

This is part of #7 (VFS step 2, PR #26), which introduces IO#read. The fix is behaviour-preserving for every other shape.

What changed

ruby-lean/RubyCore/Builtins/FS.lean, IO#read, the [Integer n] branch: a new if n == 0 then okStrEnc m (h.get inode).binary "" case ahead of the existing avail == 0 → nil branch.

Test plan (how I validated)

I cannot run CRuby in this environment (no C toolchain / no buildable CRuby), so validation is twofold:

  1. CRuby semantics from source. The behaviour is read directly from ruby/ruby io.c io_read (the snippet above): zero length returns "" regardless of EOF, before any read is attempted.

  2. The model itself, byte-for-byte on the built rubycore. I built the model on this branch (lake build rubycore) and drove it with hand-written core-language programs over the boot fixture (/tmp/vfsdata/nums.txt = "1\n2\n3\n"):

    Program This branch CRuby
    io.read; io.read(0) (at EOF) "" ""
    io.read(0) (at start) "" ""
    io.read(3) "1\n2" "1\n2"
    io.read(100); io.read(1) (at EOF) nil nil
    io.read (to EOF) "1\n2\n3\n" "1\n2\n3\n"

    Before the change, the first row returned nil.

  3. Proofs unaffected. lake build in books/ builds all 1396 targets with the change; every theorem still rests on propext + Classical.choice + Quot.sound.

Stacking

Based on nori/vfs-step2-file-open (PR #26), where IO#read is introduced. It applies cleanly to nori/vfs-step5-gets-readline-pos (PR #36) and I verified the same behaviour there after applying the patch — step 5 inherits the fix on rebase/merge.

Follow-up

A separate issue is filed for IO#gets/readline/each_line ignoring the global input record separator $/ (a related wrong answer in the same step-5 code, out of scope here).


Authored by Jiro, an autonomous agent. The code and this description were produced by an agent; the analysis above is from CRuby's io.c and the model's own observable behaviour.

…fixture

Implements the first shippable step of issue #7: the symbolic virtual
filesystem. A file is a heap object (`Payload.file`/`Payload.dir`), the fixture
is initial data H0' allocated at boot, path resolution is one pure total walk,
and every unmodeled operation is a named Unsupported rather than a guess or a
fabricated Errno.

- New bootstrap classes IO, File < IO, Dir; new payload variants file/dir.
- VFS.components/walk/resolve/lookup in Heap.lean; fixture allocated after main
  and the two realized eigenclasses, with `vfsBase` derived from
  `classTable.length` so new classes shift the fixture ids automatically.
- Builtins/FS.lean: the six File operations plus IO.read/IO.write and
  Dir.exist?, discharged through the prelude's class-method path. File.write
  mutates inode bytes in place and returns the byte count; inspect/to_s of a raw
  file/dir object gate rather than guess CRuby's path-bearing rendering.
- FS singleton methods are installed on their constants' eigenclasses in
  Prelude.boot (like Exception.exception), so they use ordinary dispatch and add
  no `send` special case. Only real CRuby class methods are installed, so
  Dir.read-style typos still NoMethodError exactly as CRuby does.
- Repaired the boot-shift the new classes caused: intMetaId/strMetaId (prelude
  heap ids) 79/88 -> 87/96, ConstantReachControls' concrete heap/Next id, the
  runFS_frame/pathArg?/VFS.lookup root-frame lemmas, and bootGlobalConsts.

Evidence: lake build rubycore + active-ratchet green, #guard bootOkB passes,
run_typed_ratchet.sh prints RATCHET GREEN, check-proofs.sh passes, and the step-1
surface agrees with CRuby byte-for-byte. The hermetic difftest "vfs" envelope and
un-gating the bootstraptest filesystem family (issue #8) are the next step.

Authored by Nori (agent).
The first cut of `VFS.walk` treated `..` as a no-op, so
`File.read("/tmp/vfsdata/../readme.txt")` gated where CRuby answers. Store the
parent in the `dir` payload (root's is `none`) and climb it, which is the one
definition issue #7 asks for. `.`, `..`, `./c` and `a/b/../c` now agree with
CRuby byte-for-byte.

Authored by Nori (agent).
Add descriptors to the symbolic virtual filesystem of issue #7. A file is
still a heap object; a descriptor is now one too.

- Payload.io (inode, mode, pos, closed) and IOMode (read/write/append/
  readWrite); each descriptor is its own object so per-descriptor position
  and closed-ness are observable, while the inode bytes stay shared.
- File.open non-block, modes r/w/a/r+ (w truncates, a appends, r+ reads
  then overwrites at pos). IO#read (to EOF, or at most n, with the CRuby
  "" vs nil split), IO#write (byte count), IO#close, IO#closed?, IO#flush.
- Class-object receivers keep the step-1 File.read/write and IO.read/write
  paths; .io receivers take the descriptor path.
- File#open joins blockSensitiveBids: the block form (step 4) gates rather
  than silently running the block zero times.
- Repr/Trace/nativeReprOwner gained the .io arm, still gating on rendering
  a descriptor (CRuby's File#inspect is path-bearing).
- openIO allocates and mutates the heap, so runFS_frame needed a new
  openIO_frame lemma; it joins rootFrameLem and closes the same way.

Missing path, closed stream, wrong-mode read, negative read length and the
block form all gate with a named reason (Errno/IOError are step 7).

Verified: lake build rubycore; RATCHET GREEN (run_typed_ratchet.sh, with and
without RATCHET_SKIP_AGREEMENT=1); check-isolation.sh OK; check-proofs.sh OK
(standard axioms). Model-vs-CRuby 4.0.5 byte-for-byte on a "hello\n" fixture
for read/read(n)/read(0)/EOF, w-truncate, a-append, r+ read-then-write, and
default mode.
CRuby's io_read returns an empty String for a zero length before it
consults the stream (io.c: `if (len == 0) return str;` precedes the
`if (n == 0) return Qnil;` EOF check). The step-2 descriptor read
returned nil whenever no bytes were available, so read(0) at or past
end of file disagreed with CRuby — a wrong answer, not a refusal.

Refs #7 (VFS step 2).
@sam-xif
sam-xif force-pushed the nori/vfs-step2-file-open branch from 95f9811 to 6d4f817 Compare October 10, 2026 15:17
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.

2 participants