Skip to content

conformance: a source map on every ISeq — __LINE__, backtrace lines, and block/class frame labels - #645

Merged
tannevaled merged 6 commits into
mainfrom
wave28/lines
Sep 24, 2026
Merged

tannevaled merged 6 commits into
mainfrom
wave28/lines

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

The blocker under the blocker

__LINE__ evaluated to 0 everywhere and every backtrace read :0:, because
the ISeq carried no line map. Three agents hit this from three directions.

The first thing to report is that the map could not be built here. The parser's
AST carried no position information at all
— 55 node types, zero line or column
fields, and Parse(src) (*ast.Program, error) the only entry point. The lexer
knew every token's line and threw it away at the AST boundary. So this wave began
one repository down.

go-ruby-parser/parser#30 →
v0.3.0
adds ast.Program.Lines, the source line each statement node began
on. It is on Program, filled by the existing Parse, rather than behind a new
entry point, so no consumer changes a call site — which mattered, because a
*ast.Program is threaded through several layers here before anything wants a
line. go.mod takes v0.3.0.

Representation: MRI's, and why it is not a line per instruction

MRI records a line for every instruction while compiling, then encodes the result
as insns_info[], a table of distinct entries, beside positions[], the
instruction index each entry starts at (iseq.c v3_4_0:673,
rb_iseq_insns_info_encode_positions). It compresses because a line spans a run
of instructions. Reading it back is a search for the last entry at or before the
pc — get_insn_info_binary_search (iseq.c v3_4_0:2160), which is what MRI ships
as VM_INSN_INFO_TABLE_IMPL == 1.

this branch MRI
bytecode.LineEntry{PC, Line} one (positions[i], insns_info[i])
ISeq.Lines the pair, sorted by PC
ISeq.LineAt(pc) get_insn_info_binary_search + rb_iseq_line_no (:2314), answering 0 for a pc it cannot place
ISeq.FirstLine body->location.first_lineno
vm.frameCode[i] cfp->iseq + cfp->pc
vm.frameLine(i) rb_vm_get_sourceline (vm_backtrace.c v3_4_0:105), first_lineno fallback included
formatBacktraceEntry location_format (vm_backtrace.c v3_4_0:446)
blockLabel calculate_iseq_label's block arm (vm_backtrace.c v3_4_0:229)

The entry is written in emit(), not where the line is set. That is what keeps
the table the shape MRI's is: a statement that emits nothing (a discarded
defined?) contributes no entry; a statement that emits twenty contributes one.

What landed

1 — __LINE__ answers truly. language/line_spec.rb 0/6 → 6/6.
__LINE__ was a Kernel method returning 0. It is not a method in MRI at all — it
is a keyword the grammar replaces with an Integer literal (parse.y v3_4_0,
keyword__LINE__). compileCall does that substitution from the map, for the
receiverless bareword only, which is what reproduces MRI's three refusals
(self.__LINE__ and 1.send(:__LINE__) are NoMethodError, __LINE__() a
SyntaxError — all verified against ruby 4.0.5). The stand-in method is deleted
rather than kept: while it existed, the two NoMethodError cases silently answered
0. Assignment is refused too, as MRI's grammar refuses it — and not only for
conformance: an accepted __LINE__ = 1 would declare a local of that name and
every later __LINE__ in the scope would read it, so the keyword would quietly
stop being one.

2 — backtraces read the map. :0: becomes a real line, verified byte-for-byte
against ruby 4.0.5 on a raise chain and a Kernel#caller chain — all five frames
match. Proc#source_location reads FirstLine, which is the right source for it
and not the pc: MRI reports where the block was written.

3 — frame labels. base_label_spec 0/5 → 4/5, label_spec 5 → 7. Class
bodies, module bodies, singleton-class bodies and blocks all reported <main>.
Block ISeqs are now named for the frame they will be (block in foo,
block (2 levels) in foo) — the same walk MRI does at backtrace time, done once
at compile time because the answer is a purely lexical fact. #base_label stops
being an alias of #label.

Measured: merged main → this branch, per file

language/ + core/, 2206 files, pinned corpus. 22 033 → 22 062 (+29).

