From 5482d115df5d04478b0ecd9fab2826664d1b4ee1 Mon Sep 17 00:00:00 2001 From: Christian Petersen Date: Mon, 8 Jun 2026 17:49:30 +0200 Subject: [PATCH 01/10] Remove `OpenStruct` from `Context` and replace with custom implementation --- .github/workflows/tests.yml | 3 + interactor.gemspec | 1 - lib/interactor/context.rb | 78 +++++++++++++-- spec/interactor/context_spec.rb | 167 ++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e257c4c..6001561 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,9 @@ on: push: branches: - "master" + pull_request: + branches: + - "master" jobs: spec: diff --git a/interactor.gemspec b/interactor.gemspec index c99e59e..fd822cf 100644 --- a/interactor.gemspec +++ b/interactor.gemspec @@ -13,7 +13,6 @@ Gem::Specification.new do |spec| spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) - spec.add_dependency "ostruct" spec.add_development_dependency "bundler" spec.add_development_dependency "rake" end diff --git a/lib/interactor/context.rb b/lib/interactor/context.rb index 8e2181e..4436cbf 100644 --- a/lib/interactor/context.rb +++ b/lib/interactor/context.rb @@ -1,5 +1,3 @@ -require "ostruct" - module Interactor # Public: The object for tracking state of an Interactor's invocation. The # context is used to initialize the interactor with the information required @@ -28,7 +26,7 @@ module Interactor # # => "baz" # context # # => # - class Context < OpenStruct + class Context # Internal: Initialize an Interactor::Context or preserve an existing one. # If the argument given is an Interactor::Context, the argument is returned. # Otherwise, a new Interactor::Context is initialized from the provided @@ -60,6 +58,58 @@ def self.build(context = {}) end end + def initialize(context = {}) + @table = {} + context&.each { |key, value| self[key] = value } + end + + # Public: Read a context attribute by key. + def [](key) + @table[key.to_sym] + end + + # Public: Write a context attribute by key, normalising to symbol. + def []=(key, value) + @table[key.to_sym] = value + end + + # Public: Return all user-set attributes as a Hash (excludes internal state). + def to_h + @table.dup + end + + def ==(other) + other.is_a?(Context) && @table == other.send(:table) + end + + def eql?(other) + other.is_a?(Context) && @table.eql?(other.send(:table)) + end + + def hash + @table.hash + end + + def inspect + pairs = @table.map { |k, v| "#{k}=#{v.inspect}" } + "#<#{self.class}#{" #{pairs.join(", ")}" unless pairs.empty?}>" + end + + def respond_to_missing?(method_name, include_private = false) + method_name.to_s.end_with?("=") || @table.key?(method_name.to_sym) || super + end + + # Dynamic getter/setter for arbitrary context attributes. + # Setters end with "="; getters return nil for unset keys. + def method_missing(method_name, *args) + name = method_name.to_s + if name.end_with?("=") + @table[name.delete_suffix("=").to_sym] = args.first + else + @table[method_name.to_sym] + end + end + # Public: Whether the Interactor::Context is successful. By default, a new # context is successful and only changes when explicitly failed. # @@ -143,7 +193,7 @@ def halted? # # Raises Interactor::Failure initialized with the Interactor::Context. def fail!(context = {}) - context.each { |key, value| self[key.to_sym] = value } + context.each { |key, value| self[key] = value } @failure = true raise Failure, self end @@ -171,7 +221,7 @@ def fail!(context = {}) # # Raises Interactor::Halt initialized with the Interactor::Context. def halt!(context = {}) - context.each { |key, value| self[key.to_sym] = value } + context.each { |key, value| self[key] = value } @halted = true raise Halt, self end @@ -204,6 +254,7 @@ def called!(interactor) # Returns true if rolled back successfully or false if already rolled back. def rollback! return false if @rolled_back || halted? + _called.reverse_each(&:rollback) @rolled_back = true end @@ -250,12 +301,25 @@ def _called # end # # Returns the context as a hash, including success, failure, and halted - def deconstruct_keys(keys) - to_h.merge( + def deconstruct_keys(_keys) + @table.merge( success: success?, failure: failure?, halted: halted? ) end + + private + + attr_reader :table + + def initialize_copy(orig) + super + @table = orig.send(:table).dup + @called = orig._called.dup + @failure = nil + @halted = nil + @rolled_back = nil + end end end diff --git a/spec/interactor/context_spec.rb b/spec/interactor/context_spec.rb index 14c88bc..0bd90a2 100644 --- a/spec/interactor/context_spec.rb +++ b/spec/interactor/context_spec.rb @@ -288,6 +288,173 @@ module Interactor end end + describe "dynamic attributes" do + let(:context) { Context.build } + + it "sets and reads attributes via method syntax" do + context.foo = "bar" + expect(context.foo).to eq("bar") + end + + it "returns nil for unset attributes" do + expect(context.missing_key).to be_nil + end + + it "overwrites previously set attributes" do + context.foo = "bar" + context.foo = "baz" + expect(context.foo).to eq("baz") + end + + it "responds to setter methods" do + expect(context.respond_to?(:foo=)).to eq(true) + end + + it "responds to getter methods only after the key is set" do + expect(context.respond_to?(:foo)).to eq(false) + context.foo = "bar" + expect(context.respond_to?(:foo)).to eq(true) + end + + it "normalises string keys to symbols" do + context["foo"] = "bar" + expect(context.foo).to eq("bar") + expect(context[:foo]).to eq("bar") + end + end + + describe "#[] and #[]=" do + let(:context) { Context.build } + + it "reads and writes with symbol keys" do + context[:foo] = "bar" + expect(context[:foo]).to eq("bar") + end + + it "normalises string keys on write" do + context["foo"] = "bar" + expect(context[:foo]).to eq("bar") + end + + it "normalises string keys on read" do + context[:foo] = "bar" + expect(context["foo"]).to eq("bar") + end + end + + describe "#to_h" do + it "returns user-set attributes as a hash" do + context = Context.build(foo: "bar", baz: 42) + expect(context.to_h).to eq(foo: "bar", baz: 42) + end + + it "does not include internal state flags" do + context = Context.build(foo: "bar") + begin + context.fail! + rescue + nil + end + hash = context.to_h + expect(hash.keys).not_to include(:failure, :success, :halted) + end + + it "returns a copy that does not affect the context" do + context = Context.build(foo: "bar") + hash = context.to_h + hash[:foo] = "mutated" + expect(context.foo).to eq("bar") + end + end + + describe "#==" do + it "is equal to another context with the same attributes" do + context1 = Context.build(foo: "bar") + context2 = Context.build(foo: "bar") + expect(context1).to eq(context2) + end + + it "is not equal to a context with different attributes" do + context1 = Context.build(foo: "bar") + context2 = Context.build(foo: "baz") + expect(context1).not_to eq(context2) + end + + it "is not equal to a plain hash" do + context = Context.build(foo: "bar") + expect(context).not_to eq(foo: "bar") + end + end + + describe "#eql? and #hash" do + it "two contexts with the same attributes are eql?" do + context1 = Context.build(foo: "bar") + context2 = Context.build(foo: "bar") + expect(context1.eql?(context2)).to eq(true) + end + + it "two contexts with different attributes are not eql?" do + context1 = Context.build(foo: "bar") + context2 = Context.build(foo: "baz") + expect(context1.eql?(context2)).to eq(false) + end + + it "equal contexts have the same hash value" do + context1 = Context.build(foo: "bar") + context2 = Context.build(foo: "bar") + expect(context1.hash).to eq(context2.hash) + end + + it "can be used as a Hash key with value semantics" do + context1 = Context.build(foo: "bar") + context2 = Context.build(foo: "bar") + h = {context1 => :found} + expect(h[context2]).to eq(:found) + end + end + + describe "#dup (initialize_copy)" do + let(:instance1) { double(:instance1) } + let(:instance2) { double(:instance2) } + + it "dups the @table so attribute mutations are isolated" do + original = Context.build(foo: "bar") + copy = original.dup + copy.foo = "baz" + expect(original.foo).to eq("bar") + end + + it "dups @called so rollback lists are independent" do + original = Context.build + original.called!(instance1) + copy = original.dup + copy.called!(instance2) + expect(original._called).to eq([instance1]) + expect(copy._called).to eq([instance1, instance2]) + end + + it "resets failure state so a dup of a failed context starts fresh" do + original = Context.build(foo: "bar") + original.fail! rescue nil + copy = original.dup + expect(copy.failure?).to eq(false) + expect(copy.success?).to eq(true) + end + + it "resets halted state so a dup of a halted context starts fresh" do + original = Context.build(foo: "bar") + original.halt! rescue nil + copy = original.dup + expect(copy.halted?).to eq(false) + end + end + + describe "OpenStruct removal" do + it "does not inherit from OpenStruct" do + expect(Context.superclass).to eq(Object) + end + end + describe "#deconstruct_keys" do let(:context) { Context.build(foo: :bar) } From a3209115ecf43b30afd67ce189db4d7e641e2cde Mon Sep 17 00:00:00 2001 From: Christian Petersen Date: Mon, 8 Jun 2026 18:02:07 +0200 Subject: [PATCH 02/10] Fix "standard" gem cops --- lib/interactor.rb | 4 +--- spec/interactor/context_spec.rb | 22 ++++++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/interactor.rb b/lib/interactor.rb index 307234c..c76ca17 100644 --- a/lib/interactor.rb +++ b/lib/interactor.rb @@ -114,9 +114,7 @@ def initialize(context = {}) def run run! rescue Failure => e - if context.object_id != e.context.object_id - raise - end + raise unless context.equal?(e.context) end # Internal: Invoke an Interactor instance along with all defined hooks. The diff --git a/spec/interactor/context_spec.rb b/spec/interactor/context_spec.rb index 0bd90a2..22a9166 100644 --- a/spec/interactor/context_spec.rb +++ b/spec/interactor/context_spec.rb @@ -144,11 +144,9 @@ module Interactor end it "makes the context available from the failure" do - begin - context.fail! - rescue Failure => error - expect(error.context).to eq(context) - end + context.fail! + rescue Failure => error + expect(error.context).to eq(context) end end @@ -435,7 +433,11 @@ module Interactor it "resets failure state so a dup of a failed context starts fresh" do original = Context.build(foo: "bar") - original.fail! rescue nil + begin + original.fail! + rescue + nil + end copy = original.dup expect(copy.failure?).to eq(false) expect(copy.success?).to eq(true) @@ -443,7 +445,11 @@ module Interactor it "resets halted state so a dup of a halted context starts fresh" do original = Context.build(foo: "bar") - original.halt! rescue nil + begin + original.halt! + rescue + nil + end copy = original.dup expect(copy.halted?).to eq(false) end @@ -485,7 +491,7 @@ module Interactor end it "supports rightward assignment for halted:" do - context => { halted: } + context => {halted:} expect(halted).to be(true) end end From 9cea340e270d31d7c0e3fa4288b900a3d098abf7 Mon Sep 17 00:00:00 2001 From: Murat Atak Date: Wed, 17 Jun 2026 13:55:55 -0600 Subject: [PATCH 03/10] Override inherited methods via singleton accessors so shadowed keys (e.g. to_json) read back stored values --- lib/interactor/context.rb | 19 +++++++++++++++++-- spec/interactor/context_spec.rb | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/lib/interactor/context.rb b/lib/interactor/context.rb index 4436cbf..462e4c5 100644 --- a/lib/interactor/context.rb +++ b/lib/interactor/context.rb @@ -70,7 +70,9 @@ def [](key) # Public: Write a context attribute by key, normalising to symbol. def []=(key, value) - @table[key.to_sym] = value + key = key.to_sym + define_accessor(key) unless singleton_class.method_defined?(key, false) + @table[key] = value end # Public: Return all user-set attributes as a Hash (excludes internal state). @@ -104,7 +106,7 @@ def respond_to_missing?(method_name, include_private = false) def method_missing(method_name, *args) name = method_name.to_s if name.end_with?("=") - @table[name.delete_suffix("=").to_sym] = args.first + self[name.delete_suffix("=").to_sym] = args.first else @table[method_name.to_sym] end @@ -313,9 +315,22 @@ def deconstruct_keys(_keys) attr_reader :table + # Mirror OpenStruct: on the first write of a key, define a getter (and + # setter) on this instance's singleton class so the getter outranks any + # method inherited from Object - notably ActiveSupport's Object#to_json, + # which application code relies on reading back as a stored context value. + # The `false` argument to method_defined? (in #[]=) stops the lookup walking + # up to Object; without it, names like :to_json would always look "defined" + # and the singleton override would never be installed. + def define_accessor(key) + define_singleton_method(key) { @table[key] } + define_singleton_method("#{key}=") { |value| @table[key] = value } + end + def initialize_copy(orig) super @table = orig.send(:table).dup + @table.each_key { |key| define_accessor(key) } @called = orig._called.dup @failure = nil @halted = nil diff --git a/spec/interactor/context_spec.rb b/spec/interactor/context_spec.rb index 22a9166..b544278 100644 --- a/spec/interactor/context_spec.rb +++ b/spec/interactor/context_spec.rb @@ -459,6 +459,25 @@ module Interactor it "does not inherit from OpenStruct" do expect(Context.superclass).to eq(Object) end + + # Regression guard for the real-world failure mode: in the application, + # ActiveSupport adds Object#to_json and ~32 endpoints store a rendered + # payload as context.to_json then read it back. A plain method_missing + # implementation lets that read fall through to the inherited method. + # ActiveSupport is not loaded in this gem's own suite, so we reproduce the + # same shadowing with Kernel#display, which exists on every Object here; + # the application-level to_json case is covered by the Docsketch suite. + it "returns the stored value for a key shadowing an inherited method" do + context = Context.build + context.display = "stored-payload" + expect(context.display).to eq("stored-payload") + end + + it "preserves the shadowing override across dup" do + context = Context.build + context.display = "stored-payload" + expect(context.dup.display).to eq("stored-payload") + end end describe "#deconstruct_keys" do From 2794a5561ac8e4f3493a6fed32601bfd59f84c76 Mon Sep 17 00:00:00 2001 From: Murat Atak Date: Wed, 17 Jun 2026 14:05:26 -0600 Subject: [PATCH 04/10] Alias to_s to inspect so Interactor::Failure messages stay readable --- lib/interactor/context.rb | 5 +++++ spec/interactor/context_spec.rb | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/lib/interactor/context.rb b/lib/interactor/context.rb index 462e4c5..a200840 100644 --- a/lib/interactor/context.rb +++ b/lib/interactor/context.rb @@ -97,6 +97,11 @@ def inspect "#<#{self.class}#{" #{pairs.join(", ")}" unless pairs.empty?}>" end + # OpenStruct aliased to_s to inspect; preserve that so Interactor::Failure + # messages (Exception#message calls context.to_s) stay readable rather than + # falling back to Object#to_s (#). + alias_method :to_s, :inspect + def respond_to_missing?(method_name, include_private = false) method_name.to_s.end_with?("=") || @table.key?(method_name.to_sym) || super end diff --git a/spec/interactor/context_spec.rb b/spec/interactor/context_spec.rb index b544278..7370073 100644 --- a/spec/interactor/context_spec.rb +++ b/spec/interactor/context_spec.rb @@ -480,6 +480,21 @@ module Interactor end end + describe "#to_s" do + it "matches #inspect so attribute info is not lost" do + context = Context.build(foo: "bar") + expect(context.to_s).to eq(context.inspect) + expect(context.to_s).to include('foo="bar"') + end + + it "keeps Interactor::Failure messages readable" do + context = Context.build(foo: "bar") + context.fail! + rescue Failure => error + expect(error.message).to include('foo="bar"') + end + end + describe "#deconstruct_keys" do let(:context) { Context.build(foo: :bar) } From cab6c2ecf80dede819fa91694518670448a864d4 Mon Sep 17 00:00:00 2001 From: Christian Petersen Date: Fri, 19 Jun 2026 12:28:36 +0200 Subject: [PATCH 05/10] Guard reserved names and install singleton accessors lazily The previous implementation defined a getter/setter on the instance's singleton class for *every* context key on first write. That had two problems for a production drop-in replacement of OpenStruct: - A key whose name matched a method the Context itself relies on (e.g. `hash`, `to_h`, `==`, `_called`) silently overrode that method for the instance, corrupting the object. `method_missing`/`==` reaching into `other.send(:table)` also broke if a key named `send` was ever stored. - Every key paid for two singleton-method definitions, allocating a per-instance singleton class even for ordinary contexts (the common, short-lived case in interactors), defeating inline caches. Now an accessor is installed only when a key would otherwise be intercepted by an inherited method (the original `to_json` motivation). Plain keys are served by `method_missing` with no singleton methods at all. A frozen `RESERVED_NAMES` set lists the names the class depends on; those are stored in `@table` and remain reachable via `#[]`/`#to_h` but are never shadowed. A spec asserts every Context-defined method is reserved, so future additions can't silently regress. Equality now compares `@table` directly via `instance_variable_get` (zero allocation, no `send`, immune to a stored `send`/`table` key), and `#freeze` freezes the backing table so writes to a frozen context raise FrozenError as they did with OpenStruct. Co-Authored-By: Claude Opus 4.8 --- lib/interactor/context.rb | 69 ++++++++++++++++++++++++++------- spec/interactor/context_spec.rb | 66 ++++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 14 deletions(-) diff --git a/lib/interactor/context.rb b/lib/interactor/context.rb index a200840..86137e5 100644 --- a/lib/interactor/context.rb +++ b/lib/interactor/context.rb @@ -1,3 +1,5 @@ +require "set" + module Interactor # Public: The object for tracking state of an Interactor's invocation. The # context is used to initialize the interactor with the information required @@ -10,6 +12,11 @@ module Interactor # # The context may be manipulated using arbitrary getter and setter methods. # + # A context is not safe for concurrent writes: the first write of a key may + # define a singleton accessor (see RESERVED_NAMES), which mutates the + # instance's singleton class. Share a context across threads for reads only, + # or guard writes externally. + # # Examples # # context = Interactor::Context.new @@ -27,6 +34,25 @@ module Interactor # context # # => # class Context + # Internal: Method names the context relies on for its own behaviour (its + # public/internal API plus the core object protocol it calls). A context key + # matching one of these is stored in @table and stays reachable through #[] + # and #to_h, but is never installed as a singleton accessor - so user data + # can never silently override a method the class itself depends on. + # + # Names added by *other* libraries (e.g. ActiveSupport's Object#to_json) are + # intentionally absent: those are shadowed by a singleton accessor so a + # stored value reads back, which is the entire reason accessors exist. + RESERVED_NAMES = Set[ + :[], :[]=, :==, :eql?, :equal?, :hash, :dig, :dup, :clone, :freeze, + :frozen?, :to_h, :to_s, :inspect, :class, :is_a?, :kind_of?, :instance_of?, + :nil?, :send, :__send__, :singleton_class, :define_singleton_method, + :instance_variable_get, :instance_variable_set, :method, :methods, + :respond_to?, :respond_to_missing?, :method_missing, :object_id, + :marshal_dump, :marshal_load, :deconstruct_keys, :success?, :failure?, + :halted?, :fail!, :halt!, :called!, :rollback!, :_called + ].freeze + # Internal: Initialize an Interactor::Context or preserve an existing one. # If the argument given is an Interactor::Context, the argument is returned. # Otherwise, a new Interactor::Context is initialized from the provided @@ -71,7 +97,7 @@ def [](key) # Public: Write a context attribute by key, normalising to symbol. def []=(key, value) key = key.to_sym - define_accessor(key) unless singleton_class.method_defined?(key, false) + define_accessor(key) if define_accessor?(key) @table[key] = value end @@ -81,17 +107,25 @@ def to_h end def ==(other) - other.is_a?(Context) && @table == other.send(:table) + other.is_a?(Context) && @table == other.instance_variable_get(:@table) end def eql?(other) - other.is_a?(Context) && @table.eql?(other.send(:table)) + other.is_a?(Context) && @table.eql?(other.instance_variable_get(:@table)) end def hash @table.hash end + # Public: Freeze the context. Freezes the backing table too so that, as with + # OpenStruct, subsequent writes raise FrozenError rather than silently + # succeeding. + def freeze + @table.freeze + super + end + def inspect pairs = @table.map { |k, v| "#{k}=#{v.inspect}" } "#<#{self.class}#{" #{pairs.join(", ")}" unless pairs.empty?}>" @@ -318,15 +352,24 @@ def deconstruct_keys(_keys) private - attr_reader :table + # Whether a singleton accessor should be installed for the given key. + # + # A plain key (one that does not shadow an existing method) is served by + # #method_missing, so no accessor is needed - keeping ordinary contexts free + # of per-key singleton methods. An accessor is installed only when the key + # would otherwise be intercepted by an inherited method (e.g. ActiveSupport's + # Object#to_json), so the stored value reads back. Reserved names and keys + # already backed by an accessor are skipped. + def define_accessor?(key) + return false if RESERVED_NAMES.include?(key) + return false if singleton_class.method_defined?(key, false) + + respond_to?(key, true) + end - # Mirror OpenStruct: on the first write of a key, define a getter (and - # setter) on this instance's singleton class so the getter outranks any - # method inherited from Object - notably ActiveSupport's Object#to_json, - # which application code relies on reading back as a stored context value. - # The `false` argument to method_defined? (in #[]=) stops the lookup walking - # up to Object; without it, names like :to_json would always look "defined" - # and the singleton override would never be installed. + # Define a getter (and setter) on this instance's singleton class so they + # outrank the inherited method the key shadows. See #define_accessor? for + # when this is invoked. def define_accessor(key) define_singleton_method(key) { @table[key] } define_singleton_method("#{key}=") { |value| @table[key] = value } @@ -334,8 +377,8 @@ def define_accessor(key) def initialize_copy(orig) super - @table = orig.send(:table).dup - @table.each_key { |key| define_accessor(key) } + @table = orig.to_h + @table.each_key { |key| define_accessor(key) if define_accessor?(key) } @called = orig._called.dup @failure = nil @halted = nil diff --git a/spec/interactor/context_spec.rb b/spec/interactor/context_spec.rb index 7370073..abf5287 100644 --- a/spec/interactor/context_spec.rb +++ b/spec/interactor/context_spec.rb @@ -12,7 +12,7 @@ module Interactor context = Context.build expect(context).to be_a(Context) - expect(context.send(:table)).to eq({}) + expect(context.to_h).to eq({}) end it "doesn't affect the original hash" do @@ -382,6 +382,12 @@ module Interactor context = Context.build(foo: "bar") expect(context).not_to eq(foo: "bar") end + + it "is equal regardless of attribute insertion order" do + context1 = Context.build(foo: "bar", baz: "qux") + context2 = Context.build(baz: "qux", foo: "bar") + expect(context1).to eq(context2) + end end describe "#eql? and #hash" do @@ -478,6 +484,64 @@ module Interactor context.display = "stored-payload" expect(context.dup.display).to eq("stored-payload") end + + it "does not install a singleton accessor for a plain key" do + context = Context.build + context.foo = "bar" + expect(context.singleton_methods).not_to include(:foo) + expect(context.foo).to eq("bar") + end + + it "installs a singleton accessor only for keys shadowing a method" do + context = Context.build + context.display = "stored-payload" + expect(context.singleton_methods).to include(:display) + end + end + + describe "reserved names" do + it "stores a reserved key without overriding the method" do + context = Context.build + context[:hash] = "stored" + # #hash still returns the real Integer identity hash, not "stored", + # while the value remains reachable through #[]. + expect(context.hash).to eq({hash: "stored"}.hash) + expect(context[:hash]).to eq("stored") + end + + it "keeps #to_h working when a :to_h key is stored" do + context = Context.build(to_h: "stored", foo: "bar") + expect(context.to_h).to eq(to_h: "stored", foo: "bar") + expect(context[:to_h]).to eq("stored") + end + + it "keeps equality working when a reserved key is stored" do + context1 = Context.build(hash: "x") + context2 = Context.build(hash: "x") + expect(context1).to eq(context2) + end + + # Guards against silent divergence: if a new public method is added to + # Context without reserving its name, a user key of the same name would + # shadow it. This fails loudly instead. + it "reserves every method the class defines" do + own_methods = Context.instance_methods(false) + unreserved = own_methods.reject { |name| Context::RESERVED_NAMES.include?(name) } + expect(unreserved).to eq([]) + end + end + + describe "#freeze" do + it "raises when writing to a frozen context" do + context = Context.build(foo: "bar") + context.freeze + expect { context.baz = "qux" }.to raise_error(FrozenError) + end + + it "reports itself as frozen" do + context = Context.build.freeze + expect(context).to be_frozen + end end describe "#to_s" do From 16cc89723600b0a1ea64a792d64f69227d4dad0e Mon Sep 17 00:00:00 2001 From: Christian Petersen Date: Fri, 19 Jun 2026 12:30:57 +0200 Subject: [PATCH 06/10] Restore Marshal and dig support for OpenStruct parity Two silent regressions versus the OpenStruct-backed Context that downstream production code can hit: - Marshal: any context with at least one attribute raised "TypeError: singleton can't be dumped" because set keys may define singleton methods. Code that caches contexts or enqueues them (Rails cache, Sidekiq args) would break. marshal_dump now serializes only @table and marshal_load rebuilds accessors, so contexts round-trip; transient state (called list, failure/halted flags) is intentionally reset, matching a freshly built context. - dig: OpenStruct supports `context.dig(:a, :b)`. The custom Context inherited no #dig, so nested lookups silently returned nil. Added a delegation to Hash#dig that normalises the first key to a symbol, as the other accessors do. The accessor-rebuild loop shared by marshal_load and initialize_copy is extracted into a private #rebuild_accessors so the two paths can't drift. Co-Authored-By: Claude Opus 4.8 --- lib/interactor/context.rb | 29 +++++++++++++++++++++- spec/interactor/context_spec.rb | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/lib/interactor/context.rb b/lib/interactor/context.rb index 86137e5..af944ac 100644 --- a/lib/interactor/context.rb +++ b/lib/interactor/context.rb @@ -106,6 +106,12 @@ def to_h @table.dup end + # Public: Extract a nested value, mirroring OpenStruct#dig (and Hash#dig). + # The first key is normalised to a symbol to match attribute storage. + def dig(key, *rest) + @table.dig(key.to_sym, *rest) + end + def ==(other) other.is_a?(Context) && @table == other.instance_variable_get(:@table) end @@ -126,6 +132,20 @@ def freeze super end + # Public: Serialize only the attribute table. Because set keys define + # singleton methods, the default Marshal path raises "singleton can't be + # dumped"; dumping just @table keeps contexts marshallable (e.g. cached or + # enqueued) and rebuilds accessors on load. Transient state (called list, + # failure/halted flags) is intentionally not preserved. + def marshal_dump + @table + end + + def marshal_load(table) + @table = table + rebuild_accessors + end + def inspect pairs = @table.map { |k, v| "#{k}=#{v.inspect}" } "#<#{self.class}#{" #{pairs.join(", ")}" unless pairs.empty?}>" @@ -375,10 +395,17 @@ def define_accessor(key) define_singleton_method("#{key}=") { |value| @table[key] = value } end + # Install singleton accessors for every stored key that needs one. Used + # after @table is replaced wholesale (dup/clone, Marshal load) rather than + # built up key-by-key through #[]=. + def rebuild_accessors + @table.each_key { |key| define_accessor(key) if define_accessor?(key) } + end + def initialize_copy(orig) super @table = orig.to_h - @table.each_key { |key| define_accessor(key) if define_accessor?(key) } + rebuild_accessors @called = orig._called.dup @failure = nil @halted = nil diff --git a/spec/interactor/context_spec.rb b/spec/interactor/context_spec.rb index abf5287..593ac5b 100644 --- a/spec/interactor/context_spec.rb +++ b/spec/interactor/context_spec.rb @@ -544,6 +544,50 @@ module Interactor end end + describe "#dig" do + it "reads a top-level attribute" do + context = Context.build(foo: "bar") + expect(context.dig(:foo)).to eq("bar") + end + + it "digs into nested values" do + context = Context.build(foo: {a: {b: 1}}) + expect(context.dig(:foo, :a, :b)).to eq(1) + end + + it "normalises a string first key" do + context = Context.build(foo: {a: 1}) + expect(context.dig("foo", :a)).to eq(1) + end + + it "returns nil for a missing key" do + expect(Context.build.dig(:missing, :nope)).to be_nil + end + end + + describe "Marshal round-trip" do + it "dumps and loads an attribute-bearing context" do + context = Context.build(foo: "bar", count: 3) + restored = Marshal.load(Marshal.dump(context)) + expect(restored).to eq(context) + expect(restored.foo).to eq("bar") + end + + it "rebuilds accessors for shadowing keys after load" do + context = Context.build + context.display = "stored-payload" + restored = Marshal.load(Marshal.dump(context)) + expect(restored.display).to eq("stored-payload") + end + + it "starts a restored context in a fresh, successful state" do + context = Context.build(foo: "bar") + restored = Marshal.load(Marshal.dump(context)) + expect(restored.success?).to eq(true) + expect(restored._called).to eq([]) + end + end + describe "#to_s" do it "matches #inspect so attribute info is not lost" do context = Context.build(foo: "bar") From e874ab14494bec81216f93b21df15fc7a36d313b Mon Sep 17 00:00:00 2001 From: Christian Petersen Date: Fri, 19 Jun 2026 12:33:10 +0200 Subject: [PATCH 07/10] Match OpenStruct parity for to_h block and getter arity Two small behavioural gaps versus OpenStruct: - to_h ignored a block. OpenStruct#to_h (Ruby 2.6+) yields each key/value pair for transformation; the custom Context silently dropped the block. It now forwards to Hash#to_h(&block), while the no-block path keeps returning @table.dup (Hash#to_h without a block returns self, which would leak the internal table). - A getter invoked with arguments (context.foo(1)) silently returned the value instead of raising. OpenStruct raises ArgumentError, which surfaces caller bugs; method_missing now does the same. Co-Authored-By: Claude Opus 4.8 --- lib/interactor/context.rb | 14 ++++++++++++-- spec/interactor/context_spec.rb | 11 +++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/interactor/context.rb b/lib/interactor/context.rb index af944ac..b5fa9cc 100644 --- a/lib/interactor/context.rb +++ b/lib/interactor/context.rb @@ -101,8 +101,12 @@ def []=(key, value) @table[key] = value end - # Public: Return all user-set attributes as a Hash (excludes internal state). - def to_h + # Public: Return all user-set attributes as a Hash (excludes internal + # state). Mirrors OpenStruct#to_h: with a block, each key/value pair is + # transformed; without one, a shallow copy is returned. + def to_h(&block) + return @table.to_h(&block) if block + @table.dup end @@ -167,6 +171,12 @@ def method_missing(method_name, *args) if name.end_with?("=") self[name.delete_suffix("=").to_sym] = args.first else + # Mirror OpenStruct: a getter takes no arguments, so flag caller bugs + # rather than silently ignoring them. + unless args.empty? + raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 0)" + end + @table[method_name.to_sym] end end diff --git a/spec/interactor/context_spec.rb b/spec/interactor/context_spec.rb index 593ac5b..c867445 100644 --- a/spec/interactor/context_spec.rb +++ b/spec/interactor/context_spec.rb @@ -298,6 +298,11 @@ module Interactor expect(context.missing_key).to be_nil end + it "raises ArgumentError when a getter is called with arguments" do + context.foo = "bar" + expect { context.foo(1) }.to raise_error(ArgumentError) + end + it "overwrites previously set attributes" do context.foo = "bar" context.foo = "baz" @@ -363,6 +368,12 @@ module Interactor hash[:foo] = "mutated" expect(context.foo).to eq("bar") end + + it "transforms pairs when given a block" do + context = Context.build(foo: "bar") + result = context.to_h { |key, value| [key.to_s, value.upcase] } + expect(result).to eq("foo" => "BAR") + end end describe "#==" do From 80d659ca2a3fbe9cd48bff56c3c19c97c2ce1ba8 Mon Sep 17 00:00:00 2001 From: Christian Petersen Date: Mon, 22 Jun 2026 19:14:43 +0200 Subject: [PATCH 08/10] Address review: derive reserved names, protected table reader, drop dead setter Three points from review feedback on the lazy-accessor implementation: - RESERVED_NAMES duplicated the names of methods defined just below it, so adding or renaming a method meant remembering to edit the list or risk a key shadowing it. The class's own API half is now derived from instance_methods(false) + private_instance_methods(false); only the inherited Object/Kernel protocol (dup, class, send, ...) stays hand-listed as CORE_PROTOCOL, since that genuinely can't be derived. - #== / #eql? reached into the other instance's @table via instance_variable_get. Replaced with a protected attr_reader :table so neither method depends on the other object's ivar layout. - define_accessor also defined a "#{key}=" singleton setter that never ran: setter names end in "=", which no inherited method does, so writes always route through method_missing to #[]=. Only the reader needs a singleton override to outrank the inherited method. Dropped it. Co-Authored-By: Claude Opus 4.8 --- lib/interactor/context.rb | 59 ++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/lib/interactor/context.rb b/lib/interactor/context.rb index b5fa9cc..32aa628 100644 --- a/lib/interactor/context.rb +++ b/lib/interactor/context.rb @@ -34,25 +34,6 @@ module Interactor # context # # => # class Context - # Internal: Method names the context relies on for its own behaviour (its - # public/internal API plus the core object protocol it calls). A context key - # matching one of these is stored in @table and stays reachable through #[] - # and #to_h, but is never installed as a singleton accessor - so user data - # can never silently override a method the class itself depends on. - # - # Names added by *other* libraries (e.g. ActiveSupport's Object#to_json) are - # intentionally absent: those are shadowed by a singleton accessor so a - # stored value reads back, which is the entire reason accessors exist. - RESERVED_NAMES = Set[ - :[], :[]=, :==, :eql?, :equal?, :hash, :dig, :dup, :clone, :freeze, - :frozen?, :to_h, :to_s, :inspect, :class, :is_a?, :kind_of?, :instance_of?, - :nil?, :send, :__send__, :singleton_class, :define_singleton_method, - :instance_variable_get, :instance_variable_set, :method, :methods, - :respond_to?, :respond_to_missing?, :method_missing, :object_id, - :marshal_dump, :marshal_load, :deconstruct_keys, :success?, :failure?, - :halted?, :fail!, :halt!, :called!, :rollback!, :_called - ].freeze - # Internal: Initialize an Interactor::Context or preserve an existing one. # If the argument given is an Interactor::Context, the argument is returned. # Otherwise, a new Interactor::Context is initialized from the provided @@ -117,11 +98,11 @@ def dig(key, *rest) end def ==(other) - other.is_a?(Context) && @table == other.instance_variable_get(:@table) + other.is_a?(Context) && @table == other.table end def eql?(other) - other.is_a?(Context) && @table.eql?(other.instance_variable_get(:@table)) + other.is_a?(Context) && @table.eql?(other.table) end def hash @@ -380,6 +361,12 @@ def deconstruct_keys(_keys) ) end + protected + + # Readable by sibling contexts only, so #== / #eql? can compare tables + # without reaching into another instance's @table by name. + attr_reader :table + private # Whether a singleton accessor should be installed for the given key. @@ -397,12 +384,12 @@ def define_accessor?(key) respond_to?(key, true) end - # Define a getter (and setter) on this instance's singleton class so they - # outrank the inherited method the key shadows. See #define_accessor? for - # when this is invoked. + # Define a getter on this instance's singleton class so it outranks the + # inherited method the key shadows. Only the reader needs overriding: setter + # names end in "=", which no inherited method does, so writes always route + # through #method_missing to #[]=. See #define_accessor? for when this runs. def define_accessor(key) define_singleton_method(key) { @table[key] } - define_singleton_method("#{key}=") { |value| @table[key] = value } end # Install singleton accessors for every stored key that needs one. Used @@ -421,5 +408,27 @@ def initialize_copy(orig) @halted = nil @rolled_back = nil end + + # Internal: Inherited Object/Kernel methods the context itself calls and + # must never let a key shadow. Only this protocol half needs hand-listing - + # it lives on Object, not here; the class's own methods are folded in by + # RESERVED_NAMES below. + CORE_PROTOCOL = %i[ + dup clone frozen? class is_a? kind_of? instance_of? nil? + send __send__ singleton_class define_singleton_method + instance_variable_get instance_variable_set method methods + respond_to? object_id equal? + ].freeze + + # Internal: Method names a context key must never shadow with a singleton + # accessor. Derived from the class's own API (so adding or renaming a method + # can't drift out of sync with this list) plus the inherited protocol above. + # Such keys are still stored in @table and reachable via #[]/#to_h. Names + # from *other* libraries (e.g. ActiveSupport's Object#to_json) are absent on + # purpose: those are shadowed so a stored value reads back, which is the + # entire reason accessors exist. + RESERVED_NAMES = ( + instance_methods(false) + private_instance_methods(false) + CORE_PROTOCOL + ).to_set.freeze end end From 67457258a6fdfdb40e13d9f35a42aa0055513093 Mon Sep 17 00:00:00 2001 From: Christian Petersen Date: Mon, 22 Jun 2026 19:21:15 +0200 Subject: [PATCH 09/10] Keep ordinary contexts free of singleton classes for YJIT define_accessor? decided whether to install a shadowing accessor with `singleton_class.method_defined?(key, false)` followed by `respond_to?(key, true)`. Two problems, both YJIT-hostile: - Calling `singleton_class` materialises a per-instance singleton class on the first write of ANY key, so every context got its own class. - `respond_to?` is true for any stored key (via respond_to_missing?), so overwriting a plain key installed a needless singleton getter on the second write. Either way ordinary contexts ended up with a unique singleton class, which makes `context.attr` call sites megamorphic and defeats YJIT's inline caches - the opposite of what the lazy-accessor design intended. Decide collision from the class's real method table instead (`self.class.method_defined?` / `private_method_defined?`): it ignores method_missing and never touches the instance's singleton class, so a singleton class is now created only for the rare key that genuinely shadows an inherited method (e.g. to_json). Measured: 2000 plain-key contexts went from 2000 singleton classes to 0; YJIT read throughput +11% on a megamorphic read site. Co-Authored-By: Claude Opus 4.8 --- lib/interactor/context.rb | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/interactor/context.rb b/lib/interactor/context.rb index 32aa628..29b6ce1 100644 --- a/lib/interactor/context.rb +++ b/lib/interactor/context.rb @@ -377,11 +377,24 @@ def deconstruct_keys(_keys) # would otherwise be intercepted by an inherited method (e.g. ActiveSupport's # Object#to_json), so the stored value reads back. Reserved names and keys # already backed by an accessor are skipped. + # + # The collision test reads the class's real method table rather than + # #respond_to?, which #respond_to_missing? would report true for any stored + # key, and avoids touching #singleton_class until we know shadowing is + # needed - materialising a per-instance singleton class for an ordinary key + # would give every context its own class and make attribute reads + # megamorphic under YJIT. def define_accessor?(key) return false if RESERVED_NAMES.include?(key) - return false if singleton_class.method_defined?(key, false) + return false unless shadows_inherited_method?(key) + + !singleton_class.method_defined?(key, false) + end - respond_to?(key, true) + # Whether a real (non-method_missing) method named key is inherited, so a + # plain getter would dispatch to it instead of the stored value. + def shadows_inherited_method?(key) + self.class.method_defined?(key) || self.class.private_method_defined?(key) end # Define a getter on this instance's singleton class so it outranks the From 0717dade02281d7d803d59d7f7590807d93f33aa Mon Sep 17 00:00:00 2001 From: Christian Petersen Date: Mon, 22 Jun 2026 19:26:15 +0200 Subject: [PATCH 10/10] Give every context one object shape for YJIT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initialize set only @table; @called/@failure/@halted/@rolled_back were created lazily by _called/fail!/halt!/rollback!. So a fresh, a failed and a halted context each had a different set of instance variables — i.e. a different object shape. Under YJIT that makes even @table reads (every attribute access) and the flag reads in success?/failure?/halted? dispatch on different shape ids, so their inline caches go polymorphic. Assign all five ivars up front in initialize, in a fixed order, via a shared reset_state helper also used by marshal_load; initialize_copy resets the same way and then carries over the called list. fail!/halt!/ rollback! now only mutate existing slots, never fork the shape. Verified: fresh, failed, halted, dup'd and Marshal-loaded contexts all report the same instance_variables in the same order (one shape); a spec locks this in so a future lazy ivar can't silently regress it. Co-Authored-By: Claude Opus 4.8 --- lib/interactor/context.rb | 22 +++++++++++++++++++--- spec/interactor/context_spec.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/lib/interactor/context.rb b/lib/interactor/context.rb index 29b6ce1..8fb64ba 100644 --- a/lib/interactor/context.rb +++ b/lib/interactor/context.rb @@ -66,7 +66,12 @@ def self.build(context = {}) end def initialize(context = {}) + # Assign every instance variable up front, in a fixed order, so all + # contexts share one object shape. fail!/halt!/rollback! then only mutate + # existing slots instead of adding ivars, which would fork the shape and + # make @table and flag reads polymorphic (slower) under YJIT. @table = {} + reset_state context&.each { |key, value| self[key] = value } end @@ -128,6 +133,7 @@ def marshal_dump def marshal_load(table) @table = table + reset_state rebuild_accessors end @@ -416,10 +422,20 @@ def initialize_copy(orig) super @table = orig.to_h rebuild_accessors + reset_state + # A copy starts fresh except that it carries over the called interactors, + # so it can still be rolled back. @called = orig._called.dup - @failure = nil - @halted = nil - @rolled_back = nil + end + + # Set the transient state to that of a brand-new context, assigning the + # ivars in the order #initialize establishes so every construction path + # produces the same object shape. + def reset_state + @called = [] + @failure = false + @halted = false + @rolled_back = false end # Internal: Inherited Object/Kernel methods the context itself calls and diff --git a/spec/interactor/context_spec.rb b/spec/interactor/context_spec.rb index c867445..c1f1b1d 100644 --- a/spec/interactor/context_spec.rb +++ b/spec/interactor/context_spec.rb @@ -599,6 +599,32 @@ module Interactor end end + describe "object shape" do + # All construction paths must assign the same instance variables in the + # same order so contexts share one object shape; otherwise @table and + # flag reads go polymorphic (slower) under YJIT. + it "is identical regardless of state or construction path" do + fresh = Context.build(a: 1) + failed = Context.build(a: 1) + begin + failed.fail! + rescue + nil + end + halted = Context.build(a: 1) + begin + halted.halt! + rescue + nil + end + duped = fresh.dup + loaded = Marshal.load(Marshal.dump(fresh)) + + shapes = [fresh, failed, halted, duped, loaded].map(&:instance_variables) + expect(shapes.uniq.size).to eq(1) + end + end + describe "#to_s" do it "matches #inspect so attribute info is not lost" do context = Context.build(foo: "bar")