Skip to content

file+filetest+exception: FileTest, the Errno subsystem, realpath_rec and the stat surface (+79) - #629

Merged
tannevaled merged 9 commits into
mainfrom
wave27/fileexc
Sep 23, 2026
Merged

tannevaled merged 9 commits into
mainfrom
wave27/fileexc

Conversation

@tannevaled

@tannevaled tannevaled commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

What this is

Wave 27, the file/exception lane: internal/vm/{file,filestat*,fileutils,dir,exception,errno}.go. Every behaviour change was read out of the MRI C source first and then checked byte for byte against the host MRI 4.0.5 before it was kept.

core/file 618 → 629, core/filetest 45 → 88, core/exception 149 → 174 (+79). Whole ruby/spec corpus: 21 815 → 21 895, with no spec file losing an example — the full language+core suite was measured per file before and after, not just in total.

core/file/mkfifo_spec.rb is still at 0. File.mkfifo was left unregistered, as the note in file.go asks, because File.open on a FIFO still blocks the scheduler.

The defects, and the source each was read from

FileTest was a second, partial copy of File's predicates (+43). MRI defines each one-path predicate once and installs it on both receivers through the same C function (define_filetest_function, file.c:6635, and its 26 call sites in Init_File at file.c:7456-7485). rbgo had a complete implementation on File and a hand-written 13-of-26 subset on FileTest, so FileTest.blockdev?, empty?, identical?, socket?, sticky?, grpowned?, owned?, pipe?, setuid?, setgid?, world_readable?, world_writable? and the three *_real? predicates were all NoMethodError. registerFileTest now re-exports File's own *Method records, sharing them rather than copying — which is what makes FileTest.method(:zero?) == FileTest.method(:empty?) hold. The same drift had left the arity checks on one side only, so oneArg now guards all of them and File.zero? with no argument no longer reaches an index-out-of-range panic.

SystemCallError#initialize did not exist (+23 across four spec files). Every argument was discarded. Implemented from syserr_initialize (error.c:3104), including the part that surprises: on the generic class a lone Integer argument is the errno, and a number with a registered class rewrites the receiver's class in place (RBASIC_SET_CLASS), so SystemCallError.new(22).instance_of?(Errno::EINVAL). Also syserr_errno (error.c:3153) — #errno returns the value as given, so new("x", 2.9).errno is 2.9 though the class lookup truncated it — and syserr_eqq (error.c:3168), the .=== that matches by error number, so rescue Errno::EINVAL catches a SystemCallError never given that class.

The Errno table had 28 of MRI's 158 names and no NOERROR. Rebuilt against Init_syserr (error.c:4204) and set_syserr (error.c:3064), which have two rules rbgo did not follow: one class per errno number rather than per name (so a second name for a number becomes another constant on the same class — that is why Errno::EWOULDBLOCK.equal?(Errno::EAGAIN)), and a name the platform does not define is still a constant, bound to Errno::NOERROR by undefined_error. Errno.constants.size now matches MRI's 158, which system_call_error_spec.rb leans on when it takes that size as an errno number no class can claim. Messages come from syscall.Errno.Error(), whose table is strerror's with a lower-case lead; errnoStrerror restores the letter and replaces Go's "errno N" placeholder with the C library's wording.

realpath/realdirpath were filepath.EvalSymlinks with an error guess bolted on. MRI resolves both with realpath_rec (file.c:4324) driven by rb_check_realpath_emulate (file.c:4437). EvalSymlinks refuses a path whose last component is a dangling symlink, where MRI's realdirpath answers the link's target (file.c:4390); it reports a missing component against the symlink rather than the resolved name; and its errors carry no errno, so ENOTDIR came out as ENOENT. realpathResolve is realpath_rec: the component loop, the skipprefixroot split and the prefixlen threaded through it (which is what stops .. walking above a root longer than one character — without it the walk built /C: on Windows and broke File.realpath there outright), ..-truncation, the loopcheck table that both detects a cycle and answers an already-resolved link, the fallback at file.c:4383, and the mode == RB_REALPATH_STRICT || !last || *unresolved_firstsep rule — the last clause is why the trailing separator is threaded separately, since MRI walks the path as written. The two error wordings differ correctly now: File.realpath reaches realpath(3) first, so ENOENT/ELOOP name rb_check_realpath_internal and the whole argument, while ENOTDIR falls through to the emulation and names realpath_rec and the component.

File::Stat#atime and #ctime returned the modification time — the same number, so File.utime(2000, 2001, f) read back as (2001, 2001). MRI reads st_atime/st_ctime off struct stat; Go's syscall.Stat_t has them under names that differ per platform (Atim on Linux, Atimespec on the BSDs), so shared code cannot name one and compile. statTimeField reads whichever the platform carries by reflection, keeping the rule in the shared file where the POSIX lanes cover it instead of four build-tagged copies. #birthtime now answers where the platform has a creation time and keeps MRI's NotImplementedError — same message — where it does not, which is MRI's own HAVE_STRUCT_STAT_ST_BIRTHTIMESPEC guard.

File::Stat#inspect was the placeholder "#<File::Stat>". It is now rb_stat_inspect (file.c:1080), written as the value's native Inspect rather than a dispatch through the Ruby accessors — because rb_stat_inspect calls the C member functions directly, and because Kernel#p reaches the same rendering, so p st and st.inspect cannot drift.

File#stat and File#lstat did not exist, which is what core/file/stat/comparison_spec.rb was failing on before it could compare anything.

The stat predicates took a path only. MRI resolves the whole family through rb_stat (file.c:1304), which converts through #to_io first, so File.directory?(io) and File.size(io) work. statPathArg now does that, applied to exactly the seventeen methods that call rb_stat — and not to readable?/writable?/executable? and the *_real? family, which go through rb_eaccess, nor to symlink?/ftype, which lstat a path.

Exception#inspect read the stored @message where exc_inspect (error.c:1838) goes through rb_obj_as_string(exc), i.e. dispatches #to_s. rbgo already had objAsString with exactly those semantics; #inspect now uses it rather than a second reading of the same rule.

Three existing tests asserted behaviour MRI does not have

Each was corrected against the host MRI, not weakened to match rbgo:

  • TestFileTest asserted FileTest.exists?. MRI 4.0 removed that spelling — 4.0.5 reports FileTest.respond_to?(:exists?) == false, and it is not in define_filetest_function's list. It now asserts the NoMethodError.
  • TestProcessSysFailFallback asserted that errno 0 has no registered Errno class. MRI registers Errno::NOERROR for it (error.c:4206; SystemCallError.new(0).class == Errno::NOERROR). The case moved to a number no name claims, and errno 0 got its own assertion.
  • Two File::Stat tests asserted the "#<File::Stat>" placeholder for #inspect.

Known gaps, named rather than papered over

