diff --git a/scripts/download-llama-server.js b/scripts/download-llama-server.js index 4fa713cbfe..ace0a3807f 100644 --- a/scripts/download-llama-server.js +++ b/scripts/download-llama-server.js @@ -2,6 +2,7 @@ const fs = require("fs"); const path = require("path"); const { + copyLibraries, downloadFile, extractArchive, fetchLatestRelease, @@ -62,36 +63,6 @@ function findAsset(release, pattern) { return release?.assets?.find((a) => pattern.test(a.name)); } -function findLibrariesInDir(dir, pattern, maxDepth = 5, currentDepth = 0) { - if (currentDepth >= maxDepth) return []; - - const results = []; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - results.push(...findLibrariesInDir(fullPath, pattern, maxDepth, currentDepth + 1)); - } else if (matchesPattern(entry.name, pattern)) { - results.push(fullPath); - } - } - - return results; -} - -function matchesPattern(filename, pattern) { - if (pattern === "*.dylib") { - return filename.endsWith(".dylib"); - } else if (pattern === "*.dll") { - return filename.endsWith(".dll"); - } else if (pattern === "*.so*") { - return /\.so(\.\d+)*$/.test(filename) || filename.endsWith(".so"); - } - return false; -} - async function downloadBinary(key, config, release, isForce = false) { if (!config) { console.log(` ${key}: Not supported`); @@ -135,14 +106,7 @@ async function downloadBinary(key, config, release, isForce = false) { console.log(` ${key}: Extracted to ${config.outputName}`); if (config.libPattern) { - const libraries = findLibrariesInDir(extractDir, config.libPattern); - - for (const libPath of libraries) { - const libName = path.basename(libPath); - const destPath = path.join(BIN_DIR, libName); - - fs.copyFileSync(libPath, destPath); - setExecutable(destPath); + for (const libName of copyLibraries(extractDir, BIN_DIR, config.libPattern)) { console.log(` ${key}: Copied library ${libName}`); } } diff --git a/scripts/download-sherpa-onnx.js b/scripts/download-sherpa-onnx.js index 8778cef739..199e063b97 100644 --- a/scripts/download-sherpa-onnx.js +++ b/scripts/download-sherpa-onnx.js @@ -6,6 +6,7 @@ const { cleanupFiles, downloadFile, findBinaryInDir, + findLibrariesInDir, parseArgs, setExecutable, } = require("./lib/download-utils"); @@ -84,40 +85,6 @@ function extractTarBz2(archivePath, destDir) { }); } -function findLibrariesInDir(dir, pattern, maxDepth = 5, currentDepth = 0) { - if (currentDepth >= maxDepth) return []; - - const results = []; - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - results.push(...findLibrariesInDir(fullPath, pattern, maxDepth, currentDepth + 1)); - } else if (matchesPattern(entry.name, pattern)) { - results.push(fullPath); - } - } - } catch { - // Ignore permission errors - } - - return results; -} - -function matchesPattern(filename, pattern) { - if (pattern === "*.dylib") { - return filename.endsWith(".dylib"); - } else if (pattern === "*.dll") { - return filename.endsWith(".dll"); - } else if (pattern === "*.so*") { - return /\.so(\.\d+)*$/.test(filename) || filename.endsWith(".so"); - } - return false; -} - function copyBinary(extractDir, binaryName, outputPath, platformArch) { const foundPath = findBinaryInDir(extractDir, binaryName); @@ -160,7 +127,10 @@ async function downloadBinary(platformArch, config, isForce = false) { const diarizeOutputPath = path.join(BIN_DIR, config.diarizeOutputName); const installMarkerPath = path.join(BIN_DIR, `.sherpa-onnx-${platformArch}.json`); - if (!isForce && isCompleteInstall(installMarkerPath, [outputPath, onlineOutputPath, diarizeOutputPath])) { + if ( + !isForce && + isCompleteInstall(installMarkerPath, [outputPath, onlineOutputPath, diarizeOutputPath]) + ) { console.log(` ${platformArch}: Already exists (use --force to re-download)`); return true; } @@ -189,7 +159,9 @@ async function downloadBinary(platformArch, config, isForce = false) { // Copy shared libraries const copiedLibraries = []; if (config.libPattern) { - const libraries = findLibrariesInDir(extractDir, config.libPattern); + const libraries = findLibrariesInDir(extractDir, config.libPattern, { + ignoreReadErrors: true, + }); // Separate versioned and unversioned libraries to create symlinks where possible // e.g. libonnxruntime.dylib -> libonnxruntime.1.23.2.dylib (saves ~71MB) diff --git a/scripts/download-whisper-cpp.js b/scripts/download-whisper-cpp.js index 505cf90dbb..6f24abdc63 100644 --- a/scripts/download-whisper-cpp.js +++ b/scripts/download-whisper-cpp.js @@ -2,6 +2,7 @@ const fs = require("fs"); const path = require("path"); const { + copyLibraries, downloadFile, extractZip, fetchLatestRelease, @@ -15,7 +16,8 @@ const WHISPER_CPP_REPO = "OpenWhispr/whisper.cpp"; // Pinned to a tested build. Tracking the latest release let an upstream whisper.cpp bump // change transcription output between app releases with no diff to review. See #1348. -const WHISPER_CPP_TAG = process.env.WHISPER_CPP_VERSION || "0.0.8"; +// 0.0.10 is the first release whose win32 zips bundle the MSVC runtime DLLs (CUS-113). +const WHISPER_CPP_TAG = process.env.WHISPER_CPP_VERSION || "0.0.10"; const BINARIES = { "darwin-arm64": { @@ -32,6 +34,10 @@ const BINARIES = { zipName: "whisper-server-win32-x64-cpu.zip", binaryName: "whisper-server-win32-x64-cpu.exe", outputName: "whisper-server-win32-x64.exe", + // MSVC runtime DLLs the exe links dynamically; without them beside the exe, + // machines lacking the VC++ redistributable die at load with 0xC0000135 (CUS-113) + libPattern: "*.dll", + requiredLibraries: ["msvcp140.dll", "vcruntime140.dll", "vcruntime140_1.dll", "vcomp140.dll"], }, "linux-x64": { zipName: "whisper-server-linux-x64-cpu.zip", @@ -57,6 +63,27 @@ function getDownloadUrl(release, zipName) { return asset?.url || null; } +function isCompleteInstall(markerPath, binaryPath, config) { + if (!fs.existsSync(binaryPath)) return false; + + try { + const marker = JSON.parse(fs.readFileSync(markerPath, "utf8")); + if (marker.version !== WHISPER_CPP_TAG || !Array.isArray(marker.libraries)) return false; + + const binDir = path.dirname(markerPath); + if (marker.libraries.some((library) => !fs.existsSync(path.join(binDir, library)))) { + return false; + } + + const installedLibraries = new Set(marker.libraries.map((library) => library.toLowerCase())); + return (config.requiredLibraries || []).every((library) => + installedLibraries.has(library.toLowerCase()) + ); + } catch { + return false; + } +} + async function downloadBinary(platformArch, config, release, isForce = false) { if (!config) { console.log(` [server] ${platformArch}: Not supported`); @@ -64,11 +91,13 @@ async function downloadBinary(platformArch, config, release, isForce = false) { } const outputPath = path.join(BIN_DIR, config.outputName); + const installMarkerPath = path.join(BIN_DIR, `.whisper-cpp-${platformArch}.json`); - if (fs.existsSync(outputPath) && !isForce) { + if (!isForce && isCompleteInstall(installMarkerPath, outputPath, config)) { console.log(` [server] ${platformArch}: Already exists (use --force to re-download)`); return true; } + if (isForce && fs.existsSync(installMarkerPath)) fs.unlinkSync(installMarkerPath); const url = getDownloadUrl(release, config.zipName); if (!url) { @@ -91,6 +120,27 @@ async function downloadBinary(platformArch, config, release, isForce = false) { fs.copyFileSync(binaryPath, outputPath); setExecutable(outputPath); console.log(` [server] ${platformArch}: Extracted to ${config.outputName}`); + + let copiedLibraries = []; + if (config.libPattern) { + copiedLibraries = copyLibraries(extractDir, BIN_DIR, config.libPattern); + for (const libName of copiedLibraries) { + console.log(` [server] ${platformArch}: Copied library ${libName}`); + } + } + + const copiedLibraryNames = new Set(copiedLibraries.map((library) => library.toLowerCase())); + const missingLibraries = (config.requiredLibraries || []).filter( + (library) => !copiedLibraryNames.has(library.toLowerCase()) + ); + if (missingLibraries.length > 0) { + throw new Error(`Archive missing required libraries: ${missingLibraries.join(", ")}`); + } + + fs.writeFileSync( + installMarkerPath, + JSON.stringify({ version: WHISPER_CPP_TAG, libraries: copiedLibraries }) + ); } else { console.error( ` [server] ${platformArch}: Binary "${config.binaryName}" not found in archive` @@ -170,4 +220,8 @@ async function main() { } } -main().catch(console.error); +if (require.main === module) { + main().catch(console.error); +} + +module.exports = { BINARIES, WHISPER_CPP_TAG, isCompleteInstall }; diff --git a/scripts/lib/download-utils.js b/scripts/lib/download-utils.js index a3cc6e9f77..2744e50a83 100644 --- a/scripts/lib/download-utils.js +++ b/scripts/lib/download-utils.js @@ -319,6 +319,56 @@ function setExecutable(filePath) { } } +function matchesPattern(filename, pattern) { + if (pattern === "*.dylib") { + return filename.endsWith(".dylib"); + } else if (pattern === "*.dll") { + return filename.endsWith(".dll"); + } else if (pattern === "*.so*") { + return /\.so(\.\d+)*$/.test(filename) || filename.endsWith(".so"); + } + return false; +} + +function findLibrariesInDir(dir, pattern, options = {}, currentDepth = 0) { + const normalizedOptions = typeof options === "number" ? { maxDepth: options } : options; + const { maxDepth = 5, ignoreReadErrors = false } = normalizedOptions; + if (currentDepth >= maxDepth) return []; + + const results = []; + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (error) { + if (ignoreReadErrors) return []; + throw error; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + results.push(...findLibrariesInDir(fullPath, pattern, normalizedOptions, currentDepth + 1)); + } else if (matchesPattern(entry.name, pattern)) { + results.push(fullPath); + } + } + + return results; +} + +function copyLibraries(extractDir, destDir, pattern) { + const copied = []; + for (const libPath of findLibrariesInDir(extractDir, pattern)) { + const libName = path.basename(libPath); + const destPath = path.join(destDir, libName); + fs.copyFileSync(libPath, destPath); + setExecutable(destPath); + copied.push(libName); + } + return copied; +} + function cleanupFiles(binDir, prefix, keepPrefix) { const keepPrefixes = Array.isArray(keepPrefix) ? keepPrefix : [keepPrefix]; // Never delete shared libraries; only platform binaries. The b9763 split ships @@ -335,11 +385,14 @@ function cleanupFiles(binDir, prefix, keepPrefix) { } module.exports = { + copyLibraries, downloadFile, extractArchive, extractZip, fetchLatestRelease, findBinaryInDir, + findLibrariesInDir, + matchesPattern, parseArgs, setExecutable, cleanupFiles, diff --git a/src/helpers/whisperVulkanManager.js b/src/helpers/whisperVulkanManager.js index a0ace36b2d..cfef9c7eae 100644 --- a/src/helpers/whisperVulkanManager.js +++ b/src/helpers/whisperVulkanManager.js @@ -22,7 +22,9 @@ const EXPECTED_DIGESTS = { const BIN_SUBDIR = "whisper-vulkan"; -// Statically linked — no companion libs to copy +// Statically linked against ggml-vulkan (no Vulkan companion libs), but the exe +// still links the dynamic MSVC runtime — from release 0.0.10 the win32 zip bundles +// those DLLs and they must be extracted beside the exe (CUS-113) class WhisperVulkanManager extends GpuBinaryManager { constructor() { super({ @@ -35,6 +37,7 @@ class WhisperVulkanManager extends GpuBinaryManager { assetName: "whisper-server-win32-x64-vulkan.zip", binaryName: "whisper-server-win32-x64-vulkan.exe", outputName: "whisper-server-win32-x64-vulkan.exe", + libPattern: /\.dll$/i, }, "linux-x64": { assetName: "whisper-server-linux-x64-vulkan.zip", diff --git a/test/scripts/downloadWhisperCpp.test.js b/test/scripts/downloadWhisperCpp.test.js new file mode 100644 index 0000000000..ddd3893b01 --- /dev/null +++ b/test/scripts/downloadWhisperCpp.test.js @@ -0,0 +1,119 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + copyLibraries, + findLibrariesInDir, + matchesPattern, +} = require("../../scripts/lib/download-utils"); +const { + BINARIES, + WHISPER_CPP_TAG, + isCompleteInstall, +} = require("../../scripts/download-whisper-cpp"); + +// Regression: the win32 CPU whisper-server.exe links the dynamic MSVC runtime +// (msvcp140/vcruntime140/vcruntime140_1/vcomp140). Shipping the bare exe kills it +// at load with 0xC0000135 on machines without the VC++ redistributable (CUS-113), +// so the zip's DLL companions must be extracted beside the exe like llama/sherpa do. +test("win32 whisper-server config extracts DLL companions from the release zip", () => { + assert.equal(BINARIES["win32-x64"].libPattern, "*.dll"); + assert.deepEqual(BINARIES["win32-x64"].requiredLibraries, [ + "msvcp140.dll", + "vcruntime140.dll", + "vcruntime140_1.dll", + "vcomp140.dll", + ]); +}); + +test("whisper-cpp pin is a release whose win32 zips bundle the MSVC runtime DLLs", () => { + // 0.0.8 and 0.0.9 ship a bare exe; 0.0.10 is the first tag with the DLLs. + assert.equal(WHISPER_CPP_TAG, "0.0.10"); +}); + +test("cached win32 install is complete only for the current tag with every required DLL", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "whisper-install-test-")); + const binaryPath = path.join(tempDir, BINARIES["win32-x64"].outputName); + const markerPath = path.join(tempDir, ".whisper-cpp-win32-x64.json"); + const requiredLibraries = BINARIES["win32-x64"].requiredLibraries; + + fs.writeFileSync(binaryPath, "binary"); + for (const library of requiredLibraries) { + fs.writeFileSync(path.join(tempDir, library), library); + } + + try { + assert.equal(isCompleteInstall(markerPath, binaryPath, BINARIES["win32-x64"]), false); + + fs.writeFileSync( + markerPath, + JSON.stringify({ version: "0.0.9", libraries: requiredLibraries }) + ); + assert.equal(isCompleteInstall(markerPath, binaryPath, BINARIES["win32-x64"]), false); + + fs.writeFileSync( + markerPath, + JSON.stringify({ version: WHISPER_CPP_TAG, libraries: requiredLibraries.slice(1) }) + ); + assert.equal(isCompleteInstall(markerPath, binaryPath, BINARIES["win32-x64"]), false); + + fs.writeFileSync( + markerPath, + JSON.stringify({ version: WHISPER_CPP_TAG, libraries: requiredLibraries }) + ); + assert.equal(isCompleteInstall(markerPath, binaryPath, BINARIES["win32-x64"]), true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("copyLibraries copies matching libraries from a nested extract dir", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "whisper-dll-test-")); + const extractDir = path.join(tempDir, "extract", "nested"); + const destDir = path.join(tempDir, "bin"); + fs.mkdirSync(extractDir, { recursive: true }); + fs.mkdirSync(destDir, { recursive: true }); + + const dlls = ["msvcp140.dll", "vcruntime140.dll", "vcruntime140_1.dll", "vcomp140.dll"]; + for (const name of [...dlls, "whisper-server-win32-x64-cpu.exe", "README.md"]) { + fs.writeFileSync(path.join(extractDir, name), name); + } + + try { + const copied = copyLibraries(path.join(tempDir, "extract"), destDir, "*.dll"); + + assert.deepEqual(copied.sort(), dlls.slice().sort()); + for (const name of dlls) { + assert.ok(fs.existsSync(path.join(destDir, name)), `${name} should be copied`); + } + assert.ok(!fs.existsSync(path.join(destDir, "whisper-server-win32-x64-cpu.exe"))); + assert.ok(!fs.existsSync(path.join(destDir, "README.md"))); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("findLibrariesInDir propagates traversal errors by default", () => { + const missingDir = path.join(os.tmpdir(), `missing-library-dir-${process.pid}-${Date.now()}`); + + assert.throws(() => findLibrariesInDir(missingDir, "*.dll"), { code: "ENOENT" }); +}); + +test("findLibrariesInDir supports explicit best-effort traversal", () => { + const missingDir = path.join(os.tmpdir(), `missing-library-dir-${process.pid}-${Date.now()}`); + + assert.deepEqual(findLibrariesInDir(missingDir, "*.dll", { ignoreReadErrors: true }), []); +}); + +test("matchesPattern matches each supported library pattern", () => { + assert.ok(matchesPattern("msvcp140.dll", "*.dll")); + assert.ok(!matchesPattern("whisper-server.exe", "*.dll")); + assert.ok(matchesPattern("libggml.dylib", "*.dylib")); + assert.ok(!matchesPattern("libggml.dylib", "*.dll")); + assert.ok(matchesPattern("libonnxruntime.so", "*.so*")); + assert.ok(matchesPattern("libonnxruntime.so.1.23.2", "*.so*")); + assert.ok(!matchesPattern("libonnxruntime.so.1.txt", "*.so*")); +});