import { describe, expect, it, vi } from "vitest"; import { registerToolcraftRendererPipeline, type ToolcraftRendererPipelinePassContract, } from "./renderer-pipeline-registration"; import { createToolcraftRendererPipelineRuntime, type ToolcraftRendererPipelineRuntime, } from "./renderer-pipeline-runtime"; type Resource = Readonly<{ id: string }>; type LifecyclePasses = { renderer: ToolcraftRendererPipelinePassContract< string, Resource, readonly [resourceId: unknown] >; source: ToolcraftRendererPipelinePassContract< string, Resource, readonly [sourceId: unknown] >; }; function sourceKey(source: unknown) { return { "source.asset": source }; } function createDeferred() { let reject!: (error: unknown) => void; let resolve!: (value: T) => void; const promise = new Promise((resolvePromise, rejectPromise) => { reject = rejectPromise; resolve = resolvePromise; }); return { promise, reject, resolve }; } function createLifecycleRegistration() { const pass = < Id extends "renderer" | "source", Scope extends "renderer" | "source", >( id: Id, resourceScope: Scope, ) => ({ cacheKey: ["source.asset"] as const, id, inputs: ["source.asset"], invalidatedBy: ["source.asset"], kind: "preprocess" as const, lifecycle: { cache: "retained-resource" as const, resourceScope }, output: "intermediate" as const, quality: "full" as const, runsOn: "worker" as const, }); return registerToolcraftRendererPipeline()({ interactionInvalidation: [], passes: [ pass("renderer", "renderer"), pass("source", "source"), ], runtimeId: "lifecycle-test-v1", }); } let resourceInvocation = 0; async function getLifecycleResource( runtime: ToolcraftRendererPipelineRuntime, registration: ReturnType, passId: "renderer" | "source", resourceKey: readonly [unknown], create: () => PromiseLike | Resource, dispose: (resource: Resource) => PromiseLike | void, ): Promise { let resource!: Resource; await runtime.runPass( registration.getPass(passId), sourceKey({ resourceInvocation: ++resourceInvocation }), async (context) => { resource = await context.getOrCreateResource(resourceKey, create, dispose); return "pixels"; }, ); return resource; } describe("Toolcraft renderer pipeline lifecycle", () => { it("isolates subscriber exceptions from passes, resources, transfers, and disposal", async () => { const registration = createLifecycleRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const renderer = registration.getPass("renderer"); const disposeResource = vi.fn(); runtime.subscribe(() => { throw new Error("subscriber failed"); }); await expect( runtime.runPass(renderer, sourceKey("source-a"), async (context) => { await context.getOrCreateResource( ["gpu"], () => ({ id: "gpu" }), disposeResource, ); return "pixels"; }), ).resolves.toBe("pixels"); expect(() => runtime.recordTransfer(renderer)).not.toThrow(); await expect(runtime.dispose()).resolves.toBeUndefined(); expect(disposeResource).toHaveBeenCalledTimes(1); expect(runtime.getSnapshot().passes.renderer).toMatchObject({ executions: 1, resourceCreations: 1, resourceDisposals: 1, transfers: 1, }); }); it("publishes to a stable listener snapshot", () => { const registration = createLifecycleRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const renderer = registration.getPass("renderer"); const first = vi.fn(); let unsubscribeFirst = runtime.subscribe(first); runtime.subscribe(() => { unsubscribeFirst(); unsubscribeFirst = runtime.subscribe(first); }); runtime.recordTransfer(renderer); expect(first).toHaveBeenCalledTimes(1); }); it("shares disposal before a disposal publish can reenter", async () => { const registration = createLifecycleRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); let listenerCalls = 0; let reentrantDisposal: Promise | undefined; runtime.subscribe(() => { if (!runtime.getSnapshot().disposed) { return; } listenerCalls += 1; if (!reentrantDisposal) { reentrantDisposal = runtime.dispose(); } }); const disposal = runtime.dispose(); expect(reentrantDisposal).toBe(disposal); await expect(disposal).resolves.toBeUndefined(); expect(listenerCalls).toBe(1); }); it("closes admissions, awaits active executions, then disposes resources", async () => { const registration = createLifecycleRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const renderer = registration.getPass("renderer"); const work = createDeferred(); const disposeResource = vi.fn(); await getLifecycleResource( runtime, registration, "renderer", ["gpu"], () => ({ id: "gpu" }), disposeResource, ); const passResult = runtime.runPass(renderer, sourceKey("source-a"), () => work.promise); const disposal = runtime.dispose(); await expect(runtime.runPass(renderer, sourceKey("late"), () => "late")).rejects.toThrow( 'Renderer pipeline runtime "lifecycle-test-v1" is disposed.', ); expect(disposeResource).not.toHaveBeenCalled(); work.resolve("pixels"); await expect(passResult).resolves.toBe("pixels"); await disposal; expect(disposeResource).toHaveBeenCalledTimes(1); const finalSnapshot = runtime.getSnapshot(); await Promise.resolve(); expect(runtime.getSnapshot()).toBe(finalSnapshot); }); it("retires stale state immediately and disposes it after external execution leases", async () => { const registration = createLifecycleRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const renderer = registration.getPass("renderer"); const work = createDeferred(); const disposeResource = vi.fn(); await getLifecycleResource( runtime, registration, "renderer", ["gpu"], () => ({ id: "gpu" }), disposeResource, ); const execution = runtime.runPass(renderer, sourceKey("source-a"), () => work.promise); const invalidation = runtime.invalidatePass(renderer); await expect( runtime.runPass(renderer, sourceKey("source-a"), () => "fresh"), ).resolves.toBe("fresh"); expect(disposeResource).not.toHaveBeenCalled(); work.resolve("pixels"); await execution; await invalidation.cleanup; expect(disposeResource).toHaveBeenCalledTimes(1); await expect(runtime.runPass(renderer, sourceKey("source-a"), () => "next")).resolves.toBe( "fresh", ); }); it("keeps public invalidation cleanup external to active work", async () => { const registration = createLifecycleRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const renderer = registration.getPass("renderer"); const disposeResource = vi.fn(); await getLifecycleResource( runtime, registration, "renderer", ["gpu"], () => ({ id: "gpu" }), disposeResource, ); let cleanup!: Promise; let disposedBeforeWorkReturned = false; const execution = runtime.runPass( renderer, sourceKey("source-a"), async () => { await Promise.resolve(); const invalidation = runtime.invalidatePass(renderer); cleanup = invalidation.cleanup; disposedBeforeWorkReturned = disposeResource.mock.calls.length > 0; return "pixels"; }, ); await expect(execution).resolves.toBe("pixels"); expect(disposedBeforeWorkReturned).toBe(false); await expect(cleanup).resolves.toBeUndefined(); expect(disposeResource).toHaveBeenCalledTimes(1); }); it("lets context self-invalidation release its execution lease before awaiting cleanup", async () => { const registration = createLifecycleRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const renderer = registration.getPass("renderer"); const disposeResource = vi.fn(); await getLifecycleResource( runtime, registration, "renderer", ["gpu"], () => ({ id: "gpu" }), disposeResource, ); const execution = runtime.runPass( renderer, sourceKey("source-a"), async ({ invalidatePass }) => { await Promise.resolve(); await invalidatePass().cleanup; return "pixels"; }, ); await expect(execution).resolves.toBe("pixels"); expect(disposeResource).toHaveBeenCalledTimes(1); await expect( runtime.runPass(renderer, sourceKey("source-a"), () => "fresh"), ).resolves.toBe("fresh"); }, 500); it("waits for pending creation and an already-started invalidation disposer", async () => { const registration = createLifecycleRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const renderer = registration.getPass("renderer"); const creation = createDeferred(); const disposer = createDeferred(); const disposeResource = vi.fn(() => disposer.promise); let resource!: Resource; const resourceExecution = runtime.runPass( renderer, sourceKey("resource-creation"), async (context) => { resource = await context.getOrCreateResource( ["gpu"], () => creation.promise, disposeResource, ); return "pixels"; }, ); const invalidation = runtime.invalidatePass(renderer); const disposal = runtime.dispose(); creation.resolve({ id: "gpu" }); await expect(resourceExecution).resolves.toBe("pixels"); expect(resource).toEqual({ id: "gpu" }); await Promise.resolve(); expect(disposeResource).toHaveBeenCalledTimes(1); expect(runtime.getSnapshot().passes.renderer.activeResources).toBe(1); disposer.resolve(undefined); await expect(invalidation.cleanup).resolves.toBeUndefined(); await expect(disposal).resolves.toBeUndefined(); expect(runtime.getSnapshot().passes.renderer).toMatchObject({ activeResources: 0, resourceCreations: 1, resourceDisposals: 1, }); }); it.each([-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( "rejects invalid transfer count %s without mutating evidence", (count) => { const registration = createLifecycleRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const renderer = registration.getPass("renderer"); expect(() => runtime.recordTransfer(renderer, count)).toThrow( "Renderer pipeline transfer count must be a finite non-negative integer.", ); expect(runtime.getSnapshot().passes.renderer.transfers).toBe(0); }, ); it("rejects transfer totals that exceed the safe integer range", () => { const registration = createLifecycleRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const renderer = registration.getPass("renderer"); runtime.recordTransfer(renderer, Number.MAX_SAFE_INTEGER); expect(() => runtime.recordTransfer(renderer, 1)).toThrow( "Renderer pipeline transfer total must remain a safe integer.", ); expect(runtime.getSnapshot().passes.renderer.transfers).toBe( Number.MAX_SAFE_INTEGER, ); }); it("aggregates all invalidation disposer failures once with pass and key context", async () => { const registration = createLifecycleRegistration(); const firstFailure = new Error("first failed"); const secondFailure = new Error("second failed"); const onCleanupError = vi.fn(); const reportingRuntime = createToolcraftRendererPipelineRuntime(registration, { onCleanupError, }); const reportingRenderer = registration.getPass("renderer"); await getLifecycleResource( reportingRuntime, registration, "renderer", ["first"], () => ({ id: "first" }), () => Promise.reject(firstFailure), ); await getLifecycleResource( reportingRuntime, registration, "renderer", ["second"], () => ({ id: "second" }), () => Promise.reject(secondFailure), ); const invalidation = reportingRuntime.invalidatePass(reportingRenderer); const failure = await invalidation.cleanup.catch((error) => error); expect(failure).toBeInstanceOf(AggregateError); expect((failure as AggregateError).errors).toHaveLength(2); expect((failure as AggregateError).errors).toEqual([ expect.objectContaining({ cause: firstFailure, message: expect.stringContaining('pass "renderer" resource ["first"]'), }), expect.objectContaining({ cause: secondFailure, message: expect.stringContaining('pass "renderer" resource ["second"]'), }), ]); expect(onCleanupError).toHaveBeenCalledTimes(1); expect(onCleanupError).toHaveBeenCalledWith(failure); await expect(reportingRuntime.dispose()).resolves.toBeUndefined(); await expect(reportingRuntime.dispose()).resolves.toBeUndefined(); }); it("rejects unsupported interaction-scoped retained resources at registration", () => { const register = registerToolcraftRendererPipeline<{ interaction: ToolcraftRendererPipelinePassContract< string, Resource, readonly [interactionId: string] >; }>(); const descriptor = { interactionInvalidation: [], passes: [ { cacheKey: ["interaction.id"], id: "interaction", inputs: ["interaction.id"], invalidatedBy: ["interaction.id"], kind: "preprocess", lifecycle: { cache: "retained-resource", resourceScope: "interaction", }, output: "intermediate", quality: "full", runsOn: "worker", }, ], runtimeId: "unsupported-interaction-v1", } as const; if (false) { // @ts-expect-error Interaction-scoped retained resources are not executable. register(descriptor); } expect(() => register(descriptor as never)).toThrow( 'Renderer pipeline pass "interaction" cannot retain interaction-scoped resources.', ); }); });