fix(core): let a failed worklet registration be retried

`ensureAudioFxWorklets` cached its promise per context and never removed a
rejected one, while `readyContexts` was only written on success. So callers
correctly kept asking and every ask replayed the same rejection: one transient
`addModule` failure left the limiter, compressor, gate and bitcrush silent for
the life of that AudioContext, with no path back short of a page reload. The
entry is now dropped on failure so the next attempt actually retries.

Falsified by removing the `registered.delete(ctx)`: the retry replays the
rejection instead of resolving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-08 12:08:56 -07:00
co-authored by Claude Opus 5
parent 71ca86456c
commit 85257571c8
2 changed files with 55 additions and 2 deletions
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import { audioFxWorkletsReady, ensureAudioFxWorklets } from "./audioFxWorklets.js";
/** Just enough of a BaseAudioContext for the registration cache to key on. */
const contextWith = (addModule: (url: string) => Promise<void>): BaseAudioContext =>
({ audioWorklet: { addModule } }) as unknown as BaseAudioContext;
describe("ensureAudioFxWorklets", () => {
it("registers once per context and reuses the result", async () => {
const addModule = vi.fn(async () => undefined);
const ctx = contextWith(addModule);
await ensureAudioFxWorklets(ctx);
await ensureAudioFxWorklets(ctx);
expect(addModule).toHaveBeenCalledTimes(1);
expect(audioFxWorkletsReady(ctx)).toBe(true);
});
it("retries after a failure instead of replaying it forever", async () => {
// The rejected promise used to stay in the cache, so every later attempt
// got the same rejection back — the limiter, compressor, gate and bitcrush
// were silent for the life of the context after one transient failure.
const addModule = vi
.fn<(url: string) => Promise<void>>()
.mockRejectedValueOnce(new Error("module load failed"))
.mockResolvedValue(undefined);
const ctx = contextWith(addModule);
await expect(ensureAudioFxWorklets(ctx)).rejects.toThrow("module load failed");
expect(audioFxWorkletsReady(ctx)).toBe(false);
await expect(ensureAudioFxWorklets(ctx)).resolves.toBeUndefined();
expect(addModule).toHaveBeenCalledTimes(2);
expect(audioFxWorkletsReady(ctx)).toBe(true);
});
it("refuses a context with no AudioWorklet rather than hanging", async () => {
const ctx = {} as BaseAudioContext;
await expect(ensureAudioFxWorklets(ctx)).rejects.toThrow(/secure context/);
});
});
+13 -2
View File
@@ -183,7 +183,9 @@ class HfBitcrush extends AudioWorkletProcessor {
this.p = o.processorOptions || {};
this.holds = [];
this.held = [];
this.port.onmessage = (e) => { this.p = { ...this.p, ...e.data }; };
this.port.onmessage = (e) => {
this.p = { ...this.p, ...e.data };
};
}
process(inputs, outputs) {
const i = inputs[0], o = outputs[0];
@@ -243,7 +245,16 @@ export function ensureAudioFxWorklets(ctx: BaseAudioContext): Promise<void> {
)}`;
await ctx.audioWorklet.addModule(url);
readyContexts.add(ctx);
})();
})().catch((err: unknown) => {
// A failed registration must not be remembered. `readyContexts` is only
// written on success, so callers correctly keep asking — and every ask
// replayed this same rejected promise, leaving the limiter, compressor,
// gate and bitcrush silent for the life of the context with no way back.
// One transient failure (a slow module load, a context still warming up)
// permanently disabled half the rack.
registered.delete(ctx);
throw err;
});
registered.set(ctx, modulePromise);
}
return modulePromise;