From 4b468b4da0fa70bfd869e3891619a0a41fe03e78 Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Thu, 6 Aug 2026 22:11:58 +0200 Subject: [PATCH] chore: audio engine stream clean up --- src/components/engine/audio/element.js | 79 +++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/src/components/engine/audio/element.js b/src/components/engine/audio/element.js index 06c8d73c..071c51cd 100644 --- a/src/components/engine/audio/element.js +++ b/src/components/engine/audio/element.js @@ -47,6 +47,9 @@ class AudioEngine extends BroadcastableDiffuseElement { /** @type {Map} Streams pending MediaSource setup */ #streams = new Map(); + /** Aborts in-flight MediaSource setup when the element is disconnected. */ + #streamAbort = new AbortController(); + // SIGNALS #items = signal(/** @type {AudioUrl[]} */ ([])); @@ -74,6 +77,9 @@ class AudioEngine extends BroadcastableDiffuseElement { * @override */ connectedCallback() { + // Reset teardown signal in case the element is reconnected (moved in DOM). + this.#streamAbort = new AbortController(); + // Setup broadcasting if part of group if (this.hasAttribute("group")) { const actions = this.broadcast( @@ -204,6 +210,49 @@ class AudioEngine extends BroadcastableDiffuseElement { }); } + /** + * @override + */ + disconnectedCallback() { + // Abort in-flight MediaSource setup so #resolveStream can wind down even + // while it's awaiting `sourceopen` (which may otherwise never fire once + // the object URL is revoked / the element is detached). + this.#streamAbort.abort(); + + // Revoke every MediaSource object URL. WebKit refcounts these and only + // frees the buffered data on revokeObjectURL — dropping the map without + // revoking leaks the decoded track bytes until the tab's process dies. + for (const objectUrl of this.#mediaSourceUrls.values()) { + URL.revokeObjectURL(objectUrl); + } + this.#mediaSourceUrls.clear(); + + // Cancel pending (not-yet-resolved) streams so their underlying sources + // (e.g. fetches) release immediately instead of draining forever. + for (const stream of this.#streams.values()) { + stream.cancel().catch(() => {}); + } + this.#streams.clear(); + + // Stop and unload any live audio nodes before they're dropped so detached + // media doesn't keep playing / holding the audio session. + this.querySelectorAll("de-audio-item").forEach((node) => { + const item = /** @type {AudioEngineItem} */ (node); + let audio; + try { + audio = item.audio; // throws when there's no child