Skip to content

fix(textures): evict orphaned cached textures during cleanup#95

Merged
chiefcll merged 2 commits into
mainfrom
fix/evict-orphaned-cached-textures
Jun 10, 2026
Merged

fix(textures): evict orphaned cached textures during cleanup#95
chiefcll merged 2 commits into
mainfrom
fix/evict-orphaned-cached-textures

Conversation

@chiefcll

Copy link
Copy Markdown
Contributor

What

Adds an orphan-eviction sweep to TextureMemoryManager.cleanup() so keyCache entries are bounded again, plus a new EventEmitter.hasListeners() liveness signal and unit tests.

Why

Since #87, cleanup() only ever calls the reversible freeTexture() (so textures reload in place), which left destroyTexture() with zero callers — nothing evicts keyCache entries anymore. GPU/CPU image data is still released on free, but each retained Texture JS object is a slow leak in long-running TV apps cycling many unique image URLs (infinite catalog rows).

How

evictOrphanedTextures() runs at the end of every cleanup() and destroy-and-evicts a cached texture only when nothing can ever re-display it:

  • zero renderableOwners, AND
  • zero event listeners (every live referencer — CoreNode.loadTextureTask, SubTexture — subscribes via on() and unsubscribes on teardown), AND
  • not preventCleanup, AND
  • in an evictable state:
    • 'freed' — already reclaimed by a prior cleanup, or
    • 'initial' / 'failed' — only after the existing 2s startup grace period, which covers the same-frame race where a brand-new node's listener subscription is still in the microtask queue. Nothing retries a 'failed' texture without a failed listener, so those are pure dead weight.

In-flight states ('fetching'/'loading'/'fetched') are never evicted, so slow network downloads are unaffected (they also hold listeners and usually an owner the entire time). 'loaded' orphans go through the pressure-driven free path first and are swept as 'freed' on a later pass.

Invariant preserved (the #87 blank-poster bug)

A texture with any listener attached is never destroyed here. destroy() calls removeAllListeners(), which would sever a live node's subscription — the exact bug #87 fixed. Nodes only subscribe once in loadTextureTask, so the skip direction is conservative: when referenced, keep.

One benign residual race: a new node can cache-hit an old orphan in the same frame as a cleanup (grace period doesn't protect stale createdAt). Worst case is a lost cache entry → a possible duplicate texture instance; the node subscribes after the destroy, so its listeners survive and the texture still reloads and renders.

Testing

  • 18 new unit tests in src/core/TextureMemoryManager.test.ts: referenced-freed kept (listeners / owners / preventCleanup), orphaned-freed evicted, full lifecycle (kept while subscribed → evicted after off()), same-pass free→evict, fresh 'initial' kept during grace, aged 'initial'/'failed' orphans evicted, aged 'initial' with listeners kept, loaded/in-flight states never evicted, and hasListeners() across on/off/once/removeAllListeners. Eviction fakes are built on a real EventEmitter so listener accounting matches CoreNode exactly.
  • vitest run: 396 tests pass. pnpm build, prettier, eslint clean.
  • No rendering change, so no new visual-regression test; the existing suite covers the reload-in-place path.

🤖 Generated with Claude Code

chiefcll and others added 2 commits June 10, 2026 12:58
Since #87, cleanup() only ever reversibly frees textures, so nothing
evicts keyCache entries anymore — a slow leak of Texture JS objects in
long-running apps that cycle many unique image URLs.

Add an orphan-eviction sweep to TextureMemoryManager.cleanup(): a cached
texture is destroyed and evicted only when nothing can ever re-display
it — zero renderableOwners AND zero event listeners (every live
referencer, CoreNode.loadTextureTask and SubTexture, subscribes via
on()). This preserves the #87 invariant: a texture with any listener is
never destroyed, since destroy()'s removeAllListeners() would sever the
node's subscription and reintroduce the blank-poster bug.

Evictable states are 'freed', plus 'initial'/'failed' orphans once the
existing 2s startup grace period expires — the grace period covers the
same-frame window where a new node's subscription microtask hasn't
flushed yet. In-flight states are never evicted; 'loaded' orphans go
through the pressure-driven free path first.

Adds EventEmitter.hasListeners() as the liveness signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
destroy() severs all listeners, so calling destroyTexture on a texture
that still has subscribers reintroduces the blank-poster bug from #87.
Restrict it to the one caller that proves the orphan precondition
(evictOrphanedTextures) and document freeTexture as the public,
reversible path for memory pressure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@chiefcll chiefcll merged commit f6041ab into main Jun 10, 2026
1 check passed
@chiefcll chiefcll deleted the fix/evict-orphaned-cached-textures branch June 10, 2026 17:03
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