From d15cd2343ac44ee609d145856628c02cdf493919 Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:47:39 +0000 Subject: [PATCH] fix: honor stop() called while start() is still connecting --- __tests__/stopWhileConnecting.test.ts | 175 ++++++++++++++++++++++++++ vapi.ts | 12 ++ 2 files changed, 187 insertions(+) create mode 100644 __tests__/stopWhileConnecting.test.ts diff --git a/__tests__/stopWhileConnecting.test.ts b/__tests__/stopWhileConnecting.test.ts new file mode 100644 index 000000000..df9bfddca --- /dev/null +++ b/__tests__/stopWhileConnecting.test.ts @@ -0,0 +1,175 @@ +import Vapi from "../vapi"; + +let mockDailyCall: any = null; +const mockCreateWebCall = jest.fn(); + +jest.mock("@daily-co/daily-js", () => { + return { + __esModule: true, + default: { + createCallObject: () => mockDailyCall, + }, + }; +}); + +jest.mock("../client", () => { + return { + client: { + baseUrl: "", + setSecurityData: () => {}, + call: { + callControllerCreateWebCall: (...args: any[]) => + mockCreateWebCall(...args), + }, + }, + }; +}); + +function createMockDailyCall() { + return { + on: jest.fn(), + once: jest.fn(), + join: jest.fn().mockResolvedValue(undefined), + destroy: jest.fn().mockResolvedValue(undefined), + iframe: jest.fn().mockReturnValue(null), + setLocalAudio: jest.fn(), + localAudio: jest.fn(), + updateInputSettings: jest.fn().mockResolvedValue(undefined), + startRemoteParticipantsAudioLevelObserver: jest.fn(), + startLocalAudioLevelObserver: jest.fn(), + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +// Lets start() run past the microphone preflight to the web call request. +async function untilWebCallRequested(times = 1) { + for (let i = 0; i < 100 && mockCreateWebCall.mock.calls.length < times; i++) { + await Promise.resolve(); + } + expect(mockCreateWebCall).toHaveBeenCalledTimes(times); +} + +const webCallResponse = { + data: { id: "call_test", webCallUrl: "https://example.daily.co/test" }, +}; + +describe("Vapi stop() while start() is connecting", () => { + let vapi: Vapi; + + beforeEach(() => { + mockCreateWebCall.mockReset(); + mockDailyCall = createMockDailyCall(); + const track = { readyState: "live", stop: jest.fn() }; + const stream = { getAudioTracks: () => [track], getTracks: () => [track] }; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + userAgent: "test", + mediaDevices: { getUserMedia: jest.fn().mockResolvedValue(stream) }, + }, + }); + vapi = new Vapi("dummy_token"); + vapi.on("error", () => {}); + }); + + it("does not join the call when stop() lands while the web call is being created", async () => { + const pending = deferred(); + mockCreateWebCall.mockReturnValue(pending.promise); + + const started = vapi.start("assistant_id"); + await untilWebCallRequested(); + await vapi.stop(); + pending.resolve(webCallResponse); + + await expect(started).resolves.toBeNull(); + expect(mockDailyCall.join).not.toHaveBeenCalled(); + expect(vapi.getDailyCallObject()).toBeNull(); + }); + + it("does not report a cancelled start as a failure", async () => { + const pending = deferred(); + mockCreateWebCall.mockReturnValue(pending.promise); + const failed = jest.fn(); + vapi.on("call-start-failed", failed); + const success = jest.fn(); + vapi.on("call-start-success", success); + + const started = vapi.start("assistant_id"); + await untilWebCallRequested(); + await vapi.stop(); + pending.resolve(webCallResponse); + await started; + + expect(failed).not.toHaveBeenCalled(); + expect(success).not.toHaveBeenCalled(); + }); + + it("does not connect a cancelled start after a new start() has begun", async () => { + const first = deferred(); + const secondCall = createMockDailyCall(); + mockCreateWebCall + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(webCallResponse); + + const firstStart = vapi.start("assistant_id"); + await untilWebCallRequested(); + await vapi.stop(); + mockDailyCall = secondCall; + await vapi.start("assistant_id"); + first.resolve(webCallResponse); + + await expect(firstStart).resolves.toBeNull(); + expect(secondCall.join).toHaveBeenCalledTimes(1); + expect(secondCall.destroy).not.toHaveBeenCalled(); + expect(vapi.getDailyCallObject()).toBe(secondCall); + }); + + it("does not report a failure when stop() destroys the call while join() is pending", async () => { + const join = deferred(); + mockDailyCall.join.mockReturnValue(join.promise); + mockCreateWebCall.mockResolvedValue(webCallResponse); + const failed = jest.fn(); + vapi.on("call-start-failed", failed); + const success = jest.fn(); + vapi.on("call-start-success", success); + + const started = vapi.start("assistant_id"); + await new Promise((resolve) => setImmediate(resolve)); + expect(mockDailyCall.join).toHaveBeenCalledTimes(1); + await vapi.stop(); + join.resolve(); + + await expect(started).resolves.toBeNull(); + expect(failed).not.toHaveBeenCalled(); + expect(success).not.toHaveBeenCalled(); + expect(vapi.getDailyCallObject()).toBeNull(); + }); + + it("does not join when stop() lands during the mobile permission wait", async () => { + jest.spyOn(vapi as any, "isMobileDevice").mockReturnValue(true); + jest.useFakeTimers(); + try { + mockCreateWebCall.mockResolvedValue(webCallResponse); + const failed = jest.fn(); + vapi.on("call-start-failed", failed); + + const started = vapi.start("assistant_id"); + await jest.advanceTimersByTimeAsync(0); + await vapi.stop(); + await jest.advanceTimersByTimeAsync(1000); + + await expect(started).resolves.toBeNull(); + expect(mockDailyCall.join).not.toHaveBeenCalled(); + expect(failed).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/vapi.ts b/vapi.ts index af3c9c914..4558ad0e2 100644 --- a/vapi.ts +++ b/vapi.ts @@ -345,6 +345,8 @@ class VapiEventEmitter extends EventEmitter { export default class Vapi extends VapiEventEmitter { private started: boolean = false; + // Bumped by stop() so a start() still in flight can tell it was cancelled. + private startGeneration = 0; private call: DailyCall | null = null; private audioPlayer: HTMLAudioElement | null = null; // Survives teardown so the caller's choice persists across calls. @@ -535,6 +537,8 @@ export default class Vapi extends VapiEventEmitter { }); this.started = true; + const generation = ++this.startGeneration; + const isCancelled = () => generation !== this.startGeneration; let failureStage = 'microphone-preflight'; try { @@ -569,6 +573,9 @@ export default class Vapi extends VapiEventEmitter { }) ).data; + // stop() was called while the web call was being created. + if (isCancelled()) return null; + const webCallDuration = Date.now() - webCallStartTime; this.emit('call-start-progress', { stage: 'web-call-creation', @@ -770,6 +777,7 @@ export default class Vapi extends VapiEventEmitter { if (isMobile) { const mobileWaitStartTime = Date.now(); await this.sleep(1000); + if (isCancelled()) return null; const mobileWaitDuration = Date.now() - mobileWaitStartTime; this.emit('call-start-progress', { stage: 'mobile-permissions', @@ -807,6 +815,8 @@ export default class Vapi extends VapiEventEmitter { subscribeToTracksAutomatically: false, }); + if (isCancelled()) return null; + const joinDuration = Date.now() - joinStartTime; this.emit('call-start-progress', { stage: 'daily-call-join', @@ -815,6 +825,7 @@ export default class Vapi extends VapiEventEmitter { timestamp: new Date().toISOString() }); } catch (error) { + if (isCancelled()) return null; const joinDuration = Date.now() - joinStartTime; const serializedError = serializeError(error); this.emit('call-start-progress', { @@ -1131,6 +1142,7 @@ export default class Vapi extends VapiEventEmitter { * If `roomDeleteOnUserLeaveEnabled` is set to `true`, the Vapi call will also be destroyed, preventing any reconnections. */ async stop(): Promise { + this.startGeneration++; if (this.cancelMicrophonePreflight) { this.cancelMicrophonePreflight(); return;