Skip to content

Reuse one adapter harness class instead of one per generation - #78

Merged
ngan merged 1 commit into
mainfrom
np-reuse-adapter-harness
Aug 4, 2026
Merged

Reuse one adapter harness class instead of one per generation#78
ngan merged 1 commit into
mainfrom
np-reuse-adapter-harness

Conversation

@ngan

@ngan ngan commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #77. Independent of it — a second thing that accumulated for the whole run.

Problem

To generate a fixture, the adapter has to run your fixture code inside something that behaves like a test, so transactions and Rails test setup work. Both adapters built a throwaway test class for every generation — and neither could ever be collected.

Two separate roots held onto them:

1. An RSpec constant. ExampleGroup.subclassset_it_upExampleGroups.assign_const(self) (example_group.rb:449) const_sets the group under RSpec::ExampleGroups, so you accumulate FixtureKit, FixtureKit_2, FixtureKit_3… RSpec only clears those in World#reset ("Reset world to 'scratch' before running suite") and RSpec.reset, neither of which fires mid-run.

2. A Rails global. rspec-rails/configuration.rb:88 includes FixtureSupport into every group with no metadata filter, and MinitestAdapter included ActiveRecord::TestFixtures directly. Either path runs:

# active_record/test_fixtures.rb:41
ActiveSupport.run_load_hooks(:active_record_fixtures, self)
# active_support/lazy_load_hooks.rb:76
@loaded[name] << base      # never pruned

Removing the constants alone does not free the classes — I removed all of them, forced GC, and recounted: 40 alive before, 40 after. A heap dump traced the survivors to ActiveSupport.@loaded[:active_record_fixtures].

These classes aren't cheap either: the RSpec harness ends up with 65 own instance methods (37 of them assertion delegators rspec-rails define_methods onto every group) across 30 ancestors, plus a LetDefinitions module, metadata hash, and singleton class.

Fix

Since we don't own either registry — and one of them is private Rails internals — the fix is to stop creating classes to leak. Each adapter builds one harness lazily and reuses it.

The block that closes over the Cache is kept off the shared class:

  • RSpec clears the group's examples after each run, since the Example holds the block.
  • Minitest defines its test method on the instance rather than the class. Minitest::Test#run does self.send self.name (test.rb:91), so a singleton method is enough, and it goes away with the instance.

That leaves the Minitest harness class completely untouched by generation — nothing to define, remove, or clear. Worth noting what isn't available here: ActiveSupport::Testing::Declarative#test is just define_method plus a raise-on-redefinition guard, and Minitest's runnable_methods is pure reflection over /^test_/ names. There's no registry to add to or remove from, so there was never anything to unregister.

Impact

At 300 fixtures:

before after
retained heap 26.5 MB 21.1 MB
live objects 622,161 452,940
allocations 4,336,506 3,987,015
harness classes retained all run 301 1

The last row is the real signal: one per generation before, exactly one total after, regardless of suite size. Per-fixture retained growth drops ~43 KB → ~24 KB, at which point the remainder is RSpec's own per-example-group cost.

Why reusing the harness is safe

Per-run state goes through the instance, not the class — configure_example uses example.example_group_instance.singleton_class (configuration.rb:1582), and with_around_and_singleton_context_hooks uses example_group_instance.singleton_class (example.rb:509). A fresh instance per generation means those die each run.

I snapshotted the RSpec group across three generations: ivars, own methods, ancestors, metadata keys, serialized metadata size, and every hook collection count were identical. Hooks are written to the group, but process subtracts hooks already present in parent_groups first, so it appends once and short-circuits after — idempotent by construction.

Global hook firing is identical reused vs. fresh: before(:context) 0, after(:context) 0, before(:example) 1 per generation, both ways.

