fix(server): stop background speech downloads

This commit is contained in:
Mohamed Boudra
2026-08-18 01:28:21 +02:00
parent 78629f92ef
commit f9633dd359
5 changed files with 83 additions and 12 deletions
+1 -1
View File
@@ -1704,7 +1704,7 @@ export async function createPaseoDaemon(
await agentStorage.flush().catch(() => undefined); await agentStorage.flush().catch(() => undefined);
await providerSnapshotManager.shutdown(); await providerSnapshotManager.shutdown();
terminalManager.killAll(); terminalManager.killAll();
speechService.stop(); await speechService.stop();
await scheduleService.stop().catch(() => undefined); await scheduleService.stop().catch(() => undefined);
await relayRuntime?.stop().catch(() => undefined); await relayRuntime?.stop().catch(() => undefined);
if (wsServer) { if (wsServer) {
@@ -34,10 +34,12 @@ export async function ensureLocalSpeechModels(options: {
modelsDir: string; modelsDir: string;
modelIds: LocalSpeechModelId[]; modelIds: LocalSpeechModelId[];
logger: import("pino").Logger; logger: import("pino").Logger;
signal?: AbortSignal;
}): Promise<Record<LocalSpeechModelId, string>> { }): Promise<Record<LocalSpeechModelId, string>> {
return ensureSherpaOnnxModels({ return ensureSherpaOnnxModels({
modelsDir: options.modelsDir, modelsDir: options.modelsDir,
modelIds: options.modelIds, modelIds: options.modelIds,
logger: options.logger, logger: options.logger,
signal: options.signal,
}); });
} }
@@ -12,6 +12,7 @@ export interface EnsureSherpaOnnxModelOptions {
modelsDir: string; modelsDir: string;
modelId: SherpaOnnxModelId; modelId: SherpaOnnxModelId;
logger: pino.Logger; logger: pino.Logger;
signal?: AbortSignal;
} }
export function getSherpaOnnxModelDir(modelsDir: string, modelId: SherpaOnnxModelId): string { export function getSherpaOnnxModelDir(modelsDir: string, modelId: SherpaOnnxModelId): string {
@@ -40,11 +41,12 @@ async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Prom
interface DownloadToFileOptions { interface DownloadToFileOptions {
url: string; url: string;
outputPath: string; outputPath: string;
signal?: AbortSignal;
} }
async function downloadToFile(options: DownloadToFileOptions): Promise<void> { async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
const { url, outputPath } = options; const { url, outputPath } = options;
const res = await fetch(url); const res = await fetch(url, { signal: options.signal });
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`); throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`);
} }
@@ -60,7 +62,7 @@ async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
const nodeStream = Readable.fromWeb(res.body as any); const nodeStream = Readable.fromWeb(res.body as any);
try { try {
await pipeline(nodeStream, createWriteStream(tmpPath)); await pipeline(nodeStream, createWriteStream(tmpPath), { signal: options.signal });
await rename(tmpPath, outputPath); await rename(tmpPath, outputPath);
} catch (error) { } catch (error) {
await rm(tmpPath, { force: true }).catch(() => undefined); await rm(tmpPath, { force: true }).catch(() => undefined);
@@ -68,12 +70,17 @@ async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
} }
} }
async function extractTarArchive(archivePath: string, destDir: string): Promise<void> { async function extractTarArchive(
archivePath: string,
destDir: string,
signal?: AbortSignal,
): Promise<void> {
await mkdir(destDir, { recursive: true }); await mkdir(destDir, { recursive: true });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const child = spawnProcess("tar", ["xf", archivePath, "-C", destDir], { const child = spawnProcess("tar", ["xf", archivePath, "-C", destDir], {
stdio: "inherit", stdio: "inherit",
signal,
}); });
child.on("error", reject); child.on("error", reject);
child.on("exit", (code) => { child.on("exit", (code) => {
@@ -119,6 +126,7 @@ export async function ensureSherpaOnnxModel(
await downloadToFile({ await downloadToFile({
url: spec.archiveUrl, url: spec.archiveUrl,
outputPath: archivePath, outputPath: archivePath,
signal: options.signal,
}); });
} }
@@ -130,7 +138,7 @@ export async function ensureSherpaOnnxModel(
}, },
"Extracting model archive", "Extracting model archive",
); );
await extractTarArchive(archivePath, options.modelsDir); await extractTarArchive(archivePath, options.modelsDir, options.signal);
logger.info( logger.info(
{ {
@@ -170,6 +178,7 @@ export async function ensureSherpaOnnxModels(options: {
modelsDir: string; modelsDir: string;
modelIds: SherpaOnnxModelId[]; modelIds: SherpaOnnxModelId[];
logger: pino.Logger; logger: pino.Logger;
signal?: AbortSignal;
}): Promise<Record<SherpaOnnxModelId, string>> { }): Promise<Record<SherpaOnnxModelId, string>> {
const uniq = Array.from(new Set(options.modelIds)); const uniq = Array.from(new Set(options.modelIds));
const entries: Array<[SherpaOnnxModelId, string]> = await Promise.all( const entries: Array<[SherpaOnnxModelId, string]> = await Promise.all(
@@ -178,6 +187,7 @@ export async function ensureSherpaOnnxModels(options: {
modelsDir: options.modelsDir, modelsDir: options.modelsDir,
modelId: id, modelId: id,
logger: options.logger, logger: options.logger,
signal: options.signal,
}); });
return [id, modelPath] as [SherpaOnnxModelId, string]; return [id, modelPath] as [SherpaOnnxModelId, string];
}), }),
@@ -7,7 +7,8 @@ import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech-provid
import type { TurnDetectionProvider } from "./turn-detection-provider.js"; import type { TurnDetectionProvider } from "./turn-detection-provider.js";
import { createSpeechService } from "./speech-runtime.js"; import { createSpeechService } from "./speech-runtime.js";
const { initializeLocalSpeechServicesMock } = vi.hoisted(() => ({ const { ensureLocalSpeechModelsMock, initializeLocalSpeechServicesMock } = vi.hoisted(() => ({
ensureLocalSpeechModelsMock: vi.fn(async () => ({})),
initializeLocalSpeechServicesMock: vi.fn<(args: unknown) => Promise<InitializedLocalSpeech>>(), initializeLocalSpeechServicesMock: vi.fn<(args: unknown) => Promise<InitializedLocalSpeech>>(),
})); }));
@@ -34,7 +35,7 @@ vi.mock("./providers/openai/runtime.js", () => ({
})); }));
vi.mock("./providers/local/models.js", () => ({ vi.mock("./providers/local/models.js", () => ({
ensureLocalSpeechModels: vi.fn(async () => {}), ensureLocalSpeechModels: ensureLocalSpeechModelsMock,
getLocalSpeechModelDir: vi.fn(() => ""), getLocalSpeechModelDir: vi.fn(() => ""),
listLocalSpeechModels: vi.fn(() => []), listLocalSpeechModels: vi.fn(() => []),
})); }));
@@ -66,6 +67,12 @@ function createStubTurnDetection(id: string): TurnDetectionProvider {
}; };
} }
function rejectWhenAborted(signal?: AbortSignal): Promise<never> {
return new Promise((_resolve, reject) => {
signal?.addEventListener("abort", () => reject(signal.reason), { once: true });
});
}
function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpeechConfig { function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpeechConfig {
return { return {
providers, providers,
@@ -78,6 +85,8 @@ function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpe
describe("createSpeechService readiness", () => { describe("createSpeechService readiness", () => {
beforeEach(() => { beforeEach(() => {
ensureLocalSpeechModelsMock.mockReset();
ensureLocalSpeechModelsMock.mockResolvedValue({});
initializeLocalSpeechServicesMock.mockReset(); initializeLocalSpeechServicesMock.mockReset();
}); });
@@ -116,7 +125,7 @@ describe("createSpeechService readiness", () => {
expect(readiness.voiceFeature.available).toBe(true); expect(readiness.voiceFeature.available).toBe(true);
expect(readiness.voiceFeature.reasonCode).toBe("ready"); expect(readiness.voiceFeature.reasonCode).toBe("ready");
runtime.stop(); await runtime.stop();
}); });
it("keeps voice feature available when only realtime voice is enabled and ready", async () => { it("keeps voice feature available when only realtime voice is enabled and ready", async () => {
@@ -156,6 +165,47 @@ describe("createSpeechService readiness", () => {
expect(readiness.voiceFeature.available).toBe(true); expect(readiness.voiceFeature.available).toBe(true);
expect(readiness.voiceFeature.reasonCode).toBe("ready"); expect(readiness.voiceFeature.reasonCode).toBe("ready");
runtime.stop(); await runtime.stop();
});
it("aborts and joins an in-flight model download when stopped", async () => {
let downloadSignal: AbortSignal | undefined;
ensureLocalSpeechModelsMock.mockImplementation(({ signal }: { signal?: AbortSignal }) => {
downloadSignal = signal;
return rejectWhenAborted(signal);
});
initializeLocalSpeechServicesMock.mockResolvedValue({
turnDetectionService: null,
sttService: null,
ttsService: null,
dictationSttService: null,
localVoiceTtsProvider: null,
localModelConfig: {
modelsDir: "/tmp/missing-local-speech-models",
defaultModelIds: ["parakeet-tdt-0.6b-v2-int8"],
},
availability: {
configured: true,
modelsDir: "/tmp/missing-local-speech-models",
},
cleanup: () => {},
});
const runtime = createSpeechService({
logger: pino({ level: "silent" }),
speechConfig: createSpeechConfig({
dictationStt: { provider: "local", enabled: true, explicit: true },
voiceTurnDetection: { provider: "local", enabled: false, explicit: true },
voiceStt: { provider: "local", enabled: false, explicit: true },
voiceTts: { provider: "local", enabled: false, explicit: true },
}),
});
runtime.start();
await runtime.ready;
expect(ensureLocalSpeechModelsMock).toHaveBeenCalledOnce();
await runtime.stop();
expect(downloadSignal?.aborted).toBe(true);
}); });
}); });
@@ -350,7 +350,7 @@ export interface SpeechService {
getReadiness: () => SpeechReadinessSnapshot; getReadiness: () => SpeechReadinessSnapshot;
onReadinessChange: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void; onReadinessChange: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void;
start: () => void; start: () => void;
stop: () => void; stop: () => Promise<void>;
ready: Promise<void>; ready: Promise<void>;
} }
@@ -394,6 +394,8 @@ export function createSpeechService(params: {
let missingLocalModelIds: LocalSpeechModelId[] = []; let missingLocalModelIds: LocalSpeechModelId[] = [];
let backgroundDownloadInProgress = false; let backgroundDownloadInProgress = false;
let backgroundDownloadAbortController: AbortController | null = null;
let backgroundDownloadPromise: Promise<void> | null = null;
let backgroundDownloadError: string | null = null; let backgroundDownloadError: string | null = null;
let stopped = false; let stopped = false;
let monitorTimeout: ReturnType<typeof setTimeout> | null = null; let monitorTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -609,12 +611,15 @@ export function createSpeechService(params: {
"Starting background download for missing local speech models", "Starting background download for missing local speech models",
); );
void (async () => { const abortController = new AbortController();
backgroundDownloadAbortController = abortController;
backgroundDownloadPromise = (async () => {
try { try {
await ensureLocalSpeechModels({ await ensureLocalSpeechModels({
modelsDir, modelsDir,
modelIds, modelIds,
logger, logger,
signal: abortController.signal,
}); });
await runReconcile(); await runReconcile();
backgroundDownloadError = null; backgroundDownloadError = null;
@@ -629,6 +634,7 @@ export function createSpeechService(params: {
"Background local speech model download failed", "Background local speech model download failed",
); );
} finally { } finally {
backgroundDownloadAbortController = null;
backgroundDownloadInProgress = false; backgroundDownloadInProgress = false;
await refreshMissingLocalModels().catch((error) => { await refreshMissingLocalModels().catch((error) => {
logger.warn({ err: error }, "Failed to refresh local speech model status after download"); logger.warn({ err: error }, "Failed to refresh local speech model status after download");
@@ -699,13 +705,16 @@ export function createSpeechService(params: {
})(); })();
}; };
const stop = (): void => { const stop = async (): Promise<void> => {
stopped = true; stopped = true;
if (monitorTimeout) { if (monitorTimeout) {
clearTimeout(monitorTimeout); clearTimeout(monitorTimeout);
monitorTimeout = null; monitorTimeout = null;
} }
localCleanup(); localCleanup();
backgroundDownloadAbortController?.abort();
await backgroundDownloadPromise?.catch(() => undefined);
backgroundDownloadPromise = null;
}; };
return { return {