From b482e20c49aad9c504cbd25bc92d2b5d3e6fab3d Mon Sep 17 00:00:00 2001 From: michal Date: Thu, 20 Aug 2026 19:07:22 +0200 Subject: [PATCH 1/9] fix: proper gc in the wpt tests --- .../src/Audio/AudioFileSourceNode.ts | 33 ++- .../src/core/AudioBufferBaseSourceNode.ts | 15 +- .../src/core/AudioBufferQueueSourceNode.ts | 14 +- .../src/core/AudioBufferSourceNode.ts | 12 +- .../src/core/AudioScheduledSourceNode.ts | 14 +- .../tests/audio-event-subscriptions.test.ts | 238 ++++++++++++++++++ .../wpt_tests/README.md | 20 +- .../wpt_tests/src/SyncCallInvoker.cpp | 17 +- .../wpt_tests/src/jsi_install.cpp | 22 +- .../wpt_tests/wpt/wpt-compare.mjs | 133 +++++++++- .../wpt_tests/wpt/wpt-harness.mjs | 206 ++++++++++++++- ...hannel-merger-splitter-attribute-locks.mjs | 37 +-- .../wpt_tests/wpt/wpt-results.mjs | 125 ++++++--- .../wpt_tests/wpt/wpt-shared.mjs | 64 ++++- .../wpt_tests/wpt/wpt-utils.mjs | 148 ++++++++--- .../wpt_tests/wpt/wpt-worker.mjs | 64 +++++ .../wpt/wrap-audio-node-constructors.mjs | 37 +-- 17 files changed, 1059 insertions(+), 140 deletions(-) create mode 100644 packages/react-native-audio-api/tests/audio-event-subscriptions.test.ts create mode 100644 packages/react-native-audio-api/wpt_tests/wpt/wpt-worker.mjs diff --git a/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts b/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts index 2f82eccac..4a8856419 100644 --- a/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts +++ b/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts @@ -1,4 +1,4 @@ -import { AudioEventEmitter } from '../events'; +import { AudioEventEmitter, AudioEventSubscription } from '../events'; import type { EventEmptyType } from '../events/types'; import type { IAudioFileSourceNode, @@ -18,16 +18,21 @@ export class AudioFileSourceNode extends AudioScheduledSourceNode { globalThis.AudioEventEmitter ); + private endedSubscription: AudioEventSubscription | null = null; + private positionSubscription: AudioEventSubscription | null = null; + private bufferingSubscription: AudioEventSubscription | null = null; + attach(options: AttachFileSourceOptions): { duration: number } { this.resetNodeAndSubscriptions(); - const sub = this.emitter.addAudioEventListener( + this.endedSubscription = this.emitter.addAudioEventListener( 'ended', (_event: EventEmptyType) => { options.onEnded(); } ); - (this.node as IAudioFileSourceNode).onEnded = sub.subscriptionId; + (this.node as IAudioFileSourceNode).onEnded = + this.endedSubscription.subscriptionId; return { duration: (this.node as IAudioFileSourceNode).duration, @@ -93,16 +98,20 @@ export class AudioFileSourceNode extends AudioScheduledSourceNode { return; } this.stopPositionTracking(); - const sub = this.emitter.addAudioEventListener( + this.positionSubscription = this.emitter.addAudioEventListener( 'positionChanged', (event) => { onTime(event.value); } ); - (this.node as IAudioFileSourceNode).onPositionChanged = sub.subscriptionId; + (this.node as IAudioFileSourceNode).onPositionChanged = + this.positionSubscription.subscriptionId; } stopPositionTracking(): void { + this.positionSubscription?.remove(); + this.positionSubscription = null; + if (this.node) { (this.node as IAudioFileSourceNode).onPositionChanged = '0'; } @@ -115,26 +124,32 @@ export class AudioFileSourceNode extends AudioScheduledSourceNode { return; } this.stopBufferingTracking(); - const sub = this.emitter.addAudioEventListener( + this.bufferingSubscription = this.emitter.addAudioEventListener( 'bufferingStateChanged', (event) => { onBufferingChange(event.value); } ); (this.node as IAudioFileSourceNode).onBufferingStateChanged = - sub.subscriptionId; + this.bufferingSubscription.subscriptionId; } stopBufferingTracking(): void { + this.bufferingSubscription?.remove(); + this.bufferingSubscription = null; + if (this.node) { (this.node as IAudioFileSourceNode).onBufferingStateChanged = '0'; } } private resetNodeAndSubscriptions(): void { + this.stopPositionTracking(); + this.stopBufferingTracking(); + this.endedSubscription?.remove(); + this.endedSubscription = null; + if (this.node) { - (this.node as IAudioFileSourceNode).onPositionChanged = '0'; - (this.node as IAudioFileSourceNode).onBufferingStateChanged = '0'; (this.node as IAudioFileSourceNode).onEnded = '0'; this.node.disconnect(undefined); } diff --git a/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts b/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts index d9d01ab8a..52e9b3ce4 100644 --- a/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts @@ -4,11 +4,13 @@ import { EventTypeWithValue } from '../events/types'; import { IAudioBufferBaseSourceNode } from '../jsi-interfaces'; import AudioScheduledSourceNode from './AudioScheduledSourceNode'; import { AudioNodeOptions } from '../types'; +import { AudioEventSubscription } from '../events'; export default class AudioBufferBaseSourceNode extends AudioScheduledSourceNode { readonly playbackRate: AudioParam; readonly detune: AudioParam; private onPositionChangedCallback?: (event: EventTypeWithValue) => void; + private onPositionChangedSubscription: AudioEventSubscription | null = null; constructor( context: BaseAudioContext, @@ -30,6 +32,11 @@ export default class AudioBufferBaseSourceNode extends AudioScheduledSourceNode public set onPositionChanged( callback: ((event: EventTypeWithValue) => void) | null ) { + // See the note in AudioScheduledSourceNode.onEnded: the native registry holds a + // strong reference, so the outgoing handler must be released here. + this.onPositionChangedSubscription?.remove(); + this.onPositionChangedSubscription = null; + if (!callback) { (this.node as IAudioBufferBaseSourceNode).onPositionChanged = '0'; this.onPositionChangedCallback = undefined; @@ -37,13 +44,11 @@ export default class AudioBufferBaseSourceNode extends AudioScheduledSourceNode } this.onPositionChangedCallback = callback; - const sub = this.audioEventEmitter.addAudioEventListener( - 'positionChanged', - callback - ); + this.onPositionChangedSubscription = + this.audioEventEmitter.addAudioEventListener('positionChanged', callback); (this.node as IAudioBufferBaseSourceNode).onPositionChanged = - sub.subscriptionId; + this.onPositionChangedSubscription.subscriptionId; } public get onPositionChangedInterval(): number { diff --git a/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts b/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts index 0a3a7f957..3e4111247 100644 --- a/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts @@ -8,9 +8,11 @@ import { AudioBufferQueueSourceState, } from '../types'; import { OnBufferEndEventType } from '../events/types'; +import { AudioEventSubscription } from '../events'; export default class AudioBufferQueueSourceNode extends AudioBufferBaseSourceNode { private onBufferEndedCallback?: (event: OnBufferEndEventType) => void; + private onBufferEndedSubscription: AudioEventSubscription | null = null; private state: AudioBufferQueueSourceState = AudioBufferQueueSourceState.IDLE; constructor( @@ -60,6 +62,7 @@ export default class AudioBufferQueueSourceNode extends AudioBufferBaseSourceNod this.state = AudioBufferQueueSourceState.PLAYING; (this.node as IAudioBufferQueueSourceNode).start(when, offset); + this.context.markRunningOnSourceStart(); } public override stop(when: number = 0): void { @@ -82,6 +85,9 @@ export default class AudioBufferQueueSourceNode extends AudioBufferBaseSourceNod public set onBufferEnded( callback: ((event: OnBufferEndEventType) => void) | null ) { + this.onBufferEndedSubscription?.remove(); + this.onBufferEndedSubscription = null; + if (!callback) { (this.node as IAudioBufferQueueSourceNode).onBufferEnded = '0'; this.onBufferEndedCallback = undefined; @@ -89,13 +95,11 @@ export default class AudioBufferQueueSourceNode extends AudioBufferBaseSourceNod } this.onBufferEndedCallback = callback; - const sub = this.audioEventEmitter.addAudioEventListener( - 'bufferEnded', - callback - ); + this.onBufferEndedSubscription = + this.audioEventEmitter.addAudioEventListener('bufferEnded', callback); (this.node as IAudioBufferQueueSourceNode).onBufferEnded = - sub.subscriptionId; + this.onBufferEndedSubscription.subscriptionId; } public pause(): void { diff --git a/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts b/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts index b5dbee150..41f5e9632 100644 --- a/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts @@ -5,9 +5,11 @@ import { InvalidStateError, RangeError } from '../errors'; import { EventEmptyType } from '../events/types'; import { AudioBufferSourceOptions } from '../types'; import type BaseAudioContext from './BaseAudioContext'; +import { AudioEventSubscription } from '../events'; export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { private onLoopEndedCallback?: (event: EventEmptyType) => void; + private onLoopEndedSubscription: AudioEventSubscription | null = null; private _buffer: AudioBuffer | null = null; private bufferHasBeenSet: boolean = false; @@ -112,6 +114,7 @@ export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { this.hasBeenStarted = true; (this.node as IAudioBufferSourceNode).start(when, offset, duration); + this.context.markRunningOnSourceStart(); } public get onLoopEnded(): ((event: EventEmptyType) => void) | undefined { @@ -119,6 +122,10 @@ export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { } public set onLoopEnded(callback: ((event: EventEmptyType) => void) | null) { + // See the note in AudioScheduledSourceNode.onEnded. + this.onLoopEndedSubscription?.remove(); + this.onLoopEndedSubscription = null; + if (!callback) { (this.node as IAudioBufferSourceNode).onLoopEnded = '0'; this.onLoopEndedCallback = undefined; @@ -126,11 +133,12 @@ export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { } this.onLoopEndedCallback = callback; - const sub = this.audioEventEmitter.addAudioEventListener( + this.onLoopEndedSubscription = this.audioEventEmitter.addAudioEventListener( 'loopEnded', callback ); - (this.node as IAudioBufferSourceNode).onLoopEnded = sub.subscriptionId; + (this.node as IAudioBufferSourceNode).onLoopEnded = + this.onLoopEndedSubscription.subscriptionId; } } diff --git a/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts b/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts index 45c27dce7..ebade2571 100644 --- a/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts @@ -2,7 +2,7 @@ import { IAudioScheduledSourceNode } from '../jsi-interfaces'; import AudioNode from './AudioNode'; import { InvalidStateError, RangeError } from '../errors'; import { EventEmptyType } from '../events/types'; -import { AudioEventEmitter } from '../events'; +import { AudioEventEmitter, AudioEventSubscription } from '../events'; export default class AudioScheduledSourceNode extends AudioNode { protected hasBeenStarted: boolean = false; @@ -11,6 +11,7 @@ export default class AudioScheduledSourceNode extends AudioNode { ); private onEndedCallback?: (event: EventEmptyType) => void; + private onEndedSubscription: AudioEventSubscription | null = null; public start(when: number = 0): void { if (when < 0) { @@ -49,6 +50,9 @@ export default class AudioScheduledSourceNode extends AudioNode { } public set onEnded(callback: ((event: EventEmptyType) => void) | null) { + this.onEndedSubscription?.remove(); + this.onEndedSubscription = null; + if (!callback) { (this.node as IAudioScheduledSourceNode).onEnded = '0'; this.onEndedCallback = undefined; @@ -56,8 +60,12 @@ export default class AudioScheduledSourceNode extends AudioNode { } this.onEndedCallback = callback; - const sub = this.audioEventEmitter.addAudioEventListener('ended', callback); + this.onEndedSubscription = this.audioEventEmitter.addAudioEventListener( + 'ended', + callback + ); - (this.node as IAudioScheduledSourceNode).onEnded = sub.subscriptionId; + (this.node as IAudioScheduledSourceNode).onEnded = + this.onEndedSubscription.subscriptionId; } } diff --git a/packages/react-native-audio-api/tests/audio-event-subscriptions.test.ts b/packages/react-native-audio-api/tests/audio-event-subscriptions.test.ts new file mode 100644 index 000000000..da46ff205 --- /dev/null +++ b/packages/react-native-audio-api/tests/audio-event-subscriptions.test.ts @@ -0,0 +1,238 @@ +/* eslint-disable */ + +/** + * Event-handler attributes (`onended`, `onloopended`, `onpositionchanged`, ...) must not + * accumulate listener registrations in the native AudioEventHandlerRegistry. + * + * The registry is process-global and stores each handler as a `std::shared_ptr`, + * so a registration that is never removed pins the callback — and, through its closure, the node, + * its context and every buffer they own — for the lifetime of the runtime. Nothing else can + * release it: it is reachable from C++, so no amount of JS garbage collection helps. + * + * The contract these tests pin down, matching how `AudioRecorder` already handles its own + * subscriptions: + * - assigning a handler registers exactly one listener, + * - reassigning replaces it (the previous registration is removed), + * - assigning null removes it, + * - once a node's handlers are cleared, no registration is left outstanding. + */ + +import AudioScheduledSourceNode from '../src/core/AudioScheduledSourceNode'; +import AudioBufferBaseSourceNode from '../src/core/AudioBufferBaseSourceNode'; +import AudioBufferQueueSourceNode from '../src/core/AudioBufferQueueSourceNode'; + +/** Records every add/remove so a test can assert nothing is left dangling. */ +class RecordingEventEmitter { + private nextId = 1; + readonly added: { name: string; id: string }[] = []; + readonly removed: { name: string; id: string }[] = []; + + addAudioEventListener = jest.fn((name: string, _callback: unknown): string => { + const id = `sub-${this.nextId++}`; + this.added.push({ name, id }); + return id; + }); + + removeAudioEventListener = jest.fn((name: string, id: string): void => { + this.removed.push({ name, id }); + }); + + /** Subscription ids handed out but never removed — i.e. leaked into the native registry. */ + outstanding(): string[] { + const removedIds = new Set(this.removed.map((entry) => entry.id)); + return this.added + .map((entry) => entry.id) + .filter((id) => !removedIds.has(id)); + } +} + +let emitter: RecordingEventEmitter; + +/** + * Minimal stand-in for the native HostObject. Only the members the TS wrappers touch are + * present; handler-id writes are kept so tests can check what the node was told. + */ +function createNativeNode(extra: Record = {}) { + return { + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'max', + channelInterpretation: 'speakers', + onEnded: '0', + ...extra, + } as any; +} + +function createContext() { + return { currentTime: 0, markRunningOnSourceStart: jest.fn() } as any; +} + +/** Stand-in for a native AudioParam HostObject, enough for the TS AudioParam wrapper. */ +function createNativeParam(value: number) { + return { + value, + defaultValue: value, + minValue: -3.4e38, + maxValue: 3.4e38, + checkCurveExclusion: () => ({ status: 'ok' }), + setValueAtTime: jest.fn(), + }; +} + +beforeEach(() => { + emitter = new RecordingEventEmitter(); + globalThis.AudioEventEmitter = emitter as any; +}); + +describe('AudioScheduledSourceNode.onEnded', () => { + let nativeNode: ReturnType; + let node: AudioScheduledSourceNode; + + beforeEach(() => { + nativeNode = createNativeNode(); + node = new AudioScheduledSourceNode(createContext(), nativeNode); + }); + + it('registers a single listener and hands its id to the native node', () => { + node.onEnded = () => {}; + + expect(emitter.addAudioEventListener).toHaveBeenCalledTimes(1); + expect(emitter.addAudioEventListener).toHaveBeenCalledWith( + 'ended', + expect.any(Function) + ); + expect(nativeNode.onEnded).toBe('sub-1'); + }); + + it('exposes the assigned callback through the getter', () => { + const callback = () => {}; + node.onEnded = callback; + + expect(node.onEnded).toBe(callback); + }); + + it('removes the previous registration when the handler is reassigned', () => { + node.onEnded = () => {}; + node.onEnded = () => {}; + + expect(emitter.removeAudioEventListener).toHaveBeenCalledWith( + 'ended', + 'sub-1' + ); + expect(nativeNode.onEnded).toBe('sub-2'); + expect(emitter.outstanding()).toEqual(['sub-2']); + }); + + it('removes the registration when the handler is set to null', () => { + node.onEnded = () => {}; + node.onEnded = null; + + expect(emitter.removeAudioEventListener).toHaveBeenCalledWith( + 'ended', + 'sub-1' + ); + expect(nativeNode.onEnded).toBe('0'); + expect(node.onEnded).toBeUndefined(); + expect(emitter.outstanding()).toEqual([]); + }); + + it('does not attempt a removal when no handler was ever assigned', () => { + node.onEnded = null; + + expect(emitter.removeAudioEventListener).not.toHaveBeenCalled(); + expect(nativeNode.onEnded).toBe('0'); + }); + + it('leaves nothing registered after repeated assignment and clearing', () => { + for (let i = 0; i < 5; i++) { + node.onEnded = () => {}; + } + node.onEnded = null; + + expect(emitter.outstanding()).toEqual([]); + }); +}); + +describe('AudioBufferBaseSourceNode.onPositionChanged', () => { + let nativeNode: ReturnType; + let node: AudioBufferBaseSourceNode; + + beforeEach(() => { + nativeNode = createNativeNode({ + detune: createNativeParam(0), + playbackRate: createNativeParam(1), + onPositionChanged: '0', + onPositionChangedInterval: 0, + }); + node = new AudioBufferBaseSourceNode(createContext(), nativeNode); + }); + + it('removes the previous registration when reassigned', () => { + node.onPositionChanged = () => {}; + node.onPositionChanged = () => {}; + + expect(emitter.removeAudioEventListener).toHaveBeenCalledWith( + 'positionChanged', + 'sub-1' + ); + expect(emitter.outstanding()).toEqual(['sub-2']); + }); + + it('removes the registration when set to null', () => { + node.onPositionChanged = () => {}; + node.onPositionChanged = null; + + expect(nativeNode.onPositionChanged).toBe('0'); + expect(emitter.outstanding()).toEqual([]); + }); +}); + +describe('AudioBufferQueueSourceNode.onBufferEnded', () => { + let nativeNode: ReturnType; + let node: AudioBufferQueueSourceNode; + + beforeEach(() => { + nativeNode = createNativeNode({ + detune: createNativeParam(0), + playbackRate: createNativeParam(1), + onBufferEnded: '0', + }); + const context = createContext(); + context.context = { createBufferQueueSource: () => nativeNode }; + node = new AudioBufferQueueSourceNode(context); + }); + + it('removes the previous registration when reassigned', () => { + node.onBufferEnded = () => {}; + node.onBufferEnded = () => {}; + + expect(emitter.outstanding()).toEqual(['sub-2']); + }); + + it('removes the registration when set to null', () => { + node.onBufferEnded = () => {}; + node.onBufferEnded = null; + + expect(nativeNode.onBufferEnded).toBe('0'); + expect(emitter.outstanding()).toEqual([]); + }); +}); + +describe('handlers on one node are independent of each other', () => { + it('clearing onEnded leaves an unrelated onPositionChanged registration alone', () => { + const nativeNode = createNativeNode({ + detune: createNativeParam(0), + playbackRate: createNativeParam(1), + onPositionChanged: '0', + }); + const node = new AudioBufferBaseSourceNode(createContext(), nativeNode); + + node.onEnded = () => {}; // sub-1 + node.onPositionChanged = () => {}; // sub-2 + node.onEnded = null; + + expect(emitter.outstanding()).toEqual(['sub-2']); + expect(nativeNode.onPositionChanged).toBe('sub-2'); + }); +}); diff --git a/packages/react-native-audio-api/wpt_tests/README.md b/packages/react-native-audio-api/wpt_tests/README.md index 0901f2f50..41c0ed6ca 100644 --- a/packages/react-native-audio-api/wpt_tests/README.md +++ b/packages/react-native-audio-api/wpt_tests/README.md @@ -8,6 +8,9 @@ This directory contains the Node.js bootstrap for running Web Audio WPT against - Native Node addon (`wpt_tests/src`) using JSI HostObjects via `node-api-jsi`. - JSI-backed runtime installation (`jsi_install.cpp`). - Smoke WPT harness (`wpt_tests/wpt/wpt-harness.mjs`) with allowlist + skip policy. + Test files run in short-lived worker processes (`wpt-worker.mjs`, 25 files per + worker by default): a native crash or hang costs one file instead of the run, + and the parent process never loads the native module, so its exit is instant. - Vendored Web Audio API tests under `wpt_tests/webaudio/` (~3 MB, full `webaudio` subtree). - Manual conformance reporting (`wpt-results.mjs`) that produces a [wpt.fyi](https://wpt.fyi/results/webaudio/the-audio-api?label=experimental&label=master&aligned)-style markdown table. @@ -68,7 +71,11 @@ run always produces identical, complete numbers. CI helpers: - `yarn wpt:ci-report`: build + smoke run with `--allow-failures` (always writes JSON) -- `yarn wpt:compare --baseline --candidate `: non-regression gate +- `yarn wpt:compare --baseline --candidate `: non-regression gate. + When both reports carry per-file data (`files` array, including failing subtest + names), the gate is exact: it also fails on new failing subtests hidden behind + unchanged pass counts, and on files that crashed, hung, or disappeared. Older + reports without `files` fall back to the category-count comparison. Useful flags: @@ -76,6 +83,10 @@ Useful flags: - `--profile full`: entire vendored `webaudio/` tree - `--report-json ` / `--write-markdown `: custom output locations - `--allow-failures`: exit 0 after a completed run even when assertions fail +- `--batch-size `: files per worker process (default 25); `0` disables + isolation and runs everything in one process +- `--inactivity-timeout `: kill a worker that emits no events for this + long, record the stuck file as `timeout`, and resume after it (default 120) - `--update-docs`: rewrite the summary block in the audiodocs coverage page - `yarn wpt:markdown`: regenerate markdown from an existing JSON report @@ -89,7 +100,12 @@ The published conformance summary lives in the docs, not here: - **Device-related instability in CI** - Node test backend is sink-less; keep tests within the smoke profile. - **Runner appears hung** - - Kill stale processes: `pkill -f wpt-harness.mjs` + - The inactivity watchdog kills a silent worker after `--inactivity-timeout` + and resumes past the stuck file, so a hang costs one file, not the run. + - A worker whose native teardown hangs after finishing its batch is killed + after a grace period — the log line "native teardown hung; killed" is + informational, not a test failure. + - Kill stale processes: `pkill -f wpt-harness.mjs; pkill -f wpt-worker.mjs` - Some tests are excluded in `wpt/skip-list.json` (crashtests, AudioWorklet, known engine hangs). - **Subset runs** - `yarn wpt --filter gain` or `node ./wpt_tests/wpt/wpt-harness.mjs --filter the-analysernode-interface` diff --git a/packages/react-native-audio-api/wpt_tests/src/SyncCallInvoker.cpp b/packages/react-native-audio-api/wpt_tests/src/SyncCallInvoker.cpp index 9e007da64..0e835e169 100644 --- a/packages/react-native-audio-api/wpt_tests/src/SyncCallInvoker.cpp +++ b/packages/react-native-audio-api/wpt_tests/src/SyncCallInvoker.cpp @@ -78,16 +78,31 @@ void SyncCallInvoker::invokeAsync(facebook::react::CallFunc &&func) noexcept { return; } - if (std::this_thread::get_id() == mainThreadId_ || tsfn_ == nullptr) { + // Before initialize() only the main thread exists; run inline. + if (tsfn_ == nullptr) { func(*runtime_); return; } + // Always queue — even from the JS thread. Running main-thread posts inline + // let them jump ahead of audio-thread posts already sitting in the queue, so + // promise-resolution order depended on which thread happened to enqueue + // first (the suspend-after-construct WPT flake). One queue gives one FIFO + // order, matching React Native's CallInvoker contract that invokeAsync never + // executes synchronously. auto *callFunc = new facebook::react::CallFunc(std::move(func)); napi_call_threadsafe_function(tsfn_, callFunc, napi_tsfn_blocking); } void SyncCallInvoker::invokeSync(facebook::react::CallFunc &&func) { + // Genuinely synchronous only on the JS thread. From any other thread a + // blocking wait would deadlock against the event loop this queue drains on, + // so those posts degrade to async FIFO — a documented harness limitation. + if (std::this_thread::get_id() == mainThreadId_ && runtime_ != nullptr) { + func(*runtime_); + return; + } + invokeAsync(std::move(func)); } diff --git a/packages/react-native-audio-api/wpt_tests/src/jsi_install.cpp b/packages/react-native-audio-api/wpt_tests/src/jsi_install.cpp index 7b2cbc674..dccc27672 100644 --- a/packages/react-native-audio-api/wpt_tests/src/jsi_install.cpp +++ b/packages/react-native-audio-api/wpt_tests/src/jsi_install.cpp @@ -54,6 +54,24 @@ void cleanupInstallState(void *data) { gInstallStates.erase(env); } +/// Advertised GC cost of one audio context HostObject. A context owns worker +/// threads (promise offloader, disposer, per-context pools) and buffers that +/// live outside the V8 heap; without this hint V8 sees a tiny object and lets +/// abandoned contexts linger for the rest of the process. +constexpr size_t kAudioContextExternalMemoryPressure = 8 * 1024 * 1024; + +Object makeContextObject( + Runtime &rt, + const std::shared_ptr &hostObject) { + auto object = Object::createFromHostObject(rt, hostObject); + try { + object.setExternalMemoryPressure(rt, kAudioContextExternalMemoryPressure); + } catch (...) { + // Runtimes without instrumentation support just skip the hint. + } + return object; +} + napi_value makeBoolean(napi_env env, bool value) { napi_value result; napi_get_boolean(env, value, &result); @@ -135,7 +153,7 @@ void installOfflineBindings( &rt, callInvoker); - return Object::createFromHostObject(rt, hostObject); + return makeContextObject(rt, hostObject); }); runtime.global().setProperty(runtime, "createOfflineAudioContext", createOfflineAudioContext); @@ -189,7 +207,7 @@ void installAudioContextBinding( &rt, callInvoker); - return Object::createFromHostObject(rt, hostObject); + return makeContextObject(rt, hostObject); }); runtime.global().setProperty(runtime, "createAudioContext", createAudioContext); diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs index 153a69af7..444d586da 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs @@ -98,6 +98,57 @@ const candidate = loadReport(options.candidate); const baselineCategories = categoryPassMap(baseline); const candidateCategories = categoryPassMap(candidate); +/** + * Exact per-file comparison. Category pass counts cannot see an equal-count + * swap (test X regresses while test Y starts passing), so when both reports + * carry per-file data — reports written before it exists fall back to the + * category-level gate — the failing-subtest sets are compared directly. + */ +function compareFiles(baselineReport, candidateReport) { + if (!Array.isArray(baselineReport.files) || !Array.isArray(candidateReport.files)) { + return null; + } + + const toMap = (report) => + new Map(report.files.map((file) => [file.path, file])); + const baseFiles = toMap(baselineReport); + const headFiles = toMap(candidateReport); + + const newFailures = []; // { path, subtests: string[] } + const brokenFiles = []; // { path, status } — crashed/hung/missing in candidate + const newFiles = []; // informational: files only the candidate ran + + for (const [filePath, base] of baseFiles) { + const head = headFiles.get(filePath); + if (head == null) { + brokenFiles.push({ path: filePath, status: 'missing' }); + continue; + } + if (head.status !== 'ok' && base.status === 'ok') { + brokenFiles.push({ path: filePath, status: head.status }); + continue; + } + + const baseFailures = new Set(base.failures ?? []); + const subtests = (head.failures ?? []).filter( + (message) => !baseFailures.has(message) + ); + if (subtests.length > 0) { + newFailures.push({ path: filePath, subtests }); + } + } + + for (const filePath of headFiles.keys()) { + if (!baseFiles.has(filePath)) { + newFiles.push(filePath); + } + } + + return { newFailures, brokenFiles, newFiles }; +} + +const fileComparison = compareFiles(baseline, candidate); + const regressions = []; const improvements = []; const unchanged = []; @@ -142,7 +193,10 @@ const summaryDelta = candidateSummaryPass - baselineSummaryPass; // pass maps, so the per-category diff stays clean while the overall pass count falls. // The summary therefore needs a regression check of its own. const summaryRegressed = summaryDelta < 0; -const hasRegression = regressions.length > 0 || summaryRegressed; +const fileRegressed = + fileComparison != null && + (fileComparison.newFailures.length > 0 || fileComparison.brokenFiles.length > 0); +const hasRegression = regressions.length > 0 || summaryRegressed || fileRegressed; const signed = (delta) => `${delta > 0 ? '+' : ''}${delta}`; @@ -154,8 +208,24 @@ const tableHeader = [ '| --- | ---: | ---: | ---: |', ]; +const regressionParts = []; +if (regressions.length > 0) { + regressionParts.push(`${regressions.length} regressed section(s)`); +} +if (fileComparison != null && fileComparison.newFailures.length > 0) { + regressionParts.push( + `${fileComparison.newFailures.length} file(s) with new failing subtests` + ); +} +if (fileComparison != null && fileComparison.brokenFiles.length > 0) { + regressionParts.push(`${fileComparison.brokenFiles.length} broken file(s)`); +} +if (regressionParts.length === 0 && summaryRegressed) { + regressionParts.push('overall pass count dropped'); +} + const verdict = hasRegression - ? `**FAIL** — ${regressions.length} regressed section(s)` + ? `**FAIL** — ${regressionParts.join(', ')}` : '**PASS** — no regressions'; const lines = [ @@ -170,6 +240,54 @@ if (changed.length > 0) { lines.push(...tableHeader, ...changed.map(formatRow), ''); } +if (fileComparison == null) { + lines.push( + '_Per-file data unavailable in one of the reports — category-level comparison only._', + '' + ); +} else { + const MAX_LISTED_SUBTESTS = 30; + const { newFailures, brokenFiles, newFiles } = fileComparison; + + if (brokenFiles.length > 0) { + lines.push('**Broken test files:**', ''); + for (const { path: filePath, status } of brokenFiles) { + lines.push(`- \`${filePath}\` — ${status}`); + } + lines.push(''); + } + + if (newFailures.length > 0) { + lines.push('**New failing subtests:**', ''); + let listed = 0; + for (const { path: filePath, subtests } of newFailures) { + lines.push(`- \`${filePath}\``); + for (const subtest of subtests) { + if (listed >= MAX_LISTED_SUBTESTS) { + break; + } + lines.push(` - ${subtest}`); + listed += 1; + } + } + const totalSubtests = newFailures.reduce( + (acc, { subtests }) => acc + subtests.length, + 0 + ); + if (totalSubtests > MAX_LISTED_SUBTESTS) { + lines.push(` - _…and ${totalSubtests - MAX_LISTED_SUBTESTS} more_`); + } + lines.push(''); + } + + if (newFiles.length > 0) { + lines.push( + `${newFiles.length} test file(s) ran only in the candidate (informational).`, + '' + ); + } +} + // Unchanged sections outnumber changed ones on almost every run, so they are collapsed // to keep the PR comment readable without dropping the full picture. if (unchanged.length > 0) { @@ -193,6 +311,17 @@ if (hasRegression) { for (const row of regressions) { console.error(` - ${row.label}: ${row.head} pass (was ${row.base}, delta ${row.delta})`); } + if (fileComparison != null) { + for (const { path: filePath, status } of fileComparison.brokenFiles) { + console.error(` - ${filePath}: ${status}`); + } + for (const { path: filePath, subtests } of fileComparison.newFailures) { + console.error(` - ${filePath}: ${subtests.length} new failing subtest(s)`); + for (const subtest of subtests.slice(0, 5)) { + console.error(` ${subtest}`); + } + } + } if (summaryRegressed) { console.error( ` - Overall summary: ${candidateSummaryPass} pass (was ${baselineSummaryPass}, delta ${summaryDelta})` diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-harness.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-harness.mjs index 1103ee0ac..42fd63c87 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wpt-harness.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-harness.mjs @@ -1,5 +1,6 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { fork } from 'node:child_process'; import chalk from 'chalk'; import { program } from 'commander'; @@ -10,6 +11,7 @@ import { createSequentialFilter, createWptEnvironment, getProfileAllowlist, + normalizeTestPath, printSummary, runSequentialWpt, } from './wpt-shared.mjs'; @@ -23,6 +25,7 @@ import { } from './wpt-results.mjs'; const harnessDir = path.dirname(fileURLToPath(import.meta.url)); +const workerPath = path.join(harnessDir, 'wpt-worker.mjs'); const defaultJsonReportPath = path.join(harnessDir, '..', 'results', 'latest.json'); const defaultMarkdownReportPath = path.join(harnessDir, '..', 'results', 'latest.md'); const defaultDocsPath = path.join( @@ -36,6 +39,16 @@ const defaultDocsPath = path.join( 'web-audio-api-coverage.mdx' ); +// How long a worker may stay silent (no reporter event) before the parent +// assumes a native hang, kills it, and resumes past the stuck file. Generous +// against the 10s per-test testharness timeout — a file holds many tests. +const DEFAULT_INACTIVITY_TIMEOUT_S = 120; + +// After a worker reports its batch done, its process.exit() still has to tear +// down native state. Give it this long to die on its own before SIGKILL — the +// parent already holds every result, so a hung teardown costs nothing. +const WORKER_EXIT_GRACE_MS = 10_000; + program .option('--list', 'List test files only') .option('--filter ', 'Additional regex filter for tests', '.*') @@ -45,6 +58,18 @@ program 'Test selection profile: smoke (the-audio-api) or full (entire webaudio tree)', 'smoke' ) + .option( + '--batch-size ', + 'Files per worker process; 0 runs everything in this process without isolation', + (value) => Number.parseInt(value, 10), + 25 + ) + .option( + '--inactivity-timeout ', + 'Kill a worker that produces no events for this long and resume past the stuck file', + (value) => Number.parseInt(value, 10), + DEFAULT_INACTIVITY_TIMEOUT_S + ) .option( '--report-json ', 'Write structured JSON results for markdown generation', @@ -85,6 +110,7 @@ let numPass = 0; let numFail = 0; let timerStarted = false; let summaryPrinted = false; +let activeWorker = null; const resultsCollector = new WptResultsCollector(); const startedAt = Date.now(); @@ -141,6 +167,7 @@ const signalExitCode = { const handleSignal = signal => { console.error(chalk.yellow(`\nReceived ${signal}; printing partial summary.`)); + activeWorker?.kill('SIGKILL'); printHarnessSummary(); writeReports(); // Interrupted runs always exit non-zero; --allow-failures only applies to completed runs. @@ -172,8 +199,152 @@ if (options.list) { process.exit(0); } -// Warm up the native module in the parent before running. -createWptEnvironment(); +/** + * Run one batch of files in a forked worker, forwarding its reporter events. + * + * @returns {Promise<{ + * startedFiles: string[], + * outcome: 'done' | 'crashed' | 'timeout', + * fileFailures: number, + * }>} `startedFiles` lists files the worker began, in order; on 'crashed' or + * 'timeout' the last entry (if any) is the file that never finished. + */ +function runWorkerBatch(files, reporter) { + return new Promise((resolve) => { + const worker = fork(workerPath, [], { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + }); + activeWorker = worker; + + const startedFiles = []; + let fileFailures = 0; + let doneReceived = false; + let settled = false; + let outcome = 'crashed'; + let inactivityTimer = null; + let exitGraceTimer = null; + + const settle = () => { + if (settled) { + return; + } + settled = true; + clearTimeout(inactivityTimer); + clearTimeout(exitGraceTimer); + activeWorker = null; + resolve({ startedFiles, outcome, fileFailures }); + }; + + const armInactivityTimer = () => { + clearTimeout(inactivityTimer); + if (options.inactivityTimeout <= 0) { + return; + } + inactivityTimer = setTimeout(() => { + outcome = 'timeout'; + worker.kill('SIGKILL'); + }, options.inactivityTimeout * 1000); + }; + + worker.on('message', (message) => { + armInactivityTimer(); + switch (message.type) { + case 'suite-start': + startedFiles.push(normalizeTestPath(message.name)); + reporter.startSuite(message.name); + break; + case 'pass': + reporter.pass(message.message); + break; + case 'fail': + reporter.fail(message.message); + break; + case 'stack': + reporter.reportStack(message.stack); + break; + case 'done': + doneReceived = true; + outcome = 'done'; + fileFailures = message.fileFailures ?? 0; + clearTimeout(inactivityTimer); + exitGraceTimer = setTimeout(() => { + console.error( + chalk.yellow( + '[wpt] worker finished its batch but its native teardown hung; killed.' + ) + ); + worker.kill('SIGKILL'); + }, WORKER_EXIT_GRACE_MS); + break; + default: + break; + } + }); + + worker.on('error', () => { + worker.kill('SIGKILL'); + }); + + worker.on('exit', () => { + // 'done' already fixed the outcome; otherwise the child died mid-batch + // (native crash) unless the watchdog set 'timeout' first. + if (!doneReceived && outcome !== 'timeout') { + outcome = 'crashed'; + } + settle(); + }); + + armInactivityTimer(); + worker.send({ files }); + }); +} + +/** + * Run all selected files through short-lived worker processes. + * + * A worker that crashes or hangs costs exactly one file: it is recorded as + * crashed and the remaining files of its batch are re-queued for a fresh + * worker. The parent never loads the native module, so its own exit is + * instant no matter what the audio engine's teardown does. + */ +async function runBatched(reporter) { + const queue = collectSelectedTestPaths({ + filterRegexp: options.filter, + includeCrashtests: options.includeCrashtests, + profile: options.profile, + }); + + let totalFileFailures = 0; + let crashedFiles = 0; + + while (queue.length > 0) { + const batch = queue.splice(0, options.batchSize); + const { startedFiles, outcome, fileFailures } = await runWorkerBatch( + batch, + reporter + ); + totalFileFailures += fileFailures; + + if (outcome === 'done') { + continue; + } + + // The file that never finished: the last one started, or — when the worker + // died before starting anything (e.g. the addon failed to load) — the first + // of the batch, so the queue always shrinks and the run always terminates. + const crashed = + startedFiles.length > 0 ? startedFiles[startedFiles.length - 1] : batch[0]; + const label = outcome === 'timeout' ? 'hung (no events)' : 'crashed the worker'; + console.error(chalk.red(`\n × ${crashed} ${label}; resuming after it.\n`)); + resultsCollector.markFileCrashed(crashed, outcome); + crashedFiles += 1; + + const crashedIndex = batch.indexOf(crashed); + queue.unshift(...batch.slice(crashedIndex + 1)); + } + + return { totalFileFailures, crashedFiles }; +} try { console.time('wpt-duration'); @@ -181,19 +352,36 @@ try { const numPassRef = { value: 0 }; const numFailRef = { value: 0 }; - const filter = createSequentialFilter({ - filterRegexp: options.filter, - includeCrashtests: options.includeCrashtests, - listOnly: false, - profile: options.profile, - }); const reporter = wrapReporter( createConsoleReporter({ numPassRef, numFailRef }), resultsCollector ); - const fileFailures = await runSequentialWpt({ filter, reporter }); + + let fileFailures = 0; + let crashedFiles = 0; + + if (options.batchSize > 0) { + ({ totalFileFailures: fileFailures, crashedFiles } = + await runBatched(reporter)); + } else { + // Legacy single-process mode: everything shares this process, including + // whatever native teardown process.exit() runs into. + createWptEnvironment(); + const filter = createSequentialFilter({ + filterRegexp: options.filter, + includeCrashtests: options.includeCrashtests, + listOnly: false, + profile: options.profile, + }); + fileFailures = await runSequentialWpt({ filter, reporter }); + } + numPass = numPassRef.value; numFail = numFailRef.value; + // A crashed file lost at least one test, and a file-level failure with no + // recorded subtest failures (a file that errored before producing subtests) + // must still fail the run. + numFail += crashedFiles; if (fileFailures > 0 && numFail === 0) { numFail = fileFailures; } diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-only/channel-merger-splitter-attribute-locks.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-only/channel-merger-splitter-attribute-locks.mjs index 0a20f13b0..2a0ae09b6 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wpt-only/channel-merger-splitter-attribute-locks.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-only/channel-merger-splitter-attribute-locks.mjs @@ -10,6 +10,11 @@ * library's core constructors. */ +import { + getCurrentTestWindow, + patchPrototypeOnce, +} from '../wpt-utils.mjs'; + /** * @param {object} node * @param {{ @@ -93,13 +98,16 @@ function lockMergerOrSplitterInstance(instance, window) { return instance; } -function wrapFactory(original, window) { +function wrapFactory(original) { if (typeof original !== 'function') { return original; } return function (...args) { - return lockMergerOrSplitterInstance(original.apply(this, args), window); + return lockMergerOrSplitterInstance( + original.apply(this, args), + getCurrentTestWindow() + ); }; } @@ -136,18 +144,19 @@ export function applyChannelMergerSplitterAttributeLocks(window) { continue; } + // Context prototypes are shared by every test window; patch them once. const proto = Ctor.prototype; - if (typeof proto.createChannelMerger === 'function') { - proto.createChannelMerger = wrapFactory( - proto.createChannelMerger, - window - ); - } - if (typeof proto.createChannelSplitter === 'function') { - proto.createChannelSplitter = wrapFactory( - proto.createChannelSplitter, - window - ); - } + patchPrototypeOnce( + proto, + 'createChannelMerger', + wrapFactory, + 'merger-splitter-lock' + ); + patchPrototypeOnce( + proto, + 'createChannelSplitter', + wrapFactory, + 'merger-splitter-lock' + ); } } diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-results.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-results.mjs index 3333f51a2..2f3ce92e8 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wpt-results.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-results.mjs @@ -142,6 +142,7 @@ function createEmptyCategoryStats() { files: 0, filesPassed: 0, filesFailed: 0, + filesCrashed: 0, runnableFiles: 0, skippedFiles: 0, skipReason: null, @@ -182,12 +183,15 @@ export function discoverAudioApiCategories({ .map(([key, meta]) => ({ key, ...meta })); } +// Bounds for the per-file failure lists stored in the JSON report, so a +// catastrophic run cannot balloon the CI artifact. +const MAX_FAILURES_PER_FILE = 100; +const MAX_FAILURE_MESSAGE_LENGTH = 200; + export class WptResultsCollector { #categories = new Map(); - #currentSuite = null; - #currentCategory = null; - #currentSuitePass = 0; - #currentSuiteFail = 0; + #files = []; + #currentFile = null; #ensureCategory(categoryKey) { if (!this.#categories.has(categoryKey)) { @@ -197,56 +201,117 @@ export class WptResultsCollector { } startSuite(name) { - if (this.#currentSuite != null) { - this.#finishSuite(); - } + this.#finishFile('ok'); - this.#currentSuite = normalizeTestPath(name); - this.#currentCategory = getCategoryKey(this.#currentSuite); - this.#currentSuitePass = 0; - this.#currentSuiteFail = 0; + const suitePath = normalizeTestPath(name); + this.#currentFile = { + path: suitePath, + category: getCategoryKey(suitePath), + pass: 0, + fail: 0, + failures: [], + startedAt: Date.now(), + }; - const category = this.#ensureCategory(this.#currentCategory); + const category = this.#ensureCategory(this.#currentFile.category); category.files += 1; } pass() { - this.#currentSuitePass += 1; - if (this.#currentCategory != null) { - this.#ensureCategory(this.#currentCategory).pass += 1; + if (this.#currentFile == null) { + return; } + this.#currentFile.pass += 1; + this.#ensureCategory(this.#currentFile.category).pass += 1; } - fail() { - this.#currentSuiteFail += 1; - if (this.#currentCategory != null) { - this.#ensureCategory(this.#currentCategory).fail += 1; + fail(message) { + if (this.#currentFile == null) { + return; } + this.#currentFile.fail += 1; + if ( + typeof message === 'string' && + this.#currentFile.failures.length < MAX_FAILURES_PER_FILE + ) { + this.#currentFile.failures.push( + message.slice(0, MAX_FAILURE_MESSAGE_LENGTH) + ); + } + this.#ensureCategory(this.#currentFile.category).fail += 1; } - #finishSuite() { - if (this.#currentCategory == null) { + /** + * Record that the file currently running (or the named file, if none is + * in flight) died without finishing — the worker process crashed or the + * inactivity watchdog killed it. Counted as a failed file in its category. + * + * @param {string} path + * @param {'crashed' | 'timeout'} status + */ + markFileCrashed(path, status) { + const suitePath = normalizeTestPath(path); + if (this.#currentFile?.path === suitePath) { + this.#finishFile(status); return; } - const category = this.#ensureCategory(this.#currentCategory); - if (this.#currentSuiteFail === 0 && this.#currentSuitePass > 0) { + const category = this.#ensureCategory(getCategoryKey(suitePath)); + category.files += 1; + category.filesFailed += 1; + category.filesCrashed += 1; + this.#files.push({ + path: suitePath, + pass: 0, + fail: 0, + failures: [], + status, + durationMs: null, + }); + } + + #finishFile(status) { + if (this.#currentFile == null) { + return; + } + + const file = this.#currentFile; + this.#currentFile = null; + + const category = this.#ensureCategory(file.category); + if (status !== 'ok') { + category.filesFailed += 1; + category.filesCrashed += 1; + } else if (file.fail === 0 && file.pass > 0) { category.filesPassed += 1; - } else if (this.#currentSuiteFail > 0) { + } else if (file.fail > 0) { category.filesFailed += 1; } + + this.#files.push({ + path: file.path, + pass: file.pass, + fail: file.fail, + failures: file.failures, + status, + durationMs: Date.now() - file.startedAt, + }); } finalize() { - this.#finishSuite(); - this.#currentSuite = null; - this.#currentCategory = null; + this.#finishFile('ok'); } getCategoryStats() { this.finalize(); return this.#categories; } + + /** Per-file outcomes in run order, for the exact non-regression compare. */ + getFileResults() { + this.finalize(); + return this.#files; + } } function formatRate(pass, total) { @@ -301,6 +366,7 @@ export function buildReport({ files: run.files, filesPassed: run.filesPassed, filesFailed: run.filesFailed, + filesCrashed: run.filesCrashed, runnableFiles, skippedFiles, skipped, @@ -320,6 +386,7 @@ export function buildReport({ acc.files += category.files; acc.filesPassed += category.filesPassed; acc.filesFailed += category.filesFailed; + acc.filesCrashed += category.filesCrashed; acc.runnableFiles += category.runnableFiles; return acc; }, @@ -329,6 +396,7 @@ export function buildReport({ files: 0, filesPassed: 0, filesFailed: 0, + filesCrashed: 0, runnableFiles: 0, skippedCategories: 0, } @@ -359,6 +427,7 @@ export function buildReport({ durationMs, summary, categories, + files: collector.getFileResults(), }; } @@ -673,7 +742,7 @@ export function wrapReporter(reporter, collector) { reporter.pass?.(message); }, fail: (message) => { - collector.fail(); + collector.fail(message); reporter.fail?.(message); }, reportStack: (stack) => { diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-shared.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-shared.mjs index ee07843ab..19741e1a6 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wpt-shared.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-shared.mjs @@ -9,6 +9,7 @@ import wptRunner from 'wpt-runner'; import { wrapAudioNodeConstructors } from './wrap-audio-node-constructors.mjs'; import { applyChannelMergerSplitterAttributeLocks } from './wpt-only/channel-merger-splitter-attribute-locks.mjs'; import { + setCurrentTestWindow, wrapAudioBufferCopyMethods, wrapWebAudioRealmErrors, } from './wpt-utils.mjs'; @@ -58,6 +59,11 @@ export function walkHtmlFiles(rootDir, prefix = '') { files = files.concat(walkHtmlFiles(rootDir, rel)); } else if (entry.name.endsWith('.html')) { files.push(rel); + } else if (entry.name.endsWith('.window.js')) { + // wpt-runner serves each .window.js as a synthesized .window.html + // test and reports it under that name; enumerate it the same way so + // batch scheduling and --list see what actually runs. + files.push(rel.replace(/\.window\.js$/, '.window.html')); } } return files; @@ -142,18 +148,60 @@ export function alignGlobalRealmConstructors(window) { } } +/** + * Record every realtime AudioContext a test constructs, so the harness can close + * the ones the test abandoned. Each context owns native worker threads that only + * a close() (or GC, eventually) releases; without this, leaked contexts pile up + * across files and the process ends the run holding hundreds of threads. + * OfflineAudioContext has no close() and winds down when its render finishes. + */ +function trackRealtimeAudioContexts(window, liveContexts) { + const Previous = window.AudioContext; + if (typeof Previous !== 'function') { + return; + } + + function Tracked(...args) { + const instance = Reflect.construct(Previous, args, new.target ?? Tracked); + liveContexts.add(instance); + return instance; + } + Tracked.prototype = Previous.prototype; + Object.defineProperty(Tracked, 'name', { value: Previous.name }); + window.AudioContext = Tracked; +} + export function createWptEnvironment() { const cleanupEmitter = new EventEmitter(); const { nodeAudioApi, audioApiForWindow } = loadNodeAudioApi(); let cancelPendingAnimationFrames = () => {}; + const liveAudioContexts = new Set(); cleanupEmitter.on('cleanup', () => { cancelPendingAnimationFrames(); + + // Close whatever realtime contexts the finished test left running. Tests + // that closed their own context make close() reject — swallowed on purpose. + for (const context of liveAudioContexts) { + try { + const result = context.close(); + if (typeof result?.catch === 'function') { + result.catch(() => {}); + } + } catch { + // Already closed or torn down. + } + } + liveAudioContexts.clear(); }); const setup = (window) => { cleanupEmitter.emit('cleanup'); + // Shared-prototype patches resolve the window through this rather than closing + // over it, so a finished test's window stays collectable. + setCurrentTestWindow(window); + setFloat32ArrayViewFactory( (buffer, byteOffset, length) => new window.Float32Array(buffer, byteOffset, length) @@ -174,6 +222,9 @@ export function createWptEnvironment() { window.requestAnimationFrame = animationFrame.requestAnimationFrame; window.cancelAnimationFrame = animationFrame.cancelAnimationFrame; cancelPendingAnimationFrames = animationFrame.cancelAll; + + // Last, so it wraps the outermost constructor and records real instances. + trackRealtimeAudioContexts(window, liveAudioContexts); }; return { setup, cleanupEmitter }; @@ -209,8 +260,17 @@ export function createSequentialFilter({ } export async function runSequentialWpt({ filter, reporter }) { - const { setup } = createWptEnvironment(); - return wptRunner(testsPath, { rootURL, setup, filter, reporter }); + const { setup, cleanupEmitter } = createWptEnvironment(); + const failures = await wptRunner(testsPath, { + rootURL, + setup, + filter, + reporter, + }); + // One final sweep for the last file — 'cleanup' otherwise only fires when the + // NEXT file's setup() runs. + cleanupEmitter.emit('cleanup'); + return failures; } export function createReporter(handlers) { diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-utils.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-utils.mjs index cf3cf9042..850be8c26 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wpt-utils.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-utils.mjs @@ -46,6 +46,71 @@ const WEB_AUDIO_CLASSES = [ 'WaveShaperNode', ]; +/** + * The jsdom window of the test file currently running. + * + * The Web Audio classes are loaded once and shared by every test window, so their + * prototypes must be patched once, not per window. A patch that closed over its + * `window` would keep that window — and every AudioContext created in it — alive for + * the rest of the run, so the shared patches read the current window from here instead. + */ +let currentTestWindow = null; + +export function setCurrentTestWindow(window) { + currentTestWindow = window; +} + +export function getCurrentTestWindow() { + return currentTestWindow; +} + +/** Prototype -> "layer:member" pairs already patched, so setup() cannot chain wrappers. */ +const patchedPrototypeMembers = new WeakMap(); + +/** + * Claim `prototype[key]` for one patch layer. Returns false if that layer already + * claimed it, which is how repeated setup() calls avoid stacking a new wrapper on the + * previous one. + * + * Layers are independent and compose: several of them legitimately wrap the same member + * (realm errors around the Float32Array assertion, say), and each must land exactly once. + */ +function claimPrototypeMember(prototype, key, layer) { + let patched = patchedPrototypeMembers.get(prototype); + if (patched == null) { + patched = new Set(); + patchedPrototypeMembers.set(prototype, patched); + } + const claim = `${layer}:${key}`; + if (patched.has(claim)) { + return false; + } + patched.add(claim); + return true; +} + +/** + * Install `wrap(original)` as `prototype[methodName]`, at most once per prototype. + * Later calls are no-ops, which keeps the wrapper depth at one however many test + * files run. + * + * @param {object} prototype + * @param {string} methodName + * @param {(original: Function) => Function} wrap + * @param {string} layer identifies the patch, so independent layers can each apply once + */ +export function patchPrototypeOnce(prototype, methodName, wrap, layer) { + if (prototype == null || typeof prototype[methodName] !== 'function') { + return; + } + + if (!claimPrototypeMember(prototype, methodName, layer)) { + return; + } + + prototype[methodName] = wrap(prototype[methodName]); +} + /** Node constructors already wrapped for invalid-argument TypeErrors. */ export const WRAPPED_NODE_CONSTRUCTORS = new Set([ 'AnalyserNode', @@ -116,21 +181,26 @@ function toWindowRealmPromise(window, thenable) { }); } -function wrapWithRealmErrors(window, fn) { +function wrapWithRealmErrors(fn) { return function (...args) { try { const result = fn.apply(this, args); if (isThenable(result)) { - return toWindowRealmPromise(window, result); + return toWindowRealmPromise(getCurrentTestWindow(), result); } return result; } catch (error) { - throw toWindowRealmError(window, error); + throw toWindowRealmError(getCurrentTestWindow(), error); } }; } -function wrapPrototypeMembers(window, ctor) { +/** + * Wrap every method and accessor on `ctor.prototype` so errors surface in the test's + * realm. The Web Audio classes are shared by all test windows, so each member is + * wrapped once and the wrapper looks the window up per call. + */ +function wrapPrototypeMembers(ctor) { if (typeof ctor !== 'function') { return; } @@ -146,24 +216,30 @@ function wrapPrototypeMembers(window, ctor) { continue; } - if (desc.get != null || desc.set != null) { + const isAccessor = desc.get != null || desc.set != null; + if (!isAccessor && typeof desc.value !== 'function') { + continue; + } + if (!claimPrototypeMember(proto, key, 'realm-errors')) { + continue; + } + + if (isAccessor) { const replacement = { ...desc }; if (desc.get != null) { - replacement.get = wrapWithRealmErrors(window, desc.get); + replacement.get = wrapWithRealmErrors(desc.get); } if (desc.set != null) { - replacement.set = wrapWithRealmErrors(window, desc.set); + replacement.set = wrapWithRealmErrors(desc.set); } Object.defineProperty(proto, key, replacement); continue; } - if (typeof desc.value === 'function') { - Object.defineProperty(proto, key, { - ...desc, - value: wrapWithRealmErrors(window, desc.value), - }); - } + Object.defineProperty(proto, key, { + ...desc, + value: wrapWithRealmErrors(desc.value), + }); } } @@ -188,7 +264,7 @@ function wrapConstructorWithRealmErrors(window, name) { export function wrapWebAudioRealmErrors(window) { for (const name of WEB_AUDIO_CLASSES) { - wrapPrototypeMembers(window, window[name]); + wrapPrototypeMembers(window[name]); if (!WRAPPED_NODE_CONSTRUCTORS.has(name)) { wrapConstructorWithRealmErrors(window, name); @@ -224,29 +300,25 @@ export function wrapAudioBufferCopyMethods(window) { return; } - const originalCopyFromChannel = AudioBuffer.prototype.copyFromChannel; - const originalCopyToChannel = AudioBuffer.prototype.copyToChannel; + patchPrototypeOnce( + AudioBuffer.prototype, + 'copyFromChannel', + (original) => + function copyFromChannel(destination, channelNumber, startInChannel = 0) { + assertFloat32Array(destination, 'destination', getCurrentTestWindow()); + return original.call(this, destination, channelNumber, startInChannel); + }, + 'float32-assert' + ); - AudioBuffer.prototype.copyFromChannel = function copyFromChannel( - destination, - channelNumber, - startInChannel = 0 - ) { - assertFloat32Array(destination, 'destination', window); - return originalCopyFromChannel.call( - this, - destination, - channelNumber, - startInChannel - ); - }; - - AudioBuffer.prototype.copyToChannel = function copyToChannel( - source, - channelNumber, - startInChannel = 0 - ) { - assertFloat32Array(source, 'source', window); - return originalCopyToChannel.call(this, source, channelNumber, startInChannel); - }; + patchPrototypeOnce( + AudioBuffer.prototype, + 'copyToChannel', + (original) => + function copyToChannel(source, channelNumber, startInChannel = 0) { + assertFloat32Array(source, 'source', getCurrentTestWindow()); + return original.call(this, source, channelNumber, startInChannel); + }, + 'float32-assert' + ); } diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-worker.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-worker.mjs new file mode 100644 index 000000000..42ba807e2 --- /dev/null +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-worker.mjs @@ -0,0 +1,64 @@ +/** + * Child process entry for batched WPT runs (see wpt-harness.mjs). + * + * Runs one batch of test files in-process and streams reporter events to the + * parent over IPC. Keeping batches in short-lived children means native state + * (audio threads, event-registry entries) accumulates only across a batch, a + * native crash loses one file instead of the whole run, and the final + * process.exit() of each child tears down a small heap — the parent survives + * even if that teardown hangs. + * + * Protocol (child -> parent): + * { type: 'suite-start', name } a test file began + * { type: 'pass', message } one subtest passed + * { type: 'fail', message } one subtest failed + * { type: 'stack', stack } stack trace for the preceding failure + * { type: 'done', fileFailures } batch finished; parent may kill us + * + * Parent -> child: a single { files: string[] } message starts the batch. + */ + +import { + createReporter, + normalizeTestPath, + runSequentialWpt, +} from './wpt-shared.mjs'; + +function send(message) { + // The parent may already have killed us (e.g. its inactivity watchdog fired + // while an event was in flight); losing that race is fine. + try { + process.send(message); + } catch { + // Channel closed — nothing left to report to. + } +} + +process.on('message', async ({ files }) => { + const batch = new Set(files.map(normalizeTestPath)); + + const reporter = createReporter({ + startSuite: (name) => send({ type: 'suite-start', name }), + pass: (message) => send({ type: 'pass', message }), + fail: (message) => send({ type: 'fail', message }), + reportStack: (stack) => send({ type: 'stack', stack }), + }); + + let fileFailures = 0; + try { + fileFailures = await runSequentialWpt({ + filter: (name) => batch.has(normalizeTestPath(name)), + reporter, + }); + } catch (error) { + send({ type: 'fail', message: `worker error: ${error.message}` }); + send({ type: 'stack', stack: error.stack ?? String(error) }); + fileFailures = Math.max(fileFailures, 1); + } + + send({ type: 'done', fileFailures }); + // Native teardown (audio thread joins, registry destruction) happens inside + // this exit. The parent holds every result already, waits briefly, and + // SIGKILLs us if teardown hangs — the historical CI "exit code 129" mode. + process.exit(0); +}); diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wrap-audio-node-constructors.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wrap-audio-node-constructors.mjs index 26b773f8e..871643b5f 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wrap-audio-node-constructors.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wrap-audio-node-constructors.mjs @@ -5,6 +5,8 @@ */ import { + getCurrentTestWindow, + patchPrototypeOnce, toWindowRealmError, WRAPPED_NODE_CONSTRUCTORS, } from './wpt-utils.mjs'; @@ -92,22 +94,21 @@ function wrapAudioNodeConnectDisconnect(window) { return; } - const originalConnect = AudioNode.prototype.connect; - const originalDisconnect = AudioNode.prototype.disconnect; - - AudioNode.prototype.connect = function connect(...args) { - try { - return originalConnect.apply(this, args); - } catch (error) { - throw toWindowRealmError(window, error); - } - }; - - AudioNode.prototype.disconnect = function disconnect(...args) { - try { - return originalDisconnect.apply(this, args); - } catch (error) { - throw toWindowRealmError(window, error); - } - }; + // AudioNode is shared by every test window, so these go on once and resolve the + // window at call time — see patchPrototypeOnce. + for (const methodName of ['connect', 'disconnect']) { + patchPrototypeOnce( + AudioNode.prototype, + methodName, + (original) => + function (...args) { + try { + return original.apply(this, args); + } catch (error) { + throw toWindowRealmError(getCurrentTestWindow(), error); + } + }, + 'connect-realm-errors' + ); + } } From 185ed381aabd2cf5820fee2f27f3e6d2051805e2 Mon Sep 17 00:00:00 2001 From: michal Date: Thu, 20 Aug 2026 20:19:59 +0200 Subject: [PATCH 2/9] feat: best performing context state model --- .../src/core/AudioBufferBaseSourceNode.ts | 2 - .../src/core/AudioBufferSourceNode.ts | 1 - .../src/core/AudioContext.ts | 24 +++++--- .../src/core/BaseAudioContext.ts | 55 ++++++++++++++++++- .../src/core/OfflineAudioContext.ts | 40 ++++++++++++-- .../tests/audio-event-subscriptions.test.ts | 2 +- .../wpt_tests/wpt/skip-list.json | 12 ++++ .../wpt_tests/wpt/wpt-worker.mjs | 20 +++++++ 8 files changed, 137 insertions(+), 19 deletions(-) diff --git a/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts b/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts index 52e9b3ce4..a16c0beb3 100644 --- a/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts @@ -32,8 +32,6 @@ export default class AudioBufferBaseSourceNode extends AudioScheduledSourceNode public set onPositionChanged( callback: ((event: EventTypeWithValue) => void) | null ) { - // See the note in AudioScheduledSourceNode.onEnded: the native registry holds a - // strong reference, so the outgoing handler must be released here. this.onPositionChangedSubscription?.remove(); this.onPositionChangedSubscription = null; diff --git a/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts b/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts index 41f5e9632..2972fac2d 100644 --- a/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts @@ -122,7 +122,6 @@ export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { } public set onLoopEnded(callback: ((event: EventEmptyType) => void) | null) { - // See the note in AudioScheduledSourceNode.onEnded. this.onLoopEndedSubscription?.remove(); this.onLoopEndedSubscription = null; diff --git a/packages/react-native-audio-api/src/core/AudioContext.ts b/packages/react-native-audio-api/src/core/AudioContext.ts index 8941e656c..a07acef85 100644 --- a/packages/react-native-audio-api/src/core/AudioContext.ts +++ b/packages/react-native-audio-api/src/core/AudioContext.ts @@ -33,8 +33,9 @@ export default class AudioContext extends BaseAudioContext { throw new InvalidStateError('Cannot close a closed audio context.'); } - this._state = 'closed'; - return (this.context as IAudioContext).close(); + this.setControlState('closed'); + await (this.context as IAudioContext).close(); + this.publishState('closed'); } async resume(): Promise { @@ -42,8 +43,9 @@ export default class AudioContext extends BaseAudioContext { throw new InvalidStateError('Cannot resume a closed audio context.'); } - this._state = 'running'; - return (this.context as IAudioContext).resume(); + this.setControlState('running'); + await (this.context as IAudioContext).resume(); + this.publishState('running'); } async suspend(): Promise { @@ -51,8 +53,9 @@ export default class AudioContext extends BaseAudioContext { throw new InvalidStateError('Cannot suspend a closed audio context.'); } - this._state = 'suspended'; - return (this.context as IAudioContext).suspend(); + this.setControlState('suspended'); + await (this.context as IAudioContext).suspend(); + this.publishState('suspended'); } /** @@ -62,8 +65,13 @@ export default class AudioContext extends BaseAudioContext { */ public override markRunningOnSourceStart(): void { if (this._state === 'suspended') { - this._state = 'running'; - (this.context as IAudioContext).resume(); + this.setControlState('running'); + (this.context as IAudioContext) + .resume() + .then(() => this.publishState('running')) + .catch(() => { + // The driver refused to start; the attribute keeps reporting reality. + }); } } diff --git a/packages/react-native-audio-api/src/core/BaseAudioContext.ts b/packages/react-native-audio-api/src/core/BaseAudioContext.ts index aa20c53ef..c18f2adfe 100644 --- a/packages/react-native-audio-api/src/core/BaseAudioContext.ts +++ b/packages/react-native-audio-api/src/core/BaseAudioContext.ts @@ -25,6 +25,11 @@ import PeriodicWave from './PeriodicWave'; import StereoPannerNode from './StereoPannerNode'; import WaveShaperNode from './WaveShaperNode'; +export interface ContextStateChangeEvent { + type: 'statechange'; + target: BaseAudioContext; +} + export default class BaseAudioContext { readonly destination: AudioDestinationNode; readonly listener: AudioListener; @@ -38,14 +43,62 @@ export default class BaseAudioContext { this.sampleRate = context.sampleRate; } + /** + * The spec's [[control thread state]]: written synchronously the moment an + * operation is accepted, so the NEXT call validates against what has already + * been requested (e.g. close() right after resume() must see 'running'). + * Never exposed — the `state` attribute reports acknowledged reality + * instead. + */ protected _state: ContextState = 'suspended'; + /** + * The `state` attribute value: published only once a transition is + * acknowledged (the operation's promise resolved, rendering reached the + * suspend point, the offline render completed). Continuations of the + * operation's promise observe the new value; `statechange` fires afterwards. + */ + private publishedState: ContextState = 'suspended'; + + /** + * Web Audio API `statechange` event handler. Fired from a queued task after + * the `state` attribute changes — after the operation's promise resolution + * and all of its microtasks, matching the spec's media-element-task order. + */ + public onstatechange: ((event: ContextStateChangeEvent) => void) | null = + null; + + /** + * Record that a state transition has been requested ([[control thread + * state]]). + */ + protected setControlState(nextState: ContextState): void { + this._state = nextState; + } + + /** + * Publish an acknowledged transition to the `state` attribute and dispatch + * `statechange`. Also aligns the control ledger, for transitions the control + * side could not anticipate (an offline render reaching its suspend point). + */ + protected publishState(nextState: ContextState): void { + this._state = nextState; + if (this.publishedState === nextState) { + return; + } + + this.publishedState = nextState; + setTimeout(() => { + this.onstatechange?.({ type: 'statechange', target: this }); + }, 0); + } + public get currentTime(): number { return this.context.currentTime; } public get state(): ContextState { - return this._state; + return this.publishedState; } /** diff --git a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts index d715a84d7..938ceceb1 100644 --- a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts +++ b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts @@ -5,10 +5,23 @@ import { OfflineAudioContextOptions } from '../types'; import AudioBuffer from './AudioBuffer'; import BaseAudioContext from './BaseAudioContext'; +export interface OfflineAudioCompletionEvent { + type: 'complete'; + target: OfflineAudioContext; + renderedBuffer: AudioBuffer; +} + export default class OfflineAudioContext extends BaseAudioContext { private isRendering: boolean; private duration: number; + /** + * Web Audio API `complete` event handler, dispatched when startRendering() + * finishes. Kept alongside the promise because plenty of code (and the WPT + * suite) never awaits the promise and relies on this event alone. + */ + public oncomplete: ((event: OfflineAudioCompletionEvent) => void) | null; + constructor(options: OfflineAudioContextOptions); constructor(numberOfChannels: number, length: number, sampleRate: number); constructor( @@ -41,6 +54,7 @@ export default class OfflineAudioContext extends BaseAudioContext { } this.isRendering = false; + this.oncomplete = null; } async resume(): Promise { @@ -56,8 +70,9 @@ export default class OfflineAudioContext extends BaseAudioContext { ); } - this._state = 'running'; - return (this.context as IOfflineAudioContext).resume(); + this.setControlState('running'); + await (this.context as IOfflineAudioContext).resume(); + this.publishState('running'); } async suspend(suspendTime: number): Promise { @@ -81,10 +96,12 @@ export default class OfflineAudioContext extends BaseAudioContext { throw new InvalidStateError('the rendering is already finished'); } + // The suspend promise resolves when rendering reaches the suspend point — + // the acknowledgment the spec publishes the state change on. const result = await (this.context as IOfflineAudioContext).suspend( suspendTime ); - this._state = 'suspended'; + this.publishState('suspended'); return result; } @@ -94,12 +111,23 @@ export default class OfflineAudioContext extends BaseAudioContext { } this.isRendering = true; - this._state = 'running'; + this.publishState('running'); const audioBuffer = await ( this.context as IOfflineAudioContext ).startRendering(); - this._state = 'closed'; + this.publishState('closed'); + + const renderedBuffer = new AudioBuffer(audioBuffer); + // A task, not a microtask: `statechange` (queued by publishState above) + // must fire before `complete`, per the spec's ordering. + setTimeout(() => { + this.oncomplete?.({ + type: 'complete', + target: this, + renderedBuffer, + }); + }, 0); - return new AudioBuffer(audioBuffer); + return renderedBuffer; } } diff --git a/packages/react-native-audio-api/tests/audio-event-subscriptions.test.ts b/packages/react-native-audio-api/tests/audio-event-subscriptions.test.ts index da46ff205..42f9c6b12 100644 --- a/packages/react-native-audio-api/tests/audio-event-subscriptions.test.ts +++ b/packages/react-native-audio-api/tests/audio-event-subscriptions.test.ts @@ -1,7 +1,7 @@ /* eslint-disable */ /** - * Event-handler attributes (`onended`, `onloopended`, `onpositionchanged`, ...) must not + * Event-handler attributes (`onEnded`, `onloopended`, `onpositionchanged`, ...) must not * accumulate listener registrations in the native AudioEventHandlerRegistry. * * The registry is process-global and stores each handler as a `std::shared_ptr`, diff --git a/packages/react-native-audio-api/wpt_tests/wpt/skip-list.json b/packages/react-native-audio-api/wpt_tests/wpt/skip-list.json index 2a50a129a..12f91711e 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/skip-list.json +++ b/packages/react-native-audio-api/wpt_tests/wpt/skip-list.json @@ -54,5 +54,17 @@ { "pattern": "the-channelmergernode-interface/active-processing.https.html", "reason": "requires AudioWorklet" + }, + { + "pattern": "the-audiocontext-interface/constructor-allowed-to-start", + "reason": "waits for the autoplay suspended->running statechange; this library starts the native driver lazily (first source start() or resume()), never from the constructor, so the transition never happens and every subtest hangs to the 10s harness timeout" + }, + { + "pattern": "the-audiocontext-interface/audiocontext-suspend-resume-close", + "reason": "waits for the autoplay suspended->running statechange; this library starts the native driver lazily (first source start() or resume()), never from the constructor, so the transition never happens and every subtest hangs to the 10s harness timeout" + }, + { + "pattern": "the-audiocontext-interface/audiocontext-state-change-after-close", + "reason": "waits for the autoplay suspended->running statechange; this library starts the native driver lazily (first source start() or resume()), never from the constructor, so the transition never happens and every subtest hangs to the 10s harness timeout" } ] diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-worker.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-worker.mjs index 42ba807e2..43f7b568e 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wpt-worker.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-worker.mjs @@ -23,6 +23,26 @@ import { normalizeTestPath, runSequentialWpt, } from './wpt-shared.mjs'; +import { getCurrentTestWindow } from './wpt-utils.mjs'; + +// In a browser, an exception thrown from an event handler (e.g. an assert +// inside `oncomplete`) surfaces as a window `error` event, which testharness +// turns into a fast harness failure. In Node the same throw would kill this +// worker. Forward it into the running test's window so the file fails +// immediately instead of crashing the batch or idling into the 10s timeout. +process.on('uncaughtException', (error) => { + const window = getCurrentTestWindow(); + try { + window.dispatchEvent( + new window.ErrorEvent('error', { + error, + message: String(error?.message ?? error), + }) + ); + } catch { + console.error('uncaught exception with no active test window:', error); + } +}); function send(message) { // The parent may already have killed us (e.g. its inactivity watchdog fired From 907bd2621803afd11b3fc87dbd8b2454dc6923ac Mon Sep 17 00:00:00 2001 From: michal Date: Fri, 21 Aug 2026 13:06:05 +0200 Subject: [PATCH 3/9] feat: best performing context state model --- .../src/core/AudioContext.ts | 24 +++++--- .../src/core/BaseAudioContext.ts | 55 ++++++++++++++++++- .../src/core/OfflineAudioContext.ts | 40 ++++++++++++-- .../wpt_tests/wpt/skip-list.json | 12 ++++ 4 files changed, 116 insertions(+), 15 deletions(-) diff --git a/packages/react-native-audio-api/src/core/AudioContext.ts b/packages/react-native-audio-api/src/core/AudioContext.ts index 8941e656c..a07acef85 100644 --- a/packages/react-native-audio-api/src/core/AudioContext.ts +++ b/packages/react-native-audio-api/src/core/AudioContext.ts @@ -33,8 +33,9 @@ export default class AudioContext extends BaseAudioContext { throw new InvalidStateError('Cannot close a closed audio context.'); } - this._state = 'closed'; - return (this.context as IAudioContext).close(); + this.setControlState('closed'); + await (this.context as IAudioContext).close(); + this.publishState('closed'); } async resume(): Promise { @@ -42,8 +43,9 @@ export default class AudioContext extends BaseAudioContext { throw new InvalidStateError('Cannot resume a closed audio context.'); } - this._state = 'running'; - return (this.context as IAudioContext).resume(); + this.setControlState('running'); + await (this.context as IAudioContext).resume(); + this.publishState('running'); } async suspend(): Promise { @@ -51,8 +53,9 @@ export default class AudioContext extends BaseAudioContext { throw new InvalidStateError('Cannot suspend a closed audio context.'); } - this._state = 'suspended'; - return (this.context as IAudioContext).suspend(); + this.setControlState('suspended'); + await (this.context as IAudioContext).suspend(); + this.publishState('suspended'); } /** @@ -62,8 +65,13 @@ export default class AudioContext extends BaseAudioContext { */ public override markRunningOnSourceStart(): void { if (this._state === 'suspended') { - this._state = 'running'; - (this.context as IAudioContext).resume(); + this.setControlState('running'); + (this.context as IAudioContext) + .resume() + .then(() => this.publishState('running')) + .catch(() => { + // The driver refused to start; the attribute keeps reporting reality. + }); } } diff --git a/packages/react-native-audio-api/src/core/BaseAudioContext.ts b/packages/react-native-audio-api/src/core/BaseAudioContext.ts index aa20c53ef..c18f2adfe 100644 --- a/packages/react-native-audio-api/src/core/BaseAudioContext.ts +++ b/packages/react-native-audio-api/src/core/BaseAudioContext.ts @@ -25,6 +25,11 @@ import PeriodicWave from './PeriodicWave'; import StereoPannerNode from './StereoPannerNode'; import WaveShaperNode from './WaveShaperNode'; +export interface ContextStateChangeEvent { + type: 'statechange'; + target: BaseAudioContext; +} + export default class BaseAudioContext { readonly destination: AudioDestinationNode; readonly listener: AudioListener; @@ -38,14 +43,62 @@ export default class BaseAudioContext { this.sampleRate = context.sampleRate; } + /** + * The spec's [[control thread state]]: written synchronously the moment an + * operation is accepted, so the NEXT call validates against what has already + * been requested (e.g. close() right after resume() must see 'running'). + * Never exposed — the `state` attribute reports acknowledged reality + * instead. + */ protected _state: ContextState = 'suspended'; + /** + * The `state` attribute value: published only once a transition is + * acknowledged (the operation's promise resolved, rendering reached the + * suspend point, the offline render completed). Continuations of the + * operation's promise observe the new value; `statechange` fires afterwards. + */ + private publishedState: ContextState = 'suspended'; + + /** + * Web Audio API `statechange` event handler. Fired from a queued task after + * the `state` attribute changes — after the operation's promise resolution + * and all of its microtasks, matching the spec's media-element-task order. + */ + public onstatechange: ((event: ContextStateChangeEvent) => void) | null = + null; + + /** + * Record that a state transition has been requested ([[control thread + * state]]). + */ + protected setControlState(nextState: ContextState): void { + this._state = nextState; + } + + /** + * Publish an acknowledged transition to the `state` attribute and dispatch + * `statechange`. Also aligns the control ledger, for transitions the control + * side could not anticipate (an offline render reaching its suspend point). + */ + protected publishState(nextState: ContextState): void { + this._state = nextState; + if (this.publishedState === nextState) { + return; + } + + this.publishedState = nextState; + setTimeout(() => { + this.onstatechange?.({ type: 'statechange', target: this }); + }, 0); + } + public get currentTime(): number { return this.context.currentTime; } public get state(): ContextState { - return this._state; + return this.publishedState; } /** diff --git a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts index d715a84d7..938ceceb1 100644 --- a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts +++ b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts @@ -5,10 +5,23 @@ import { OfflineAudioContextOptions } from '../types'; import AudioBuffer from './AudioBuffer'; import BaseAudioContext from './BaseAudioContext'; +export interface OfflineAudioCompletionEvent { + type: 'complete'; + target: OfflineAudioContext; + renderedBuffer: AudioBuffer; +} + export default class OfflineAudioContext extends BaseAudioContext { private isRendering: boolean; private duration: number; + /** + * Web Audio API `complete` event handler, dispatched when startRendering() + * finishes. Kept alongside the promise because plenty of code (and the WPT + * suite) never awaits the promise and relies on this event alone. + */ + public oncomplete: ((event: OfflineAudioCompletionEvent) => void) | null; + constructor(options: OfflineAudioContextOptions); constructor(numberOfChannels: number, length: number, sampleRate: number); constructor( @@ -41,6 +54,7 @@ export default class OfflineAudioContext extends BaseAudioContext { } this.isRendering = false; + this.oncomplete = null; } async resume(): Promise { @@ -56,8 +70,9 @@ export default class OfflineAudioContext extends BaseAudioContext { ); } - this._state = 'running'; - return (this.context as IOfflineAudioContext).resume(); + this.setControlState('running'); + await (this.context as IOfflineAudioContext).resume(); + this.publishState('running'); } async suspend(suspendTime: number): Promise { @@ -81,10 +96,12 @@ export default class OfflineAudioContext extends BaseAudioContext { throw new InvalidStateError('the rendering is already finished'); } + // The suspend promise resolves when rendering reaches the suspend point — + // the acknowledgment the spec publishes the state change on. const result = await (this.context as IOfflineAudioContext).suspend( suspendTime ); - this._state = 'suspended'; + this.publishState('suspended'); return result; } @@ -94,12 +111,23 @@ export default class OfflineAudioContext extends BaseAudioContext { } this.isRendering = true; - this._state = 'running'; + this.publishState('running'); const audioBuffer = await ( this.context as IOfflineAudioContext ).startRendering(); - this._state = 'closed'; + this.publishState('closed'); + + const renderedBuffer = new AudioBuffer(audioBuffer); + // A task, not a microtask: `statechange` (queued by publishState above) + // must fire before `complete`, per the spec's ordering. + setTimeout(() => { + this.oncomplete?.({ + type: 'complete', + target: this, + renderedBuffer, + }); + }, 0); - return new AudioBuffer(audioBuffer); + return renderedBuffer; } } diff --git a/packages/react-native-audio-api/wpt_tests/wpt/skip-list.json b/packages/react-native-audio-api/wpt_tests/wpt/skip-list.json index 2a50a129a..12f91711e 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/skip-list.json +++ b/packages/react-native-audio-api/wpt_tests/wpt/skip-list.json @@ -54,5 +54,17 @@ { "pattern": "the-channelmergernode-interface/active-processing.https.html", "reason": "requires AudioWorklet" + }, + { + "pattern": "the-audiocontext-interface/constructor-allowed-to-start", + "reason": "waits for the autoplay suspended->running statechange; this library starts the native driver lazily (first source start() or resume()), never from the constructor, so the transition never happens and every subtest hangs to the 10s harness timeout" + }, + { + "pattern": "the-audiocontext-interface/audiocontext-suspend-resume-close", + "reason": "waits for the autoplay suspended->running statechange; this library starts the native driver lazily (first source start() or resume()), never from the constructor, so the transition never happens and every subtest hangs to the 10s harness timeout" + }, + { + "pattern": "the-audiocontext-interface/audiocontext-state-change-after-close", + "reason": "waits for the autoplay suspended->running statechange; this library starts the native driver lazily (first source start() or resume()), never from the constructor, so the transition never happens and every subtest hangs to the 10s harness timeout" } ] From 64c543af380ebe236dd6c35ac14bfb7a8007623f Mon Sep 17 00:00:00 2001 From: michal Date: Thu, 20 Aug 2026 19:07:22 +0200 Subject: [PATCH 4/9] fix: proper gc in the wpt tests --- .../src/Audio/AudioFileSourceNode.ts | 33 ++- .../src/core/AudioBufferBaseSourceNode.ts | 13 +- .../src/core/AudioBufferQueueSourceNode.ts | 14 +- .../src/core/AudioBufferSourceNode.ts | 11 +- .../src/core/AudioScheduledSourceNode.ts | 14 +- .../tests/audio-event-subscriptions.test.ts | 238 ++++++++++++++++++ .../wpt_tests/README.md | 20 +- .../wpt_tests/src/SyncCallInvoker.cpp | 17 +- .../wpt_tests/src/jsi_install.cpp | 22 +- .../wpt_tests/wpt/wpt-compare.mjs | 133 +++++++++- .../wpt_tests/wpt/wpt-harness.mjs | 206 ++++++++++++++- ...hannel-merger-splitter-attribute-locks.mjs | 37 +-- .../wpt_tests/wpt/wpt-results.mjs | 125 ++++++--- .../wpt_tests/wpt/wpt-shared.mjs | 64 ++++- .../wpt_tests/wpt/wpt-utils.mjs | 148 ++++++++--- .../wpt_tests/wpt/wpt-worker.mjs | 84 +++++++ .../wpt/wrap-audio-node-constructors.mjs | 37 +-- 17 files changed, 1076 insertions(+), 140 deletions(-) create mode 100644 packages/react-native-audio-api/tests/audio-event-subscriptions.test.ts create mode 100644 packages/react-native-audio-api/wpt_tests/wpt/wpt-worker.mjs diff --git a/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts b/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts index 2f82eccac..4a8856419 100644 --- a/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts +++ b/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts @@ -1,4 +1,4 @@ -import { AudioEventEmitter } from '../events'; +import { AudioEventEmitter, AudioEventSubscription } from '../events'; import type { EventEmptyType } from '../events/types'; import type { IAudioFileSourceNode, @@ -18,16 +18,21 @@ export class AudioFileSourceNode extends AudioScheduledSourceNode { globalThis.AudioEventEmitter ); + private endedSubscription: AudioEventSubscription | null = null; + private positionSubscription: AudioEventSubscription | null = null; + private bufferingSubscription: AudioEventSubscription | null = null; + attach(options: AttachFileSourceOptions): { duration: number } { this.resetNodeAndSubscriptions(); - const sub = this.emitter.addAudioEventListener( + this.endedSubscription = this.emitter.addAudioEventListener( 'ended', (_event: EventEmptyType) => { options.onEnded(); } ); - (this.node as IAudioFileSourceNode).onEnded = sub.subscriptionId; + (this.node as IAudioFileSourceNode).onEnded = + this.endedSubscription.subscriptionId; return { duration: (this.node as IAudioFileSourceNode).duration, @@ -93,16 +98,20 @@ export class AudioFileSourceNode extends AudioScheduledSourceNode { return; } this.stopPositionTracking(); - const sub = this.emitter.addAudioEventListener( + this.positionSubscription = this.emitter.addAudioEventListener( 'positionChanged', (event) => { onTime(event.value); } ); - (this.node as IAudioFileSourceNode).onPositionChanged = sub.subscriptionId; + (this.node as IAudioFileSourceNode).onPositionChanged = + this.positionSubscription.subscriptionId; } stopPositionTracking(): void { + this.positionSubscription?.remove(); + this.positionSubscription = null; + if (this.node) { (this.node as IAudioFileSourceNode).onPositionChanged = '0'; } @@ -115,26 +124,32 @@ export class AudioFileSourceNode extends AudioScheduledSourceNode { return; } this.stopBufferingTracking(); - const sub = this.emitter.addAudioEventListener( + this.bufferingSubscription = this.emitter.addAudioEventListener( 'bufferingStateChanged', (event) => { onBufferingChange(event.value); } ); (this.node as IAudioFileSourceNode).onBufferingStateChanged = - sub.subscriptionId; + this.bufferingSubscription.subscriptionId; } stopBufferingTracking(): void { + this.bufferingSubscription?.remove(); + this.bufferingSubscription = null; + if (this.node) { (this.node as IAudioFileSourceNode).onBufferingStateChanged = '0'; } } private resetNodeAndSubscriptions(): void { + this.stopPositionTracking(); + this.stopBufferingTracking(); + this.endedSubscription?.remove(); + this.endedSubscription = null; + if (this.node) { - (this.node as IAudioFileSourceNode).onPositionChanged = '0'; - (this.node as IAudioFileSourceNode).onBufferingStateChanged = '0'; (this.node as IAudioFileSourceNode).onEnded = '0'; this.node.disconnect(undefined); } diff --git a/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts b/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts index d9d01ab8a..a16c0beb3 100644 --- a/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioBufferBaseSourceNode.ts @@ -4,11 +4,13 @@ import { EventTypeWithValue } from '../events/types'; import { IAudioBufferBaseSourceNode } from '../jsi-interfaces'; import AudioScheduledSourceNode from './AudioScheduledSourceNode'; import { AudioNodeOptions } from '../types'; +import { AudioEventSubscription } from '../events'; export default class AudioBufferBaseSourceNode extends AudioScheduledSourceNode { readonly playbackRate: AudioParam; readonly detune: AudioParam; private onPositionChangedCallback?: (event: EventTypeWithValue) => void; + private onPositionChangedSubscription: AudioEventSubscription | null = null; constructor( context: BaseAudioContext, @@ -30,6 +32,9 @@ export default class AudioBufferBaseSourceNode extends AudioScheduledSourceNode public set onPositionChanged( callback: ((event: EventTypeWithValue) => void) | null ) { + this.onPositionChangedSubscription?.remove(); + this.onPositionChangedSubscription = null; + if (!callback) { (this.node as IAudioBufferBaseSourceNode).onPositionChanged = '0'; this.onPositionChangedCallback = undefined; @@ -37,13 +42,11 @@ export default class AudioBufferBaseSourceNode extends AudioScheduledSourceNode } this.onPositionChangedCallback = callback; - const sub = this.audioEventEmitter.addAudioEventListener( - 'positionChanged', - callback - ); + this.onPositionChangedSubscription = + this.audioEventEmitter.addAudioEventListener('positionChanged', callback); (this.node as IAudioBufferBaseSourceNode).onPositionChanged = - sub.subscriptionId; + this.onPositionChangedSubscription.subscriptionId; } public get onPositionChangedInterval(): number { diff --git a/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts b/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts index 0a3a7f957..3e4111247 100644 --- a/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioBufferQueueSourceNode.ts @@ -8,9 +8,11 @@ import { AudioBufferQueueSourceState, } from '../types'; import { OnBufferEndEventType } from '../events/types'; +import { AudioEventSubscription } from '../events'; export default class AudioBufferQueueSourceNode extends AudioBufferBaseSourceNode { private onBufferEndedCallback?: (event: OnBufferEndEventType) => void; + private onBufferEndedSubscription: AudioEventSubscription | null = null; private state: AudioBufferQueueSourceState = AudioBufferQueueSourceState.IDLE; constructor( @@ -60,6 +62,7 @@ export default class AudioBufferQueueSourceNode extends AudioBufferBaseSourceNod this.state = AudioBufferQueueSourceState.PLAYING; (this.node as IAudioBufferQueueSourceNode).start(when, offset); + this.context.markRunningOnSourceStart(); } public override stop(when: number = 0): void { @@ -82,6 +85,9 @@ export default class AudioBufferQueueSourceNode extends AudioBufferBaseSourceNod public set onBufferEnded( callback: ((event: OnBufferEndEventType) => void) | null ) { + this.onBufferEndedSubscription?.remove(); + this.onBufferEndedSubscription = null; + if (!callback) { (this.node as IAudioBufferQueueSourceNode).onBufferEnded = '0'; this.onBufferEndedCallback = undefined; @@ -89,13 +95,11 @@ export default class AudioBufferQueueSourceNode extends AudioBufferBaseSourceNod } this.onBufferEndedCallback = callback; - const sub = this.audioEventEmitter.addAudioEventListener( - 'bufferEnded', - callback - ); + this.onBufferEndedSubscription = + this.audioEventEmitter.addAudioEventListener('bufferEnded', callback); (this.node as IAudioBufferQueueSourceNode).onBufferEnded = - sub.subscriptionId; + this.onBufferEndedSubscription.subscriptionId; } public pause(): void { diff --git a/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts b/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts index b5dbee150..2972fac2d 100644 --- a/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts @@ -5,9 +5,11 @@ import { InvalidStateError, RangeError } from '../errors'; import { EventEmptyType } from '../events/types'; import { AudioBufferSourceOptions } from '../types'; import type BaseAudioContext from './BaseAudioContext'; +import { AudioEventSubscription } from '../events'; export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { private onLoopEndedCallback?: (event: EventEmptyType) => void; + private onLoopEndedSubscription: AudioEventSubscription | null = null; private _buffer: AudioBuffer | null = null; private bufferHasBeenSet: boolean = false; @@ -112,6 +114,7 @@ export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { this.hasBeenStarted = true; (this.node as IAudioBufferSourceNode).start(when, offset, duration); + this.context.markRunningOnSourceStart(); } public get onLoopEnded(): ((event: EventEmptyType) => void) | undefined { @@ -119,6 +122,9 @@ export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { } public set onLoopEnded(callback: ((event: EventEmptyType) => void) | null) { + this.onLoopEndedSubscription?.remove(); + this.onLoopEndedSubscription = null; + if (!callback) { (this.node as IAudioBufferSourceNode).onLoopEnded = '0'; this.onLoopEndedCallback = undefined; @@ -126,11 +132,12 @@ export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { } this.onLoopEndedCallback = callback; - const sub = this.audioEventEmitter.addAudioEventListener( + this.onLoopEndedSubscription = this.audioEventEmitter.addAudioEventListener( 'loopEnded', callback ); - (this.node as IAudioBufferSourceNode).onLoopEnded = sub.subscriptionId; + (this.node as IAudioBufferSourceNode).onLoopEnded = + this.onLoopEndedSubscription.subscriptionId; } } diff --git a/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts b/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts index 45c27dce7..ebade2571 100644 --- a/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts @@ -2,7 +2,7 @@ import { IAudioScheduledSourceNode } from '../jsi-interfaces'; import AudioNode from './AudioNode'; import { InvalidStateError, RangeError } from '../errors'; import { EventEmptyType } from '../events/types'; -import { AudioEventEmitter } from '../events'; +import { AudioEventEmitter, AudioEventSubscription } from '../events'; export default class AudioScheduledSourceNode extends AudioNode { protected hasBeenStarted: boolean = false; @@ -11,6 +11,7 @@ export default class AudioScheduledSourceNode extends AudioNode { ); private onEndedCallback?: (event: EventEmptyType) => void; + private onEndedSubscription: AudioEventSubscription | null = null; public start(when: number = 0): void { if (when < 0) { @@ -49,6 +50,9 @@ export default class AudioScheduledSourceNode extends AudioNode { } public set onEnded(callback: ((event: EventEmptyType) => void) | null) { + this.onEndedSubscription?.remove(); + this.onEndedSubscription = null; + if (!callback) { (this.node as IAudioScheduledSourceNode).onEnded = '0'; this.onEndedCallback = undefined; @@ -56,8 +60,12 @@ export default class AudioScheduledSourceNode extends AudioNode { } this.onEndedCallback = callback; - const sub = this.audioEventEmitter.addAudioEventListener('ended', callback); + this.onEndedSubscription = this.audioEventEmitter.addAudioEventListener( + 'ended', + callback + ); - (this.node as IAudioScheduledSourceNode).onEnded = sub.subscriptionId; + (this.node as IAudioScheduledSourceNode).onEnded = + this.onEndedSubscription.subscriptionId; } } diff --git a/packages/react-native-audio-api/tests/audio-event-subscriptions.test.ts b/packages/react-native-audio-api/tests/audio-event-subscriptions.test.ts new file mode 100644 index 000000000..42f9c6b12 --- /dev/null +++ b/packages/react-native-audio-api/tests/audio-event-subscriptions.test.ts @@ -0,0 +1,238 @@ +/* eslint-disable */ + +/** + * Event-handler attributes (`onEnded`, `onloopended`, `onpositionchanged`, ...) must not + * accumulate listener registrations in the native AudioEventHandlerRegistry. + * + * The registry is process-global and stores each handler as a `std::shared_ptr`, + * so a registration that is never removed pins the callback — and, through its closure, the node, + * its context and every buffer they own — for the lifetime of the runtime. Nothing else can + * release it: it is reachable from C++, so no amount of JS garbage collection helps. + * + * The contract these tests pin down, matching how `AudioRecorder` already handles its own + * subscriptions: + * - assigning a handler registers exactly one listener, + * - reassigning replaces it (the previous registration is removed), + * - assigning null removes it, + * - once a node's handlers are cleared, no registration is left outstanding. + */ + +import AudioScheduledSourceNode from '../src/core/AudioScheduledSourceNode'; +import AudioBufferBaseSourceNode from '../src/core/AudioBufferBaseSourceNode'; +import AudioBufferQueueSourceNode from '../src/core/AudioBufferQueueSourceNode'; + +/** Records every add/remove so a test can assert nothing is left dangling. */ +class RecordingEventEmitter { + private nextId = 1; + readonly added: { name: string; id: string }[] = []; + readonly removed: { name: string; id: string }[] = []; + + addAudioEventListener = jest.fn((name: string, _callback: unknown): string => { + const id = `sub-${this.nextId++}`; + this.added.push({ name, id }); + return id; + }); + + removeAudioEventListener = jest.fn((name: string, id: string): void => { + this.removed.push({ name, id }); + }); + + /** Subscription ids handed out but never removed — i.e. leaked into the native registry. */ + outstanding(): string[] { + const removedIds = new Set(this.removed.map((entry) => entry.id)); + return this.added + .map((entry) => entry.id) + .filter((id) => !removedIds.has(id)); + } +} + +let emitter: RecordingEventEmitter; + +/** + * Minimal stand-in for the native HostObject. Only the members the TS wrappers touch are + * present; handler-id writes are kept so tests can check what the node was told. + */ +function createNativeNode(extra: Record = {}) { + return { + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'max', + channelInterpretation: 'speakers', + onEnded: '0', + ...extra, + } as any; +} + +function createContext() { + return { currentTime: 0, markRunningOnSourceStart: jest.fn() } as any; +} + +/** Stand-in for a native AudioParam HostObject, enough for the TS AudioParam wrapper. */ +function createNativeParam(value: number) { + return { + value, + defaultValue: value, + minValue: -3.4e38, + maxValue: 3.4e38, + checkCurveExclusion: () => ({ status: 'ok' }), + setValueAtTime: jest.fn(), + }; +} + +beforeEach(() => { + emitter = new RecordingEventEmitter(); + globalThis.AudioEventEmitter = emitter as any; +}); + +describe('AudioScheduledSourceNode.onEnded', () => { + let nativeNode: ReturnType; + let node: AudioScheduledSourceNode; + + beforeEach(() => { + nativeNode = createNativeNode(); + node = new AudioScheduledSourceNode(createContext(), nativeNode); + }); + + it('registers a single listener and hands its id to the native node', () => { + node.onEnded = () => {}; + + expect(emitter.addAudioEventListener).toHaveBeenCalledTimes(1); + expect(emitter.addAudioEventListener).toHaveBeenCalledWith( + 'ended', + expect.any(Function) + ); + expect(nativeNode.onEnded).toBe('sub-1'); + }); + + it('exposes the assigned callback through the getter', () => { + const callback = () => {}; + node.onEnded = callback; + + expect(node.onEnded).toBe(callback); + }); + + it('removes the previous registration when the handler is reassigned', () => { + node.onEnded = () => {}; + node.onEnded = () => {}; + + expect(emitter.removeAudioEventListener).toHaveBeenCalledWith( + 'ended', + 'sub-1' + ); + expect(nativeNode.onEnded).toBe('sub-2'); + expect(emitter.outstanding()).toEqual(['sub-2']); + }); + + it('removes the registration when the handler is set to null', () => { + node.onEnded = () => {}; + node.onEnded = null; + + expect(emitter.removeAudioEventListener).toHaveBeenCalledWith( + 'ended', + 'sub-1' + ); + expect(nativeNode.onEnded).toBe('0'); + expect(node.onEnded).toBeUndefined(); + expect(emitter.outstanding()).toEqual([]); + }); + + it('does not attempt a removal when no handler was ever assigned', () => { + node.onEnded = null; + + expect(emitter.removeAudioEventListener).not.toHaveBeenCalled(); + expect(nativeNode.onEnded).toBe('0'); + }); + + it('leaves nothing registered after repeated assignment and clearing', () => { + for (let i = 0; i < 5; i++) { + node.onEnded = () => {}; + } + node.onEnded = null; + + expect(emitter.outstanding()).toEqual([]); + }); +}); + +describe('AudioBufferBaseSourceNode.onPositionChanged', () => { + let nativeNode: ReturnType; + let node: AudioBufferBaseSourceNode; + + beforeEach(() => { + nativeNode = createNativeNode({ + detune: createNativeParam(0), + playbackRate: createNativeParam(1), + onPositionChanged: '0', + onPositionChangedInterval: 0, + }); + node = new AudioBufferBaseSourceNode(createContext(), nativeNode); + }); + + it('removes the previous registration when reassigned', () => { + node.onPositionChanged = () => {}; + node.onPositionChanged = () => {}; + + expect(emitter.removeAudioEventListener).toHaveBeenCalledWith( + 'positionChanged', + 'sub-1' + ); + expect(emitter.outstanding()).toEqual(['sub-2']); + }); + + it('removes the registration when set to null', () => { + node.onPositionChanged = () => {}; + node.onPositionChanged = null; + + expect(nativeNode.onPositionChanged).toBe('0'); + expect(emitter.outstanding()).toEqual([]); + }); +}); + +describe('AudioBufferQueueSourceNode.onBufferEnded', () => { + let nativeNode: ReturnType; + let node: AudioBufferQueueSourceNode; + + beforeEach(() => { + nativeNode = createNativeNode({ + detune: createNativeParam(0), + playbackRate: createNativeParam(1), + onBufferEnded: '0', + }); + const context = createContext(); + context.context = { createBufferQueueSource: () => nativeNode }; + node = new AudioBufferQueueSourceNode(context); + }); + + it('removes the previous registration when reassigned', () => { + node.onBufferEnded = () => {}; + node.onBufferEnded = () => {}; + + expect(emitter.outstanding()).toEqual(['sub-2']); + }); + + it('removes the registration when set to null', () => { + node.onBufferEnded = () => {}; + node.onBufferEnded = null; + + expect(nativeNode.onBufferEnded).toBe('0'); + expect(emitter.outstanding()).toEqual([]); + }); +}); + +describe('handlers on one node are independent of each other', () => { + it('clearing onEnded leaves an unrelated onPositionChanged registration alone', () => { + const nativeNode = createNativeNode({ + detune: createNativeParam(0), + playbackRate: createNativeParam(1), + onPositionChanged: '0', + }); + const node = new AudioBufferBaseSourceNode(createContext(), nativeNode); + + node.onEnded = () => {}; // sub-1 + node.onPositionChanged = () => {}; // sub-2 + node.onEnded = null; + + expect(emitter.outstanding()).toEqual(['sub-2']); + expect(nativeNode.onPositionChanged).toBe('sub-2'); + }); +}); diff --git a/packages/react-native-audio-api/wpt_tests/README.md b/packages/react-native-audio-api/wpt_tests/README.md index 0901f2f50..41c0ed6ca 100644 --- a/packages/react-native-audio-api/wpt_tests/README.md +++ b/packages/react-native-audio-api/wpt_tests/README.md @@ -8,6 +8,9 @@ This directory contains the Node.js bootstrap for running Web Audio WPT against - Native Node addon (`wpt_tests/src`) using JSI HostObjects via `node-api-jsi`. - JSI-backed runtime installation (`jsi_install.cpp`). - Smoke WPT harness (`wpt_tests/wpt/wpt-harness.mjs`) with allowlist + skip policy. + Test files run in short-lived worker processes (`wpt-worker.mjs`, 25 files per + worker by default): a native crash or hang costs one file instead of the run, + and the parent process never loads the native module, so its exit is instant. - Vendored Web Audio API tests under `wpt_tests/webaudio/` (~3 MB, full `webaudio` subtree). - Manual conformance reporting (`wpt-results.mjs`) that produces a [wpt.fyi](https://wpt.fyi/results/webaudio/the-audio-api?label=experimental&label=master&aligned)-style markdown table. @@ -68,7 +71,11 @@ run always produces identical, complete numbers. CI helpers: - `yarn wpt:ci-report`: build + smoke run with `--allow-failures` (always writes JSON) -- `yarn wpt:compare --baseline --candidate `: non-regression gate +- `yarn wpt:compare --baseline --candidate `: non-regression gate. + When both reports carry per-file data (`files` array, including failing subtest + names), the gate is exact: it also fails on new failing subtests hidden behind + unchanged pass counts, and on files that crashed, hung, or disappeared. Older + reports without `files` fall back to the category-count comparison. Useful flags: @@ -76,6 +83,10 @@ Useful flags: - `--profile full`: entire vendored `webaudio/` tree - `--report-json ` / `--write-markdown `: custom output locations - `--allow-failures`: exit 0 after a completed run even when assertions fail +- `--batch-size `: files per worker process (default 25); `0` disables + isolation and runs everything in one process +- `--inactivity-timeout `: kill a worker that emits no events for this + long, record the stuck file as `timeout`, and resume after it (default 120) - `--update-docs`: rewrite the summary block in the audiodocs coverage page - `yarn wpt:markdown`: regenerate markdown from an existing JSON report @@ -89,7 +100,12 @@ The published conformance summary lives in the docs, not here: - **Device-related instability in CI** - Node test backend is sink-less; keep tests within the smoke profile. - **Runner appears hung** - - Kill stale processes: `pkill -f wpt-harness.mjs` + - The inactivity watchdog kills a silent worker after `--inactivity-timeout` + and resumes past the stuck file, so a hang costs one file, not the run. + - A worker whose native teardown hangs after finishing its batch is killed + after a grace period — the log line "native teardown hung; killed" is + informational, not a test failure. + - Kill stale processes: `pkill -f wpt-harness.mjs; pkill -f wpt-worker.mjs` - Some tests are excluded in `wpt/skip-list.json` (crashtests, AudioWorklet, known engine hangs). - **Subset runs** - `yarn wpt --filter gain` or `node ./wpt_tests/wpt/wpt-harness.mjs --filter the-analysernode-interface` diff --git a/packages/react-native-audio-api/wpt_tests/src/SyncCallInvoker.cpp b/packages/react-native-audio-api/wpt_tests/src/SyncCallInvoker.cpp index 9e007da64..0e835e169 100644 --- a/packages/react-native-audio-api/wpt_tests/src/SyncCallInvoker.cpp +++ b/packages/react-native-audio-api/wpt_tests/src/SyncCallInvoker.cpp @@ -78,16 +78,31 @@ void SyncCallInvoker::invokeAsync(facebook::react::CallFunc &&func) noexcept { return; } - if (std::this_thread::get_id() == mainThreadId_ || tsfn_ == nullptr) { + // Before initialize() only the main thread exists; run inline. + if (tsfn_ == nullptr) { func(*runtime_); return; } + // Always queue — even from the JS thread. Running main-thread posts inline + // let them jump ahead of audio-thread posts already sitting in the queue, so + // promise-resolution order depended on which thread happened to enqueue + // first (the suspend-after-construct WPT flake). One queue gives one FIFO + // order, matching React Native's CallInvoker contract that invokeAsync never + // executes synchronously. auto *callFunc = new facebook::react::CallFunc(std::move(func)); napi_call_threadsafe_function(tsfn_, callFunc, napi_tsfn_blocking); } void SyncCallInvoker::invokeSync(facebook::react::CallFunc &&func) { + // Genuinely synchronous only on the JS thread. From any other thread a + // blocking wait would deadlock against the event loop this queue drains on, + // so those posts degrade to async FIFO — a documented harness limitation. + if (std::this_thread::get_id() == mainThreadId_ && runtime_ != nullptr) { + func(*runtime_); + return; + } + invokeAsync(std::move(func)); } diff --git a/packages/react-native-audio-api/wpt_tests/src/jsi_install.cpp b/packages/react-native-audio-api/wpt_tests/src/jsi_install.cpp index 7b2cbc674..dccc27672 100644 --- a/packages/react-native-audio-api/wpt_tests/src/jsi_install.cpp +++ b/packages/react-native-audio-api/wpt_tests/src/jsi_install.cpp @@ -54,6 +54,24 @@ void cleanupInstallState(void *data) { gInstallStates.erase(env); } +/// Advertised GC cost of one audio context HostObject. A context owns worker +/// threads (promise offloader, disposer, per-context pools) and buffers that +/// live outside the V8 heap; without this hint V8 sees a tiny object and lets +/// abandoned contexts linger for the rest of the process. +constexpr size_t kAudioContextExternalMemoryPressure = 8 * 1024 * 1024; + +Object makeContextObject( + Runtime &rt, + const std::shared_ptr &hostObject) { + auto object = Object::createFromHostObject(rt, hostObject); + try { + object.setExternalMemoryPressure(rt, kAudioContextExternalMemoryPressure); + } catch (...) { + // Runtimes without instrumentation support just skip the hint. + } + return object; +} + napi_value makeBoolean(napi_env env, bool value) { napi_value result; napi_get_boolean(env, value, &result); @@ -135,7 +153,7 @@ void installOfflineBindings( &rt, callInvoker); - return Object::createFromHostObject(rt, hostObject); + return makeContextObject(rt, hostObject); }); runtime.global().setProperty(runtime, "createOfflineAudioContext", createOfflineAudioContext); @@ -189,7 +207,7 @@ void installAudioContextBinding( &rt, callInvoker); - return Object::createFromHostObject(rt, hostObject); + return makeContextObject(rt, hostObject); }); runtime.global().setProperty(runtime, "createAudioContext", createAudioContext); diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs index 153a69af7..444d586da 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-compare.mjs @@ -98,6 +98,57 @@ const candidate = loadReport(options.candidate); const baselineCategories = categoryPassMap(baseline); const candidateCategories = categoryPassMap(candidate); +/** + * Exact per-file comparison. Category pass counts cannot see an equal-count + * swap (test X regresses while test Y starts passing), so when both reports + * carry per-file data — reports written before it exists fall back to the + * category-level gate — the failing-subtest sets are compared directly. + */ +function compareFiles(baselineReport, candidateReport) { + if (!Array.isArray(baselineReport.files) || !Array.isArray(candidateReport.files)) { + return null; + } + + const toMap = (report) => + new Map(report.files.map((file) => [file.path, file])); + const baseFiles = toMap(baselineReport); + const headFiles = toMap(candidateReport); + + const newFailures = []; // { path, subtests: string[] } + const brokenFiles = []; // { path, status } — crashed/hung/missing in candidate + const newFiles = []; // informational: files only the candidate ran + + for (const [filePath, base] of baseFiles) { + const head = headFiles.get(filePath); + if (head == null) { + brokenFiles.push({ path: filePath, status: 'missing' }); + continue; + } + if (head.status !== 'ok' && base.status === 'ok') { + brokenFiles.push({ path: filePath, status: head.status }); + continue; + } + + const baseFailures = new Set(base.failures ?? []); + const subtests = (head.failures ?? []).filter( + (message) => !baseFailures.has(message) + ); + if (subtests.length > 0) { + newFailures.push({ path: filePath, subtests }); + } + } + + for (const filePath of headFiles.keys()) { + if (!baseFiles.has(filePath)) { + newFiles.push(filePath); + } + } + + return { newFailures, brokenFiles, newFiles }; +} + +const fileComparison = compareFiles(baseline, candidate); + const regressions = []; const improvements = []; const unchanged = []; @@ -142,7 +193,10 @@ const summaryDelta = candidateSummaryPass - baselineSummaryPass; // pass maps, so the per-category diff stays clean while the overall pass count falls. // The summary therefore needs a regression check of its own. const summaryRegressed = summaryDelta < 0; -const hasRegression = regressions.length > 0 || summaryRegressed; +const fileRegressed = + fileComparison != null && + (fileComparison.newFailures.length > 0 || fileComparison.brokenFiles.length > 0); +const hasRegression = regressions.length > 0 || summaryRegressed || fileRegressed; const signed = (delta) => `${delta > 0 ? '+' : ''}${delta}`; @@ -154,8 +208,24 @@ const tableHeader = [ '| --- | ---: | ---: | ---: |', ]; +const regressionParts = []; +if (regressions.length > 0) { + regressionParts.push(`${regressions.length} regressed section(s)`); +} +if (fileComparison != null && fileComparison.newFailures.length > 0) { + regressionParts.push( + `${fileComparison.newFailures.length} file(s) with new failing subtests` + ); +} +if (fileComparison != null && fileComparison.brokenFiles.length > 0) { + regressionParts.push(`${fileComparison.brokenFiles.length} broken file(s)`); +} +if (regressionParts.length === 0 && summaryRegressed) { + regressionParts.push('overall pass count dropped'); +} + const verdict = hasRegression - ? `**FAIL** — ${regressions.length} regressed section(s)` + ? `**FAIL** — ${regressionParts.join(', ')}` : '**PASS** — no regressions'; const lines = [ @@ -170,6 +240,54 @@ if (changed.length > 0) { lines.push(...tableHeader, ...changed.map(formatRow), ''); } +if (fileComparison == null) { + lines.push( + '_Per-file data unavailable in one of the reports — category-level comparison only._', + '' + ); +} else { + const MAX_LISTED_SUBTESTS = 30; + const { newFailures, brokenFiles, newFiles } = fileComparison; + + if (brokenFiles.length > 0) { + lines.push('**Broken test files:**', ''); + for (const { path: filePath, status } of brokenFiles) { + lines.push(`- \`${filePath}\` — ${status}`); + } + lines.push(''); + } + + if (newFailures.length > 0) { + lines.push('**New failing subtests:**', ''); + let listed = 0; + for (const { path: filePath, subtests } of newFailures) { + lines.push(`- \`${filePath}\``); + for (const subtest of subtests) { + if (listed >= MAX_LISTED_SUBTESTS) { + break; + } + lines.push(` - ${subtest}`); + listed += 1; + } + } + const totalSubtests = newFailures.reduce( + (acc, { subtests }) => acc + subtests.length, + 0 + ); + if (totalSubtests > MAX_LISTED_SUBTESTS) { + lines.push(` - _…and ${totalSubtests - MAX_LISTED_SUBTESTS} more_`); + } + lines.push(''); + } + + if (newFiles.length > 0) { + lines.push( + `${newFiles.length} test file(s) ran only in the candidate (informational).`, + '' + ); + } +} + // Unchanged sections outnumber changed ones on almost every run, so they are collapsed // to keep the PR comment readable without dropping the full picture. if (unchanged.length > 0) { @@ -193,6 +311,17 @@ if (hasRegression) { for (const row of regressions) { console.error(` - ${row.label}: ${row.head} pass (was ${row.base}, delta ${row.delta})`); } + if (fileComparison != null) { + for (const { path: filePath, status } of fileComparison.brokenFiles) { + console.error(` - ${filePath}: ${status}`); + } + for (const { path: filePath, subtests } of fileComparison.newFailures) { + console.error(` - ${filePath}: ${subtests.length} new failing subtest(s)`); + for (const subtest of subtests.slice(0, 5)) { + console.error(` ${subtest}`); + } + } + } if (summaryRegressed) { console.error( ` - Overall summary: ${candidateSummaryPass} pass (was ${baselineSummaryPass}, delta ${summaryDelta})` diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-harness.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-harness.mjs index 1103ee0ac..42fd63c87 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wpt-harness.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-harness.mjs @@ -1,5 +1,6 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { fork } from 'node:child_process'; import chalk from 'chalk'; import { program } from 'commander'; @@ -10,6 +11,7 @@ import { createSequentialFilter, createWptEnvironment, getProfileAllowlist, + normalizeTestPath, printSummary, runSequentialWpt, } from './wpt-shared.mjs'; @@ -23,6 +25,7 @@ import { } from './wpt-results.mjs'; const harnessDir = path.dirname(fileURLToPath(import.meta.url)); +const workerPath = path.join(harnessDir, 'wpt-worker.mjs'); const defaultJsonReportPath = path.join(harnessDir, '..', 'results', 'latest.json'); const defaultMarkdownReportPath = path.join(harnessDir, '..', 'results', 'latest.md'); const defaultDocsPath = path.join( @@ -36,6 +39,16 @@ const defaultDocsPath = path.join( 'web-audio-api-coverage.mdx' ); +// How long a worker may stay silent (no reporter event) before the parent +// assumes a native hang, kills it, and resumes past the stuck file. Generous +// against the 10s per-test testharness timeout — a file holds many tests. +const DEFAULT_INACTIVITY_TIMEOUT_S = 120; + +// After a worker reports its batch done, its process.exit() still has to tear +// down native state. Give it this long to die on its own before SIGKILL — the +// parent already holds every result, so a hung teardown costs nothing. +const WORKER_EXIT_GRACE_MS = 10_000; + program .option('--list', 'List test files only') .option('--filter ', 'Additional regex filter for tests', '.*') @@ -45,6 +58,18 @@ program 'Test selection profile: smoke (the-audio-api) or full (entire webaudio tree)', 'smoke' ) + .option( + '--batch-size ', + 'Files per worker process; 0 runs everything in this process without isolation', + (value) => Number.parseInt(value, 10), + 25 + ) + .option( + '--inactivity-timeout ', + 'Kill a worker that produces no events for this long and resume past the stuck file', + (value) => Number.parseInt(value, 10), + DEFAULT_INACTIVITY_TIMEOUT_S + ) .option( '--report-json ', 'Write structured JSON results for markdown generation', @@ -85,6 +110,7 @@ let numPass = 0; let numFail = 0; let timerStarted = false; let summaryPrinted = false; +let activeWorker = null; const resultsCollector = new WptResultsCollector(); const startedAt = Date.now(); @@ -141,6 +167,7 @@ const signalExitCode = { const handleSignal = signal => { console.error(chalk.yellow(`\nReceived ${signal}; printing partial summary.`)); + activeWorker?.kill('SIGKILL'); printHarnessSummary(); writeReports(); // Interrupted runs always exit non-zero; --allow-failures only applies to completed runs. @@ -172,8 +199,152 @@ if (options.list) { process.exit(0); } -// Warm up the native module in the parent before running. -createWptEnvironment(); +/** + * Run one batch of files in a forked worker, forwarding its reporter events. + * + * @returns {Promise<{ + * startedFiles: string[], + * outcome: 'done' | 'crashed' | 'timeout', + * fileFailures: number, + * }>} `startedFiles` lists files the worker began, in order; on 'crashed' or + * 'timeout' the last entry (if any) is the file that never finished. + */ +function runWorkerBatch(files, reporter) { + return new Promise((resolve) => { + const worker = fork(workerPath, [], { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + }); + activeWorker = worker; + + const startedFiles = []; + let fileFailures = 0; + let doneReceived = false; + let settled = false; + let outcome = 'crashed'; + let inactivityTimer = null; + let exitGraceTimer = null; + + const settle = () => { + if (settled) { + return; + } + settled = true; + clearTimeout(inactivityTimer); + clearTimeout(exitGraceTimer); + activeWorker = null; + resolve({ startedFiles, outcome, fileFailures }); + }; + + const armInactivityTimer = () => { + clearTimeout(inactivityTimer); + if (options.inactivityTimeout <= 0) { + return; + } + inactivityTimer = setTimeout(() => { + outcome = 'timeout'; + worker.kill('SIGKILL'); + }, options.inactivityTimeout * 1000); + }; + + worker.on('message', (message) => { + armInactivityTimer(); + switch (message.type) { + case 'suite-start': + startedFiles.push(normalizeTestPath(message.name)); + reporter.startSuite(message.name); + break; + case 'pass': + reporter.pass(message.message); + break; + case 'fail': + reporter.fail(message.message); + break; + case 'stack': + reporter.reportStack(message.stack); + break; + case 'done': + doneReceived = true; + outcome = 'done'; + fileFailures = message.fileFailures ?? 0; + clearTimeout(inactivityTimer); + exitGraceTimer = setTimeout(() => { + console.error( + chalk.yellow( + '[wpt] worker finished its batch but its native teardown hung; killed.' + ) + ); + worker.kill('SIGKILL'); + }, WORKER_EXIT_GRACE_MS); + break; + default: + break; + } + }); + + worker.on('error', () => { + worker.kill('SIGKILL'); + }); + + worker.on('exit', () => { + // 'done' already fixed the outcome; otherwise the child died mid-batch + // (native crash) unless the watchdog set 'timeout' first. + if (!doneReceived && outcome !== 'timeout') { + outcome = 'crashed'; + } + settle(); + }); + + armInactivityTimer(); + worker.send({ files }); + }); +} + +/** + * Run all selected files through short-lived worker processes. + * + * A worker that crashes or hangs costs exactly one file: it is recorded as + * crashed and the remaining files of its batch are re-queued for a fresh + * worker. The parent never loads the native module, so its own exit is + * instant no matter what the audio engine's teardown does. + */ +async function runBatched(reporter) { + const queue = collectSelectedTestPaths({ + filterRegexp: options.filter, + includeCrashtests: options.includeCrashtests, + profile: options.profile, + }); + + let totalFileFailures = 0; + let crashedFiles = 0; + + while (queue.length > 0) { + const batch = queue.splice(0, options.batchSize); + const { startedFiles, outcome, fileFailures } = await runWorkerBatch( + batch, + reporter + ); + totalFileFailures += fileFailures; + + if (outcome === 'done') { + continue; + } + + // The file that never finished: the last one started, or — when the worker + // died before starting anything (e.g. the addon failed to load) — the first + // of the batch, so the queue always shrinks and the run always terminates. + const crashed = + startedFiles.length > 0 ? startedFiles[startedFiles.length - 1] : batch[0]; + const label = outcome === 'timeout' ? 'hung (no events)' : 'crashed the worker'; + console.error(chalk.red(`\n × ${crashed} ${label}; resuming after it.\n`)); + resultsCollector.markFileCrashed(crashed, outcome); + crashedFiles += 1; + + const crashedIndex = batch.indexOf(crashed); + queue.unshift(...batch.slice(crashedIndex + 1)); + } + + return { totalFileFailures, crashedFiles }; +} try { console.time('wpt-duration'); @@ -181,19 +352,36 @@ try { const numPassRef = { value: 0 }; const numFailRef = { value: 0 }; - const filter = createSequentialFilter({ - filterRegexp: options.filter, - includeCrashtests: options.includeCrashtests, - listOnly: false, - profile: options.profile, - }); const reporter = wrapReporter( createConsoleReporter({ numPassRef, numFailRef }), resultsCollector ); - const fileFailures = await runSequentialWpt({ filter, reporter }); + + let fileFailures = 0; + let crashedFiles = 0; + + if (options.batchSize > 0) { + ({ totalFileFailures: fileFailures, crashedFiles } = + await runBatched(reporter)); + } else { + // Legacy single-process mode: everything shares this process, including + // whatever native teardown process.exit() runs into. + createWptEnvironment(); + const filter = createSequentialFilter({ + filterRegexp: options.filter, + includeCrashtests: options.includeCrashtests, + listOnly: false, + profile: options.profile, + }); + fileFailures = await runSequentialWpt({ filter, reporter }); + } + numPass = numPassRef.value; numFail = numFailRef.value; + // A crashed file lost at least one test, and a file-level failure with no + // recorded subtest failures (a file that errored before producing subtests) + // must still fail the run. + numFail += crashedFiles; if (fileFailures > 0 && numFail === 0) { numFail = fileFailures; } diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-only/channel-merger-splitter-attribute-locks.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-only/channel-merger-splitter-attribute-locks.mjs index 0a20f13b0..2a0ae09b6 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wpt-only/channel-merger-splitter-attribute-locks.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-only/channel-merger-splitter-attribute-locks.mjs @@ -10,6 +10,11 @@ * library's core constructors. */ +import { + getCurrentTestWindow, + patchPrototypeOnce, +} from '../wpt-utils.mjs'; + /** * @param {object} node * @param {{ @@ -93,13 +98,16 @@ function lockMergerOrSplitterInstance(instance, window) { return instance; } -function wrapFactory(original, window) { +function wrapFactory(original) { if (typeof original !== 'function') { return original; } return function (...args) { - return lockMergerOrSplitterInstance(original.apply(this, args), window); + return lockMergerOrSplitterInstance( + original.apply(this, args), + getCurrentTestWindow() + ); }; } @@ -136,18 +144,19 @@ export function applyChannelMergerSplitterAttributeLocks(window) { continue; } + // Context prototypes are shared by every test window; patch them once. const proto = Ctor.prototype; - if (typeof proto.createChannelMerger === 'function') { - proto.createChannelMerger = wrapFactory( - proto.createChannelMerger, - window - ); - } - if (typeof proto.createChannelSplitter === 'function') { - proto.createChannelSplitter = wrapFactory( - proto.createChannelSplitter, - window - ); - } + patchPrototypeOnce( + proto, + 'createChannelMerger', + wrapFactory, + 'merger-splitter-lock' + ); + patchPrototypeOnce( + proto, + 'createChannelSplitter', + wrapFactory, + 'merger-splitter-lock' + ); } } diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-results.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-results.mjs index 3333f51a2..2f3ce92e8 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wpt-results.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-results.mjs @@ -142,6 +142,7 @@ function createEmptyCategoryStats() { files: 0, filesPassed: 0, filesFailed: 0, + filesCrashed: 0, runnableFiles: 0, skippedFiles: 0, skipReason: null, @@ -182,12 +183,15 @@ export function discoverAudioApiCategories({ .map(([key, meta]) => ({ key, ...meta })); } +// Bounds for the per-file failure lists stored in the JSON report, so a +// catastrophic run cannot balloon the CI artifact. +const MAX_FAILURES_PER_FILE = 100; +const MAX_FAILURE_MESSAGE_LENGTH = 200; + export class WptResultsCollector { #categories = new Map(); - #currentSuite = null; - #currentCategory = null; - #currentSuitePass = 0; - #currentSuiteFail = 0; + #files = []; + #currentFile = null; #ensureCategory(categoryKey) { if (!this.#categories.has(categoryKey)) { @@ -197,56 +201,117 @@ export class WptResultsCollector { } startSuite(name) { - if (this.#currentSuite != null) { - this.#finishSuite(); - } + this.#finishFile('ok'); - this.#currentSuite = normalizeTestPath(name); - this.#currentCategory = getCategoryKey(this.#currentSuite); - this.#currentSuitePass = 0; - this.#currentSuiteFail = 0; + const suitePath = normalizeTestPath(name); + this.#currentFile = { + path: suitePath, + category: getCategoryKey(suitePath), + pass: 0, + fail: 0, + failures: [], + startedAt: Date.now(), + }; - const category = this.#ensureCategory(this.#currentCategory); + const category = this.#ensureCategory(this.#currentFile.category); category.files += 1; } pass() { - this.#currentSuitePass += 1; - if (this.#currentCategory != null) { - this.#ensureCategory(this.#currentCategory).pass += 1; + if (this.#currentFile == null) { + return; } + this.#currentFile.pass += 1; + this.#ensureCategory(this.#currentFile.category).pass += 1; } - fail() { - this.#currentSuiteFail += 1; - if (this.#currentCategory != null) { - this.#ensureCategory(this.#currentCategory).fail += 1; + fail(message) { + if (this.#currentFile == null) { + return; } + this.#currentFile.fail += 1; + if ( + typeof message === 'string' && + this.#currentFile.failures.length < MAX_FAILURES_PER_FILE + ) { + this.#currentFile.failures.push( + message.slice(0, MAX_FAILURE_MESSAGE_LENGTH) + ); + } + this.#ensureCategory(this.#currentFile.category).fail += 1; } - #finishSuite() { - if (this.#currentCategory == null) { + /** + * Record that the file currently running (or the named file, if none is + * in flight) died without finishing — the worker process crashed or the + * inactivity watchdog killed it. Counted as a failed file in its category. + * + * @param {string} path + * @param {'crashed' | 'timeout'} status + */ + markFileCrashed(path, status) { + const suitePath = normalizeTestPath(path); + if (this.#currentFile?.path === suitePath) { + this.#finishFile(status); return; } - const category = this.#ensureCategory(this.#currentCategory); - if (this.#currentSuiteFail === 0 && this.#currentSuitePass > 0) { + const category = this.#ensureCategory(getCategoryKey(suitePath)); + category.files += 1; + category.filesFailed += 1; + category.filesCrashed += 1; + this.#files.push({ + path: suitePath, + pass: 0, + fail: 0, + failures: [], + status, + durationMs: null, + }); + } + + #finishFile(status) { + if (this.#currentFile == null) { + return; + } + + const file = this.#currentFile; + this.#currentFile = null; + + const category = this.#ensureCategory(file.category); + if (status !== 'ok') { + category.filesFailed += 1; + category.filesCrashed += 1; + } else if (file.fail === 0 && file.pass > 0) { category.filesPassed += 1; - } else if (this.#currentSuiteFail > 0) { + } else if (file.fail > 0) { category.filesFailed += 1; } + + this.#files.push({ + path: file.path, + pass: file.pass, + fail: file.fail, + failures: file.failures, + status, + durationMs: Date.now() - file.startedAt, + }); } finalize() { - this.#finishSuite(); - this.#currentSuite = null; - this.#currentCategory = null; + this.#finishFile('ok'); } getCategoryStats() { this.finalize(); return this.#categories; } + + /** Per-file outcomes in run order, for the exact non-regression compare. */ + getFileResults() { + this.finalize(); + return this.#files; + } } function formatRate(pass, total) { @@ -301,6 +366,7 @@ export function buildReport({ files: run.files, filesPassed: run.filesPassed, filesFailed: run.filesFailed, + filesCrashed: run.filesCrashed, runnableFiles, skippedFiles, skipped, @@ -320,6 +386,7 @@ export function buildReport({ acc.files += category.files; acc.filesPassed += category.filesPassed; acc.filesFailed += category.filesFailed; + acc.filesCrashed += category.filesCrashed; acc.runnableFiles += category.runnableFiles; return acc; }, @@ -329,6 +396,7 @@ export function buildReport({ files: 0, filesPassed: 0, filesFailed: 0, + filesCrashed: 0, runnableFiles: 0, skippedCategories: 0, } @@ -359,6 +427,7 @@ export function buildReport({ durationMs, summary, categories, + files: collector.getFileResults(), }; } @@ -673,7 +742,7 @@ export function wrapReporter(reporter, collector) { reporter.pass?.(message); }, fail: (message) => { - collector.fail(); + collector.fail(message); reporter.fail?.(message); }, reportStack: (stack) => { diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-shared.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-shared.mjs index ee07843ab..19741e1a6 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wpt-shared.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-shared.mjs @@ -9,6 +9,7 @@ import wptRunner from 'wpt-runner'; import { wrapAudioNodeConstructors } from './wrap-audio-node-constructors.mjs'; import { applyChannelMergerSplitterAttributeLocks } from './wpt-only/channel-merger-splitter-attribute-locks.mjs'; import { + setCurrentTestWindow, wrapAudioBufferCopyMethods, wrapWebAudioRealmErrors, } from './wpt-utils.mjs'; @@ -58,6 +59,11 @@ export function walkHtmlFiles(rootDir, prefix = '') { files = files.concat(walkHtmlFiles(rootDir, rel)); } else if (entry.name.endsWith('.html')) { files.push(rel); + } else if (entry.name.endsWith('.window.js')) { + // wpt-runner serves each .window.js as a synthesized .window.html + // test and reports it under that name; enumerate it the same way so + // batch scheduling and --list see what actually runs. + files.push(rel.replace(/\.window\.js$/, '.window.html')); } } return files; @@ -142,18 +148,60 @@ export function alignGlobalRealmConstructors(window) { } } +/** + * Record every realtime AudioContext a test constructs, so the harness can close + * the ones the test abandoned. Each context owns native worker threads that only + * a close() (or GC, eventually) releases; without this, leaked contexts pile up + * across files and the process ends the run holding hundreds of threads. + * OfflineAudioContext has no close() and winds down when its render finishes. + */ +function trackRealtimeAudioContexts(window, liveContexts) { + const Previous = window.AudioContext; + if (typeof Previous !== 'function') { + return; + } + + function Tracked(...args) { + const instance = Reflect.construct(Previous, args, new.target ?? Tracked); + liveContexts.add(instance); + return instance; + } + Tracked.prototype = Previous.prototype; + Object.defineProperty(Tracked, 'name', { value: Previous.name }); + window.AudioContext = Tracked; +} + export function createWptEnvironment() { const cleanupEmitter = new EventEmitter(); const { nodeAudioApi, audioApiForWindow } = loadNodeAudioApi(); let cancelPendingAnimationFrames = () => {}; + const liveAudioContexts = new Set(); cleanupEmitter.on('cleanup', () => { cancelPendingAnimationFrames(); + + // Close whatever realtime contexts the finished test left running. Tests + // that closed their own context make close() reject — swallowed on purpose. + for (const context of liveAudioContexts) { + try { + const result = context.close(); + if (typeof result?.catch === 'function') { + result.catch(() => {}); + } + } catch { + // Already closed or torn down. + } + } + liveAudioContexts.clear(); }); const setup = (window) => { cleanupEmitter.emit('cleanup'); + // Shared-prototype patches resolve the window through this rather than closing + // over it, so a finished test's window stays collectable. + setCurrentTestWindow(window); + setFloat32ArrayViewFactory( (buffer, byteOffset, length) => new window.Float32Array(buffer, byteOffset, length) @@ -174,6 +222,9 @@ export function createWptEnvironment() { window.requestAnimationFrame = animationFrame.requestAnimationFrame; window.cancelAnimationFrame = animationFrame.cancelAnimationFrame; cancelPendingAnimationFrames = animationFrame.cancelAll; + + // Last, so it wraps the outermost constructor and records real instances. + trackRealtimeAudioContexts(window, liveAudioContexts); }; return { setup, cleanupEmitter }; @@ -209,8 +260,17 @@ export function createSequentialFilter({ } export async function runSequentialWpt({ filter, reporter }) { - const { setup } = createWptEnvironment(); - return wptRunner(testsPath, { rootURL, setup, filter, reporter }); + const { setup, cleanupEmitter } = createWptEnvironment(); + const failures = await wptRunner(testsPath, { + rootURL, + setup, + filter, + reporter, + }); + // One final sweep for the last file — 'cleanup' otherwise only fires when the + // NEXT file's setup() runs. + cleanupEmitter.emit('cleanup'); + return failures; } export function createReporter(handlers) { diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-utils.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-utils.mjs index cf3cf9042..850be8c26 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wpt-utils.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-utils.mjs @@ -46,6 +46,71 @@ const WEB_AUDIO_CLASSES = [ 'WaveShaperNode', ]; +/** + * The jsdom window of the test file currently running. + * + * The Web Audio classes are loaded once and shared by every test window, so their + * prototypes must be patched once, not per window. A patch that closed over its + * `window` would keep that window — and every AudioContext created in it — alive for + * the rest of the run, so the shared patches read the current window from here instead. + */ +let currentTestWindow = null; + +export function setCurrentTestWindow(window) { + currentTestWindow = window; +} + +export function getCurrentTestWindow() { + return currentTestWindow; +} + +/** Prototype -> "layer:member" pairs already patched, so setup() cannot chain wrappers. */ +const patchedPrototypeMembers = new WeakMap(); + +/** + * Claim `prototype[key]` for one patch layer. Returns false if that layer already + * claimed it, which is how repeated setup() calls avoid stacking a new wrapper on the + * previous one. + * + * Layers are independent and compose: several of them legitimately wrap the same member + * (realm errors around the Float32Array assertion, say), and each must land exactly once. + */ +function claimPrototypeMember(prototype, key, layer) { + let patched = patchedPrototypeMembers.get(prototype); + if (patched == null) { + patched = new Set(); + patchedPrototypeMembers.set(prototype, patched); + } + const claim = `${layer}:${key}`; + if (patched.has(claim)) { + return false; + } + patched.add(claim); + return true; +} + +/** + * Install `wrap(original)` as `prototype[methodName]`, at most once per prototype. + * Later calls are no-ops, which keeps the wrapper depth at one however many test + * files run. + * + * @param {object} prototype + * @param {string} methodName + * @param {(original: Function) => Function} wrap + * @param {string} layer identifies the patch, so independent layers can each apply once + */ +export function patchPrototypeOnce(prototype, methodName, wrap, layer) { + if (prototype == null || typeof prototype[methodName] !== 'function') { + return; + } + + if (!claimPrototypeMember(prototype, methodName, layer)) { + return; + } + + prototype[methodName] = wrap(prototype[methodName]); +} + /** Node constructors already wrapped for invalid-argument TypeErrors. */ export const WRAPPED_NODE_CONSTRUCTORS = new Set([ 'AnalyserNode', @@ -116,21 +181,26 @@ function toWindowRealmPromise(window, thenable) { }); } -function wrapWithRealmErrors(window, fn) { +function wrapWithRealmErrors(fn) { return function (...args) { try { const result = fn.apply(this, args); if (isThenable(result)) { - return toWindowRealmPromise(window, result); + return toWindowRealmPromise(getCurrentTestWindow(), result); } return result; } catch (error) { - throw toWindowRealmError(window, error); + throw toWindowRealmError(getCurrentTestWindow(), error); } }; } -function wrapPrototypeMembers(window, ctor) { +/** + * Wrap every method and accessor on `ctor.prototype` so errors surface in the test's + * realm. The Web Audio classes are shared by all test windows, so each member is + * wrapped once and the wrapper looks the window up per call. + */ +function wrapPrototypeMembers(ctor) { if (typeof ctor !== 'function') { return; } @@ -146,24 +216,30 @@ function wrapPrototypeMembers(window, ctor) { continue; } - if (desc.get != null || desc.set != null) { + const isAccessor = desc.get != null || desc.set != null; + if (!isAccessor && typeof desc.value !== 'function') { + continue; + } + if (!claimPrototypeMember(proto, key, 'realm-errors')) { + continue; + } + + if (isAccessor) { const replacement = { ...desc }; if (desc.get != null) { - replacement.get = wrapWithRealmErrors(window, desc.get); + replacement.get = wrapWithRealmErrors(desc.get); } if (desc.set != null) { - replacement.set = wrapWithRealmErrors(window, desc.set); + replacement.set = wrapWithRealmErrors(desc.set); } Object.defineProperty(proto, key, replacement); continue; } - if (typeof desc.value === 'function') { - Object.defineProperty(proto, key, { - ...desc, - value: wrapWithRealmErrors(window, desc.value), - }); - } + Object.defineProperty(proto, key, { + ...desc, + value: wrapWithRealmErrors(desc.value), + }); } } @@ -188,7 +264,7 @@ function wrapConstructorWithRealmErrors(window, name) { export function wrapWebAudioRealmErrors(window) { for (const name of WEB_AUDIO_CLASSES) { - wrapPrototypeMembers(window, window[name]); + wrapPrototypeMembers(window[name]); if (!WRAPPED_NODE_CONSTRUCTORS.has(name)) { wrapConstructorWithRealmErrors(window, name); @@ -224,29 +300,25 @@ export function wrapAudioBufferCopyMethods(window) { return; } - const originalCopyFromChannel = AudioBuffer.prototype.copyFromChannel; - const originalCopyToChannel = AudioBuffer.prototype.copyToChannel; + patchPrototypeOnce( + AudioBuffer.prototype, + 'copyFromChannel', + (original) => + function copyFromChannel(destination, channelNumber, startInChannel = 0) { + assertFloat32Array(destination, 'destination', getCurrentTestWindow()); + return original.call(this, destination, channelNumber, startInChannel); + }, + 'float32-assert' + ); - AudioBuffer.prototype.copyFromChannel = function copyFromChannel( - destination, - channelNumber, - startInChannel = 0 - ) { - assertFloat32Array(destination, 'destination', window); - return originalCopyFromChannel.call( - this, - destination, - channelNumber, - startInChannel - ); - }; - - AudioBuffer.prototype.copyToChannel = function copyToChannel( - source, - channelNumber, - startInChannel = 0 - ) { - assertFloat32Array(source, 'source', window); - return originalCopyToChannel.call(this, source, channelNumber, startInChannel); - }; + patchPrototypeOnce( + AudioBuffer.prototype, + 'copyToChannel', + (original) => + function copyToChannel(source, channelNumber, startInChannel = 0) { + assertFloat32Array(source, 'source', getCurrentTestWindow()); + return original.call(this, source, channelNumber, startInChannel); + }, + 'float32-assert' + ); } diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wpt-worker.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wpt-worker.mjs new file mode 100644 index 000000000..43f7b568e --- /dev/null +++ b/packages/react-native-audio-api/wpt_tests/wpt/wpt-worker.mjs @@ -0,0 +1,84 @@ +/** + * Child process entry for batched WPT runs (see wpt-harness.mjs). + * + * Runs one batch of test files in-process and streams reporter events to the + * parent over IPC. Keeping batches in short-lived children means native state + * (audio threads, event-registry entries) accumulates only across a batch, a + * native crash loses one file instead of the whole run, and the final + * process.exit() of each child tears down a small heap — the parent survives + * even if that teardown hangs. + * + * Protocol (child -> parent): + * { type: 'suite-start', name } a test file began + * { type: 'pass', message } one subtest passed + * { type: 'fail', message } one subtest failed + * { type: 'stack', stack } stack trace for the preceding failure + * { type: 'done', fileFailures } batch finished; parent may kill us + * + * Parent -> child: a single { files: string[] } message starts the batch. + */ + +import { + createReporter, + normalizeTestPath, + runSequentialWpt, +} from './wpt-shared.mjs'; +import { getCurrentTestWindow } from './wpt-utils.mjs'; + +// In a browser, an exception thrown from an event handler (e.g. an assert +// inside `oncomplete`) surfaces as a window `error` event, which testharness +// turns into a fast harness failure. In Node the same throw would kill this +// worker. Forward it into the running test's window so the file fails +// immediately instead of crashing the batch or idling into the 10s timeout. +process.on('uncaughtException', (error) => { + const window = getCurrentTestWindow(); + try { + window.dispatchEvent( + new window.ErrorEvent('error', { + error, + message: String(error?.message ?? error), + }) + ); + } catch { + console.error('uncaught exception with no active test window:', error); + } +}); + +function send(message) { + // The parent may already have killed us (e.g. its inactivity watchdog fired + // while an event was in flight); losing that race is fine. + try { + process.send(message); + } catch { + // Channel closed — nothing left to report to. + } +} + +process.on('message', async ({ files }) => { + const batch = new Set(files.map(normalizeTestPath)); + + const reporter = createReporter({ + startSuite: (name) => send({ type: 'suite-start', name }), + pass: (message) => send({ type: 'pass', message }), + fail: (message) => send({ type: 'fail', message }), + reportStack: (stack) => send({ type: 'stack', stack }), + }); + + let fileFailures = 0; + try { + fileFailures = await runSequentialWpt({ + filter: (name) => batch.has(normalizeTestPath(name)), + reporter, + }); + } catch (error) { + send({ type: 'fail', message: `worker error: ${error.message}` }); + send({ type: 'stack', stack: error.stack ?? String(error) }); + fileFailures = Math.max(fileFailures, 1); + } + + send({ type: 'done', fileFailures }); + // Native teardown (audio thread joins, registry destruction) happens inside + // this exit. The parent holds every result already, waits briefly, and + // SIGKILLs us if teardown hangs — the historical CI "exit code 129" mode. + process.exit(0); +}); diff --git a/packages/react-native-audio-api/wpt_tests/wpt/wrap-audio-node-constructors.mjs b/packages/react-native-audio-api/wpt_tests/wpt/wrap-audio-node-constructors.mjs index 26b773f8e..871643b5f 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt/wrap-audio-node-constructors.mjs +++ b/packages/react-native-audio-api/wpt_tests/wpt/wrap-audio-node-constructors.mjs @@ -5,6 +5,8 @@ */ import { + getCurrentTestWindow, + patchPrototypeOnce, toWindowRealmError, WRAPPED_NODE_CONSTRUCTORS, } from './wpt-utils.mjs'; @@ -92,22 +94,21 @@ function wrapAudioNodeConnectDisconnect(window) { return; } - const originalConnect = AudioNode.prototype.connect; - const originalDisconnect = AudioNode.prototype.disconnect; - - AudioNode.prototype.connect = function connect(...args) { - try { - return originalConnect.apply(this, args); - } catch (error) { - throw toWindowRealmError(window, error); - } - }; - - AudioNode.prototype.disconnect = function disconnect(...args) { - try { - return originalDisconnect.apply(this, args); - } catch (error) { - throw toWindowRealmError(window, error); - } - }; + // AudioNode is shared by every test window, so these go on once and resolve the + // window at call time — see patchPrototypeOnce. + for (const methodName of ['connect', 'disconnect']) { + patchPrototypeOnce( + AudioNode.prototype, + methodName, + (original) => + function (...args) { + try { + return original.apply(this, args); + } catch (error) { + throw toWindowRealmError(getCurrentTestWindow(), error); + } + }, + 'connect-realm-errors' + ); + } } From 0c955d56fd274b8ab550b04c0f14ad6539529c14 Mon Sep 17 00:00:00 2001 From: michal Date: Fri, 21 Aug 2026 13:41:37 +0200 Subject: [PATCH 5/9] fix: eliminate all 10s-timeout WPT files (frame rounding, param queue overflow, onended) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root causes, measured on a full smoke run (2631/670 in 161s -> 2682/644 in 31s, no per-file regressions, all 13 harness-timeout files gone): 1. AudioParam automation boundaries landed one frame late. The a-rate loop accumulated `time += 1/sampleRate` (ULP drift per quantum) and event times were compared raw. Events now carry both frame-snapped times (round(T*sampleRate), driving ordering and effect boundaries per the WPT reference) and raw times (feeding interpolation, which the spec defines on real times — audioparam-close.html schedules ramps inside a single frame); a due event supersedes an unfinished ramp, and per-sample times are derived as (quantumStartFrame + i) / sampleRate. dsp::timeToSampleFrame now rounds half-up like Blink instead of truncating. 2. The 100-event WPT automation suites overflowed the 64-slot param event queues; BoundedPriorityQueue::push silently drops when full, freezing automation at event ~62. AUDIO_PARAM_MAX_QUEUED_EVENTS raised to 256 and the memory-pressure estimate updated to model the queues. 3. WPT sets the spec-lowercase `src.onended` and uses addEventListener('ended'); the TS layer only had camelCase onEnded, so the assignments were dead expandos and four files waited forever. Added the spec-name alias, EventTarget-style add/removeEventListener sharing one native subscription, and a dispatched event shaped {type: 'ended', target}. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Lmw8EDPFNAUqozoD4ho11o --- .claude/skills/audio-nodes/SKILL.md | 16 ++++ .../HostObjects/AudioParamHostObject.h | 6 +- .../common/cpp/audioapi/core/AudioParam.cpp | 14 ++-- .../cpp/audioapi/core/utils/Constants.h | 8 +- .../core/utils/param/ParamQueueBase.hpp | 2 +- .../utils/param/ParamRenderEventFactory.h | 79 +++++++++--------- .../core/utils/param/ParamRenderQueue.cpp | 83 ++++++++++++++----- .../core/utils/param/ParamRenderQueue.h | 20 ++++- .../core/utils/param/RenderParamEvent.h | 33 ++++++++ .../common/cpp/audioapi/dsp/AudioUtils.h | 6 +- .../src/Audio/AudioFileSourceNode.ts | 10 +-- .../src/core/AudioScheduledSourceNode.ts | 75 ++++++++++++++--- 12 files changed, 264 insertions(+), 88 deletions(-) diff --git a/.claude/skills/audio-nodes/SKILL.md b/.claude/skills/audio-nodes/SKILL.md index da108b451..eb7af2812 100644 --- a/.claude/skills/audio-nodes/SKILL.md +++ b/.claude/skills/audio-nodes/SKILL.md @@ -278,6 +278,22 @@ quantum). Consequently, unit tests that re-process the same node must advance th `getValueAtTimeUnmodulated` / `ParamRenderQueue`). Clip only in `finalizeKRate` / `finalizeARate` after adding modulation — never on the intrinsic alone before modulation. +**Automation timing model (hybrid snapped/raw times):** `ParamRenderQueue::push` stores each +event's times twice — the inherited start/end are snapped to `round(T * sampleRate) / sampleRate` +and drive ordering, popping, and effect boundaries (matching Blink and the WPT reference's +`timeToSampleFrame`), while `rawStartTime`/`rawEndTime` keep the scheduled times and feed the +`calculateValue` interpolation (a ramp between two times inside one frame must interpolate on +the real times — WPT `audioparam-close.html`). A queued event supersedes the current one as soon +as its snapped start is due, even mid-ramp. On the evaluation side, `processARateParam` derives +each sample's time as `(quantumStartFrame + i) / sampleRate` — never accumulate +`time += 1/sampleRate`, the ULP drift lands boundaries one frame late. + +**Param queue capacity:** both param event queues (`ParamRenderQueue` on `AudioParam`, +`ParamControlQueue` on the host object) are bounded by `AUDIO_PARAM_MAX_QUEUED_EVENTS` and +**silently drop** events past capacity (`BoundedPriorityQueue::push` returns false, nobody +checks). Automation scheduled far ahead must fit entirely; the WPT audioparam suites queue +100 events per file. Symptom of overflow: automation freezes at the last accepted event. + ### JS → Audio Thread parameter updates `CrossThreadEventScheduler` is a lock-free SPSC channel. When JS calls `param.setValueAtTime(...)`, it enqueues a lambda on the scheduler. The audio thread drains the queue at the start of each `processARateParam` / `processKRateParam` call. diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioParamHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioParamHostObject.h index ea2f4fda9..7846fcc9f 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioParamHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioParamHostObject.h @@ -16,8 +16,10 @@ using namespace facebook; class AudioParam; /// Rough native footprint of an AudioParamHostObject: -/// two DSPAudioBuffer(RQ) (k-rate + a-rate scratch) + control queue + atomics. -inline constexpr size_t kAudioParamBytes = 2 * RENDER_QUANTUM_SIZE * sizeof(float) + 512; +/// two DSPAudioBuffer(RQ) (k-rate + a-rate scratch) + the preallocated control +/// and render event queues (~160 B combined per slot) + atomics. +inline constexpr size_t kAudioParamBytes = + 2 * RENDER_QUANTUM_SIZE * sizeof(float) + AUDIO_PARAM_MAX_QUEUED_EVENTS * 160 + 512; /// @brief Host object for AudioParam that owns its BridgeNode. /// diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioParam.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioParam.cpp index 2a485b527..c597ef2ec 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioParam.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioParam.cpp @@ -16,7 +16,7 @@ AudioParam::AudioParam( : GeneralizedAudioParam(minValue, maxValue, context), value_(defaultValue), defaultValue_(defaultValue), - eventRenderQueue_(defaultValue), + eventRenderQueue_(defaultValue, context->getSampleRate()), inputBuffer_( std::make_shared(RENDER_QUANTUM_SIZE, 1, context->getSampleRate())) {} @@ -74,15 +74,19 @@ std::shared_ptr AudioParam::processARateParam(int framesToProces } float sampleRate = context->getSampleRate(); - double timeCache = time; - double timeStep = 1.0 / sampleRate; + // Evaluate each sample at the exact frame time `frame / sampleRate` instead + // of accumulating `time += 1/sampleRate`: accumulation drifts by a few ULPs + // per quantum, which is enough to observe a snapped event boundary one + // frame late. + auto quantumStartFrame = static_cast(dsp::timeToSampleFrame(time, sampleRate)); // Read modulation from input buffer (filled by BridgeNode if connected, otherwise zeros) auto inputData = inputBuffer_->getChannel(0)->span(); auto outputData = outputBuffer_->getChannel(0)->span(); - for (int i = 0; i < framesToProcess; i++, timeCache += timeStep) { - outputData[i] = inputData[i] + getValueAtTimeUnmodulated(timeCache); + for (int i = 0; i < framesToProcess; i++) { + double frameTime = dsp::sampleFrameToTime(quantumStartFrame + i, sampleRate); + outputData[i] = inputData[i] + getValueAtTimeUnmodulated(frameTime); } inputBuffer_->zero(); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h index e84191247..535f44861 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h @@ -49,5 +49,11 @@ constexpr std::size_t hardware_destructive_interference_size = 64; #endif // audio param -inline constexpr size_t AUDIO_PARAM_MAX_QUEUED_EVENTS = 64; +/// Slots preallocated per param queue (both the render queue on AudioParam and +/// the control queue on AudioParamHostObject). Events past capacity are +/// SILENTLY DROPPED — automation scheduled far ahead (e.g. a per-beat value +/// sequence for a whole track, or the 100-event WPT audioparam suites) must +/// fit here in full, so keep a generous margin. Cost: each slot reserves +/// ~100 B in AudioParam and ~60 B in its host object. +inline constexpr size_t AUDIO_PARAM_MAX_QUEUED_EVENTS = 256; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamQueueBase.hpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamQueueBase.hpp index 4139db34e..546f611e0 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamQueueBase.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamQueueBase.hpp @@ -20,7 +20,7 @@ class ParamQueueBase { /// @brief Cancel scheduled parameter changes at or after the given time. /// @param cancelTime The time at which to cancel scheduled changes. - void cancelScheduledValues(double cancelTime) { + virtual void cancelScheduledValues(double cancelTime) { eventQueue_.erase(eventQueue_.lowerBound(cancelTime), eventQueue_.end()); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderEventFactory.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderEventFactory.h index a45e3449c..757e1d6f7 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderEventFactory.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderEventFactory.h @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include #include @@ -10,17 +12,24 @@ namespace audioapi { /// @brief A factory for creating RenderParamEvents and resolving their values /// based on the current state of the queue. +/// +/// The calculateValue functions are pure interpolation formulas evaluated on an +/// event's RAW times; whether an event is in effect at a given frame is decided +/// by ParamRenderQueue::computeValueAtTime against the snapped times. Because a +/// frame's exact time can fall fractionally outside [rawStartTime, rawEndTime) +/// while the frame still belongs to the event, each formula must extrapolate +/// gracefully (clamp, or accept a sub-ULP overshoot) instead of branching on +/// the raw boundary. class ParamRenderEventFactory { public: static RenderParamEvent createSetValueEvent(float value, double startTime) { - auto calculateValue = - [](double startTime, double /* endTime */, float startValue, float endValue, double time) { - if (time < startTime) { - return startValue; - } - - return endValue; - }; + auto calculateValue = [](double /* startTime */, + double /* endTime */, + float /* startValue */, + float endValue, + double /* time */) { + return endValue; + }; return RenderParamEvent( startTime, startTime, value, value, std::move(calculateValue), ParamEventType::SET_VALUE); @@ -29,16 +38,12 @@ class ParamRenderEventFactory { static RenderParamEvent createLinearRampEvent(float value, double endTime) { auto calculateValue = [](double startTime, double endTime, float startValue, float endValue, double time) { - if (time < startTime) { - return startValue; - } - - if (time < endTime) { - return static_cast( - startValue + (endValue - startValue) * (time - startTime) / (endTime - startTime)); + if (endTime <= startTime) { + return endValue; } - return endValue; + return static_cast( + startValue + (endValue - startValue) * (time - startTime) / (endTime - startTime)); }; return RenderParamEvent( @@ -52,17 +57,12 @@ class ParamRenderEventFactory { return startValue; } - if (time < startTime) { - return startValue; - } - - if (time < endTime) { - return static_cast( - startValue * - pow(endValue / startValue, (time - startTime) / (endTime - startTime))); + if (endTime <= startTime) { + return endValue; } - return endValue; + return static_cast( + startValue * pow(endValue / startValue, (time - startTime) / (endTime - startTime))); }; return RenderParamEvent( @@ -81,10 +81,6 @@ class ParamRenderEventFactory { return target; } - if (time < startTime) { - return startValue; - } - return static_cast( target + (startValue - target) * exp(-(time - startTime) / timeConstant)); }; @@ -106,21 +102,20 @@ class ParamRenderEventFactory { auto calculateValue = [values, length]( double startTime, double endTime, float startValue, float endValue, double time) { - if (time < startTime) { - return startValue; - } - - if (time < endTime) { - // Calculate position in the array based on time progress - auto k = static_cast(std::floor( - static_cast(length - 1) / (endTime - startTime) * (time - startTime))); - // Calculate interpolation factor between adjacent array elements - auto factor = static_cast( - (time - startTime) * static_cast(length - 1) / (endTime - startTime) - k); - return dsp::linearInterpolate(values->span(), k, k + 1, factor); + if (endTime <= startTime) { + return endValue; } - return endValue; + // Position in the array based on time progress, clamped so a frame + // snapped fractionally outside the raw interval stays in range. + double position = std::clamp( + static_cast(length - 1) / (endTime - startTime) * (time - startTime), + 0.0, + static_cast(length - 1)); + auto k = static_cast(position); + size_t nextIndex = std::min(k + 1, length - 1); + auto factor = static_cast(position - static_cast(k)); + return dsp::linearInterpolate(values->span(), k, nextIndex, factor); }; return RenderParamEvent( diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.cpp index 00f947719..9ec3d88c3 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.cpp @@ -2,17 +2,25 @@ #include #include #include +#include #include #include #include namespace audioapi { +double ParamRenderQueue::snapToSampleFrameTime(double time) const { + return dsp::sampleFrameToTime( + static_cast(dsp::timeToSampleFrame(time, sampleRate_)), sampleRate_); +} + std::optional ParamRenderQueue::computeValueAtTime(double time) { - while ( - !eventQueue_.isEmpty() && - (!currentEvent_ || - (time >= currentEvent_->getEndTime() && eventQueue_.peekFront().getStartTime() <= time))) { + // A queued event whose snapped effect time has arrived supersedes the + // current one even when the current event's own interval has not elapsed + // (e.g. a setValueAtTime whose frame lands fractionally before a ramp's raw + // end time). + while (!eventQueue_.isEmpty() && + (!currentEvent_ || eventQueue_.peekFront().getStartTime() <= time)) { RenderParamEvent next; eventQueue_.pop(next); currentEvent_ = std::move(next); @@ -22,15 +30,35 @@ std::optional ParamRenderQueue::computeValueAtTime(double time) { return std::nullopt; } - return currentEvent_->getCalculateValue()( - currentEvent_->getStartTime(), - currentEvent_->getEndTime(), - currentEvent_->getStartValue(), - currentEvent_->getEndValue(), + const RenderParamEvent &event = *currentEvent_; + if (time < event.getStartTime()) { + return event.getStartValue(); + } + + // Ramps stay active until their raw end so sub-frame intervals still + // interpolate; other finite events end on their snapped boundary. SetTarget + // never ends on its own. + double effectiveEndTime = event.isRampType() ? event.getRawEndTime() : event.getEndTime(); + if (event.getType() != ParamEventType::SET_TARGET && time >= effectiveEndTime) { + return event.getEndValue(); + } + + return event.getCalculateValue()( + event.getRawStartTime(), + event.getRawEndTime(), + event.getStartValue(), + event.getEndValue(), time); } bool ParamRenderQueue::push(RenderParamEvent &&event) { + // Keep the scheduled times for interpolation; snap the effect boundaries + // onto the sample-frame grid for ordering and comparisons. + event.setRawStartTime(event.getStartTime()); + event.setRawEndTime(event.getEndTime()); + event.setStartTime(snapToSampleFrameTime(event.getStartTime())); + event.setEndTime(snapToSampleFrameTime(event.getEndTime())); + resolveEventValues(event); return ParamQueueBase::push(std::move(event)); } @@ -45,14 +73,16 @@ void ParamRenderQueue::resolveEventValues(RenderParamEvent &event) { // if the new event is a ramp resolve its startTime and startValue from the predecessor event if (event.isRampType()) { event.setStartTime(predIt->getEndTime()); + event.setRawStartTime(predIt->getRawEndTime()); } - event.setStartValue(getValueOfPreviousEventAt(*predIt, event.getStartTime())); + event.setStartValue(getValueOfPreviousEventAt(*predIt, event.getRawStartTime())); // If the predecessor is a setTarget event, adjust its endTime and endValue to connect to the new event if (predIt->getType() == ParamEventType::SET_TARGET) { - float newEndValue = getValueOfPreviousEventAt(*predIt, event.getStartTime()); + float newEndValue = getValueOfPreviousEventAt(*predIt, event.getRawStartTime()); auto node = eventQueue_.extract(predIt); node.value().setEndTime(event.getStartTime()); + node.value().setRawEndTime(event.getRawStartTime()); node.value().setEndValue(newEndValue); eventQueue_.insert(it, std::move(node)); } @@ -62,13 +92,16 @@ void ParamRenderQueue::resolveEventValues(RenderParamEvent &event) { // if the new event is a ramp resolve its startTime and startValue from the predecessor event if (event.isRampType()) { event.setStartTime(currentEvent_->getEndTime()); + event.setRawStartTime(currentEvent_->getRawEndTime()); } - event.setStartValue(getValueOfPreviousEventAt(*currentEvent_, event.getStartTime())); + event.setStartValue(getValueOfPreviousEventAt(*currentEvent_, event.getRawStartTime())); // If the predecessor is a setTarget event, adjust its endTime and endValue to connect to the new event if (currentEvent_->getType() == ParamEventType::SET_TARGET) { + currentEvent_->setEndValue( + getValueOfPreviousEventAt(*currentEvent_, event.getRawStartTime())); currentEvent_->setEndTime(event.getStartTime()); - currentEvent_->setEndValue(getValueOfPreviousEventAt(*currentEvent_, event.getStartTime())); + currentEvent_->setRawEndTime(event.getRawStartTime()); } } else { // Case 3: no predecessor at all — fall back to default value @@ -80,6 +113,7 @@ void ParamRenderQueue::resolveEventValues(RenderParamEvent &event) { auto hint = std::next(it); auto node = eventQueue_.extract(it); node.value().setStartTime(event.getEndTime()); + node.value().setRawStartTime(event.getRawEndTime()); node.value().setStartValue(event.getEndValue()); eventQueue_.insert(hint, std::move(node)); } @@ -88,25 +122,32 @@ void ParamRenderQueue::resolveEventValues(RenderParamEvent &event) { float ParamRenderQueue::getValueOfPreviousEventAt(const RenderParamEvent &event, double time) { if (event.getType() == ParamEventType::SET_TARGET) { return event.getCalculateValue()( - event.getStartTime(), event.getEndTime(), event.getStartValue(), event.getEndValue(), time); + event.getRawStartTime(), + event.getRawEndTime(), + event.getStartValue(), + event.getEndValue(), + time); } return event.getEndValue(); } void ParamRenderQueue::cancelAndHoldAtTime(double cancelTime) { + cancelTime = snapToSampleFrameTime(cancelTime); + // E2: first event with automationTime strictly after cancelTime auto e2It = eventQueue_.upperBound(cancelTime); if (e2It != eventQueue_.end() && e2It->isRampType()) { // Spec step 3: E2 is a ramp — truncate it to end at cancelTime float holdValue = e2It->getCalculateValue()( - e2It->getStartTime(), - e2It->getEndTime(), + e2It->getRawStartTime(), + e2It->getRawEndTime(), e2It->getStartValue(), e2It->getEndValue(), cancelTime); auto node = eventQueue_.extract(e2It); node.value().setEndTime(cancelTime); + node.value().setRawEndTime(cancelTime); node.value().setEndValue(holdValue); auto insertPos = eventQueue_.upperBound(cancelTime); eventQueue_.insert(insertPos, std::move(node)); @@ -130,14 +171,15 @@ void ParamRenderQueue::cancelAndHoldAtTime(double cancelTime) { if (e1It->getType() == ParamEventType::SET_VALUE_CURVE && cancelTime <= e1It->getEndTime()) { // Truncate curve; compute holdValue using original endTime to preserve sampling behaviour float holdValue = e1It->getCalculateValue()( - e1It->getStartTime(), - e1It->getEndTime(), + e1It->getRawStartTime(), + e1It->getRawEndTime(), e1It->getStartValue(), e1It->getEndValue(), cancelTime); auto hint = std::next(e1It); auto node = eventQueue_.extract(e1It); node.value().setEndTime(cancelTime); + node.value().setRawEndTime(cancelTime); node.value().setEndValue(holdValue); eventQueue_.insert(hint, std::move(node)); // fall through to step 5 @@ -155,12 +197,13 @@ void ParamRenderQueue::cancelAndHoldAtTime(double cancelTime) { if (currentEvent_->getType() == ParamEventType::SET_VALUE_CURVE && cancelTime <= currentEvent_->getEndTime()) { float holdValue = currentEvent_->getCalculateValue()( - currentEvent_->getStartTime(), - currentEvent_->getEndTime(), + currentEvent_->getRawStartTime(), + currentEvent_->getRawEndTime(), currentEvent_->getStartValue(), currentEvent_->getEndValue(), cancelTime); currentEvent_->setEndTime(cancelTime); + currentEvent_->setRawEndTime(cancelTime); currentEvent_->setEndValue(holdValue); // fall through to step 5 } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.h index 5f8adf7ef..08b42a532 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/ParamRenderQueue.h @@ -8,9 +8,15 @@ namespace audioapi { /// @brief A queue for managing audio parameter change events on the audio render thread. /// @note The invariant of the queue is that its internal buffer always contains non-overlapping events. +/// +/// Event times are kept in two forms (see RenderParamEvent): push() snaps the +/// inherited times onto the sample-frame grid of @c sampleRate while preserving +/// the scheduled times in the raw fields, so effect boundaries land on +/// round(T * sampleRate) frames and interpolation still runs on real times. class ParamRenderQueue : public ParamQueueBase { public: - explicit ParamRenderQueue(float defaultValue) : defaultValue_(defaultValue) {} + explicit ParamRenderQueue(float defaultValue, float sampleRate) + : defaultValue_(defaultValue), sampleRate_(sampleRate) {} /// @brief Compute the value at a specific time based on the events in the queue. /// @param time The time at which to compute the value. @@ -22,12 +28,24 @@ class ParamRenderQueue : public ParamQueueBase { /// @return True if the event was successfully added, false if the queue is full. bool push(RenderParamEvent &&event) override; + /// @brief Cancel scheduled parameter changes at or after the given time + /// (compared on the snapped time grid). + void cancelScheduledValues(double cancelTime) override { + ParamQueueBase::cancelScheduledValues(snapToSampleFrameTime(cancelTime)); + } + /// @brief Cancel scheduled parameter changes and hold the current value at the given time. /// @param cancelTime The time at which to cancel scheduled changes. void cancelAndHoldAtTime(double cancelTime); private: float defaultValue_; + float sampleRate_; + + /// @brief Snap a time to the exact time of its nearest sample frame + /// (round(time * sampleRate) / sampleRate), the grid the render + /// loop evaluates frames on. + [[nodiscard]] double snapToSampleFrameTime(double time) const; /// @brief Resolve new event's startValue and startTime based on the previous event in the queue, /// and adjust neighboring events to maintain the invariant of non-overlapping events in the queue. diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/RenderParamEvent.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/RenderParamEvent.h index 6d5b843f0..f63502681 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/RenderParamEvent.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/RenderParamEvent.h @@ -11,6 +11,15 @@ namespace audioapi { /// @brief A RenderParamEvent extends ParamEvent with additional properties and a value calculation /// function that can compute the parameter value at any time during the event's active period /// based on its type and the current state of the queue. +/// +/// Times exist in two forms. The inherited startTime/endTime are snapped to the +/// sample-frame grid by ParamRenderQueue::push and drive ordering and +/// effect-boundary decisions, so an event scheduled at T takes effect exactly +/// at frame round(T * sampleRate) — the convention Blink and the WPT reference +/// use. rawStartTime/rawEndTime keep the times as scheduled and feed the value +/// interpolation, which the spec defines on real times: a ramp between two +/// times inside one frame must still interpolate on those times, not on their +/// collapsed snapped values. class RenderParamEvent : public ParamEvent { public: RenderParamEvent() = default; @@ -25,6 +34,8 @@ class RenderParamEvent : public ParamEvent { ParamEventType type) : ParamEvent(type, startTime, endTime), calculateValue_(std::move(calculateValue)), + rawStartTime_(startTime), + rawEndTime_(endTime), startValue_(startValue), endValue_(endValue) {} @@ -34,6 +45,8 @@ class RenderParamEvent : public ParamEvent { RenderParamEvent(RenderParamEvent &&other) noexcept : ParamEvent(std::move(other)), calculateValue_(std::move(other.calculateValue_)), + rawStartTime_(other.rawStartTime_), + rawEndTime_(other.rawEndTime_), startValue_(other.startValue_), endValue_(other.endValue_) {} @@ -41,12 +54,30 @@ class RenderParamEvent : public ParamEvent { if (this != &other) { ParamEvent::operator=(std::move(other)); calculateValue_ = std::move(other.calculateValue_); + rawStartTime_ = other.rawStartTime_; + rawEndTime_ = other.rawEndTime_; startValue_ = other.startValue_; endValue_ = other.endValue_; } return *this; } + [[nodiscard]] double getRawStartTime() const noexcept { + return rawStartTime_; + } + + [[nodiscard]] double getRawEndTime() const noexcept { + return rawEndTime_; + } + + void setRawStartTime(double rawStartTime) noexcept { + rawStartTime_ = rawStartTime; + } + + void setRawEndTime(double rawEndTime) noexcept { + rawEndTime_ = rawEndTime; + } + [[nodiscard]] float getEndValue() const noexcept { return endValue_; } @@ -70,6 +101,8 @@ class RenderParamEvent : public ParamEvent { private: std::function calculateValue_; + double rawStartTime_ = 0.0; + double rawEndTime_ = 0.0; float startValue_ = 0.0f; float endValue_ = 0.0f; }; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/dsp/AudioUtils.h b/packages/react-native-audio-api/common/cpp/audioapi/dsp/AudioUtils.h index 272cfd0d0..7fb28fcd1 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/dsp/AudioUtils.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/dsp/AudioUtils.h @@ -7,8 +7,12 @@ namespace audioapi::dsp { +/// Round to the nearest frame, matching Blink and the WPT reference +/// (audit-util.js). Truncation lands one frame late whenever +/// `time * sampleRate` computes fractionally below the intended integer +/// (e.g. 0.03 * 44100 = 1322.9999...). [[nodiscard]] inline size_t timeToSampleFrame(double time, float sampleRate) { - return static_cast(time * sampleRate); + return static_cast(0.5 + time * sampleRate); } [[nodiscard]] inline double sampleFrameToTime(int sampleFrame, float sampleRate) { diff --git a/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts b/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts index 4a8856419..9a63079c2 100644 --- a/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts +++ b/packages/react-native-audio-api/src/Audio/AudioFileSourceNode.ts @@ -18,21 +18,21 @@ export class AudioFileSourceNode extends AudioScheduledSourceNode { globalThis.AudioEventEmitter ); - private endedSubscription: AudioEventSubscription | null = null; + private attachedEndedSubscription: AudioEventSubscription | null = null; private positionSubscription: AudioEventSubscription | null = null; private bufferingSubscription: AudioEventSubscription | null = null; attach(options: AttachFileSourceOptions): { duration: number } { this.resetNodeAndSubscriptions(); - this.endedSubscription = this.emitter.addAudioEventListener( + this.attachedEndedSubscription = this.emitter.addAudioEventListener( 'ended', (_event: EventEmptyType) => { options.onEnded(); } ); (this.node as IAudioFileSourceNode).onEnded = - this.endedSubscription.subscriptionId; + this.attachedEndedSubscription.subscriptionId; return { duration: (this.node as IAudioFileSourceNode).duration, @@ -146,8 +146,8 @@ export class AudioFileSourceNode extends AudioScheduledSourceNode { private resetNodeAndSubscriptions(): void { this.stopPositionTracking(); this.stopBufferingTracking(); - this.endedSubscription?.remove(); - this.endedSubscription = null; + this.attachedEndedSubscription?.remove(); + this.attachedEndedSubscription = null; if (this.node) { (this.node as IAudioFileSourceNode).onEnded = '0'; diff --git a/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts b/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts index ebade2571..e5421fcf0 100644 --- a/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioScheduledSourceNode.ts @@ -11,7 +11,8 @@ export default class AudioScheduledSourceNode extends AudioNode { ); private onEndedCallback?: (event: EventEmptyType) => void; - private onEndedSubscription: AudioEventSubscription | null = null; + private endedListeners = new Set<(event: EventEmptyType) => void>(); + private endedSubscription: AudioEventSubscription | null = null; public start(when: number = 0): void { if (when < 0) { @@ -45,27 +46,81 @@ export default class AudioScheduledSourceNode extends AudioNode { (this.node as IAudioScheduledSourceNode).stop(when); } + /** + * Web Audio API spec spelling of the ended-event handler. Delegates to + * `onEnded` so both spellings drive the same native subscription. + */ + public get onended(): ((event: EventEmptyType) => void) | undefined { + return this.onEnded; + } + + public set onended(callback: ((event: EventEmptyType) => void) | null) { + this.onEnded = callback; + } + public get onEnded(): ((event: EventEmptyType) => void) | undefined { return this.onEndedCallback; } public set onEnded(callback: ((event: EventEmptyType) => void) | null) { - this.onEndedSubscription?.remove(); - this.onEndedSubscription = null; + this.onEndedCallback = callback ?? undefined; + this.syncEndedSubscription(); + } + + /** + * EventTarget-style registration for the `ended` event, sharing one native + * subscription with the `onEnded`/`onended` handler. Other event types are + * ignored: the node dispatches nothing else. + */ + public addEventListener( + type: string, + listener: (event: EventEmptyType) => void + ): void { + if (type !== 'ended') { + return; + } + + this.endedListeners.add(listener); + this.syncEndedSubscription(); + } - if (!callback) { + public removeEventListener( + type: string, + listener: (event: EventEmptyType) => void + ): void { + if (type !== 'ended') { + return; + } + + this.endedListeners.delete(listener); + this.syncEndedSubscription(); + } + + /** + * Keep exactly one native `ended` subscription alive while any consumer + * (handler or listener) exists, and none otherwise — an orphaned subscription + * would retain this node in the native handler registry. + */ + private syncEndedSubscription(): void { + this.endedSubscription?.remove(); + this.endedSubscription = null; + + if (!this.onEndedCallback && this.endedListeners.size === 0) { (this.node as IAudioScheduledSourceNode).onEnded = '0'; - this.onEndedCallback = undefined; return; } - this.onEndedCallback = callback; - this.onEndedSubscription = this.audioEventEmitter.addAudioEventListener( + this.endedSubscription = this.audioEventEmitter.addAudioEventListener( 'ended', - callback + (event: EventEmptyType) => this.dispatchEnded(event) ); - (this.node as IAudioScheduledSourceNode).onEnded = - this.onEndedSubscription.subscriptionId; + this.endedSubscription.subscriptionId; + } + + private dispatchEnded(event: EventEmptyType): void { + const endedEvent = { ...event, type: 'ended', target: this }; + this.onEndedCallback?.(endedEvent); + this.endedListeners.forEach((listener) => listener(endedEvent)); } } From dd06a52bfb526c687839e17724e017fe418ee4ea Mon Sep 17 00:00:00 2001 From: michal Date: Fri, 21 Aug 2026 15:21:26 +0200 Subject: [PATCH 6/9] fix: moved the event purely to c++ --- .claude/skills/thread-safety-itc/SKILL.md | 24 ++++++ .../audiodocs/docs/core/audio-context.mdx | 4 + .../docs/core/base-audio-context.mdx | 27 +++++++ .../docs/core/offline-audio-context.mdx | 28 +++++++ .../swmansion/audioapi/system/AudioEvent.kt | 1 + .../BaseAudioContextHostObject.cpp | 19 ++++- .../HostObjects/BaseAudioContextHostObject.h | 3 + .../OfflineAudioContextHostObject.cpp | 3 + .../HostObjects/utils/JsEnumParser.cpp | 2 + .../audioapi/HostObjects/utils/JsEnumParser.h | 2 +- .../common/cpp/audioapi/core/AudioContext.cpp | 6 +- .../cpp/audioapi/core/BaseAudioContext.cpp | 16 ++++ .../cpp/audioapi/core/BaseAudioContext.h | 31 +++++++ .../cpp/audioapi/core/OfflineAudioContext.cpp | 7 +- .../cpp/audioapi/core/types/ContextState.h | 14 ++++ .../common/cpp/audioapi/events/AudioEvent.h | 1 + .../events/AudioEventPayloadMapping.hpp | 1 + .../audioapi/jsi/ContextPromiseResolver.cpp | 14 +++- .../src/core/AudioContext.ts | 12 +-- .../src/core/BaseAudioContext.ts | 81 ++++++++++++------- .../src/core/OfflineAudioContext.ts | 40 ++++++--- .../src/events/types.ts | 6 ++ .../src/jsi-interfaces.ts | 8 ++ 23 files changed, 291 insertions(+), 59 deletions(-) diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index 1e13b88b4..83a029480 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -195,6 +195,30 @@ together in the `ContextPromise` resolve task (CallInvoker), after driver work still reads the prior value until settlement (needed when `resume()` then `suspend()` are issued back-to-back). +**Context `statechange` event:** `BaseAudioContext::dispatchStateChange(state)` fires +`AudioEvent::STATE_CHANGE` (payload `{state: "..."}`), deduped against the last dispatched state so +the two paths that both reach RUNNING (implicit `tryStartDriver` from a source start, then the +`resume()` promise) emit once. Call it *after* the `jsiPromise->resolve(...)` enqueue in the +`ContextPromiseResolver` factory lambdas — the registry worker can only enqueue its own +`invokeAsync` later, so the event's JS task always lands behind the resolve task and its +microtasks (spec's promise-then-statechange order). Carry the state by value: `getState()` may +still report SUSPENDED until the driver settles. On the TS side the event handler is +notification-only — it must NOT write the `state` attribute; a rapid `resume()+suspend()` settles +both operations before either event arrives, and a handler write would roll the attribute back to +the stale event's value. The attribute is fully native: `BaseAudioContext::publishedState_` is read +by the JSI `state` getter and written by `setPublishedState()` **inside each promise's own +resolution lambda** (the callback passed to `jsiPromise->resolve`, which runs on the JS thread in +that promise's resolve task) — never from the worker/render thread directly, where a later +operation's write can land before an earlier operation's continuations read. The statechange event +must use `EventCaller::dispatchOnJSQueue` (registry `dispatchEventOnJSQueue`, straight +`invokeAsync`), NOT the worker-queue `dispatch`: FIFO placement right behind its own resolve task +makes the handler observe each intermediate state instead of only the final one. Do not dispatch +statechange from mid-lifecycle bodies (`tryStartDriver`) — that enqueues the event before the +resolve task, so the handler reads the pre-transition state; the resolver's dispatch covers the +implicit-start path because TS always issues `resume()` for it. Verified with WPT +`suspend-after-construct` (5/0) and a probe asserting `resume.then→running, event→running, +suspend.then→suspended, event→suspended`. + --- ## Common Mistakes diff --git a/packages/audiodocs/docs/core/audio-context.mdx b/packages/audiodocs/docs/core/audio-context.mdx index befc54774..45eaebeca 100644 --- a/packages/audiodocs/docs/core/audio-context.mdx +++ b/packages/audiodocs/docs/core/audio-context.mdx @@ -71,3 +71,7 @@ Resumes a previously suspended audio context. #### Returns `Promise`. +## Events + +Inherits [`onstatechange`](./base-audio-context.mdx#onstatechange) from [`BaseAudioContext`](./base-audio-context.mdx#events); `close`, `suspend` and `resume` each fire it once their returned promise has resolved. + diff --git a/packages/audiodocs/docs/core/base-audio-context.mdx b/packages/audiodocs/docs/core/base-audio-context.mdx index b92e87cd9..4821aae0f 100644 --- a/packages/audiodocs/docs/core/base-audio-context.mdx +++ b/packages/audiodocs/docs/core/base-audio-context.mdx @@ -227,6 +227,33 @@ const buffer = await this.audioContext.decodeAudioData(data, 4800, 2, false); ``` +## Events + +### `onstatechange` + +Sets (or removes, when `null` is assigned) a callback fired whenever the context's [`state`](./base-audio-context.mdx#properties) changes to a different value. + +The event is dispatched by the native audio engine once a transition is **acknowledged** — after the promise of the operation that caused it ([`resume`](./audio-context.mdx#resume), [`suspend`](./audio-context.mdx#suspend), [`close`](./audio-context.mdx#close), [`OfflineAudioContext.startRendering`](./offline-audio-context.mdx#startrendering)) has resolved. Code awaiting that promise always observes the new `state` value first; the `statechange` callback runs in a later task. + +It also fires for transitions no method call requested, e.g. when the context starts running implicitly because a source node's `start()` engaged the audio driver. + +The callback receives an event object: + +| Field | Type | Description | +| :---: | :---: | :---- | +| `type` | `string` | Always `'statechange'`. | +| `target` | [`BaseAudioContext`](./base-audio-context.mdx) | The context whose state changed. | + +```tsx +const audioContext = new AudioContext(); + +audioContext.onstatechange = (event) => { + console.log(`state is now ${event.target.state}`); +}; + +await audioContext.resume(); // logs "state is now running" shortly after +``` + ## Remarks #### `currentTime` diff --git a/packages/audiodocs/docs/core/offline-audio-context.mdx b/packages/audiodocs/docs/core/offline-audio-context.mdx index 62834e6fc..6c876f2ac 100644 --- a/packages/audiodocs/docs/core/offline-audio-context.mdx +++ b/packages/audiodocs/docs/core/offline-audio-context.mdx @@ -49,3 +49,31 @@ Resume time progression in audio context when it has been suspended. Starts rendering the audio, taking into account the current connections and the current scheduled changes. #### Returns `Promise`. + +## Events + +Inherits [`onstatechange`](./base-audio-context.mdx#onstatechange) from [`BaseAudioContext`](./base-audio-context.mdx#events). It fires when rendering starts (`running`), when a scheduled [`suspend`](./offline-audio-context.mdx#suspend) point is reached (`suspended`), after [`resume`](./offline-audio-context.mdx#resume) (`running`), and when rendering finishes (`closed`). + +### `oncomplete` + +Sets (or removes, when `null` is assigned) a callback fired when rendering has finished. It is dispatched after the `closed` [`statechange`](./base-audio-context.mdx#onstatechange), so the two events always arrive in spec order. The callback receives an event object: + +| Field | Type | Description | +| :---: | :---: | :---- | +| `type` | `string` | Always `'complete'`. | +| `target` | [`OfflineAudioContext`](./offline-audio-context.mdx) | The context that finished rendering. | +| `renderedBuffer` | [`AudioBuffer`](../sources/audio-buffer.mdx) | The rendered audio. | + +```tsx +const offlineContext = new OfflineAudioContext({ + numberOfChannels: 2, + length: 44100, + sampleRate: 44100, +}); + +offlineContext.oncomplete = (event) => { + console.log(`rendered ${event.renderedBuffer.duration}s of audio`); +}; + +await offlineContext.startRendering(); +``` diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt index 398a93c0f..324fac4e2 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt @@ -26,4 +26,5 @@ enum class AudioEvent { BUFFER_ENDED, RECORDER_ERROR, BUFFERING_STATE_CHANGE, + STATE_CHANGE, } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp index fadced0ca..53d91784c 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp @@ -48,7 +48,10 @@ BaseAudioContextHostObject::BaseAudioContextHostObject( JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, destination), JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, listener), JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, sampleRate), - JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, currentTime)); + JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, currentTime), + JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, state)); + + addSetters(JSI_EXPORT_PROPERTY_SETTER(BaseAudioContextHostObject, onstatechange)); addFunctions( JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createRecorderAdapter), @@ -74,7 +77,19 @@ BaseAudioContextHostObject::BaseAudioContextHostObject( // "key function" for the audio classes - this allow for RTTI to work // properly across dynamic library boundaries (i.e. dynamic_cast that is used by // isHostObject method), android specific issue -BaseAudioContextHostObject::~BaseAudioContextHostObject() = default; +BaseAudioContextHostObject::~BaseAudioContextHostObject() { + // The C++ context can outlive this HostObject (lifecycle promises hold it); + // never let it fire statechange into a GC'd JSI function. + context_->assignOnStateChangeCallbackId(0); +} + +JSI_PROPERTY_GETTER_IMPL(BaseAudioContextHostObject, state) { + return jsi::String::createFromUtf8(runtime, contextStateToString(context_->getPublishedState())); +} + +JSI_PROPERTY_SETTER_IMPL(BaseAudioContextHostObject, onstatechange) { + context_->assignOnStateChangeCallbackId(std::stoull(value.getString(runtime).utf8(runtime))); +} JSI_PROPERTY_GETTER_IMPL(BaseAudioContextHostObject, destination) { return jsi::Object::createFromHostObject(runtime, destination_); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h index d79425db0..e3824d126 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h @@ -26,6 +26,9 @@ class BaseAudioContextHostObject : public HostObject { ~BaseAudioContextHostObject() override; JSI_PROPERTY_GETTER_DECL(destination); + JSI_PROPERTY_GETTER_DECL(state); + JSI_PROPERTY_SETTER_DECL(onstatechange); + JSI_PROPERTY_GETTER_DECL(listener); JSI_PROPERTY_GETTER_DECL(sampleRate); JSI_PROPERTY_GETTER_DECL(currentTime); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/OfflineAudioContextHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/OfflineAudioContextHostObject.cpp index a00cbad9b..9838c326f 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/OfflineAudioContextHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/OfflineAudioContextHostObject.cpp @@ -51,6 +51,9 @@ JSI_HOST_FUNCTION_IMPL(OfflineAudioContextHostObject, suspend) { } JSI_HOST_FUNCTION_IMPL(OfflineAudioContextHostObject, startRendering) { + // No promise acknowledges this first transition; rendering counts as + // running from the moment it is requested, synchronously with the call. + context_->setPublishedState(ContextState::RUNNING); return promiseVendor_->createPromise([this](Promise &&promise) { auto resultPromise = OfflineAudioContextResultPromise::makeOfflineAudioContextResultResolver( std::move(promise), context_); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp index a2259b7db..aed6aaea5 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp @@ -150,6 +150,8 @@ AudioEvent audioEventFromString(const std::string &event) { return AudioEvent::RECORDER_ERROR; if (event == "bufferingStateChanged") return AudioEvent::BUFFERING_STATE_CHANGE; + if (event == "stateChange") + return AudioEvent::STATE_CHANGE; throw std::invalid_argument("Unknown audio event: " + event); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.h index d9bb1969d..5735276c8 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.h @@ -11,6 +11,7 @@ #include namespace audioapi::js_enum_parser { + std::string overSampleTypeToString(OverSampleType type); OverSampleType overSampleTypeFromString(const std::string &type); std::string oscillatorTypeToString(OscillatorType type); @@ -18,7 +19,6 @@ OscillatorType oscillatorTypeFromString(const std::string &type); std::string filterTypeToString(BiquadFilterType type); BiquadFilterType filterTypeFromString(const std::string &type); AudioEvent audioEventFromString(const std::string &event); -std::string contextStateToString(ContextState state); std::string channelCountModeToString(ChannelCountMode mode); ChannelCountMode channelCountModeFromString(const std::string &mode); std::string channelInterpretationToString(ChannelInterpretation interpretation); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp index c7a038e48..4f4c90504 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp @@ -70,10 +70,10 @@ bool AudioContext::tryStartDriver() { if (audioPlayer_->start()) { isInitialized_.store(true, std::memory_order_release); - // The driver also starts implicitly, from the first - // `AudioScheduledSourceNode::start()`. Publish RUNNING here so the visible state + // Publish RUNNING here so the visible state // never reports SUSPENDED while the graph is actually rendering; `resume()` - // reaches the same state through its promise task. + // reaches the same state through its promise task (its dispatch then + // dedupes against this one). setState(ContextState::RUNNING); return true; } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp index a845a8230..0ecc83a2b 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp @@ -18,6 +18,7 @@ BaseAudioContext::BaseAudioContext( : state_(ContextState::SUSPENDED), sampleRate_(sampleRate), audioEventHandlerRegistry_(audioEventHandlerRegistry), + stateChangeEvent_(audioEventHandlerRegistry), pendingPromisesOffloader_( std::make_unique(getCurrentSampleFrame()) / getSampleRate(); } +void BaseAudioContext::assignOnStateChangeCallbackId(uint64_t callbackId) { + stateChangeEvent_.assignCallbackId(callbackId); +} + +void BaseAudioContext::dispatchStateChange(ContextState state) { + if (lastDispatchedState_.exchange(state, std::memory_order_acq_rel) == state) { + return; + } + + // FIFO with the promise resolution the caller just enqueued, so the event + // fires between this transition's continuations and the next transition's — + // the handler observes each state, not just the final one. + stateChangeEvent_.dispatch(StringPayload{.name = "state", .reason = contextStateToString(state)}); +} + void BaseAudioContext::setState(ContextState state) { state_.store(state, std::memory_order_release); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h index a6c4c17ba..ad2c216ec 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -47,6 +48,30 @@ class BaseAudioContext : public std::enable_shared_from_this { void setState(ContextState state); + /// The value behind the JS `state` attribute. Read from the JSI getter on + /// the JS thread. + [[nodiscard]] ContextState getPublishedState() const { + return publishedState_.load(std::memory_order_acquire); + } + + /// Publishes an acknowledged transition to the JS-visible state. JS thread + /// only, and for promise-driven transitions only from the operation's own + /// resolution continuation (the TS `publishedState` setter): CallInvokers + /// may batch queued resolve tasks ahead of the first microtask checkpoint, + /// so any earlier write point lets a rapid resume()+suspend() pair publish + /// both states before either continuation reads. Native-originated + /// transitions with no acknowledging promise (planned `interrupted`) write + /// here from a CallInvoker task instead. + void setPublishedState(ContextState state) { + publishedState_.store(state, std::memory_order_release); + } + + /// JS thread. Wires the `statechange` listener registered by the TS context. + void assignOnStateChangeCallbackId(uint64_t callbackId); + + /// Fires `statechange` for an acknowledged transition to @p state. + void dispatchStateChange(ContextState state); + [[nodiscard]] std::shared_ptr createPeriodicWave( const std::vector> &complexData, bool disableNormalization, @@ -159,6 +184,12 @@ class BaseAudioContext : public std::enable_shared_from_this { std::atomic sampleRate_; std::shared_ptr audioEventHandlerRegistry_; + EventCaller stateChangeEvent_; + /// Ledger backing dispatchStateChange()'s dedupe; contexts start suspended. + std::atomic lastDispatchedState_{ContextState::SUSPENDED}; + /// Backs the JS `state` attribute; written only via setPublishedState(). + std::atomic publishedState_{ContextState::SUSPENDED}; + std::shared_ptr cachedSineWave_ = nullptr; std::shared_ptr cachedSquareWave_ = nullptr; std::shared_ptr cachedSawtoothWave_ = nullptr; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.cpp index f75b104c3..b069d9ef7 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/OfflineAudioContext.cpp @@ -163,7 +163,12 @@ void OfflineAudioContext::startRendering( renderingStarted_ = true; resultPromise_ = promise; auto runningStatePromise = std::make_shared>( - [self = shared_from_this()]() { self->setState(ContextState::RUNNING); }, + [self = shared_from_this()]() { + self->setState(ContextState::RUNNING); + // startRendering has no acknowledging promise for this transition; + // the render thread actually starting is the acknowledgment. + self->dispatchStateChange(ContextState::RUNNING); + }, [](const std::string &) {}); renderAudio(runningStatePromise); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/types/ContextState.h b/packages/react-native-audio-api/common/cpp/audioapi/core/types/ContextState.h index c40d7070f..acf3f9bad 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/types/ContextState.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/types/ContextState.h @@ -6,4 +6,18 @@ namespace audioapi { enum class ContextState : std::uint8_t { SUSPENDED, RUNNING, CLOSED }; +/// The Web Audio API `AudioContextState` string for @p state, as carried by +/// the `statechange` event payload and the JS `state` attribute. +inline const char *contextStateToString(ContextState state) { + switch (state) { + case ContextState::SUSPENDED: + return "suspended"; + case ContextState::RUNNING: + return "running"; + case ContextState::CLOSED: + return "closed"; + } + return "suspended"; } + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h index e11dc5262..beb125403 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h @@ -29,5 +29,6 @@ enum class AudioEvent : uint8_t { BUFFER_ENDED, RECORDER_ERROR, BUFFERING_STATE_CHANGE, + STATE_CHANGE, }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventPayloadMapping.hpp b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventPayloadMapping.hpp index bf26da887..eb44b720c 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventPayloadMapping.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventPayloadMapping.hpp @@ -41,6 +41,7 @@ AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::POSITION_CHANGED, DoubleValuePayload); AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::BUFFER_ENDED, BufferEndedPayload); AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::RECORDER_ERROR, StringPayload); AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::BUFFERING_STATE_CHANGE, BoolValuePayload); +AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::STATE_CHANGE, StringPayload); #undef AUDIOAPI_DEFINE_EVENT_PAYLOAD diff --git a/packages/react-native-audio-api/common/cpp/audioapi/jsi/ContextPromiseResolver.cpp b/packages/react-native-audio-api/common/cpp/audioapi/jsi/ContextPromiseResolver.cpp index e62275f24..0295f32db 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/jsi/ContextPromiseResolver.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/jsi/ContextPromiseResolver.cpp @@ -25,7 +25,12 @@ std::shared_ptr> ContextPromiseResolver::makeCon // Spec: update the state attribute in the same follow-up task that // resolves the lifecycle promise (before statechange reactions). audioContext->setState(nextState); - jsiPromise->resolve([](jsi::Runtime &runtime) { return jsi::Value::undefined(); }); + // Publishes the JS-visible state on the JS thread + jsiPromise->resolve([audioContext, nextState](jsi::Runtime &runtime) { + audioContext->setPublishedState(nextState); + return jsi::Value::undefined(); + }); + audioContext->dispatchStateChange(nextState); }, [jsiPromise](const std::string &message) { jsiPromise->reject(message); }); } @@ -44,9 +49,14 @@ ContextPromiseResolver::makeOfflineAudioContextResultResolver( // resolves the startRendering promise (before statechange reactions). audioContext->setState(ContextState::CLOSED); auto audioBufferHostObject = std::make_shared(audioBuffer); - jsiPromise->resolve([audioBufferHostObject](jsi::Runtime &runtime) { + // Published in the resolution task, as above. + jsiPromise->resolve([audioContext, audioBufferHostObject](jsi::Runtime &runtime) { + audioContext->setPublishedState(ContextState::CLOSED); return jsi::Object::createFromHostObject(runtime, audioBufferHostObject); }); + // Behind the resolve enqueue, as above; the TS layer orders the + // `complete` event after this statechange lands. + audioContext->dispatchStateChange(ContextState::CLOSED); }, [jsiPromise](const std::string &message) { jsiPromise->reject(message); }); } diff --git a/packages/react-native-audio-api/src/core/AudioContext.ts b/packages/react-native-audio-api/src/core/AudioContext.ts index a07acef85..5a8e52f05 100644 --- a/packages/react-native-audio-api/src/core/AudioContext.ts +++ b/packages/react-native-audio-api/src/core/AudioContext.ts @@ -35,7 +35,6 @@ export default class AudioContext extends BaseAudioContext { this.setControlState('closed'); await (this.context as IAudioContext).close(); - this.publishState('closed'); } async resume(): Promise { @@ -45,7 +44,6 @@ export default class AudioContext extends BaseAudioContext { this.setControlState('running'); await (this.context as IAudioContext).resume(); - this.publishState('running'); } async suspend(): Promise { @@ -55,7 +53,6 @@ export default class AudioContext extends BaseAudioContext { this.setControlState('suspended'); await (this.context as IAudioContext).suspend(); - this.publishState('suspended'); } /** @@ -66,12 +63,9 @@ export default class AudioContext extends BaseAudioContext { public override markRunningOnSourceStart(): void { if (this._state === 'suspended') { this.setControlState('running'); - (this.context as IAudioContext) - .resume() - .then(() => this.publishState('running')) - .catch(() => { - // The driver refused to start; the attribute keeps reporting reality. - }); + (this.context as IAudioContext).resume().catch(() => { + // The driver refused to start; the attribute keeps reporting reality. + }); } } diff --git a/packages/react-native-audio-api/src/core/BaseAudioContext.ts b/packages/react-native-audio-api/src/core/BaseAudioContext.ts index c18f2adfe..b62b20eb7 100644 --- a/packages/react-native-audio-api/src/core/BaseAudioContext.ts +++ b/packages/react-native-audio-api/src/core/BaseAudioContext.ts @@ -1,4 +1,6 @@ import { InvalidStateError, NotSupportedError } from '../errors'; +import { AudioEventEmitter } from '../events'; +import { OnStateChangeEventType } from '../events/types'; import { IBaseAudioContext } from '../jsi-interfaces'; import { ContextState, @@ -41,6 +43,17 @@ export default class BaseAudioContext { this.destination = new AudioDestinationNode(this, context.destination); this.listener = new AudioListener(this, context.listener); this.sampleRate = context.sampleRate; + + // The native context owns statechange: it dispatches once per acknowledged + // transition, after settling the operation's promise, so the event always + // lands in a later task than the promise continuations. This subscription + // lives as long as the context; native transitions that no JS call + // requested (e.g. a future interrupted state) flow through the same path. + this.stateChangeSubscription = this.audioEventEmitter.addAudioEventListener( + 'stateChange', + (event: OnStateChangeEventType) => this.onNativeStateChange(event) + ); + this.context.onstatechange = this.stateChangeSubscription.subscriptionId; } /** @@ -52,21 +65,50 @@ export default class BaseAudioContext { */ protected _state: ContextState = 'suspended'; + protected readonly audioEventEmitter = new AudioEventEmitter( + globalThis.AudioEventEmitter + ); + + private stateChangeSubscription: ReturnType< + AudioEventEmitter['addAudioEventListener'] + >; + + private onstatechangeCallback: + | ((event: ContextStateChangeEvent) => void) + | null = null; + /** - * The `state` attribute value: published only once a transition is - * acknowledged (the operation's promise resolved, rendering reached the - * suspend point, the offline render completed). Continuations of the - * operation's promise observe the new value; `statechange` fires afterwards. + * Web Audio API `statechange` event handler. Dispatched by the native context + * from a queued task after the `state` attribute changes — after the + * operation's promise resolution and all of its microtasks, matching the + * spec's media-element-task order. */ - private publishedState: ContextState = 'suspended'; + public get onstatechange(): + | ((event: ContextStateChangeEvent) => void) + | null { + return this.onstatechangeCallback; + } + + public set onstatechange( + callback: ((event: ContextStateChangeEvent) => void) | null + ) { + this.onstatechangeCallback = callback; + } /** - * Web Audio API `statechange` event handler. Fired from a queued task after - * the `state` attribute changes — after the operation's promise resolution - * and all of its microtasks, matching the spec's media-element-task order. + * Terminal handler for the native statechange dispatch. Deliberately does NOT + * write `state`: the attribute is published in the resolution task of the + * operation that caused the transition (spec order), and a rapid + * resume()+suspend() pair acknowledges both before either event lands — a + * write here would roll the attribute back to the stale event's value. When a + * native-originated state with no acknowledging promise arrives (the planned + * `interrupted`), its attribute update must be added here explicitly. + * Subclasses extend this to order dependent events (offline `complete`) after + * `statechange`. */ - public onstatechange: ((event: ContextStateChangeEvent) => void) | null = - null; + protected onNativeStateChange(_event: OnStateChangeEventType): void { + this.onstatechangeCallback?.({ type: 'statechange', target: this }); + } /** * Record that a state transition has been requested ([[control thread @@ -76,29 +118,12 @@ export default class BaseAudioContext { this._state = nextState; } - /** - * Publish an acknowledged transition to the `state` attribute and dispatch - * `statechange`. Also aligns the control ledger, for transitions the control - * side could not anticipate (an offline render reaching its suspend point). - */ - protected publishState(nextState: ContextState): void { - this._state = nextState; - if (this.publishedState === nextState) { - return; - } - - this.publishedState = nextState; - setTimeout(() => { - this.onstatechange?.({ type: 'statechange', target: this }); - }, 0); - } - public get currentTime(): number { return this.context.currentTime; } public get state(): ContextState { - return this.publishedState; + return this.context.state as ContextState; } /** diff --git a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts index 938ceceb1..eb35a82d1 100644 --- a/packages/react-native-audio-api/src/core/OfflineAudioContext.ts +++ b/packages/react-native-audio-api/src/core/OfflineAudioContext.ts @@ -1,5 +1,6 @@ import { InvalidStateError, NotSupportedError } from '../errors'; import { assertSupportedSampleRate } from '../utils/validation'; +import { OnStateChangeEventType } from '../events/types'; import { IOfflineAudioContext } from '../jsi-interfaces'; import { OfflineAudioContextOptions } from '../types'; import AudioBuffer from './AudioBuffer'; @@ -14,6 +15,8 @@ export interface OfflineAudioCompletionEvent { export default class OfflineAudioContext extends BaseAudioContext { private isRendering: boolean; private duration: number; + /** Set when startRendering() resolves; consumed by the `closed` statechange. */ + private pendingRenderedBuffer: AudioBuffer | null; /** * Web Audio API `complete` event handler, dispatched when startRendering() @@ -55,6 +58,7 @@ export default class OfflineAudioContext extends BaseAudioContext { this.isRendering = false; this.oncomplete = null; + this.pendingRenderedBuffer = null; } async resume(): Promise { @@ -72,7 +76,6 @@ export default class OfflineAudioContext extends BaseAudioContext { this.setControlState('running'); await (this.context as IOfflineAudioContext).resume(); - this.publishState('running'); } async suspend(suspendTime: number): Promise { @@ -101,7 +104,7 @@ export default class OfflineAudioContext extends BaseAudioContext { const result = await (this.context as IOfflineAudioContext).suspend( suspendTime ); - this.publishState('suspended'); + this.setControlState('suspended'); return result; } @@ -111,23 +114,34 @@ export default class OfflineAudioContext extends BaseAudioContext { } this.isRendering = true; - this.publishState('running'); + this.setControlState('running'); const audioBuffer = await ( this.context as IOfflineAudioContext ).startRendering(); - this.publishState('closed'); + this.setControlState('closed'); const renderedBuffer = new AudioBuffer(audioBuffer); - // A task, not a microtask: `statechange` (queued by publishState above) - // must fire before `complete`, per the spec's ordering. - setTimeout(() => { - this.oncomplete?.({ - type: 'complete', - target: this, - renderedBuffer, - }); - }, 0); + // `complete` is fired by onNativeStateChange when the native `closed` + // statechange lands — a strictly later task than this continuation — so + // `statechange` always precedes `complete`, per the spec's ordering. + this.pendingRenderedBuffer = renderedBuffer; return renderedBuffer; } + + protected override onNativeStateChange(event: OnStateChangeEventType): void { + super.onNativeStateChange(event); + + if (event.state !== 'closed' || this.pendingRenderedBuffer === null) { + return; + } + + const renderedBuffer = this.pendingRenderedBuffer; + this.pendingRenderedBuffer = null; + this.oncomplete?.({ + type: 'complete', + target: this, + renderedBuffer, + }); + } } diff --git a/packages/react-native-audio-api/src/events/types.ts b/packages/react-native-audio-api/src/events/types.ts index 34aff0d1a..94c620e46 100644 --- a/packages/react-native-audio-api/src/events/types.ts +++ b/packages/react-native-audio-api/src/events/types.ts @@ -1,5 +1,6 @@ import AudioBuffer from '../core/AudioBuffer'; import { NotificationEvents } from '../system'; +import { ContextState } from '../types'; export interface EventEmptyType {} @@ -36,6 +37,10 @@ export interface OnRecorderErrorEventType { message: string; } +export interface OnStateChangeEventType { + state: ContextState; +} + type SystemEvents = { volumeChange: EventTypeWithValue; interruption: OnInterruptionEventType; @@ -81,6 +86,7 @@ interface AudioAPIEvents { recorderError: OnRecorderErrorEventType; /** `value` is true while an `