I also checked store_before_context_ivars, which copies instance ivars onto a class-level hash — the one path that could carry fixture data onto the group. With four fixtures each setting a 500KB ivar: before_context_ivars is {} and 0 blobs survive. It runs against the instance's singleton, and run_after_context_hooks ends with before_context_ivars.clear.

After 5 real generations the harness has 30 ancestors — same as a pristine group — so Definition#evaluate's context.singleton_class.prepend lands on the instance, not the class.

Known behavior change

Reusing the harness means fixture code that explicitly reaches self.class now mutates a shared class instead of a per-generation one. Demonstrated:

fixture do
  self.class.instance_variable_set(:@leaked, "w" * 300_000)
  self.class.define_method(:"sneaky_#{i}") { :hi }
end
# => @leaked persists; [:sneaky_0, :sneaky_1] accumulate

In practice this is unreachable from normal fixtures: a definition block runs at instance level via instance_exec, and any factory or library code it calls has its own self. Only a literal self.class.… written in a definition is affected. Nothing in this repo does that. Flagging it because it is a real reduction in isolation, not because I expect it to bite.

Testing

  • bundle exec rspec — 197 examples, 0 failures
  • FIXTURE_KIT_INTEGRATION_FRAMEWORK=minitest bundle exec rspec — 197 examples, 0 failures
  • Dummy app suites directly (multiple generations per process, incl. extends chains): minitest 21 runs / 67 assertions, RSpec 23 examples

New specs: harness reuse across executes, only one RSpec::ExampleGroups constant no matter how many runs, no retained example after running, the Minitest test method defined on the instance and not the class, and no per-generation state accumulating on the reused harness class across a success and a raise.

Note for review

ExampleGroup#examples is marked # @private in rspec-core though it is a public method. There is no less-coupled way to drop the example while reusing the group; it is consistent with the existing deliberate use of connection.__send__(:execute_batch, …), and there is an inline comment explaining why it is there.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PQjkiuuGX2t3TSZKpzqpPv

@ngan
ngan force-pushed the np-reuse-adapter-harness branch from d495bc5 to 2f452f4 Compare August 3, 2026 23:40
Both adapters built a throwaway test class for every fixture generation,
and neither could be collected. Two roots held them:

  - RSpec assigns a permanent constant to every example group it builds
    (ExampleGroups.assign_const), and only clears them in World#reset,
    which runs before a suite rather than during one.
  - rspec-rails includes FixtureSupport into every example group with no
    metadata filter, and MinitestAdapter included ActiveRecord::TestFixtures
    directly. Either way that runs
    ActiveSupport.run_load_hooks(:active_record_fixtures, self), which
    appends to ActiveSupport's @loaded array. That array is never pruned.

Removing the constants alone does not free the classes -- verified by
removing them and recounting -- so the fix is to stop building them.
Each adapter now builds one harness lazily and reuses it.

The block that closes over the Cache is kept off the shared class. RSpec
clears the group's examples after each run, since the example holds the
block. Minitest defines its test method on the instance instead of the
class: a test is run by sending its name to the instance, so a singleton
method is enough, and it goes away with the instance. That also leaves
the harness class untouched by generation -- nothing to define, remove,
or clear.

Measured at 300 fixtures: retained heap 26.5 MB -> 21.1 MB, live objects
622,161 -> 452,940, allocations 4,336,506 -> 3,987,015, and harness
classes retained for the whole run 301 -> 1.

Reusing the harness means fixture code that explicitly reaches
`self.class` now mutates a shared class rather than a per-generation one.
Fixture blocks run at instance level, and code they call has its own
`self`, so only a literal `self.class` in a definition is affected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQjkiuuGX2t3TSZKpzqpPv
@ngan
ngan force-pushed the np-reuse-adapter-harness branch from 2f452f4 to 04b6417 Compare August 3, 2026 23:47
@ngan
ngan merged commit 3010f81 into main Aug 4, 2026
18 checks passed
@ngan
ngan deleted the np-reuse-adapter-harness branch August 4, 2026 01:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant