Skip to content

kernel: core/kernel conformance wave 23 — the ARGF/IO/Process global functions, raise's backtrace argument, Class#methods, exit status (+26) - #607

Merged
tannevaled merged 3 commits into
mainfrom
wave23/kernel
Sep 11, 2026
Merged

tannevaled merged 3 commits into
mainfrom
wave23/kernel

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

What

Wave-23 core/kernel conformance against MRI 4.0.5. Three commits, confined to
internal/vm/builtins.go (Kernel methods only) and internal/vm/kernel_introspect.go,
plus one new whitebox test file.

ruby/spec, whole language + core suite, per file against origin/main:
20093 → 20119 passing, no file losing an example.

core/kernel alone: 1847 → 1870.

Method

Everything here was found with a differential harness: each core/kernel spec
runs under BOTH rbgo and the real ruby 4.0.5 with the same MSpec shim, and only
the failures unique to rbgo are treated as defects. The pinned corpus reports
Ruby 3.4.1, so a number of specs expect messages MRI 4.0.5 no longer produces —
those show up as failures under both engines and were skipped rather than
"fixed" into a divergence from MRI.

Unique-to-rbgo failing examples in core/kernel: 268 → 245.

Every behavioural claim below was read out of the CRuby source (cited per commit)
and then checked byte-for-byte against ruby 4.0.5 on the same input.

1. The Kernel global functions that forward to ARGF / IO / Process (+20)

MRI declares a set of Kernel methods with rb_define_global_function, which makes
each a private instance method callable without a receiver AND — through the
Kernel module_function split — a public method on the Kernel module. rbgo was
missing ten of them outright and defined #test without reflecting the split, so
neither Kernel.private_instance_methods(false) nor Kernel.public_methods(false)
listed them.

New, each forwarding exactly where CRuby forwards:

method reference forwards to
#gets #readline #readlines io.c rb_f_gets, rb_f_readline, rb_f_readlines — all forward(argf, …) ARGF, by dispatch, so a stubbed ARGF.gets is what runs
#select io.c rb_f_select IO.select
#spawn process.c rb_f_spawn Process.spawn
#` io.c rb_f_backquote — arity 1, StringValue so a non-String goes through #to_str the same runShellCommand the %x{…}/backtick literal uses (OpXStr), so the two spellings cannot drift apart
#syscall io.c ends #define rb_f_syscall rb_f_notimplement without HAVE_SYSCALL NotImplementedError with MRI's exact message — which is what MRI 4.0.5 on darwin answers
#global_variables variable.c rb_f_global_variables walks rb_global_tbl the global table as Symbols, plus the specials specialGvar serves from VM state rather than the table ($!, $0, $$), sorted because Go map iteration is randomised and MRI's own order is unspecified

and "`", gets, global_variables, readline, readlines, select,
spawn, syscall, test added to registerKernelModuleFunctions' name list so
the visibility split covers them.

#select and #spawn reach rbgo's single implementation by dispatch, where
MRI has a separate C entry point over a shared body. The trade is deliberate: a
program that redefines IO.select also changes Kernel#select, in exchange for
the two never being able to diverge. The tests pin that property.

2. Three Kernel defects (+6)

Kernel#raise(class, message, backtrace) — the third argument was read and
discarded, so the exception carried the current stack instead. MRI's
make_exception ends if (argc == 3) set_backtrace(mesg, argv[2]) (eval.c),
applying it before the raise; set_backtrace routes the value through the
exception's own #set_backtrace (so an override runs) and accepts
Thread::Backtrace::Location values, converting them with rb_backtrace_to_str_ary
when it cannot store locations. rbgo never stores locations — a backtrace is an
Array of String and #backtrace_locations re-derives Locations by parsing them
back
— so Location elements are converted the same way, and because a Location's
#to_s is the line it was parsed from the round trip is exact. Other non-String
elements pass through so #set_backtrace still raises MRI's TypeError.

Kernel#methods on a class or module — rb_obj_methods lists the instance
methods of CLASS_OF(obj) (object.c). For a class that is its singleton class,
whose ancestry runs through the singleton classes of its superclasses and only then
reaches Class/Module/Object. rbgo's classOf answers Class for an RClass
and cannot see the singleton chain, so C.methods listed no class method at
all
— C.methods & C.methods(false) was empty. Both halves are now gathered and
merged (de-duplicated, sorted).

Kernel#exit / #exit! / #abort — the status argument was discarded: every
call raised SystemExit with status 0, and a bad argument raised nothing. MRI's
rb_f_exit (process.c) takes 0..1 arguments through exit_status_code — true is
EXIT_SUCCESS, false is EXIT_FAILURE, anything else NUM2INT, so a Float
truncates and a String/nil/Array is a TypeError — and raises
SystemExit.new(status, "exit"). exit! differs only in defaulting to
EXIT_FAILURE. abort puts its message (through StringValue, so #to_str
converts) on stderr and raises SystemExit.new(EXIT_FAILURE, message).

This one was a real loss, not only a spec gap: the code's own comment names
Puppet's exit_on_fail, which calls exit(code) and reads the code back off the
SystemExit. It also pays off outside core/kernel —
core/exception/status_spec and core/exception/success_spec each gain an
example.

