Skip to content

fix(core): release shader value cache & subtexture listeners on destroy#56

Merged
chiefcll merged 1 commit into
mainfrom
fix/lifecycle-memory-leaks
Jun 4, 2026
Merged

fix(core): release shader value cache & subtexture listeners on destroy#56
chiefcll merged 1 commit into
mainfrom
fix/lifecycle-memory-leaks

Conversation

@chiefcll
Copy link
Copy Markdown
Contributor

@chiefcll chiefcll commented Jun 4, 2026

Background

While fixing the unbounded SDF text layout cache (#55), I audited the codebase for the same class of leak — things that grow over a long-lived session (embedded/set-top, page never reloaded) as nodes and textures are created/destroyed during navigation. This PR fixes the two HIGH-severity leaks found that occur per-node during normal navigation. (Two MEDIUM findings — colorCache and normalizedMetrics — are noted below as follow-ups.)

Leak 1 — shader value cache never released on node destroy

CoreShaderManager.valuesCache / valuesCacheUsage are keyed by resolved shader props + node width/height. Usage is incremented when values are first computed and only decremented in the shader node's update() when the key changes. The idle cleanup() only evicts entries at usage <= 0, so a destroyed node's last value-cache entry stayed pinned at usage >= 1 forever and could never be reclaimed.

This affects nearly every built-in shader that defines an update (RoundedRectangle, Border, Shadow, the gradients, HolePunch, RadialProgress…), so any shader-styled node leaks an entry on destroy.

Fix: move the held valueKey onto the base CoreShaderNode and add detachNode(), which decrements that key's usage and detaches from the node. CoreNode.destroy() calls it for non-default (per-node) shaders — the shared default shader is skipped. This mirrors the existing prevKey release done on every key change in update().

Leak 2 — SubTexture never unsubscribed from its parent atlas

SubTexture attaches 4 listeners (loading/loaded/failed/freed) to its parent texture, which is typically a long-lived shared atlas (preventCleanup). It had no destroy() override, so every destroyed SubTexture — and everything its handlers captured — was retained by the parent's listener lists for the rest of the session.

Fix: SubTexture.destroy() now off()s the four handlers before super.destroy(); Texture.destroy() additionally calls removeAllListeners() defensively so any texture destroyed while something still holds a listener doesn't retain it.

Testing

  • pnpm build clean; full unit suite green; lint clean (lint-staged on commit).
  • New tests:
    • CoreShaderNode.test.tsdetachNode() decrements the held key, clears it, is a no-op when no key is held, and doesn't double-decrement.
    • SubTexture.test.tsdestroy() detaches all four parent listeners.
  • No visual regression test — no rendering-output change; these are teardown/memory fixes.

Follow-ups (not in this PR)

  • colorCache (src/core/lib/colorCache.ts) — unbounded Map<number,string>, Canvas2D path only. Bound with the same idle-LRU pattern as feat(text): bound SDF text layout cache with idle LRU eviction #55 if the Canvas backend is used.
  • normalizedMetrics (font handlers) — not cleared on unloadFont; bounded for a fixed font set but grows with fractional/scaled sizes.

🤖 Generated with Claude Code

…troy

Two lifecycle leaks that accumulate over a long-lived session (embedded
device, page never reloaded) as nodes/textures are created and destroyed
during navigation:

1. Shader value cache never released on node destroy.
   CoreShaderManager.valuesCache/valuesCacheUsage are keyed by resolved shader
   props + node size. Usage is incremented when values are computed and only
   decremented in the shader node's update() when the key changes, so the idle
   cleanup() (which evicts entries at usage <= 0) could never reclaim a
   destroyed node's last entry — it stayed pinned at usage >= 1 forever. This
   affects nearly every built-in shader (RoundedRectangle, Border, Shadow,
   gradients, ...).
   Fix: move the held `valueKey` onto the base CoreShaderNode and add
   detachNode(), which decrements its usage and detaches from the node.
   CoreNode.destroy() calls it for non-default (per-node) shaders.

2. SubTexture never unsubscribed from its parent atlas.
   SubTexture attaches 4 listeners (loading/loaded/failed/freed) to the parent
   texture, which is typically a long-lived shared atlas (preventCleanup), but
   had no destroy() override — so every destroyed SubTexture (and everything
   its handlers captured) was retained by the parent's listener lists.
   Fix: SubTexture.destroy() off()s the four handlers; Texture.destroy() now
   also removeAllListeners() defensively.

Found via a memory-leak audit prompted by the SDF text-cache fix (#55).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@chiefcll chiefcll merged commit 844fec6 into main Jun 4, 2026
1 check passed
@chiefcll chiefcll deleted the fix/lifecycle-memory-leaks branch June 4, 2026 22:35
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