Skip to content

vm.c: honor a [] redefinition installed on a core class - #7198

Merged
matz merged 2 commits into
mruby:masterfrom
takumin:index-opcodes-honor-core-redefinition
Aug 16, 2026
Merged

vm.c: honor a [] redefinition installed on a core class#7198
matz merged 2 commits into
mruby:masterfrom
takumin:index-opcodes-honor-core-redefinition

Conversation

@takumin

@takumin takumin commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

OP_GETIDX, OP_GETIDX0 and OP_SETIDX answer [] and []= from C for an Array, Hash or String receiver, guarded only by the receiver's class pointer being the core class. A redefinition installed on the core class itself passes that guard and is silently ignored; only a subclass or singleton receiver, which fails the pointer test for an unrelated reason, ever reached one.

$ ruby -e 'class String; def [](i); "hooked"; end; end; p "ab"[0]'
"hooked"
$ mruby -e 'class String; def [](i); "hooked"; end; end; p "ab"[0]'
"a"

The bypass is not self-consistent

self[idx] compiles to OP_SSEND :[] rather than to an index opcode:

$ cat ssend.rb
def bar(i); self[i]; end
$ mrbc -v -o /dev/null ssend.rb
    1 007 SSEND		R3	:[]	n=1

so the mrblib core methods that index self, Array#each among them with its yield self[idx], go through the method table and honor the very redefinition the opcode ignores. Redefining a core [] half-works today: iterators change, direct indexing does not.

$ mruby -e 'class Array; def [](i); :x; end; end
            r = ""; [7,8].each {|y| r << y.to_s}
            puts r; puts [7,8][1].to_s'
xx
8

What this changes

Each (class, operator) pair gets a slot in mrb->idx_class[]. The slot holds the core class while the name still resolves to the builtin the opcode reimplements, and NULL once it does not. The opcodes compare the receiver's class against that slot instead of against the core class, so a disarmed slot fails the comparison and the operator is sent like any other method. NULL is safe as the disabled value because no live object has a NULL class pointer.

-    if (mrb_unlikely(ary->c != mrb->array_class)) goto getidx_fallback;
+    if (mrb_unlikely(ary->c != mrb->idx_class[MRB_IDX_OP_ARY_AREF])) goto getidx_fallback;

Validity is the resolved mrb_method_t itself, not a "was assigned" flag: a slot is armed while the operator resolves, from the core class, to exactly the method recorded at startup. That covers def, alias_method, undef_method, remove_method, visibility changes and prepend without enumerating them, re-arms when an override is aliased back away, and stays armed when a module without [] is included. The recheck hangs off the four places that already invalidate the method cache, mrb_define_method_raw(), include_module_at(), mrb_mod_visibility() and mrb_remove_method(), and returns immediately unless the changed name is [] or []=.

mrb_idx_op_init() runs at the end of mrb_open_core(), after bootstrapping, so a core [] that mrblib itself replaces is recorded as replaced rather than as the builtin. A slot whose operator was already not a C function at that point is never armed.

Cost on the fast path

The guard is the same single compare it always was. In the generated code only the displacement widens, because the slots sit past the method cache (build_config/default.rb, objdump -d src/vm.o, a String branch of the index opcodes):

 cmp    $0x12,%eax
-jne    5c7c <mrb_vm_exec+0x4bbc>
-mov    0x50(%rbp),%rax
+jne    5c7f <mrb_vm_exec+0x4bbf>
+mov    0x5fe8(%rbp),%rax
 cmp    %rax,(%rsi)
-jne    5c7c <mrb_vm_exec+0x4bbc>
+jne    5c7f <mrb_vm_exec+0x4bbf>

All eight guard sites, three in vm_op_getidx(), three in vm_op_getidx0() and two in vm_op_setidx(), change the same way, for +16 bytes in vm.o.

Under callgrind the executed instruction count per iteration is unchanged for every one of them. Measured in a build without mruby-regexp, so that all eight are live; each figure is Ir(200,000 iterations) - Ir(100,000 iterations), divided by 100,000, which cancels startup:

loop body opcode master this PR
ary[1] OP_GETIDX 278.00 278.00
hsh[1] OP_GETIDX 383.00 383.00
str[1] OP_GETIDX 687.67 687.67
ary[0] OP_GETIDX0 240.00 240.00
hsh[0] OP_GETIDX0 486.00 486.00
str[0] OP_GETIDX0 645.61 645.61
ary[1] = 9 OP_SETIDX 345.00 345.00
hsh[1] = 9 OP_SETIDX 401.00 401.00

Size

build_config/default.rb, .text from size:

