Repository navigation
Conversation
…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
force-pushed
the
nori/vfs-step2-file-open
branch
from
October 10, 2026 15:17
95f9811 to
6d4f817
Compare
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.
Summary
IO#read(0)— a descriptor read with an explicit zero length — answerednilat end of file, where CRuby answers"". This is a wrong answer, not a refusal: CRuby'sio_readreturns an empty String for a zero length before it ever consults the stream for data.io.c(io_read):So the
len == 0early return precedes theif (n == 0) return Qnil;EOF check. The step-2 descriptor read collapsed both cases into "no bytes available →nil", soread(0)at (or past) end of file disagreed with CRuby. The fix returns an empty String (with the inode's encoding tag) forn == 0, leaving the descriptor position untouched, and keepsnilforn > 0at 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 newif n == 0 then okStrEnc m (h.get inode).binary ""case ahead of the existingavail == 0 → nilbranch.Test plan (how I validated)
I cannot run CRuby in this environment (no C toolchain / no buildable CRuby), so validation is twofold:
CRuby semantics from source. The behaviour is read directly from
ruby/rubyio.cio_read(the snippet above): zero length returns""regardless of EOF, before any read is attempted.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"):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)nilnilio.read(to EOF)"1\n2\n3\n""1\n2\n3\n"Before the change, the first row returned
nil.Proofs unaffected.
lake buildinbooks/builds all 1396 targets with the change; every theorem still rests onpropext + Classical.choice + Quot.sound.Stacking
Based on
nori/vfs-step2-file-open(PR #26), whereIO#readis introduced. It applies cleanly tonori/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_lineignoring 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.cand the model's own observable behaviour.