onnxruntime (wake word) and ctranslate2 (local speech-to-text) link against msvcp140.dll and msvcp140_1.dll, which the managed CPython does not ship. On Windows without the system VC++ Redistributable both imports failed with "DLL load failed: The specified module could not be found", so "Hey Hermes" and voice chat transcription never worked. - Bundle the Microsoft-signed VC143 CRT set (14.44.35211, matching the managed CPython's vcruntime140) as a Windows extra resource with a SHA-256 manifest. - Before every local backend start, copy missing or mismatching CRT files beside the managed python.exe (the process image directory, searched first), verifying digests; never overwrite the interpreter's own vcruntime and never block boot. Existing installs heal on their next launch. - Report an unloadable native voice wheel as a VC++ runtime problem in the wake-word requirements, local transcription and `hermes doctor`, instead of treating installed package metadata as proof the wheel loads. - Bump AITURK IDE to 1.0.0-beta.12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
172 lines
6.4 KiB
TypeScript
172 lines
6.4 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import crypto from 'node:crypto'
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import { afterEach, test } from 'vitest'
|
|
|
|
import { ensureWindowsVcRuntime, loadVcRuntimeManifest, readPythonHome } from './windows-vc-runtime'
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
const roots: string[] = []
|
|
|
|
function tempRoot() {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aiturk-vc-runtime-'))
|
|
roots.push(root)
|
|
|
|
return root
|
|
}
|
|
|
|
function sha256(data: Buffer) {
|
|
return crypto.createHash('sha256').update(data).digest('hex')
|
|
}
|
|
|
|
// A fake install: venv/pyvenv.cfg -> python home, plus a bundled source dir
|
|
// whose manifest describes the given files.
|
|
function fixture(files: Record<string, { data: string; ownedByPython?: boolean }>) {
|
|
const root = tempRoot()
|
|
const home = path.join(root, 'python home')
|
|
const venv = path.join(root, 'venv')
|
|
const source = path.join(root, 'bundle')
|
|
|
|
for (const dir of [home, venv, source]) {
|
|
fs.mkdirSync(dir, { recursive: true })
|
|
}
|
|
|
|
fs.writeFileSync(path.join(home, 'python.exe'), 'exe')
|
|
fs.writeFileSync(path.join(venv, 'pyvenv.cfg'), `home = ${home}\r\nversion_info = 3.11\r\n`)
|
|
|
|
const entries = Object.entries(files).map(([name, { data, ownedByPython }]) => {
|
|
const bytes = Buffer.from(data)
|
|
fs.writeFileSync(path.join(source, name), bytes)
|
|
|
|
return { name, size: bytes.length, sha256: sha256(bytes), ...(ownedByPython ? { ownedByPython } : {}) }
|
|
})
|
|
|
|
fs.writeFileSync(
|
|
path.join(source, 'manifest.json'),
|
|
JSON.stringify({ schemaVersion: 1, version: 'test', architecture: 'x64', files: entries })
|
|
)
|
|
|
|
return { home, venv, source }
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const root of roots.splice(0)) {
|
|
fs.rmSync(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test('non-Windows platforms are left untouched', () => {
|
|
const { home, venv, source } = fixture({ 'msvcp140.dll': { data: 'crt' } })
|
|
|
|
assert.deepEqual(ensureWindowsVcRuntime({ platform: 'linux', venvRoot: venv, sourceDir: source }), {
|
|
status: 'skipped',
|
|
reason: 'not windows'
|
|
})
|
|
assert.equal(fs.existsSync(path.join(home, 'msvcp140.dll')), false)
|
|
})
|
|
|
|
test('readPythonHome parses pyvenv.cfg home with spaces', () => {
|
|
const { home, venv } = fixture({ 'msvcp140.dll': { data: 'crt' } })
|
|
|
|
assert.equal(readPythonHome(venv), home)
|
|
assert.equal(readPythonHome(path.join(venv, 'missing')), null)
|
|
})
|
|
|
|
test('missing CRT files are copied beside python.exe and verified', () => {
|
|
const { home, venv, source } = fixture({
|
|
'msvcp140.dll': { data: 'msvcp' },
|
|
'msvcp140_1.dll': { data: 'msvcp1' }
|
|
})
|
|
|
|
const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source })
|
|
|
|
assert.deepEqual(result, { status: 'repaired', pythonHome: home, copied: ['msvcp140.dll', 'msvcp140_1.dll'] })
|
|
assert.equal(fs.readFileSync(path.join(home, 'msvcp140_1.dll'), 'utf8'), 'msvcp1')
|
|
assert.deepEqual(fs.readdirSync(home).filter(name => name.endsWith('.tmp')), [])
|
|
// Second launch: nothing to do.
|
|
assert.deepEqual(ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source }), {
|
|
status: 'ok',
|
|
pythonHome: home,
|
|
copied: []
|
|
})
|
|
})
|
|
|
|
test('a mismatching msvcp140 copy is replaced, a python-owned vcruntime is kept', () => {
|
|
const { home, venv, source } = fixture({
|
|
'msvcp140.dll': { data: 'good' },
|
|
'vcruntime140.dll': { data: 'bundled', ownedByPython: true }
|
|
})
|
|
|
|
fs.writeFileSync(path.join(home, 'msvcp140.dll'), 'stale')
|
|
fs.writeFileSync(path.join(home, 'vcruntime140.dll'), 'interpreter-owned')
|
|
|
|
const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source })
|
|
|
|
assert.deepEqual(result, { status: 'repaired', pythonHome: home, copied: ['msvcp140.dll'] })
|
|
assert.equal(fs.readFileSync(path.join(home, 'msvcp140.dll'), 'utf8'), 'good')
|
|
assert.equal(fs.readFileSync(path.join(home, 'vcruntime140.dll'), 'utf8'), 'interpreter-owned')
|
|
})
|
|
|
|
test('a tampered bundled file is refused and nothing is written', () => {
|
|
const { home, venv, source } = fixture({ 'msvcp140.dll': { data: 'genuine' } })
|
|
|
|
fs.writeFileSync(path.join(source, 'msvcp140.dll'), 'tampered')
|
|
|
|
const result = ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source })
|
|
|
|
assert.equal(result.status, 'failed')
|
|
assert.match((result as { error: string }).error, /does not match its manifest digest/)
|
|
assert.equal(fs.existsSync(path.join(home, 'msvcp140.dll')), false)
|
|
})
|
|
|
|
test('an unresolvable python home fails without throwing', () => {
|
|
const { venv, source } = fixture({ 'msvcp140.dll': { data: 'crt' } })
|
|
|
|
fs.writeFileSync(path.join(venv, 'pyvenv.cfg'), 'home = C:\\does-not-exist\n')
|
|
|
|
assert.equal(ensureWindowsVcRuntime({ platform: 'win32', venvRoot: venv, sourceDir: source }).status, 'failed')
|
|
})
|
|
|
|
test('the shipped CRT set matches its manifest and covers the voice wheels', () => {
|
|
const sourceDir = path.join(here, '..', 'assets', 'windows', 'vc-runtime', 'x64')
|
|
const manifest = loadVcRuntimeManifest(sourceDir)
|
|
const names = manifest.files.map(file => file.name)
|
|
|
|
for (const required of ['msvcp140.dll', 'msvcp140_1.dll', 'vcruntime140.dll', 'vcruntime140_1.dll']) {
|
|
assert.ok(names.includes(required), `${required} must be bundled`)
|
|
}
|
|
|
|
for (const file of manifest.files) {
|
|
const bytes = fs.readFileSync(path.join(sourceDir, file.name))
|
|
|
|
assert.equal(bytes.length, file.size, file.name)
|
|
assert.equal(sha256(bytes), file.sha256, file.name)
|
|
}
|
|
})
|
|
|
|
test('the Windows package ships the CRT set as an extra resource', () => {
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'))
|
|
const resources = pkg.build.win.extraResources
|
|
|
|
assert.ok(
|
|
resources.some(entry => entry.from === 'assets/windows/vc-runtime/x64' && entry.to === 'vc-runtime/x64'),
|
|
'win.extraResources must carry vc-runtime/x64'
|
|
)
|
|
})
|
|
|
|
test('ensureRuntime places the CRT before handing the venv python to the backend', () => {
|
|
const source = fs.readFileSync(path.join(here, 'main.ts'), 'utf8')
|
|
const start = source.indexOf('async function ensureRuntime(')
|
|
const body = source.slice(start, source.indexOf('\nfunction ', start + 1))
|
|
|
|
const ensureIdx = body.indexOf('ensureWindowsVcRuntime(')
|
|
const commandIdx = body.indexOf('backend.command = getVenvPython(VENV_ROOT)')
|
|
|
|
assert.notEqual(ensureIdx, -1, 'ensureRuntime must call ensureWindowsVcRuntime')
|
|
assert.ok(ensureIdx < commandIdx, 'CRT must be in place before the backend command is chosen')
|
|
})
|