These are not failures this branch could fix from its own files, and no assertion was relaxed to hide any of them. Each is filed: #630, #631, #632, #633, #635.

  • 11 of the 12 realpath/realdirpath failures are a harness artefact, not a VM defect. The shim's SPEC_TMP_BASE is /tmp, which on darwin is a symlink to /private/tmp, so File.realpath(link).should == @file compares a resolved path with an unresolved one. MRI 4.0.5 fails them the same way — it also answers /private/tmp there. They need SPEC_TMP_BASE moved off a symlinked root in scripts/conformance/rubyspec/spec_helper.rb, which this branch does not own — conformance shim: SPEC_TMP_BASE is /tmp, a symlink on darwin — 11 realpath/realdirpath examples compare a resolved path with an unresolved one #630. tmp(""), as_real_superuser and with_block_device are missing from the same shim and cost further examples in core/file/stat/* and core/filetest/*_real_spec.rb.
  • Per-platform errno numbers. The table takes its numbers from the 76 names Go's portable syscall package defines on every GOOS rbgo builds for. The rest take MRI's undefined_error path, which is exact on a platform whose libc lacks them and reports 0 where MRI reports a real number on one that has them (macOS has EAUTH at 80, EHOSTDOWN at 64). Fixing it needs either build-tagged supplements or a per-GOOS table with a per-platform MRI witness — there is no Linux MRI on this host to witness one — Errno: 32 constants disagree with MRI on darwin (not 82 — 49 of MRI's own also read 0) #633.
  • Numeric#to_int is missing, so SystemCallError.new("foo", Complex(2.9, 0)) is a TypeError where MRI truncates to 2, and the non-real case is a TypeError where MRI raises RangeError. MRI gets both from Numeric#to_int (defined as to_i); rbgo's Complex has to_i with the right RangeError but no to_int alias. That is complex.go/builtins.go, not this branch. Costs 2 examples in system_call_error_spec.rb — Numeric#to_int is missing: SystemCallError.new with a Complex errno raises TypeError where MRI truncates, or raises RangeError #631.
  • File::Stat#== does not dispatch Comparable#==. a.==(b) is true and a == b is false, because the OpEq fast path routes a non-RObject value to identity unless hasCustomEq lists its type — and that predicate lives in digest_bind.go. Costs 1 example in stat/comparison_spec.rb — File::Stat#== does not dispatch Comparable#==: a.==(b) is true while a == b is false #632.
  • File#stat on an unlinked-but-open file. MRI fstats the descriptor; rbgo's File streams are buffered by path, so an unlinked file is Errno::ENOENT. Costs 1 example in stat_spec.rb — File::Stat: no atime/ctime on Windows, and File#stat cannot answer for an unlinked open file #635, which also covers the Windows atime/ctime/birthtime gap: Win32FileAttributeData carries Filetime rather than a timespec, so the two tests that would otherwise assert the fallback skip there, naming the missing Windows MRI witness.
  • core/file/open_spec.rb (28) and new_spec.rb (9) are the mode/options contract, File#truncate, File#size and File#flock — all of which live in io.go, which a sibling agent owns this wave. Untouched here.
  • full_message_spec.rb (16) is Exception#full_message/#detailed_message, implemented in builtins.go. Untouched here.
  • Errno::EFTYPE now resolves (to NOERROR) where it used to be a NameError, which changes the shape but not the outcome of core/file/sticky_spec.rb's BSD-guarded example — the shim puts :bsd in PLATFORMS on darwin, which real mspec does not.

Gates

🤖 Generated with Claude Code

tannevaled and others added 5 commits September 23, 2026 14:13
…n core/filetest)

MRI defines its one-path file predicates once and installs each on BOTH
FileTest and File through the same C function:

    #define define_filetest_function(name, func, argc) do {        \
        rb_define_module_function(rb_mFileTest, name, func, argc); \
        rb_define_singleton_method(rb_cFile, name, func, argc);    \
    } while(false)

(ruby/ruby v3_4_0 file.c:6635, and its 26 call sites in Init_File at
file.c:7456-7485). rbgo instead had two implementations: a complete one on
File and a partial hand-written one on FileTest carrying 13 of the 26 names.
The 13 missing were blockdev?, chardev?, empty?, executable_real?, grpowned?,
identical?, owned?, pipe?, readable_real?, setgid?, setuid?, socket?,
sticky?, world_readable?, world_writable? and writable_real? — every one of
them a NoMethodError, which is what core/filetest was mostly failing on.

registerFileTest now re-exports File's own *Method records for exactly the
names in define_filetest_function's list. Sharing the record (rather than
copying a closure) is what makes FileTest.method(:zero?) == FileTest.method
(:empty?) hold, which core/filetest/zero_spec.rb asserts.

That exposed a second half of the same drift: File's predicates did not all
check arity. MRI's rb_file_directory_p and friends take argc 1, and
core/filetest/shared/exist.rb asserts the ArgumentError for both receivers.
oneArg now guards the four shared closures (access, realAccess, statTest,
worldPerm) and the four inline predicates, so File.zero? no longer reaches an
index-out-of-range panic with no arguments.

MRI 4.0 removed the deprecated exists? spelling: 4.0.5 reports
FileTest.respond_to?(:exists?) == false, and it is not in
define_filetest_function's list. TestFileTest asserted the pre-4.0 spelling;
it now asserts the NoMethodError, witnessed against the host MRI.

core/filetest: 45 -> 87 passing. core/file and core/exception unchanged
(618 / 149). No spec file lost an example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…3 in core/exception)

SystemCallError had no #initialize at all: every argument was discarded, so
SystemCallError.new(Errno::EINVAL::Errno) was a message-less generic
SystemCallError, #errno was read off the class rather than the call, and
Errno::EINVAL.new.message said "Errno::EINVAL" where MRI says "Invalid
argument". This implements MRI's syserr_initialize (ruby/ruby v3_4_0
error.c:3104) whole, including the part that surprises: on the generic class a
lone Integer argument IS the errno, and when the number has a registered class
the receiver's class is REWRITTEN in place (RBASIC_SET_CLASS), which is what
makes SystemCallError.new(22).instance_of?(Errno::EINVAL) true.

Also from error.c: syserr_errno (3153) — #errno returns the value AS GIVEN, so
new("x", 2.9).errno is 2.9 even though the class lookup truncated it to 2 —
and syserr_eqq (3168), the .=== that matches by error NUMBER, so
`rescue Errno::EINVAL` catches a SystemCallError that was never given that
class.

The Errno table was rebuilt against Init_syserr (error.c:4204) and set_syserr
(error.c:3064), which have two rules rbgo did not follow:

  - ONE CLASS PER NUMBER, not per name. A second name for a number that
    already has a class becomes another constant on that same class. That is
    why Errno::EWOULDBLOCK.equal?(Errno::EAGAIN) holds.
  - A name the platform does not define is STILL A CONSTANT — MRI's
    undefined_error binds it to Errno::NOERROR. So Errno.constants is the full
    known_errors list on every platform.

rbgo had 28 names and no NOERROR; it now has MRI's 158, which is what
system_call_error_spec.rb leans on when it takes Errno.constants.size as an
errno number no class can claim. Numbers come from Go's portable syscall
package for the 76 names it defines on every GOOS rbgo builds for (darwin,
linux, windows, wasip1); the remaining names take the undefined_error path.
That is exact on a platform whose libc does not define them and reports 0
where MRI reports a real number on one that does (macOS has EAUTH at 80) —
named in the PR, not papered over. EWOULDBLOCK is registered as EAGAIN's
second name, which every platform rbgo builds for agrees with.

Messages come from syscall.Errno.Error(), whose table is strerror's with a
lower-case lead; errnoStrerror restores the letter and replaces Go's "errno N"
placeholder with the C library's wording. Witnessed byte-for-byte against MRI
4.0.5 over 45 expressions (class, message, errno, arity and coercion errors
for both receiver shapes).

TestProcessSysFailFallback asserted that errno 0 has no registered class. That
premise is now false, and it is false in MRI too (4.0.5:
SystemCallError.new(0).class == Errno::NOERROR), so the case was moved to a
number no name claims and errno 0 given its own assertion.

