From d3f1437dbd8270375b9908c8269f45f27c5466f0 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 10 Aug 2026 13:09:15 +0200 Subject: [PATCH 1/2] fix(linux): bundle libgomp in the AppImage, the one format that cannot declare it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.9.2 declared the three sonames its packages needed and never asked for, which fixed three formats out of four. The AppImage has no dependency mechanism at all, so declaring anything does nothing for it: on a machine without libgomp.so.1 its STT stack still dies in ld.so before main(), and transcription still shows an end user a developer error. Of those three, exactly one may be bundled, and it happens to be that one. The AppImage project's excludelist names the other two and gives the reason: libgbm.so.1 is "part of mesa" and talks to the host's DRM stack, libasound.so.2 loads the host's ALSA plugins and configuration. A bundled copy of either is worse than none. libgomp is a self-contained runtime and is absent from that list. Nothing else was needed to make it resolve, which is worth recording: whisper-stt-server and every libggml/libwhisper/libparakeet beside it already carry `RUNPATH=$ORIGIN:$ORIGIN/bin`. A copy in that directory is found ahead of the system one, so this needs no patchelf pass and no custom AppRun — both of which the first sketch of this change assumed were unavoidable. WHERE the copy is made matters more than that it is made. It happens in build-whisper-stt.sh, on the machine that compiles these binaries, whose Linux leg build-whisper-stt.yml pins to ubuntu-22.04 — the same floor before-pack.cjs enforces. So the library that ships comes from the same machine and the same glibc as the binaries that load it, and it travels inside the whisper artifact to every consumer whatever their own distro. The first version of this change copied it during packaging instead, and that was wrong in a way worth naming: it took the library from whoever ran the build, so a developer on 24.04 staged a libgomp needing GLIBC_2.38, before-pack.cjs correctly refused it, and `npm run build:linux` stopped working locally for a change that was supposed to be invisible there. Provenance was the fix, not a version check. stage-whisper-stt.sh now only asserts the library arrived, and names the workflow to re-run when an older artifact does not carry it. It checks on the early-exit path too: that is the branch a developer with a locally built binary takes, and skipping it there is how the AppImage would keep shipping without libgomp on exactly the builds nobody re-checks. Resolved through the binary rather than a hardcoded /usr/lib path so arm64 needs no second case, and copied under its soname because that is the DT_NEEDED the loader looks for; the file on disk is libgomp.so.1.0.0. The libgomp1/libgomp entries added to deb, rpm and pacman in 1.9.2 stay. They are redundant now that the copy travels with the binaries, and they cost nothing: if this staging ever regresses silently, three formats out of four still resolve. Verified: with the copy in place, ldd resolves libgomp through $ORIGIN to the bundled file rather than the system one, and the STT binary loads on an ubuntu:24.04 container that has no libgomp1 installed. Remove the staged copy in that same container and it dies on "libgomp.so.1: cannot open shared object file". --- scripts/build-whisper-stt.sh | 41 ++++++++++++++++ scripts/stage-whisper-stt.sh | 49 +++++++++++++++++++ .../engineering/build-and-packaging.md | 8 ++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/scripts/build-whisper-stt.sh b/scripts/build-whisper-stt.sh index 16d6a4f7e..3dd1f304e 100644 --- a/scripts/build-whisper-stt.sh +++ b/scripts/build-whisper-stt.sh @@ -261,6 +261,47 @@ libparakeet*.so|libparakeet*.so.*|\ relocate_macos_rpaths "${OUT_DIR}" fi + # Linux: stage GCC's OpenMP runtime beside what links it. + # + # Every binary staged above links libgomp.so.1, and nothing shipped it. The + # deb/rpm/pacman declare it since 1.9.2, but the AppImage has no dependency + # mechanism at all, so on a machine without it the whole STT stack dies in + # ld.so before main() and transcription shows an end user a developer error. + # + # It belongs HERE rather than in stage-whisper-stt.sh, and that distinction is + # the whole point: this script runs on the machine that COMPILES these + # binaries, and build-whisper-stt.yml pins that to ubuntu-22.04, matching the + # floor before-pack.cjs enforces. Copying it at packaging time instead took it + # from whoever happened to run the build — and a 24.04 desktop's libgomp needs + # GLIBC_2.38, so a developer there could no longer package at all. Staged here + # it travels inside the whisper artifact, so every consumer gets the 22.04 copy + # whatever their own distro is. + # + # libgomp is the only system library this stack may bundle. The AppImage + # project's excludelist names the two it must not — libgbm.so.1 is "part of + # mesa" and speaks to the host's DRM stack, libasound.so.2 loads the host's + # ALSA plugins — and libgomp, a self-contained runtime, is absent from it. + # + # Resolved through the binary rather than a hardcoded /usr/lib path so arm64 + # needs no second case, and copied under its soname because that is the + # DT_NEEDED the loader looks for; the file on disk is libgomp.so.1.0.0. No + # patchelf is needed: these binaries already carry RUNPATH=$ORIGIN. + if [[ "${OS_ARCH}" == linux-* ]]; then + local gomp + gomp="$(ldd "${OUT_DIR}/${out_bin_name}" 2>/dev/null | awk '/libgomp\.so\.1/ {print $3; exit}')" + if [[ -z "${gomp}" || ! -f "${gomp}" ]]; then + echo "FATAL: libgomp.so.1 is not resolvable for ${out_bin_name}." >&2 + echo " Install it (libgomp1 on Debian/Ubuntu, libgomp on Fedora/Arch)." >&2 + echo " Without it the AppImage ships an STT stack that cannot load," >&2 + echo " which is silent until a user tries to transcribe." >&2 + exit 1 + fi + # Already ours: ldd resolved it through $ORIGIN on a re-run of this script. + if [[ "$(cd "$(dirname "${gomp}")" && pwd)" != "$(cd "${OUT_DIR}" && pwd)" ]]; then + cp -v "${gomp}" "${OUT_DIR}/libgomp.so.1" + fi + fi + echo "[whisper-stt] built ${variant_name} -> ${OUT_DIR}/${out_bin_name}" ls -la "${OUT_DIR}" } diff --git a/scripts/stage-whisper-stt.sh b/scripts/stage-whisper-stt.sh index 37883d34e..cb09ca555 100755 --- a/scripts/stage-whisper-stt.sh +++ b/scripts/stage-whisper-stt.sh @@ -25,10 +25,54 @@ ARTIFACT="whisper-stt-${TAG}" DEST="electron/native/bin/${TAG}" REPO="${GITHUB_REPOSITORY:-getopenscreen/openscreen}" +# Asserts that GCC's OpenMP runtime travelled with the binaries, on Linux only. +# +# Every ELF of this stack links libgomp.so.1, and 1.9.1 shipped none of them a +# way to find it: the deb/rpm/pacman did not declare it (1.9.2 does) and the +# AppImage has no dependency mechanism at all, so on a machine without it +# whisper-stt-server dies in ld.so before main() and transcription reports a +# developer error to end users. Declaring it fixes three formats out of four; +# bundling the library fixes the fourth, which cannot be fixed any other way. +# +# This only CHECKS. build-whisper-stt.sh does the copying, on the machine that +# compiles these binaries, and build-whisper-stt.yml pins that to ubuntu-22.04. +# Copying it here instead would take it from whoever runs the packaging, and a +# 24.04 desktop's libgomp needs GLIBC_2.38 — which before-pack.cjs then refuses, +# so a developer on a current distro could not package at all. Provenance is the +# whole point: the copy that ships must come from the same machine, and the same +# glibc floor, as the binaries that load it. +# +# Nothing else is needed to make it resolve: these binaries already carry +# `RUNPATH=$ORIGIN:$ORIGIN/bin`, so a copy beside them wins over the system one. +assert_openmp_runtime_staged() { + case "${TAG}" in linux-*) ;; *) return 0 ;; esac + [ -f "${DEST}/libgomp.so.1" ] && return 0 + + cat >&2 < /dev/null; then echo "whisper-stt-server already present in ${DEST} — leaving it alone." + # Checked on this path too: it is the one a developer takes, and a local + # whisper build predating the staging change would otherwise package an + # AppImage whose STT stack cannot load. + assert_openmp_runtime_staged exit 0 fi @@ -70,6 +114,11 @@ BIN="$(find "${DEST}" -maxdepth 1 -name 'whisper-stt-server*' -print -quit)" [ -n "${BIN}" ] || { echo "FATAL: no whisper-stt-server binary in ${DEST}" >&2; exit 1; } [ "${TAG#win32}" = "${TAG}" ] && chmod +x "${BIN}" +# Before the load check below, not after: if libgomp did not travel with the +# artifact, the check would otherwise pass by resolving the build machine's copy +# and prove nothing about what ships. +assert_openmp_runtime_staged + # The existence check above is not enough: 1.8.0-rc.1..rc.3 staged a binary that # was present and unrunnable. cpp-httplib had linked OpenSSL, the two # libssl/libcrypto DLLs were never in the artifact, and Windows killed the diff --git a/technical-documentation/engineering/build-and-packaging.md b/technical-documentation/engineering/build-and-packaging.md index 8ef6af70b..72e0bad78 100644 --- a/technical-documentation/engineering/build-and-packaging.md +++ b/technical-documentation/engineering/build-and-packaging.md @@ -127,7 +127,13 @@ The container is the point, not an implementation detail — the runner has more `rpm` and `pacman` are verified too, and they are the ones with no other safety net: nobody installs them often enough to report a gap quickly, and the package names genuinely differ. `libgomp.so.1` is `libgomp1` on Debian and `libgomp` on both Fedora and Arch, where it was split out of `gcc-libs` — a guess would have been wrong. -The AppImage is deliberately not covered. It has no dependency mechanism, so there is no declaration to verify against and every system soname is missing by construction. It stays exposed, which is what `d3d_linux::diagnose` naming the Mesa package is for. +The AppImage is deliberately not covered by this check. It has no dependency mechanism, so there is no declaration to verify against and every system soname is missing by construction. + +It is instead handled at the source, which is the layer to prefer anyway. Of the three sonames 1.9.2 chased, exactly one may be bundled, and `build-whisper-stt.sh` now does: `libgomp.so.1` is a self-contained runtime, it is absent from [the AppImage project's excludelist](https://github.com/AppImage/pkg2appimage/blob/master/excludelist), and the STT binaries already carry `RUNPATH=$ORIGIN:$ORIGIN/bin`, so a copy beside them is found before the system one — no `patchelf`, no `AppRun` wrapper. The other two are on that excludelist and say why: `libgbm.so.1` is "part of mesa" and speaks to the host's DRM stack, `libasound.so.2` loads the host's ALSA plugins and configuration. A bundled copy of either is worse than none. + +**Which script does the copying is the interesting part.** It belongs to the build, not to packaging, because provenance is what makes the bundled copy correct: `build-whisper-stt.yml` pins its Linux leg to `ubuntu-22.04`, the same floor `before-pack.cjs` enforces, so the library that ships comes from the same machine and the same glibc as the binaries that load it, and it travels inside the whisper artifact to every consumer. Copying it at packaging time instead would take it from whoever ran the build — and a 24.04 desktop's `libgomp` needs `GLIBC_2.38`, which the symbol-version guard then rejects, leaving a developer on a current distro unable to package at all. `stage-whisper-stt.sh` only asserts it arrived, and says to re-run the whisper workflow if it did not. + +What remains host-supplied for the AppImage is the GTK/GLib/NSS stack, which no AppImage bundles — theme engines, GIO modules and pixbuf loaders all resolve against the host. `libvulkan.so.1` is already bundled at the AppImage root by electron-builder itself. For the Vulkan *driver*, which cannot be bundled, `d3d_linux::diagnose` names the Mesa package instead. ### Testing without the build machine's advantages From b4dc916a2e78d27e23ec19a447807b7f74572d27 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 10 Aug 2026 13:22:35 +0200 Subject: [PATCH 2/2] docs(build): fix a self-contradiction in the AppImage paragraph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review catch, and a fair one. The paragraph said every system soname is missing by construction for the AppImage, then described two that ship inside it — libgomp.so.1 from this change, libvulkan.so.1 from electron-builder. Both cannot be true. What is actually true is narrower: the check verifies that everything a package needs is either declared or shipped, and the AppImage declares nothing, so it would report every library the format legitimately expects from the host. That is the model working as intended rather than a defect, which is the reason the check skips the format — not that its sonames are absent. Both scripts also take the repository-relative path on first mention, which is what the rest of this file does (`scripts/before-pack.cjs` then `before-pack.cjs`). --- technical-documentation/engineering/build-and-packaging.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/technical-documentation/engineering/build-and-packaging.md b/technical-documentation/engineering/build-and-packaging.md index 72e0bad78..d7fbdd3c4 100644 --- a/technical-documentation/engineering/build-and-packaging.md +++ b/technical-documentation/engineering/build-and-packaging.md @@ -127,11 +127,11 @@ The container is the point, not an implementation detail — the runner has more `rpm` and `pacman` are verified too, and they are the ones with no other safety net: nobody installs them often enough to report a gap quickly, and the package names genuinely differ. `libgomp.so.1` is `libgomp1` on Debian and `libgomp` on both Fedora and Arch, where it was split out of `gcc-libs` — a guess would have been wrong. -The AppImage is deliberately not covered by this check. It has no dependency mechanism, so there is no declaration to verify against and every system soname is missing by construction. +The AppImage is deliberately not covered by this check. What the check verifies is that everything a package needs is either declared or shipped, and the AppImage declares nothing — there is no manifest to verify against, so it would report every library the format legitimately expects from the host. That is the AppImage model working as intended, not a defect, and a check that cannot distinguish the two says nothing. -It is instead handled at the source, which is the layer to prefer anyway. Of the three sonames 1.9.2 chased, exactly one may be bundled, and `build-whisper-stt.sh` now does: `libgomp.so.1` is a self-contained runtime, it is absent from [the AppImage project's excludelist](https://github.com/AppImage/pkg2appimage/blob/master/excludelist), and the STT binaries already carry `RUNPATH=$ORIGIN:$ORIGIN/bin`, so a copy beside them is found before the system one — no `patchelf`, no `AppRun` wrapper. The other two are on that excludelist and say why: `libgbm.so.1` is "part of mesa" and speaks to the host's DRM stack, `libasound.so.2` loads the host's ALSA plugins and configuration. A bundled copy of either is worse than none. +The exposure is instead handled at the source, which is the layer to prefer anyway. Of the three sonames 1.9.2 chased, exactly one may be bundled, and `scripts/build-whisper-stt.sh` now does: `libgomp.so.1` is a self-contained runtime, it is absent from [the AppImage project's excludelist](https://github.com/AppImage/pkg2appimage/blob/master/excludelist), and the STT binaries already carry `RUNPATH=$ORIGIN:$ORIGIN/bin`, so a copy beside them is found before the system one — no `patchelf`, no `AppRun` wrapper. The other two are on that excludelist and say why: `libgbm.so.1` is "part of mesa" and speaks to the host's DRM stack, `libasound.so.2` loads the host's ALSA plugins and configuration. A bundled copy of either is worse than none. -**Which script does the copying is the interesting part.** It belongs to the build, not to packaging, because provenance is what makes the bundled copy correct: `build-whisper-stt.yml` pins its Linux leg to `ubuntu-22.04`, the same floor `before-pack.cjs` enforces, so the library that ships comes from the same machine and the same glibc as the binaries that load it, and it travels inside the whisper artifact to every consumer. Copying it at packaging time instead would take it from whoever ran the build — and a 24.04 desktop's `libgomp` needs `GLIBC_2.38`, which the symbol-version guard then rejects, leaving a developer on a current distro unable to package at all. `stage-whisper-stt.sh` only asserts it arrived, and says to re-run the whisper workflow if it did not. +**Which script does the copying is the interesting part.** It belongs to the build, not to packaging, because provenance is what makes the bundled copy correct: `build-whisper-stt.yml` pins its Linux leg to `ubuntu-22.04`, the same floor `before-pack.cjs` enforces, so the library that ships comes from the same machine and the same glibc as the binaries that load it, and it travels inside the whisper artifact to every consumer. Copying it at packaging time instead would take it from whoever ran the build — and a 24.04 desktop's `libgomp` needs `GLIBC_2.38`, which the symbol-version guard then rejects, leaving a developer on a current distro unable to package at all. `scripts/stage-whisper-stt.sh` only asserts it arrived, and says to re-run the whisper workflow if it did not. What remains host-supplied for the AppImage is the GTK/GLib/NSS stack, which no AppImage bundles — theme engines, GIO modules and pixbuf loaders all resolve against the host. `libvulkan.so.1` is already bundled at the AppImage root by electron-builder itself. For the Vulkan *driver*, which cannot be bundled, `d3d_linux::diagnose` names the Mesa package instead.