Browse Source

fix(assets): dedupe concurrent loads and close session lifecycle

Made-with: Cursor
master
npmrun 2 weeks ago
parent
commit
5c9ba882bb
  1. 21
      src/assets/AssetGraph.ts
  2. 18
      src/assets/AssetSession.ts
  3. 92
      src/core/AssetManager.ts
  4. 56
      tests/assets/asset-session.test.ts

21
src/assets/AssetGraph.ts

@ -20,6 +20,27 @@ export class AssetGraph {
this.ownerToSessions.set(owner, sessions); 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 { trackBundle(sessionId: string, bundleName: string): void {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) { if (!session) {

18
src/assets/AssetSession.ts

@ -5,6 +5,7 @@ export class AssetSession {
readonly id: string; readonly id: string;
readonly owner: string; readonly owner: string;
private manager: AssetManager; private manager: AssetManager;
private closed = false;
constructor(manager: AssetManager, id: string, owner: string) { constructor(manager: AssetManager, id: string, owner: string) {
this.manager = manager; this.manager = manager;
@ -16,17 +17,34 @@ export class AssetSession {
name: string, name: string,
onProgress?: (progress: number) => void onProgress?: (progress: number) => void
): Promise<AssetsBundle | null> { ): Promise<AssetsBundle | null> {
this.assertOpen();
return this.manager.acquireBySession(this.id, name, onProgress); return this.manager.acquireBySession(this.id, name, onProgress);
} }
releaseBundle(name: string): Promise<void> { releaseBundle(name: string): Promise<void> {
this.assertOpen();
return this.manager.releaseBySession(this.id, name); return this.manager.releaseBySession(this.id, name);
} }
async releaseAll(): Promise<void> { async releaseAll(): Promise<void> {
this.assertOpen();
const bundles = this.manager.getSessionBundles(this.id); const bundles = this.manager.getSessionBundles(this.id);
for (const bundle of bundles) { for (const bundle of bundles) {
await this.releaseBundle(bundle); await this.releaseBundle(bundle);
} }
} }
async destroy(): Promise<void> {
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`);
}
}
} }

92
src/core/AssetManager.ts

@ -32,9 +32,11 @@ export class AssetManager {
private adapter: AssetRuntimeAdapter; private adapter: AssetRuntimeAdapter;
private bundleRefCounts: Map<string, number> = new Map(); private bundleRefCounts: Map<string, number> = new Map();
private bundleCache: Map<string, AssetsBundle> = new Map(); private bundleCache: Map<string, AssetsBundle> = new Map();
private pendingLoads: Map<string, Promise<AssetsBundle | null>> = new Map();
private sessionBundleRefs: Map<string, Map<string, number>> = new Map(); private sessionBundleRefs: Map<string, Map<string, number>> = new Map();
private graph: AssetGraph = new AssetGraph(); private graph: AssetGraph = new AssetGraph();
private sessionIdSeed = 0; private sessionIdSeed = 0;
private defaultSessionId = "";
private defaultSession: AssetSession; private defaultSession: AssetSession;
constructor(adapter?: AssetRuntimeAdapter) { constructor(adapter?: AssetRuntimeAdapter) {
@ -57,6 +59,9 @@ export class AssetManager {
const sessionId = `session-${this.sessionIdSeed}`; const sessionId = `session-${this.sessionIdSeed}`;
this.graph.registerSession(sessionId, normalizedOwner); this.graph.registerSession(sessionId, normalizedOwner);
this.sessionBundleRefs.set(sessionId, new Map()); this.sessionBundleRefs.set(sessionId, new Map());
if (this.defaultSessionId === "") {
this.defaultSessionId = sessionId;
}
return new AssetSession(this, sessionId, normalizedOwner); return new AssetSession(this, sessionId, normalizedOwner);
} }
@ -68,6 +73,23 @@ export class AssetManager {
return this.graph.getSessionBundles(sessionId); return this.graph.getSessionBundles(sessionId);
} }
async destroySession(sessionId: string): Promise<void> {
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( async acquireBySession(
sessionId: string, sessionId: string,
name: string, name: string,
@ -86,29 +108,71 @@ export class AssetManager {
const totalRefCount = this.bundleRefCounts.get(name) ?? 0; const totalRefCount = this.bundleRefCounts.get(name) ?? 0;
const sessionRefCount = sessionRefs.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) { if (totalRefCount > 0) {
this.bundleRefCounts.set(name, totalRefCount + 1);
sessionRefs.set(name, sessionRefCount + 1);
this.graph.trackBundle(sessionId, name);
logger.debug( logger.debug(
`AssetManager: reuse bundle "${name}", refCount = ${totalRefCount + 1}` `AssetManager: reuse bundle "${name}", refCount = ${totalRefCount + 1}`
); );
onProgress?.(1); onProgress?.(1);
return this.bundleCache.get(name) ?? this.adapter.loadBundle(name); return this.bundleCache.get(name) ?? null;
} }
logger.debug(`AssetManager: loading bundle "${name}"`); logger.debug(`AssetManager: loading bundle "${name}"`);
try { const loadPromise = this.adapter
const bundle = await this.adapter.loadBundle(name, onProgress); .loadBundle(name, onProgress)
this.bundleRefCounts.set(name, 1); .then((bundle) => {
this.bundleCache.set(name, bundle); this.bundleCache.set(name, bundle);
sessionRefs.set(name, 1); return bundle;
this.graph.trackBundle(sessionId, name); })
return bundle; .catch((error) => {
} catch (error) { logger.error(`AssetManager: Failed to load bundle "${name}":`, 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 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<void> { async releaseBySession(sessionId: string, name: string): Promise<void> {
@ -181,9 +245,11 @@ export class AssetManager {
clearAll(): void { clearAll(): void {
this.bundleRefCounts.clear(); this.bundleRefCounts.clear();
this.bundleCache.clear(); this.bundleCache.clear();
this.pendingLoads.clear();
this.sessionBundleRefs.clear(); this.sessionBundleRefs.clear();
this.sessionIdSeed = 0; this.sessionIdSeed = 0;
this.graph = new AssetGraph(); this.graph = new AssetGraph();
this.defaultSessionId = "";
this.defaultSession = this.createSession("global"); this.defaultSession = this.createSession("global");
logger.debug("AssetManager: Reference count map cleared"); logger.debug("AssetManager: Reference count map cleared");
} }

56
tests/assets/asset-session.test.ts

@ -4,6 +4,35 @@ import { AssetManager } from "@/core/AssetManager";
type FakeBundle = Record<string, unknown>; type FakeBundle = Record<string, unknown>;
describe("AssetSession", () => { 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<FakeBundle>((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 () => { it("tracks owner/session relationships in inspector snapshot", async () => {
const loadCalls: string[] = []; const loadCalls: string[] = [];
const unloadCalls: string[] = []; const unloadCalls: string[] = [];
@ -65,4 +94,31 @@ describe("AssetSession", () => {
expect(snapshot.bundles).toEqual({}); expect(snapshot.bundles).toEqual({});
expect(unloadCalls.sort()).toEqual(["fx", "music"]); 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"]);
});
}); });

Loading…
Cancel
Save