master this PR delta
src/vm.o 65,880 65,896 +16
src/class.o 47,838 48,829 +991
src/state.o 2,207 2,231 +24
bin/mruby 1,721,255 1,722,303 +1,048

sizeof(struct mrb_state) goes from 24,536 to 24,656, once per state: 5 class pointers plus 5 mrb_method_t. The slots sit at the end of the struct, so no existing field moves.

One consequence is not free

mruby-regexp replaces String#[] with a Ruby method at gem initialization, so in a build that includes it the String read fast path is now correctly off and str[i] costs a send. The comment in mrbgems/mruby-regexp/mrblib/string_regexp.rb claiming the opcode keeps bypassing the override is corrected in the same commit.

N = 2_000_000
def bench(name)
  best = nil
  25.times do
    t0 = Time.now
    yield
    t = Time.now - t0
    best = t if best.nil? || t < best
  end
  puts sprintf("%-10s %8.2f ns/iter", name, best * 1e9 / N)
end

str = "hello"
ary = [1, 2, 3, 4]
hsh = {1 => :a, 2 => :b}

bench("str[i]")  { i = 0; while i < N; str[1]; i += 1; end }
bench("str[0]")  { i = 0; while i < N; str[0]; i += 1; end }
bench("ary[i]")  { i = 0; while i < N; ary[1]; i += 1; end }
bench("ary[0]")  { i = 0; while i < N; ary[0]; i += 1; end }
bench("hash[k]") { i = 0; while i < N; hsh[1]; i += 1; end }

build_config/default.rb, which carries mruby-regexp, best of 25:

master this PR ratio
str[1] 31.52 ns 130.29 ns 4.13x
str[0] 31.03 ns 131.00 ns 4.22x
ary[1] 20.59 ns 21.17 ns 1.03x
ary[0] 23.66 ns 15.51 ns 0.66x
hsh[1] 27.01 ns 25.05 ns 0.93x

Only the String read moves. The Array and Hash rows sit at the measurement floor on this box: run to run they swing by more than the difference between the two columns, in either direction, which is why the ratios there are noise and not a result. The same build under callgrind separates the two cleanly:

loop body master this PR ratio
str[1] 635.29 2379.17 3.74x
str[0] 586.25 2346.17 4.00x
ary[1] 308.00 308.00 1.00x
ary[0] 264.00 264.00 1.00x
hsh[1] 410.00 410.00 1.00x
hsh[0] 507.00 506.00 1.00x
ary[1] = 9 378.00 378.00 1.00x
hsh[1] = 9 434.00 434.00 1.00x

That slowdown is the price of the regexp forms of String#[] being reachable at all. A build without mruby-regexp keeps all eight fast paths, and str[1] there measures 38.25 ns on master and 39.02 ns with this PR, at the identical instruction count shown above.

Tests

test/t/string.rb had a named test pinning the bypass as intended. It now pins the redefinition being honored, and Array and Hash gain the equivalent: two named tests in test/t/array.rb, one in test/t/hash.rb. Between them they cover the read, the write, a subclass receiver, the receiver being left untouched by a redefined []=, and re-arming by aliasing the original implementation back.

The four GC arena assertions in test/t/gc.rb measure whether a C branch of OP_GETIDX or OP_GETIDX0 leaves its result in the arena, and they measure it only while the loop body reaches the opcode. A send does not: its cfunc epilogue drains the arena, so the assertion would pass whatever the branch under test does. The first commit puts the C implementation back around the two String loops through a with_builtin_string_aref helper, which is a no-op where no gem replaced the operator. Without it those loop bodies would stop reaching the opcode in any build that carries mruby-regexp.

build config Total OK KO Skip
host build_config/default.rb 2,092 2,044 0 48
full-debug build_config/ci/gcc-clang.rb 2,315 2,312 0 3
bintest build_config/ci/gcc-clang.rb 2,315 2,304 0 11
cxx_abi build_config/ci/gcc-clang.rb 2,315 2,304 0 11
byte-string build_config/ci/gcc-clang.rb 2,246 2,198 0 48
ascii-case build_config/ci/gcc-clang.rb 2,312 2,299 0 13
MRB_NO_BOXING full-core, enable_debug 2,315 2,312 0 3
MRB_WORD_BOXING full-core, enable_debug 2,315 2,312 0 3
MRB_NAN_BOXING full-core, enable_debug 2,295 2,291 0 4
MRB_NO_METHOD_CACHE full-core, enable_debug 2,315 2,312 0 3

bintest is green in both builds that enable it: 106 of 106 under build_config/default.rb, 117 of 117 under build_config/ci/gcc-clang.rb. Both commits are green on their own under build_config/default.rb.

