Feature #22459
openScoped method-assumption validity tokens (RubyVM.method_token)
Description
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¶
token = RubyVM.method_token(klass, :method_name) # RubyVM::MethodToken or nil
token.valid? # boolean
The current prototype
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 and
PR #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,
on master base
cfa50b0,
with a clean detached Lowrail harness at
0f83cd0,
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.
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
(patch):
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.
- Lowrail, the motivating experiment;
earlier epoch investigation.
The token direction follows Feature #16614.
I also found Feature #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?
Updated by thomasklemm (Thomas Klemm) 1 day ago
- Description updated (diff)
Description updated: review found a reproducible Cannot malloc during GC in the token registry lifecycle; a30db2f 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 preserves historical figures and the INVALID mixed-harness attempt; existing full-suite, write-timing and scope caveats remain.
Updated by mame (Yusuke Endoh) 1 day ago
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.
Updated by thomasklemm (Thomas Klemm) 1 day ago
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 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¶
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:
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:
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). 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?
Updated by alanwu (Alan Wu) 1 day ago
· Edited
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 with adding on additional semantics or tweaking some semantics. You can do that by forking the code, as you've done, but I don't see why we need to ship your experiment to everyone and take on the maintenance burden.
Updated by tenderlovemaking (Aaron Patterson) 1 day ago
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. 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.