diff --git a/src/common/EventEmitter.ts b/src/common/EventEmitter.ts index 6847695..994c7e2 100644 --- a/src/common/EventEmitter.ts +++ b/src/common/EventEmitter.ts @@ -70,6 +70,27 @@ export class EventEmitter implements IEventEmitter { }); } + /** + * Whether any listener is currently registered for any event. + * + * @remarks + * Used as a liveness signal: a `Texture` with no listeners has no + * `CoreNode` (or `SubTexture`) subscribed to it. + */ + hasListeners(): boolean { + const map = this.eventListeners; + if (map === null) { + return false; + } + for (const event in map) { + const listeners = map[event]; + if (listeners !== undefined && listeners.length > 0) { + return true; + } + } + return false; + } + removeAllListeners() { this.eventListeners = null; } diff --git a/src/core/TextureMemoryManager.test.ts b/src/core/TextureMemoryManager.test.ts index bf6cd1c..f5692de 100644 --- a/src/core/TextureMemoryManager.test.ts +++ b/src/core/TextureMemoryManager.test.ts @@ -5,6 +5,7 @@ import { } from './TextureMemoryManager.js'; import type { Stage } from './Stage.js'; import { TextureType, type Texture } from './textures/Texture.js'; +import { EventEmitter } from '../common/EventEmitter.js'; function makeSettings( overrides: Partial = {}, @@ -20,23 +21,38 @@ function makeSettings( }; } -// The only Stage method the OOM path touches is queueFrameEvent. +// The OOM path touches queueFrameEvent; cleanup() additionally sweeps the +// texture manager's keyCache and evicts orphans via removeTextureFromCache. function makeStage(): { stage: Stage; queueFrameEvent: ReturnType; + keyCache: Map; } { const queueFrameEvent = vi.fn(); - const stage = { queueFrameEvent } as unknown as Stage; - return { stage, queueFrameEvent }; + const keyCache = new Map(); + const txManager = { + keyCache, + removeTextureFromCache: (texture: Texture) => { + // Mirrors CoreTextureManager.removeTextureFromCache + const cacheKey = texture.cacheKey; + if (cacheKey !== null) { + keyCache.delete(cacheKey); + texture.cacheKey = null; + } + }, + }; + const stage = { queueFrameEvent, txManager } as unknown as Stage; + return { stage, queueFrameEvent, keyCache }; } function makeManager(overrides: Partial = {}): { mgr: TextureMemoryManager; queueFrameEvent: ReturnType; + keyCache: Map; } { - const { stage, queueFrameEvent } = makeStage(); + const { stage, queueFrameEvent, keyCache } = makeStage(); const mgr = new TextureMemoryManager(stage, makeSettings(overrides)); - return { mgr, queueFrameEvent }; + return { mgr, queueFrameEvent, keyCache }; } // setTextureMemUse expects a Texture with a mutable memUsed field; nothing else @@ -145,3 +161,254 @@ describe('TextureMemoryManager — cleanup is reversible', () => { expect(texture.memUsed).toBe(0); }); }); + +// A cached, already-freed texture as left behind by a prior cleanup(): GPU and +// CPU data released (memUsed 0, not in loadedTextures), but the Texture object +// still sits in the keyCache. Built on a real EventEmitter so hasListeners() +// reflects actual on()/off() subscriptions, exactly like CoreNode's +// loadTextureTask/unloadTexture. +function freedCachedTexture(cacheKey: string): Texture & { + destroy: ReturnType; +} { + const texture = Object.assign(new EventEmitter(), { + memUsed: 0, + state: 'freed', + type: TextureType.image, + preventCleanup: false, + renderableOwners: [], + cacheKey, + free: vi.fn(), + destroy: vi.fn(), + canBeCleanedUp: () => true, + // Fresh texture by default — within the 2s startup grace period. + isWithinStartupGracePeriod: () => true, + }); + return texture as unknown as Texture & { + destroy: ReturnType; + }; +} + +describe('TextureMemoryManager — orphaned freed texture eviction', () => { + it('keeps a freed texture that a node still references via listeners', () => { + const { mgr, keyCache } = makeManager(); + const texture = freedCachedTexture('img:poster.png'); + keyCache.set('img:poster.png', texture); + // CoreNode.loadTextureTask subscribes; the node is alive but offscreen. + texture.on('freed', () => {}); + + mgr.cleanup(); + + expect(texture.destroy).not.toHaveBeenCalled(); + expect(keyCache.get('img:poster.png')).toBe(texture); + expect(texture.cacheKey).toBe('img:poster.png'); + }); + + it('keeps a freed texture that still has renderable owners', () => { + const { mgr, keyCache } = makeManager(); + const texture = freedCachedTexture('img:poster.png'); + (texture.renderableOwners as unknown[]).push(1); + keyCache.set('img:poster.png', texture); + + mgr.cleanup(); + + expect(texture.destroy).not.toHaveBeenCalled(); + expect(keyCache.get('img:poster.png')).toBe(texture); + }); + + it('keeps a freed texture marked preventCleanup', () => { + const { mgr, keyCache } = makeManager(); + const texture = freedCachedTexture('img:poster.png'); + (texture as { preventCleanup: boolean }).preventCleanup = true; + keyCache.set('img:poster.png', texture); + + mgr.cleanup(); + + expect(texture.destroy).not.toHaveBeenCalled(); + expect(keyCache.get('img:poster.png')).toBe(texture); + }); + + it('does not evict loaded or in-flight cached textures, even aged orphans', () => { + const { mgr, keyCache } = makeManager(); + const states = ['loaded', 'fetching', 'fetched', 'loading']; + const textures: ReturnType[] = []; + for (const state of states) { + const texture = freedCachedTexture(`img:${state}.png`); + (texture as { state: string }).state = state; + // Aged out — eviction must be blocked by state alone. + ( + texture as { isWithinStartupGracePeriod: () => boolean } + ).isWithinStartupGracePeriod = () => false; + keyCache.set(`img:${state}.png`, texture); + textures.push(texture); + } + + mgr.cleanup(); + + for (const texture of textures) { + expect(texture.destroy).not.toHaveBeenCalled(); + } + expect(keyCache.size).toBe(states.length); + }); + + it('keeps a fresh initial texture during the startup grace period', () => { + // A node created this same frame subscribes in a queued microtask, so a + // brand-new texture can look orphaned during a same-frame cleanup. The + // grace period is the race guard. + const { mgr, keyCache } = makeManager(); + const texture = freedCachedTexture('img:fresh.png'); + (texture as { state: string }).state = 'initial'; + + keyCache.set('img:fresh.png', texture); + mgr.cleanup(); + + expect(texture.destroy).not.toHaveBeenCalled(); + expect(keyCache.get('img:fresh.png')).toBe(texture); + }); + + it('evicts an orphaned initial texture once the grace period expires', () => { + const { mgr, keyCache } = makeManager(); + const texture = freedCachedTexture('img:never-loaded.png'); + (texture as { state: string }).state = 'initial'; + ( + texture as { isWithinStartupGracePeriod: () => boolean } + ).isWithinStartupGracePeriod = () => false; + + keyCache.set('img:never-loaded.png', texture); + mgr.cleanup(); + + expect(texture.destroy).toHaveBeenCalledTimes(1); + expect(keyCache.has('img:never-loaded.png')).toBe(false); + expect(texture.cacheKey).toBe(null); + }); + + it('evicts an orphaned failed texture once the grace period expires', () => { + // Retry only happens through a node's 'failed' listener — with no + // listeners nothing will ever retry it. + const { mgr, keyCache } = makeManager(); + const texture = freedCachedTexture('img:404.png'); + (texture as { state: string }).state = 'failed'; + ( + texture as { isWithinStartupGracePeriod: () => boolean } + ).isWithinStartupGracePeriod = () => false; + + keyCache.set('img:404.png', texture); + mgr.cleanup(); + + expect(texture.destroy).toHaveBeenCalledTimes(1); + expect(keyCache.has('img:404.png')).toBe(false); + }); + + it('keeps an aged initial texture that a node references via listeners', () => { + const { mgr, keyCache } = makeManager(); + const texture = freedCachedTexture('img:queued.png'); + (texture as { state: string }).state = 'initial'; + ( + texture as { isWithinStartupGracePeriod: () => boolean } + ).isWithinStartupGracePeriod = () => false; + texture.on('loaded', () => {}); + + keyCache.set('img:queued.png', texture); + mgr.cleanup(); + + expect(texture.destroy).not.toHaveBeenCalled(); + expect(keyCache.get('img:queued.png')).toBe(texture); + }); + + it('destroys and evicts an orphaned freed texture', () => { + const { mgr, keyCache } = makeManager(); + const texture = freedCachedTexture('img:gone.png'); + keyCache.set('img:gone.png', texture); + + mgr.cleanup(); + + expect(texture.destroy).toHaveBeenCalledTimes(1); + expect(keyCache.has('img:gone.png')).toBe(false); + expect(texture.cacheKey).toBe(null); + }); + + it('evicts only once the last listener is removed (node destroyed)', () => { + const { mgr, keyCache } = makeManager(); + const texture = freedCachedTexture('img:row-item.png'); + keyCache.set('img:row-item.png', texture); + const onFreed = () => {}; + texture.on('freed', onFreed); + + mgr.cleanup(); + expect(texture.destroy).not.toHaveBeenCalled(); + expect(keyCache.has('img:row-item.png')).toBe(true); + + // Node destroyed: CoreNode.unloadTexture removes its subscriptions. + texture.off('freed', onFreed); + + mgr.cleanup(); + expect(texture.destroy).toHaveBeenCalledTimes(1); + expect(keyCache.has('img:row-item.png')).toBe(false); + }); + + it('evicts orphans freed by the same cleanup pass', () => { + const { mgr, keyCache } = makeManager({ criticalThreshold: 200e6 }); + // A loaded, cleanable texture whose free() transitions it to 'freed', + // mirroring ctxTexture.free() -> setState('freed'). No listeners and no + // owners: its node was already destroyed. + const texture = freedCachedTexture('img:orphan.png'); + (texture as { state: string }).state = 'loaded'; + (texture as { free: () => void }).free = () => { + (texture as { state: string }).state = 'freed'; + }; + keyCache.set('img:orphan.png', texture); + mgr.setTextureMemUse(texture, 100e6); + + mgr.cleanup(true); + + expect(texture.destroy).toHaveBeenCalledTimes(1); + expect(keyCache.has('img:orphan.png')).toBe(false); + expect(mgr.getMemoryInfo().memUsed).toBe(26e6); // baseline only + }); +}); + +describe('EventEmitter — hasListeners', () => { + it('is false before any listener is registered', () => { + const emitter = new EventEmitter(); + expect(emitter.hasListeners()).toBe(false); + }); + + it('is true while a listener is registered and false after off()', () => { + const emitter = new EventEmitter(); + const listener = () => {}; + emitter.on('loaded', listener); + expect(emitter.hasListeners()).toBe(true); + + emitter.off('loaded', listener); + expect(emitter.hasListeners()).toBe(false); + }); + + it('is false after off() removes all listeners for an event by name', () => { + const emitter = new EventEmitter(); + emitter.on('loaded', () => {}); + emitter.off('loaded'); + expect(emitter.hasListeners()).toBe(false); + }); + + it('is true if any one of several events still has a listener', () => { + const emitter = new EventEmitter(); + const a = () => {}; + emitter.on('loaded', a); + emitter.on('freed', () => {}); + emitter.off('loaded', a); + expect(emitter.hasListeners()).toBe(true); + }); + + it('is false after a once() listener has fired', () => { + const emitter = new EventEmitter(); + emitter.once('loaded', () => {}); + emitter.emit('loaded'); + expect(emitter.hasListeners()).toBe(false); + }); + + it('is false after removeAllListeners()', () => { + const emitter = new EventEmitter(); + emitter.on('loaded', () => {}); + emitter.removeAllListeners(); + expect(emitter.hasListeners()).toBe(false); + }); +}); diff --git a/src/core/TextureMemoryManager.ts b/src/core/TextureMemoryManager.ts index 86c6bab..7f95f89 100644 --- a/src/core/TextureMemoryManager.ts +++ b/src/core/TextureMemoryManager.ts @@ -205,11 +205,20 @@ export class TextureMemoryManager { } /** - * Destroy a texture and remove it from the memory manager + * Destroy a texture, evict its cache entry, and remove it from the memory + * manager. + * + * @remarks + * Private on purpose: `destroy()` calls `removeAllListeners()`, so running + * this on a texture that still has subscribers severs a live `CoreNode`'s + * (or `SubTexture`'s) connection — the blank-poster bug. The only safe + * entry point is {@link evictOrphanedTextures}, which proves the texture + * is unreferenced first. For memory pressure use {@link freeTexture}, + * which is reversible. * * @param texture - The texture to destroy */ - destroyTexture(texture: Texture) { + private destroyTexture(texture: Texture) { if (this.debugLogging === true) { console.log( `[TextureMemoryManager] Destroying texture. State: ${texture.state}`, @@ -278,6 +287,8 @@ export class TextureMemoryManager { } } + this.evictOrphanedTextures(); + if (this.memUsed >= this.criticalThreshold) { this.stage.queueFrameEvent('criticalCleanupFailed', { memUsed: this.memUsed, @@ -300,6 +311,52 @@ export class TextureMemoryManager { } } + /** + * Destroy-and-evict freed textures that nothing references anymore. + * + * @remarks + * {@link freeTexture} intentionally keeps the texture's cache entry and + * listeners so a live `CoreNode` can reload it in place. But once the last + * referencing node is destroyed (`unloadTexture` removes its listeners and + * owner), the freed texture's `keyCache` entry can never be displayed again + * — without eviction the cache grows unboundedly in apps cycling many + * unique textures. + * + * A texture is an orphan only when it has zero `renderableOwners` AND zero + * event listeners: every live referencer (`CoreNode.loadTextureTask`, + * `SubTexture`) subscribes via `on()`. A texture with any listener must + * NEVER be destroyed here — `destroy()` calls `removeAllListeners()`, which + * would sever the node's subscription and reintroduce the blank-poster bug + * that {@link freeTexture} exists to prevent. + * + * `'initial'` and `'failed'` orphans leak the same way (created or failed, + * then the node was destroyed before the texture ever loaded; nothing will + * ever retry a `'failed'` texture without a listener). They are evicted only + * after the startup grace period: a node created this same frame subscribes + * in a queued microtask, so a fresh texture can look orphaned during a + * same-frame cleanup. In-flight states (`'fetching'`/`'loading'`/`'fetched'`) + * are never evicted; `'loaded'` orphans go through the pressure-driven free + * loop first and are swept here as `'freed'` on a later pass. + */ + private evictOrphanedTextures(): void { + const keyCache = this.stage.txManager.keyCache; + for (const texture of keyCache.values()) { + const state = texture.state; + const evictable = + state === 'freed' || + ((state === 'initial' || state === 'failed') && + texture.isWithinStartupGracePeriod() === false); + if ( + evictable === true && + texture.preventCleanup === false && + texture.renderableOwners.length === 0 && + texture.hasListeners() === false + ) { + this.destroyTexture(texture); + } + } + } + /** * Get the current texture memory usage information *