From d2dbe96c59a1f1a1f889597edc11037e1a6753dd 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 | 155 ++++++++++++++++++++++++++ vapi.ts | 12 ++ 2 files changed, 167 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..cbd9f1541 --- /dev/null +++ b/__tests__/stopWhileConnecting.test.ts @@ -0,0 +1,155 @@ +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 }; +} + +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(); + 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 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 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 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 6fa5e7f47..b00998061 100644 --- a/vapi.ts +++ b/vapi.ts @@ -343,6 +343,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. @@ -497,6 +499,8 @@ export default class Vapi extends VapiEventEmitter { }); this.started = true; + const generation = ++this.startGeneration; + const isCancelled = () => generation !== this.startGeneration; try { // Stage 1: Create web call @@ -522,6 +526,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', @@ -733,6 +740,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', @@ -770,6 +778,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', @@ -778,6 +788,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', { @@ -1092,6 +1103,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++; this.started = false; if (this.call) { await this.call.destroy();