[ruby-core:127190] [Ruby Feature#22459] Scoped method-assumption validity tokens (RubyVM.method_token)
Issue #22459 has been reported by thomasklemm (Thomas Klemm). ---------------------------------------- Feature #22459: Scoped method-assumption validity tokens (RubyVM.method_token) https://bugs.ruby-lang.org/issues/22459 * Author: thomasklemm (Thomas Klemm) * Status: Open ---------------------------------------- Would MRI consider an API for subscribing to a method-lookup assumption and cheaply checking whether it remains valid? The intended shape is scoped to `(class, method name)`, using existing callable-method-entry (CME) invalidation, not a new global cache key. I would appreciate guidance on whether a C-only API, an opaque Ruby token, or another shape would fit MRI better. Memoization and dependency-injection libraries, sorbet-runtime-style checks, specialization tools and diagnostics sometimes need to detect changed method lookup without preventing ordinary Ruby mutation. Ruby callbacks can be suppressed; freezing holders and their complete ancestor chains is useful where acceptable, but enforces a different contract. Tokens would support unfrozen holders and apps that cannot freeze, with detection/fallback instead of enforcement. They would not make arbitrary inlining or heap assumptions safe. ## Illustrative API and semantics ```ruby token = RubyVM.method_token(klass, :method_name) # RubyVM::MethodToken or nil token.valid? # boolean ``` ```c VALUE rb_vm_method_token(VALUE klass, ID mid); int rb_vm_method_token_valid_p(VALUE token); ``` The prototype accepts a Class (including an existing singleton Class), resolves and registers the CME even without an existing callsite, and retains the class and entry until token GC. Missing/refined entries return nil; ordinary Modules raise TypeError. Relevant existing CME/cache invalidation paths permanently clear the token. Checking allocates nothing and invokes no Ruby callback. Invalidation may be conservative; renewal requires revalidating assumptions. This is **a main-Ractor/GVL experiment**, not a final concurrency contract: outside the main Ractor acquisition returns nil, checks return false and revocation is ignored. Cross-Ractor mutation is not safe under this prototype. A check is not a lock across later Ruby execution. Consumers still need actual receiver-class guards (a new per-instance singleton does not change lookup on the ordinary class), reentry checkpoints, and independent value/scope checks. Constants/autoload, refinements and Box selection are not covered. Ruby wrappers are ordinary overridable methods; the C interface is distinct. ## Why not a global epoch? I initially prototyped a global mutation counter. It invalidates no caches itself, but its consumers suffer the same coarse-grained churn that motivated [Feature #18589](https://bugs.ruby-lang.org/issues/18589) and [PR #5766](https://github.com/ruby/ruby/pull/5766). In production-mode Fizzy with Rails 8.1.4, after 200 warmup requests, **every one of 1,000 authenticated `/bubbles` requests bumped it 20 times**. The sampled stack was `Module#extend_object -> Kernel#extend -> ActiveRecord::Associations::CollectionProxy#initialize`, at `collection_proxy.rb:37: extend(*extending)`, reached through association reading from the assignees view. Five singleton creations, ten include entry/exit bumps and five method-cache clears accounted for each request. Ignoring singleton holders still left five ordinary superclass-cache bumps. On the master token build, the same `/bubbles` and `/buckets` cohorts each had **zero invalidations of 12 held tokens**: Hash#each, Enumerable-owned Hash#each_with_object and ten view/builder methods. `/bubbles` still had 20 epoch bumps/request. The blog's two endpoints and Fizzy `/buckets` had zero epoch churn. This is limited endpoint evidence, not a universal stability claim. ## Costs, tests and patch One six-round fixed-heap Callgrind cohort per mode, paired A/A, N=0/100,000, no retries-to-green. All heap, A/A and actual-JIT gates passed; every YJIT window was compilation/invalidation-flat. Current-master net Ir per check: | Operation | Interpreter | YJIT | |---|---:|---:| | Ruby token read / successful guarded branch | 183 / 201 | 102 / 105 | | Direct C token validity check | 26 | 26 | | Global epoch read / successful guarded branch | 194 / 265 | 75 / 91 | These exclude acquisition/revalidation and are instructions, not latency or application speedups. The historical 4.0.7 epoch read was 195/36 Ir; it is a different build/cohort. The scoped token is not cheaper than the epoch in YJIT; scope is the reason to prefer it. The epoch's isolated uncontended CAS costs 7 net Ir. Paired native 4.0.7 CPU time: Rails blog boot +0.44% (95% interval −2.62% to +1.86%); 100,000 define_method operations +2.81% (+1.16% to +3.61%). The master epoch+token definition result, +1.24% (−2.06% to +22.21%), was too noisy for a precise token overhead claim; subscription-heavy revocation remains unmeasured. Standalone tests passed in interpreter and YJIT: **10 tests, 59 assertions**. Master `make test-all`: **38,696 tests, one failure, two errors, 221 skips**. The same exact identities reproduced on a clean-master targeted control: `TestThread#test_handle_interrupt_masks_sigint` (missing stdout), and `TestIO#test_seek` / `TestIO#test_seek_symwhence` (ENOENT). This is not a green suite or a full clean-master equivalence run. Tests include deprecated constant removal reentering Warning.warn: the epoch must change before that callback; method tokens do not cover it. A constant-name token would need a separate design rather than blindly using the late cache-invalidation seam. - [Prototype branch on current master](https://github.com/thomasklemm/ruby/tree/mutation-epoch-prototype) ([patch](https://github.com/thomasklemm/ruby/compare/cfa50b00939fd07ae87ffceb730bd2cf...)); the first commit is the rejected global experiment, the second adds scoped tokens. - [Self-contained evidence, controls and limitations](https://github.com/thomasklemm/lowrail/blob/main/docs/upstream-drafts/ruby-m...). - [Lowrail](https://github.com/thomasklemm/lowrail), the motivating experiment; [earlier epoch investigation](https://github.com/thomasklemm/lowrail/blob/main/docs/passes/vm-epoch.md). The token direction follows [Feature #16614](https://bugs.ruby-lang.org/issues/16614). I also found [Feature #17660](https://bugs.ruby-lang.org/issues/17660), but no supported API with this scoped subscription contract in master at [`cfa50b0`](https://github.com/ruby/ruby/commit/cfa50b00939fd07ae87ffceb730bd2cfbccd8553) (2026-10-21). Would exposing this kind of assumption be useful and maintainable? Which ownership, lifetime, concurrency and reentry semantics would core prefer? Would a C-only interface or an existing facility be a better starting point? -- https://bugs.ruby-lang.org/
Issue #22459 has been updated by thomasklemm (Thomas Klemm). Description updated Description updated: review found a reproducible `Cannot malloc during GC` in the token registry lifecycle; [a30db2f](https://github.com/thomasklemm/ruby/commit/a30db2f20843940c089bd8247135a7c7c...) fixes existing-head updates and registration publication, and permanently disables token acquisition and checks upon entering multi-Ractor mode, including in subsequent forked children. Pinned remeasurement gives token read/successful-guard costs of **184/202 interpreter Ir and 103/106 YJIT Ir**, with **26 Ir** for the direct C check in both, passing Ruby/native A/A, fixed-heap and JIT gates. The [evidence](https://github.com/thomasklemm/lowrail/blob/acfef7152895dbdb70caafbbf6823bf2...) preserves historical figures and the INVALID mixed-harness attempt; existing full-suite, write-timing and scope caveats remain. ---------------------------------------- Feature #22459: Scoped method-assumption validity tokens (RubyVM.method_token) https://bugs.ruby-lang.org/issues/22459#change-119608 * Author: thomasklemm (Thomas Klemm) * Status: Open ---------------------------------------- Would MRI consider an API for subscribing to a method-lookup assumption and cheaply checking whether it remains valid? The intended shape is scoped to `(class, method name)`, using existing callable-method-entry (CME) invalidation, not a new global cache key. I would appreciate guidance on whether a C-only API, an opaque Ruby token, or another shape would fit MRI better. Memoization and dependency-injection libraries, sorbet-runtime-style checks, specialization tools and diagnostics sometimes need to detect changed method lookup without preventing ordinary Ruby mutation. Ruby callbacks can be suppressed; freezing holders and their complete ancestor chains is useful where acceptable, but enforces a different contract. Tokens would support unfrozen holders and detection/fallback, not make arbitrary inlining or heap assumptions safe. ## Illustrative API and semantics ```ruby token = RubyVM.method_token(klass, :method_name) # RubyVM::MethodToken or nil token.valid? # boolean ``` ```c VALUE rb_vm_method_token(VALUE klass, ID mid); int rb_vm_method_token_valid_p(VALUE token); ``` The [current prototype](https://github.com/thomasklemm/ruby/commit/a30db2f20843940c089bd8247135a7c7c...) accepts a Class, including an existing singleton Class, resolves/registers the CME even without an existing callsite, and retains and pins the class and entry until token GC. Missing/refined entries return nil; ordinary Modules raise TypeError while enabled. A non-owning registry associates CMEs with tokens; four existing CME/cache invalidation paths also permanently revoke their registered tokens. Revocation may be conservative; renewal requires revalidating assumptions. The C check of a genuine token allocates nothing and invokes no Ruby callback. Acquisition does not call the target or `method_missing`; Ruby name coercion may execute Ruby or raise. Review of the first prototype found a reproducible `Cannot malloc during GC`: `st_insert` can grow even when replacing an existing registry head. The follow-up retains the registry but uses existing-only `st_update` for destruction and existing-list registration, with no allocation after borrowing a collectible head. `valid` is published only after registration succeeds, so failed registration cannot unlink an unpublished node. Compaction/parallel-sweep concerns were checked and not established: `rb_gc_mark` pins the retained references, and the current GC defers non-thread-safe `dfree` during parallel local sweeps. The header is self-contained; experimental lookup-only RDoc was added. This API has no compatibility guarantee; NEWS is deferred pending acceptance. This is **a single-Ractor/GVL experiment**, not a final concurrency contract. C callers require the GVL. The first multi-Ractor transition sets a latch before other Ractors can run, permanently disabling acquisition (nil) and checks (false) in every Ractor, even after termination and fork. Revocation is skipped after disabling, but GC still unlinks registered nodes. The resettable single-Ractor predicate alone would allow fork revalidation. No concurrent-Ractor assumption API is claimed. A check is not a lock across later Ruby execution or thread switches. Consumers must validate the original definition and independently guard the actual receiver class, lexical scope and mutable values, then recheck at reentry boundaries. A new per-instance singleton does not change lookup on the ordinary class. Callees, including `super`, constants/autoload, refinements and Box selection are not certified. Ruby wrappers are ordinary overridable methods; the C interface is distinct. ## Why not a global epoch? I initially prototyped a global mutation counter. It invalidates no caches itself, but consumers suffer the coarse-grained churn that motivated [Feature #18589](https://bugs.ruby-lang.org/issues/18589) and [PR #5766](https://github.com/ruby/ruby/pull/5766). In the earlier production-mode Fizzy census with Rails 8.1.4, after 200 warmup requests, **every one of 1,000 authenticated `/bubbles` requests bumped it 20 times**. The sampled stack was `Module#extend_object -> Kernel#extend -> ActiveRecord::Associations::CollectionProxy#initialize`, at `collection_proxy.rb:37: extend(*extending)`, reached through association reading from the assignees view. Five singleton creations, ten include entry/exit bumps and five method-cache clears accounted for each request. Ignoring singleton holders still left five ordinary superclass-cache bumps. On the original master token build, `/bubbles` and `/buckets` each had **zero invalidations of 12 held tokens**: `Hash#each`, Enumerable-owned `Hash#each_with_object` and ten view/builder methods. `/bubbles` still had 20 epoch bumps/request. The blog's two endpoints and Fizzy `/buckets` had zero epoch churn. These were selected dependencies, partly acquired through a separately constructed view context, not complete endpoint execution certificates or a universal stability claim. The global epoch remains a rejected alternative, not the proposed API. ## Costs, tests and patch The new read cohort measures Ruby at [`a30db2f`](https://github.com/thomasklemm/ruby/commit/a30db2f20843940c089bd8247135a7c7c...), on master base [`cfa50b0`](https://github.com/ruby/ruby/commit/cfa50b00939fd07ae87ffceb730bd2cfbccd8553), with a clean detached Lowrail harness at [`0f83cd0`](https://github.com/thomasklemm/lowrail/commit/0f83cd0eddab1997c9dda6e54c6e99...), recorded in every worker. GCC 12.2, `-O3 -g`, x86-64 Linux, patched Valgrind 3.19; one finite six-round pair, seed 41231, N=0/100,000, at most two workers. Both modes passed Ruby/native A/A (95% intervals [0, 0]%), fixed-heap and actual-JIT gates; every YJIT window was compilation/invalidation-flat. No window collected; positive windows allocated zero objects. Net Ir/check: | Operation | Interpreter | YJIT | |---|---:|---:| | Ruby token read / successful guarded branch | 184 / 202 | 103 / 106 | | Direct C token validity check | 26 | 26 | | Global epoch read / successful guarded branch | 194 / 265 | 75 / 91 | Earlier figures on the pre-fix prototype are superseded and retained in the [pinned evidence](https://github.com/thomasklemm/lowrail/blob/acfef7152895dbdb70caafbbf6823bf2...). An intervening mixed-harness cohort is INVALID for protocol drift; none of its prices is claimed. The separately authorized pinned cohort had no retry. Costs exclude acquisition/revalidation and failure paths, and are instructions, not latency or application speedups. The token is not cheaper than the epoch in YJIT; scope is the reason to prefer it. The historical 4.0.7 epoch read was 195/36 Ir, a different build/cohort. Historical write-side CPU timing: 4.0.7 epoch Rails blog boot +0.44% (95% interval −2.62% to +1.86%), 100,000 `define_method` operations +2.81% (+1.16% to +3.61%). The pre-fix master epoch+token definition result, +1.24% (−2.06% to +22.21%), was too noisy for a precise token write-overhead claim and held no subscriptions to the redefined method. The isolated uncontended epoch CAS costs 7 net Ir, not a full mutation or contended atomic. There is no master-token boot-cost result; subscribed revocation, memory use and multicore writes remain unmeasured. These are not write-cost measurements of the lifecycle fix. On the current fix, interpreter and YJIT each pass **13 tests, 97 assertions**, including the formerly failing lifecycle regression, GC stress/compaction, lookup invalidation and permanent disabling after multi-Ractor entry, preserved across fork. Allocation-failure publication ordering was source-reviewed, not fault-injected. Historical master `make test-all`: **38,696 tests, one failure, two errors, 221 skips**. The exact identities reproduced on a clean-master targeted control: `TestThread#test_handle_interrupt_masks_sigint` (missing stdout), and `TestIO#test_seek` / `TestIO#test_seek_symwhence` (ENOENT). This is not a green suite or full clean-master equivalence; full `test-all` was not rerun on the follow-up. Tests also cover deprecated constant removal reentering `Warning.warn`: the epoch changes before the callback, but method tokens do not cover constants. A constant-name token needs a separate design, not blind subscription to the late constant-cache invalidator. - [Prototype branch](https://github.com/thomasklemm/ruby/tree/mutation-epoch-prototype) ([patch](https://github.com/thomasklemm/ruby/compare/cfa50b00939fd07ae87ffceb730bd2cf...)): rejected global experiment, original scoped tokens, then the lifecycle fix; original commit links are preserved. No upstream PR has been opened. - [Evidence, controls and limitations](https://github.com/thomasklemm/lowrail/blob/main/docs/upstream-drafts/ruby-m...). - [Lowrail](https://github.com/thomasklemm/lowrail), the motivating experiment; [earlier epoch investigation](https://github.com/thomasklemm/lowrail/blob/main/docs/passes/vm-epoch.md). The token direction follows [Feature #16614](https://bugs.ruby-lang.org/issues/16614). I also found [Feature #17660](https://bugs.ruby-lang.org/issues/17660), but no supported API with this scoped subscription contract in master at the pinned base (2026-10-21). This is a scoped search finding, not an exhaustive survey. Would exposing this kind of assumption be useful and maintainable? Which ownership, lifetime, concurrency and reentry semantics would core prefer? Would a C-only interface or an existing facility be a better starting point? -- https://bugs.ruby-lang.org/
Issue #22459 has been updated by mame (Yusuke Endoh). This feels like an XY problem. Before describing the feature, please explain what you actually want to do, ideally with a short concrete example.
Memoization and dependency-injection libraries, sorbet-runtime-style checks, specialization tools and diagnostics
What would these do after detecting a redefinition? To always call the original method, store a Method object and use bind_call. To always call the latest method, just call it. Detecting redefinition itself might help some very specialized optimizations, but I can't tell what you have in mind, or whether it is common enough to justify this feature. ---------------------------------------- Feature #22459: Scoped method-assumption validity tokens (RubyVM.method_token) https://bugs.ruby-lang.org/issues/22459#change-119610 * Author: thomasklemm (Thomas Klemm) * Status: Open ---------------------------------------- Would MRI consider an API for subscribing to a method-lookup assumption and cheaply checking whether it remains valid? The intended shape is scoped to `(class, method name)`, using existing callable-method-entry (CME) invalidation, not a new global cache key. I would appreciate guidance on whether a C-only API, an opaque Ruby token, or another shape would fit MRI better. Memoization and dependency-injection libraries, sorbet-runtime-style checks, specialization tools and diagnostics sometimes need to detect changed method lookup without preventing ordinary Ruby mutation. Ruby callbacks can be suppressed; freezing holders and their complete ancestor chains is useful where acceptable, but enforces a different contract. Tokens would support unfrozen holders and detection/fallback, not make arbitrary inlining or heap assumptions safe. ## Illustrative API and semantics ```ruby token = RubyVM.method_token(klass, :method_name) # RubyVM::MethodToken or nil token.valid? # boolean ``` ```c VALUE rb_vm_method_token(VALUE klass, ID mid); int rb_vm_method_token_valid_p(VALUE token); ``` The [current prototype](https://github.com/thomasklemm/ruby/commit/a30db2f20843940c089bd8247135a7c7c...) accepts a Class, including an existing singleton Class, resolves/registers the CME even without an existing callsite, and retains and pins the class and entry until token GC. Missing/refined entries return nil; ordinary Modules raise TypeError while enabled. A non-owning registry associates CMEs with tokens; four existing CME/cache invalidation paths also permanently revoke their registered tokens. Revocation may be conservative; renewal requires revalidating assumptions. The C check of a genuine token allocates nothing and invokes no Ruby callback. Acquisition does not call the target or `method_missing`; Ruby name coercion may execute Ruby or raise. Review of the first prototype found a reproducible `Cannot malloc during GC`: `st_insert` can grow even when replacing an existing registry head. The follow-up retains the registry but uses existing-only `st_update` for destruction and existing-list registration, with no allocation after borrowing a collectible head. `valid` is published only after registration succeeds, so failed registration cannot unlink an unpublished node. Compaction/parallel-sweep concerns were checked and not established: `rb_gc_mark` pins the retained references, and the current GC defers non-thread-safe `dfree` during parallel local sweeps. The header is self-contained; experimental lookup-only RDoc was added. This API has no compatibility guarantee; NEWS is deferred pending acceptance. This is **a single-Ractor/GVL experiment**, not a final concurrency contract. C callers require the GVL. The first multi-Ractor transition sets a latch before other Ractors can run, permanently disabling acquisition (nil) and checks (false) in every Ractor, even after termination and fork. Revocation is skipped after disabling, but GC still unlinks registered nodes. The resettable single-Ractor predicate alone would allow fork revalidation. No concurrent-Ractor assumption API is claimed. A check is not a lock across later Ruby execution or thread switches. Consumers must validate the original definition and independently guard the actual receiver class, lexical scope and mutable values, then recheck at reentry boundaries. A new per-instance singleton does not change lookup on the ordinary class. Callees, including `super`, constants/autoload, refinements and Box selection are not certified. Ruby wrappers are ordinary overridable methods; the C interface is distinct. ## Why not a global epoch? I initially prototyped a global mutation counter. It invalidates no caches itself, but consumers suffer the coarse-grained churn that motivated [Feature #18589](https://bugs.ruby-lang.org/issues/18589) and [PR #5766](https://github.com/ruby/ruby/pull/5766). In the earlier production-mode Fizzy census with Rails 8.1.4, after 200 warmup requests, **every one of 1,000 authenticated `/bubbles` requests bumped it 20 times**. The sampled stack was `Module#extend_object -> Kernel#extend -> ActiveRecord::Associations::CollectionProxy#initialize`, at `collection_proxy.rb:37: extend(*extending)`, reached through association reading from the assignees view. Five singleton creations, ten include entry/exit bumps and five method-cache clears accounted for each request. Ignoring singleton holders still left five ordinary superclass-cache bumps. On the original master token build, `/bubbles` and `/buckets` each had **zero invalidations of 12 held tokens**: `Hash#each`, Enumerable-owned `Hash#each_with_object` and ten view/builder methods. `/bubbles` still had 20 epoch bumps/request. The blog's two endpoints and Fizzy `/buckets` had zero epoch churn. These were selected dependencies, partly acquired through a separately constructed view context, not complete endpoint execution certificates or a universal stability claim. The global epoch remains a rejected alternative, not the proposed API. ## Costs, tests and patch The new read cohort measures Ruby at [`a30db2f`](https://github.com/thomasklemm/ruby/commit/a30db2f20843940c089bd8247135a7c7c...), on master base [`cfa50b0`](https://github.com/ruby/ruby/commit/cfa50b00939fd07ae87ffceb730bd2cfbccd8553), with a clean detached Lowrail harness at [`0f83cd0`](https://github.com/thomasklemm/lowrail/commit/0f83cd0eddab1997c9dda6e54c6e99...), recorded in every worker. GCC 12.2, `-O3 -g`, x86-64 Linux, patched Valgrind 3.19; one finite six-round pair, seed 41231, N=0/100,000, at most two workers. Both modes passed Ruby/native A/A (95% intervals [0, 0]%), fixed-heap and actual-JIT gates; every YJIT window was compilation/invalidation-flat. No window collected; positive windows allocated zero objects. Net Ir/check: | Operation | Interpreter | YJIT | |---|---:|---:| | Ruby token read / successful guarded branch | 184 / 202 | 103 / 106 | | Direct C token validity check | 26 | 26 | | Global epoch read / successful guarded branch | 194 / 265 | 75 / 91 | Earlier figures on the pre-fix prototype are superseded and retained in the [pinned evidence](https://github.com/thomasklemm/lowrail/blob/acfef7152895dbdb70caafbbf6823bf2...). An intervening mixed-harness cohort is INVALID for protocol drift; none of its prices is claimed. The separately authorized pinned cohort had no retry. Costs exclude acquisition/revalidation and failure paths, and are instructions, not latency or application speedups. The token is not cheaper than the epoch in YJIT; scope is the reason to prefer it. The historical 4.0.7 epoch read was 195/36 Ir, a different build/cohort. Historical write-side CPU timing: 4.0.7 epoch Rails blog boot +0.44% (95% interval −2.62% to +1.86%), 100,000 `define_method` operations +2.81% (+1.16% to +3.61%). The pre-fix master epoch+token definition result, +1.24% (−2.06% to +22.21%), was too noisy for a precise token write-overhead claim and held no subscriptions to the redefined method. The isolated uncontended epoch CAS costs 7 net Ir, not a full mutation or contended atomic. There is no master-token boot-cost result; subscribed revocation, memory use and multicore writes remain unmeasured. These are not write-cost measurements of the lifecycle fix. On the current fix, interpreter and YJIT each pass **13 tests, 97 assertions**, including the formerly failing lifecycle regression, GC stress/compaction, lookup invalidation and permanent disabling after multi-Ractor entry, preserved across fork. Allocation-failure publication ordering was source-reviewed, not fault-injected. Historical master `make test-all`: **38,696 tests, one failure, two errors, 221 skips**. The exact identities reproduced on a clean-master targeted control: `TestThread#test_handle_interrupt_masks_sigint` (missing stdout), and `TestIO#test_seek` / `TestIO#test_seek_symwhence` (ENOENT). This is not a green suite or full clean-master equivalence; full `test-all` was not rerun on the follow-up. Tests also cover deprecated constant removal reentering `Warning.warn`: the epoch changes before the callback, but method tokens do not cover constants. A constant-name token needs a separate design, not blind subscription to the late constant-cache invalidator. - [Prototype branch](https://github.com/thomasklemm/ruby/tree/mutation-epoch-prototype) ([patch](https://github.com/thomasklemm/ruby/compare/cfa50b00939fd07ae87ffceb730bd2cf...)): rejected global experiment, original scoped tokens, then the lifecycle fix; original commit links are preserved. No upstream PR has been opened. - [Evidence, controls and limitations](https://github.com/thomasklemm/lowrail/blob/main/docs/upstream-drafts/ruby-m...). - [Lowrail](https://github.com/thomasklemm/lowrail), the motivating experiment; [earlier epoch investigation](https://github.com/thomasklemm/lowrail/blob/main/docs/passes/vm-epoch.md). The token direction follows [Feature #16614](https://bugs.ruby-lang.org/issues/16614). I also found [Feature #17660](https://bugs.ruby-lang.org/issues/17660), but no supported API with this scoped subscription contract in master at the pinned base (2026-10-21). This is a scoped search finding, not an exhaustive survey. Would exposing this kind of assumption be useful and maintainable? Which ownership, lifetime, concurrency and reentry semantics would core prefer? Would a C-only interface or an existing facility be a better starting point? -- https://bugs.ruby-lang.org/
Issue #22459 has been updated by thomasklemm (Thomas Klemm). Thank you, that is a fair question. My list of possible users was too broad. Here is the concrete case. ### What I want to do [Lowrail](https://github.com/thomasklemm/lowrail) is an experimental specializer for Rails apps. It boots the app, records which method definitions are actually in effect, and replaces a few hot methods with bodies that inline what their callees do. Such a body is correct only while those callees still resolve to the definitions it was built from. After a redefinition I want neither of the two options you describe. The specialized body should stop being used, and the method should run its original body, which calls whatever is defined now. So the behavior is "call the latest method"; the token only answers whether the shortcut that *skips* the call is still allowed. YJIT already does this internally with callable-method-entry invalidation. I am asking whether code generated in Ruby could get the same signal. ### A short example ```ruby class Order < ApplicationRecord enum :status, { pending: 0, paid: 1 } # Rails defines paid? via define_method + public_send def shippable? = paid? && !archived? end ``` At boot, after checking that `Order#paid?` is the method Rails generated for this enum, a tool would install: ```ruby class Order PAID = RubyVM.method_token(Order, :paid?) def shippable? if PAID.valid? # plus a receiver-class check, omitted here status_for_database == 1 && !archived? # body of the generated paid?, inlined else paid? && !archived? # original body end end end ``` If the app, a gem or a test stub later redefines `paid?`, the token is revoked and `shippable?` simply calls `paid?` again. The tool can rebuild the specialization after checking the new definition. The example is deliberately small: it saves one block-method call and one `public_send`. The specializations that matter inline longer chains of Rails view helpers. On one endpoint (`GET /articles/1` in a Rails blog) they removed 15.5% of interpreter instructions per request. That is one endpoint and an instruction count, not latency. It also did not use tokens: it relied on an opt-in promise not to redefine those methods after boot, because there was no detection the tool could trust. ### Why method_added is not enough Today the tool detects redefinitions by prepending `method_added` / `singleton_method_added` hooks to `Module` / `BasicObject`. A class that defines its own hook without calling `super` hides its redefinitions: ```ruby class Order def self.method_added(name) = nil # e.g. from a gem def paid? = status == "paid" && !refunded? # the tool's hook never runs end ``` When we suppressed hooks like this in tests, seven cached specializations kept their fast path and returned stale results ([details](https://github.com/thomasklemm/lowrail/blob/main/docs/passes/suppressed-hook...)). Freezing the class and its ancestors prevents redefinition, but it also breaks things apps and test suites rely on, such as stubbing methods. A token tied to the method entry does not depend on Ruby callbacks. ### Is it common enough? I cannot claim that it is. The users I can name are tools that specialize or inline Ruby code across method boundaries, and I withdraw memoization and DI libraries as motivation. If that is too narrow for a Ruby-level API, would an experimental C-level API (`rb_vm_method_token` / `rb_vm_method_token_valid_p`) be acceptable? Or is there an existing facility I should use instead? ---------------------------------------- Feature #22459: Scoped method-assumption validity tokens (RubyVM.method_token) https://bugs.ruby-lang.org/issues/22459#change-119612 * Author: thomasklemm (Thomas Klemm) * Status: Open ---------------------------------------- Would MRI consider an API for subscribing to a method-lookup assumption and cheaply checking whether it remains valid? The intended shape is scoped to `(class, method name)`, using existing callable-method-entry (CME) invalidation, not a new global cache key. I would appreciate guidance on whether a C-only API, an opaque Ruby token, or another shape would fit MRI better. Memoization and dependency-injection libraries, sorbet-runtime-style checks, specialization tools and diagnostics sometimes need to detect changed method lookup without preventing ordinary Ruby mutation. Ruby callbacks can be suppressed; freezing holders and their complete ancestor chains is useful where acceptable, but enforces a different contract. Tokens would support unfrozen holders and detection/fallback, not make arbitrary inlining or heap assumptions safe. ## Illustrative API and semantics ```ruby token = RubyVM.method_token(klass, :method_name) # RubyVM::MethodToken or nil token.valid? # boolean ``` ```c VALUE rb_vm_method_token(VALUE klass, ID mid); int rb_vm_method_token_valid_p(VALUE token); ``` The [current prototype](https://github.com/thomasklemm/ruby/commit/a30db2f20843940c089bd8247135a7c7c...) accepts a Class, including an existing singleton Class, resolves/registers the CME even without an existing callsite, and retains and pins the class and entry until token GC. Missing/refined entries return nil; ordinary Modules raise TypeError while enabled. A non-owning registry associates CMEs with tokens; four existing CME/cache invalidation paths also permanently revoke their registered tokens. Revocation may be conservative; renewal requires revalidating assumptions. The C check of a genuine token allocates nothing and invokes no Ruby callback. Acquisition does not call the target or `method_missing`; Ruby name coercion may execute Ruby or raise. Review of the first prototype found a reproducible `Cannot malloc during GC`: `st_insert` can grow even when replacing an existing registry head. The follow-up retains the registry but uses existing-only `st_update` for destruction and existing-list registration, with no allocation after borrowing a collectible head. `valid` is published only after registration succeeds, so failed registration cannot unlink an unpublished node. Compaction/parallel-sweep concerns were checked and not established: `rb_gc_mark` pins the retained references, and the current GC defers non-thread-safe `dfree` during parallel local sweeps. The header is self-contained; experimental lookup-only RDoc was added. This API has no compatibility guarantee; NEWS is deferred pending acceptance. This is **a single-Ractor/GVL experiment**, not a final concurrency contract. C callers require the GVL. The first multi-Ractor transition sets a latch before other Ractors can run, permanently disabling acquisition (nil) and checks (false) in every Ractor, even after termination and fork. Revocation is skipped after disabling, but GC still unlinks registered nodes. The resettable single-Ractor predicate alone would allow fork revalidation. No concurrent-Ractor assumption API is claimed. A check is not a lock across later Ruby execution or thread switches. Consumers must validate the original definition and independently guard the actual receiver class, lexical scope and mutable values, then recheck at reentry boundaries. A new per-instance singleton does not change lookup on the ordinary class. Callees, including `super`, constants/autoload, refinements and Box selection are not certified. Ruby wrappers are ordinary overridable methods; the C interface is distinct. ## Why not a global epoch? I initially prototyped a global mutation counter. It invalidates no caches itself, but consumers suffer the coarse-grained churn that motivated [Feature #18589](https://bugs.ruby-lang.org/issues/18589) and [PR #5766](https://github.com/ruby/ruby/pull/5766). In the earlier production-mode Fizzy census with Rails 8.1.4, after 200 warmup requests, **every one of 1,000 authenticated `/bubbles` requests bumped it 20 times**. The sampled stack was `Module#extend_object -> Kernel#extend -> ActiveRecord::Associations::CollectionProxy#initialize`, at `collection_proxy.rb:37: extend(*extending)`, reached through association reading from the assignees view. Five singleton creations, ten include entry/exit bumps and five method-cache clears accounted for each request. Ignoring singleton holders still left five ordinary superclass-cache bumps. On the original master token build, `/bubbles` and `/buckets` each had **zero invalidations of 12 held tokens**: `Hash#each`, Enumerable-owned `Hash#each_with_object` and ten view/builder methods. `/bubbles` still had 20 epoch bumps/request. The blog's two endpoints and Fizzy `/buckets` had zero epoch churn. These were selected dependencies, partly acquired through a separately constructed view context, not complete endpoint execution certificates or a universal stability claim. The global epoch remains a rejected alternative, not the proposed API. ## Costs, tests and patch The new read cohort measures Ruby at [`a30db2f`](https://github.com/thomasklemm/ruby/commit/a30db2f20843940c089bd8247135a7c7c...), on master base [`cfa50b0`](https://github.com/ruby/ruby/commit/cfa50b00939fd07ae87ffceb730bd2cfbccd8553), with a clean detached Lowrail harness at [`0f83cd0`](https://github.com/thomasklemm/lowrail/commit/0f83cd0eddab1997c9dda6e54c6e99...), recorded in every worker. GCC 12.2, `-O3 -g`, x86-64 Linux, patched Valgrind 3.19; one finite six-round pair, seed 41231, N=0/100,000, at most two workers. Both modes passed Ruby/native A/A (95% intervals [0, 0]%), fixed-heap and actual-JIT gates; every YJIT window was compilation/invalidation-flat. No window collected; positive windows allocated zero objects. Net Ir/check: | Operation | Interpreter | YJIT | |---|---:|---:| | Ruby token read / successful guarded branch | 184 / 202 | 103 / 106 | | Direct C token validity check | 26 | 26 | | Global epoch read / successful guarded branch | 194 / 265 | 75 / 91 | Earlier figures on the pre-fix prototype are superseded and retained in the [pinned evidence](https://github.com/thomasklemm/lowrail/blob/acfef7152895dbdb70caafbbf6823bf2...). An intervening mixed-harness cohort is INVALID for protocol drift; none of its prices is claimed. The separately authorized pinned cohort had no retry. Costs exclude acquisition/revalidation and failure paths, and are instructions, not latency or application speedups. The token is not cheaper than the epoch in YJIT; scope is the reason to prefer it. The historical 4.0.7 epoch read was 195/36 Ir, a different build/cohort. Historical write-side CPU timing: 4.0.7 epoch Rails blog boot +0.44% (95% interval −2.62% to +1.86%), 100,000 `define_method` operations +2.81% (+1.16% to +3.61%). The pre-fix master epoch+token definition result, +1.24% (−2.06% to +22.21%), was too noisy for a precise token write-overhead claim and held no subscriptions to the redefined method. The isolated uncontended epoch CAS costs 7 net Ir, not a full mutation or contended atomic. There is no master-token boot-cost result; subscribed revocation, memory use and multicore writes remain unmeasured. These are not write-cost measurements of the lifecycle fix. On the current fix, interpreter and YJIT each pass **13 tests, 97 assertions**, including the formerly failing lifecycle regression, GC stress/compaction, lookup invalidation and permanent disabling after multi-Ractor entry, preserved across fork. Allocation-failure publication ordering was source-reviewed, not fault-injected. Historical master `make test-all`: **38,696 tests, one failure, two errors, 221 skips**. The exact identities reproduced on a clean-master targeted control: `TestThread#test_handle_interrupt_masks_sigint` (missing stdout), and `TestIO#test_seek` / `TestIO#test_seek_symwhence` (ENOENT). This is not a green suite or full clean-master equivalence; full `test-all` was not rerun on the follow-up. Tests also cover deprecated constant removal reentering `Warning.warn`: the epoch changes before the callback, but method tokens do not cover constants. A constant-name token needs a separate design, not blind subscription to the late constant-cache invalidator. - [Prototype branch](https://github.com/thomasklemm/ruby/tree/mutation-epoch-prototype) ([patch](https://github.com/thomasklemm/ruby/compare/cfa50b00939fd07ae87ffceb730bd2cf...)): rejected global experiment, original scoped tokens, then the lifecycle fix; original commit links are preserved. No upstream PR has been opened. - [Evidence, controls and limitations](https://github.com/thomasklemm/lowrail/blob/main/docs/upstream-drafts/ruby-m...). - [Lowrail](https://github.com/thomasklemm/lowrail), the motivating experiment; [earlier epoch investigation](https://github.com/thomasklemm/lowrail/blob/main/docs/passes/vm-epoch.md). The token direction follows [Feature #16614](https://bugs.ruby-lang.org/issues/16614). I also found [Feature #17660](https://bugs.ruby-lang.org/issues/17660), but no supported API with this scoped subscription contract in master at the pinned base (2026-10-21). This is a scoped search finding, not an exhaustive survey. Would exposing this kind of assumption be useful and maintainable? Which ownership, lifetime, concurrency and reentry semantics would core prefer? Would a C-only interface or an existing facility be a better starting point? -- https://bugs.ruby-lang.org/
Issue #22459 has been updated by alanwu (Alan Wu). Frankly, I don't want to parse through this huge wall of LLM generated prose. Please be mindful of what you're asking readers to do. Your request boils down to wanting to expose some internal primitive that is used to implement Ruby's method call semantics so you can experiment adding on additional semantics or tweaking some. You can do that by forking the code and what not. I don't see why we need to ship your experiment to everyone and take on the maintenance burden. ---------------------------------------- Feature #22459: Scoped method-assumption validity tokens (RubyVM.method_token) https://bugs.ruby-lang.org/issues/22459#change-119614 * Author: thomasklemm (Thomas Klemm) * Status: Open ---------------------------------------- Would MRI consider an API for subscribing to a method-lookup assumption and cheaply checking whether it remains valid? The intended shape is scoped to `(class, method name)`, using existing callable-method-entry (CME) invalidation, not a new global cache key. I would appreciate guidance on whether a C-only API, an opaque Ruby token, or another shape would fit MRI better. Memoization and dependency-injection libraries, sorbet-runtime-style checks, specialization tools and diagnostics sometimes need to detect changed method lookup without preventing ordinary Ruby mutation. Ruby callbacks can be suppressed; freezing holders and their complete ancestor chains is useful where acceptable, but enforces a different contract. Tokens would support unfrozen holders and detection/fallback, not make arbitrary inlining or heap assumptions safe. ## Illustrative API and semantics ```ruby token = RubyVM.method_token(klass, :method_name) # RubyVM::MethodToken or nil token.valid? # boolean ``` ```c VALUE rb_vm_method_token(VALUE klass, ID mid); int rb_vm_method_token_valid_p(VALUE token); ``` The [current prototype](https://github.com/thomasklemm/ruby/commit/a30db2f20843940c089bd8247135a7c7c...) accepts a Class, including an existing singleton Class, resolves/registers the CME even without an existing callsite, and retains and pins the class and entry until token GC. Missing/refined entries return nil; ordinary Modules raise TypeError while enabled. A non-owning registry associates CMEs with tokens; four existing CME/cache invalidation paths also permanently revoke their registered tokens. Revocation may be conservative; renewal requires revalidating assumptions. The C check of a genuine token allocates nothing and invokes no Ruby callback. Acquisition does not call the target or `method_missing`; Ruby name coercion may execute Ruby or raise. Review of the first prototype found a reproducible `Cannot malloc during GC`: `st_insert` can grow even when replacing an existing registry head. The follow-up retains the registry but uses existing-only `st_update` for destruction and existing-list registration, with no allocation after borrowing a collectible head. `valid` is published only after registration succeeds, so failed registration cannot unlink an unpublished node. Compaction/parallel-sweep concerns were checked and not established: `rb_gc_mark` pins the retained references, and the current GC defers non-thread-safe `dfree` during parallel local sweeps. The header is self-contained; experimental lookup-only RDoc was added. This API has no compatibility guarantee; NEWS is deferred pending acceptance. This is **a single-Ractor/GVL experiment**, not a final concurrency contract. C callers require the GVL. The first multi-Ractor transition sets a latch before other Ractors can run, permanently disabling acquisition (nil) and checks (false) in every Ractor, even after termination and fork. Revocation is skipped after disabling, but GC still unlinks registered nodes. The resettable single-Ractor predicate alone would allow fork revalidation. No concurrent-Ractor assumption API is claimed. A check is not a lock across later Ruby execution or thread switches. Consumers must validate the original definition and independently guard the actual receiver class, lexical scope and mutable values, then recheck at reentry boundaries. A new per-instance singleton does not change lookup on the ordinary class. Callees, including `super`, constants/autoload, refinements and Box selection are not certified. Ruby wrappers are ordinary overridable methods; the C interface is distinct. ## Why not a global epoch? I initially prototyped a global mutation counter. It invalidates no caches itself, but consumers suffer the coarse-grained churn that motivated [Feature #18589](https://bugs.ruby-lang.org/issues/18589) and [PR #5766](https://github.com/ruby/ruby/pull/5766). In the earlier production-mode Fizzy census with Rails 8.1.4, after 200 warmup requests, **every one of 1,000 authenticated `/bubbles` requests bumped it 20 times**. The sampled stack was `Module#extend_object -> Kernel#extend -> ActiveRecord::Associations::CollectionProxy#initialize`, at `collection_proxy.rb:37: extend(*extending)`, reached through association reading from the assignees view. Five singleton creations, ten include entry/exit bumps and five method-cache clears accounted for each request. Ignoring singleton holders still left five ordinary superclass-cache bumps. On the original master token build, `/bubbles` and `/buckets` each had **zero invalidations of 12 held tokens**: `Hash#each`, Enumerable-owned `Hash#each_with_object` and ten view/builder methods. `/bubbles` still had 20 epoch bumps/request. The blog's two endpoints and Fizzy `/buckets` had zero epoch churn. These were selected dependencies, partly acquired through a separately constructed view context, not complete endpoint execution certificates or a universal stability claim. The global epoch remains a rejected alternative, not the proposed API. ## Costs, tests and patch The new read cohort measures Ruby at [`a30db2f`](https://github.com/thomasklemm/ruby/commit/a30db2f20843940c089bd8247135a7c7c...), on master base [`cfa50b0`](https://github.com/ruby/ruby/commit/cfa50b00939fd07ae87ffceb730bd2cfbccd8553), with a clean detached Lowrail harness at [`0f83cd0`](https://github.com/thomasklemm/lowrail/commit/0f83cd0eddab1997c9dda6e54c6e99...), recorded in every worker. GCC 12.2, `-O3 -g`, x86-64 Linux, patched Valgrind 3.19; one finite six-round pair, seed 41231, N=0/100,000, at most two workers. Both modes passed Ruby/native A/A (95% intervals [0, 0]%), fixed-heap and actual-JIT gates; every YJIT window was compilation/invalidation-flat. No window collected; positive windows allocated zero objects. Net Ir/check: | Operation | Interpreter | YJIT | |---|---:|---:| | Ruby token read / successful guarded branch | 184 / 202 | 103 / 106 | | Direct C token validity check | 26 | 26 | | Global epoch read / successful guarded branch | 194 / 265 | 75 / 91 | Earlier figures on the pre-fix prototype are superseded and retained in the [pinned evidence](https://github.com/thomasklemm/lowrail/blob/acfef7152895dbdb70caafbbf6823bf2...). An intervening mixed-harness cohort is INVALID for protocol drift; none of its prices is claimed. The separately authorized pinned cohort had no retry. Costs exclude acquisition/revalidation and failure paths, and are instructions, not latency or application speedups. The token is not cheaper than the epoch in YJIT; scope is the reason to prefer it. The historical 4.0.7 epoch read was 195/36 Ir, a different build/cohort. Historical write-side CPU timing: 4.0.7 epoch Rails blog boot +0.44% (95% interval −2.62% to +1.86%), 100,000 `define_method` operations +2.81% (+1.16% to +3.61%). The pre-fix master epoch+token definition result, +1.24% (−2.06% to +22.21%), was too noisy for a precise token write-overhead claim and held no subscriptions to the redefined method. The isolated uncontended epoch CAS costs 7 net Ir, not a full mutation or contended atomic. There is no master-token boot-cost result; subscribed revocation, memory use and multicore writes remain unmeasured. These are not write-cost measurements of the lifecycle fix. On the current fix, interpreter and YJIT each pass **13 tests, 97 assertions**, including the formerly failing lifecycle regression, GC stress/compaction, lookup invalidation and permanent disabling after multi-Ractor entry, preserved across fork. Allocation-failure publication ordering was source-reviewed, not fault-injected. Historical master `make test-all`: **38,696 tests, one failure, two errors, 221 skips**. The exact identities reproduced on a clean-master targeted control: `TestThread#test_handle_interrupt_masks_sigint` (missing stdout), and `TestIO#test_seek` / `TestIO#test_seek_symwhence` (ENOENT). This is not a green suite or full clean-master equivalence; full `test-all` was not rerun on the follow-up. Tests also cover deprecated constant removal reentering `Warning.warn`: the epoch changes before the callback, but method tokens do not cover constants. A constant-name token needs a separate design, not blind subscription to the late constant-cache invalidator. - [Prototype branch](https://github.com/thomasklemm/ruby/tree/mutation-epoch-prototype) ([patch](https://github.com/thomasklemm/ruby/compare/cfa50b00939fd07ae87ffceb730bd2cf...)): rejected global experiment, original scoped tokens, then the lifecycle fix; original commit links are preserved. No upstream PR has been opened. - [Evidence, controls and limitations](https://github.com/thomasklemm/lowrail/blob/main/docs/upstream-drafts/ruby-m...). - [Lowrail](https://github.com/thomasklemm/lowrail), the motivating experiment; [earlier epoch investigation](https://github.com/thomasklemm/lowrail/blob/main/docs/passes/vm-epoch.md). The token direction follows [Feature #16614](https://bugs.ruby-lang.org/issues/16614). I also found [Feature #17660](https://bugs.ruby-lang.org/issues/17660), but no supported API with this scoped subscription contract in master at the pinned base (2026-10-21). This is a scoped search finding, not an exhaustive survey. Would exposing this kind of assumption be useful and maintainable? Which ownership, lifetime, concurrency and reentry semantics would core prefer? Would a C-only interface or an existing facility be a better starting point? -- https://bugs.ruby-lang.org/
Issue #22459 has been updated by tenderlovemaking (Aaron Patterson). This sounds like the responsibility of the JIT compiler and not something that we should expose. It looks like you're trying to replace Ruby code with more efficient Ruby code (presumably to get a speedup via the JIT?) We've optimized this particular code pattern [upstream in Rails](https://github.com/rails/rails/pull/59250). It feels like if the general strategy is "replace less optimal Ruby code with more optimal Ruby code" we could just do that upstream in the framework and save everyone some trouble. There are probably cases where we _can't_ do that, and some kind of transpiler might make sense, but I would like it if we found a more concrete use-case. ---------------------------------------- Feature #22459: Scoped method-assumption validity tokens (RubyVM.method_token) https://bugs.ruby-lang.org/issues/22459#change-119615 * Author: thomasklemm (Thomas Klemm) * Status: Open ---------------------------------------- Would MRI consider an API for subscribing to a method-lookup assumption and cheaply checking whether it remains valid? The intended shape is scoped to `(class, method name)`, using existing callable-method-entry (CME) invalidation, not a new global cache key. I would appreciate guidance on whether a C-only API, an opaque Ruby token, or another shape would fit MRI better. Memoization and dependency-injection libraries, sorbet-runtime-style checks, specialization tools and diagnostics sometimes need to detect changed method lookup without preventing ordinary Ruby mutation. Ruby callbacks can be suppressed; freezing holders and their complete ancestor chains is useful where acceptable, but enforces a different contract. Tokens would support unfrozen holders and detection/fallback, not make arbitrary inlining or heap assumptions safe. ## Illustrative API and semantics ```ruby token = RubyVM.method_token(klass, :method_name) # RubyVM::MethodToken or nil token.valid? # boolean ``` ```c VALUE rb_vm_method_token(VALUE klass, ID mid); int rb_vm_method_token_valid_p(VALUE token); ``` The [current prototype](https://github.com/thomasklemm/ruby/commit/a30db2f20843940c089bd8247135a7c7c...) accepts a Class, including an existing singleton Class, resolves/registers the CME even without an existing callsite, and retains and pins the class and entry until token GC. Missing/refined entries return nil; ordinary Modules raise TypeError while enabled. A non-owning registry associates CMEs with tokens; four existing CME/cache invalidation paths also permanently revoke their registered tokens. Revocation may be conservative; renewal requires revalidating assumptions. The C check of a genuine token allocates nothing and invokes no Ruby callback. Acquisition does not call the target or `method_missing`; Ruby name coercion may execute Ruby or raise. Review of the first prototype found a reproducible `Cannot malloc during GC`: `st_insert` can grow even when replacing an existing registry head. The follow-up retains the registry but uses existing-only `st_update` for destruction and existing-list registration, with no allocation after borrowing a collectible head. `valid` is published only after registration succeeds, so failed registration cannot unlink an unpublished node. Compaction/parallel-sweep concerns were checked and not established: `rb_gc_mark` pins the retained references, and the current GC defers non-thread-safe `dfree` during parallel local sweeps. The header is self-contained; experimental lookup-only RDoc was added. This API has no compatibility guarantee; NEWS is deferred pending acceptance. This is **a single-Ractor/GVL experiment**, not a final concurrency contract. C callers require the GVL. The first multi-Ractor transition sets a latch before other Ractors can run, permanently disabling acquisition (nil) and checks (false) in every Ractor, even after termination and fork. Revocation is skipped after disabling, but GC still unlinks registered nodes. The resettable single-Ractor predicate alone would allow fork revalidation. No concurrent-Ractor assumption API is claimed. A check is not a lock across later Ruby execution or thread switches. Consumers must validate the original definition and independently guard the actual receiver class, lexical scope and mutable values, then recheck at reentry boundaries. A new per-instance singleton does not change lookup on the ordinary class. Callees, including `super`, constants/autoload, refinements and Box selection are not certified. Ruby wrappers are ordinary overridable methods; the C interface is distinct. ## Why not a global epoch? I initially prototyped a global mutation counter. It invalidates no caches itself, but consumers suffer the coarse-grained churn that motivated [Feature #18589](https://bugs.ruby-lang.org/issues/18589) and [PR #5766](https://github.com/ruby/ruby/pull/5766). In the earlier production-mode Fizzy census with Rails 8.1.4, after 200 warmup requests, **every one of 1,000 authenticated `/bubbles` requests bumped it 20 times**. The sampled stack was `Module#extend_object -> Kernel#extend -> ActiveRecord::Associations::CollectionProxy#initialize`, at `collection_proxy.rb:37: extend(*extending)`, reached through association reading from the assignees view. Five singleton creations, ten include entry/exit bumps and five method-cache clears accounted for each request. Ignoring singleton holders still left five ordinary superclass-cache bumps. On the original master token build, `/bubbles` and `/buckets` each had **zero invalidations of 12 held tokens**: `Hash#each`, Enumerable-owned `Hash#each_with_object` and ten view/builder methods. `/bubbles` still had 20 epoch bumps/request. The blog's two endpoints and Fizzy `/buckets` had zero epoch churn. These were selected dependencies, partly acquired through a separately constructed view context, not complete endpoint execution certificates or a universal stability claim. The global epoch remains a rejected alternative, not the proposed API. ## Costs, tests and patch The new read cohort measures Ruby at [`a30db2f`](https://github.com/thomasklemm/ruby/commit/a30db2f20843940c089bd8247135a7c7c...), on master base [`cfa50b0`](https://github.com/ruby/ruby/commit/cfa50b00939fd07ae87ffceb730bd2cfbccd8553), with a clean detached Lowrail harness at [`0f83cd0`](https://github.com/thomasklemm/lowrail/commit/0f83cd0eddab1997c9dda6e54c6e99...), recorded in every worker. GCC 12.2, `-O3 -g`, x86-64 Linux, patched Valgrind 3.19; one finite six-round pair, seed 41231, N=0/100,000, at most two workers. Both modes passed Ruby/native A/A (95% intervals [0, 0]%), fixed-heap and actual-JIT gates; every YJIT window was compilation/invalidation-flat. No window collected; positive windows allocated zero objects. Net Ir/check: | Operation | Interpreter | YJIT | |---|---:|---:| | Ruby token read / successful guarded branch | 184 / 202 | 103 / 106 | | Direct C token validity check | 26 | 26 | | Global epoch read / successful guarded branch | 194 / 265 | 75 / 91 | Earlier figures on the pre-fix prototype are superseded and retained in the [pinned evidence](https://github.com/thomasklemm/lowrail/blob/acfef7152895dbdb70caafbbf6823bf2...). An intervening mixed-harness cohort is INVALID for protocol drift; none of its prices is claimed. The separately authorized pinned cohort had no retry. Costs exclude acquisition/revalidation and failure paths, and are instructions, not latency or application speedups. The token is not cheaper than the epoch in YJIT; scope is the reason to prefer it. The historical 4.0.7 epoch read was 195/36 Ir, a different build/cohort. Historical write-side CPU timing: 4.0.7 epoch Rails blog boot +0.44% (95% interval −2.62% to +1.86%), 100,000 `define_method` operations +2.81% (+1.16% to +3.61%). The pre-fix master epoch+token definition result, +1.24% (−2.06% to +22.21%), was too noisy for a precise token write-overhead claim and held no subscriptions to the redefined method. The isolated uncontended epoch CAS costs 7 net Ir, not a full mutation or contended atomic. There is no master-token boot-cost result; subscribed revocation, memory use and multicore writes remain unmeasured. These are not write-cost measurements of the lifecycle fix. On the current fix, interpreter and YJIT each pass **13 tests, 97 assertions**, including the formerly failing lifecycle regression, GC stress/compaction, lookup invalidation and permanent disabling after multi-Ractor entry, preserved across fork. Allocation-failure publication ordering was source-reviewed, not fault-injected. Historical master `make test-all`: **38,696 tests, one failure, two errors, 221 skips**. The exact identities reproduced on a clean-master targeted control: `TestThread#test_handle_interrupt_masks_sigint` (missing stdout), and `TestIO#test_seek` / `TestIO#test_seek_symwhence` (ENOENT). This is not a green suite or full clean-master equivalence; full `test-all` was not rerun on the follow-up. Tests also cover deprecated constant removal reentering `Warning.warn`: the epoch changes before the callback, but method tokens do not cover constants. A constant-name token needs a separate design, not blind subscription to the late constant-cache invalidator. - [Prototype branch](https://github.com/thomasklemm/ruby/tree/mutation-epoch-prototype) ([patch](https://github.com/thomasklemm/ruby/compare/cfa50b00939fd07ae87ffceb730bd2cf...)): rejected global experiment, original scoped tokens, then the lifecycle fix; original commit links are preserved. No upstream PR has been opened. - [Evidence, controls and limitations](https://github.com/thomasklemm/lowrail/blob/main/docs/upstream-drafts/ruby-m...). - [Lowrail](https://github.com/thomasklemm/lowrail), the motivating experiment; [earlier epoch investigation](https://github.com/thomasklemm/lowrail/blob/main/docs/passes/vm-epoch.md). The token direction follows [Feature #16614](https://bugs.ruby-lang.org/issues/16614). I also found [Feature #17660](https://bugs.ruby-lang.org/issues/17660), but no supported API with this scoped subscription contract in master at the pinned base (2026-10-21). This is a scoped search finding, not an exhaustive survey. Would exposing this kind of assumption be useful and maintainable? Which ownership, lifetime, concurrency and reentry semantics would core prefer? Would a C-only interface or an existing facility be a better starting point? -- https://bugs.ruby-lang.org/
participants (4)
-
alanwu (Alan Wu) -
mame (Yusuke Endoh) -
tenderlovemaking (Aaron Patterson) -
thomasklemm (Thomas Klemm)