mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
refactor(voice-call): unify runtime cleanup lifecycle
This commit is contained in:
147
extensions/voice-call/src/runtime.test.ts
Normal file
147
extensions/voice-call/src/runtime.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { VoiceCallConfig } from "./config.js";
|
||||
import type { CoreConfig } from "./core-bridge.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveVoiceCallConfig: vi.fn(),
|
||||
validateProviderConfig: vi.fn(),
|
||||
managerInitialize: vi.fn(),
|
||||
webhookStart: vi.fn(),
|
||||
webhookStop: vi.fn(),
|
||||
webhookGetMediaStreamHandler: vi.fn(),
|
||||
startTunnel: vi.fn(),
|
||||
setupTailscaleExposure: vi.fn(),
|
||||
cleanupTailscaleExposure: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./config.js", () => ({
|
||||
resolveVoiceCallConfig: mocks.resolveVoiceCallConfig,
|
||||
validateProviderConfig: mocks.validateProviderConfig,
|
||||
}));
|
||||
|
||||
vi.mock("./manager.js", () => ({
|
||||
CallManager: class {
|
||||
initialize = mocks.managerInitialize;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./webhook.js", () => ({
|
||||
VoiceCallWebhookServer: class {
|
||||
start = mocks.webhookStart;
|
||||
stop = mocks.webhookStop;
|
||||
getMediaStreamHandler = mocks.webhookGetMediaStreamHandler;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./tunnel.js", () => ({
|
||||
startTunnel: mocks.startTunnel,
|
||||
}));
|
||||
|
||||
vi.mock("./webhook/tailscale.js", () => ({
|
||||
setupTailscaleExposure: mocks.setupTailscaleExposure,
|
||||
cleanupTailscaleExposure: mocks.cleanupTailscaleExposure,
|
||||
}));
|
||||
|
||||
import { createVoiceCallRuntime } from "./runtime.js";
|
||||
|
||||
function createBaseConfig(): VoiceCallConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
provider: "mock",
|
||||
fromNumber: "+15550001234",
|
||||
inboundPolicy: "disabled",
|
||||
allowFrom: [],
|
||||
outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 },
|
||||
maxDurationSeconds: 300,
|
||||
staleCallReaperSeconds: 600,
|
||||
silenceTimeoutMs: 800,
|
||||
transcriptTimeoutMs: 180000,
|
||||
ringTimeoutMs: 30000,
|
||||
maxConcurrentCalls: 1,
|
||||
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
|
||||
tailscale: { mode: "off", path: "/voice/webhook" },
|
||||
tunnel: { provider: "ngrok", allowNgrokFreeTierLoopbackBypass: false },
|
||||
webhookSecurity: {
|
||||
allowedHosts: [],
|
||||
trustForwardingHeaders: false,
|
||||
trustedProxyIPs: [],
|
||||
},
|
||||
streaming: {
|
||||
enabled: false,
|
||||
sttProvider: "openai-realtime",
|
||||
sttModel: "gpt-4o-transcribe",
|
||||
silenceDurationMs: 800,
|
||||
vadThreshold: 0.5,
|
||||
streamPath: "/voice/stream",
|
||||
preStartTimeoutMs: 5000,
|
||||
maxPendingConnections: 32,
|
||||
maxPendingConnectionsPerIp: 4,
|
||||
maxConnections: 128,
|
||||
},
|
||||
skipSignatureVerification: false,
|
||||
stt: { provider: "openai", model: "whisper-1" },
|
||||
tts: {
|
||||
provider: "openai",
|
||||
openai: { model: "gpt-4o-mini-tts", voice: "coral" },
|
||||
},
|
||||
responseModel: "openai/gpt-4o-mini",
|
||||
responseTimeoutMs: 30000,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createVoiceCallRuntime lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveVoiceCallConfig.mockImplementation((cfg: VoiceCallConfig) => cfg);
|
||||
mocks.validateProviderConfig.mockReturnValue({ valid: true, errors: [] });
|
||||
mocks.managerInitialize.mockResolvedValue(undefined);
|
||||
mocks.webhookStart.mockResolvedValue("http://127.0.0.1:3334/voice/webhook");
|
||||
mocks.webhookStop.mockResolvedValue(undefined);
|
||||
mocks.webhookGetMediaStreamHandler.mockReturnValue(undefined);
|
||||
mocks.startTunnel.mockResolvedValue(null);
|
||||
mocks.setupTailscaleExposure.mockResolvedValue(null);
|
||||
mocks.cleanupTailscaleExposure.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("cleans up tunnel, tailscale, and webhook server when init fails after start", async () => {
|
||||
const tunnelStop = vi.fn().mockResolvedValue(undefined);
|
||||
mocks.startTunnel.mockResolvedValue({
|
||||
publicUrl: "https://public.example/voice/webhook",
|
||||
provider: "ngrok",
|
||||
stop: tunnelStop,
|
||||
});
|
||||
mocks.managerInitialize.mockRejectedValue(new Error("init failed"));
|
||||
|
||||
await expect(
|
||||
createVoiceCallRuntime({
|
||||
config: createBaseConfig(),
|
||||
coreConfig: {},
|
||||
}),
|
||||
).rejects.toThrow("init failed");
|
||||
|
||||
expect(tunnelStop).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.cleanupTailscaleExposure).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.webhookStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns an idempotent stop handler", async () => {
|
||||
const tunnelStop = vi.fn().mockResolvedValue(undefined);
|
||||
mocks.startTunnel.mockResolvedValue({
|
||||
publicUrl: "https://public.example/voice/webhook",
|
||||
provider: "ngrok",
|
||||
stop: tunnelStop,
|
||||
});
|
||||
|
||||
const runtime = await createVoiceCallRuntime({
|
||||
config: createBaseConfig(),
|
||||
coreConfig: {} as CoreConfig,
|
||||
});
|
||||
|
||||
await runtime.stop();
|
||||
await runtime.stop();
|
||||
|
||||
expect(tunnelStop).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.cleanupTailscaleExposure).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.webhookStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,49 @@ type Logger = {
|
||||
debug?: (message: string) => void;
|
||||
};
|
||||
|
||||
function createRuntimeResourceLifecycle(params: {
|
||||
config: VoiceCallConfig;
|
||||
webhookServer: VoiceCallWebhookServer;
|
||||
}): {
|
||||
setTunnelResult: (result: TunnelResult | null) => void;
|
||||
stop: (opts?: { suppressErrors?: boolean }) => Promise<void>;
|
||||
} {
|
||||
let tunnelResult: TunnelResult | null = null;
|
||||
let stopped = false;
|
||||
|
||||
const runStep = async (step: () => Promise<void>, suppressErrors: boolean) => {
|
||||
if (suppressErrors) {
|
||||
await step().catch(() => {});
|
||||
return;
|
||||
}
|
||||
await step();
|
||||
};
|
||||
|
||||
return {
|
||||
setTunnelResult: (result) => {
|
||||
tunnelResult = result;
|
||||
},
|
||||
stop: async (opts) => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
const suppressErrors = opts?.suppressErrors ?? false;
|
||||
await runStep(async () => {
|
||||
if (tunnelResult) {
|
||||
await tunnelResult.stop();
|
||||
}
|
||||
}, suppressErrors);
|
||||
await runStep(async () => {
|
||||
await cleanupTailscaleExposure(params.config);
|
||||
}, suppressErrors);
|
||||
await runStep(async () => {
|
||||
await params.webhookServer.stop();
|
||||
}, suppressErrors);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isLoopbackBind(bind: string | undefined): boolean {
|
||||
if (!bind) {
|
||||
return false;
|
||||
@@ -123,9 +166,9 @@ export async function createVoiceCallRuntime(params: {
|
||||
const provider = resolveProvider(config);
|
||||
const manager = new CallManager(config);
|
||||
const webhookServer = new VoiceCallWebhookServer(config, manager, provider, coreConfig);
|
||||
const lifecycle = createRuntimeResourceLifecycle({ config, webhookServer });
|
||||
|
||||
const localUrl = await webhookServer.start();
|
||||
let tunnelResult: TunnelResult | null = null;
|
||||
|
||||
// Wrap remaining initialization in try/catch so the webhook server is
|
||||
// properly stopped if any subsequent step fails. Without this, the server
|
||||
@@ -137,14 +180,15 @@ export async function createVoiceCallRuntime(params: {
|
||||
|
||||
if (!publicUrl && config.tunnel?.provider && config.tunnel.provider !== "none") {
|
||||
try {
|
||||
tunnelResult = await startTunnel({
|
||||
const nextTunnelResult = await startTunnel({
|
||||
provider: config.tunnel.provider,
|
||||
port: config.serve.port,
|
||||
path: config.serve.path,
|
||||
ngrokAuthToken: config.tunnel.ngrokAuthToken,
|
||||
ngrokDomain: config.tunnel.ngrokDomain,
|
||||
});
|
||||
publicUrl = tunnelResult?.publicUrl ?? null;
|
||||
lifecycle.setTunnelResult(nextTunnelResult);
|
||||
publicUrl = nextTunnelResult?.publicUrl ?? null;
|
||||
} catch (err) {
|
||||
log.error(
|
||||
`[voice-call] Tunnel setup failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
@@ -193,13 +237,7 @@ export async function createVoiceCallRuntime(params: {
|
||||
|
||||
await manager.initialize(provider, webhookUrl);
|
||||
|
||||
const stop = async () => {
|
||||
if (tunnelResult) {
|
||||
await tunnelResult.stop();
|
||||
}
|
||||
await cleanupTailscaleExposure(config);
|
||||
await webhookServer.stop();
|
||||
};
|
||||
const stop = async () => await lifecycle.stop();
|
||||
|
||||
log.info("[voice-call] Runtime initialized");
|
||||
log.info(`[voice-call] Webhook URL: ${webhookUrl}`);
|
||||
@@ -220,11 +258,7 @@ export async function createVoiceCallRuntime(params: {
|
||||
// If any step after the server started fails, clean up every provisioned
|
||||
// resource (tunnel, tailscale exposure, and webhook server) so retries
|
||||
// don't leak processes or keep the port bound.
|
||||
if (tunnelResult) {
|
||||
await tunnelResult.stop().catch(() => {});
|
||||
}
|
||||
await cleanupTailscaleExposure(config).catch(() => {});
|
||||
await webhookServer.stop().catch(() => {});
|
||||
await lifecycle.stop({ suppressErrors: true });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user