Environment

Details
OS Ubuntu 24.04.4 LTS
Kernel Linux 7.0.0-28-generic x86_64
CPU AMD Ryzen 9 5950X 16-Core Processor
C compiler gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0
binutils GNU ld (GNU Binutils) 2.47.20260726
valgrind valgrind-3.27.1
CRuby ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [x86_64-linux]

Compile line per build, src/vm.c, with -MMD -c, -I and -o removed:

# build_config/default.rb, host
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DMRB_USE_COMPLEX -DMRB_USE_BIGINT -DMRB_USE_DEBUG_HOOK

# build_config/ci/gcc-clang.rb, full-debug
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -g3 -O0 -DMRB_GC_STRESS -DMRB_USE_DEBUG_HOOK -DMRB_DEBUG -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER

# build_config/ci/gcc-clang.rb, bintest
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_GC_FIXED_ARENA -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER -DMRB_USE_DEBUG_HOOK

# build_config/ci/gcc-clang.rb, cxx_abi (gcc -x c++; g++ links only)
gcc -g -O3 -Wall -Wundef -Wwrite-strings -x c++ -std=gnu++03 -DMRB_GC_FIXED_ARENA -DMRB_USE_CXX_EXCEPTION -DMRB_USE_CXX_ABI -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER

# build_config/ci/gcc-clang.rb, byte-string
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER

# build_config/ci/gcc-clang.rb, ascii-case
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -DMRB_USE_ASCII_CASE -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER

# full-core + enable_debug, one per boxing
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -g3 -O0 -DMRB_NO_BOXING -DMRB_DEBUG -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -g3 -O0 -DMRB_WORD_BOXING -DMRB_DEBUG -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -g3 -O0 -DMRB_NAN_BOXING -DMRB_DEBUG -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER

# full-core + enable_debug, MRB_NO_METHOD_CACHE
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -g3 -O0 -DMRB_NO_METHOD_CACHE -DMRB_DEBUG -DMRB_USE_BIGINT -DMRB_USE_COMPLEX -DHAVE_MRUBY_ENCODING_GEM -DMRB_UTF8_STRING -DHAVE_MRUBY_IO_GEM -DMRB_USE_RATIONAL -DHAVE_MRUBY_REGEXP_GEM -DMRB_USE_SET -DMRB_USE_TASK_SCHEDULER

# the mruby-regexp-free timings and instruction counts
# (the second -O3 is the config repeating the toolchain default)
gcc -std=gnu99 -g -O3 -Wall -Wundef -Werror-implicit-function-declaration -Wwrite-strings -O3 -DHAVE_MRUBY_IO_GEM

Summary by CodeRabbit

  • Bug Fixes
    • Corrected indexed reads and writes for Array, Hash, and String when their indexing methods are overridden, removed, or redefined.
    • Ensured subclasses and objects with custom indexing behavior use the correct method dispatch instead of built-in shortcuts.
    • Preserved normal built-in indexing after temporary method overrides are restored.
  • Tests
    • Added regression coverage for customized Array, Hash, and String indexing, including subclass behavior and garbage-collection scenarios.

The four assertions in `test/t/gc.rb` measure whether a C branch of
`OP_GETIDX` or `OP_GETIDX0` leaves its result in the GC arena, and they
measure it only while the loop body reaches the opcode.  A send does
not: its cfunc epilogue drains the arena, so the assertion would pass
whatever the branch under test does.

Nothing in this tree redefines `Hash#[]`, so the Hash loops reach the
opcode as they stand.  mruby-regexp does replace `String#[]` with a Ruby
method, keeping the C implementation under `__aref`, so put that back
around the two String loops through `with_builtin_string_aref`.  The
helper is a no-op in a build where no gem replaced the operator.
`OP_GETIDX`, `OP_GETIDX0` and `OP_SETIDX` answer `[]` and `[]=` from C
for an Array, Hash or String receiver, guarded only by the receiver's
class pointer being the core class.  A redefinition installed on the
core class itself passes that guard and is silently ignored; only a
subclass or singleton receiver, which fails the pointer test for an
unrelated reason, ever reached one.

    $ ruby -e 'class String; def [](i); "hooked"; end; end; p "ab"[0]'
    "hooked"
    $ mruby -e 'class String; def [](i); "hooked"; end; end; p "ab"[0]'
    "a"

The bypass was not even self-consistent, because `self[idx]` compiles
to `OP_SSEND :[]` rather than to an index opcode:

    $ mrbc -v -o /dev/null ssend.rb        # def bar(i); self[i]; end
        3 007 SSEND		R3	:[]	n=1

