diff --git a/src/assets/AssetGraph.ts b/src/assets/AssetGraph.ts index 85b93ae..bd88baa 100644 --- a/src/assets/AssetGraph.ts +++ b/src/assets/AssetGraph.ts @@ -20,6 +20,27 @@ export class AssetGraph { this.ownerToSessions.set(owner, sessions); } + hasSession(sessionId: string): boolean { + return this.sessions.has(sessionId); + } + + removeSession(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (!session) { + return; + } + + this.sessions.delete(sessionId); + const sessions = this.ownerToSessions.get(session.owner); + if (!sessions) { + return; + } + sessions.delete(sessionId); + if (sessions.size === 0) { + this.ownerToSessions.delete(session.owner); + } + } + trackBundle(sessionId: string, bundleName: string): void { const session = this.sessions.get(sessionId); if (!session) { diff --git a/src/assets/AssetSession.ts b/src/assets/AssetSession.ts index c5e8a51..6720750 100644 --- a/src/assets/AssetSession.ts +++ b/src/assets/AssetSession.ts @@ -5,6 +5,7 @@ export class AssetSession { readonly id: string; readonly owner: string; private manager: AssetManager; + private closed = false; constructor(manager: AssetManager, id: string, owner: string) { this.manager = manager; @@ -16,17 +17,34 @@ export class AssetSession { name: string, onProgress?: (progress: number) => void ): Promise { + this.assertOpen(); return this.manager.acquireBySession(this.id, name, onProgress); } releaseBundle(name: string): Promise { + this.assertOpen(); return this.manager.releaseBySession(this.id, name); } async releaseAll(): Promise { + this.assertOpen(); const bundles = this.manager.getSessionBundles(this.id); for (const bundle of bundles) { await this.releaseBundle(bundle); } } + + async destroy(): Promise { + if (this.closed) { + return; + } + this.closed = true; + await this.manager.destroySession(this.id); + } + + private assertOpen(): void { + if (this.closed) { + throw new Error(`AssetSession "${this.id}" is already destroyed`); + } + } } diff --git a/src/core/AssetManager.ts b/src/core/AssetManager.ts index b738cd9..e5af916 100644 --- a/src/core/AssetManager.ts +++ b/src/core/AssetManager.ts @@ -32,9 +32,11 @@ export class AssetManager { private adapter: AssetRuntimeAdapter; private bundleRefCounts: Map = new Map(); private bundleCache: Map = new Map(); + private pendingLoads: Map> = new Map(); private sessionBundleRefs: Map> = new Map(); private graph: AssetGraph = new AssetGraph(); private sessionIdSeed = 0; + private defaultSessionId = ""; private defaultSession: AssetSession; constructor(adapter?: AssetRuntimeAdapter) { @@ -57,6 +59,9 @@ export class AssetManager { const sessionId = `session-${this.sessionIdSeed}`; this.graph.registerSession(sessionId, normalizedOwner); this.sessionBundleRefs.set(sessionId, new Map()); + if (this.defaultSessionId === "") { + this.defaultSessionId = sessionId; + } return new AssetSession(this, sessionId, normalizedOwner); } @@ -68,6 +73,23 @@ export class AssetManager { return this.graph.getSessionBundles(sessionId); } + async destroySession(sessionId: string): Promise { + if (!this.graph.hasSession(sessionId)) { + return; + } + if (sessionId === this.defaultSessionId) { + throw new Error("AssetManager: default session cannot be destroyed"); + } + + const bundles = this.getSessionBundles(sessionId); + for (const bundle of bundles) { + await this.releaseBySession(sessionId, bundle); + } + + this.sessionBundleRefs.delete(sessionId); + this.graph.removeSession(sessionId); + } + async acquireBySession( sessionId: string, name: string, @@ -86,29 +108,71 @@ export class AssetManager { const totalRefCount = this.bundleRefCounts.get(name) ?? 0; const sessionRefCount = sessionRefs.get(name) ?? 0; + this.bundleRefCounts.set(name, totalRefCount + 1); + sessionRefs.set(name, sessionRefCount + 1); + this.graph.trackBundle(sessionId, name); + + const pendingLoad = this.pendingLoads.get(name); + if (pendingLoad) { + const bundle = await pendingLoad; + if (!bundle) { + this.rollbackFailedAcquire(sessionId, name); + } + return bundle; + } + if (totalRefCount > 0) { - this.bundleRefCounts.set(name, totalRefCount + 1); - sessionRefs.set(name, sessionRefCount + 1); - this.graph.trackBundle(sessionId, name); logger.debug( `AssetManager: reuse bundle "${name}", refCount = ${totalRefCount + 1}` ); onProgress?.(1); - return this.bundleCache.get(name) ?? this.adapter.loadBundle(name); + return this.bundleCache.get(name) ?? null; } logger.debug(`AssetManager: loading bundle "${name}"`); - try { - const bundle = await this.adapter.loadBundle(name, onProgress); - this.bundleRefCounts.set(name, 1); - this.bundleCache.set(name, bundle); - sessionRefs.set(name, 1); - this.graph.trackBundle(sessionId, name); - return bundle; - } catch (error) { - logger.error(`AssetManager: Failed to load bundle "${name}":`, error); + const loadPromise = this.adapter + .loadBundle(name, onProgress) + .then((bundle) => { + this.bundleCache.set(name, bundle); + return bundle; + }) + .catch((error) => { + logger.error(`AssetManager: Failed to load bundle "${name}":`, error); + return null; + }) + .finally(() => { + this.pendingLoads.delete(name); + }); + this.pendingLoads.set(name, loadPromise); + + const bundle = await loadPromise; + if (!bundle) { + this.rollbackFailedAcquire(sessionId, name); return null; } + return bundle; + } + + private rollbackFailedAcquire(sessionId: string, name: string): void { + const sessionRefs = this.sessionBundleRefs.get(sessionId); + if (!sessionRefs) { + return; + } + + const sessionRefCount = sessionRefs.get(name) ?? 0; + if (sessionRefCount <= 1) { + sessionRefs.delete(name); + this.graph.untrackBundle(sessionId, name); + } else { + sessionRefs.set(name, sessionRefCount - 1); + } + + const totalRefCount = (this.bundleRefCounts.get(name) ?? 0) - 1; + if (totalRefCount <= 0) { + this.bundleRefCounts.delete(name); + return; + } + this.bundleRefCounts.set(name, totalRefCount); } async releaseBySession(sessionId: string, name: string): Promise { @@ -181,9 +245,11 @@ export class AssetManager { clearAll(): void { this.bundleRefCounts.clear(); this.bundleCache.clear(); + this.pendingLoads.clear(); this.sessionBundleRefs.clear(); this.sessionIdSeed = 0; this.graph = new AssetGraph(); + this.defaultSessionId = ""; this.defaultSession = this.createSession("global"); logger.debug("AssetManager: Reference count map cleared"); } diff --git a/tests/assets/asset-session.test.ts b/tests/assets/asset-session.test.ts index 83010a8..99c27be 100644 --- a/tests/assets/asset-session.test.ts +++ b/tests/assets/asset-session.test.ts @@ -4,6 +4,35 @@ import { AssetManager } from "@/core/AssetManager"; type FakeBundle = Record; describe("AssetSession", () => { + it("deduplicates concurrent first-time bundle loads", async () => { + const loadCalls: string[] = []; + let resolveLoad: ((bundle: FakeBundle) => void) | null = null; + const manager = new AssetManager({ + init: async () => undefined, + loadBundle: (name: string) => + new Promise((resolve) => { + loadCalls.push(name); + resolveLoad = resolve; + }), + unloadBundle: async () => undefined, + }); + + const sessionA = manager.createSession("scene"); + const sessionB = manager.createSession("ui"); + + const pendingA = sessionA.loadBundle("shared"); + const pendingB = sessionB.loadBundle("shared"); + + expect(loadCalls).toEqual(["shared"]); + + resolveLoad?.({ shared: true }); + const [bundleA, bundleB] = await Promise.all([pendingA, pendingB]); + + expect(bundleA).toEqual({ shared: true }); + expect(bundleB).toEqual({ shared: true }); + expect(manager.getRefCount("shared")).toBe(2); + }); + it("tracks owner/session relationships in inspector snapshot", async () => { const loadCalls: string[] = []; const unloadCalls: string[] = []; @@ -65,4 +94,31 @@ describe("AssetSession", () => { expect(snapshot.bundles).toEqual({}); expect(unloadCalls.sort()).toEqual(["fx", "music"]); }); + + it("cleans up destroyed sessions and keeps mixed default/explicit semantics consistent", async () => { + const unloadCalls: string[] = []; + const manager = new AssetManager({ + init: async () => undefined, + loadBundle: async (name: string) => ({ name }) as FakeBundle, + unloadBundle: async (name: string) => { + unloadCalls.push(name); + }, + }); + + const explicit = manager.createSession("scene"); + await manager.loadBundle("shared"); + await explicit.loadBundle("shared"); + + await manager.unloadBundle("shared"); + expect(unloadCalls).toEqual([]); + expect(manager.getInspectorSnapshot().activeBundles).toEqual(["shared"]); + + await explicit.destroy(); + const snapshot = manager.getInspectorSnapshot(); + + expect(snapshot.sessions[explicit.id]).toBeUndefined(); + expect(snapshot.owners.scene).toBeUndefined(); + expect(snapshot.activeBundles).toEqual([]); + expect(unloadCalls).toEqual(["shared"]); + }); });