core/exception: 149 -> 172. errno_spec 1 -> 8, io_error_spec 2 -> 6,
system_call_error_spec 11 -> 22, case_compare_spec 4 -> 5. core/file and
core/filetest unchanged (618 / 87). No spec file lost an example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mlinks

Both were filepath.EvalSymlinks with an error guess bolted on. MRI resolves
them with one recursive walk, realpath_rec (ruby/ruby v3_4_0 file.c:4324),
driven by rb_check_realpath_emulate (file.c:4437) and reached through
rb_check_realpath_internal (file.c:4554). EvalSymlinks differs in ways the
specs notice:

  - It refuses a path whose LAST component is a dangling symlink. MRI's
    realdirpath answers the link's TARGET there: realpath_rec follows the link
    and then tolerates the absent name (file.c:4390). rbgo answered the link
    itself.
  - A missing component under a symlink was reported against the symlink, not
    against the resolved name the walk actually failed on.
  - Its errors carry no errno, so ENOTDIR (a path walking through a regular
    file) came out as ENOENT.

realpathResolve is realpath_rec: the component loop, ".."-truncation, the
loopcheck table that both detects a cycle (ELOOP) and answers a link already
resolved, the fallback at file.c:4383, and the
`mode == RB_REALPATH_STRICT || !last || *unresolved_firstsep` rule that decides
whether an absent final component is the answer or an error. The last clause is
why the trailing separator is threaded separately: MRI walks the path as
WRITTEN, so realdirpath("dir/absent/") raises where realdirpath("dir/absent")
does not, and expanding the path (which the base-directory argument requires)
drops it.

The two error WORDINGS are not the same, and now differ correctly: File.realpath
reaches realpath(3) first, so ENOENT and ELOOP name rb_check_realpath_internal
and the whole argument, while ENOTDIR falls through to the emulation and names
realpath_rec and the failing component. File.realdirpath is the emulation, so it
always names realpath_rec.

Verified byte-for-byte against MRI 4.0.5 over 25 expressions (dangling links,
links into missing directories, cycles, relative and absolute link targets,
"..", trailing separators, a regular file used as a directory) — identical.

core/file/realdirpath_spec.rb: 3 -> 4.

The other 11 realpath/realdirpath failures are NOT VM defects: the shim's
SPEC_TMP_BASE is "/tmp", which on darwin is a symlink to /private/tmp, so every
expectation of the form `File.realpath(link).should == @file` compares a
resolved path with an unresolved one. MRI 4.0.5 fails them the same way — it
also answers /private/tmp there. They need SPEC_TMP_BASE moved off a symlinked
root in scripts/conformance/rubyspec/spec_helper.rb, which this branch does not
own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…spect, #inspect through #to_s

Four defects, all in this branch's files.

1. File::Stat#atime and #ctime REPORTED THE MODIFICATION TIME. Not an
   approximation — the same number, so File.utime(2000, 2001, f) came back as
   (2001, 2001) and core/file/lutime_spec.rb was reading a value rbgo could not
   have got right. MRI reads st_atime / st_ctime off struct stat (ruby/ruby
   v3_4_0 file.c rb_file_s_atime, stat_atime). Go's syscall.Stat_t has them, but
   under names that differ per platform — Atim/Ctim on Linux, Atimespec/
   Ctimespec on the BSDs and macOS — so shared code cannot name one and
   compile. statTimeField reads whichever the platform carries by reflection,
   which keeps the rule in the shared file where the POSIX lanes cover it
   instead of splitting it across four build-tagged copies. A stat struct with
   neither still falls back to the modification time, which is what rbgo
   reported everywhere before.

2. File::Stat#birthtime raised NotImplementedError unconditionally. It now
   answers where the platform has a creation time (st_birthtime on the BSDs and
   macOS) and keeps MRI's NotImplementedError — same message — where there is
   none, which is MRI's own HAVE_STRUCT_STAT_ST_BIRTHTIMESPEC guard and what
   MRI does on Linux.

3. File::Stat#inspect was the placeholder "#<File::Stat>". It is now
   rb_stat_inspect (ruby/ruby v3_4_0 file.c:1080): the member list in that
   order, dev and rdev in hex, mode in octal with a leading zero, birthtime only
   where it exists. It is written as the value's native Inspect, not as a
   dispatch through the Ruby accessors, because rb_stat_inspect calls the C
   member functions directly — a subclass overriding #uid does not change what
   #inspect prints — and because Kernel#p reaches the same rendering, so `p st`
   and `st.inspect` cannot drift. Two existing tests asserted the placeholder;
   they now assert the real shape, witnessed against MRI 4.0.5.

4. File#stat and File#lstat did not exist, which is what core/file/stat/
   comparison_spec.rb was failing on before it could compare anything. Added in
   registerFileStat beside File.stat/File.lstat. They take no argument
   (ArgumentError otherwise) and refuse a closed stream with IOError, but NOT a
   read-only one — MRI's rb_io_stat works there, so ioCheckOpen is the wrong
   guard.

Also Exception#inspect: it read the stored @message where MRI's exc_inspect
(error.c:1838) goes through rb_obj_as_string(exc), i.e. dispatches #to_s. A
subclass that overrides #to_s therefore changes what #inspect reports, and a
#to_s returning a non-String falls back to the object's identity form. rbgo
already had objAsString with exactly those semantics; #inspect now uses it
rather than a second reading of the same rule.

Verified byte-for-byte against MRI 4.0.5: #inspect over 7 to_s shapes, the
atime/lutime/utime triple, File#stat/#lstat over a symlink, and
File::Stat#inspect built member by member the way the spec builds it.

core/file 618 -> 624, core/exception 172 -> 174, core/filetest unchanged (87).
lutime_spec 6 -> 8, stat/inspect_spec 0 -> 1, stat/comparison_spec 0 -> 2,
stat_spec 4 -> 5, lstat_spec 4 -> 5, exception/inspect_spec 3 -> 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MRI resolves the argument of its whole stat-predicate family through one
function, rb_stat (ruby/ruby v3_4_0 file.c:1304):

    tmp = rb_check_convert_type_with_id(file, T_FILE, "IO", idTo_io);
    if (!NIL_P(tmp)) { GetOpenFile(tmp, fptr); result = fstat_without_gvl(fptr, st); }
    else { FilePathValue(file); result = stat_without_gvl(RSTRING_PTR(file), st); }

so File.directory?(io), File.size(io) and FileTest.directory?(anything_with_
to_io) all work. rbgo coerced every one of them with pathArg, which is
FilePathValue alone, so an IO or a #to_io object was "no implicit conversion of
X into String".

statPathArg now does what rb_stat does, and is applied to exactly the seventeen
methods that call rb_stat in file.c — directory?, pipe?, socket?, blockdev?,
chardev?, setuid?, setgid?, sticky?, owned?, grpowned?, exist?, file?,
zero?/empty?, size?, world_readable?, world_writable?, identical? and
File.size. NOT to readable?/writable?/executable? and the *_real? family, which
go through rb_eaccess, nor to symlink?/ftype, which lstat a path. (An open File
still resolves there, through File#to_path — MRI answers true for
File.readable?(io), and so does rbgo; the difference is which rule gets it
there.)

A #to_io that answers something other than an IO is not silently ignored:
rb_check_convert_type_with_id raises "can't convert Object to IO (Object#to_io
gives Integer)", which statPathArg reproduces.

Verified byte-for-byte against MRI 4.0.5 over 14 expressions, including
File.directory?(STDIN) and an open directory handle.

core/file 624 -> 627, core/filetest 87 -> 88. directory_spec 7 -> 8 on both
receivers, size_spec 18 -> 20.

