Repository navigation
conformance: a source map on every ISeq — __LINE__, backtrace lines, and block/class frame labels - #645
Merged
Conversation
__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
added a commit
that referenced
this pull request
Sep 24, 2026
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>
This was referenced Sep 25, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The blocker under the blocker
__LINE__evaluated to 0 everywhere and every backtrace read:0:, becausethe 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 lexerknew 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 beganon. It is on
Program, filled by the existingParse, rather than behind a newentry point, so no consumer changes a call site — which mattered, because a
*ast.Programis threaded through several layers here before anything wants aline.
go.modtakes 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, besidepositions[], theinstruction index each entry starts at (
iseq.cv3_4_0:673,rb_iseq_insns_info_encode_positions). It compresses because a line spans a runof instructions. Reading it back is a search for the last entry at or before the
pc —
get_insn_info_binary_search(iseq.cv3_4_0:2160), which is what MRI shipsas
VM_INSN_INFO_TABLE_IMPL == 1.bytecode.LineEntry{PC, Line}(positions[i], insns_info[i])ISeq.LinesISeq.LineAt(pc)get_insn_info_binary_search+rb_iseq_line_no(:2314), answering 0 for a pc it cannot placeISeq.FirstLinebody->location.first_linenovm.frameCode[i]cfp->iseq+cfp->pcvm.frameLine(i)rb_vm_get_sourceline(vm_backtrace.cv3_4_0:105), first_lineno fallback includedformatBacktraceEntrylocation_format(vm_backtrace.cv3_4_0:446)blockLabelcalculate_iseq_label's block arm (vm_backtrace.cv3_4_0:229)The entry is written in
emit(), not where the line is set. That is what keepsthe 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.rb0/6 → 6/6.__LINE__was a Kernel method returning 0. It is not a method in MRI at all — itis a keyword the grammar replaces with an Integer literal (
parse.yv3_4_0,keyword__LINE__).compileCalldoes that substitution from the map, for thereceiverless bareword only, which is what reproduces MRI's three refusals
(
self.__LINE__and1.send(:__LINE__)are NoMethodError,__LINE__()aSyntaxError — 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__ = 1would declare a local of that name andevery later
__LINE__in the scope would read it, so the keyword would quietlystop being one.
2 — backtraces read the map.
:0:becomes a real line, verified byte-for-byteagainst ruby 4.0.5 on a raise chain and a
Kernel#callerchain — all five framesmatch.
Proc#source_locationreadsFirstLine, which is the right source for itand not the pc: MRI reports where the block was written.
3 — frame labels.
base_label_spec0/5 → 4/5,label_spec5 → 7. Classbodies, 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 onceat compile time because the answer is a purely lexical fact.
#base_labelstopsbeing an alias of
#label.Measured: merged main → this branch, per file
language/+core/, 2206 files, pinned corpus. 22 033 → 22 062 (+29).require_specis a parallel-load flake: run alone, both binaries give 69, fivetimes out of five.
core/module/autoload_spec.rbalso read 0 once (issue #615,reproduced here 1 run in 4) and is 61 on re-run, as before.
core/thread/to_s_spec.rbis a genuine loss and is left:Thread#to_sshouldreport the line the thread's block was written on, which
Proc#source_locationnow 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:
blocks.rbis millions of tiny block calls — the worst case for anything paid perframe. 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.gohad to learn
Lines/FirstLine, because an AOT program has no source torecompile 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.
TestEmbeddedPreludeMatchesSourcecaught it.A hang worth naming
The pc publish was first written unconditional.
TestConditionVariablethen hungfor nine minutes. The write was indexing past the end:
RunresetsframeNamesto[:0]at its boundary, and a thread body runs through such aboundary 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
frameNamesis a frame no backtrace renders.Not done, and why
Module#const_source_location— the line map it needs is now there, but themethod is in
internal/vm/module_residuals.go, a sibling's file this wave.warn(uplevel:)— same: the scaffolding already validates the level and hasthe "no caller line to prepend" comment, but it is in
internal/vm/io.go.Object#foowhere thisprints
foo(rb_gen_method_name). A different mechanism: it needs the frame'sdefining 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'sremaining 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.gocompared two backtracestaken on different lines, which stringified identically while every line was 0,
so the comparison compared nothing; ruby answers
falseto both. Andbacktrace_internal_test.gobuilt a frame stack by hand and was reporting line109 from the prelude's ISeq, left over from VM boot — a wrong line, which is
worse than none.
Gates
go build ./..., the fullGOWORK=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'scompileNoderenamed (45.0% → 45.3%) when theline-setting wrapper was split off; the wrapper is at 100%.
🤖 Generated with Claude Code