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:
co-authored by
Claude Opus 4.8
parent
bb3fb7e573
commit
98fbdbd5cf
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user