From f6681c23d34e8de4208e67e38ae6ae057a7c4e98 Mon Sep 17 00:00:00 2001 From: PriyeshPandey2000 Date: Tue, 1 Sep 2026 12:47:55 +0530 Subject: [PATCH 1/2] fix: guard playTo() against reset() nulling the iterator mid-await reset() (called synchronously on play->pause) nulls this.iterator without acquiring playTo's mutex, so it can race an in-flight decode and throw "Cannot read properties of null (reading 'next')". Snapshot the iterator per loop iteration and re-check identity after each await instead of trusting the mutable field directly. Add a vitest regression test reproducing the race. --- package-lock.json | 363 ++++++++++++++++++++++- packages/runtime/package.json | 6 +- packages/runtime/src/media/audio.test.ts | 190 ++++++++++++ packages/runtime/src/media/audio.ts | 18 +- 4 files changed, 573 insertions(+), 4 deletions(-) create mode 100644 packages/runtime/src/media/audio.test.ts diff --git a/package-lock.json b/package-lock.json index 1bce33a6..217f8dfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3535,6 +3535,13 @@ "solid-js": "^1.8.6" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.109.0", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.109.0.tgz", @@ -4077,6 +4084,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/chrome": { "version": "0.1.43", "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.43.tgz", @@ -4088,6 +4106,13 @@ "@types/har-format": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/dom-mediacapture-transform": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz", @@ -4502,6 +4527,119 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vscode/sudo-prompt": { "version": "9.3.2", "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz", @@ -4951,6 +5089,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-kit": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", @@ -5451,6 +5599,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6701,6 +6859,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6840,6 +7008,16 @@ "which": "bin/which" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -9505,6 +9683,20 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -10759,6 +10951,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -11003,12 +11202,26 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stats.js": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", "license": "MIT" }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-buffers": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", @@ -11317,6 +11530,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyest": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyest/-/tinyest-0.3.2.tgz", @@ -11339,6 +11559,16 @@ "node": ">=12.20.0" } }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -11384,6 +11614,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -11992,6 +12232,109 @@ } } }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/watchpack": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", @@ -12150,6 +12493,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -12482,7 +12842,8 @@ }, "devDependencies": { "@webgpu/types": "^0.1.71", - "typescript": "~5.9.3" + "typescript": "~5.9.3", + "vitest": "^4.1.11" } } } diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 6dfa3451..c7e0b10d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -12,7 +12,8 @@ "./media": "./src/media/index.ts" }, "scripts": { - "check": "tsc --noEmit" + "check": "tsc --noEmit", + "test": "vitest run" }, "keywords": [ "koota", @@ -29,6 +30,7 @@ }, "devDependencies": { "@webgpu/types": "^0.1.71", - "typescript": "~5.9.3" + "typescript": "~5.9.3", + "vitest": "^4.1.11" } } diff --git a/packages/runtime/src/media/audio.test.ts b/packages/runtime/src/media/audio.test.ts new file mode 100644 index 00000000..2a67c5eb --- /dev/null +++ b/packages/runtime/src/media/audio.test.ts @@ -0,0 +1,190 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, it, expect } from 'vitest'; +import { AudioDecoder } from './audio'; +import type { AudioAsset } from '@diffusionstudio/assets'; +import type { AudioBus } from './audio-bus'; +import type { WrappedAudioBuffer } from 'mediabunny'; + +/** + * Regression test for the play -> pause audio race: playback.ts fires `playTo()` + * fire-and-forget while playing, then synchronously calls `decoder.reset()` on pause + * without acquiring playTo's mutex. If reset() lands mid-await inside playTo, the + * resumed loop reads a nulled `this.iterator` -> TypeError. + * + * The "Deferred" mock iterator lets each `next()` be resolved manually, so the test + * can freeze `playTo` at the exact iteration where `reset()` runs. + */ + +function makeAsset(): AudioAsset { + return { id: 'test-audio', type: 'AUDIO', src: 'mem://x.mp3', channels: 2, sampleRate: 44100 } as unknown as AudioAsset; +} + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const playOptions = { + relativeFrom: 0, + relativeTo: 1, + trimStart: 0, + trimEnd: 10, + playbackRate: 1, + currentTime: 0, + relativeDelay: 0, +}; + +const fakeBus = { + context: { + currentTime: 10, + sampleRate: 44100, + createBufferSource: () => ({ connect() {}, start() {}, stop() {} }), + createGain: () => ({ gain: { value: 1 }, connect() {} }), + createMediaElementSource: () => ({ connect() {} }), + destination: {}, + }, + input: {}, +} as unknown as AudioBus; + +/** A decoded audio packet. */ +function sample(timestamp: number): WrappedAudioBuffer { + return { + timestamp, + duration: 0.5, + buffer: { + sampleRate: 44100, + numberOfChannels: 2, + getChannelData: (_c: number) => new Float32Array(1), + }, + } as WrappedAudioBuffer; +} + +interface DecoderInternals { + iterator: AsyncGenerator | null; + firstBuffer: WrappedAudioBuffer | null; + lastBuffer: WrappedAudioBuffer | null; + stretcher: { append(...args: unknown[]): unknown; finalize(): unknown } | null; + nextTimestamp: number; + audioNodes: Set; +} + +/** Routes playTo down the "reuse existing iterator" branch, so it loops on our + * injected iterator instead of re-seeding from this.sink (unset without init()). */ +function setupDecoder(): { decoder: AudioDecoder; internals: DecoderInternals } { + const decoder = new AudioDecoder(makeAsset()); + const internals = decoder as unknown as DecoderInternals; + + internals.audioNodes = new Set(); + internals.nextTimestamp = 0; + const seed = sample(0); + internals.firstBuffer = seed; + internals.lastBuffer = seed; + internals.stretcher = { append: () => null, finalize: () => null }; + + return { decoder, internals }; +} + +/** Mirrors the fire-and-forget call in systems/playback.ts:188. */ +function startPlayTo(decoder: AudioDecoder): Promise { + return (decoder.playTo as (bus: AudioBus, o: typeof playOptions) => Promise) + .call(decoder, fakeBus, playOptions) + .then( + () => null, + (err) => err as Error, + ); +} + +describe('AudioDecoder.playTo vs reset race', () => { + it('does not dereference a nulled iterator when reset() runs mid-playTo', async () => { + const { decoder, internals } = setupDecoder(); + + // Each `next()` returns a manually-resolvable promise, so the test can freeze + // `playTo` at a chosen iteration and interleave a synchronous `reset()`. + const nextQueue: Array> = []; + internals.iterator = { + next: () => { + const d = deferred<{ value: WrappedAudioBuffer | undefined; done: boolean }>(); + nextQueue.push(d); + return d.promise; + }, + return: () => + Promise.resolve({ value: undefined, done: true }), + } as never; + + const playPromise = startPlayTo(decoder); + + // Iteration 1: packet below relativeTo(1), so the loop goes around again. + await waitForNext(nextQueue); + const iter1 = nextQueue.shift()!; + iter1.resolve({ value: sample(0.5), done: false }); + await waitForNext(nextQueue); + + // Iteration 2: playTo is suspended here when the user pauses. + const iter2 = nextQueue.shift()!; + decoder.reset(); + + // Resolve below relativeTo(1) too, forcing a third iteration that (pre-fix) + // would read the now-nulled `this.iterator` and throw. + iter2.resolve({ value: sample(0.5), done: false }); + + const error = await playPromise; + expect(error).toBeNull(); + }); + + it('gracefully swallows a decoder-abort rejection when reset() ran mid-await', async () => { + const { decoder, internals } = setupDecoder(); + + // Pending next() rejects with a teardown error after reset() has already + // nulled `this.iterator` underneath it. + let rejectNext!: (err: Error) => void; + internals.iterator = { + next: () => new Promise((_, reject) => { + rejectNext = reject; + }), + return: () => Promise.resolve({ value: undefined, done: true }), + } as never; + + const playPromise = startPlayTo(decoder); + + await waitForReject(internals, () => rejectNext); + decoder.reset(); + rejectNext(new Error('Worker terminated')); + + expect(await playPromise).toBeNull(); + }); +}); + +// Every pre-loop `await` in `playTo` is a microtask (the AsyncMutex chains +// `Promise.resolve()`), so a fixed number of microtask yields deterministically +// reaches the point where `playTo` is suspended on `await iterator.next()`. + +async function waitForReject( + _internals: DecoderInternals, + getReject: () => (err: Error) => void, +): Promise { + for (let i = 0; i < 8; i++) { + await Promise.resolve(); + if (typeof getReject() === 'function') return; + } + throw new Error('playTo never suspended on iterator.next()'); +} + +async function waitForNext( + queue: Array>, +): Promise { + for (let i = 0; i < 8 && queue.length === 0; i++) { + await Promise.resolve(); + } + expect(queue.length).toBeGreaterThan(0); +} diff --git a/packages/runtime/src/media/audio.ts b/packages/runtime/src/media/audio.ts index df05a8dd..d23d5d20 100644 --- a/packages/runtime/src/media/audio.ts +++ b/packages/runtime/src/media/audio.ts @@ -173,7 +173,23 @@ export class AudioDecoder { } while (true) { - const nextBuffer = (await this.iterator.next()).value; + // reset() nulls this.iterator without acquiring the mutex, so it can run + // while we're suspended below. Snapshot it and re-check identity after + // each await instead of trusting this.iterator directly. + const iterator: AsyncGenerator | null = this.iterator; + if (iterator == null) break; + + let result: IteratorResult; + try { + result = await iterator.next(); + } catch (err) { + // A concurrent reset()/dispose() can cause the pending next() to reject. + if (this.iterator !== iterator) break; + throw err; + } + + if (this.iterator !== iterator) break; + const nextBuffer = result.value; // Use the actual decoded sample rate if available const sampleRate = nextBuffer?.buffer.sampleRate ?? this.asset.sampleRate; const numberOfChannels = nextBuffer?.buffer.numberOfChannels ?? this.asset.channels; From 7184587305f3c56d6d048a700643fc6e18132cd6 Mon Sep 17 00:00:00 2001 From: PriyeshPandey2000 Date: Tue, 1 Sep 2026 14:17:45 +0530 Subject: [PATCH 2/2] fix: invalidate in-flight playTo() work across reset() The iterator identity checks miss two windows: a playTo queued behind the mutex, and one suspended on the reseed branch's iterator.return(). In both the iterator is null before and after the reset, so identity cannot detect it, and playTo goes on to reseed a decode and schedule audio for a clip the user already paused. Track a generation counter bumped on every reset() and bail whenever it moves while playTo is suspended. --- packages/runtime/src/media/audio.test.ts | 66 +++++++++++++++++++++++- packages/runtime/src/media/audio.ts | 14 ++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/media/audio.test.ts b/packages/runtime/src/media/audio.test.ts index 2a67c5eb..1e5d1b28 100644 --- a/packages/runtime/src/media/audio.test.ts +++ b/packages/runtime/src/media/audio.test.ts @@ -77,6 +77,7 @@ interface DecoderInternals { stretcher: { append(...args: unknown[]): unknown; finalize(): unknown } | null; nextTimestamp: number; audioNodes: Set; + sink: unknown; } /** Routes playTo down the "reuse existing iterator" branch, so it loops on our @@ -96,9 +97,9 @@ function setupDecoder(): { decoder: AudioDecoder; internals: DecoderInternals } } /** Mirrors the fire-and-forget call in systems/playback.ts:188. */ -function startPlayTo(decoder: AudioDecoder): Promise { +function startPlayTo(decoder: AudioDecoder, bus: AudioBus = fakeBus): Promise { return (decoder.playTo as (bus: AudioBus, o: typeof playOptions) => Promise) - .call(decoder, fakeBus, playOptions) + .call(decoder, bus, playOptions) .then( () => null, (err) => err as Error, @@ -163,6 +164,67 @@ describe('AudioDecoder.playTo vs reset race', () => { expect(await playPromise).toBeNull(); }); + + it('does not resurrect a decode when reset() lands while a queued playTo waits on the mutex', async () => { + const { decoder, internals } = setupDecoder(); + + // Count every audio node the decoder schedules, so the test can assert that a + // paused decoder never starts playback. + const started: number[] = []; + const bus = { + context: { + ...(fakeBus as unknown as { context: Record }).context, + createBufferSource: () => ({ + connect() {}, + start(when: number) { started.push(when); }, + stop() {}, + }), + }, + input: {}, + } as unknown as AudioBus; + + const queueA: Array> = []; + internals.iterator = { + next: () => { + const d = deferred<{ value: WrappedAudioBuffer | undefined; done: boolean }>(); + queueA.push(d); + return d.promise; + }, + return: () => Promise.resolve({ value: undefined, done: true }), + } as never; + + // A holds the mutex, suspended on next(). + const a = startPlayTo(decoder, bus); + await waitForNext(queueA); + + // B is queued behind the mutex, and cannot observe anything about the reset + // below from the iterator alone: it is null both before and after. + const b = startPlayTo(decoder, bus); + + let reseeds = 0; + internals.sink = { + buffers: () => { + reseeds++; + return { + next: () => Promise.resolve({ value: undefined, done: true }), + return: () => Promise.resolve({ value: undefined, done: true }), + }; + }, + }; + + // User pauses. + decoder.reset(); + + // A resumes, notices the iterator changed, breaks, and releases the mutex to B. + queueA.shift()!.resolve({ value: sample(0.5), done: false }); + + expect(await a).toBeNull(); + expect(await b).toBeNull(); + + expect(reseeds).toBe(0); + expect(internals.iterator).toBeNull(); + expect(started).toEqual([]); + }); }); // Every pre-loop `await` in `playTo` is a microtask (the AsyncMutex chains diff --git a/packages/runtime/src/media/audio.ts b/packages/runtime/src/media/audio.ts index d23d5d20..5ad8bf8f 100644 --- a/packages/runtime/src/media/audio.ts +++ b/packages/runtime/src/media/audio.ts @@ -64,6 +64,7 @@ export class AudioDecoder { private readonly audioNodes = new Set(); private iterator: AsyncGenerator | null = null; + private generation = 0; private firstBuffer: WrappedAudioBuffer | null = null; private lastBuffer: WrappedAudioBuffer | null = null; private stretcher: TimeStretcher | null = null; @@ -156,6 +157,10 @@ export class AudioDecoder { const { relativeFrom, relativeTo } = options; + // Sampled before the mutex so a reset() that lands while we are queued behind + // another playTo is still observed: by then the iterator is null both before + // and after the reset, so identity alone cannot detect it. + const generation = this.generation; const release = await this.mutex.acquire(); const firstTs = this.firstBuffer?.timestamp ?? Number.POSITIVE_INFINITY; @@ -163,9 +168,12 @@ export class AudioDecoder { const isBufferOutOfRange = relativeFrom < firstTs || lastTs < relativeFrom - 1; try { + if (this.generation !== generation) return; + if (this.iterator == null || this.stretcher == null || isBufferOutOfRange) { // Close the previous iterator so its pre-decoded AudioSamples get released. await this.iterator?.return(); + if (this.generation !== generation) return; this.iterator = this.sink.buffers(relativeFrom); this.firstBuffer = null; this.lastBuffer = null; @@ -183,7 +191,7 @@ export class AudioDecoder { try { result = await iterator.next(); } catch (err) { - // A concurrent reset()/dispose() can cause the pending next() to reject. + // A concurrent reset() can cause the pending next() to reject. if (this.iterator !== iterator) break; throw err; } @@ -244,6 +252,10 @@ export class AudioDecoder { } public reset() { + // Bumped unconditionally: an in-flight playTo must be invalidated even when + // there is no iterator left to null out. + this.generation++; + if (!this.iterator) return; this.iterator?.return();