From: "Eregon (Benoit Daloze) via ruby-core" Date: 2026-11-10T16:32:45+00:00 Subject: [ruby-core:127408] [Ruby Bug#22521] `Ractor.shareable_proc` allows outer variables reassigned by loops, and silently keeps a stale value Issue #22521 has been updated by Eregon (Benoit Daloze). I found a related case, which I added to https://github.com/ruby/ruby/pull/19802 as a second commit since it is the same analysis (and both changes touch the same code). A variable assigned only once in the source is considered never reassigned, even when the block is created *before* that assignment, while the variable is still `nil`: ```ruby klass = Class.new do PR = Ractor.shareable_proc { klass } end p PR.call # expected: Ractor::IsolationError when creating PR, as klass is assigned after pr = Ractor.shareable_proc { pr } p pr.call # expected: Ractor::IsolationError ``` ``` $ ruby -v rba.rb ruby 4.1.0dev (2026-11-09T15:47:20Z master 7e5d882817) +PRISM [arm64-darwin25] nil nil ``` Same result on 4.0.7. The shareable Proc silently ignores the assignment of the variable, which is what #21557 wants to prevent ("Reassigning a captured variable outside the block"). ## Fix When a block reads an outer variable which has no assignment compiled yet, the compiler now marks the variable. A later assignment then makes it reassigned. If the variable is never assigned, it is always `nil` and can still be captured. This relies on the compilation order following the evaluation order. The exceptions only make it more conservative, e.g. a `rescue` clause is compiled before the `begin` body. With `compile.c` (`--parser=parse.y`), the default values of optional parameters are compiled before `iseq_set_parameters_lvar_state()`, which then overwrote the state of optional parameters. The fix leaves them as computed by compiling their default values, as with prism. This makes `def m(a = Ractor.shareable_proc { a })` raise with both parsers. A known remaining case is multiple assignment (`a, b = 1, Ractor.shareable_proc { a }`): both compilers compile its assignments before its right-hand side, so this is not detected. This also matters for https://github.com/ruby/ruby/pull/19801 (making the body of `define_method` shareable when possible). With that change, this code from `ActionView::Base.with_empty_template_cache` got `nil` from `compiled_method_container`: ```ruby subclass = Class.new(self) { define_method(:compiled_method_container) { subclass } define_singleton_method(:compiled_method_container) { subclass } } ``` With the fix, such a body is not made shareable and the method works as before. ---------------------------------------- Bug #22521: `Ractor.shareable_proc` allows outer variables reassigned by loops, and silently keeps a stale value https://bugs.ruby-lang.org/issues/22521#change-119852 * Author: Eregon (Benoit Daloze) * Status: Open * Target version: 4.1 * ruby -v: ruby 4.1.0dev (2026-11-09T15:47:20Z master 7e5d882817) +PRISM [arm64-darwin25] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- `Ractor.shareable_proc` must reject blocks reading an outer variable which may be reassigned, because the shareable Proc copies the variable's value ([Feature #21557]). The "Loop Cases" section of #21557 lists cases which should be `Ractor::IsolationError` but are currently allowed, because the variable is assigned only once in the source: ```ruby # for loop: one variable, reassigned on each iteration procs = [] for x in [1, 2] procs << Ractor.shareable_proc { x } end p procs.map(&:call) # Ruby block semantics: [2, 2] # while loop procs = [] i = 0 while i < 2 y = i procs << Ractor.shareable_proc { y } i += 1 end p procs.map(&:call) # [1, 1] # begin/rescue/retry procs = [] i = 0 begin z = i procs << Ractor.shareable_proc { z } raise if (i += 1) < 2 rescue retry end p procs.map(&:call) # [1, 1] # redo in a block procs = [] i = 0 [1].each do w = i procs << Ractor.shareable_proc { w } redo if (i += 1) < 2 end p procs.map(&:call) # [1, 1] ``` ``` $ ruby -v loops.rb ruby 4.1.0dev (2026-11-09T15:47:20Z master 7e5d882817) +PRISM [arm64-darwin25] [1, 2] [0, 1] [0, 1] [0, 1] ``` Same result on 4.0.7. With `proc { x }` instead of `Ractor.shareable_proc { x }`, the first case gives `[2, 2]`. Each shareable Proc silently keeps the value the variable had when it was created and ignores the later reassignments. This is exactly what #21557 wanted to prevent by raising `Ractor::IsolationError`. Since #22444 this also applies to `Ractor.new` blocks. ## Cause The static analysis marks a variable as reassigned only if the source contains more than one assignment to it. It does not consider that a single assignment can be executed several times for the same variable: * in a `while`/`until` loop (including its condition), * in a `begin` body whose `rescue` uses `retry`, * in a block using `redo`, which reruns the body in the same frame, * from a nested block, which may be called many times. This covers `for` loops, whose variables belong to the enclosing scope and are assigned from the loop's block. ## Fix PR: https://github.com/ruby/ruby/pull/19802 The fix treats such assignments as reassignments, in both `prism_compile.c` and `compile.c`. Assignments from `rescue`/`ensure` clauses are also level > 0 and are conservatively treated the same way. Blocks still get a new frame per call, so this keeps working: ```ruby [1, 2].each do |e| f = e * 2 Ractor.shareable_proc { [e, f] } # OK end ``` With the fix, the cases above raise: ``` cannot make a shareable Proc because the outer variable 'x' may be reassigned. (Ractor::IsolationError) ``` Code which currently creates such a shareable Proc gets `Ractor::IsolationError` after this fix, but it is currently silently getting stale values, so I don't think this should be backported. -- 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/