import { describe, expect, it, vi } from "vitest"; import { registerToolcraftRendererPipeline, type ToolcraftRendererPipelinePassContract, } from "./renderer-pipeline-registration"; import { createToolcraftRendererPipelineRuntime } from "./renderer-pipeline-runtime"; type Resource = Readonly<{ id: string }>; function createDeferred() { let resolve!: (value: T) => void; const promise = new Promise((resolvePromise) => { resolve = resolvePromise; }); return { promise, resolve }; } function sourceKey(source: unknown) { return { "source.asset": source }; } function createRegistration() { return registerToolcraftRendererPipeline<{ renderer: ToolcraftRendererPipelinePassContract< string, Resource, readonly [resourceId: string] >; }>()({ interactionInvalidation: [], passes: [ { cacheKey: ["source.asset"], id: "renderer", inputs: ["source.asset"], invalidatedBy: ["source.asset"], kind: "preprocess", lifecycle: { cache: "retained-resource", resourceScope: "renderer" }, output: "intermediate", quality: "full", runsOn: "worker", }, ], runtimeId: "source-invalidation-test-v1", }); } describe("renderer pipeline contextual source invalidation", () => { it("releases a matching execution lease before awaiting cleanup", async () => { const registration = createRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const renderer = registration.getPass("renderer"); const source = { id: "source-a" }; const disposeResource = vi.fn(); const execution = runtime.runPass( renderer, sourceKey(source), async ({ getOrCreateResource, invalidateSource }) => { await getOrCreateResource( ["gpu"], () => ({ id: "gpu" }), disposeResource, ); await invalidateSource(source).cleanup; return "pixels"; }, ); await expect(execution).resolves.toBe("pixels"); expect(disposeResource).toHaveBeenCalledTimes(1); await expect( runtime.runPass(renderer, sourceKey(source), () => "fresh"), ).resolves.toBe("fresh"); }, 500); it("keeps cleanup bound to an execution generation already retired externally", async () => { const registration = createRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const renderer = registration.getPass("renderer"); const source = { id: "source-a" }; const ready = createDeferred(); const continueWork = createDeferred(); const resourceDisposal = createDeferred(); const contextualCleanup = createDeferred(); let contextualCleanupComplete = false; const execution = runtime.runPass( renderer, sourceKey(source), async ({ getOrCreateResource, invalidateSource }) => { await getOrCreateResource( ["gpu"], () => ({ id: "gpu" }), () => resourceDisposal.promise, ); ready.resolve(undefined); await continueWork.promise; await invalidateSource(source).cleanup; contextualCleanupComplete = true; contextualCleanup.resolve(undefined); return "pixels"; }, ); await ready.promise; const externalInvalidation = runtime.invalidatePass(renderer); continueWork.resolve(undefined); await expect( Promise.race([ contextualCleanup.promise.then(() => "complete" as const), new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0), ), ]), ).resolves.toBe("pending"); resourceDisposal.resolve(undefined); await expect(execution).resolves.toBe("pixels"); await expect(externalInvalidation.cleanup).resolves.toBeUndefined(); expect(contextualCleanupComplete).toBe(true); }, 500); it("reports each failing generation cleanup once across nested invalidations", async () => { const registration = createRegistration(); const onCleanupError = vi.fn(); const runtime = createToolcraftRendererPipelineRuntime(registration, { onCleanupError, }); const renderer = registration.getPass("renderer"); const source = { id: "source-a" }; const ready = createDeferred(); const continueWork = createDeferred(); const oldFailure = new Error("old resource failed"); const currentFailure = new Error("current resource failed"); const oldExecution = runtime.runPass( renderer, sourceKey(source), async ({ getOrCreateResource, invalidateSource }) => { await getOrCreateResource( ["old-gpu"], () => ({ id: "old-gpu" }), () => Promise.reject(oldFailure), ); ready.resolve(undefined); await continueWork.promise; await invalidateSource(source).cleanup; return "old"; }, ); await ready.promise; const oldInvalidation = runtime.invalidatePass(renderer); await runtime.runPass( renderer, sourceKey(source), async ({ getOrCreateResource }) => { await getOrCreateResource( ["current-gpu"], () => ({ id: "current-gpu" }), () => Promise.reject(currentFailure), ); return "current"; }, ); continueWork.resolve(undefined); await expect(oldExecution).rejects.toBeInstanceOf(AggregateError); await expect(oldInvalidation.cleanup).rejects.toBeInstanceOf(Error); await vi.waitFor(() => expect(onCleanupError).toHaveBeenCalledTimes(2)); }); it("releases its lease when invalidating another execution source in the same generation", async () => { const registration = createRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const renderer = registration.getPass("renderer"); const sourceA = { id: "source-a" }; const sourceB = { id: "source-b" }; const ready = createDeferred(); const invalidate = createDeferred(); const executionA = runtime.runPass( renderer, sourceKey(sourceA), async ({ invalidateSource }) => { ready.resolve(undefined); await invalidate.promise; await invalidateSource(sourceB).cleanup; return "source-a"; }, ); await ready.promise; await runtime.runPass( renderer, sourceKey(sourceB), () => "source-b", ); invalidate.resolve(undefined); await expect(executionA).resolves.toBe("source-a"); await expect( runtime.runPass(renderer, sourceKey(sourceB), () => "fresh"), ).resolves.toBe("fresh"); }, 500); });