From: "ko1 (Koichi Sasada) via ruby-core" Date: 2026-08-10T07:23:40+00:00 Subject: [ruby-core:126358] [Ruby Feature#22226] Ractor: class/module ownership -- restrict modification to the Ractor that created it Issue #22226 has been updated by ko1 (Koichi Sasada). Thank you for the acceptance. Every result below was measured on three builds: current master, the branch as you accepted it, and the branch now. Each example says which is which. ## freeze and set_temporary_name Both are owner-only now, as you asked. `Module#freeze` only checks when it would actually change something, so freezing an already frozen class stays a no-op for every Ractor and idempotent `.freeze` calls keep working. ## Class variables I went back to this rather than ship the state you were not comfortable with. They follow ownership now, so a Ractor has full use of the class variables of the classes it created. What I could not define when I wrote section 6 was *which* owner decides, because a class variable is shared across the inheritance chain and the class it is stored in can migrate (`cvar_overtaken`). The answer is the owner of the class the variable is **stored in** at the time of the access, not the owner of the receiver it was looked up through. That is a single well-defined owner at any moment; when the variable migrates to an ancestor, the check follows it there. It is also the same single writer the rest of that class's fields already have, so nothing new is exposed. ```ruby Ractor.new do k = Class.new k.class_variable_set(:@@count, 'not shareable') k.class_variable_get(:@@count) end.value # master : can not set class variables from non-main Ractors (@@count from ...) # accepted proposal : can not set class variables from non-main Ractors (@@count from ...) # now : "not shareable" <- changed ``` Reading someone else's unshareable class variable is still refused everywhere; only the wording moved from "you are not main" to "you are not the owner": ```ruby class A; @@cv = 'str'; end Ractor.new { A.class_variable_get(:@@cv) }.value # master : can not read non-shareable class variable @@cv from non-main Ractors (A) # accepted proposal : can not read non-shareable class variable @@cv from non-main Ractors (A) # now : can not read non-shareable class variable @@cv of A, which was created by another Ractor ``` ### The inheritance chain Owning the receiver is not enough, because the variable is not stored there. Note that the *outcome* here is the same in all three; what the new rule changes is the reason and the class the message names: ```ruby class Base # created by the main Ractor @@cv = 1 end Ractor.new do sub = Class.new(Base) # created by this Ractor, so this Ractor owns sub sub.class_variable_get(:@@cv) # master / proposal / now: 1 sub.class_variable_set(:@@cv, 2) end.join # master : can not set class variables from non-main Ractors (@@cv from #) # accepted proposal : can not set class variables from non-main Ractors (@@cv from #) # now : can not set class variable @@cv of Base, which was created by another Ractor ``` `@@cv` is stored in `Base`, so `Base` is what the rule looks at, and the message names `Base` rather than the receiver. Reading is allowed here only because the value is shareable; had `Base` stored an unshareable value, the read would have been refused naming `Base` too. Where a *new* variable gets stored is ordinary Ruby class variable semantics, and the rule simply follows it. With nothing to overtake in the chain, it is stored in the receiver, which this Ractor owns: ```ruby class Plain; end # created by the main Ractor, no class variable Ractor.new do sub = Class.new(Plain) sub.class_variable_set(:@@new, 'mine') sub.class_variable_get(:@@new) end.value # master : can not set class variables from non-main Ractors (@@new from #) # accepted proposal : can not set class variables from non-main Ractors (@@new from #) # now : "mine" <- changed Plain.class_variables # master / proposal / now: [] (it did not leak into the ancestor) ``` So a Ractor cannot reach a main-owned class variable by subclassing, and it does not lose the use of its own subclass either. I find this easier to explain than the previous rule, which refused both. ## A bug in master: class variables can be deleted but not written `Module#remove_class_variable` reaches the field through `rb_ivar_delete` instead of `rb_cvar_set`, so it never met the main-Ractor check, and the generic instance variable guard does not fire either because it only looks at `@`-names. A non-main Ractor therefore cannot *set* a class variable but can *delete* one, from any class: ```ruby class E; @@cv = 1; end Ractor.new { E.class_variable_set(:@@cv, 9) }.value # master : can not set class variables from non-main Ractors (@@cv from E) # accepted proposal : can not set class variables from non-main Ractors (@@cv from E) # now : can not set class variable @@cv of E, which was created by another Ractor Ractor.new { E.send(:remove_class_variable, :@@cv) }.value # master : 1 <- the variable is gone # accepted proposal : 1 <- the variable is gone # now : can not set class variable @@cv of E, which was created by another Ractor E.class_variables # master : [] # accepted proposal : [] # now : [:@@cv] <- changed ``` The accepted proposal did not close this, because it left class variables alone; bringing them under ownership closes it. There was a second one in the same area. The class variable inline caches keyed their fast path on "am I the main Ractor?", and on a hit the write goes straight into the cached class without passing through `rb_cvar_set`. The main Ractor running a method of a class created by another Ractor therefore skipped the ownership check entirely. The fast path is keyed on the owner of the cached class now, which also lets a non-main owner use a fast path it could not reach before. ## A regression the accepted proposal introduced, now fixed A singleton class belongs to the owner of the object it is attached to, and `send(move: true)` hands that object over -- but the singleton class kept its old owner, so the receiver could not go on defining singleton methods on an object it now exclusively holds. Worse, whether it broke depended on something invisible: if the singleton class had not been materialized before the move, the receiver created it, owned it, and everything worked. ```ruby o = Object.new def o.foo = :from_main # materialize the singleton class in the main Ractor r = Ractor.new do x = Ractor.receive def x.bar = :from_ractor [x.foo, x.bar] end r.send(o, move: true) r.value # master : [:from_main, :from_ractor] # accepted proposal : can not modify # because it is created by another Ractor <- regression # now : [:from_main, :from_ractor] ``` The ownership moves with the object now, in both directions. This is safe for the same reason the move is: the sender cannot reach the object any more, and its singleton class is reachable only through it. To be precise about OpenStruct, which is the realistic case here: this fixes the ownership half only. OpenStruct builds its accessors with `define_singleton_method`, and a method defined from a Proc still cannot be *called* from another Ractor ("defined with an un-shareable Proc in a different Ractor") -- true on master today and unchanged here. So a moved OpenStruct stays unusable, for that older reason rather than for ownership. An object whose singleton methods come from `def obj.foo` moves and keeps working. One more correction to the implementation rather than to the specification: visibility changes were listed as owner-only in section 7 from the start, but `set_method_visibility()` only checked for a frozen class, so `public`/`private`/`protected`/`module_function` were in fact still reaching a foreign class's method table on the accepted branch. The ownership check is there now. ## What this means, compared to today's master For a Ractor acting on a class it did not create: | operation | master | with this PR | |---|---|---| | `def`, `define_method` | works | `Ractor::IsolationError` | | `alias_method` | works | `Ractor::IsolationError` | | `remove_method`, `undef_method` | works | `Ractor::IsolationError` | | `public`, `private`, `protected` | works | `Ractor::IsolationError` | | `private_class_method` | works | `Ractor::IsolationError` | | `module_function :name` | works | `Ractor::IsolationError` | | `include`, `prepend` | works | `Ractor::IsolationError` | | `refine` | works | `Ractor::IsolationError` | | `const_set` with a shareable value | works | `Ractor::IsolationError` | | `remove_const` | works | `Ractor::IsolationError` | | `autoload` | works | `Ractor::IsolationError` | | `remove_class_variable` | works | `Ractor::IsolationError` | | `Module#freeze` | works | `Ractor::IsolationError` | | `Module#set_temporary_name` | works | `Ractor::IsolationError` | | `dup`/`clone` of a class holding unshareable values | works | `Ractor::IsolationError` | For a Ractor acting on a class it created itself, the direction is the other way: | operation | master | with this PR | |---|---|---| | `instance_variable_set` with an unshareable value | `Ractor::IsolationError` | works | | `const_set` with an unshareable value | `Ractor::IsolationError` | works | | `class_variable_set` / `class_variable_get` | `Ractor::IsolationError` | works | Unchanged in both directions: calling methods, instantiating, subclassing, reading shareable constants, instance variables and class variables, `extend` on your own object, `freeze` on an already frozen class, bare `module_function`, `dup`/`clone` of a class whose contents are all shareable, and the refusal to read an unshareable constant, instance variable or class variable belonging to another Ractor's class. Practically: `Class#inherited` hooks that mutate the superclass (registry patterns) fail when the subclass is created by a different Ractor, and lazy definition (`const_missing`/`method_missing` + define) fails on first touch from a non-owner Ractor. Eager loading before spawning Ractors avoids both. ## Deferred Top-level class and module names in non-main Ractors, and `Ruby::Box`: agreed, let us settle that with the `Ruby::Box` author separately. The implementation is in https://github.com/ruby/ruby/pull/17913 . `make btest`, `make test-all` and `make test-spec` pass, on a release build and on a `RUBY_DEBUG=1` build. ---------------------------------------- Feature #22226: Ractor: class/module ownership -- restrict modification to the Ractor that created it https://bugs.ruby-lang.org/issues/22226#change-118484 * Author: ko1 (Koichi Sasada) * Status: Open * Assignee: ko1 (Koichi Sasada) * Target version: 4.1 ---------------------------------------- ## Abstract Every class/module records the Ractor that created it as its *owner*. Only the owner Ractor can modify it. Reading is unchanged and allowed from any Ractor. This replaces several ad-hoc "main Ractor only" rules with a single rule keyed on the creator. It **relaxes** the rules for classes a Ractor creates itself, and **tightens** them for classes created by somebody else. No Ruby-level API is added: ownership is recorded internally and is not exposed. --- ## 1. Motivation ### 1.1 The current rules ask the wrong question Today's restrictions ask *"are you the main Ractor?"*. They never ask *"did you create this class?"*. The result is inconsistent in both directions. A non-main Ractor may freely redefine **any** class in the process: ```ruby class C; end Ractor.new { C.define_method(:m) { 1 }; :done }.value #=> :done (works today) Ractor.new { class String; def foo = 1; end; :done }.value #=> :done (works today) Ractor.new { def f = 42; f() }.value #=> 42 (works today; defines Object#f) Ractor.new { Object.send(:remove_const, :FOO); :done }.value #=> :done (works today) ``` ...but it may **not** set an instance variable on a class it created itself: ```ruby Ractor.new { k = Class.new # nobody else can even name this class yet k.instance_variable_set(:@iv, 1) }.value #=> Ractor::IsolationError: # can not set instance variables of classes/modules by non-main Ractors ``` ...nor give it an unshareable constant: ```ruby Ractor.new { k = Class.new k.const_set(:X, "str".dup) }.value #=> Ractor::IsolationError: # can not set constants with non-shareable objects by non-main Ractors ``` The first group is unrestricted although it affects the whole process. The second group is forbidden although it affects nothing but the Ractor doing it. The rules are exactly inverted with respect to who is actually affected. ### 1.2 A Ractor's classes can be redefined under it by another Ractor Because any Ractor may modify any class, the code a Ractor runs can be changed by somebody else at any moment: ```ruby Ractor.new { class String; def upcase = :patched; end; :done }.value "abc".upcase #=> :patched # evaluated in the *main* Ractor ``` ```ruby FOO = 1 Ractor.new { Object.send(:remove_const, :FOO); :removed }.value FOO #=> NameError: uninitialized constant FOO ``` Both of these run fine today. There is no way for a Ractor to rely on the classes it uses. ### 1.3 Class internals have no single writer `m_tbl`, `const_tbl` and the class fields can be written by any Ractor today. This means the implementation cannot assume a single writer for any class, which stands in the way of synchronization and caching work (and it is also how the `cvar_overtaken()` bug below arose: a *read* path physically deletes a table entry, potentially of another Ractor's class). --- ## 2. Proposal A class/module records its creator Ractor as its **owner**. Only the owner may: * define/remove/undef methods, `alias`, change method visibility * `include`/`prepend` into it, and `Module#refine` targeting it * define/remove constants, register `autoload` * set instance variables on the class/module object Non-owner Ractors get `Ractor::IsolationError`. **Reading is completely unchanged**: method calls, instantiation, subclassing, constant reads and instance-variable reads all work from any Ractor as before. In exchange, the previous "main Ractor only" rules for class instance variables and unshareable constant values become "**owner Ractor only**". A Ractor gets full use of the classes it creates. Since all classes/modules created at boot or by main-Ractor code (including everything loaded by `require`) are owned by the main Ractor, this is equivalent to today's rules for programs that do not create classes inside non-main Ractors. --- ## 3. Examples ### 3.1 Newly allowed: a Ractor can fully use the classes it creates Instance variables on a class the Ractor created: ```ruby Ractor.new { k = Class.new k.instance_variable_set(:@iv, 1) # was: IsolationError, even for a shareable value! k.instance_variable_set(:@iv, "str".dup) # was: IsolationError k.instance_variable_get(:@iv) #=> "str" }.value ``` **Unshareable values in constants** of a class the Ractor created. This was the main Ractor's privilege before; now every Ractor has it for its own classes: ```ruby Ractor.new { k = Class.new k::BUF = "buffer".dup # unshareable value; was: IsolationError k::BUF << "!" # ...and the owner can freely use it k::BUF #=> "buffer!" }.value Ractor.new { k = Class.new k.const_set(:LIST, [1, 2, 3]) # an unfrozen Array is unshareable too k::LIST << 4 k::LIST #=> [1, 2, 3, 4] }.value ``` The value stays private to the owner -- reading it from another Ractor raises, which is exactly the rule that applied to the main Ractor before: ```ruby port = Ractor::Port.new r = Ractor.new(port) { |pt| k = Class.new; k::BUF = "buffer".dup; pt << k; Ractor.receive } k = port.receive k::BUF #=> Ractor::IsolationError: can not access non-shareable objects in constant # #::BUF of a class/module created by another Ractor. ``` A *shareable* value in the same position is readable from anywhere, as before: ```ruby r = Ractor.new(port) { |pt| k = Class.new; k::N = 42; pt << k; Ractor.receive } k = port.receive k::N #=> 42 ``` Classes defined under a module the Ractor created itself work fully: ```ruby Ractor.new { mod = Module.new mod.const_set(:Foo, Class.new { def m = :ok }) mod::Foo.new.m #=> :ok }.value ``` ### 3.2 Newly prohibited: modifying a class created by another Ractor ```ruby class C; end module M; end Ractor.new { def f = 42 }.value #=> can not modify Object because it is created by another Ractor # (top-level `def` defines a private method on Object) Ractor.new { class String; def foo = 1; end }.value #=> can not modify String because it is created by another Ractor Ractor.new { C.define_method(:m) { 1 } }.value Ractor.new { C.send(:alias_method, :a, :inspect) }.value Ractor.new { C.send(:private, :inspect) }.value Ractor.new { C.send(:undef_method, :inspect) }.value Ractor.new { C.include(M) }.value Ractor.new { C.prepend(M) }.value Ractor.new { Module.new { refine(C) { def z = 1 } } }.value Ractor.new { C.autoload(:Zz, "zz") }.value #=> can not modify C because it is created by another Ractor Ractor.new { C.const_set(:X, 1) }.value # note: even a *shareable* value now raises #=> can not set constants of classes/modules created by another Ractor Ractor.new { class TopCls; end }.value # a top-level class name... Ractor.new { module TopMod; end }.value # ...and a top-level module name #=> can not set constants of classes/modules created by another Ractor # (both write a constant into Object) Ractor.new { Object.send(:remove_const, :FOO) }.value #=> can not modify Object because it is created by another Ractor Ractor.new { C.instance_variable_set(:@iv, 1) }.value #=> can not set instance variables of classes/modules created by another Ractor ``` Patterns that stop working: ```ruby # a registry in Class#inherited that writes into the superclass Base = Class.new { def self.inherited(sub) = const_set(:"Sub#{sub.object_id}", sub) } Ractor.new { Class.new(Base) }.value #=> can not set constants of classes/modules created by another Ractor # lazy definition on first touch M2 = Module.new { def self.const_missing(n) = const_set(n, Class.new) } Ractor.new { M2::Foo }.value #=> can not set constants of classes/modules created by another Ractor ``` Both work if the definition happens before the Ractor is spawned (eager loading). ### 3.3 Unchanged ```ruby Ractor.new { "abc".upcase }.value #=> "ABC" Ractor.new { C.new }.value # instantiation Ractor.new { Class.new(String) { def m = :ok }.new.m }.value #=> :ok (subclassing is creation) Ractor.new { require "time"; Time.now.respond_to?(:xmlschema) }.value #=> true ``` `require` from a non-main Ractor keeps working because `Ractor#require` performs the load on the main Ractor, so the library's classes are defined by (and owned by) the main Ractor. `def` on an object a Ractor created is also unaffected -- an ordinary object's singleton class is created by, and owned by, the Ractor that triggers it: ```ruby Ractor.new { o = Object.new; def o.f = :ok; o.f }.value #=> :ok ``` ### 3.4 Summary table | operation from a non-main Ractor | today | proposal | |---|---|---| | define a method on a foreign class | OK | **IsolationError** | | top-level `def` | OK | **IsolationError** | | `include`/`prepend`/`refine` a foreign class | OK | **IsolationError** | | set a **shareable** constant on a foreign class | OK | **IsolationError** | | set an **unshareable** constant on a foreign class | IsolationError | IsolationError | | `remove_const` / `autoload` on a foreign class | OK | **IsolationError** | | set an ivar on a foreign class | IsolationError | IsolationError | | define a method on its **own** class | OK | OK | | set a **shareable** constant on its **own** class | OK | OK | | set an **unshareable** constant on its **own** class | IsolationError | **OK** | | set an ivar on its **own** class | IsolationError | **OK** | | read / call / instantiate / subclass any class | OK | OK | --- ## 4. How the proposal answers the motivation * **1.1 (rules ask the wrong question)** -- the rules now ask "did you create this?" instead of "are you main?". Every restriction is on the class of somebody else, and every relaxation is on your own class. The two inverted cases in 1.1 both flip to the right side. The old "main Ractor only" rules become the special case *"boot-time classes are owned by the main Ractor"*, so they are no longer separate rules. * **1.2 (classes changed under you)** -- no Ractor can modify a class it did not create, so the classes a Ractor uses cannot be redefined by another Ractor. * **1.3 (no single writer)** -- every class/module now has exactly one Ractor which can write its method table, constant table and fields. --- ## 5. What this does **not** give Ownership bounds *who may write*, not *what readers may see*. Reads stay unsynchronized and a class modification is not atomic, so a non-owner Ractor can still observe a class in the middle of being modified by its owner: after the first `def` of a `class ... end` body but before the second, or while `include`/`prepend` is rewiring the ancestor chain. The guarantee is **a single writer per class/module**, not a consistent view for readers. This proposal is not, and does not claim to be, full isolation of class state. --- ## 6. Details * **Singleton classes / metaclasses** are owned by the owner of the object they are attached to, not by the Ractor that happened to trigger their lazy creation. `def C.foo` is allowed exactly for the owner of `C`: ```ruby class C; end Ractor.new { C.singleton_class; :touched }.value # another Ractor touches it first class << C; def foo = :ok; end # still fine in main C.foo #=> :ok Ractor.new { class << C; def bar = 1; end }.value #=> IsolationError ``` * **Terminated owner**: a class whose owner Ractor has finished becomes permanently read-only for everybody, including the main Ractor. ```ruby K = Ractor.new { Class.new { def x = :made_in_ractor } }.value K.new.x #=> :made_in_ractor (reading is fine) K.define_method(:y) { 1 } #=> IsolationError (nobody owns it any more) ``` There is intentionally no API to look up or transfer ownership. * **Class variables** are *not* covered by the relaxation. They are shared across the whole inheritance chain and the class they are physically stored in can migrate over time (`cvar_overtaken`), so no single owner can be defined for them. Writes still require the main Ractor, and additionally must not cross the ownership boundary (the class actually written into must be owned), so class fields keep a single writer. * **Copying a foreign class**: `Class#dup`/`clone` of a class created by another Ractor produces a copy owned by the copying Ractor. It raises if the source's constants or instance variables refer to unshareable objects (they would leak across Ractors); copying classes holding only shareable values works. This gives a mutation-free alternative to monkey-patching: ```ruby S = Class.new; S.const_set(:OK, 1) Ractor.new { k = S.dup; k.define_method(:m) { 1 }; k.new.m }.value #=> 1 ``` * **Top-level class and module names**: `class Foo; end` / `module Foo; end` at the top level of a non-main Ractor writes a constant into `Object`, so both are prohibited. Define them under a module the Ractor creates itself instead (`mod = Module.new; mod.const_set(:Foo, Class.new)`). Integration with `Ruby::Box` is future work. * **Bug fix included**: `cvar_overtaken()` physically deleted a duplicated cvar entry from the `front` class, and that path is reachable from *read* operations (`rb_cvar_find`) -- i.e. a cross-Ractor write triggered by a read. The clean-up is now skipped unless the current Ractor owns `front`. --- ## 7. Incompatibility Only code that modifies classes from a non-main Ractor is affected. Concretely, the following raise `Ractor::IsolationError` where they used to work: * method definition / `alias` / visibility change on a foreign class, including top-level `def` * `include`/`prepend` into a foreign class, `refine` targeting one * setting a **shareable** constant on a foreign class, `remove_const`, `autoload` registration * `Class#inherited` and `const_missing`/`method_missing` hooks which mutate a foreign class Migration is usually "define it before spawning the Ractor" (eager loading), or "create the class inside the Ractor that uses it", or "`dup` the foreign class and modify the copy". Programs which never define classes inside non-main Ractors are unaffected. --- ## 8. Open questions / future work * `freeze` of a foreign class is still allowed. It is a behavior-changing write and should probably be owner-only too. * `Module#set_temporary_name` is not checked yet. * Singleton class ownership is not transferred by `Ractor#send(move: true)`. * Integration with `Ruby::Box`, so that non-main Ractors can define top-level class/module names. * Should the error message for a class owned by the main Ractor say "owned by the main Ractor" rather than "created by another Ractor"? The current wording is confusing for top-level `def`, where the user never mentioned `Object`. --- ## 9. Implementation https://github.com/ruby/ruby/pull/17913 The owner is stored in `rb_classext_t::owner_ractor` as the Ractor object; `0` means the main Ractor, so single-Ractor programs get no additional GC edges. It is marked from the classext and updated on compaction, so the comparison never sees a dangling or reused reference. Method-table checks funnel through `rb_class_modify_check()`; constant/ivar/cvar checks replace the previous `rb_ractor_main_p()` checks in `variable.c`. The class-ivar fast paths in `vm_insnhelper.c` switch from "main Ractor" to "owner Ractor", which keeps them valid because the owner is the only writer and its threads are serialized by the per-Ractor lock. `make btest` (2053 tests), `make test-all` (35797 tests, 0 failures / 0 errors) and `make test-spec` (32628 examples, 0 failures / 0 errors / 0 tagged) all pass. Three tests which relied on cross-Ractor method definition were rewritten so that the owner Ractor performs the definition. ## Notes * The description above was written by Claude Code based on my explanation. * The primary motivation is to eliminate the special cases for the main Ractor. The concept of an "owner Ractor" for classes and modules provides a general solution to such cases. * Another possible approach would be to prohibit class and module mutations from non-main Ractors, since most classes and modules are defined by the main Ractor during program loading. However, this would also prohibit `Class.new` in non-main Ractors, even though dynamically creating classes is a common programming pattern in Ruby. -- https://bugs.ruby-lang.org/ ______________________________________________ ruby-core mailing list -- ruby-core@ml.ruby-lang.org To unsubscribe send an email to ruby-core-leave@ml.ruby-lang.org ruby-core info -- https://ml.ruby-lang.org/mailman3/lists/ruby-core.ml.ruby-lang.org/