diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 1d396228..330bf749 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -49,6 +49,20 @@ distributed by their own registries, not redistributed inside our binaries. - The speech model (`ggml-*.bin`) is **not** bundled — it is downloaded into the user's data directory on first use by `electron/stt/modelManager.ts`. +## Microsoft OpenMP runtime — `vcomp140.dll` (Windows only) + +- **Component**: `resources/electron/native/bin/win32-x64/vcomp140.dll`. +- **License**: redistributable under the Microsoft Visual C++ Redistributable + terms accompanying Visual Studio; the copy shipped is taken from the + `VC\Redist\MSVC\\x64\Microsoft.VC.OpenMP\` directory of the + Visual Studio installation that builds the release, never from `System32`. +- **Why it ships**: the ggml backends above are compiled with OpenMP and import + it. It is **not** part of Windows, so without it `whisper-stt-server` dies in + the loader before `main()` on any machine that has no Visual C++ + Redistributable, and transcription and captions fail with no usable error. + Staged by `scripts/stage-vcomp-runtime.mjs`; `scripts/before-pack.cjs` refuses + to package if it is missing while anything still imports it. + ## PipeWire — headers (Linux only) - **Components**: header sources under diff --git a/package.json b/package.json index ba1945b3..47a386c2 100644 --- a/package.json +++ b/package.json @@ -37,12 +37,13 @@ "build:native:mac": "node scripts/build-macos-screencapturekit-helper.mjs", "build:mac": "npm run build:native:mac && npm run fetch:ffmpeg:mac && npm run build:native:compositor:mac && tsc && vite build && electron-builder --mac", "build:native:win": "node scripts/build-windows-wgc-helper.mjs", + "stage:vcomp": "node scripts/stage-vcomp-runtime.mjs", "build:native:compositor": "node scripts/build-windows-compositor-addon.mjs", "build:native:compositor:mac": "node scripts/build-macos-compositor-addon.mjs", "build:native:compositor:linux": "node scripts/build-linux-compositor-addon.mjs", "build:native:linux": "node scripts/build-linux-pipewire-helper.mjs", - "build:win": "npm run build:native:win && npm run fetch:ffmpeg && npm run build:native:compositor && tsc && vite build && electron-builder --win --config.npmRebuild=false", - "build:win:store": "npm run build:native:win && npm run fetch:ffmpeg && npm run build:native:compositor && tsc && vite build && electron-builder --win appx --config.npmRebuild=false", + "build:win": "npm run build:native:win && npm run fetch:ffmpeg && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && electron-builder --win --config.npmRebuild=false", + "build:win:store": "npm run build:native:win && npm run fetch:ffmpeg && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && electron-builder --win appx --config.npmRebuild=false", "build:linux": "npm run fetch:ffmpeg:sdk && npm run build:native:linux && npm run build:native:compositor:linux && tsc && vite build && electron-builder --linux AppImage deb pacman rpm --config.npmRebuild=false", "build:whisper-binaries": "bash scripts/build-whisper-stt.sh", "test:whisper-stt": "node scripts/test-whisper-stt.mjs", diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index 23cc8fd8..227d2d48 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -247,8 +247,15 @@ const WIN_REQUIRED = [ * * The `api-ms-win-crt-*` api-sets are deliberately absent: that is the UCRT, which IS * part of Windows 10 and later. These are not, and no machine is obliged to have them. + * + * `vcomp` and `vcamp` were missing from the first version of this list, and the gap was + * not academic: ggml-base.dll and ggml-cpu.dll are built with OpenMP and import + * vcomp140.dll, so the shipped whisper payload still needed the redistributable after + * 1.9.1 was supposed to have ended that. The guard reported the payload clean because + * `vcomp` starts with none of msvcp/vcruntime/concrt. Enumerate the family, not the + * members that happened to bite. */ -const VC_REDIST_DLL = /^(msvcp|vcruntime|concrt)\d+/i; +const VC_REDIST_DLL = /^(msvcp|vcruntime|concrt|vcomp|vcamp|mfc)\d+/i; /** * The DLL names a PE binary imports — just enough of the format to walk the import @@ -336,8 +343,18 @@ function importedDlls(file) { * (crates/.cargo/config.toml). */ function checkWinNoRedistDependency(dir) { - const scanned = fs - .readdirSync(dir) + // Regular files only, and the same list serves both questions below. A directory + // answers `readdirSync` by name exactly as a file does, and this directory really + // does hold subdirectories (the vendored ffmpeg SDK), so the distinction is not + // hypothetical. Without it one named `something.dll` is opened as a binary and the + // hook dies on a raw `EISDIR` — the build stops, which is right, on a message that + // names nothing, which is not. + const files = fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + + const scanned = files .filter((name) => /\.(exe|dll|node)$/i.test(name)) .map((name) => ({ name, imports: importedDlls(path.join(dir, name)) })); @@ -356,8 +373,18 @@ function checkWinNoRedistDependency(dir) { ); } + // The property that matters is not "imports a redistributable DLL", it is "imports a + // redistributable DLL that will not be there". A copy sitting in this directory WILL + // be there: it ships, and the loader searches an executable's own directory and, for + // a .node, the module's — the same colocation that carries the ffmpeg DLLs. Shipping + // vcomp140.dll beside the ggml libraries is therefore a fix, not an exception, and + // the check has to be able to say so or it would forbid the remedy it asks for. + const shipped = new Set(files.map((name) => name.toLowerCase())); const offenders = scanned - .map((entry) => ({ name: entry.name, bad: entry.imports.filter((d) => VC_REDIST_DLL.test(d)) })) + .map((entry) => ({ + name: entry.name, + bad: entry.imports.filter((d) => VC_REDIST_DLL.test(d) && !shipped.has(d.toLowerCase())), + })) .filter((entry) => entry.bad.length > 0); if (offenders.length === 0) { return; @@ -373,7 +400,9 @@ function checkWinNoRedistDependency(dir) { "Build against the static CRT instead:\n" + " - CMake helpers: CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded (electron/native/wgc-capture)\n" + " - Rust addon: -C target-feature=+crt-static (crates/.cargo/config.toml)\n\n" + - "For a third-party binary that cannot be rebuilt, ship the DLLs it needs beside it.", + "For a prebuilt binary that is not ours to recompile, ship the DLL it needs into this\n" + + "same directory and the check passes — that is what scripts/stage-vcomp-runtime.mjs\n" + + "does for the OpenMP runtime the whisper/ggml libraries import.", ); } diff --git a/scripts/stage-vcomp-runtime.mjs b/scripts/stage-vcomp-runtime.mjs new file mode 100644 index 00000000..06b35d6c --- /dev/null +++ b/scripts/stage-vcomp-runtime.mjs @@ -0,0 +1,128 @@ +// Stages vcomp140.dll beside the whisper/ggml payload it is loaded by. +// +// ggml-base.dll and ggml-cpu.dll are compiled with OpenMP, so they import +// vcomp140.dll — Microsoft's OpenMP runtime, which ships with the Visual C++ +// Redistributable and is NOT part of Windows. Every machine that can build this +// repo has it in System32, so the dependency is invisible here and in CI, and on +// a clean machine whisper-stt-server dies in the loader before main(): captions +// and transcription fail with the unactionable timeout described in +// scripts/before-pack.cjs. +// +// This is the same class of failure that Store certification rejected 1.9.1 for, +// and it survived that fix because the guard only looked for msvcp/vcruntime/concrt +// prefixes — `vcomp` matches none of them. The guard now covers the whole family +// and, more usefully, only objects when the DLL is not shipped alongside. +// +// Shipping the DLL rather than rebuilding whisper without OpenMP is deliberate: +// it leaves the computation byte-for-byte identical, where -DGGML_OPENMP=OFF would +// swap OpenMP's scheduler for ggml's own and change transcription throughput by an +// amount nobody has measured. 200 KB against that unknown is a cheap trade. If the +// dependency ever becomes inconvenient, measure first, then switch. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { findVcVarsAll } from "./msvcEnv.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, ".."); +const DEST_DIR = path.join(ROOT, "electron", "native", "bin", "win32-x64"); +const DLL = "vcomp140.dll"; + +if (process.platform !== "win32") { + console.log("Skipping OpenMP runtime staging: Windows-only."); + process.exit(0); +} + +/** + * Every vcomp140.dll under a Visual Studio redistributable directory. + * + * The toolset tag moves with the compiler — Microsoft.VC143.OpenMP on the 2022 + * runners, Microsoft.VC145.OpenMP on a 2026 install — so this globs rather than + * hard-coding it, and takes the newest it finds. The redistributable copy is used + * in preference to the one in System32 because that is the copy Microsoft licenses + * for redistribution with an application. + * + * Discovery reuses `findVcVarsAll`, the same lookup the two native build scripts + * already run, so a Visual Studio installed anywhere is found here as well: it + * consults VCVARSALL, then vswhere, then VSINSTALLDIR, then sweeps for the + * pre-release channels vswhere does not enumerate. vcvarsall.bat sits at + * `\VC\Auxiliary\Build\`, hence the three levels up. + * + * That root is searched alone when it yields anything, which both prefers the + * toolchain that actually compiled the helpers and avoids re-walking the same + * subtree — the installation usually lives under the fixed paths below, and those + * trees are large enough that scanning one twice is worth avoiding. + */ +function searchRoots() { + const vcvarsall = findVcVarsAll(); + if (vcvarsall) { + return [path.resolve(path.dirname(vcvarsall), "..", "..", "..")]; + } + return [ + "C:\\Program Files\\Microsoft Visual Studio", + "C:\\Program Files (x86)\\Microsoft Visual Studio", + ]; +} + +function findRedistCopies() { + const found = []; + const walk = (dir, depth) => { + if (depth > 8) return; + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; // unreadable directory, not a reason to fail the build + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, depth + 1); + } else if ( + entry.name.toLowerCase() === DLL && + /\\Redist\\/i.test(full) && + /\\x64\\/i.test(full) + ) { + // `onecore\x64` is a trimmed variant for Windows Core headless SKUs; the + // desktop app wants the ordinary one. + if (!/\\onecore\\/i.test(full)) found.push(full); + } + } + }; + for (const root of searchRoots()) walk(root, 0); + return found; +} + +const candidates = findRedistCopies(); +if (candidates.length === 0) { + throw new Error( + `Could not find a redistributable ${DLL} under any Visual Studio installation.\n\n` + + "It lives in VC\\Redist\\MSVC\\\\x64\\Microsoft.VC.OpenMP\\.\n" + + "Install the Visual Studio C++ workload, which is required to build the native\n" + + "helpers anyway. Without this file the shipped whisper/ggml libraries cannot load\n" + + "on a machine that has no Visual C++ Redistributable, and transcription fails there\n" + + "with no usable error.", + ); +} + +// Newest by file version, so a machine carrying several toolsets stages the latest. +const versionOf = (file) => { + const match = file.match(/MSVC\\(\d+(?:\.\d+)*)\\/i); + return match ? match[1].split(".").map(Number) : [0]; +}; +candidates.sort((a, b) => { + const [x, y] = [versionOf(a), versionOf(b)]; + for (let i = 0; i < Math.max(x.length, y.length); i++) { + if ((x[i] ?? 0) !== (y[i] ?? 0)) return (y[i] ?? 0) - (x[i] ?? 0); + } + return 0; +}); + +const source = candidates[0]; +fs.mkdirSync(DEST_DIR, { recursive: true }); +const dest = path.join(DEST_DIR, DLL); +fs.copyFileSync(source, dest); + +console.log(`Staged ${DLL} from ${source}`); +console.log(` -> ${path.relative(ROOT, dest)}`); diff --git a/technical-documentation/engineering/build-and-packaging.md b/technical-documentation/engineering/build-and-packaging.md index 33b3d1a4..e2407ecc 100644 --- a/technical-documentation/engineering/build-and-packaging.md +++ b/technical-documentation/engineering/build-and-packaging.md @@ -91,10 +91,23 @@ The fix is to link the CRT statically, which removes the dependency instead of o - CMake helpers — `set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>")` in `electron/native/wgc-capture/CMakeLists.txt`. These are standalone processes that share no CRT state with anything, so `/MT` costs about 100 KB each and nothing else. - Rust addon — `-C target-feature=+crt-static` in `crates/.cargo/config.toml`. Safe for the napi cdylib: only opaque `napi_value`s cross the boundary, and Buffers handed to Node carry a finalizer that frees, in the addon, what the addon allocated. -`scripts/before-pack.cjs` now reads the import table of every `.exe`/`.dll`/`.node` in `electron/native/bin/win32-x64/` and refuses to package if any of them imports `msvcp*`/`vcruntime*`/`concrt*`. The `api-ms-win-crt-*` api-sets are deliberately not flagged: that is the UCRT, which does ship with Windows 10 and later. +`scripts/before-pack.cjs` reads the import table of every `.exe`/`.dll`/`.node` in `electron/native/bin/win32-x64/` and refuses to package a binary that needs a redistributable DLL **the package does not ship**. The `api-ms-win-crt-*` api-sets are deliberately not flagged: that is the UCRT, which does ship with Windows 10 and later. + +#### `vcomp140.dll`: the member that got away + +The first version of that guard matched `msvcp*`/`vcruntime*`/`concrt*` and reported the payload clean. It was not. `ggml-base.dll` and `ggml-cpu.dll` are compiled with OpenMP and import **`vcomp140.dll`** — the same redistributable, under a name starting with none of those three prefixes. So after 1.9.1 was supposed to have ended this whole class of bug, transcription and captions still failed on a clean machine, in exactly the way this section describes, and the check said nothing. + +Two lessons, and the second is the useful one: + +- Enumerate the **family**, not the members that happened to bite: `msvcp`, `vcruntime`, `concrt`, `vcomp`, `vcamp`, `mfc`. +- A guard is only as good as the property it actually tests. This one tested "imports a redistributable DLL" when the property that matters is "imports a redistributable DLL that will not be there". Those differ precisely when the DLL is shipped alongside — which is the remedy, so the old wording forbade its own fix. + +The remedy here is to ship it: `scripts/stage-vcomp-runtime.mjs` copies `vcomp140.dll` out of the Visual Studio redistributable directory into the payload, and `win.extraResources` carries it like everything else in that folder. Shipping rather than rebuilding whisper with `-DGGML_OPENMP=OFF` is deliberate — the DLL leaves the computation identical, where dropping OpenMP swaps its scheduler for ggml's own and changes transcription throughput by an amount nobody has measured. 200 KB against that unknown is a cheap trade; measure before revisiting it. **Local testing cannot confirm this class of fix.** This machine has the redistributable and always will, so a successful run here proves the build is not broken — it says nothing about the clean-machine behaviour. The import table is the only evidence for that half, which is why the guard reads it rather than running anything. +Everything the payload needs from outside itself is now either shipped beside it or present on every Windows edition — with one exception worth knowing: `wgc-capture.exe` imports `mf.dll`, `mfplat.dll` and `mfreadwrite.dll`, and **Media Foundation is absent from Windows N editions** unless the user installs the Media Feature Pack. Recording would fail there with the same `0xC0000135` as above. Untested and unhandled; N editions are sold in Europe. + ### Verifying a package actually loads The two failures above were found by the Store, not by us, and each was fixed with a guard aimed at the failure already understood — the colocation check would never have caught the redistributable, and the import-table check would never have caught the `PATH` bug. Both are worth keeping, and neither generalises.