core/binding/eval_spec.rb                              3 ->  8   (+5)
language/line_spec.rb                                  0 ->  6   (+6)
core/thread/backtrace/location/base_label_spec.rb      0 ->  4   (+4)
core/exception/backtrace_spec.rb                      11 -> 13   (+2)
core/proc/source_location_spec.rb                      3 ->  5   (+2)
core/thread/backtrace/location/label_spec.rb           5 ->  7   (+2)
core/kernel/caller_spec.rb                            16 -> 17   (+1)
core/module/method_added_spec.rb                       6 ->  7   (+1)
core/thread/backtrace/location/absolute_path_spec.rb   1 ->  2   (+1)
core/thread/backtrace/location/inspect_spec.rb         0 ->  1   (+1)
core/thread/backtrace/location/lineno_spec.rb          1 ->  2   (+1)
core/thread/backtrace/location/to_s_spec.rb            0 ->  1   (+1)
language/encoding_spec.rb                              3 ->  4   (+1)
language/ensure_spec.rb                               30 -> 31   (+1)
language/file_spec.rb                                  4 ->  5   (+1)
language/rescue_spec.rb                               50 -> 51   (+1)
core/thread/to_s_spec.rb                              14 -> 13   (-1)   real, see below
core/kernel/require_spec.rb                           69 -> 68   (-1)   flake

require_spec is a parallel-load flake: run alone, both binaries give 69, five
times out of five. core/module/autoload_spec.rb also read 0 once (issue #615,
reproduced here 1 run in 4) and is 61 on re-run, as before.

core/thread/to_s_spec.rb is a genuine loss and is left: Thread#to_s should
report the line the thread's block was written on, which Proc#source_location
now knows, but it lives in internal/vm/thread.go — a sibling's file this wave.
One line, once that file is free.

Cost

Best-of-9 per program, branch vs merged main, Apple M4 Max:

fib dispatch loop mandelbrot blocks
+2.1% −0.4% +2.4% +1.8% +7.5%

blocks.rb is millions of tiny block calls — the worst case for anything paid per
frame. A best-of-5 pass once showed −0.2% there and prompted merging the two frame
stacks into one; 15 interleaved runs of each binary put it at +7.5% best-of-15 and
+6.8% by median, so the −0.2% was noise and the merge is worth roughly nothing.
The number is in the code so nobody re-derives it. Binary +0.08%
(181 646 482 → 181 796 146 bytes). Parsing costs +6.0% time and +2.1% bytes
(measured in the parser PR).

The frozen prelude is regenerated and now carries its source map: aot/freeze.go
had to learn Lines/FirstLine, because an AOT program has no source to
recompile from, so a map dropped by the emitter is line 0 for every frozen frame —
the old state reintroduced silently and only on that path.
TestEmbeddedPreludeMatchesSource caught it.

A hang worth naming

The pc publish was first written unconditional. TestConditionVariable then hung
for nine minutes. The write was indexing past the end: Run resets
frameNames to [:0] at its boundary, and a thread body runs through such a
boundary while the frame that started it is still live and will resume the loop
afterwards. The panic never surfaced as a panic — a recover on the way out
swallowed it and the thread simply never signalled. The bounds check is
load-bearing, and skipping the write is also the correct answer, since a frame
absent from frameNames is a frame no backtrace renders.

Not done, and why

  • Module#const_source_location — the line map it needs is now there, but the
    method is in internal/vm/module_residuals.go, a sibling's file this wave.
  • warn(uplevel:) — same: the scaffolding already validates the level and has
    the "no caller line to prepend" comment, but it is in internal/vm/io.go.
  • The owner prefix on method labels — ruby prints Object#foo where this
    prints foo (rb_gen_method_name). A different mechanism: it needs the frame's
    defining module, not its ISeq, and it changes every method label in every
    backtrace, so it is not bolted onto this. It is the bulk of label_spec's
    remaining 29.

Pinned tests

Ten assertions changed, each to a value re-measured by running the test's own
source under ruby 4.0.5. Two of them are assertions getting their teeth back
rather than losing them: wave24_module_internal_test.go compared two backtraces
taken on different lines, which stringified identically while every line was 0,
so the comparison compared nothing; ruby answers false to both. And
backtrace_internal_test.go built a frame stack by hand and was reporting line
109 from the prelude's ISeq, left over from VM boot — a wrong line, which is
worse than none.

Gates

go build ./..., the full GOWORK=off go test ./..., gofmt -l internal/ empty,
GOOS=wasip1 GOARCH=wasm go build ./.... Coverage: 98.7%, identical to main,
and the below-100 function list matches main's exactly — the one name that differs
is compileNode1, which is main's compileNode renamed (45.0% → 45.3%) when the
line-setting wrapper was split off; the wrapper is at 100%.

🤖 Generated with Claude Code

tannevaled and others added 6 commits September 24, 2026 08:13
__LINE__ evaluates to 0 everywhere and every backtrace reads `:0:`
because the ISeq carries no line map. This is the map.

REPRESENTATION — MRI's, not a line per instruction.

