Add per-class ractor_safe! opt-in for Redefiner's method codegen - #51
Open
maedi wants to merge 3 commits into
Open
Add per-class ractor_safe! opt-in for Redefiner's method codegen#51maedi wants to merge 3 commits into
maedi wants to merge 3 commits into
Conversation
Redefiner.typed_methods/untyped_methods wrap every method via
define_method blocks closing over method_proxy. Confirmed empirically
that this makes the resulting method uncallable from any Ractor other
than the one that defined it -- Ruby refuses to invoke a Proc-backed
method cross-Ractor, even with zero closure captures and even when
everything captured is frozen:
RuntimeError: defined with an un-shareable Proc in a different Ractor
A method compiled from a source string via class_eval has no such
restriction -- it's a real method, not Proc-backed -- but a string
can't close over a local variable, so it re-derives method_proxy from
Lowkey's registry on every call instead (Lowkey[file_path][namespace]
[__method__], the same lookup untyped_methods used to do per-call
before that was removed as a redundant allocation).
`ractor_safe!` in a class body opts that one class into the
class_eval-string codegen path; every other class keeps the fast
define_method path unchanged. Most classes never run inside a worker
Ractor and shouldn't pay for a per-call registry lookup they don't
need -- this makes Ractor-compatibility something you ask for per
class, not a global tradeoff everyone pays.
Only actually callable from a worker Ractor once Lowkey.make_shareable!
has been called (low-rb/lowkey#12) and class_proxy.class_binding has
been cleared (#50) -- a live Binding can never be made
Ractor-shareable, even frozen.
Testing:
- Full suite: 145 examples, 0 failures (up from 141 -- existing
define_method path completely unchanged, verified before writing any
of this).
- New fixtures (spec/fixtures/ractor_safe_{typed,untyped}.rb) +
spec/features/ractor_safety_spec.rb: confirms validation behavior is
identical to the non-ractor-safe path, and separately spawns a real
Ractor (in a subprocess, so the shared suite process's Lowkey.keys is
never touched) proving the generated method is actually callable from
it, for both the typed and untyped codegen branches.
- Rubocop clean bar the same pre-existing, unrelated Metrics/AbcSize
offense on .included already present on main.
Third and last of three PRs from a look at what it'd take to make
Raindeer's stack Ractor-safe (see #49 and #50 for the other two, and
low-rb/lowkey#12).
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Redefiner.typed_methods/untyped_methodswrap every method viadefine_methodblocks closing overmethod_proxy. I confirmed empiricallythat this makes the resulting method uncallable from any Ractor other than
the one that defined it, regardless of whether the closure captures
anything, and regardless of whether captured state is frozen:
A method compiled from a source string via
class_evaldoesn't have thisrestriction -- it's a real, ISeq-backed method, not a Proc:
Fix
ractor_safe!in a class body (extended via the newLow::RactorSafetymixin) opts that one class into the
class_eval-string codegen path(
Redefiner.define_ractor_safe_typed_method/_untyped_method). Every otherclass keeps the existing
define_methodpath completely unchanged. Theractor-safe path re-derives
method_proxyfromLowkey's registry on everycall (
Lowkey[file_path][namespace][__method__]) instead of closing over it-- the exact per-call lookup that was removed as a redundant allocation in
untyped_methodsa while back. That's the real tradeoff: Ractor-callabilitycosts back some of that allocation win, but only for classes that actually
opt in, not globally.
Dependencies
This only actually works end-to-end once:
Lowkey.make_shareable!exists, and (as of itslatest commit) warms
MethodProxy#params_with_expressions's lazy cachebefore freezing.
class_proxy.class_bindingis cleared once a class finishesloading. A live
Bindingcan never be made Ractor-shareable, even frozen,and blocks
Ractor.make_shareable(Lowkey.keys)outright otherwise.This PR's own spec simulates #50 inline (clears
class_bindingmanually ina throwaway subprocess) so its tests pass standalone regardless of merge
order, but real usage needs all three.
Testing
pre-existing 141 first, confirmed 0 regressions, before writing anything
ractor-safe-specific.
spec/fixtures/ractor_safe_{typed,untyped}.rb) +spec/features/ractor_safety_spec.rb:(a bad arg still raises
Low::ArgumentTypeError; type-checking-disabledstill passes through unvalidated) --
ractor_safe!changes codegenmechanism only, not behavior.
process's
Lowkey.keysregistry is never touched -- freezing itin-process would break any later spec expecting to load a new fixture)
and confirms the generated method is actually callable from it, for both
the typed and untyped codegen branches.
Metrics/AbcSizeoffense on
.includedalready onmain. Addedrubocop:disable Style/EvalWithLocationaround the twoclass_evalcalls -- deliberatelyattributing generated methods to the app's own file/line, not
redefiner.rb's__FILE__, so a validation error's backtrace points atthe user's actual method definition.
Third and last of three PRs from a look at what it'd take to make
Raindeer's stack Ractor-safe (see #49 and #50 for the other two, and
low-rb/lowkey#12).
🤖 Generated with Claude Code