3. Tests

One new file, internal/vm/kernel_delegates_wave23_internal_test.go, covering
every branch added above; each expectation was read off ruby 4.0.5. The
backquote test is guarded by runtime.GOOS (it needs a POSIX shell).

Gates

  • go build ./..., go vet ./..., full go test ./... — green.
  • Coverage: reproduced the CI gate
    (-coverpkg=$(go list ./internal/...) ./...) on this branch and on
    origin/main
    : the list of functions below 100% is byte-identical between
    the two, and every function added here is at 100%. Nothing that was 100% before
    is below it now.
  • gofmt -l internal/vm/ — empty.
  • GOOS=wasip1 GOARCH=wasm build, vet and go test -c — all succeed.
  • ruby/spec ratchet: 20119 ≥ floor 20093. FLOOR deliberately not raised here
    — left for whoever lands the wave.

Measurement notes

Two files in the corpus are pre-existing flakes, on this branch and on
origin/main alike — sampled repeatedly on both binaries before concluding
anything from them:

  • core/random/bytes_spec.rb flips 8 ↔ 9,
  • core/kernel/require_spec.rb flips 26 ↔ 27.

Neither is touched by this change.

Left on the table — found, located, out of this PR's file scope

Reported rather than fixed, because each lives outside the files this wave owns.
All were localised precisely:

  • Kernel#sleep with no argument deadlocks (thread.go:766 — a channel
    receive nothing ever sends to). It is not a hang in the spec's logic: Go's
    runtime aborts with fatal error: all goroutines are asleep, so no
    RBGO_RESULT line is printed and the whole file scores 0. This alone costs
    ~20 examples: core/kernel/exit_spec.rb (12 under MRI) and
    core/process/exit_spec.rb (8) both fail to load for this single reason.
    Fixing it would make this PR's exit status work visible as well.
  • trace_var / untrace_var / set_trace_func (~10 examples): the hook
    point is setGVar in globals.go, the single chokepoint vm.go:1578 calls.
  • Exception#to_s for an anonymous class (1 example, one word): it uses
    class.name, which is "" for Class.new(Exception); MRI uses class.to_s
    → #<Class:0x…>.
  • toIntCoerce (converter_impl.go): MRI has a special message for nil —
    no implicit conversion from nil to integer, not of nil into Integer. That
    is the only line on which this PR's exit differs from MRI.
  • Backtick interpolation is dropped: `echo #{ip}` runs echo — the
    compiler puts v.Command in the name pool verbatim (compiler.go:360), so
    OpXStr never sees the interpolated parts.
  • Kernel.` is unparsable: parse error: expected method name after '.'.
  • binding / block_given? / local_variables (~14 examples): the first two
    are compiler intrinsics with no method record, and natives get no access to the
    caller's env, so none can be given a real body without a VM change.
  • require/require_relative/load/eval are 117 of the 245 remaining
    unique-to-rbgo failures, almost all path-resolution in require.go. That is
    where the next large core/kernel win is.
  • kind_of? was left as two method records on purpose: the code carries a
    prior wave's note that sharing one record to satisfy the == alias example
    would regress four reflection examples.

🤖 Generated with Claude Code

…rocess (core/kernel +20)

MRI declares a set of Kernel methods with rb_define_global_function, which
makes each a private instance method callable without a receiver AND — via
the Kernel module_function split — a public method on the Kernel module.
rbgo was missing ten of them outright, and defined #test without reflecting
the split, so `Kernel.private_instance_methods(false)` and
`Kernel.public_methods(false)` did not list them.

New, each forwarding exactly where CRuby forwards:

  #gets / #readline / #readlines -> ARGF    (io.c rb_f_gets, rb_f_readline,
      rb_f_readlines: all `forward(argf, …)` when the receiver is not ARGF,
      so a stubbed ARGF.gets is what runs)
  #select                        -> IO.select        (io.c rb_f_select)
  #spawn                         -> Process.spawn    (process.c rb_f_spawn)
  #`                             -> runShellCommand  (io.c rb_f_backquote:
      arity 1, StringValue so a non-String goes through #to_str). It uses the
      same runner the `%x{…}` literal uses (OpXStr) so the two spellings
      cannot drift apart.
  #syscall                       -> NotImplementedError. A CGO-free runtime
      has no portable syscall(2); this is the situation CRuby is in without
      HAVE_SYSCALL, where io.c ends `#define rb_f_syscall rb_f_notimplement`.
      MRI 4.0.5 on darwin answers with exactly this message.
  #global_variables              -> the global table as Symbols (variable.c
      rb_f_global_variables walks rb_global_tbl). The specials specialGvar
      answers from VM state rather than the table ($!, $0, $$) are always
      defined, so they are listed too; the result is sorted because Go map
      iteration is randomised and MRI's own order is unspecified.

and "`", gets, global_variables, readline, readlines, select, spawn,
syscall and test added to registerKernelModuleFunctions' name list so the
visibility split covers them.

