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. 82
      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);
}
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) {

18
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<AssetsBundle | null> {
this.assertOpen();
return this.manager.acquireBySession(this.id, name, onProgress);
}
releaseBundle(name: string): Promise<void> {
this.assertOpen();
return this.manager.releaseBySession(this.id, name);
}
async releaseAll(): Promise<void> {
this.assertOpen();
const bundles = this.manager.getSessionBundles(this.id);
for (const bundle of bundles) {
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`);
}
}
}

82
src/core/AssetManager.ts

@ -32,9 +32,11 @@ export class AssetManager {
private adapter: AssetRuntimeAdapter;
private bundleRefCounts: Map<string, number> = 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 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<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(
sessionId: string,
name: string,
@ -86,29 +108,71 @@ export class AssetManager {
const totalRefCount = this.bundleRefCounts.get(name) ?? 0;
const sessionRefCount = sessionRefs.get(name) ?? 0;
if (totalRefCount > 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) {
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);
const loadPromise = this.adapter
.loadBundle(name, onProgress)
.then((bundle) => {
this.bundleCache.set(name, bundle);
sessionRefs.set(name, 1);
this.graph.trackBundle(sessionId, name);
return bundle;
} catch (error) {
})
.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<void> {
@ -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");
}

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

@ -4,6 +4,35 @@ import { AssetManager } from "@/core/AssetManager";
type FakeBundle = Record<string, unknown>;
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 () => {
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"]);
});
});

Loading…
Cancel
Save