Coverage: every function in internal/vm is at the same percentage as on main,
with the new code at 100%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…has no witness

The arch lanes (linux amd64/arm64) went red on one assertion:

    errnoStrerror(92) = "Protocol not available", want "Illegal byte sequence"

92 is EILSEQ on darwin and ENOPROTOOPT on Linux. The test hard-coded the
NUMBER and darwin's text for it, which asserts one platform's <errno.h> on
every lane — exactly the mistake this lane has paid for before.

TestErrnoStrerror now separates the two things it was conflating:

  - The RULE, which is portable: for an errno the platform's own table names,
    errnoStrerror is that text with its leading letter restored. Checked for
    every name in errnoNumbers, on every platform.
  - The TEXTS, which are not: the literals are MRI 4.0.5's output on darwin,
    the only platform with an MRI witness on this host, so they are skipped
    elsewhere rather than loosened to whatever the lane happens to print.

Two more platform assumptions found by reading, before the remaining lanes
reported:

  - The "unknown errno" wording only applies where the platform leaves Go's
    "errno N" placeholder. Windows' FormatMessage names every number, so there
    is nothing to substitute and nothing to assert.
  - TestSyserrInitializeShapes asserts strerror texts (darwin and glibc agree on
    the three it uses). Windows has no POSIX errno numbers at all — Go spells
    them APPLICATION_ERROR+n — so it skips there, naming the missing witness.
  - TestFileStatInspect guessed birthtime's presence from runtime.GOOS, which
    was wrong for Windows (Sys() is a Win32FileAttributeData whose CreationTime
    is a Filetime, not a Sec-bearing timespec). It now asks the object, which is
    also what core/file/stat/inspect_spec.rb does.
  - TestFileStatAtimeIsNotMtime skips on Windows for the same reason: atime
    falls back to mtime there, MRI on Windows reports a real one, and there is
    no Windows MRI here to witness the right fix.

`go vet` now passes for darwin, linux/amd64, linux/arm64, windows/amd64 and
wasip1/wasm — which compiles the tests, not only the package. Coverage is
unchanged: identical to main, function for function.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled and others added 3 commits September 23, 2026 17:05
…isagreed with #blocks

Two product defects the Windows lane found, and one test that asserted one
platform's strerror.

**`realpath_rec` built `/C:`.** `realpathResolve` started the walk at `"/"` and
split the whole path on `/`, so on Windows the volume letter became an ordinary
component and every `File.realpath` / `File.realdirpath` produced
`/C:/Users/...` — a path no such filesystem has. It broke `TestFileIO` and
`TestFileRealdirpath` on windows-latest, and it would have broken
`File.realpath` for every Windows user, not only the tests.

MRI does not start at the root either: `rb_check_realpath_emulate` runs the
path through `skipprefixroot` first and threads the resulting **prefixlen**
through `realpath_rec` (ruby/ruby v3_4_0 file.c:4324, and the
`if (*prefixlenp < RSTRING_LEN(*resolvedp))` guard in its `..` branch). That
prefixlen is not decoration: it is what stops `..` walking above a root that is
more than one character long. rbgo now does the same — `realpathRoot` splits off
the volume (`filepath.VolumeName`, empty on POSIX, `C:` or `//host/share` on
Windows), the walk starts there, `realpathParent` truncates no further, and a
symlink target that names a root of its own restarts from that root rather than
from `/`.

On POSIX the change is a no-op by construction — `filepath.VolumeName` returns
"" there, so the root is `"/"` and nothing is consumed — which is asserted
rather than assumed (`TestRealpathRoot`). The multi-character-root behaviour is
covered on the POSIX lanes too, by passing the prefix length directly to the
primitives instead of waiting for a platform where `VolumeName` returns
something.

**`File::Stat#inspect` printed `blocks=0` where `#blocks` answers `nil`.** On
Windows there is no POSIX stat behind the object, so `#blocks` returns nil (as
MRI does where HAVE_STRUCT_STAT_ST_BLOCKS is undefined) — but the native
`Inspect` formatted the raw field. I had written the member values a second time
instead of reading the accessors' rule, and they drifted at the one member that
can be nil. `blocksValue` is now that rule, in one place, read by both. This is
the defect, not the test: a member-by-member comparison is exactly what should
catch it, and it did.

**`TestRealpathSyscallErr` asserted "Not a directory".** The class is portable;
the message is the platform's strerror, and Windows words it "The system cannot
find the path specified." There is no Windows MRI on this host to witness what
MRI prints there, so the text is asserted only where it was witnessed (MRI 4.0.5
on darwin) rather than relaxed to whatever the lane returns — see the errno-text
issue. The class assertion still runs everywhere.

