fix(textures): evict orphaned cached textures during cleanup#95
Merged
Conversation
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>
This was referenced Jun 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds an orphan-eviction sweep to
TextureMemoryManager.cleanup()sokeyCacheentries are bounded again, plus a newEventEmitter.hasListeners()liveness signal and unit tests.Why
Since #87,
cleanup()only ever calls the reversiblefreeTexture()(so textures reload in place), which leftdestroyTexture()with zero callers — nothing evictskeyCacheentries anymore. GPU/CPU image data is still released on free, but each retainedTextureJS 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 everycleanup()and destroy-and-evicts a cached texture only when nothing can ever re-display it:renderableOwners, ANDCoreNode.loadTextureTask,SubTexture— subscribes viaon()and unsubscribes on teardown), ANDpreventCleanup, AND'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 afailedlistener, 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()callsremoveAllListeners(), which would sever a live node's subscription — the exact bug #87 fixed. Nodes only subscribe once inloadTextureTask, 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
src/core/TextureMemoryManager.test.ts: referenced-freed kept (listeners / owners /preventCleanup), orphaned-freed evicted, full lifecycle (kept while subscribed → evicted afteroff()), 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, andhasListeners()acrosson/off/once/removeAllListeners. Eviction fakes are built on a realEventEmitterso listener accounting matchesCoreNodeexactly.vitest run: 396 tests pass.pnpm build, prettier, eslint clean.🤖 Generated with Claude Code