From 98fbdbd5cf71a3ce1825b598cd25b3d003ab8990 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Wed, 1 Jul 2026 23:56:13 -0400 Subject: [PATCH] fix(lotus-audio-inject): close double-publish window on rapid inject actions playInjectedClip only registered its cleanup (and thus became abortable by a later clip's replace-mode loop) AFTER publishing. Two inject actions fired in quick succession could both pass their fetch/decode/publish awaits before either was registered, so both tracks got published. Register a synchronous placeholder abort BEFORE the first await: it aborts the in-flight fetch and flips an `aborted` flag checked after every await, so a newer clip cancels the older one during the vulnerable window. The real cleanup replaces the placeholder once the track is live, and if we were superseded mid-publish we tear the just-published track down immediately. Co-Authored-By: Claude Opus 4.8 --- src/lotus/lotusAudioInject.ts | 58 +++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/src/lotus/lotusAudioInject.ts b/src/lotus/lotusAudioInject.ts index d5fe59ed..b06b9179 100644 --- a/src/lotus/lotusAudioInject.ts +++ b/src/lotus/lotusAudioInject.ts @@ -101,16 +101,42 @@ async function playInjectedClip( return; } - // Max ONE clip at a time (replace mode): stop any in-flight clip before - // starting a new one, so clips can't overlap or be spammed. Runs - // synchronously before the first await, and each cleanup() is idempotent and - // removes itself from activeClips (so no track leak). The host also debounces - // the button; together they cover the brief fetch window. + // Max ONE clip at a time (replace mode): stop any in-flight or playing clip + // before starting a new one, so clips can't overlap or be spammed. for (const abort of [...activeClips]) abort(); - const resp = await fetch(url, { credentials: "omit", mode: "cors" }); + // A second inject action can arrive while THIS one is still awaiting its + // fetch/decode/publish — before its real cleanup() exists. Register a + // synchronous placeholder abort NOW, BEFORE the first await, so the + // replace-mode loop above (run by that later action) cancels this one; + // otherwise both clips would sail past their awaits and DOUBLE-PUBLISH. The + // placeholder aborts the in-flight fetch and flips `aborted`, which we check + // after every await; the real cleanup() replaces it once the track is live. + let aborted = false; + const controller = new AbortController(); + const placeholder = (): void => { + aborted = true; + controller.abort(); + activeClips.delete(placeholder); + }; + activeClips.add(placeholder); + + let resp: Response; + try { + resp = await fetch(url, { + credentials: "omit", + mode: "cors", + signal: controller.signal, + }); + } catch (e) { + // Superseded by a newer clip mid-fetch — expected, not a failure. + if (aborted) return; + throw e; + } + if (aborted) return; if (!resp.ok) throw new Error(`fetch ${url} -> ${resp.status}`); const arrayBuffer = await resp.arrayBuffer(); + if (aborted) return; const ctx = new AudioContext(); // The action arrives via host postMessage, not a gesture in this iframe, so @@ -121,6 +147,10 @@ async function playInjectedClip( } catch { /* best effort */ } + if (aborted) { + void ctx.close(); + return; + } if (ctx.state !== "running") logger.warn(`[lotus] inject_audio: AudioContext is ${ctx.state}`); @@ -131,6 +161,10 @@ async function playInjectedClip( void ctx.close(); throw e; } + if (aborted) { + void ctx.close(); + return; + } const dest = ctx.createMediaStreamDestination(); const gain = ctx.createGain(); @@ -183,8 +217,20 @@ async function playInjectedClip( } void ctx.close().catch(() => undefined); }; + // Swap the synchronous placeholder for the real cleanup: from here an abort + // (teardown or a newer clip) must unpublish the LIVE track, not just cancel a + // fetch. This delete+add is synchronous (no await), so a newer clip's + // replace-mode loop always sees exactly one of {placeholder, cleanup}. + activeClips.delete(placeholder); activeClips.add(cleanup); + // If a newer clip aborted us WHILE we were publishing, tear down now so we + // don't leave an orphan track published after it ran its replace-mode loop. + if (aborted) { + cleanup(); + return; + } + source.onended = cleanup; // Safety net: clip metadata can lie (NaN/huge duration), so force teardown // after a sane, capped delay.