From 4c72c73c90358bbf303082a277e8fa8ecdce857e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 22 May 2026 13:07:33 +0000 Subject: [PATCH 1/2] Guard model loads against scene disposal Loading characters in a tight loop and re-running could leave in-flight LoadAssetContainerAsync calls outliving disposeOldScene(). This caused an unhandled "aborted" rejection, "No scene available" warnings, and a null scene crash in setupMesh. - Attach a benign catch to the readiness promise so aborted loads don't surface as unhandled rejections when no whenModelReady consumer exists. - Bail out of createCharacter/createObject early when no live scene. - Skip the load .then handlers (dispose the container instead) when the scene was disposed or the load aborted mid-flight. https://claude.ai/code/session_01ENptDkxsw6UEFt5BDdydPt --- api/models.js | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/api/models.js b/api/models.js index 286dce7b..c4713342 100644 --- a/api/models.js +++ b/api/models.js @@ -33,6 +33,12 @@ export const flockModels = { } }; + // --- bail if there is no live scene (e.g. called during dispose) --- + if (!flock.scene || flock.scene.isDisposed) { + console.warn("createCharacter: no active scene"); + return "error_no_scene"; + } + // --- validate --- if (!modelName || typeof modelName !== "string" || modelName.length > 100) { console.warn("createCharacter: invalid modelName"); @@ -94,6 +100,11 @@ export const flockModels = { resolveReady = res; rejectReady = rej; }); + // Aborted loads reject this promise; attach a benign catch so the + // rejection never surfaces as an unhandled rejection when no + // whenModelReady consumer attached a handler. Real consumers still + // observe the rejection via their own .then/.catch registrations. + readyPromise.catch(() => {}); flock.modelReadyPromises.set(meshName, readyPromise); // Also register the pre-sanitization name (e.g. "dimnnd monkey" → "dimnndmonkey"). // This lets event handlers declared before createCharacter (which still hold the @@ -131,6 +142,20 @@ export const flockModels = { { signal: flock.abortController?.signal }, ) .then((container) => { + // The scene was disposed / the load was aborted while this + // container was still loading. Drop it without touching the + // (now null) scene. onAbort has already rejected readyPromise + // and released the reserved name. + if (signal?.aborted || !flock.scene || flock.scene.isDisposed) { + try { + container?.dispose?.(); + } catch (_) { + console.warn("Suppressed non-critical error:", _); + } + cleanupAbort(); + return; + } + container.addAllToScene(); const mesh = container.meshes[0]; @@ -303,6 +328,10 @@ export const flockModels = { }; try { + if (!flock.scene || flock.scene.isDisposed) { + console.warn("createObject: no active scene"); + return "error_no_scene"; + } if (flock.maxMeshesReached()) return "error_" + flock.scene.getUniqueId(); let [desiredBase, bKey] = modelId.includes("__") @@ -337,6 +366,7 @@ export const flockModels = { if (flock.modelsBeingLoaded[modelName]) { flock.modelsBeingLoaded[modelName].then(() => { + if (!flock.scene || flock.scene.isDisposed) return; flock._recycleOldestByKey(modelName); const mesh = flock.modelCache[modelName].clone( flock.modelCache[modelName].name, @@ -358,6 +388,17 @@ export const flockModels = { // ... inside the loadPromise.then block ... loadPromise.then((container) => { + // Scene disposed while the container was loading: drop it. + if (!flock.scene || flock.scene.isDisposed) { + try { + container?.dispose?.(); + } catch (_) { + console.warn("Suppressed non-critical error:", _); + } + delete flock.modelsBeingLoaded[modelName]; + return; + } + container.addAllToScene(); container.animationGroups.forEach((ag) => ag.stop()); container.meshes.forEach((m) => { From a008936b779c05ee4c832c95ab277016f73b01af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 22 May 2026 13:19:33 +0000 Subject: [PATCH 2/2] Settle readiness promise on disposed-without-abort path CodeRabbit flagged that createCharacter's disposed-scene branch only disposed the container. When the scene is disposed without an abort, readyPromise never settles and the reserved name leaks, hanging any whenModelReady waiter. Mirror the abort cleanup in that branch. https://claude.ai/code/session_01ENptDkxsw6UEFt5BDdydPt --- api/models.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/api/models.js b/api/models.js index c4713342..2a5aa169 100644 --- a/api/models.js +++ b/api/models.js @@ -144,14 +144,27 @@ export const flockModels = { .then((container) => { // The scene was disposed / the load was aborted while this // container was still loading. Drop it without touching the - // (now null) scene. onAbort has already rejected readyPromise - // and released the reserved name. + // (now null) scene. On the abort path onAbort has already + // rejected readyPromise and released the reserved name; on the + // disposed-without-abort path we must do that cleanup here so + // whenModelReady waiters settle and the name isn't leaked. if (signal?.aborted || !flock.scene || flock.scene.isDisposed) { try { container?.dispose?.(); } catch (_) { console.warn("Suppressed non-critical error:", _); } + if (!signal?.aborted) { + rejectReady(new Error("scene disposed")); + flock.modelReadyPromises.delete(meshName); + if ( + originalBase !== meshName && + flock.modelReadyPromises.get(originalBase) === readyPromise + ) { + flock.modelReadyPromises.delete(originalBase); + } + flock._releaseName?.(meshName); + } cleanupAbort(); return; }