fix(server): harden sherpa native loading and centralize runtime env
This commit is contained in:
@@ -3,6 +3,7 @@ import { closeSync, existsSync, openSync, readFileSync, rmSync } from 'node:fs'
|
|||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { loadConfig, resolvePaseoHome } from '@getpaseo/server'
|
import { loadConfig, resolvePaseoHome } from '@getpaseo/server'
|
||||||
|
import { applySherpaLoaderEnv } from './sherpa-env.js'
|
||||||
|
|
||||||
export interface DaemonStartOptions {
|
export interface DaemonStartOptions {
|
||||||
port?: string
|
port?: string
|
||||||
@@ -280,6 +281,7 @@ export async function startLocalDaemonDetached(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const childEnv: NodeJS.ProcessEnv = { ...process.env }
|
const childEnv: NodeJS.ProcessEnv = { ...process.env }
|
||||||
|
applySherpaLoaderEnv(childEnv)
|
||||||
if (options.home) {
|
if (options.home) {
|
||||||
childEnv.PASEO_HOME = options.home
|
childEnv.PASEO_HOME = options.home
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
startLocalDaemonDetached,
|
startLocalDaemonDetached,
|
||||||
type DaemonStartOptions as StartOptions,
|
type DaemonStartOptions as StartOptions,
|
||||||
} from './local-daemon.js'
|
} from './local-daemon.js'
|
||||||
|
import { reexecWithSherpaLoaderEnvIfNeeded } from './sherpa-env.js'
|
||||||
import { getErrorMessage } from '../../utils/errors.js'
|
import { getErrorMessage } from '../../utils/errors.js'
|
||||||
|
|
||||||
export type { DaemonStartOptions as StartOptions } from './local-daemon.js'
|
export type { DaemonStartOptions as StartOptions } from './local-daemon.js'
|
||||||
@@ -79,6 +80,8 @@ export async function runStart(options: StartOptions): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reexecWithSherpaLoaderEnvIfNeeded()
|
||||||
|
|
||||||
if (options.home) {
|
if (options.home) {
|
||||||
process.env.PASEO_HOME = options.home
|
process.env.PASEO_HOME = options.home
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ export {
|
|||||||
type LocalSttModelId,
|
type LocalSttModelId,
|
||||||
type LocalTtsModelId,
|
type LocalTtsModelId,
|
||||||
} from "./speech/providers/local/models.js";
|
} 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
|
// Agent SDK types for CLI commands
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
+108
-30
@@ -1,5 +1,12 @@
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import path from "node:path";
|
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 = {
|
export type SherpaOnnxNodeModule = {
|
||||||
OfflineRecognizer: new (config: any) => any;
|
OfflineRecognizer: new (config: any) => any;
|
||||||
@@ -9,21 +16,77 @@ export type SherpaOnnxNodeModule = {
|
|||||||
|
|
||||||
let cached: SherpaOnnxNodeModule | null = null;
|
let cached: SherpaOnnxNodeModule | null = null;
|
||||||
|
|
||||||
function platformArch(): string {
|
type LoadAttempt = {
|
||||||
const platform = process.platform === "win32" ? "win" : process.platform;
|
target: string;
|
||||||
return `${platform}-${process.arch}`;
|
error: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function appendAttempt(attempts: LoadAttempt[], target: string, error: unknown): void {
|
||||||
|
attempts.push({ target, error });
|
||||||
}
|
}
|
||||||
|
|
||||||
function prependLibraryPath(
|
function formatError(error: unknown): string {
|
||||||
envKey: "DYLD_LIBRARY_PATH" | "LD_LIBRARY_PATH" | "PATH",
|
if (!error) {
|
||||||
dir: string
|
return "unknown error";
|
||||||
): void {
|
}
|
||||||
const current = process.env[envKey] ?? "";
|
if (error instanceof Error) {
|
||||||
const parts = current.split(path.delimiter).filter(Boolean);
|
return error.message;
|
||||||
if (parts.includes(dir)) {
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybePatchLinuxAddonRunpath(addonPath: string): void {
|
||||||
|
if (process.platform !== "linux") {
|
||||||
return;
|
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 {
|
export function loadSherpaOnnxNode(): SherpaOnnxNodeModule {
|
||||||
@@ -32,26 +95,41 @@ export function loadSherpaOnnxNode(): SherpaOnnxNodeModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
const attempts: LoadAttempt[] = [];
|
||||||
|
|
||||||
// sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64)
|
// Pass through upstream support matrix first.
|
||||||
// that contains the native addon and shared libraries. Ensure the OS loader path includes it.
|
const direct = loadWithRequire(require, "sherpa-onnx-node", attempts);
|
||||||
const arch = platformArch();
|
if (direct) {
|
||||||
const pkgName = `sherpa-onnx-${arch}`;
|
cached = direct;
|
||||||
|
return cached;
|
||||||
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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cached = require("sherpa-onnx-node") as SherpaOnnxNodeModule;
|
// sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64)
|
||||||
return cached;
|
// 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user