Skip to content

Comments

Fix process-global memory leak in folded_declaration_cache#180

Merged
grosser merged 2 commits intopremailer:masterfrom
sokolikp:fix/folded-declaration-cache-memory-leak
Feb 23, 2026
Merged

Fix process-global memory leak in folded_declaration_cache#180
grosser merged 2 commits intopremailer:masterfrom
sokolikp:fix/folded-declaration-cache-memory-leak

Conversation

@sokolikp
Copy link
Contributor

Problem

In long-lived processes (e.g., Sidekiq workers rendering emails via Premailer), the process-global @folded_declaration_cache on CssParser::Parser accumulates entries from every CSS parse operation and is never cleared, causing unbounded memory growth.

Root cause

CssParser::Parser defined a class-level instance variable:

@folded_declaration_cache = {}
class << self; attr_reader :folded_declaration_cache; end

This hash persists for the lifetime of the Ruby process. While reset! (called in initialize) creates a per-instance @folded_declaration_cache, the class-level variable and its public accessor remained, leaking a global reference that never gets GC'd.

Similarly, CssParser.merge set @folded_declaration_cache = {} at the module level on every call.

Fix

  • Remove the class-level @folded_declaration_cache and its attr_reader from CssParser::Parser
  • Remove the module-level @folded_declaration_cache assignment in CssParser.merge
  • The per-instance cache (set in reset!) is already properly scoped and gets garbage collected with the Parser instance

Tests

Added tests verifying:

  • The cache is per-instance (separate instances have separate caches)
  • The cache does not persist across instances
  • No class-level folded_declaration_cache accessor exists
  • New instances start with an empty cache

In long-lived processes (e.g., Sidekiq workers rendering emails via
Premailer), the process-global `@folded_declaration_cache` on
`CssParser::Parser` accumulates entries from every CSS parse operation
and is never cleared, causing unbounded memory growth.

The class-level `@folded_declaration_cache` (and its `attr_reader`) on
`CssParser::Parser` was defined as a class instance variable that
persists for the lifetime of the process. While `reset!` (called in
`initialize`) creates a per-instance `@folded_declaration_cache`, the
class-level variable and accessor remained, leaking the global
reference.

Similarly, `CssParser.merge` set a module-level
`@folded_declaration_cache = {}` on every call, which also persisted
at the module level.

This commit:
- Removes the class-level `@folded_declaration_cache` and its
  `class << self; attr_reader` from `CssParser::Parser`
- Removes the module-level `@folded_declaration_cache` assignment
  in `CssParser.merge`
- The per-instance cache (set in `reset!`) is already properly scoped
  and gets garbage collected with the `Parser` instance
- Adds tests verifying the cache is per-instance and not class-level

Amp-Thread-ID: https://ampcode.com/threads/T-019c8b1f-829d-70cd-83c5-3409a635e616
Co-authored-by: Amp <amp@ampcode.com>
# Class variable? see http://www.oreillynet.com/ruby/blog/2007/01/nubygems_dont_use_class_variab_1.html
#++
@folded_declaration_cache = {}
class << self; attr_reader :folded_declaration_cache; end
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The class-level @folded_declaration_cache and its accessor were dead code. Nothing ever reads Parser.folded_declaration_cache (the class method). All actual usage (lines 693, 698, 702) references @folded_declaration_cache on self — the instance. The reset! method (called from initialize) creates a fresh per-instance @folded_declaration_cache = {} that shadows the class-level one. So the class-level hash just accumulated stale data forever with nothing reading it. Same story for the @folded_declaration_cache = {} reset in CssParser.merge — it was setting it on the CssParser module object, which nothing ever reads.

# TODO: declaration_hashes should be able to contain a RuleSet
# this should be a Class method
def self.merge(*rule_sets)
@folded_declaration_cache = {}
Copy link
Contributor Author

@sokolikp sokolikp Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line was setting @folded_declaration_cache on the CssParser module object itself — completely separate from both the class-level one on Parser and the per-instance one created in reset!. Three different Ruby objects all had an ivar with the same name, but nothing ever read from this one.

@sokolikp
Copy link
Contributor Author

@grosser I think this one is ready too! Somewhat related to but independent from premailer/premailer#472.

Copy link
Contributor

@grosser grosser left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the tests seem a bit unnecessary, if the code is no longer there then they can go too ?

The tests were asserting the absence of removed code — unnecessary.

Amp-Thread-ID: https://ampcode.com/threads/T-019c8b1f-829d-70cd-83c5-3409a635e616
Co-authored-by: Amp <amp@ampcode.com>
@sokolikp
Copy link
Contributor Author

the tests seem a bit unnecessary, if the code is no longer there then they can go too ?

@grosser updated!

@grosser
Copy link
Contributor

grosser commented Feb 23, 2026

I'm working on fixing CI, then merging this

@sokolikp
Copy link
Contributor Author

(Unrelated) test fix: #181

@grosser grosser merged commit 6a6b8c0 into premailer:master Feb 23, 2026
1 of 7 checks passed
@grosser
Copy link
Contributor

grosser commented Feb 23, 2026

2.0.0

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.

2 participants