MRI records a line for every instruction while compiling and then
encodes the result as insns_info[], a table of DISTINCT entries, beside
positions[], the instruction index each entry starts at (iseq.c v3_4_0:
673, rb_iseq_insns_info_encode_positions). It compresses because a line
spans a run of instructions; storing one per instruction would multiply
every ISeq for nothing. Reading it back is a search for the last entry
at or before the pc — get_insn_info_binary_search (iseq.c v3_4_0:2160),
the implementation MRI ships as VM_INSN_INFO_TABLE_IMPL == 1.

  bytecode.LineEntry{PC, Line}  is one (positions[i], insns_info[i]).
  ISeq.Lines                    is the pair, sorted by PC.
  ISeq.LineAt(pc)               is that binary search, answering 0 for a
                                pc it cannot place — which is what
                                rb_iseq_line_no returns when
                                get_insn_info finds nothing
                                (iseq.c v3_4_0:2314).
  ISeq.FirstLine                is body->location.first_lineno, the
                                fallback rb_vm_get_sourceline uses when
                                the per-pc lookup yields 0
                                (vm_backtrace.c v3_4_0:105).

WHERE THE LINES COME FROM.

The parser's AST carried no position information at all — 55 node types,
zero line fields — so there was nothing to thread. ast.Program.Lines
(go-ruby-parser/parser#30) now gives the line each STATEMENT node began
on, and compileNode moves the current line to a node's own when the map
has one. An expression inside a statement inherits the statement's line,
so receiver, arguments and the send all land on one line and the table
gets one entry for the lot — the same shape MRI's insns_info has,
because MRI's nd_line for those inner nodes is that line too.

The entry is written in emit(), not where the line is SET. That is what
keeps the table compressed: a statement that emits nothing (a discarded
`defined?`) contributes no entry, a statement that emits twenty
contributes one. A line is written down only because an instruction
exists to carry it.

A Program with no Lines map — a hand-assembled AST in a test — compiles
to an empty source map and every lookup answers 0, exactly the behaviour
this VM had before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
language/line_spec.rb: 0/6 -> 6/6.

__LINE__ was a Kernel method returning 0, standing in for the line map
that did not exist. It is not a method in MRI at all — it is a KEYWORD
the grammar replaces with an Integer literal (parse.y v3_4_0:
`keyword__LINE__` yields NEW_INTEGER of the current line). compileCall
now does that substitution one layer down, from the source map, for the
receiverless argumentless blockless bareword only.

Confining it to that one form is what reproduces MRI's three refusals,
all verified against ruby 4.0.5 (+PRISM):

  self.__LINE__        NoMethodError    (rbgo used to answer 0)
  1.send(:__LINE__)    NoMethodError    (rbgo used to answer 0)
  __LINE__()           SyntaxError

so the stand-in method is deleted rather than kept as a fallback: while
it existed, the two NoMethodError cases silently answered 0.

Assignment is refused too. ruby 4.0.5 answers `__LINE__ = 1` with
`Can't assign to __LINE__`, a SyntaxError, and line_spec.rb pins
`eval("__LINE__ = 1")` raising it. The refusal is not only conformance:
an accepted assignment would declare a LOCAL of that name, and every
later `__LINE__` in the scope would parse as a read of it — the keyword
would quietly stop being one. __FILE__ and __ENCODING__ are refused the
same way; ruby gives the same message for both.

Byte-for-byte against MRI on a file exercising top level, a paren
expression spanning two lines, a def, a class body and a block: `cmp`
of the two outputs is clean.

ALSO, and not optional: aot/freeze.go now freezes Lines and FirstLine.
An AOT-compiled program has no source to recompile from, so a source map
dropped by the emitter is a backtrace reporting line 0 for every frozen
frame — the state this VM was in before the map existed, reintroduced
silently and only on that path. TestEmbeddedPreludeMatchesSource is what
caught it: the frozen prelude no longer round-tripped.

internal/vm/puppet_boot_batch3_test.go pinned __LINE__ at 0, 0 and 1.
Each of the three is re-measured against ruby 4.0.5 by running the same
source from a file: 1, 2 and 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every frame now reports where it actually is. Verified byte-for-byte
against ruby 4.0.5 on a raise chain and a Kernel#caller chain: the line
of all five frames matches.

HOW A FRAME GETS A LINE.

Not by storing one. frameISeqs/framePCs join frameNames/frameFiles as
per-frame stacks, holding which ISeq the frame runs and how far it has
got; frameLine COMPUTES the line from the pair when somebody asks. That
is MRI's arrangement exactly — cfp->iseq and cfp->pc, resolved by
calc_lineno(loc->iseq, loc->pc) (vm_backtrace.c v3_4_0:87) — and it is
why the cost is one integer store per instruction rather than a search.
frameLine is rb_vm_get_sourceline (v3_4_0:105), fallback included: a pc
the map cannot place falls back to the ISeq's first_lineno.

formatBacktraceEntry is location_format (vm_backtrace.c v3_4_0:446),
including the part that is easy to miss — the ":%d" is appended only
`if (lineno != 0)`. An unplaceable frame reports "path:in 'label'", and
so says nothing about its line rather than claiming line zero.

FirstLine is now actually set, in Compiler.push, so every scope is
stamped with the line of the `def`/`class`/`{` that opened it — one
place rather than the eight sites that open a scope. Proc#source_location
reads it, which is the right source for that method and not the pc: MRI
reports where the block was WRITTEN (proc.c rb_proc_location via
iseq_location), so a proc halfway through its body still answers the
line of its `do`.

A HANG, AND WHAT IT TAUGHT.

The pc publish was first written unconditional. TestConditionVariable
then hung for nine minutes. The write was indexing past the end: Run
resets frameNames to :0 at its boundary, and a thread body runs through
such a boundary while the frame that started it is still live and will
resume the loop afterwards. The panic never surfaced as a panic — a
recover on the way out swallowed it, and the thread simply never
signalled. The bounds check is load-bearing, and skipping the write is
also the CORRECT answer: a frame absent from frameNames is a frame no
backtrace renders.

PINNED TESTS. Eight asserted `:0:` or a consequence of it. Each new
value is MRI's, obtained by writing the test's own source to a file and
running ruby 4.0.5:

  backtrace_test.go          five tests, lines 3/6/9, 3, 3+6, 3+5, 3, 6
  backtrace_internal_test.go a hand-built frame stack now builds the
                             code stacks too — it was reporting line 109
                             from the PRELUDE's ISeq, left over from VM
                             boot. A wrong line is worse than none.
  wave24_module_internal_test.go
                             two `true`s become `false`. They compared
                             two backtraces taken on DIFFERENT lines,
                             which stringified identically while every
                             line was 0 — so the comparison compared
                             nothing. ruby 4.0.5 answers false to both.
  puppet_apply_primitives_test.go
                             Proc#source_location 0 -> 1; ruby prints
                             ["<file>", 1] for a proc on line 1.

go.mod takes go-ruby-parser/parser v0.3.0, which carries
ast.Program.Lines.

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

base_label_spec 0/5 -> 4/5, label_spec 5/36 -> 7/36, and
absolute_path/ensure/rescue one each: +9 examples.

Every frame that was not a method reported "<main>" — a class body, a
module body, a singleton-class body and a block all claimed to be the
top level. Two halves fix that, and they are on opposite sides of the
compiler/VM line because MRI's are too.

BLOCK LABELS, AT COMPILE TIME. blockLabel is calculate_iseq_label's
ISEQ_TYPE_BLOCK arm (vm_backtrace.c v3_4_0:229): walk out through the
enclosing scopes counting the block ones, then "block in NAME" for one
level and "block (N levels) in NAME" beyond. MRI walks parent_iseq at
backtrace time; the answer is a purely lexical fact, so the walk happens
once, at compile time, and the block ISeq is NAMED for the frame it will
be. `for` scopes are skipped in the count rather than followed: `for`
opens no Ruby scope, so a block inside a `for` body is one level deep.

Verified against ruby 4.0.5 — a method, a block in it and a block in
that print "block in …" and "block (2 levels) in …" with the same
nesting counts, and #base_label prints the undecorated name.

SCOPE LABELS, AT BACKTRACE TIME. frameLabel consults the frame's ISeq
when frameNames has no method name. The recognised names are a
WHITELIST, and that is the care this needed: an ISeq's Name is not
always a label — a required file's top-level ISeq is named by its PATH,
so taking any name turned "prog.rb:9:in '<main>'" into
"prog.rb:9:in 'prog.rb'", a frame labelled with its own file. The full
suite caught it. Anything not on the list is the top level by another
name and reports "<main>".

#base_label stops being an alias of #label. MRI reads the two from
different fields (vm_backtrace.c v3_4_0:331) and the difference is
exactly the block decoration, so stripBlockQualifier reconstructs
base_label by removing it.

WHAT IS STILL MISSING, deliberately: the OWNER prefix.
ruby prints "Object#foo" where this prints "foo", because
rb_gen_method_name (vm_backtrace.c v3_4_0:~200) qualifies a method label
with its owner. That is a different mechanism — it needs the frame's
defining module, not its ISeq — and it changes every method label in
every backtrace, so it is not being bolted onto this change. It is the
bulk of label_spec's remaining 29.

blockISeqs in wave26_for_defined_test.go matched the literal "<block>";
it now matches the prefix, so it keeps finding block ISeqs whatever the
enclosing scope is called.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
frameISeqs and framePCs become one []frameCode. A frame push touches
both halves, so one bounds check and one store now serve for both.

It did not buy the speed it was reached for, and the number is in the
code so nobody re-derives it. bench/blocks.rb — millions of tiny block
calls, the worst case for anything paid per frame — is about +7% either
way. A best-of-5 pass appeared to show the merge taking it from +8.7%
to -0.2%, which is why the merge was made; 15 interleaved runs of each
binary put it at +7.5% best-of-15 and +6.8% by median. The -0.2% was
noise in a best-of-5 on a benchmark whose own spread is 870-1037 ms.
The cost is the per-instruction pc store and the per-frame push, not
the slice count.

The rest of the standard bench set, best-of-9, branch vs merged main:

  fib         +2.1%      dispatch    -0.4%      loop        +2.4%
  mandelbrot  +1.8%      blocks      +7.5%

Binary: 181,646,482 -> 181,796,146 bytes, +0.08%. The frozen prelude
grows by ~1070 lines of source map, which is the AOT half of the same
table.

Whitebox tests for the three new compiler functions, so the coverage
gate's below-100 list matches main's exactly: blockLabel over one, two
and three levels, `for` scopes skipped, no parent, and end-to-end
through a compiled ISeq; refuseKeywordAssign over all three keywords
plus two names that merely resemble one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 4c7284b into main Sep 24, 2026
7 checks passed
@tannevaled
tannevaled deleted the wave28/lines branch September 24, 2026 08:32
tannevaled added a commit that referenced this pull request Sep 24, 2026
Four units, and one of them was a dependency away:

  #639  regexp search anchoring and encoding, Refinement#import_methods,
        pack/unpack
  #645  THE LINE MAP. __LINE__ evaluated to 0 everywhere because the ISeq
        carried no line table — 101 examples across five directories were
        blocked on it, and three earlier agents each reported it as the wall
        they could not pass. The wall was one repository down: the parser's
        AST carried no position information at all, 55 node types with no
        line field, and the lexer discarded every token's line at the AST
        boundary. go-ruby-parser v0.3.0 adds ast.Program.Lines; this PR
        builds MRI's compressed insns_info/positions table on it.
        language/line_spec 0/6 -> 6/6, backtraces carry real lines, frame
        labels name blocks the way MRI does.
  #640  Kernel#sleep with the fiber scheduler (sleep_spec 0 -> 15, one
        example had been hanging the whole file), File.open's mode/flags/perm
        contract driven by the open(2) flag set rather than a six-way mode
        string, IO::Buffer.map                     (+90 across core/file,
                                                    core/io, core/kernel)
  #537, #527 already in the previous floor's tree

Measured with scripts/conformance/rubyspec/run.sh against the pinned corpus at
87b1631, 16-way, TIMEOUT=25, three consecutive runs: 22153, 22152, 22152. The
floor is set twelve below the lowest, the margin rule the last three bumps
used: enough for the run-to-run jitter, not enough to hide the loss of a spec
file. #615 remains open and still costs a file when it fires.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Sep 25, 2026
…evel:), Thread.each_caller_location (#649)

* vm: warn(uplevel:) prepends the caller's file and line

Kernel#warn's uplevel: level was validated and then thrown away: the
prefix was unconditionally "warning: ", with a comment saying rbgo had no
caller line to prepend. PR #645 made that untrue — frameLine is now MRI's
calc_lineno over the frame's (iseq, pc) pair — so the level now names a
frame and its "path:lineno: " is prepended.

Read for this: ruby/ruby v3_4_0 error.c rb_warn_m. It takes the location
from rb_ec_backtrace_location_ary(ec, lev + 1, 1, TRUE) and formats it as
rb_sprintf("%s:%ld: warning: ", path, lineno), falling back to the bare
"warning: " when that yields no location. The `+ 1` skips the frame of
the warn cfunc itself; rbgo pushes no frame for a native, so level 0 is
already the top of frameNames and the level indexes straight down.

Also from rb_warn_m: the category runs through rb_to_symbol_type, i.e.
rb_convert_type_with_id(val, T_SYMBOL, "Symbol", idTo_sym). Anything that
answers #to_sym converts; rbgo accepted only Symbol and String and raised
TypeError on the rest. A String needs no case of its own — String#to_sym
does it. Both TypeError messages are MRI's, verified byte-for-byte:

  warn("m", category: Object.new)
    -> no implicit conversion of Object into Symbol
  def o.to_sym; 5; end; warn("m", category: o)
    -> can't convert Object to Symbol (Object#to_sym gives Integer)

And the level is now converted ONCE (it was run through toIntCoerce twice
on the raise path), so a #to_int that counts its calls is not called twice.

Verified byte-for-byte against ruby 4.0.5 on the uplevel matrix (levels
0/1/past-the-bottom, empty and nil messages, several messages under one
prefix, a lone message already ending in a newline) and on the fixture
core/kernel/fixtures/classes.rb the spec uses: identical output, including
that the "path:lineno: warning: " prefix is written once and not per line.

core/kernel/warn_spec.rb stays at pass=21. MRI 4.0.5 run under the SAME
shim scores 25, and the four it beats us by all reduce to one gap outside
this wave's files: methodSourceLocation (internal/vm/method_reflect2.go)
still returns line 0, so the spec's own warn_call_lineno is 1 rather than
434 and every uplevel assertion compares against the wrong number. The
remaining 4 MRI also fails are shim limits (Warning[:deprecated] default,
Warning.warn arity), not rbgo bugs.

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

* vm: Thread.each_caller_location

Thread.each_caller_location was not defined at all (pass=0 on
core/thread/each_caller_location_spec.rb). It yields each frame of the
current execution stack as a Thread::Backtrace::Location and answers nil.

Read for this: ruby/ruby v3_4_0 vm_backtrace.c each_caller_location
(:1401) and ec_backtrace_range (:1300). each_caller_location selects its
range with ec_backtrace_range(ec, argc, argv, 1, 1, &n) — the SAME
lev_default and lev_plus that rb_f_caller_locations hands
ec_backtrace_to_ary — so its argument handling is Kernel#caller_locations'
exactly, Range form and nil-for-overshoot included. callerSlice therefore
serves both rather than the rule being restated here.

Three details that come from reading it rather than from the spec:

  - rb_scan_args(argc, argv, "02:") caps the positionals at two, and the
    rb_get_kwargs that follows runs against an EMPTY keyword table, so
    EVERY keyword is an unknown one ("unknown keyword: :foo").
  - the LocalJumpError is raised by the rb_yield inside the iteration, not
    by a guard before it: `if (lev >= 0 && n != 0)`. A selection that
    yields nothing is therefore not an error even with no block, which is
    why the check here is on "about to yield at least once".
  - the message is "no block given", not the "(yield)" form.

Verified against ruby 4.0.5: identical for the default form, explicit
(start, length), a Range, a start past the top, no block, one and two
unknown keywords, three positionals and a negative level.

core/thread/each_caller_location_spec.rb: pass=0 -> pass=5 of 6.

The sixth ("stops the backtrace iteration if 'break' occurs") fails on a
PRE-EXISTING defect that is not in this wave's files and that my change
neither causes nor can reach: a `break` out of a block leaves its exec
frame on vm.frameNames. exec's recover handler truncates the per-frame
stacks for a returnSignal only, and a breakSignal re-panics past it, so
every backtrace taken after a break is one frame too deep. Reproduced on
main with no Thread involved at all:

  def top
    [1,2,3].each { |x| break x if x == 2 }
    p caller_locations(1,2).map(&:to_s)
  end
  def mid; top; end
  mid

  ruby 4.0.5 -> ["...:5:in 'Object#mid'", "...:6:in '<main>'"]
  rbgo        -> ["...:3:in 'top'",       "...:5:in 'mid'"]

The fix belongs in exec's recover (internal/vm/vm.go) or in callBlock
(internal/vm/object_model.go), both owned by other agents this wave.

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

* vm: qualify every backtrace label with its owner (rb_gen_method_name)

rbgo printed `foo` where ruby prints `Object#foo`. The owner prefix was
left out of the line-map wave deliberately: it needs the frame's DEFINING
MODULE rather than its ISeq, and it moves every method label in every
backtrace, so it is one coherent change rather than a bolt-on.

Read for this: ruby/ruby v3_4_0 vm_backtrace.c rb_gen_method_name (:205),
calculate_iseq_label (:229), location_label (:285) and location_to_str
(:460), plus variable.c classname / rb_mod_name0 (:104).

  - rb_gen_method_name qualifies a method name with its owner: "Owner#name"
    for an instance method, "Owner.name" for a singleton one, taken from
    the attached object when the owner is a singleton class.
  - it does so ONLY when the class path is PERMANENT. classname() reports
    RCLASS_EXT(klass)->permanent_classpath beside the path, and a
    non-permanent or absent one drops the prefix entirely — so an anonymous
    Class.new labels its methods bare. rbgo's RClass.named is that flag.
  - calculate_iseq_label recurses THROUGH the block decoration, so the
    prefix belongs to the method a block was written in and the
    "block (2 levels) in " goes back in front of the qualified name.
  - ISEQ_TYPE_TOP/CLASS return location.label untouched: "<main>",
    "<class:K>", "<module:M>" and a singleton-class body take no prefix.
  - location_label and location_to_str BOTH go through it, so the prefix is
    on Thread::Backtrace::Location#label, on Kernel#caller and on the lines
    of an exception's backtrace alike.

Two things fall out of reading it that the spec does not ask for:

  - an ALIASED method reports its ORIGINAL name. MRI's label is the ISeq's
    location.label, the name the body was defined under; rbgo printed
    frameNames[i], the name it was CALLED by. The other half of the pair is
    already recorded in frameMethods[i].orig, so the label now uses it.
  - Location#base_label must strip BOTH decorations, not just the block
    one. MRI reads it from a separate field holding the plain compiled
    name, so "block (2 levels) in C#foo" has base_label "foo".
    stripOwnerPrefix removes the owner only when what precedes the
    separator is a constant path (reusing isConstantPath, MRI's
    is_constant_path) — which is what leaves `main.label_sdef_method_of_main`
    whole.

rbgo files a class method under the CLASS (C.smethods, owner == C) where
MRI files it under C's singleton class, so which of the two tables holds
the name is what picks "#" from "." — hence frameOwner returns the flag
rather than reading owner.isSingleton.

Verified byte-for-byte against ruby 4.0.5 on a 15-case matrix: instance
method, block in one, nested block, `def self.x`, `class << self`, alias,
base_label, module function, module instance method via extend, a nested
class path, a top-level def, a per-object singleton, an anonymous class, a
top-level block, and an exception backtrace line.

core/thread/backtrace/location/label_spec.rb: pass=7 -> pass=27 of 41.
(MRI 4.0.5 under the same shim scores 40; the gap is itemised in the PR.)

Six pinned assertions moved, each re-measured by writing the test's exact
source to a file and running ruby 4.0.5 on it:

  backtrace_test.go TestBacktraceCaptureChain   'foo'  -> 'Object#foo'
                                                'bar'  -> 'Object#bar'
  backtrace_test.go TestReRaisePreservesBacktrace 'deep' -> 'Object#deep'
  backtrace_test.go TestFullMessageShape        'foo'  -> 'Object#foo'
  backtrace_test.go TestUncaughtBacktraceEscaping 'boom' -> 'Object#boom'
  backtrace_test.go TestCallerStillWorks        'a'    -> 'Object#a'
  kernel_warn_internal_test.go TestKernelWarn    two uplevel: cases now
      carry the location the previous commit made available

wave28_lines_internal_test.go's TestStripBlockQualifier is replaced by
TestSplitBlockQualifier and TestStripOwnerPrefix, the two halves
base_label now has to undo.

Also fixed, because this change is what made it visible: Kernel#catch
restored frameNames on a matched throw but not frameCrefs/frameMethods,
which mirror it one-for-one. The three slices then sat at different depths
for the rest of the program and frameCrefs[i] stopped describing frame i.
Nothing noticed while a backtrace read frameNames alone. Reachable from
Ruby:

  def probe(t); puts "#{t}: #{caller_locations(0)[0].label}"; end
  probe("before")
  def deep(n); n.zero? ? throw(:done, 1) : deep(n - 1); end
  catch(:done) { deep(5) }
  probe("after")

  before main -> Object#probe / Object#probe   (ruby 4.0.5 and rbgo now)
  before fix  -> Object#probe / probe

THREE MORE SITES truncate frameNames alone and are in files this wave does
not own; they are named in the PR body. frameStacksAligned makes a drift
from any of them cost the prefix rather than print another frame's owner.

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

* vm: cover the owner-prefix branches, and drop one that cannot be reached

Whitebox tests for every branch the previous two commits added, plus the
removal of one that was dead.

Removed: frameOwner tested `cref.attached.(*RClass)` after `cref.metaOf`,
as a second way to reach rb_gen_method_name's "attached object is a class
or module" case. It cannot happen. The three sites that set `attached`
(object_model.go singletonClass and ensureSingleton, builtins.go
cloneSingleton) only ever store a per-object receiver, and ensureSingleton
refuses a class outright — `case *RClass: return nil, false`, classes use
metaClass() — so metaOf IS that case and the type assertion could never
succeed. Covering it would have meant writing a test for a state the VM
cannot enter; the branch is gone and the invariant is in the comment.

Added (internal/vm/wave29_owner_internal_test.go):

  - TestFrameOwnerNilCref: exec pushes frameCrefs[i] as nil and fills it in
    once the frame's lexical scope is settled. The window is inside one
    exec call and cannot be held still from Ruby, so the guard is exercised
    against a stack built by hand.
  - TestFrameStacksAlignedGuard: both drift shapes (a leaked frameCrefs
    entry, a leaked frameMethods entry) make the label fall back to the
    bare name instead of reporting another frame's owner.
  - TestGenMethodNamePermanence: the permanence rule, including the
    Module#set_temporary_name shape (name set, named false). Checked on
    ruby 4.0.5: a Class.new given set_temporary_name("fake name") still
    labels its method "m", not "fake name#m".

And in wave29_lineharvest_test.go, the uplevel level EXACTLY one past the
outermost frame, which is the `i < 0` arm rather than the int64 guard: at
the top level there is one frame, so uplevel 1 already has no location.
ruby 4.0.5 prints "warning: x" for the same two lines.

Coverage compared as a LIST against origin/main, not as a rounded total —
and with each profile rendered against ITS OWN source tree, since
`go tool cover -func` resolves line numbers against the working directory
and reading main's profile from this worktree misattributed 18 functions
to 0.0%. Rendered correctly, internal/vm shows the SAME 15 functions under
100% on main and on this branch, at the same percentages: no gap opened,
none closed. None of the new functions is among them.

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 25, 2026
Three units, and this wave is mostly the line map paying out.

  #649  Harvesting what #645 made possible: owner-qualified backtrace labels
        (Object#foo where we printed foo), warn(uplevel:) prepending the
        caller's file and line — its scaffolding still carried the comment
        "rbgo has no caller line to prepend", untrue since yesterday — and
        Thread.each_caller_location.
  #650  Keywords decided at the CALL SITE, as MRI does: a trailing Hash is a
        positional argument unless the call site wrote it as keywords. The new
        flag is NEGATIVE on purpose — only sites the compiler can prove
        positional are flagged, so Object#send, Proc#call and define_method
        bodies keep the older behaviour rather than silently losing keywords.
        Also: post parameters bind BEFORE optionals (def q(a, b=9, *c, d) gave
        [1, nil, [], 2] where MRI gives [1, 9, [], 2]), a duplicated parameter
        name is a SyntaxError, and the implicit `it` block parameter.
  #648  Marshal's ivar and extend containers, Time's instance variables and
        the :zone/:offset round trip, String#unpack encodings         (+27)
  #647  dependency bump, green without intervention

Measured with scripts/conformance/rubyspec/run.sh against the pinned corpus at
87b1631, 16-way, TIMEOUT=25, three consecutive runs: 22227, 22226, 22226. The
floor is set eleven below the lowest — the margin rule the last four bumps
used.

One defect this wave exposed rather than introduced, now issue #651: three
parallel frame stacks are kept in step by hand, and FOUR sites truncate
frameNames alone. It went unnoticed while backtraces read that stack alone;
frameLabel now reads all three, so one leaked entry makes every later label
name a different frame. One site fixed in #649, three named and routed.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Sep 25, 2026
Three units, and one of them closes the last wholly-absent subsystem.

  #654  TracePoint, 0 -> 69 of 75. core/tracepoint had been pass=0 across six
        censuses, untouched because it is hooks in the execution core rather
        than an API to complete — and because a tracepoint must report a line,
        which the VM could not do until #645. Events line/class/end/call/
        return/b_call/b_return/rescue, every accessor, enable with target:/
        target_line:/target_thread:, allow_reentry. Costs ~1-2% on every
        program; that trade is recorded and justified in issue #658, together
        with the zero-cost alternative (MRI rewrites instruction encodings
        rather than testing per instruction).
  #656  String/IO encoding conformance: gsub's result-encoding negotiation,
        IO paragraph mode's second swallow, $/ seeded to "\n", grapheme
        clusters, chomp character-wise, UTF-16/32 transcoding      (+44)
  #653  core/module: Module#dup RETURNED THE RECEIVER, Module#ruby2_keywords
        did not exist at all, visibility directives, const_source_location's
        search                                                     (+19)

Measured with scripts/conformance/rubyspec/run.sh against the pinned corpus at
87b1631, 16-way, TIMEOUT=25, three consecutive runs: 22359, 22359, 22358. The
floor is set thirteen below the lowest, the margin rule the last five bumps
used.

A caveat that belongs on this number rather than a later one: issue #655
records that the shim's `should.equal?` asserts NOTHING — it never reaches the
matcher — and it is used 765 times across 328 corpus files. An unknown share of
the total above is therefore vacuous. Fixing it will move the floor in an
unpredictable direction and the result must be re-measured, not assumed. The
number is the best we can currently measure, not a claim that every example
beneath it tests something.

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