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 <noreply@anthropic.com>
This commit is contained in:
Lotus CI
2026-07-01 23:56:13 -04:00
co-authored by Claude Opus 4.8
parent bb3fb7e573
commit 98fbdbd5cf
+52 -6
View File
@@ -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.