Measured differentially — every core/kernel spec run under BOTH rbgo and MRI
4.0.5 with the same shim, counting only failures unique to rbgo:
core/kernel 1847 -> 1867 passing, unique-to-rbgo failures 268 -> 248.
…it/abort status (+6)

Three Kernel defects found by running every core/kernel spec under BOTH rbgo
and MRI 4.0.5 with the same shim and keeping only the failures unique to rbgo.

Kernel#raise(class, message, backtrace)
  The third argument was read and discarded, so the raised exception carried
  the current stack instead. MRI's make_exception ends
  `if (argc == 3) set_backtrace(mesg, argv[2])` (eval.c), applying it before
  the raise; set_backtrace routes the value through the exception's own
  #set_backtrace (so an override runs) and accepts
  Thread::Backtrace::Location values, converting them with
  rb_backtrace_to_str_ary when it cannot store locations. rbgo never stores
  locations — a backtrace is an Array of String and #backtrace_locations
  re-derives Locations by parsing them back — so Location elements are
  converted the same way here, and because a Location's #to_s is the line it
  was parsed from the round trip is exact. Other non-String elements are
  passed through so #set_backtrace still raises MRI's TypeError.

Kernel#methods on a class or module
  rb_obj_methods lists the instance methods of CLASS_OF(obj) (object.c). For
  a class that is its SINGLETON class, so `C.methods` carries C's class
  methods and its superclasses' as well as Class's instance methods. rbgo's
  classOf answers Class for an RClass and cannot see the singleton chain, so
  `C.methods` listed no class method at all — `C.methods & C.methods(false)`
  was empty. Both halves are now gathered and merged.

Kernel#exit / #exit! / #abort
  The status argument was discarded: every call raised SystemExit with
  status 0, and a bad argument raised nothing. MRI's rb_f_exit (process.c)
  takes 0..1 arguments through exit_status_code — true is EXIT_SUCCESS,
  false is EXIT_FAILURE, anything else NUM2INT, so a Float truncates and a
  String/nil/Array is a TypeError — and raises SystemExit.new(status,
  "exit"). exit! differs only in defaulting to EXIT_FAILURE. abort puts its
  message (through StringValue, so #to_str converts) on stderr and raises
  SystemExit.new(EXIT_FAILURE, message). This was a real loss, not only a
  spec gap: the code's own comment names Puppet's exit_on_fail, which reads
  the code back off the SystemExit.

ruby/spec, whole language+core suite, per file against origin/main:
20093 -> 20119 passing, no file losing an example. core/kernel 1847 -> 1870;
also core/exception/status +1, core/exception/success +1 (exit status),
core/module/include +1 (Class#methods).
Covers every branch added by the two preceding commits, each expectation
read off ruby 4.0.5:

  registerKernelDelegates  - the module_function visibility split for all
      nine names; ARGF forwarding for #gets/#readline/#readlines (stubbed,
      so no real input stream); #select against IO.pipe (nil on nothing
      ready, the [r, w, e] triple otherwise); #spawn forwarding without
      starting a subprocess; #syscall's NotImplementedError; #` through
      #send (rbgo's parser rejects a backquote after a dot) including the
      #to_str, arity and TypeError arms; #global_variables including the
      alwaysDefinedGvars branch, the +1 on a fresh global and the sort.
  exitStatusArg / raiseSystemExit - true/false/Integer/Float/#to_int,
      exit vs exit! defaults, Kernel.exit, the three TypeError arms and the
      arity error; abort's status, message, #to_str and stderr write.
  applyRaiseBacktrace - String, Array of String, empty, nil, a Location
      array round-tripping through #backtrace_locations, coexistence with
      cause:, and the TypeError for a non-String element.
  mergeMethodNames - a class's own, meta and inherited class methods plus
      Class's instance methods, each listed once and sorted, with private
      class methods and instance methods excluded, and the module and
      plain-object receivers.

The backquote test is guarded by runtime.GOOS (it needs a POSIX shell).
@tannevaled
tannevaled merged commit 8e2caa2 into main Sep 11, 2026
7 checks passed
@tannevaled
tannevaled deleted the wave23/kernel branch September 11, 2026 10:55
tannevaled added a commit that referenced this pull request Sep 12, 2026
…611)

Three units landed in this wave:

  #607  core/kernel: the Kernel global functions that forward to ARGF/IO/
        Process, raise's backtrace argument, Class#methods' singleton chain,
        exit/abort status                                              (+26)
  #608  deps: go-ruby-parser v0.1.9, which honours a backslash inside a
        %w/%W/%i/%I word list — an escaped space no longer splits a word,
        an escaped delimiter no longer ends the list                   (+30
        measured over core/dir + core/file alone)
  #609  core/io: IO#reopen, IO.popen, IO#fcntl and Fcntl, IO.select argument
        checks, a faithful IO#read_nonblock with WaitReadable/WaitWritable,
        set_encoding(nil, nil)                                         (+92)

Measured with scripts/conformance/rubyspec/run.sh against the pinned corpus
at 87b1631, 16-way, TIMEOUT=25: 20244 passing (2173 files loaded, 33 failed
to load). CI runs the same ratchet with TIMEOUT=60, so its total can only be
greater than or equal to this one.

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