refactor(voice-call): unify runtime cleanup lifecycle

This commit is contained in:
Peter Steinberger
2026-03-03 02:51:17 +00:00
parent c85bd2646a
commit ac318be405
2 changed files with 196 additions and 15 deletions

View 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);
});
});

View File

@@ -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;
}
}