import { describe, expect, it, vi } from "vitest"; import { encodeToolcraftModelDocument } from "../../model-import/canonical/model-document-codec"; import { createToolcraftModelAppearanceResourceRef } from "../../model-import/canonical/model-appearance-resource"; import { createModelTexture, createValidModelDocument, createValidModelDocumentV2, } from "../../model-import/canonical/model-document-test-support"; import { digestModelSourceBytes } from "../../model-import/model-source-digest"; import { createToolcraftModelPresentationResourceManager } from "./model-presentation-resource-manager"; import { createToolcraftModelPresentation } from "./model-render-binding"; import { deferred, exportContext } from "./model-render-lifetime.test-support"; import { createToolcraftThreeModelRenderAdapter } from "./three-model-render-adapter"; import { asset, createModelRenderContext, createRenderer, } from "./three-model-render-adapter.test-support"; async function createAdapterFixture() { const document = createValidModelDocument(); const renderer = createRenderer(); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: () => renderer }); const presentation = createToolcraftModelPresentation(asset, { bounds: document.bounds, phase: "clean", purpose: "export", }); const resource = await adapter.create(presentation, createModelRenderContext(document)); const dispose = () => { adapter.dispose(resource); adapter.disposePreparedPreview?.(); }; return { adapter, dispose, renderer, resource }; } describe("model export cancellation boundaries", () => { it("keeps a cancelled acquisition pending until its non-cooperating load settles", async () => { const pending = deferred(); const releaseReachability = vi.fn(); let loadSignal!: AbortSignal; const manager = createToolcraftModelPresentationResourceManager({ resolveResource: (_ref, { signal }) => { loadSignal = signal; return pending.promise; }, retainResourceRef: () => releaseReachability, }); const controller = new AbortController(); let settled = false; const outcome = manager.acquirePresentation("document:a", { purpose: "export", signal: controller.signal, }).catch((error: unknown) => error).finally(() => { settled = true; }); controller.abort(new Error("cancel slow load")); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); expect(loadSignal.aborted).toBe(true); expect(settled).toBe(false); expect(releaseReachability).not.toHaveBeenCalled(); pending.resolve(encodeToolcraftModelDocument(createValidModelDocument())); expect(await outcome).toBe(controller.signal.reason); expect(releaseReachability).toHaveBeenCalledTimes(1); manager.dispose(); }); it("preserves a real resolver rejection when cancellation arrives before its microtask", async () => { const failure = new Error("resolver failed before cancellation"); const manager = createToolcraftModelPresentationResourceManager({ resolveResource: async () => { throw failure; }, }); const controller = new AbortController(); const outcome = manager.acquirePresentation("document:a", { purpose: "export", signal: controller.signal, }).catch((error: unknown) => error); controller.abort(new Error("cancel later")); expect(await outcome).toBe(failure); manager.dispose(); }); it("does not replace a real texture-decoder failure with a later cancellation", async () => { const texture = new Uint8Array([137, 80, 78, 71]); const digest = digestModelSourceBytes(texture); const model = createValidModelDocumentV2({ textures: [createModelTexture({ contentDigest: digest, resourceRef: createToolcraftModelAppearanceResourceRef(digest), })], }); const entered = deferred(); const pending = deferred(); const manager = createToolcraftModelPresentationResourceManager({ resolveResource: async (ref) => ref === "document:a" ? encodeToolcraftModelDocument(model) : texture, createImageBitmap: () => { entered.resolve(); return pending.promise; }, }); const controller = new AbortController(); const outcome = manager.acquirePresentation("document:a", { purpose: "export", signal: controller.signal, }).catch((error: unknown) => error); await entered.promise; const failure = new Error("texture decoder failed"); pending.reject(failure); controller.abort(new Error("cancel after decoder failure")); expect(await outcome).toBe(failure); manager.dispose(); }); it.each([false, true])( "closes a decoded bitmap when cancellation lands before presentation construction (cleanup throws: %s)", async (cleanupThrows) => { const texture = new Uint8Array([137, 80, 78, 71]); const digest = digestModelSourceBytes(texture); const model = createValidModelDocumentV2({ textures: [createModelTexture({ contentDigest: digest, resourceRef: createToolcraftModelAppearanceResourceRef(digest), })], }); const entered = deferred(); const pending = deferred(); const close = vi.fn(() => { if (cleanupThrows) throw new Error("bitmap close failed"); }); const bitmap = { close, height: 2, width: 2 } as ImageBitmap; const releaseReachability = vi.fn(); const manager = createToolcraftModelPresentationResourceManager({ resolveResource: async (ref) => ref === "document:a" ? encodeToolcraftModelDocument(model) : texture, createImageBitmap: () => { entered.resolve(); return pending.promise; }, retainResourceRef: () => releaseReachability, }); const controller = new AbortController(); const outcome = manager.acquirePresentation("document:a", { purpose: "export", signal: controller.signal, }).catch((error: unknown) => error); await entered.promise; pending.resolve(bitmap); // Let the texture helper return its owned bitmap, but pause its consumer. await Promise.resolve(); controller.abort(new Error("cancel after texture helper settled")); expect(await outcome).toBe(controller.signal.reason); expect(close).toHaveBeenCalledTimes(1); expect(releaseReachability).toHaveBeenCalledTimes(1); manager.dispose(); expect(close).toHaveBeenCalledTimes(1); }, ); it("does not cancel or dispose a shared load when only one of two waiters cancels", async () => { const pending = deferred(); let loadSignal!: AbortSignal; const releaseReachability = vi.fn(); const resolveResource = vi.fn((_ref: string, { signal }: { signal: AbortSignal }) => { loadSignal = signal; return pending.promise; }); const manager = createToolcraftModelPresentationResourceManager({ resolveResource, retainResourceRef: () => releaseReachability, }); const controller = new AbortController(); let cancelledSettled = false; const cancelled = manager.acquirePresentation("document:a", { purpose: "export", signal: controller.signal, }).catch((error: unknown) => error).finally(() => { cancelledSettled = true; }); const active = manager.acquirePresentation("document:a", { purpose: "preview", signal: new AbortController().signal, }); controller.abort(new Error("cancel first only")); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); expect(cancelledSettled).toBe(false); expect(loadSignal.aborted).toBe(false); pending.resolve(encodeToolcraftModelDocument(createValidModelDocument())); expect(await cancelled).toBe(controller.signal.reason); const lease = await active; expect(resolveResource).toHaveBeenCalledTimes(1); expect(lease.root.children.length).toBeGreaterThan(0); expect(releaseReachability).not.toHaveBeenCalled(); lease.release(); expect(releaseReachability).toHaveBeenCalledTimes(1); manager.dispose(); }); it("preserves each cancelled waiter's own reason when the shared load stops", async () => { const pending = deferred(); const manager = createToolcraftModelPresentationResourceManager({ resolveResource: () => pending.promise }); const first = new AbortController(); const second = new AbortController(); const outcomes = [first, second].map((controller) => manager.acquirePresentation("document:a", { purpose: "export", signal: controller.signal, }).catch((error: unknown) => error)); first.abort(new Error("first reason")); second.abort(new Error("second reason")); pending.resolve(encodeToolcraftModelDocument(createValidModelDocument())); expect(await outcomes[0]).toBe(first.signal.reason); expect(await outcomes[1]).toBe(second.signal.reason); manager.dispose(); }); it("retains a pending load through manager disposal until actual settlement", async () => { const pending = deferred(); const releaseReachability = vi.fn(); const manager = createToolcraftModelPresentationResourceManager({ resolveResource: () => pending.promise, retainResourceRef: () => releaseReachability, }); const outcome = manager.acquirePresentation("document:a", { purpose: "export", signal: new AbortController().signal, }).catch((error: unknown) => error); manager.dispose(); expect(releaseReachability).not.toHaveBeenCalled(); pending.resolve(encodeToolcraftModelDocument(createValidModelDocument())); expect(await outcome).toMatchObject({ name: "AbortError" }); expect(releaseReachability).toHaveBeenCalledTimes(1); }); it("starts a fresh load for a new waiter while an aborted old load settles", async () => { const oldLoad = deferred(); const newLoad = deferred(); const resolver = vi.fn().mockReturnValueOnce(oldLoad.promise).mockReturnValueOnce(newLoad.promise); const manager = createToolcraftModelPresentationResourceManager({ resolveResource: resolver }); const oldController = new AbortController(); const oldOutcome = manager.acquirePresentation("document:a", { purpose: "export", signal: oldController.signal, }).catch((error: unknown) => error); oldController.abort(new Error("old cancelled")); const fresh = manager.acquirePresentation("document:a", { purpose: "preview", signal: new AbortController().signal, }); const bytes = encodeToolcraftModelDocument(createValidModelDocument()); oldLoad.resolve(bytes); expect(await oldOutcome).toBe(oldController.signal.reason); newLoad.resolve(bytes); const lease = await fresh; expect(lease.root.children.length).toBeGreaterThan(0); lease.release(); expect(manager.inspectResourceCounters().active).toBe(0); manager.dispose(); }); it("retains the caller's exact cancellation reason during presentation acquisition", async () => { const pending = deferred(); const manager = createToolcraftModelPresentationResourceManager({ resolveResource: () => pending.promise, }); const controller = new AbortController(); const reason = new Error("cancel acquisition"); const acquisition = manager.acquirePresentation("document:a", { purpose: "export", signal: controller.signal, }); controller.abort(reason); const outcome = acquisition.catch((error: unknown) => error); pending.resolve(encodeToolcraftModelDocument(createValidModelDocument())); expect(await outcome).toBe(reason); manager.dispose(); }); it("does not render or invoke a callback for an already cancelled adapter export", async () => { const fixture = await createAdapterFixture(); const signal = AbortSignal.abort(new Error("do not render")); const onRendered = vi.fn(); await expect(fixture.adapter.renderExport(fixture.resource, { ...exportContext(signal), onRendered, })).rejects.toBe(signal.reason); expect(fixture.renderer.render).not.toHaveBeenCalled(); expect(onRendered).not.toHaveBeenCalled(); fixture.dispose(); }); it("checks cancellation between renderer output and the draw callback", async () => { const fixture = await createAdapterFixture(); const controller = new AbortController(); fixture.renderer.render.mockImplementation(() => controller.abort(new Error("cancel drawing"))); const onRendered = vi.fn(); await expect(fixture.adapter.renderExport(fixture.resource, { ...exportContext(controller.signal), onRendered, })).rejects.toBe(controller.signal.reason); expect(onRendered).not.toHaveBeenCalled(); fixture.dispose(); }); it("checks cancellation after an asynchronous adapter callback", async () => { const fixture = await createAdapterFixture(); const controller = new AbortController(); const entered = deferred(); const pending = deferred(); const rendering = fixture.adapter.renderExport(fixture.resource, { ...exportContext(controller.signal), onRendered: async () => { entered.resolve(); await pending.promise; }, }); await entered.promise; controller.abort(new Error("cancel callback")); pending.resolve(); await expect(rendering).rejects.toBe(controller.signal.reason); fixture.dispose(); }); it("preserves a real acquisition error when adapter renderer cleanup also fails", async () => { const renderer = createRenderer(); const failure = new Error("document decoding failed"); renderer.dispose.mockImplementation(() => { throw new Error("renderer cleanup failed"); }); const adapter = createToolcraftThreeModelRenderAdapter({ createRenderer: () => renderer }); const presentation = createToolcraftModelPresentation(asset, { phase: "clean", purpose: "export" }); await expect(adapter.create(presentation, { acquirePresentation: async () => { throw failure; }, signal: new AbortController().signal, })).rejects.toBe(failure); }); });