Skip to content

internal/vm: one gate and one composition for rb_warn, so the diagnostics fire, carry their position, and a redefined constant warns twice (#641, #699) - #706

Merged
tannevaled merged 1 commit into
mainfrom
wt45-verb-verbose
Sep 26, 2026
Merged

tannevaled merged 1 commit into
mainfrom
wt45-verb-verbose

Conversation

@tannevaled

@tannevaled tannevaled commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Closes #641. Addresses parts 2 and 3 of #699 (part 1, the bypassed Warning.warn, landed in #697).

The defect: one value, two representations, and the gate read the one nobody writes

rb_warn's gate was spelled four different ways. Four call sites read vm.globals["$VERBOSE"] straight out of the map, which is unset until a program assigns it; every Ruby-level read went through verboseSlot, which answers MRI's default false. So:

$ rbgo -e 'X=1; X=2'                     # nothing
$ rbgo -e '$VERBOSE=false; X=1; X=2'     # it FIRES
$ rbgo -e 'p $VERBOSE'                   # false -- as on MRI

Assigning $VERBOSE the value it already read turned the warnings on. That is why it survived: $VERBOSE.nil? is false on both engines, so no Ruby-level probe can see the gate, and the fix is verified here only by its observable consequences — which warnings appear, on which stream, with which prefix, in which order — always A/B'd against a $VERBOSE = nil control that must silence them again.

C functions read (ruby/ruby, tags v3_4_0 and ruby_4_0, cached under /Users/Shared/rbgo-measure/mri-c/)

decision source
the gate is !NIL_P(ruby_verbose) error.c:466 rb_warn
the stricter gate is RTEST(ruby_verbose) error.c:497 rb_warning
the message is composed from the CURRENT frame error.c:450 warning_string → rb_source_location_cstr
the prefix is file[:line]: then warning: error.c:381 warn_vsprintf → error.c:125 err_vcatf
the sink is Warning.warn, not $stderr error.c:374 rb_write_warning_str → rb_warning_warn → rb_funcallv(Warning, :warn, str)
a one-argument override is called without the category error.c:355 rb_warn_category / rb_warning_warn_arity
an EXPLICIT position, same gate and sink error.c:397 rb_compile_warn
a redefined constant emits TWO lines; the second's position is the OLD entry's ce->file/ce->line, and its name is the BARE one variable.c:3694-3706 const_tbl_update (ruby_4_0: 4046-4052)
where that location comes from variable.c setup_const_entry: rb_source_location(&ce->line) at set time
the three $VERBOSE states ruby.c:1250-1268 (-W0/-W1/-W2), ruby.c:3069 verbose_setter, ruby.c:3077 opt_W_getter
Struct.new(name, …) words it differently and emits no second line struct.c:273 new_struct (it calls rb_mod_remove_const first, so rb_const_set never sees a live entry)
Regexp.new's ignorecase argument is rb_warning, not rb_warn object.c:3493 rb_bool_expected (ruby_4_0), reached from re.c:4081
which methods warn "given block not used", and with which helper enum.c 315/464/1794-1798/1859/1921/2210/2271 (rb_warn), enum.c:1071 (rb_warning), array.c 2119/2177/6488/7792/7859/7920/7985 (rb_warn), array.c:1136 (rb_warning), hash.c:4555

The three $VERBOSE states and what each selects

value how you get it rb_warn rb_warning
nil -W0 silent silent
false the default — ruby_verbose starts at Qfalse speaks silent
true -w / -W2, or any truthy assignment (verbose_setter coerces with RTEST) speaks speaks

$VERBOSE = 1 therefore reads back as true, which is asserted.

One rule, one place

warnEnabled() (!NIL_P) and warningEnabled() (RTEST) are now the only two gates, and they read the slot through verboseSlot rather than seeding the map. Seeding would have left the raw-map read alive, so any future delete or a fresh VM could re-open the same hole; removing the second representation cannot.

rbWarn is now the only place a warning is composed, and rbWarning, rbWarnf, rbWarningf and rbCompileWarn all route through it or its formatter. Nine of its callers passed their own literal "warning: " and two passed none, so syswrite for buffered IO and Ignoring encoding parameter … printed with no warning: at all. Callers now pass the text alone.

Two duplicated rules were folded:

  • warnRedefineConst (struct.go) wrote the const_tbl_update text through Kernel#warn, with no prefix and no gate, while warnAlreadyInitialized (vm.go) wrote it through rb_warn. Module#const_set used the first, the two assignment opcodes the second. They are one rule now — except for Struct.new's named form, which is genuinely different in MRI and says so at its call site.
  • warnCircularRequire and warnAutoloadDidNotDefine wrote to curStderr() directly, bypassing Warning.warn, with a Bool type assertion on the unset map slot that could never be true.

The brief's premise about builtins.go:3106 is wrong, and measurement says so

The task named rbWarning1 at builtins.go:3106 as a drifted call site. It is Array#initialize with no arguments, and array.c:1136 is rb_warning("given block not used") — rbgo was right, and MRI's own split is per function, not per message: enum.c:1071 (inject with two arguments) is also rb_warning, while the other thirteen sites are rb_warn. The change follows MRI function by function rather than making the message uniform.

Per-method conformance, measured against MRI 4.0.5

Thirteen warnings MRI emits were missing entirely, all of them given block not used where an argument makes the block dead. In rbgo these methods all come from Enumerable in the prelude (in MRI, Array and Hash define their own), so they are emitted with warn …, uplevel: 1, which is rb_warn's gate, rb_warn's sink and the caller's position:

Array#count/any?/all?/none?/one?, Hash#any?, Enumerable#count/find_index/any?/all?/none?/one?, and Enumerable#inject(init, op) (that one rb_warning, so -w only).

Going the other way, Regexp.new("a", Object.new) was rb_warn but MRI's rb_bool_expected is rb_warning; it is silent at the default level again.

#699's own eight-line probe, byte-for-byte on the same file:

$ ruby /tmp/wt45probe.rb           $ rbgo /tmp/wt45probe.rb
intercepted=3                      intercepted=3
…:6: warning: block supersedes…    …:6: warning: block supersedes…
…:8: warning: already initialized… …:8: warning: already initialized…
…:7: warning: previous definition… …:7: warning: previous definition…

A defect this fix EXPOSED in our own prelude

With the gate working, every rbgo run printed three warnings MRI never emits:

(rbgo):2261: warning: already initialized constant Slim::Engine
(rbgo):2347: warning: already initialized constant Haml::Engine
(rbgo):2417: warning: already initialized constant OptParse

slim.go, haml.go and optparse.go already bind those three names to exactly the objects the prelude then re-assigned. The redundant assignments are removed and the frozen prelude regenerated; Slim::Engine, Haml::Engine and OptParse resolve to the same classes as before. Warnings fired before Warning itself exists, which is also why the first build of this change panicked in warningWarn — a useful witness that the prelude had no business warning at all.

ruby/spec: 2206 spec files under language/ + core/, corpus pinned 87b1631

origin/main @ 0450238 23215 passing (2198 files loaded, 8 failed to load)
this branch 23228 passing (2198 loaded, 8 failed to load)

Per-file, every file that moved:

file before → after cause
core/array/{all,any,count,none,one}_spec.rb +1 each the missing given block not used
core/enumerable/{all,any,count,find_index,none,one}_spec.rb +1 each same
core/kernel/load_spec.rb 45 → 46 circular-require warning now reaches Warning.warn
core/kernel/require_spec.rb 68 → 69 same
core/module/autoload_spec.rb 66 → 67 autoload-did-not-define now reaches Warning.warn

Downward moves: none. core/random/bytes_spec.rb read 9 before and 8 after, and is a pre-existing flake: sampled six times per binary it gave 8, 8, 8, 9, 9, 9 on origin/main's binary and 9, 9, 8, 9, 8, 8 on this one. One sample would have been a fabricated regression.

The census figure in the task does NOT survive measurement

The wave was framed as "82 examples across 57 files behind one closed gate". It is 14 examples across 14 files, and none of them is a constant-redefinition example. Every spec that asserts one of these warnings does so through mspec's complain, which assigns $VERBOSE — and assigning it is exactly what used to satisfy the broken gate. language/constants_spec.rb (85 passing), optional_assignments_spec.rb (74), for_spec.rb (29), core/module/const_set_spec.rb (15), const_added_spec.rb (13), const_source_location_spec.rb (39) and language/assignments_spec.rb (34) are unchanged to the example, before and after. The gate was invisible to the suite for the same reason it was invisible to a Ruby probe. What the suite could see was the thirteen warnings we never emitted, and it saw twelve of them.

No spec in language/ or core/ asserts previous definition of … was here at all, so part 3 of #699 is worth 0 examples and is in here on MRI's authority, not the corpus's.

Tests

internal/vm/verbose_gate_wave45_test.go, plus corrections to eleven tests that had pinned our own divergence (wave24_lang, phase5f, env_wave16, io_modeenc_internal, wave28_modreg_internal, wave28_file_fd_open, wave31_consts_internal, getoptlong, struct_conformance, regexp_wave17_internal, regexp_bridge). One of them, TestIORbWarnSilentByDefault, documented the defect in its own comment — "rbgo's rb_warn gate still keys on the raw, unset slot, which is why nothing is printed here" — and is now TestIORbWarnSpeaksAtTheDefaultLevel.

Which new test is a witness and which a guard:

  • witnesses (they fail against the old code): TestVerboseGateThreeStates (the UNSET rows), TestConstantRedefinitionEmitsBothLinesWithPositions, TestPreviousDefinitionPointsAtTheOriginalSite, TestStructNamedRedefineUsesStructsOwnWording, TestGivenBlockNotUsedMatchesMRIPerMethod, TestGivenBlockNotUsedCarriesTheCallersPosition, TestOverriddenWarningWarnInterceptsInternalWarnings, TestWarnGatesAgreeBetweenUnsetAndExplicitFalse.
  • guards (they pass both ways): TestWarningStringAtShapes, whose two degenerate rows the single caller filters out at the call site exactly as MRI's if (!NIL_P(ce->file) && ce->line) does, and TestNativeConstantRedefinitionHasNoSecondLine.

Gates: full suite -count=1 green, go build on native / wasip1 / js, gofmt -l internal/ cmd/ empty.

🤖 Generated with Claude Code

Coverage — four parts, measured under Go 1.27.1 (not the host default)

The host defaults to Go 1.26.4, which counts fewer basic blocks than setup-go@v7's stable (1.27.1); a profile from the older toolchain is an upper bound, not a measurement. Both profiles below were produced with ~/.pkgx/go.dev/v1.27.1/bin/go, each resolved by cover -func in the tree that produced it: origin/main @ 0450238 in a separate worktree, this branch @ a885255 in its own.

The instrument was validated before it was trusted: the local darwin/1.27.1 below-100 list for main is identical, entry for entry, to CI's macos-latest list for main (16 entries). CI's ubuntu-latest list holds 3 more — internal/vm/dir.go:572 cwdStillNamed, internal/vm/filestat.go:59 Inspect, internal/vm/filestat.go:372 registerFileStat — all filesystem-specific and untouched here.

Acceptance criterion: the below-100 LIST is unchanged, not the rounded total (#704). It is:

main this branch
below-100 entries 16 16
the entries themselves — identical, same functions, same percentages; only builtins.go bootstrap shifts line 20 → 21 from the added fmt import

Full measured function set, compared by (file, name) so line shifts cannot hide anything. Four functions removed, each with a named replacement — no orphaning:

removed replaced by
builtins.go rbWarn1, builtins.go rbWarning1, io_class.go rbWarn, struct.go warnRedefineConst builtins.go rbWarn, rbWarnf, rbWarning, rbWarningf, rbCompileWarn, warningString, warningStringAt; globals.go warnEnabled, warningEnabled; struct.go warnStructRedefineConst

All ten new functions read 100.0% under 1.27.1, as does warnAlreadyInitialized. No new below-100 entry appeared, so there was no candidate for the "is this even reachable?" question — the one branch pair that a single caller cannot reach, warningStringAt's empty-file and zero-line arms, is err_vcatf's own shape and is covered by a direct unit test labelled a guard rather than a witness.

-race is not available on this host (Xcode licence), so the profiles are race-free; CI runs the raced version.

Second instrument

CI's own ratchet (ubuntu, job 108497805824) reads 23230 passing, 2198 files loaded, 8 failed to load, against the local darwin sweep's 23228 with the same file counts — a two-example platform difference, of which the core/random/bytes_spec.rb flake accounts for one. FLOOR is untouched; it now sits 117 below the measurement.

What the rest of the census group turned out to be

A census group formed by SYMPTOM bounds its cause's size; it does not measure it. Measured here, over the 89 files in language/ + core/ that assert a warning at all (complain, suppress_warning or a $VERBOSE assignment — 186 complain sites and 64 suppress_warning sites):

MRI-through-shim ceiling rbgo gap
before 3062 2799 263
after 3062 2811 251

So this mechanism accounts for 12 of 263 in its own neighbourhood (14 across the whole suite, two of the moved files being outside the 89). Eleven of the twelve files that moved reached the ceiling exactly:

core/array/{all,any,count,none,one}_spec.rb and core/enumerable/{all,any,count,find_index,none,one}_spec.rb — all now at gap 0. 34 of the 89 files are now at the ceiling.

Three files moved and did not close, so here are the numbers rather than the implication:

file rbgo after ceiling residual gap what the residue is
core/module/autoload_spec.rb 67 79 12 autoload CONCURRENCY, not warnings: "blocks a second thread while a first is doing the autoload", and the in-autoload-thread views of defined? / autoload? / const_source_location. Owning files internal/vm/autoload.go + thread.go
core/kernel/require_spec.rb 69 80 11 same family, plus $LOADED_FEATURES timing
core/kernel/load_spec.rb 46 53 7 load(wrap: Module) and load(freeze:)

And the ten largest residual gaps among the 89 are not warning defects at all — each named with its owning file, since this is the part that turns into routable work:

examples cause owning file
~9 $! is not cleared after a successful rescue and not restored to the outer exception — expected #<StandardError: foo> to == nil throughout predefined_spec.rb internal/vm/vm.go (curExc, the rescue opcodes)
~9 a read-only global or pseudo-variable assigned raises NameError at RUN time where MRI raises SyntaxError at PARSE time ($&, $`, $', $+, nil =, true =, self =), and alias-ing one then assigning raises nothing at all internal/compiler + go-ruby-parser
11 chilled string literals are not implemented — core/string/chilled_string_spec.rb is 6/17. This IS a warning feature (literal string will be frozen in the future, string returned by :sym.to_s will be frozen) and is the largest warning-shaped gap left in the corpus. It is a String-representation change, not a gate internal/object/string.go, internal/vm, the literal path in internal/compiler
8 Module#ruby2_keywords does not actually mark the hash (expected false to == true, eight times). rbgo has the warning for declining the flag but not the flag's effect internal/vm/modulefunc.go
~6 eval's filename/lineno arguments do not reach __FILE__, __LINE__ or the SyntaxError message; rbgo reports parse error at line 1: … where MRI reports file:line: … internal/vm eval path + parser error mapping
~10 constant lookup: the lexical scope of a singleton method is not searched, const_missing is not sent to the original scope, private_constant is not enforced under ::Const. This is language/constants_spec.rb's residual 15, in the very file the census counted for "already initialized constant" internal/vm/vm.go
~14 parser gaps: -> () { … rescue … }, -> (**nil), lambda { |**nil| }, BEGIN { } go-ruby-parser (external)
1 caller's precise location for a top-level constant reports [1, 3, 6, 199] where MRI reports [190, 192, 195, 199] — const_added_spec.rb. Adjacent to this PR: it is the same frameLine machinery the file:lineno: prefix uses, so it is worth looking at next internal/vm/vm.go
1 $_ is not thread-local internal/vm/globals.go
1 assigning $0 does not set the process name internal/vm/globals.go

The two companions of #641 behave differently from each other on this axis, and that is worth stating plainly. The missing file:lineno: prefix was worth 0 examples (every corpus assertion is a regex against the message body, never the prefix), and the missing second line was worth 0 examples (nothing in language/ or core/ matches previous definition). Both are in this PR on MRI's authority alone. What the corpus could see was the thirteen warnings we never emitted — which were not in the census's framing at all.

CI verdict

All 9/9 jobs pass, including the three -race + coverage lanes (macos 54m58s, ubuntu 56m55s, windows 54m7s) and the ruby/spec ratchet.

The acceptance criterion is confirmed on CI's own lists, not just locally. Below-100 functions, compared by (file, name) so line shifts cannot hide a substitution:

lane main @ 0450238 this branch
ubuntu-latest 19 19 identical, entry for entry
macos-latest 16 16 identical, entry for entry

Both lanes also print coverage: 100.0%, but per #704 that is not what was checked — the unchanged list is.

…tics fire, carry their position, and a redefined constant warns twice (#641, #699)

$VERBOSE had two representations of one value and the gate read the one nobody
writes. Four call sites tested vm.globals["$VERBOSE"] straight out of the map,
which is unset until a program assigns it; every Ruby-level read went through
verboseSlot and answered false, MRI's default. So `$VERBOSE.nil?` agreed on both
engines and no Ruby-level probe could see the gate, while assigning $VERBOSE the
value it already read turned the warnings on.

warnEnabled (!NIL_P) and warningEnabled (RTEST) are now the only two places that
decide, reading the slot through verboseSlot rather than seeding it, so a fresh
VM and one handed an explicit false cannot disagree again.

rbWarn is now the only place a warning is composed: error.c's
warning_string -> warn_vsprintf -> err_vcatf, i.e. the current frame's
"path:lineno: " then "warning: ". Nine callers passed their own "warning: " and
two passed none, so two of them printed no "warning: " at all. rbCompileWarn is
error.c's rb_compile_warn: the same gate and sink with an explicit position,
which is what the second line of a constant redefinition needs.

A redefined constant now emits both of MRI's lines, the second pointing at the
original definition through the constLocs record const_source_location already
keeps. Struct.new's named form keeps struct.c's own wording ("redefining
constant Struct::Foo"), which a test had pinned as "already initialized".

Thirteen "given block not used" warnings MRI emits were missing entirely
(Array/Hash/Enumerable count, any?, all?, none?, one?, find_index), and
Regexp.new's ignorecase warning was rb_warn where object.c rb_bool_expected uses
rb_warning. The circular-require and autoload-did-not-define warnings wrote past
Warning.warn to $stderr.

Three constants the prelude re-assigned (Slim::Engine, Haml::Engine, OptParse)
were already bound natively to the same objects, so with the gate fixed every
rbgo run printed three warnings MRI never emits. The redundant assignments are
gone and the frozen prelude regenerated.

ruby/spec language+core (2206 spec files, corpus 87b1631): 23215 -> 23228.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 3eb0527 into main Sep 26, 2026
9 checks passed
@tannevaled
tannevaled deleted the wt45-verb-verbose branch September 26, 2026 23:18
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.

rb_warn diagnostics never fire: $VERBOSE is unset in vm.globals while Ruby reads it as false

1 participant