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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Max ONE clip at a time (replace mode): stop any in-flight clip before
|
// Max ONE clip at a time (replace mode): stop any in-flight or playing clip
|
||||||
// starting a new one, so clips can't overlap or be spammed. Runs
|
// before starting a new one, so clips can't overlap or be spammed.
|
||||||
// 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.
|
|
||||||
for (const abort of [...activeClips]) abort();
|
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}`);
|
if (!resp.ok) throw new Error(`fetch ${url} -> ${resp.status}`);
|
||||||
const arrayBuffer = await resp.arrayBuffer();
|
const arrayBuffer = await resp.arrayBuffer();
|
||||||
|
if (aborted) return;
|
||||||
|
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
// The action arrives via host postMessage, not a gesture in this iframe, so
|
// The action arrives via host postMessage, not a gesture in this iframe, so
|
||||||
@@ -121,6 +147,10 @@ async function playInjectedClip(
|
|||||||
} catch {
|
} catch {
|
||||||
/* best effort */
|
/* best effort */
|
||||||
}
|
}
|
||||||
|
if (aborted) {
|
||||||
|
void ctx.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (ctx.state !== "running")
|
if (ctx.state !== "running")
|
||||||
logger.warn(`[lotus] inject_audio: AudioContext is ${ctx.state}`);
|
logger.warn(`[lotus] inject_audio: AudioContext is ${ctx.state}`);
|
||||||
|
|
||||||
@@ -131,6 +161,10 @@ async function playInjectedClip(
|
|||||||
void ctx.close();
|
void ctx.close();
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
if (aborted) {
|
||||||
|
void ctx.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const dest = ctx.createMediaStreamDestination();
|
const dest = ctx.createMediaStreamDestination();
|
||||||
const gain = ctx.createGain();
|
const gain = ctx.createGain();
|
||||||
@@ -183,8 +217,20 @@ async function playInjectedClip(
|
|||||||
}
|
}
|
||||||
void ctx.close().catch(() => undefined);
|
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);
|
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;
|
source.onended = cleanup;
|
||||||
// Safety net: clip metadata can lie (NaN/huge duration), so force teardown
|
// Safety net: clip metadata can lie (NaN/huge duration), so force teardown
|
||||||
// after a sane, capped delay.
|
// after a sane, capped delay.
|
||||||
|
|||||||
Reference in New Issue
Block a user