Merged origin/main (#628, #537) and re-measured against it, whole corpus, per
file: **21 815 -> 21 895, and not one spec file lost an example.**
core/file 618 -> 629, core/filetest 45 -> 88, core/exception 149 -> 174.
`gofmt` clean; `go vet ./...` passes for darwin, linux/amd64, linux/arm64,
windows/amd64 and wasip1/wasm, which compiles the tests too; coverage identical
to main function for function, new code at 100%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two assertions failed on windows, and neither was the VM being wrong.

File::Stat#inspect printed `blocks=nil, blksize=0` where the layout regexp
demanded digits. That IS MRI's answer: #blocks is nil where
HAVE_STRUCT_STAT_ST_BLOCKS is undefined, which is every Windows build, and
rb_stat_inspect prints that nil. blocksValue already models it; only the test
insisted on a number no Windows stat can produce. The regexp now accepts either
shape, with the reason beside it.

TestFileRealdirpath compared File.realdirpath against Go's
filepath.EvalSymlinks. On POSIX the two canonicalisers agree; on Windows
EvalSymlinks additionally expands an 8.3 short name (C:/Users/RUNNER~1 ->
C:/Users/runneradmin) and realpath_rec does not, so the test was measuring Go's
normalisation rather than ours. Whether MRI expands it there is unwitnessed —
there is no Windows MRI on this host — so the full equality is asserted where
the two agree by construction, and windows asserts what holds either way:
absolute, forward-slashed, naming the file. The case is not skipped; realdirpath
is still exercised on that lane. The open question is issue #636.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Sep 23, 2026
…, and a plain limitations section

Verified item by item against main (68cb53a) with a probe run through the
built binary, MRI 4.0.5 on the same host as the oracle:

* Process (spawn/exec, the wait family, process groups, priorities, rlimits) —
  and that Process.fork does NOT exist
* Fiber#raise / #kill / storage / blocking?
* IO.popen / fcntl / select / read_nonblock
* File.realpath / realdirpath, and FileTest — 11 of MRI's 26 predicates, with
  the 15 that are missing named
* autoload, defined?(super), module_function, the predefined globals with MRI's
  per-name setters, trace_var
* Thread#backtrace: current thread only; another thread raises
  NotImplementedError

New "Not implemented" section. __LINE__ is always 0 — rbgo records no line map —
which costs const_source_location, warn(uplevel:) and every backtrace line
number; the section shows the rbgo/MRI output side by side. The other entries
each carry their open issue (#610 #615 #621 #630 #631 #632 #633 #635 #636),
with the two that are harness rather than VM marked as such.

Errno was NOT claimed at 158 constants: main has 28. The full table is in
#629, still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit d498ddc into main Sep 23, 2026
7 checks passed
@tannevaled
tannevaled deleted the wave27/fileexc branch September 23, 2026 17:10
tannevaled added a commit that referenced this pull request Sep 23, 2026
…, and a plain limitations section

Verified item by item against main (68cb53a) with a probe run through the
built binary, MRI 4.0.5 on the same host as the oracle:

* Process (spawn/exec, the wait family, process groups, priorities, rlimits) —
  and that Process.fork does NOT exist
* Fiber#raise / #kill / storage / blocking?
* IO.popen / fcntl / select / read_nonblock
* File.realpath / realdirpath, and FileTest — 11 of MRI's 26 predicates, with
  the 15 that are missing named
* autoload, defined?(super), module_function, the predefined globals with MRI's
  per-name setters, trace_var
* Thread#backtrace: current thread only; another thread raises
  NotImplementedError

New "Not implemented" section. __LINE__ is always 0 — rbgo records no line map —
which costs const_source_location, warn(uplevel:) and every backtrace line
number; the section shows the rbgo/MRI output side by side. The other entries
each carry their open issue (#610 #615 #621 #630 #631 #632 #633 #635 #636),
with the two that are harness rather than VM marked as such.

Errno was NOT claimed at 158 constants: main has 28. The full table is in
#629, still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Sep 23, 2026
…line

#629 (FileTest, the Errno subsystem, realpath_rec, the stat surface) merged at
17:10Z, ten minutes after the 68cb53a baseline this branch was written against.
Two statements were left wrong in the conservative direction. Rebased onto
d498ddc, rebuilt, re-measured everything.

ruby/spec, four consecutive runs on d498ddc: 21982, all four identical (the
68cb53a runs spread 21845-21903 on #615; it did not fire this time, but the
issue is open, so the text still says to run more than once and take the low
run). Detail sweep: 21983 pass, 1321 fail, 740 error, 473 skip, 2191 of 2206
files producing a result, 91.4% of the 24044 examples that ran.

The floor on main is still 21740; #638 is open to take it to 21970. Every place
the floor appears now says which one it is and names #638, rather than carrying
a number that goes stale when that PR merges.

Errno: was "28 constants, not MRI's 158". Measured on d498ddc, Errno.constants
is 158 under rbgo AND under MRI 4.0.5. Dumping every name/value pair from both
and joining them: all 158 names present in both, 32 disagree, and all 32 are
the same shape -- rbgo reports 0 where MRI on darwin has a real number (EAUTH
0 vs 80, EBADRPC 0 vs 72, EDEVERR 0 vs 83). The other 126 match exactly. That
is #633, and 32 is the number that matters rather than the 81 constants that
read 0 in rbgo, because MRI reads 0 for 49 of them too.

FileTest: was "11 of MRI's 26 predicates", with 15 listed as not implemented.
Measured: FileTest.methods(false) is 26 under both, name for name identical.
Differentially tested all 26 against MRI 4.0.5 over a temp tree (regular file,
empty file, directory, symlink, executable, FIFO, missing path, /dev/null,
/dev/disk0): 252 assertions, 0 disagreements.

Everything else in the limitations table re-probed on d498ddc and still holds:
Numeric#to_int NoMethodError, File::Stat#== identity ([false, true] vs MRI's
[true, true]), __LINE__ 0, const_source_location nil, Thread#backtrace of
another thread NotImplementedError, no Process.fork. All nine issues still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Sep 23, 2026
Four units:

  #628  language (compiler): defined?(super) — 18 examples behind one opcode —
        the defined? tags frozen, array/hash literal probing, the two method
        probes MRI distinguishes, block auto-splat through #to_ary, and the
        pattern-matching deconstruct contract   (language 2158 -> 2222)
  #634  predefined globals, trace_var/untrace_var, rb_feature_p, Kernel#Complex,
        Module#module_function, Kernel#warn and #open
                                   (predefined_spec 73 -> 124 passing,
                                    core/kernel +26, core/module +6)
  #629  FileTest re-exported from File's own records, the Errno table from 28
        names to MRI's 158, SystemCallError#initialize/#errno/.===, the real
        realpath/realdirpath walk, File::Stat#atime/#ctime (both had returned
        mtime), #inspect, File#stat/#lstat
                                   (core/file 618 -> 629, core/filetest 45 -> 88,
                                    core/exception 149 -> 174)
  #537  ci: actions/setup-go v7 (unblocked by rebasing; it had been red for
        three weeks on a coverage gate main no longer enforces on that lane)

Measured with scripts/conformance/rubyspec/run.sh against the pinned corpus at
87b1631, 16-way, TIMEOUT=25, three consecutive runs: 21983, 21982, 21981. The
floor is set eleven below the lowest, the same margin rule as the last two
bumps: enough for the run-to-run jitter, not enough to hide the loss of a whole
spec file. #615 remains open and can still cost a file when it fires.

This bump is VM conformance throughout — unlike the previous one, none of it
comes from the harness or from a dependency making new files visible.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Sep 23, 2026
…, and a plain limitations section

Verified item by item against main (68cb53a) with a probe run through the
built binary, MRI 4.0.5 on the same host as the oracle:

* Process (spawn/exec, the wait family, process groups, priorities, rlimits) —
  and that Process.fork does NOT exist
* Fiber#raise / #kill / storage / blocking?
* IO.popen / fcntl / select / read_nonblock
* File.realpath / realdirpath, and FileTest — 11 of MRI's 26 predicates, with
  the 15 that are missing named
* autoload, defined?(super), module_function, the predefined globals with MRI's
  per-name setters, trace_var
* Thread#backtrace: current thread only; another thread raises
  NotImplementedError

