Browse / github.com/storytold/artcraft / patch
fix(video-editor): retain replacement waveform requests after stale failures
fix(video-editor): retain replacement waveform requests after stale failures ## Summary An in-flight waveform request can outlive `clearSource` or `clearAll`. If another request for the same source is cached and the old request then fails, the old catch handler currently deletes the replacement cache entry. The next consumer starts a duplicate download instead of joining the replacement request. Evict a failed request only while it still owns that source's cache entry. Keep retrying ordinary current-request failures as before. ## Verification - Actual `WaveformCache`, native `Response` objects, controlled network completion: frozen main fails both invalidation case
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- rudycelekli (imported from pull request #2100, open)
- Base
28a39a0de4a2fc2eccc237df2046c24245bd12ab- Size
- +79 −1 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
efb42f6f823325ceecd0cb1a
How it went
No other operator has reported using it yet.
Diff
raw · frontend/libs/components/video-editor/src/lib/services/waveform-cache/service.spec.ts frontend/libs/components/video-editor/src/lib/services/waveform-cache/service.ts
new file mode 100644@@ -0,0 +1,76 @@+import { WaveformCache } from "./service";++const requests: Array<{ resolve: (response: Response) => void }> = [];+const observed: Promise<unknown>[] = [];+let fetchSource: ReturnType<typeof vi.fn>;++// Failed HTTP responses do not reach audio decoding or retime calculations.+// Fail loudly if a test accidentally exercises those unrelated paths.+vi.mock("../../media/audio", () => ({ createAudioContext: () => { throw new Error("Unexpected decoder call"); } }));+vi.mock("../../media/waveform-summary", () => ({ buildSourceWaveformSummary: () => { throw new Error("Unexpected waveform calculation"); } }));++beforeEach(() => {+ requests.length = 0;+ observed.length = 0;+ fetchSource = vi.fn(() => new Promise<Response>((resolve) => requests.push({ resolve })));+ vi.stubGlobal("fetch", fetchSource);+});++afterEach(async () => {+ for (const request of requests) request.resolve(new Response("", { status: 503 }));+ await Promise.allSettled(observed);+ vi.unstubAllGlobals();+});++it("keeps the replacement download cached when an invalidated source request fails", async () => {+ const cache = new WaveformCache();+ const previous = requestSummary(cache);+ cache.clearSource({ sourceKey: "media:audio-a" });+ const replacement = requestSummary(cache);+ requests[0].resolve(new Response("", { status: 503 }));+ await expect(previous).rejects.toThrow("Failed to fetch waveform source: 503");++ const joined = requestSummary(cache);+ expect(fetchSource).toHaveBeenCalledTimes(2);+ expect(joined).toBe(replacement);+});++it("keeps the new project request cached after clearing all older summaries", async () => {+ const cache = new WaveformCache();+ const previous = requestSummary(cache);+ cache.clearAll();+ const replacement = requestSummary(cache);+ requests[0].resolve(new Response("", { status: 503 }));+ await expect(previous).rejects.toThrow("Failed to fetch waveform source: 503");++ const joined = requestSummary(cache);+ expect(fetchSource).toHaveBeenCalledTimes(2);+ expect(joined).toBe(replacement);+});++it("still shares concurrent requests for the same active source", () => {+ const cache = new WaveformCache();+ const first = requestSummary(cache);+ const second = requestSummary(cache);+ expect(first).toBe(second);+ expect(fetchSource).toHaveBeenCalledTimes(1);+});++it("still retries when the current source request itself fails", async () => {+ const cache = new WaveformCache();+ const failed = requestSummary(cache);+ requests[0].resolve(new Response("", { status: 404 }));+ await expect(failed).rejects.toThrow("Failed to fetch waveform source: 404");+ const retry = requestSummary(cache);+ expect(retry).not.toBe(failed);+ expect(fetchSource).toHaveBeenCalledTimes(2);+});++function requestSummary(cache: WaveformCache) {+ const promise = cache.getSourceSummary({ sourceKey: "media:audio-a", audioUrl: "https://media.example/audio-a.wav" });+ // Record and immediately observe every potentially rejected promise so+ // teardown can settle pending fetches without leaking unhandled rejection.+ observed.push(promise);+ void promise.catch(() => {});+ return promise;+}@@ -37,7 +37,9 @@ export class WaveformCache { sourceFile, audioUrl, }).catch((error) => {- this.summaries.delete(sourceKey);+ if (this.summaries.get(sourceKey) === promise) {+ this.summaries.delete(sourceKey);+ } throw error; });