diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index e8721ba85..dda532c6a 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -3,6 +3,7 @@ import { closeSync, existsSync, openSync, readFileSync, rmSync } from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' import { loadConfig, resolvePaseoHome } from '@getpaseo/server' +import { applySherpaLoaderEnv } from './sherpa-env.js' export interface DaemonStartOptions { port?: string @@ -280,6 +281,7 @@ export async function startLocalDaemonDetached( } const childEnv: NodeJS.ProcessEnv = { ...process.env } + applySherpaLoaderEnv(childEnv) if (options.home) { childEnv.PASEO_HOME = options.home } diff --git a/packages/cli/src/commands/daemon/sherpa-env.ts b/packages/cli/src/commands/daemon/sherpa-env.ts new file mode 100644 index 000000000..1e91c7d71 --- /dev/null +++ b/packages/cli/src/commands/daemon/sherpa-env.ts @@ -0,0 +1,53 @@ +import { spawnSync } from 'node:child_process' +import { + applySherpaLoaderEnv as applySherpaLoaderEnvFromServer, + resolveSherpaLoaderEnv, +} from '@getpaseo/server' + +const SHERPA_ENV_BOOTSTRAPPED = 'PASEO_SHERPA_ENV_BOOTSTRAPPED' + +export function applySherpaLoaderEnv(env: NodeJS.ProcessEnv): { + changed: boolean + key: 'LD_LIBRARY_PATH' | 'DYLD_LIBRARY_PATH' | 'PATH' | null + libDir: string | null +} { + const result = applySherpaLoaderEnvFromServer(env) + return { + changed: result.changed, + key: result.key, + libDir: result.libDir, + } +} + +export function reexecWithSherpaLoaderEnvIfNeeded(): never | void { + const resolved = resolveSherpaLoaderEnv() + if (!resolved) { + return + } + if (process.env[SHERPA_ENV_BOOTSTRAPPED] === '1') { + return + } + + const envCopy: NodeJS.ProcessEnv = { ...process.env } + const applied = applySherpaLoaderEnvFromServer(envCopy) + const updatedValue = applied.key ? envCopy[applied.key] ?? '' : '' + if (applied.key && updatedValue === (process.env[applied.key] ?? '')) { + return + } + + const env: NodeJS.ProcessEnv = { + ...envCopy, + [SHERPA_ENV_BOOTSTRAPPED]: '1', + } + + const result = spawnSync(process.execPath, process.argv.slice(1), { + env, + stdio: 'inherit', + }) + + if (result.error) { + throw result.error + } + + process.exit(result.status ?? 1) +} diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index caa84ef69..b2209aa5f 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -12,6 +12,7 @@ import { startLocalDaemonDetached, type DaemonStartOptions as StartOptions, } from './local-daemon.js' +import { reexecWithSherpaLoaderEnvIfNeeded } from './sherpa-env.js' import { getErrorMessage } from '../../utils/errors.js' export type { DaemonStartOptions as StartOptions } from './local-daemon.js' @@ -79,6 +80,8 @@ export async function runStart(options: StartOptions): Promise { return } + reexecWithSherpaLoaderEnvIfNeeded() + if (options.home) { process.env.PASEO_HOME = options.home } diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index f4c8c5406..5095dc93e 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -13,6 +13,15 @@ export { type LocalSttModelId, type LocalTtsModelId, } from "./speech/providers/local/models.js"; +export { + applySherpaLoaderEnv, + resolveSherpaLoaderEnv, + sherpaLoaderEnvKey, + sherpaPlatformArch, + sherpaPlatformPackageName, + type SherpaLoaderEnvKey, + type SherpaLoaderEnvResolution, +} from "./speech/providers/local/sherpa/sherpa-runtime-env.js"; // Agent SDK types for CLI commands export type { diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts index 81498c079..ae7107c16 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts @@ -1,5 +1,12 @@ import { createRequire } from "node:module"; import path from "node:path"; +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { + applySherpaLoaderEnv, + resolveSherpaLoaderEnv, + sherpaPlatformPackageName, +} from "./sherpa-runtime-env.js"; export type SherpaOnnxNodeModule = { OfflineRecognizer: new (config: any) => any; @@ -9,21 +16,77 @@ export type SherpaOnnxNodeModule = { let cached: SherpaOnnxNodeModule | null = null; -function platformArch(): string { - const platform = process.platform === "win32" ? "win" : process.platform; - return `${platform}-${process.arch}`; +type LoadAttempt = { + target: string; + error: unknown; +}; + +function appendAttempt(attempts: LoadAttempt[], target: string, error: unknown): void { + attempts.push({ target, error }); } -function prependLibraryPath( - envKey: "DYLD_LIBRARY_PATH" | "LD_LIBRARY_PATH" | "PATH", - dir: string -): void { - const current = process.env[envKey] ?? ""; - const parts = current.split(path.delimiter).filter(Boolean); - if (parts.includes(dir)) { +function formatError(error: unknown): string { + if (!error) { + return "unknown error"; + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function maybePatchLinuxAddonRunpath(addonPath: string): void { + if (process.platform !== "linux") { return; } - process.env[envKey] = [dir, ...parts].join(path.delimiter); + const patchelfCheck = spawnSync("patchelf", ["--version"], { + stdio: "ignore", + }); + if (patchelfCheck.status !== 0) { + return; + } + + const currentRpath = spawnSync("patchelf", ["--print-rpath", addonPath], { + encoding: "utf8", + }); + if (currentRpath.status !== 0) { + return; + } + const rpath = (currentRpath.stdout ?? "").trim(); + if (rpath.includes("$ORIGIN")) { + return; + } + + spawnSync("patchelf", ["--set-rpath", "$ORIGIN", addonPath], { + stdio: "ignore", + }); +} + +function loadWithRequire( + requireFn: NodeRequire, + target: string, + attempts: LoadAttempt[] +): SherpaOnnxNodeModule | null { + try { + return requireFn(target) as SherpaOnnxNodeModule; + } catch (error) { + appendAttempt(attempts, target, error); + return null; + } +} + +function buildFailure(attempts: LoadAttempt[], pkgName: string): Error { + const details = attempts + .map((attempt) => `- ${attempt.target}: ${formatError(attempt.error)}`) + .join("\n"); + const message = [ + `Failed to load sherpa-onnx-node for ${process.platform}-${process.arch}.`, + `Node ${process.version} (ABI ${process.versions.modules}).`, + `Platform package: ${pkgName}.`, + "Load attempts:", + details || "- (no attempts made)", + ].join("\n"); + return new Error(message); } export function loadSherpaOnnxNode(): SherpaOnnxNodeModule { @@ -32,26 +95,41 @@ export function loadSherpaOnnxNode(): SherpaOnnxNodeModule { } const require = createRequire(import.meta.url); + const attempts: LoadAttempt[] = []; - // sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64) - // that contains the native addon and shared libraries. Ensure the OS loader path includes it. - const arch = platformArch(); - const pkgName = `sherpa-onnx-${arch}`; - - try { - const pkgJson = require.resolve(`${pkgName}/package.json`); - const pkgDir = path.dirname(pkgJson); - if (process.platform === "darwin") { - prependLibraryPath("DYLD_LIBRARY_PATH", pkgDir); - } else if (process.platform === "linux") { - prependLibraryPath("LD_LIBRARY_PATH", pkgDir); - } else if (process.platform === "win32") { - prependLibraryPath("PATH", pkgDir); - } - } catch { - // Best effort - if the platform package isn't present, require() below will throw a useful error. + // Pass through upstream support matrix first. + const direct = loadWithRequire(require, "sherpa-onnx-node", attempts); + if (direct) { + cached = direct; + return cached; } - cached = require("sherpa-onnx-node") as SherpaOnnxNodeModule; - return cached; + // sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64) + // that contains the native addon and shared libraries. + const pkgName = sherpaPlatformPackageName(); + const resolvedEnv = resolveSherpaLoaderEnv(); + const platformPkgDir = resolvedEnv?.libDir ?? null; + + if (platformPkgDir) { + applySherpaLoaderEnv(process.env); + const addonPath = path.join(platformPkgDir, "sherpa-onnx.node"); + + if (existsSync(addonPath)) { + const byPath = loadWithRequire(require, addonPath, attempts); + if (byPath) { + cached = byPath; + return cached; + } + + // Linux fallback for broken prebuilt RUNPATHs. + maybePatchLinuxAddonRunpath(addonPath); + const afterPatch = loadWithRequire(require, addonPath, attempts); + if (afterPatch) { + cached = afterPatch; + return cached; + } + } + } + + throw buildFailure(attempts, pkgName); } diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-runtime-env.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-runtime-env.ts new file mode 100644 index 000000000..85cbd80f5 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-runtime-env.ts @@ -0,0 +1,103 @@ +import { createRequire } from "node:module"; +import path from "node:path"; + +export type SherpaLoaderEnvKey = "LD_LIBRARY_PATH" | "DYLD_LIBRARY_PATH" | "PATH"; + +export type SherpaLoaderEnvResolution = { + key: SherpaLoaderEnvKey; + libDir: string; + packageName: string; +}; + +export function sherpaPlatformArch( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): string { + const normalizedPlatform = platform === "win32" ? "win" : platform; + return `${normalizedPlatform}-${arch}`; +} + +export function sherpaPlatformPackageName( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): string { + return `sherpa-onnx-${sherpaPlatformArch(platform, arch)}`; +} + +export function sherpaLoaderEnvKey( + platform: NodeJS.Platform = process.platform +): SherpaLoaderEnvKey | null { + if (platform === "linux") { + return "LD_LIBRARY_PATH"; + } + if (platform === "darwin") { + return "DYLD_LIBRARY_PATH"; + } + if (platform === "win32") { + return "PATH"; + } + return null; +} + +export function prependEnvPath(existing: string | undefined, value: string): string { + const parts = (existing ?? "").split(path.delimiter).filter(Boolean); + if (parts.includes(value)) { + return parts.join(path.delimiter); + } + return [value, ...parts].join(path.delimiter); +} + +export function resolveSherpaLoaderEnv( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): SherpaLoaderEnvResolution | null { + const key = sherpaLoaderEnvKey(platform); + if (!key) { + return null; + } + + const packageName = sherpaPlatformPackageName(platform, arch); + const require = createRequire(import.meta.url); + try { + const pkgJson = require.resolve(`${packageName}/package.json`); + return { + key, + libDir: path.dirname(pkgJson), + packageName, + }; + } catch { + return null; + } +} + +export function applySherpaLoaderEnv( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): { + changed: boolean; + key: SherpaLoaderEnvKey | null; + libDir: string | null; + packageName: string | null; +} { + const resolved = resolveSherpaLoaderEnv(platform, arch); + if (!resolved) { + return { + changed: false, + key: null, + libDir: null, + packageName: null, + }; + } + + const next = prependEnvPath(env[resolved.key], resolved.libDir); + const changed = next !== (env[resolved.key] ?? ""); + env[resolved.key] = next; + return { + changed, + key: resolved.key, + libDir: resolved.libDir, + packageName: resolved.packageName, + }; +} +