New "Not implemented" section. __LINE__ is always 0 — rbgo records no line map —
which costs const_source_location, warn(uplevel:) and every backtrace line
number; the section shows the rbgo/MRI output side by side. The other entries
each carry their open issue (#610 #615 #621 #630 #631 #632 #633 #635 #636),
with the two that are harness rather than VM marked as such.

Errno was NOT claimed at 158 constants: main has 28. The full table is in
#629, still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Sep 23, 2026
…line

#629 (FileTest, the Errno subsystem, realpath_rec, the stat surface) merged at
17:10Z, ten minutes after the 68cb53a baseline this branch was written against.
Two statements were left wrong in the conservative direction. Rebased onto
d498ddc, rebuilt, re-measured everything.

ruby/spec, four consecutive runs on d498ddc: 21982, all four identical (the
68cb53a runs spread 21845-21903 on #615; it did not fire this time, but the
issue is open, so the text still says to run more than once and take the low
run). Detail sweep: 21983 pass, 1321 fail, 740 error, 473 skip, 2191 of 2206
files producing a result, 91.4% of the 24044 examples that ran.

The floor on main is still 21740; #638 is open to take it to 21970. Every place
the floor appears now says which one it is and names #638, rather than carrying
a number that goes stale when that PR merges.

Errno: was "28 constants, not MRI's 158". Measured on d498ddc, Errno.constants
is 158 under rbgo AND under MRI 4.0.5. Dumping every name/value pair from both
and joining them: all 158 names present in both, 32 disagree, and all 32 are
the same shape -- rbgo reports 0 where MRI on darwin has a real number (EAUTH
0 vs 80, EBADRPC 0 vs 72, EDEVERR 0 vs 83). The other 126 match exactly. That
is #633, and 32 is the number that matters rather than the 81 constants that
read 0 in rbgo, because MRI reads 0 for 49 of them too.

FileTest: was "11 of MRI's 26 predicates", with 15 listed as not implemented.
Measured: FileTest.methods(false) is 26 under both, name for name identical.
Differentially tested all 26 against MRI 4.0.5 over a temp tree (regular file,
empty file, directory, symlink, executable, FIFO, missing path, /dev/null,
/dev/disk0): 252 assertions, 0 disagreements.

Everything else in the limitations table re-probed on d498ddc and still holds:
Numeric#to_int NoMethodError, File::Stat#== identity ([false, true] vs MRI's
[true, true]), __LINE__ 0, const_source_location nil, Thread#backtrace of
another thread NotImplementedError, no Process.fork. All nine issues still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Sep 23, 2026
…), and the claims that had drifted (#637)

* README: measured ruby/spec conformance, and the claims that had drifted

The ruby/spec figure said 15,400+, which was the FLOOR bumped on 2026-08-25
(#451). Measured on main (68cb53a) against the pinned corpus today: 21,901
passing, four runs 21845/21903/21901/21902, floor 21,740.

Also corrected against the code and .github/workflows/ci.yml:

* the 100% coverage claim ("across all six 64-bit targets and three OSes"):
  the gate runs on ubuntu + macOS only, per-PR arch lanes are amd64/arm64
  without coverage, the exotic four are nightly QEMU + scheduled hardware
* the bound-module count, 181 -> 178 modules across 176 orgs, with the seven
  bound outside the go-ruby-* convention named
* Set (in-tree over object.Hash, not go-composites/set), BigDecimal
  (go-ruby-bigdecimal, not go-composites/bigfloat) and Date (go-ruby-date,
  not go-composites/date)
* the phase badge: plan-rbgo.md marks Phase 6 "substantially landed", not done

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* README: the subsystems that landed since the feature list was written, and a plain limitations section

Verified item by item against main (68cb53a) with a probe run through the
built binary, MRI 4.0.5 on the same host as the oracle:

* Process (spawn/exec, the wait family, process groups, priorities, rlimits) —
  and that Process.fork does NOT exist
* Fiber#raise / #kill / storage / blocking?
* IO.popen / fcntl / select / read_nonblock
* File.realpath / realdirpath, and FileTest — 11 of MRI's 26 predicates, with
  the 15 that are missing named
* autoload, defined?(super), module_function, the predefined globals with MRI's
  per-name setters, trace_var
* Thread#backtrace: current thread only; another thread raises
  NotImplementedError

New "Not implemented" section. __LINE__ is always 0 — rbgo records no line map —
which costs const_source_location, warn(uplevel:) and every backtrace line
number; the section shows the rbgo/MRI output side by side. The other entries
each carry their open issue (#610 #615 #621 #630 #631 #632 #633 #635 #636),
with the two that are harness rather than VM marked as such.

Errno was NOT claimed at 158 constants: main has 28. The full table is in
#629, still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* README: use one thousands separator throughout the new figures

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* README: re-measure against d498ddc — #629 merged after the first baseline

#629 (FileTest, the Errno subsystem, realpath_rec, the stat surface) merged at
17:10Z, ten minutes after the 68cb53a baseline this branch was written against.
Two statements were left wrong in the conservative direction. Rebased onto
d498ddc, rebuilt, re-measured everything.

ruby/spec, four consecutive runs on d498ddc: 21982, all four identical (the
68cb53a runs spread 21845-21903 on #615; it did not fire this time, but the
issue is open, so the text still says to run more than once and take the low
run). Detail sweep: 21983 pass, 1321 fail, 740 error, 473 skip, 2191 of 2206
files producing a result, 91.4% of the 24044 examples that ran.

The floor on main is still 21740; #638 is open to take it to 21970. Every place
the floor appears now says which one it is and names #638, rather than carrying
a number that goes stale when that PR merges.

Errno: was "28 constants, not MRI's 158". Measured on d498ddc, Errno.constants
is 158 under rbgo AND under MRI 4.0.5. Dumping every name/value pair from both
and joining them: all 158 names present in both, 32 disagree, and all 32 are
the same shape -- rbgo reports 0 where MRI on darwin has a real number (EAUTH
0 vs 80, EBADRPC 0 vs 72, EDEVERR 0 vs 83). The other 126 match exactly. That
is #633, and 32 is the number that matters rather than the 81 constants that
read 0 in rbgo, because MRI reads 0 for 49 of them too.

FileTest: was "11 of MRI's 26 predicates", with 15 listed as not implemented.
Measured: FileTest.methods(false) is 26 under both, name for name identical.
Differentially tested all 26 against MRI 4.0.5 over a temp tree (regular file,
empty file, directory, symlink, executable, FIFO, missing path, /dev/null,
/dev/disk0): 252 assertions, 0 disagreements.

Everything else in the limitations table re-probed on d498ddc and still holds:
Numeric#to_int NoMethodError, File::Stat#== identity ([false, true] vs MRI's
[true, true]), __LINE__ 0, const_source_location nil, Thread#backtrace of
another thread NotImplementedError, no Process.fork. All nine issues still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* README: the limitations section names d498ddc too

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* README: the floor IS 21,970 — #638 merged

#638 merged at 18:02Z. FLOOR on origin/main is 21970, which I re-read from the
tree. Three mentions phrased it as a pending proposal and are now simply wrong.

The arithmetic mattered more than the link. Every sentence built on a floor of
21,740 has been recomputed: the margin is 12, not ~240, and it appeared as
"~240 above the floor" in two places and "the gap is ~240 examples" in a third.

Rewrote the floor-vs-score explanation rather than swapping the number into it.
The old wording said the measured total "normally sits above" the floor and the
floor is "raised deliberately after a win", which reads as the floor lagging out
of laziness and leaves a reader wondering why 21,982 and 21,970 are nearly
equal. #638 states the actual rule, and its own arithmetic confirms it -- its
lowest run was 21981 against a floor of 21970, eleven below:

  the floor is set a handful of examples below the LOWEST observed run --
  enough to absorb the #615 jitter, and no more, because a floor slack enough
  to hide the loss of a whole spec file would defeat what the ratchet exists
  to catch.

So a small gap is the ratchet working; a large one would mean it had gone slack.
My own four runs give a margin of 12 against the same rule.

Provenance: the measurement was taken on d498ddc. main is now 2200e17, whose
only change is the FLOOR file, so I rebuilt and compared -- the binaries are
byte-identical (sha256 32cd4f9e...), and the figures carry over rather than
being assumed to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Sep 26, 2026
… a lane

Two screens failed open around #682 and both are closed here.

ci.yml gated wasip1 only. GOOS=js and GOOS=wasip1 are different targets with
different syscall packages, so the wasip1 lane stayed green for three days while
the browser target did not compile at all. The new `wasm (js, browser)` lane
builds ./... for js/wasm -- not just ./cmd/wasm, because the break was three names
in internal/vm, a package the playground reaches only transitively -- then vets,
builds the playground module and RUNS it.

Running it is the part that matters. web/smoke.mjs loads the module into node with
Go's own lib/wasm/wasm_exec.js, the same glue web/index.html loads in a browser,
waits for main() to publish its entry points and then calls rbgoEval: arithmetic
and $stdout capture, a rescued Errno::ENOENT, a syntax error coming back as an
error string rather than a crash, and both halves of errno.go's bucket rule live
in the browser runtime (Errno::ENOENT::Errno is 2, Errno::ETXTBSY is
Errno::NOERROR here, Errno.constants.size is 158). A compile is not a witness for
a runtime target.

The second screen: three integration tests in cmd/rbgo sat behind
`os.Getenv("RBGO_BUILD_IT")` and `grep -rn RBGO_BUILD_IT .github/workflows/`
returned nothing. TestClosedWasmBuildIntegration fails on the #629 regression with
exactly its three errors -- correct, complete and unreachable. The choice is to
wire it in, in its own lane, because those three cover what nothing else does: the
nested `go build` the rbgo command runs, that --closed really drops the front-end,
and that a baked program executes.

MEASURED COST, cold GOCACHE, M-series host (a GitHub runner is slower):

  build ./... (js/wasm)                                14s
  vet ./cmd/wasm ./cmd/rbgo ./internal/vm (js/wasm)    18s
  ./cmd/wasm module                                    96.9 MB
  ./cmd/rbgo module                                    98.4 MB
  warm rebuild of the module                            1s
  all three RBGO_BUILD_IT tests                        35s

Both lanes together are minutes, none of it -race-priced, and they run in parallel
with the existing ones -- next to the 20-90 min -race coverage lane the addition
is small. Being its own lane rather than a step in `test` keeps it off that lane's
critical path.

Proof the new lane is a gate: `CGO_ENABLED=0 GOOS=js GOARCH=wasm go build ./...`
run in a detached worktree at origin/main (2aa1c94, before this branch) fails with
the three undefined-symbol errors. A gate nobody has seen fail is not known to be
a gate.

TestClosedWasmBuildIntegration's docstring claimed the baked program's JS.log line
"ran in a headless browser when one is available". No version of that function ever
ran anything -- it read the module's first four bytes and scanned it for symbols.
It now really runs the module under node with the toolchain's wasm_exec_node.js and
asserts both JS.log lines reach the console, including that the absent `document`
global comes back as a value rather than trapping. node is a hard requirement of
RBGO_BUILD_IT=1 and a missing node fails the test: a check that steps aside when
its host is absent is the exact failure this lane exists to correct.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Sep 26, 2026
…t it reachable (#684)

* internal/vm: put the three js/wasm-undefined errno names in a per-target bucket

GOOS=js GOARCH=wasm has not compiled since #629: errno.go referred to
syscall.ENOTRECOVERABLE, syscall.EOWNERDEAD and syscall.ETXTBSY, which Go's
js/wasm syscall package does not define. wasip1 defines all three, so the
existing wasm lane could not see it.

errno.go already had the rule this needs: a known_errors name with no number on
this platform is still a constant, bound to Errno::NOERROR, the way MRI's
undefined_error does it. The three names were simply in the wrong bucket on one
target. They now live in errnoPlatformNames, with the numbers (or their absence)
supplied per target by errno_native.go and errno_wasm.go -- the same
!(js && wasm) / (js && wasm) file pair this package already uses for
jsbridge and servergems. No number is hard-coded; the browser has no MRI to
witness one against.

The bucket rule is now stated once, at the top of errno.go, with the invariant it
exists to protect: the buckets partition known_errors on every target, so a name
that leaves one bucket must arrive in another or Errno.constants silently
shrinks.

TestErrnoBucketsPartitionKnownNames asserts that partition against a literal 158,
MRI's known_errors count. That matters: TestErrnoClassRegistration compares the
registered constants to len(errnoNumbers)+len(errnoUndefinedNames)+len(errnoAliases),
an expectation that shrinks with its subject -- orphaning ETXTBSY in a scratch
mutation left it green while the new test failed with "the buckets hold 157
distinct names, want 158".

Measured while enumerating rather than fixing the three names given: js/wasm is
the ONLY one of darwin/{amd64,arm64}, linux/{amd64,386,arm64,riscv64,loong64,
ppc64le,s390x}, windows/{amd64,arm64}, wasip1/wasm and js/wasm that lacks any
name errnoNumbers claimed was universal. The comment claiming Go exposes the
other known_errors names "only under a build tag" is wrong and is corrected: they
are plainly available per-GOOS (57 of 81 on linux/amd64, 56 on windows, 31 on
darwin/amd64, 2 on wasip1), so the 0 rbgo reports for them is a larger declared
conformance gap than the one macOS EAUTH the comment named. Closing it still
needs a per-platform MRI witness and is left to the open per-platform errno
issue.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: a js/wasm lane that runs the module, and RBGO_BUILD_IT wired into a lane

Two screens failed open around #682 and both are closed here.

ci.yml gated wasip1 only. GOOS=js and GOOS=wasip1 are different targets with
different syscall packages, so the wasip1 lane stayed green for three days while
the browser target did not compile at all. The new `wasm (js, browser)` lane
builds ./... for js/wasm -- not just ./cmd/wasm, because the break was three names
in internal/vm, a package the playground reaches only transitively -- then vets,
builds the playground module and RUNS it.

Running it is the part that matters. web/smoke.mjs loads the module into node with
Go's own lib/wasm/wasm_exec.js, the same glue web/index.html loads in a browser,
waits for main() to publish its entry points and then calls rbgoEval: arithmetic
and $stdout capture, a rescued Errno::ENOENT, a syntax error coming back as an
error string rather than a crash, and both halves of errno.go's bucket rule live
in the browser runtime (Errno::ENOENT::Errno is 2, Errno::ETXTBSY is
Errno::NOERROR here, Errno.constants.size is 158). A compile is not a witness for
a runtime target.

The second screen: three integration tests in cmd/rbgo sat behind
`os.Getenv("RBGO_BUILD_IT")` and `grep -rn RBGO_BUILD_IT .github/workflows/`
returned nothing. TestClosedWasmBuildIntegration fails on the #629 regression with
exactly its three errors -- correct, complete and unreachable. The choice is to
wire it in, in its own lane, because those three cover what nothing else does: the
nested `go build` the rbgo command runs, that --closed really drops the front-end,
and that a baked program executes.

MEASURED COST, cold GOCACHE, M-series host (a GitHub runner is slower):

  build ./... (js/wasm)                                14s
  vet ./cmd/wasm ./cmd/rbgo ./internal/vm (js/wasm)    18s
  ./cmd/wasm module                                    96.9 MB
  ./cmd/rbgo module                                    98.4 MB
  warm rebuild of the module                            1s
  all three RBGO_BUILD_IT tests                        35s

Both lanes together are minutes, none of it -race-priced, and they run in parallel
with the existing ones -- next to the 20-90 min -race coverage lane the addition
is small. Being its own lane rather than a step in `test` keeps it off that lane's
critical path.

Proof the new lane is a gate: `CGO_ENABLED=0 GOOS=js GOARCH=wasm go build ./...`
run in a detached worktree at origin/main (2aa1c94, before this branch) fails with
the three undefined-symbol errors. A gate nobody has seen fail is not known to be
a gate.

TestClosedWasmBuildIntegration's docstring claimed the baked program's JS.log line
"ran in a headless browser when one is available". No version of that function ever
ran anything -- it read the module's first four bytes and scanned it for symbols.
It now really runs the module under node with the toolchain's wasm_exec_node.js and
asserts both JS.log lines reach the console, including that the absent `document`
global comes back as a value rather than trapping. node is a hard requirement of
RBGO_BUILD_IT=1 and a missing node fails the test: a check that steps aside when
its host is absent is the exact failure this lane exists to correct.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* cmd/rbgo, ci: name the reason the closed wasm module exits non-zero, and the leak

Two things came out of actually RUNNING what RBGO_BUILD_IT was hiding.

The closed wasm module aborts after its program finishes, and that is by design:
closed_main_wasm.go ends in select{} to keep the runtime alive for JS event and
animation callbacks, and the baked test program registers none, so Go's deadlock
detector fires once every goroutine is parked. The test now asserts that REASON
rather than tolerating any failure -- "all goroutines are asleep - deadlock!"
after both JS.log lines reached the console is the documented behaviour; a trap, a
link error or a Ruby exception still fails.

The second is the finding: with RBGO_BUILD_IT on, both closed-world tests fail
their "the front-end must not be linked" assertion, on this branch and identically
on the commit it branched from (verified in a detached worktree at origin/main,
unmodified). `rbgo build --closed` announces "front-end dropped (no
lexer/parser/compiler linked)" and produces a 172.9 MiB binary holding 255
github.com/go-ruby-parser/parser.(*Parser).* text symbols.

The cause is one edge: internal/vm/rubocop.go and rubocop_bind.go carry no build
tags, and github.com/go-ruby-rubocop/rubocop imports the parser, so the front-end
re-enters the closed dependency graph through the RuboCop binding --
`go list -deps -tags rbgo_closed ./cmd/rbgo` names it and nothing else.

That fix is not here. It is a scope decision about what --closed supports (does a
closed build still answer `require "rubocop"`, or degrade to a LoadError as the
network backends do on wasm?) and it touches internal/vm. The lane says so in
place, so the next reader meets the diagnosis rather than a red tick. No assertion
was weakened to make it green: a check nobody runs is documentation, and the point
of turning the var on was to stop that.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: setup-node@v7, not v4

The repo pins checkout and setup-go at v7 under a "track the latest stable"
convention. actions/setup-node latest major is v7 too; v4 was three majors
stale on arrival. Caught by reading the action back with `gh api
repos/actions/setup-node/git/matching-refs/tags/v` rather than assuming the
major a different action happens to be on.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: replace the runner-cost estimate with the runner measurement

The lane comments carried a laptop number and the word "budget". The
build-integration lane has now run on ubuntu-latest: 141s of test time, 5.3 min
for the job, against 35s cold on an M-series host -- the runner is 4.0x slower,
which is the figure to reuse rather than the guess.

The useful frame is not "cheap" but where it lands among lanes this file already
pays for on the same commit: wasm (wasip1) 2.0 min, rbgo build integration 5.3
min, arch arm64 5.9 min, arch amd64 9.4 min. The new lane is cheaper than a lane
already on every PR. wasm (js, browser) has not had a runner of its own yet, so
the comment says that instead of inventing a number for it.

Also records that the lane fails on Linux for EXACTLY the one predicted reason:
TestBuildIntegration, TestBuildEnv and TestRunSendsWarningsToTheRealStderr pass,
the node run of the closed wasm module passes, and the only failing lines are the
two "still links the front-end" assertions. The leak is platform-independent.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: the js/wasm lane is measured too -- 1.9 min, the cheapest lane in the file

The lane comment said it had not yet had a runner of its own to time. It has now:
1.9 min end to end on ubuntu-latest with all nine smoke checks green, which makes
it the cheapest lane here -- below the wasm (wasip1) lane beside it at 2.0 min.
No estimate is left in either new lane comment.

The runner log also carries the part that matters more than the timing: the bucket
rule witnessed inside a JS host ON LINUX, not just on a laptop --
Errno::ETXTBSY::Errno is 0 there, Errno::ETXTBSY.equal?(Errno::NOERROR) is true,
Errno.constants.size is 158. That is the half of #682 no compile-only check could
have reached.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* internal/vm: a closed-world build must not link a linter

`rbgo build --closed` announced "front-end dropped (no lexer/parser/compiler
linked)" and then linked the parser anyway. internal/vm/rubocop.go and
rubocop_bind.go carried no build tags, and github.com/go-ruby-rubocop/rubocop
imports github.com/go-ruby-parser/parser -- a linter needs an AST to inspect --
so the front-end re-entered the closed dependency graph through the RuboCop
binding. Both closed-build integration tests asserted against it and both failed,
which is what wiring RBGO_BUILD_IT to a CI lane finally made visible (#682).

The reasoning for dropping rather than keeping: --closed already promises no
front-end, and a lint needs a parser, so a linter has no business in a binary that
claims to have dropped one. There is also no useful subset of RuboCop to keep, so
this takes the LoadError degradation of backends_wasm.go rather than the
graceful-module one of bindings_wasm.go: `require "rubocop"` fails exactly as a
missing gem would.

  rubocop.go, rubocop_bind.go            -> //go:build !rbgo_closed
  rubocop_test.go, rubocop_internal_test.go -> the same, since they assert the
                                            open build's behaviour
  rubocop_closed.go (rbgo_closed)        -> the registerRuboCop stub builtins.go
                                            calls, plus hookClosedUnavailable
  rubocop_unavailable.go (no tag)        -> the roster and the raise, tag-free so
                                            a NATIVE test can assert the LoadError
                                            without a closed-world build

builtins.go needs no tag: the stub supplies the symbol it calls, exactly as
backends_wasm.go does for registerGRPC and the rest.

The four wrapper types are redeclared as empty shells in the closed file. They are
not vestigial: object_model.go's classOf has a case arm per wrapper type in a file
with no build tag, so the names must resolve for the closed build to compile. No
value of them can be constructed there, so the arms are unreachable -- the same
shape as arSQLiteAdapter in bindings_wasm.go.

MEASURED, on a --closed binary built from the same one-line program:

                              before      after
  parser.(*Parser).* T syms       98          0
  any go-ruby-parser sym         255          0
  any go-ruby-rubocop sym        n/a          0
  internal/compiler sym          n/a          0
  binary                   172.9 MiB   172.2 MiB

The symbol counts are over 173,293 nm lines, and the same grep finds 15,412
internal/vm symbols in that input -- so the zeros are zeros, not a grep that could
not run. `go list -deps -tags rbgo_closed ./cmd/rbgo` now names no parser, no
rubocop and no compiler, so rubocop was the only edge.

AND A CORRECTION THAT MATTERS: the size barely moved, and not because another edge
remains. The front-end is simply not what makes this binary large. An ordinary
(non-closed) rbgo is 173.7 MiB, so dropping the whole front-end saves 1.5 MiB --
0.85%. Earlier notes on this, including mine, cited "172.9 MiB carrying 255 parser
symbols" as if the size were the symptom. It was not: the symbols were the symptom
and the size is a separate, much larger question about the gem bindings. The
integration test's "closed should be smaller than open" assertion holds, but it was
never measuring much.

Both directions verified, because the risk of a build tag is breaking the ordinary
path silently:

  normal build: require "rubocop" succeeds, RuboCop::Runner#inspect returns an
                Array, RuboCop::Cop::Offense is a Class -- unchanged
  closed build: LoadError "cannot load such file -- rubocop (not available in the
                closed-world build)", a retried require re-raises, and the program
                keeps running

All five RBGO_BUILD_IT tests now pass. No assertion was rewritten to accept the
255 symbols; the product changed, which is the point.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* internal/vm: a test that cannot build its subject has not tested it

TestNetHTTPThroughRbgoBinary and TestSocketThroughRbgoBinary each built cmd/rbgo
and, if the build failed, called t.Skipf("cannot build cmd/rbgo"). A compile break
in cmd/rbgo therefore reported SKIP, which reads as green. That is the defect class
of issue #682: the guard that caught a three-day js/wasm compile break was itself
unreachable, and these two would have swallowed the next one.

Both now t.Fatalf.

The socket test's skip was justified in its own comment by "the unrelated
pre-existing cmd/rbgo codegen issue". That issue no longer exists -- cmd/rbgo
builds -- so the premise had expired and left behind a skip that protected nothing
and hid everything. The nethttp test cited only "mirroring the socket suite", which
means the reasoning was never independently held there.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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