From 0670441f8a3319b8a44998da36b2af31dd50b98e Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Sat, 8 Aug 2026 00:29:01 +1000 Subject: [PATCH 1/2] fix(web): make microphone start failure-atomic --- src/web.js | 56 ++++++++++++++++++++-------- test/web-adapter.test.js | 79 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 16 deletions(-) diff --git a/src/web.js b/src/web.js index 19c1b2e..44f5062 100644 --- a/src/web.js +++ b/src/web.js @@ -87,11 +87,50 @@ export class WebMicrophoneAdapter { return { started: true, constraints }; } catch (error) { const code = classifyMediaError(error); - this.engine.raiseError(new Error(code), this.now()); + await this.cleanupResources(); + try { + this.engine.raiseError(new Error(code), this.now()); + } catch {} throw Object.assign(new Error(code), { cause: error }); } } + async cleanupResources() { + if (this.intervalId !== null) { + try { + this.clearIntervalRef(this.intervalId); + } catch {} + } + + try { + this.sourceNode?.disconnect?.(); + } catch {} + try { + this.analyser?.disconnect?.(); + } catch {} + + let tracks = []; + try { + tracks = this.stream?.getTracks?.() ?? []; + } catch {} + for (const track of tracks) { + try { + track.stop(); + } catch {} + } + + try { + await this.audioContext?.close?.(); + } catch {} + + this.stream = null; + this.audioContext = null; + this.sourceNode = null; + this.analyser = null; + this.intervalId = null; + this.reuseBuffer = new Float32Array(0); + } + sampleOnce(timestamp = this.now()) { if (!this.analyser) { return null; @@ -104,21 +143,8 @@ export class WebMicrophoneAdapter { } async stop() { - if (this.intervalId) { - this.clearIntervalRef(this.intervalId); - this.intervalId = null; - } - - this.sourceNode?.disconnect?.(); - this.analyser?.disconnect?.(); - this.stream?.getTracks?.().forEach((track) => track.stop()); - await this.audioContext?.close?.(); + await this.cleanupResources(); this.engine.stop(this.now()); - - this.stream = null; - this.audioContext = null; - this.sourceNode = null; - this.analyser = null; return { stopped: true }; } } diff --git a/test/web-adapter.test.js b/test/web-adapter.test.js index a5edc3e..17b42eb 100644 --- a/test/web-adapter.test.js +++ b/test/web-adapter.test.js @@ -40,8 +40,14 @@ class FakeAnalyser { } class FakeSourceNode { + constructor() { + this.disconnected = false; + } + connect() {} - disconnect() {} + disconnect() { + this.disconnected = true; + } } class FakeAudioContext { @@ -133,3 +139,74 @@ test('web adapter surfaces permission denied errors', async () => { await assert.rejects(() => adapter.start(), /permission_denied/); assert.equal(engine.getSnapshot().state, 'error'); }); + +test('web adapter releases the stream when AudioContext construction fails', async () => { + const engine = createBargeKit(); + const stream = new FakeStream(); + const adapter = createWebMicrophoneAdapter({ + engine, + navigatorRef: { + mediaDevices: { async getUserMedia() { return stream; } } + }, + AudioContextCtor: class { + constructor() { + throw new Error('context construction failed'); + } + } + }); + + await assert.rejects( + () => adapter.start(), + (error) => error.message === 'microphone_error' && error.cause.message === 'context construction failed' + ); + assert.equal(stream.getTracks()[0].stopped, true); + assert.equal(adapter.stream, null); + assert.equal(adapter.audioContext, null); + assert.equal(adapter.sourceNode, null); + assert.equal(adapter.analyser, null); + assert.equal(adapter.intervalId, null); + assert.equal(engine.getSnapshot().state, 'error'); +}); + +test('web adapter tears down graph and leaves no timer after late start failure', async () => { + const engine = createBargeKit(); + const stream = new FakeStream(); + const analyser = new FakeAnalyser(); + const context = new FakeAudioContext(analyser); + const source = new FakeSourceNode(); + context.createMediaStreamSource = () => source; + context.createAnalyser = () => { + throw new Error('analyser setup failed'); + }; + const activeTimers = []; + const adapter = createWebMicrophoneAdapter({ + engine, + navigatorRef: { + mediaDevices: { async getUserMedia() { return stream; } } + }, + AudioContextCtor: class { constructor() { return context; } }, + setIntervalRef(handler) { + activeTimers.push(handler); + return 0; + }, + clearIntervalRef(timer) { + assert.equal(timer, 0); + activeTimers.length = 0; + } + }); + + await assert.rejects( + () => adapter.start(), + (error) => error.message === 'microphone_error' && error.cause.message === 'analyser setup failed' + ); + assert.equal(stream.getTracks()[0].stopped, true); + assert.equal(source.disconnected, true); + assert.equal(context.closed, true); + assert.equal(activeTimers.length, 0); + assert.equal(adapter.stream, null); + assert.equal(adapter.audioContext, null); + assert.equal(adapter.sourceNode, null); + assert.equal(adapter.analyser, null); + assert.equal(adapter.intervalId, null); + assert.equal(engine.getSnapshot().state, 'error'); +}); From ca8e93bb1c00a6d9ba7a1aee6e21b35342ed627c Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Sat, 8 Aug 2026 00:29:01 +1000 Subject: [PATCH 2/2] docs(web): document failed-start cleanup guarantee --- docs/API.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index d7e6642..ec12df5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -41,7 +41,7 @@ The ducking controller turns barge-in events into hold/duck/cancel/resume decisi import { createWebMicrophoneAdapter } from '@bargekit/core/web'; ``` -The browser adapter requests microphone permission explicitly, samples analyser levels, and does not record or upload audio. +The browser adapter requests microphone permission explicitly, samples analyser levels, and does not record or upload audio. `start()` is failure-atomic: if setup fails after permission is granted, it stops acquired tracks, clears timers, disconnects the audio graph, closes the audio context, and releases its resource references before rejecting with the classified microphone error. ## Observability and tuning