From 39644b2507aa2699f3b9c08384ee28f8e71cf4b9 Mon Sep 17 00:00:00 2001 From: digua Date: Sat, 1 Aug 2026 12:41:53 +0800 Subject: [PATCH] fix(cli): verify embedding runtime before atomic rename --- .../embedding/local-runtime.test.ts | 49 +++++++++++++++++-- .../semantic-index/embedding/local-runtime.ts | 49 ++++++++++++++++++- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/packages/node-runtime/src/semantic-index/embedding/local-runtime.test.ts b/packages/node-runtime/src/semantic-index/embedding/local-runtime.test.ts index c5780649..b5c654ac 100644 --- a/packages/node-runtime/src/semantic-index/embedding/local-runtime.test.ts +++ b/packages/node-runtime/src/semantic-index/embedding/local-runtime.test.ts @@ -15,6 +15,9 @@ function setupRuntimeTest(t: TestContext, overrides: Partial fs.rmSync(root, { recursive: true, force: true })) const installs: Array<{ command: string; args: string[]; cwd: string }> = [] let verifies = 0 + const verifyRuntime = async () => { + verifies++ + } const manager = createLocalEmbeddingRuntimeManager({ baseDir: root, installMode: 'auto', @@ -26,9 +29,8 @@ function setupRuntimeTest(t: TestContext, overrides: Partial { - verifies++ - }, + verifyStagedRuntime: verifyRuntime, + verifyRuntime, ...overrides, }) return { root, manager, installs, getVerifyCount: () => verifies } @@ -95,6 +97,43 @@ test('cleans a failed staging install and retries on the next request', async (t assert.equal(fixture.manager.getStatus(), 'ready') }) +test('verifies the staged native runtime in a disposable subprocess before renaming', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-local-runtime-subprocess-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const verifierPidPath = path.join(root, 'verifier.pid') + const manager = createLocalEmbeddingRuntimeManager({ + baseDir: root, + installMode: 'auto', + platform: 'test-platform', + arch: 'test-arch', + runInstall: async (input) => { + const transformersDir = path.join(input.cwd, 'node_modules', '@huggingface', 'transformers') + const onnxDir = path.join(input.cwd, 'node_modules', 'onnxruntime-node') + fs.mkdirSync(transformersDir, { recursive: true }) + fs.mkdirSync(onnxDir, { recursive: true }) + fs.writeFileSync( + path.join(transformersDir, 'package.json'), + `${JSON.stringify({ type: 'module', exports: './index.js' })}\n` + ) + fs.writeFileSync( + path.join(transformersDir, 'index.js'), + `import fs from 'node:fs'\nfs.writeFileSync(${JSON.stringify(verifierPidPath)}, String(process.pid))\nexport const pipeline = () => {}\nexport const env = {}\n` + ) + fs.writeFileSync( + path.join(onnxDir, 'package.json'), + `${JSON.stringify({ type: 'module', exports: './index.js' })}\n` + ) + fs.writeFileSync(path.join(onnxDir, 'index.js'), 'export const listSupportedBackends = () => []\n') + }, + verifyRuntime: async () => {}, + }) + + const runtimeDir = await manager.ensureInstalled() + + assert.notEqual(Number(fs.readFileSync(verifierPidPath, 'utf8')), process.pid) + assert.equal(fs.existsSync(runtimeDir), true) +}) + test('preinstalled mode fails clearly instead of invoking npm', async (t) => { let installCalled = false const fixture = setupRuntimeTest(t, { @@ -120,6 +159,10 @@ test('repairs an invalid managed runtime instead of keeping a broken install', a installMode: 'auto', platform: 'test-platform', arch: 'test-arch', + verifyStagedRuntime: async () => { + verifyAttempts++ + if (verifyAttempts === 1) throw new Error('corrupt native runtime') + }, verifyRuntime: async () => { verifyAttempts++ if (verifyAttempts === 1) throw new Error('corrupt native runtime') diff --git a/packages/node-runtime/src/semantic-index/embedding/local-runtime.ts b/packages/node-runtime/src/semantic-index/embedding/local-runtime.ts index 82a0f2ac..fbe48484 100644 --- a/packages/node-runtime/src/semantic-index/embedding/local-runtime.ts +++ b/packages/node-runtime/src/semantic-index/embedding/local-runtime.ts @@ -35,12 +35,36 @@ export interface LocalEmbeddingRuntimeManagerOptions extends LocalEmbeddingRunti arch?: string npmCommand?: string runInstall?: (input: InstallCommand) => Promise + verifyStagedRuntime?: (runtimeDir: string) => Promise verifyRuntime?: (runtimeDir: string) => Promise importTransformers?: (runtimeDir: string) => Promise } const RUNTIME_MANIFEST_FILE = 'runtime.json' +const VERIFY_RUNTIME_SCRIPT = ` +import { createRequire } from 'node:module' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +const [, runtimeDir, transformersPackage] = process.argv +const runtimeRequire = createRequire(path.join(runtimeDir, 'package.json')) +const importFromRuntime = async (packageName) => { + const resolved = runtimeRequire.resolve(packageName) + return await import(pathToFileURL(resolved).href) +} + +const transformers = await importFromRuntime(transformersPackage) +if (typeof transformers.pipeline !== 'function' || typeof transformers.env !== 'object') { + throw new Error('Installed Transformers runtime does not expose the expected Node API.') +} + +const onnxRuntime = await importFromRuntime('onnxruntime-node') +if (typeof onnxRuntime.listSupportedBackends !== 'function') { + throw new Error('Installed ONNX runtime does not expose listSupportedBackends().') +} +` + function executeInstall(input: InstallCommand): Promise { return new Promise((resolve, reject) => { execFile( @@ -63,6 +87,26 @@ function executeInstall(input: InstallCommand): Promise { }) } +function verifyRuntimeInSubprocess(runtimeDir: string): Promise { + return new Promise((resolve, reject) => { + execFile( + process.execPath, + ['--input-type=module', '--eval', VERIFY_RUNTIME_SCRIPT, runtimeDir, LOCAL_EMBEDDING_RUNTIME_PACKAGE], + { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + maxBuffer: 10 * 1024 * 1024, + }, + (error, _stdout, stderr) => { + if (!error) { + resolve() + return + } + reject(new Error(stderr.trim() || error.message, { cause: error })) + } + ) + }) +} + function createRuntimeRequire(runtimeDir: string): NodeJS.Require { return createRequire(path.join(runtimeDir, 'package.json')) } @@ -98,6 +142,7 @@ export class LocalEmbeddingRuntimeManager { private readonly arch: string private readonly npmCommand: string private readonly runInstall: (input: InstallCommand) => Promise + private readonly verifyStagedRuntime: (runtimeDir: string) => Promise private readonly verifyRuntime: (runtimeDir: string) => Promise private readonly importTransformers: (runtimeDir: string) => Promise @@ -113,6 +158,7 @@ export class LocalEmbeddingRuntimeManager { this.arch = options.arch ?? process.arch this.npmCommand = options.npmCommand ?? (this.platform === 'win32' ? 'npm.cmd' : 'npm') this.runInstall = options.runInstall ?? executeInstall + this.verifyStagedRuntime = options.verifyStagedRuntime ?? verifyRuntimeInSubprocess this.verifyRuntime = options.verifyRuntime ?? verifyInstalledRuntime this.importTransformers = options.importTransformers ?? loadInstalledTransformers this.status = 'idle' @@ -221,7 +267,8 @@ export class LocalEmbeddingRuntimeManager { ], cwd: stagingDir, }) - await this.verifyRuntime(stagingDir) + // Keep native modules in a disposable process so Windows releases DLL handles before the directory rename. + await this.verifyStagedRuntime(stagingDir) fs.writeFileSync(path.join(stagingDir, RUNTIME_MANIFEST_FILE), `${JSON.stringify(this.manifest(), null, 2)}\n`) if (fs.existsSync(runtimeDir)) {