so the mrblib core methods that index `self`, `Array#each` among them
with its `yield self[idx]`, went through the method table and honored
the very redefinition the opcode ignored.  Redefining a core `[]`
half-worked: iterators changed, direct indexing did not.

    $ mruby -e 'class Array; def [](i); :x; end; end
                r = ""; [7,8].each {|y| r << y.to_s}
                puts r; puts [7,8][1].to_s'
    xx
    8

Give each (class, operator) pair a slot in `mrb->idx_class[]` holding
the core class while the name still resolves to the builtin the opcode
reimplements, and NULL once it does not.  The opcodes compare the
receiver's class against that slot instead of against the core class,
so nothing is added to the hot path: the guard is the same single
compare it always was, and a NULL slot fails it because no live object
has a NULL class pointer.  In the generated code each guard is still
one load from `mrb` followed by one compare; only the displacement
widens, because the slots sit past the method cache.  Under callgrind
the executed instruction count per iteration is unchanged for all eight
fast paths.

Validity is the resolved `mrb_method_t` itself rather than a "was
assigned" flag, so `def`, `alias_method`, `undef_method`,
`remove_method`, visibility changes and `prepend` are covered without
enumerating them; aliasing the original implementation back re-arms the
slot, and including a module that has no `[]` leaves it armed.  The
recheck hangs off the places that already invalidate the method cache
and does nothing unless the changed name is `[]` or `[]=`.

One consequence is not free.  mruby-regexp replaces `String#[]` with a
Ruby method at gem initialization, so in a build that includes it the
String read fast path is now correctly off and `str[i]` costs a send,
measured at 635 to 2379 instructions and 32ns to 130ns per iteration in
a tight loop.  That is the price of its regexp forms being reachable at
all; the comment there claiming the opcode keeps bypassing the override
is corrected.

`test/t/string.rb` had a named test pinning the bypass as intended; it
now pins the redefinition being honored, and Array and Hash gain the
equivalent.  The swap `test/t/gc.rb` wraps its two String assertions in
becomes load-bearing here: without it those loop bodies would stop
reaching the opcode in any build that carries mruby-regexp.
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 160498c3-0028-4be5-8b76-e833cce1f5fd

📥 Commits

Reviewing files that changed from the base of the PR and between 9710e46 and 261866e.

📒 Files selected for processing (10)
  • include/mruby.h
  • include/mruby/internal.h
  • mrbgems/mruby-regexp/mrblib/string_regexp.rb
  • src/class.c
  • src/state.c
  • src/vm.c
  • test/t/array.rb
  • test/t/gc.rb
  • test/t/hash.rb
  • test/t/string.rb

Included review availability: Your plan includes up to 8 reviews per rolling hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The change adds cached guards for Array, Hash, and String index operators. Method changes refresh these guards, and VM fast paths fall back to dynamic dispatch when operators are overridden. Tests cover overrides, subclasses, restoration, and GC behavior.

Changes

Inline index operation guards

Layer / File(s) Summary
Guard storage and initialization
include/mruby.h, include/mruby/internal.h, src/state.c
Adds index-operation slots, cached classes, builtin methods, and post-bootstrap initialization.
Method-change tracking
src/class.c
Refreshes affected or all index-operation guards after method definitions, inclusion, visibility changes, and removals.
Fast-path validation and fallback
src/vm.c, mrbgems/mruby-regexp/mrblib/string_regexp.rb
Validates receiver classes before indexed reads and assignments. Updated comments describe String dispatch behavior.
Override and restoration coverage
test/t/array.rb, test/t/hash.rb, test/t/string.rb, test/t/gc.rb
Tests overridden operators, subclass dispatch, restoration, and string indexing GC checks.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 26186

The change makes indexed access honor core-class redefinitions while preserving the existing fast path when the builtin remains active; no actionable merge-blocking risk remains beyond normal checks and review.

Possibly related PRs

Suggested reviewers: matz

Sequence Diagram(s)

sequenceDiagram
  participant RubyCode
  participant MethodTable
  participant IndexGuards
  participant VM
  RubyCode->>MethodTable: redefine Array, Hash, or String index method
  MethodTable->>IndexGuards: refresh affected slot
  RubyCode->>VM: execute indexed operation
  VM->>IndexGuards: validate cached receiver class
  alt builtin implementation remains active
    VM->>VM: use fast path
  else method override is active
    VM->>MethodTable: perform dynamic dispatch
  end
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: honoring [] redefinitions on core classes in vm.c.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants