From 634ff2e2b7e198b6c2abab26a04a1b5bfd240123 Mon Sep 17 00:00:00 2001 From: "Tony Simons (AIowa)" Date: Thu, 24 Sep 2026 01:05:25 -0500 Subject: [PATCH] fix(exporter): prefer WebGL before WebGPU in default/auto modern export mode on Linux The modern (Lightning) exporter tried WebGPU first in default/auto mode even when WebGPU was only nominally present on Linux, then crashed during the first renderFrame (rendererInitMs reported success, frameCount stayed 0). The crash surfaced as an _resourceType fault inside the Pixi WebGPU renderer path. This swap makes WebGL the first attempt in default/auto mode when the browser reports a GPU object, matching the legacy frameRenderer path and the preview renderer. An explicit preferredRenderBackend is still honored, and the fallback to WebGPU still kicks in when WebGL init fails. Add a regression test that exercises the WebGL-first default/auto branch and a second test that exercises the WebGL-failure WebGPU fallback, plus a regression test that continues to the next backend when failed-init cleanup would throw. Fixes the Linux Lightning-export crash. Verified end-to-end by rebuilding the bundled renderer + Linux AppImage (electron-builder --linux), packaging the patched renderer into the asar in release/linux-unpacked/resources/app.asar, and running the smoke harness twice against the rebuilt binary: - default/auto (no forced backend): success, renderBackend=webgl, 2522 frames, 84.038s - explicit smokeRenderBackend=webgl: success, renderBackend=webgl, 2522 frames, 84.038s Co-Authored-By: Hermes Agent (hermes-agent.nousresearch.com) --- src/lib/exporter/modernFrameRenderer.test.ts | 64 ++++++++++++++++++++ src/lib/exporter/modernFrameRenderer.ts | 2 +- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/lib/exporter/modernFrameRenderer.test.ts b/src/lib/exporter/modernFrameRenderer.test.ts index 79c2caa13..4707fd1ff 100644 --- a/src/lib/exporter/modernFrameRenderer.test.ts +++ b/src/lib/exporter/modernFrameRenderer.test.ts @@ -231,6 +231,67 @@ it("bypasses blur annotation compositing during gaps and clears stale composite }); describe("ModernFrameRenderer Pixi lifecycle", () => { + it("tries WebGL before WebGPU in default/auto mode when both are nominally available", async () => { + pixiApplicationInstancesMock.length = 0; + pixiInitializationErrorsMock.length = 0; + // Default/auto mode with navigator.gpu present used to try WebGPU first, which + // crashed on the broken Linux path before any fallback could engage. The fix makes + // WebGL the first attempt in that case, so a nominally-present-but-crashing WebGPU + // backend no longer blocks the export. + vi.stubGlobal("navigator", { gpu: {} }); + + try { + const renderer = createRenderer() as unknown as { + config: { preferredRenderBackend?: "webgl" | "webgpu" }; + createPixiApplication: ( + canvas: HTMLCanvasElement, + ) => Promise<{ backend: "webgl" | "webgpu" }>; + }; + + await expect( + renderer.createPixiApplication({} as HTMLCanvasElement), + ).resolves.toMatchObject({ + backend: "webgl", + }); + + // WebGL succeeded on the first attempt; WebGPU was never tried. + expect(pixiApplicationInstancesMock).toHaveLength(1); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("falls back to WebGPU when WebGL fails in default/auto mode", async () => { + pixiApplicationInstancesMock.length = 0; + pixiInitializationErrorsMock.length = 0; + pixiInitializationErrorsMock.push(new Error("WebGL initialization failed")); + vi.stubGlobal("navigator", { gpu: {} }); + + try { + const renderer = createRenderer() as unknown as { + config: { preferredRenderBackend?: "webgl" | "webgpu" }; + createPixiApplication: ( + canvas: HTMLCanvasElement, + ) => Promise<{ backend: "webgl" | "webgpu" }>; + }; + + await expect( + renderer.createPixiApplication({} as HTMLCanvasElement), + ).resolves.toMatchObject({ + backend: "webgpu", + }); + + // WebGL failed first; WebGPU succeeded on the second attempt. + expect(pixiApplicationInstancesMock).toHaveLength(2); + // The failed WebGL attempt is destroyed; the successful WebGPU instance survives. + expect(pixiApplicationInstancesMock[0].destroy).not.toHaveBeenCalled(); + expect(pixiApplicationInstancesMock[0].stage.destroy).toHaveBeenCalledTimes(1); + expect(pixiApplicationInstancesMock[0].renderer.destroy).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); + it("continues to the next backend when failed-init cleanup would throw", async () => { pixiApplicationInstancesMock.length = 0; pixiInitializationErrorsMock.length = 0; @@ -244,6 +305,8 @@ describe("ModernFrameRenderer Pixi lifecycle", () => { canvas: HTMLCanvasElement, ) => Promise<{ backend: "webgl" | "webgpu" }>; }; + // Explicit `preferredRenderBackend: "webgpu"` still tries WebGPU first; + // the bug fix only changes the default/auto order. renderer.config.preferredRenderBackend = "webgpu"; await expect( @@ -252,6 +315,7 @@ describe("ModernFrameRenderer Pixi lifecycle", () => { backend: "webgl", }); + // WebGPU failed first; WebGL succeeded on the second attempt. expect(pixiApplicationInstancesMock).toHaveLength(2); expect(pixiApplicationInstancesMock[0].destroy).not.toHaveBeenCalled(); expect(pixiApplicationInstancesMock[0].stage.destroy).toHaveBeenCalledTimes(1); diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index c66c18d79..0f6616933 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -629,7 +629,7 @@ export class FrameRenderer { : preferredRenderBackend === "webgpu" ? ["webgpu", "webgl"] : typeof navigator !== "undefined" && "gpu" in navigator - ? ["webgpu", "webgl"] + ? ["webgl", "webgpu"] : ["webgl"]; const failures: PixiRendererAttempt[] = [];