From 00fd3e3556a4c6f3bb96a51a850d4b5048d5fe56 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 28 Aug 2026 07:27:26 +0000 Subject: [PATCH] fix(voice): serialize capture lifecycle A capture start already awaiting permission could resume after teardown, and repeated destroy calls did not share completion. Order capture lifecycle operations behind one promise and build the native module declarations before clean-CI typechecking. --- .github/workflows/ci.yml | 3 + .../app/src/voice/audio-engine.native.test.ts | 56 +++++++ packages/app/src/voice/audio-engine.native.ts | 141 ++++++++++-------- 3 files changed, 134 insertions(+), 66 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69689e9c13..46b9dcf45b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,9 @@ jobs: - name: Build server stack run: npm run build:server + - name: Build native app dependency + run: npm run build --workspace=@getpaseo/expo-two-way-audio + - name: Typecheck all packages run: npm run typecheck diff --git a/packages/app/src/voice/audio-engine.native.test.ts b/packages/app/src/voice/audio-engine.native.test.ts index 9b19396fa6..fb04492e11 100644 --- a/packages/app/src/voice/audio-engine.native.test.ts +++ b/packages/app/src/voice/audio-engine.native.test.ts @@ -105,6 +105,60 @@ describe("native audio capture", () => { expect(nativeAudio.tearDown).toHaveBeenCalledOnce(); }); + it("does not finish an in-flight capture start after destruction", async () => { + let resolvePermission!: (permission: { granted: boolean }) => void; + nativeAudio.getMicrophonePermissionsAsync.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePermission = resolve; + }), + ); + const engine = createAudioEngine({ + onCaptureData: vi.fn(), + onVolumeLevel: vi.fn(), + }); + + const started = engine.startCapture(); + await Promise.resolve(); + const destroyed = engine.destroy(); + resolvePermission({ granted: true }); + + await expect(started).rejects.toThrow("Audio engine has been destroyed"); + await destroyed; + + expect(nativeAudio.initialize).not.toHaveBeenCalled(); + expect(nativeAudio.toggleRecording).not.toHaveBeenCalled(); + }); + + it("makes concurrent destruction wait for the same capture drain", async () => { + const engine = createAudioEngine({ + onCaptureData: vi.fn(), + onVolumeLevel: vi.fn(), + }); + + await engine.startCapture(); + let firstFinished = false; + let secondFinished = false; + const first = engine.destroy().then(() => { + firstFinished = true; + return undefined; + }); + const second = engine.destroy().then(() => { + secondFinished = true; + return undefined; + }); + await Promise.resolve(); + + expect(firstFinished).toBe(false); + expect(secondFinished).toBe(false); + expect(nativeAudio.stopRecording).toHaveBeenCalledOnce(); + + nativeAudio.resolveStop(); + await Promise.all([first, second]); + + expect(nativeAudio.tearDown).toHaveBeenCalledOnce(); + }); + it("does not restart capture when destruction overtakes a pending stop", async () => { const engine = createAudioEngine({ onCaptureData: vi.fn(), @@ -115,7 +169,9 @@ describe("native audio capture", () => { const stopped = engine.stopCapture(); const restarted = engine.startCapture(); const destroyed = engine.destroy(); + await Promise.resolve(); + expect(nativeAudio.stopRecording).toHaveBeenCalledOnce(); nativeAudio.resolveStop(); await stopped; await expect(restarted).rejects.toThrow("Audio engine has been destroyed"); diff --git a/packages/app/src/voice/audio-engine.native.ts b/packages/app/src/voice/audio-engine.native.ts index 40440d06ae..20294ab91d 100644 --- a/packages/app/src/voice/audio-engine.native.ts +++ b/packages/app/src/voice/audio-engine.native.ts @@ -74,7 +74,8 @@ export function createAudioEngine( const refs: { initialized: boolean; captureActive: boolean; - captureStopPromise: Promise | null; + captureLifecycle: Promise; + destroyPromise: Promise | null; muted: boolean; queue: QueuedAudio[]; processingQueue: boolean; @@ -88,7 +89,8 @@ export function createAudioEngine( } = { initialized: false, captureActive: false, - captureStopPromise: null, + captureLifecycle: Promise.resolve(), + destroyPromise: null, muted: false, queue: [], processingQueue: false, @@ -180,29 +182,22 @@ export function createAudioEngine( } } - async function stopCapture(): Promise { - if (refs.captureStopPromise) { - await refs.captureStopPromise; - return; - } + function runCaptureLifecycle(operation: () => Promise): Promise { + const result = refs.captureLifecycle.then(operation, operation); + refs.captureLifecycle = result.catch(() => undefined); + return result; + } + + async function stopCaptureNow(): Promise { if (!refs.captureActive) { return; } - const stopping = (async () => { - await native.stopRecording(); - refs.captureActive = false; - refs.muted = false; - callbacks.onVolumeLevel(0); - releaseSessionIfIdle(); - })(); - - refs.captureStopPromise = stopping; - try { - await stopping; - } finally { - refs.captureStopPromise = null; - } + await native.stopRecording(); + refs.captureActive = false; + refs.muted = false; + callbacks.onVolumeLevel(0); + releaseSessionIfIdle(); } async function playAudio(audio: AudioPlaybackSource): Promise { @@ -273,56 +268,70 @@ export function createAudioEngine( await ensureInitialized(); }, - async destroy() { - if (refs.destroyed) { - return; + destroy() { + if (refs.destroyPromise) { + return refs.destroyPromise; } refs.destroyed = true; - await stopCapture(); - this.stop(); - this.clearQueue(); - clearPlaybackTimeout(); - refs.muted = false; - callbacks.onVolumeLevel(0); - if (refs.initialized) { - native.tearDown(); - refs.initialized = false; - } - microphoneSubscription.remove(); - volumeSubscription.remove(); - interruptionSubscription.remove(); - }, - - async startCapture() { - if (refs.captureStopPromise) { - await refs.captureStopPromise; - } - if (refs.destroyed) { - throw new Error("Audio engine has been destroyed"); - } - if (refs.captureActive) { - return; - } - - try { - await ensureMicrophonePermission(); - await ensureInitialized(); - const isRecording = native.toggleRecording(true); - if (!isRecording) { - throw new Error( - "Microphone capture could not start because Android audio focus is unavailable.", - ); + refs.destroyPromise = runCaptureLifecycle(async () => { + await stopCaptureNow(); + this.stop(); + this.clearQueue(); + clearPlaybackTimeout(); + refs.muted = false; + callbacks.onVolumeLevel(0); + if (refs.initialized) { + native.tearDown(); + refs.initialized = false; } - refs.captureActive = true; - } catch (error) { - const wrapped = error instanceof Error ? error : new Error(String(error)); - callbacks.onError?.(wrapped); - throw wrapped; - } + microphoneSubscription.remove(); + volumeSubscription.remove(); + interruptionSubscription.remove(); + }); + return refs.destroyPromise; }, - async stopCapture() { - await stopCapture(); + startCapture() { + if (refs.destroyed) { + return Promise.reject(new Error("Audio engine has been destroyed")); + } + + return runCaptureLifecycle(async () => { + if (refs.destroyed) { + throw new Error("Audio engine has been destroyed"); + } + if (refs.captureActive) { + return; + } + + try { + await ensureMicrophonePermission(); + if (refs.destroyed) { + throw new Error("Audio engine has been destroyed"); + } + await ensureInitialized(); + if (refs.destroyed) { + throw new Error("Audio engine has been destroyed"); + } + const isRecording = native.toggleRecording(true); + if (!isRecording) { + throw new Error( + "Microphone capture could not start because Android audio focus is unavailable.", + ); + } + refs.captureActive = true; + } catch (error) { + const wrapped = error instanceof Error ? error : new Error(String(error)); + if (!refs.destroyed) { + callbacks.onError?.(wrapped); + } + throw wrapped; + } + }); + }, + + stopCapture() { + return runCaptureLifecycle(stopCaptureNow); }, toggleMute() {