From a0944cc12952c0e055c3b5253ff1bba1b24c329d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:11:44 -0700 Subject: [PATCH] =?UTF-8?q?fix(linux):=20restore=20Ubuntu=2020.04=20launch?= =?UTF-8?q?=20=E2=80=94=20pin=20node-pty=20glibc=20symbols=20+=20add=20gli?= =?UTF-8?q?bc/libstdc++=20packaging=20gate=20(#9902)=20(#10019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(linux): restore Ubuntu 20.04 launch by pinning node-pty glibc symbols (#9902) The bundled node-pty pty.node is compiled from source in release CI on ubuntu-latest (glibc 2.39). glibc's 2.32-2.34 libpthread/libutil merge relocated openpty/forkpty (GLIBC_2.34) and pthread_sigmask (GLIBC_2.32) into libc under new symbol versions, so the from-source build bound to versions absent on Ubuntu 20.04 (glibc 2.31). The main process imports node-pty at startup, so the app crashed on launch. pty.node is the sole blocker (Electron needs GLIBC_2.25; other native modules <= 2.17). - Patch node-pty: a .symver shim pins the 3 symbols to their pre-merge version (GLIBC_2.2.5 x64 / GLIBC_2.17 arm64), and Linux-only ldflags force libutil.so.1/libpthread.so.0 back into DT_NEEDED. Guarded to Linux; macOS/Windows untouched. - Add a packaging gate (verify-linux-glibc-floor.cjs, afterPack): reads each bundled native binary's objdump -p version needs and fails the Linux build if any strong GLIBC_/GLIBCXX_/CXXABI_ node exceeds stock Ubuntu 20.04 (glibc 2.31 / GLIBCXX_3.4.28 / CXXABI_1.3.12). Catches GLIBC_ABI_DT_RELR, rejects GLIBC_PRIVATE, skips weak needs, fail-closed. - Docs + tests; the lazy sherpa-onnx speech prebuilt (GLIBCXX_3.4.29, never loaded at launch) is a documented libstdc++-floor exemption. * fix(linux): assert DT_NEEDED provider deps in the glibc-floor gate Harden the packaging gate (flagged in adversarial re-eval): the version-floor check alone can false-pass if the patch's forced `-l:libutil.so.1` ever silently drops — the pinned openpty@GLIBC_2.2.5 still resolves from libc's compat alias at build time, but fails to load on Ubuntu 20.04 where openpty/forkpty live only in libutil. The gate now also asserts that any binary importing openpty/forkpty keeps libutil.so.1 in DT_NEEDED. Validated on a real symver-pinned .so with libutil dropped (now fails) vs. present (passes). Documents the recommended real-host smoke-test follow-up. --- .gitignore | 1 + AGENTS.md | 1 + config/electron-builder.config.cjs | 7 + config/patches/node-pty@1.1.0.patch | 115 +++-- config/scripts/verify-linux-glibc-floor.cjs | 394 ++++++++++++++++++ .../scripts/verify-linux-glibc-floor.test.mjs | 323 ++++++++++++++ docs/reference/headless-linux-server.md | 6 +- docs/reference/linux-glibc-compatibility.md | 99 +++++ pnpm-lock.yaml | 6 +- 9 files changed, 912 insertions(+), 40 deletions(-) create mode 100644 config/scripts/verify-linux-glibc-floor.cjs create mode 100644 config/scripts/verify-linux-glibc-floor.test.mjs create mode 100644 docs/reference/linux-glibc-compatibility.md diff --git a/.gitignore b/.gitignore index 1b3f81e72..00b6dd028 100644 --- a/.gitignore +++ b/.gitignore @@ -92,6 +92,7 @@ docs/** !docs/reference/ !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md +!docs/reference/linux-glibc-compatibility.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/AGENTS.md b/AGENTS.md index eb43f9119..d4edf8dba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`. - **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms. - **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`. +- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc. ## SSH Use Case diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 50cfe7691..6d9db80c5 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -11,6 +11,7 @@ const { prunePackagedRuntimeNodeModules, verifyPackagedMainRuntimeDeps } = require('./packaged-runtime-node-modules.cjs') +const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' @@ -133,6 +134,12 @@ module.exports = { 'node_modules/sherpa-onnx*/**' ], afterPack: async (context) => { + // Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node + // requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902). + // Fail packaging if any bundled native binary exceeds the supported floor. + if (context.electronPlatformName === 'linux') { + verifyLinuxGlibcFloor(context.appOutDir) + } const resourcesDir = context.electronPlatformName === 'darwin' ? join( diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index e100def93..0b0038ed6 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -1,5 +1,5 @@ diff --git a/binding.gyp b/binding.gyp -index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc3500acacae 100644 +index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644 --- a/binding.gyp +++ b/binding.gyp @@ -5,9 +5,6 @@ @@ -12,6 +12,23 @@ index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc35 'msvs_settings': { 'VCCLCompilerTool': { 'AdditionalOptions': [ +@@ -88,6 +85,16 @@ + 'libraries!': [ + '-lutil' + ] ++ }], ++ # Orca: pair with the .symver pins in pty.cc. Force the real ++ # libutil.so.1/libpthread.so.0 into DT_NEEDED (gcc's default ++ # --as-needed drops them because the pinned symbols resolve from ++ # libc's compat aliases at build time) so openpty/forkpty/ ++ # pthread_sigmask still resolve on Ubuntu 20.04 (glibc 2.31). ++ ['OS=="linux"', { ++ 'ldflags': [ ++ '-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed' ++ ] + }] + ] + } diff --git a/deps/winpty/src/winpty.gyp b/deps/winpty/src/winpty.gyp index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8360130ef 100644 --- a/deps/winpty/src/winpty.gyp @@ -54,6 +71,27 @@ index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8 'msvs_settings': { # Specify this setting here to override a setting from somewhere # else, such as node's common.gypi. +diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js +index 8c4fca9022a6d6f015bca87f61625cde2278f428..0a01730616488119aa21ef441cf3c441e02a974c 100644 +--- a/lib/conpty_console_list_agent.js ++++ b/lib/conpty_console_list_agent.js +@@ -10,7 +10,14 @@ Object.defineProperty(exports, "__esModule", { value: true }); + var utils_1 = require("./utils"); + var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList; + var shellPid = parseInt(process.argv[2], 10); +-var consoleProcessList = getConsoleProcessList(shellPid); ++var consoleProcessList; ++try { ++ consoleProcessList = getConsoleProcessList(shellPid); ++} ++catch (_a) { ++ // Why: AttachConsole can fail after the shell exits; parent already has this fallback. ++ consoleProcessList = [shellPid]; ++} + process.send({ consoleProcessList: consoleProcessList }); + process.exit(0); + //# sourceMappingURL=conpty_console_list_agent.js.map +\ No newline at end of file diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 100644 --- a/lib/unixTerminal.js @@ -73,31 +111,12 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b var DEFAULT_FILE = 'sh'; var DEFAULT_NAME = 'xterm'; var DESTROY_SOCKET_TIMEOUT_MS = 200; -diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js -index ccc111c9e03a4a661ccfd5d8e8f0ee699571b5dd..f92c6bef7d46dc35c941c87ef186aa46d8ed9c44 100644 ---- a/lib/conpty_console_list_agent.js -+++ b/lib/conpty_console_list_agent.js -@@ -9,7 +9,14 @@ Object.defineProperty(exports, "__esModule", { value: true }); - var utils_1 = require("./utils"); - var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList; - var shellPid = parseInt(process.argv[2], 10); --var consoleProcessList = getConsoleProcessList(shellPid); -+var consoleProcessList; -+try { -+ consoleProcessList = getConsoleProcessList(shellPid); -+} -+catch (_a) { -+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback. -+ consoleProcessList = [shellPid]; -+} - process.send({ consoleProcessList: consoleProcessList }); - process.exit(0); - //# sourceMappingURL=conpty_console_list_agent.js.map diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts -index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc02d29c95 100644 +index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd8eeb4a79 100644 --- a/src/conpty_console_list_agent.ts +++ b/src/conpty_console_list_agent.ts -@@ -11,5 +11,11 @@ import { loadNativeModule } from './utils'; +@@ -10,6 +10,12 @@ import { loadNativeModule } from './utils'; + const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList; const shellPid = parseInt(process.argv[2], 10); -const consoleProcessList = getConsoleProcessList(shellPid); @@ -111,7 +130,7 @@ index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc process.send!({ consoleProcessList }); process.exit(0); diff --git a/src/unix/pty.cc b/src/unix/pty.cc -index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644 +index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d15c4dd44 100644 --- a/src/unix/pty.cc +++ b/src/unix/pty.cc @@ -23,7 +23,9 @@ @@ -124,7 +143,33 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 #include #include -@@ -237,13 +239,23 @@ pty_getproc(int, char *); +@@ -47,6 +49,25 @@ + #include + #endif + ++/* Orca: glibc 2.32-2.34 relocated pthread_sigmask/openpty/forkpty into libc ++ * under new symbol versions, so building on a newer glibc produces references ++ * (GLIBC_2.32/2.34) absent on Ubuntu 20.04 (glibc 2.31) and the app fails to ++ * launch. Pin these to the pre-merge version glibc still ships as a compat ++ * alias; the binding.gyp ldflags force libutil/libpthread into DT_NEEDED so ++ * those aliases are actually loaded on the target. */ ++#if defined(__linux__) ++# if defined(__x86_64__) ++# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.2.5" ++# elif defined(__aarch64__) ++# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.17" ++# endif ++# ifdef ORCA_GLIBC_COMPAT_VERSION ++__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION); ++__asm__(".symver forkpty,forkpty@" ORCA_GLIBC_COMPAT_VERSION); ++__asm__(".symver pthread_sigmask,pthread_sigmask@" ORCA_GLIBC_COMPAT_VERSION); ++# endif ++#endif ++ + /* Some platforms name VWERASE and VDISCARD differently */ + #if !defined(VWERASE) && defined(VWERSE) + #define VWERASE VWERSE +@@ -237,13 +258,23 @@ pty_getproc(int, char *); #endif #if defined(__APPLE__) || defined(__OpenBSD__) @@ -149,7 +194,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 #endif struct DelBuf { -@@ -367,10 +379,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { +@@ -367,10 +398,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { argv[i + 3] = strdup(arg.c_str()); } @@ -165,7 +210,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 } if (pty_nonblock(master) == -1) { throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); -@@ -684,15 +697,73 @@ pty_getproc(int fd, char *tty) { +@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) { #endif #if defined(__APPLE__) @@ -241,25 +286,25 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 for (; count < 3; count++) { low_fds[count] = posix_openpt(O_RDWR); -@@ -706,80 +777,118 @@ pty_posix_spawn(char** argv, char** env, +@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env, POSIX_SPAWN_SETSID; *master = posix_openpt(O_RDWR); if (*master == -1) { - return; + pty_set_spawn_error(err, "posix_openpt", errno); -+ goto done; -+ } -+ -+ res = grantpt(*master); -+ if (res == -1) { -+ pty_set_spawn_error(err, "grantpt", errno); + goto done; } - int res = grantpt(*master) || unlockpt(*master); -+ res = unlockpt(*master); ++ res = grantpt(*master); if (res == -1) { - return; ++ pty_set_spawn_error(err, "grantpt", errno); ++ goto done; ++ } ++ ++ res = unlockpt(*master); ++ if (res == -1) { + pty_set_spawn_error(err, "unlockpt", errno); + goto done; } diff --git a/config/scripts/verify-linux-glibc-floor.cjs b/config/scripts/verify-linux-glibc-floor.cjs new file mode 100644 index 000000000..55ec8ca77 --- /dev/null +++ b/config/scripts/verify-linux-glibc-floor.cjs @@ -0,0 +1,394 @@ +const { readdirSync, openSync, readSync, closeSync } = require('node:fs') +const { spawnSync } = require('node:child_process') +const { join, relative } = require('node:path') + +// Why: v1.4.150 shipped a Linux build whose node-pty pty.node required +// GLIBC_2.34 (openpty/forkpty were relocated into libc by glibc's +// libutil/libpthread merge), so the app crashed on startup on Ubuntu 20.04 +// (glibc 2.31) — the runner image silently bumped the build-host glibc. This +// gate fails Linux packaging if any bundled native binary requires a glibc (or +// libstdc++) symbol version newer than stock Ubuntu 20.04 ships, so a future +// runner bump or dependency change cannot reintroduce the regression unnoticed. +// See docs/reference/linux-glibc-compatibility.md. +const MIN_GLIBC = Object.freeze([2, 31]) + +// The symbol-version families this gate checks, each with the highest version +// node stock Ubuntu 20.04 provides. glibc is the #9902 launch-crash axis; +// libstdc++ (GLIBCXX_/CXXABI_) is the same crash class for C++ native modules +// against the system libstdc++ (Orca does not bundle one). +const VERSION_FLOORS = Object.freeze([ + Object.freeze({ prefix: 'GLIBC_', floor: MIN_GLIBC }), + Object.freeze({ prefix: 'GLIBCXX_', floor: Object.freeze([3, 4, 28]) }), + Object.freeze({ prefix: 'CXXABI_', floor: Object.freeze([1, 3, 12]) }) +]) +const FLOOR_LABEL = 'Ubuntu 20.04 (glibc 2.31 / libstdc++ GLIBCXX_3.4.28)' + +// Why: the sherpa-onnx speech prebuilt is a third-party manylinux binary that +// already requires GLIBCXX_3.4.29 (GCC 11 / Ubuntu 21.10+, 22.04 LTS). It loads +// lazily in the speech worker (src/main/speech/stt-worker.ts), never at app +// launch, so it cannot cause the #9902 startup crash. Exempt it from the +// libstdc++ floor (its glibc is still gated) rather than fail the release on a +// pre-existing, non-launch condition — speech needs libstdc++ >= GCC 11. +const LIBSTDCXX_FLOOR_EXEMPT = /(?:^|[/\\])sherpa-onnx/ + +// VER_FLG_WEAK: a version need whose references are all weak. The loader +// tolerates its absence (resolves to null and the caller's fallback runs) +// instead of refusing to load, so a weak need must not count as a requirement. +const VER_FLG_WEAK = 0x2 + +/** Parse a "2.34" / "3.4.28" version string into a numeric tuple. */ +function parseGlibcVersion(versionStr) { + return versionStr.split('.').map((part) => Number.parseInt(part, 10)) +} + +/** Compare two numeric version tuples; missing trailing parts are 0. */ +function compareGlibcVersions(a, b) { + const length = Math.max(a.length, b.length) + for (let i = 0; i < length; i += 1) { + const diff = (a[i] ?? 0) - (b[i] ?? 0) + if (diff !== 0) { + return diff < 0 ? -1 : 1 + } + } + return 0 +} + +/** + * Parse `objdump -p` "Version References" (the ELF `.gnu.version_r` section) + * into the version nodes this binary requires from each shared library. This is + * the authoritative load-time requirement list: unlike the dynamic symbol table + * (`objdump -T`), it also captures symbol-less ABI markers such as + * `GLIBC_ABI_DT_RELR` (packed relative relocations, glibc 2.36+) that still + * block loading on an older glibc. Each entry: `0xHASH 0xFLAGS `. + */ +function parseVersionNeeds(objdumpOutput) { + const needs = [] + let library = null + let inSection = false + for (const line of objdumpOutput.split('\n')) { + if (line.startsWith('Version References:')) { + inSection = true + continue + } + if (!inSection) { + continue + } + // Any new non-indented line ends the Version References block. + if (!/^\s/.test(line)) { + inSection = false + continue + } + const libraryMatch = line.match(/^\s+required from (\S+):/) + if (libraryMatch) { + library = libraryMatch[1] + continue + } + const entryMatch = line.match(/^\s+0x[0-9a-fA-F]+\s+0x([0-9a-fA-F]+)\s+\d+\s+(\S+)/) + if (entryMatch) { + const flags = Number.parseInt(entryMatch[1], 16) + needs.push({ library, name: entryMatch[2], weak: (flags & VER_FLG_WEAK) !== 0 }) + } + } + return needs +} + +/** + * Whether a version node is newer than the floor Ubuntu 20.04 provides. Numeric + * nodes (`GLIBC_2.34`, `GLIBCXX_3.4.29`) compare by version. Any non-numeric + * glibc node is rejected: `GLIBC_ABI_DT_RELR` is a 2.36+ marker, and + * `GLIBC_PRIVATE` is not a stable ABI contract — its symbols differ across + * glibc releases, so a binary needing one can fail to load on the floor even + * though the version node itself exists (a well-formed addon needs neither). + * Named libstdc++ nodes (`CXXABI_TM_1`, `GLIBCXX_LDBL_*`) ship on 20.04. + * Families we do not gate (`GCC_`, `NSS_`) return false. + */ +function isVersionNodeAboveFloor(name) { + for (const { prefix, floor } of VERSION_FLOORS) { + if (!name.startsWith(prefix)) { + continue + } + const rest = name.slice(prefix.length) + if (/^[0-9]+(?:\.[0-9]+)*$/.test(rest)) { + return compareGlibcVersions(parseGlibcVersion(rest), floor) > 0 + } + // Non-numeric suffix: reject every glibc node (ABI markers and PRIVATE). + return prefix === 'GLIBC_' + } + return false +} + +function isLibstdcxxNode(name) { + return name.startsWith('GLIBCXX_') || name.startsWith('CXXABI_') +} + +/** + * Version needs from `filePath` that would prevent loading on the floor OS. + * `sherpa-onnx` is exempt from the libstdc++ floor (see LIBSTDCXX_FLOOR_EXEMPT) + * but its glibc needs are still checked. + */ +function findFloorViolations(needs, filePath = '') { + const exemptLibstdcxx = LIBSTDCXX_FLOOR_EXEMPT.test(filePath) + return needs.filter( + (need) => + !need.weak && + isVersionNodeAboveFloor(need.name) && + !(exemptLibstdcxx && isLibstdcxxNode(need.name)) + ) +} + +// On stock Ubuntu 20.04 (glibc 2.31) these symbols live ONLY in these DSOs — +// glibc kept openpty/forkpty in libutil until the 2.34 merge. A binary that +// imports them must keep the DSO in DT_NEEDED or they will not resolve on the +// floor. This guards config/patches/node-pty@1.1.0.patch's forced +// `-l:libutil.so.1`: if a toolchain change ever dropped that ldflag, the pinned +// openpty@GLIBC_2.2.5 would still resolve from libc's compat alias at build time +// (so the version-floor check passes) yet fail to load on 20.04. libpthread +// (pthread_sigmask) is intentionally omitted — the Node/Electron host always +// loads it, so it resolves regardless of this addon's DT_NEEDED. +const RELOCATED_SYMBOL_PROVIDERS = Object.freeze({ + openpty: 'libutil.so.1', + forkpty: 'libutil.so.1' +}) + +/** + * Relocated symbols the binary imports whose providing DSO is absent from + * DT_NEEDED — meaning they resolve at build time but not on the floor OS. + */ +function findMissingProviderDeps(importedSymbols, neededLibraries) { + const missing = [] + for (const [symbol, library] of Object.entries(RELOCATED_SYMBOL_PROVIDERS)) { + if (importedSymbols.has(symbol) && !neededLibraries.has(library)) { + missing.push({ symbol, library }) + } + } + return missing +} + +function isElfFile(filePath) { + let fd + try { + fd = openSync(filePath, 'r') + const header = Buffer.alloc(4) + const bytesRead = readSync(fd, header, 0, 4, 0) + return bytesRead === 4 && header[0] === 0x7f && header.toString('latin1', 1, 4) === 'ELF' + } catch { + return false + } finally { + if (fd !== undefined) { + closeSync(fd) + } + } +} + +/** Recursively collect ELF native binaries (`.node`, `.so[.N]`, executables). */ +function collectNativeBinaries(rootDir) { + const binaries = [] + const walk = (dir) => { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const fullPath = join(dir, entry.name) + if (entry.isSymbolicLink()) { + continue + } + if (entry.isDirectory()) { + walk(fullPath) + continue + } + if (!entry.isFile()) { + continue + } + // Why: .node/.so are always native; extensionless files (the Electron + // executable, chrome-sandbox) are checked via the ELF magic so we cover + // every launch-critical binary without objdump-ing app.asar or assets. + const looksNative = entry.name.endsWith('.node') || /\.so(\.\d+)*$/.test(entry.name) + if (looksNative || !entry.name.includes('.')) { + if (isElfFile(fullPath)) { + binaries.push(fullPath) + } + } + } + } + walk(rootDir) + return binaries.sort() +} + +function resolveObjdump(explicitPath) { + const candidates = [explicitPath, 'objdump', 'llvm-objdump'].filter(Boolean) + for (const candidate of candidates) { + const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8', env: cLocaleEnv() }) + if (!probe.error && probe.status === 0) { + return candidate + } + } + return null +} + +// Why: GNU objdump localizes its section headers ("Version References:") via +// gettext, and the parser anchors on the English text. Force the C locale so +// output stays deterministic on non-English packaging hosts (LC_ALL=C also +// disables LANGUAGE-based message translation). +function cLocaleEnv() { + return { ...process.env, LC_ALL: 'C', LANG: 'C' } +} + +/** + * Run objdump with one flag on `filePath`. Fail-closed: a spawn error, non-zero + * exit, or signal throws, because a silently-unreadable binary (truncated, + * corrupt, or an objdump that cannot decode its format) would let a too-new + * binary slip past the gate. + */ +function runObjdump(objdumpPath, flag, filePath) { + const result = spawnSync(objdumpPath, [flag, filePath], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + env: cLocaleEnv() + }) + if (result.error) { + throw new Error( + `[verify-linux-glibc-floor] could not run objdump on ${filePath}: ${result.error.message}` + ) + } + if (result.signal || result.status !== 0) { + throw new Error( + `[verify-linux-glibc-floor] objdump ${flag} failed for ${filePath} ` + + `(status ${result.status}, signal ${result.signal ?? 'none'}): ${(result.stderr || '').trim()}` + ) + } + return result.stdout || '' +} + +/** DT_NEEDED shared-library names from `objdump -p` (` NEEDED `). */ +function parseNeededLibraries(objdumpOutput) { + const needed = new Set() + for (const line of objdumpOutput.split('\n')) { + const match = line.match(/^\s+NEEDED\s+(\S+)/) + if (match) { + needed.add(match[1]) + } + } + return needed +} + +/** Undefined (imported) dynamic symbol base names from `objdump -T` (`*UND*`). */ +function parseImportedSymbols(objdumpOutput) { + const imported = new Set() + for (const line of objdumpOutput.split('\n')) { + if (!line.includes('*UND*')) { + continue + } + // The symbol name is the final token; strip any @VERSION suffix. + const token = line.trim().split(/\s+/).pop() + if (token) { + imported.add(token.split('@')[0]) + } + } + return imported +} + +/** Version needs + DT_NEEDED from a single `objdump -p` (fail-closed). */ +function readDynamicInfo(filePath, objdumpPath) { + const output = runObjdump(objdumpPath, '-p', filePath) + return { + versionNeeds: parseVersionNeeds(output), + neededLibraries: parseNeededLibraries(output) + } +} + +/** Imported (undefined) dynamic symbols from `objdump -T` (fail-closed). */ +function readImportedSymbols(filePath, objdumpPath) { + return parseImportedSymbols(runObjdump(objdumpPath, '-T', filePath)) +} + +/** + * Fail Linux packaging if any bundled native binary under `rootDir` requires a + * glibc/libstdc++ symbol version newer than the floor OS. No-op is not allowed + * on Linux: a missing objdump throws, because a silent skip would defeat the + * regression gate on exactly the host where it matters. + */ +function verifyLinuxGlibcFloor(rootDir, options = {}) { + const binaries = collectNativeBinaries(rootDir) + if (binaries.length === 0) { + console.log(`[verify-linux-glibc-floor] OK — no bundled native binaries under ${rootDir}`) + return + } + + // Why: resolve objdump only once there is something to inspect, so a fixture + // with no ELF binaries does not fail on a host that lacks binutils. + const objdumpPath = resolveObjdump(options.objdumpPath) + if (!objdumpPath) { + throw new Error( + '[verify-linux-glibc-floor] objdump not found. Install binutils on the Linux ' + + 'packaging host so the glibc-floor gate can inspect bundled native binaries.' + ) + } + + const offenders = [] + for (const filePath of binaries) { + const { versionNeeds, neededLibraries } = readDynamicInfo(filePath, objdumpPath) + const floorViolations = findFloorViolations(versionNeeds, filePath) + // Only pay for `objdump -T` when a relocated-symbol provider is not already + // in DT_NEEDED (the common, healthy case short-circuits without it). + const providerViolations = Object.values(RELOCATED_SYMBOL_PROVIDERS).some( + (library) => !neededLibraries.has(library) + ) + ? findMissingProviderDeps(readImportedSymbols(filePath, objdumpPath), neededLibraries) + : [] + if (floorViolations.length > 0 || providerViolations.length > 0) { + offenders.push({ filePath, floorViolations, providerViolations }) + } + } + + if (offenders.length > 0) { + const detail = offenders + .map(({ filePath, floorViolations, providerViolations }) => { + const reasons = [] + if (floorViolations.length > 0) { + const nodes = [...new Set(floorViolations.map((v) => v.name))].sort() + const libraries = [...new Set(floorViolations.map((v) => v.library).filter(Boolean))] + reasons.push( + `needs ${nodes.join(', ')}${libraries.length > 0 ? ` (from ${libraries.join(', ')})` : ''}` + ) + } + for (const { symbol, library } of providerViolations) { + reasons.push(`imports ${symbol} but ${library} is not in DT_NEEDED`) + } + return ` ${relative(rootDir, filePath) || filePath} ${reasons.join('; ')}` + }) + .join('\n') + throw new Error( + `[verify-linux-glibc-floor] ${offenders.length} bundled native binar${offenders.length === 1 ? 'y' : 'ies'} ` + + `will not load on ${FLOOR_LABEL}, so the app will crash on startup there:\n${detail}\n` + + 'See docs/reference/linux-glibc-compatibility.md — rebuild the offending module against an older ' + + 'toolchain or pin the relocated symbols (as config/patches/node-pty@1.1.0.patch does).' + ) + } + + console.log( + `[verify-linux-glibc-floor] OK — ${binaries.length} bundled native binaries all load on ${FLOOR_LABEL}` + ) +} + +module.exports = { + MIN_GLIBC, + VERSION_FLOORS, + FLOOR_LABEL, + RELOCATED_SYMBOL_PROVIDERS, + parseGlibcVersion, + compareGlibcVersions, + parseVersionNeeds, + parseNeededLibraries, + parseImportedSymbols, + isVersionNodeAboveFloor, + isLibstdcxxNode, + findFloorViolations, + findMissingProviderDeps, + collectNativeBinaries, + readDynamicInfo, + readImportedSymbols, + verifyLinuxGlibcFloor +} diff --git a/config/scripts/verify-linux-glibc-floor.test.mjs b/config/scripts/verify-linux-glibc-floor.test.mjs new file mode 100644 index 000000000..603e4e85c --- /dev/null +++ b/config/scripts/verify-linux-glibc-floor.test.mjs @@ -0,0 +1,323 @@ +import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + parseGlibcVersion, + compareGlibcVersions, + parseVersionNeeds, + parseNeededLibraries, + parseImportedSymbols, + isVersionNodeAboveFloor, + findFloorViolations, + findMissingProviderDeps, + collectNativeBinaries, + verifyLinuxGlibcFloor +} = require('./verify-linux-glibc-floor.cjs') + +// 0x7f 'E' 'L' 'F' + class/data/version padding — enough for the magic check. +const ELF_HEADER = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00]) + +// Real `objdump -p` "Version References" shape (entry: 0xHASH 0xFLAGS NAME; +// flags 0x02 = VER_FLG_WEAK). Includes a symbol-less ABI marker, a weak need, +// and a libstdc++ need. +const OBJDUMP_P = [ + 'Dynamic Section:', + ' NEEDED libc.so.6', + '', + 'Version References:', + ' required from libc.so.6:', + ' 0x09691a75 0x00 06 GLIBC_2.2.5', + ' 0x069691b4 0x00 05 GLIBC_2.34', + ' 0x0d696914 0x02 04 GLIBC_2.18', + ' 0x00fd0e42 0x00 03 GLIBC_ABI_DT_RELR', + ' required from libstdc++.so.6:', + ' 0x0b481abc 0x00 07 GLIBCXX_3.4.29', + '' +].join('\n') + +describe('verify-linux-glibc-floor parsing', () => { + it('parses and compares numeric version tuples', () => { + expect(parseGlibcVersion('2.34')).toEqual([2, 34]) + expect(parseGlibcVersion('3.4.28')).toEqual([3, 4, 28]) + expect(compareGlibcVersions([2, 2, 5], [2, 14])).toBe(-1) + expect(compareGlibcVersions([2, 31], [2, 32])).toBe(-1) + expect(compareGlibcVersions([2, 34], [2, 31])).toBe(1) + expect(compareGlibcVersions([2, 31], [2, 31])).toBe(0) + expect(compareGlibcVersions([2, 31], [2, 31, 0])).toBe(0) + expect(compareGlibcVersions([3, 4, 29], [3, 4, 28])).toBe(1) + }) + + it('parses objdump -p Version References into per-library version needs', () => { + const needs = parseVersionNeeds(OBJDUMP_P) + expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_2.34', weak: false }) + expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_ABI_DT_RELR', weak: false }) + expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_2.18', weak: true }) + expect(needs).toContainEqual({ library: 'libstdc++.so.6', name: 'GLIBCXX_3.4.29', weak: false }) + }) + + it('classifies version nodes across glibc and libstdc++ families', () => { + expect(isVersionNodeAboveFloor('GLIBC_2.34')).toBe(true) + expect(isVersionNodeAboveFloor('GLIBC_2.31')).toBe(false) + expect(isVersionNodeAboveFloor('GLIBC_ABI_DT_RELR')).toBe(true) // symbol-less marker (2.36+) + // GLIBC_PRIVATE is not a stable ABI contract; a needed private symbol can be + // absent on the floor even though the version node exists — reject it. + expect(isVersionNodeAboveFloor('GLIBC_PRIVATE')).toBe(true) + expect(isVersionNodeAboveFloor('CXXABI_TM_1')).toBe(false) // named libstdc++ node on 20.04 + expect(isVersionNodeAboveFloor('GLIBCXX_3.4.29')).toBe(true) // GCC 11, above 20.04's 3.4.28 + expect(isVersionNodeAboveFloor('GLIBCXX_3.4.28')).toBe(false) + expect(isVersionNodeAboveFloor('CXXABI_1.3.13')).toBe(true) + expect(isVersionNodeAboveFloor('CXXABI_1.3.12')).toBe(false) + expect(isVersionNodeAboveFloor('GCC_3.0')).toBe(false) // family not gated + }) + + it('flags strong too-new glibc + libstdc++ needs, skipping weak and ungated families', () => { + const violations = findFloorViolations(parseVersionNeeds(OBJDUMP_P), '/opt/app/pty.node') + const names = violations.map((v) => v.name).sort() + // GLIBC_2.34, GLIBC_ABI_DT_RELR, GLIBCXX_3.4.29 fail; weak GLIBC_2.18 and + // GLIBC_2.2.5 are excluded. + expect(names).toEqual(['GLIBCXX_3.4.29', 'GLIBC_2.34', 'GLIBC_ABI_DT_RELR'].sort()) + }) + + it('exempts sherpa-onnx from the libstdc++ floor but still gates its glibc', () => { + const needs = [ + { library: 'libstdc++.so.6', name: 'GLIBCXX_3.4.29', weak: false }, + { library: 'libc.so.6', name: 'GLIBC_2.34', weak: false } + ] + // A launch-critical module: both are violations. + expect( + findFloorViolations(needs, '/opt/app/node_modules/node-pty/pty.node').map((v) => v.name) + ).toEqual(['GLIBCXX_3.4.29', 'GLIBC_2.34']) + // sherpa: GLIBCXX exempt (lazy speech prebuilt), glibc still enforced. + expect( + findFloorViolations( + needs, + '/opt/app/node_modules/sherpa-onnx-linux-x64/sherpa-onnx.node' + ).map((v) => v.name) + ).toEqual(['GLIBC_2.34']) + }) + + it('reports no violations when every strong need is at or below the floor', () => { + const needs = parseVersionNeeds( + [ + 'Version References:', + ' required from libc.so.6:', + ' 0x00 0x00 02 GLIBC_2.2.5', + ' 0x00 0x00 03 GLIBC_2.28', + ' required from libstdc++.so.6:', + ' 0x00 0x00 04 GLIBCXX_3.4.22' + ].join('\n') + ) + expect(findFloorViolations(needs, '/opt/app/pty.node')).toEqual([]) + }) +}) + +describe('DT_NEEDED provider check', () => { + const OBJDUMP_P_DYNAMIC = [ + 'Dynamic Section:', + ' NEEDED libutil.so.1', + ' NEEDED libpthread.so.0', + ' NEEDED libc.so.6', + '', + 'Version References:', + ' required from libc.so.6:', + ' 0x0 0x00 02 GLIBC_2.2.5' + ].join('\n') + + it('parses DT_NEEDED shared libraries from objdump -p', () => { + const needed = parseNeededLibraries(OBJDUMP_P_DYNAMIC) + expect([...needed].sort()).toEqual(['libc.so.6', 'libpthread.so.0', 'libutil.so.1']) + }) + + it('parses undefined imported symbols from objdump -T, stripping @VERSION', () => { + const output = [ + '0000000000000000 DF *UND*\t0000000000000000 (GLIBC_2.2.5) openpty', + '0000000000000000 w DF *UND*\t0000000000000000 __cxa_finalize@GLIBC_2.2.5', + '0000000000000000 DF .text\t0000000000000000 defined_symbol' + ].join('\n') + const imported = parseImportedSymbols(output) + expect(imported.has('openpty')).toBe(true) + expect(imported.has('__cxa_finalize')).toBe(true) + expect(imported.has('defined_symbol')).toBe(false) // not *UND* + }) + + it('flags a binary that imports openpty/forkpty without libutil.so.1 in DT_NEEDED', () => { + const importsPty = new Set(['openpty', 'forkpty', 'free']) + // Missing libutil.so.1 -> the pinned symbols would not resolve on the floor. + expect( + findMissingProviderDeps(importsPty, new Set(['libc.so.6'])).map((m) => m.symbol) + ).toEqual(['openpty', 'forkpty']) + // With libutil.so.1 present, no violation. + expect(findMissingProviderDeps(importsPty, new Set(['libc.so.6', 'libutil.so.1']))).toEqual([]) + // A binary that doesn't import the relocated symbols is never flagged. + expect(findMissingProviderDeps(new Set(['free']), new Set(['libc.so.6']))).toEqual([]) + }) +}) + +describe('collectNativeBinaries', () => { + it('collects only ELF .node/.so/executable files, skipping non-ELF and symlinks', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-glibc-collect-')) + try { + await mkdir(join(root, 'nested'), { recursive: true }) + await writeFile(join(root, 'addon.node'), ELF_HEADER) + await writeFile(join(root, 'nested', 'lib.so'), ELF_HEADER) + await writeFile(join(root, 'nested', 'lib.so.1'), ELF_HEADER) + await writeFile(join(root, 'orca-ide'), ELF_HEADER) // extensionless executable + await writeFile(join(root, 'script.js'), ELF_HEADER) // has extension, not native + await writeFile(join(root, 'text.node'), 'not an elf file') // native name, non-ELF + await writeFile(join(root, 'notes.md'), ELF_HEADER) + try { + await symlink(join(root, 'addon.node'), join(root, 'alias.node')) + } catch { + // Symlink creation can be restricted; the rest of the assertions still hold. + } + + const found = collectNativeBinaries(root).map((p) => p.slice(root.length + 1)) + expect(found).toContain('addon.node') + expect(found).toContain(join('nested', 'lib.so')) + expect(found).toContain(join('nested', 'lib.so.1')) + expect(found).toContain('orca-ide') + expect(found).not.toContain('script.js') + expect(found).not.toContain('text.node') + expect(found).not.toContain('notes.md') + expect(found).not.toContain('alias.node') + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) + +describe.skipIf(process.platform === 'win32')('verifyLinuxGlibcFloor', () => { + // A stub objdump keyed on the inspected file's basename. Handles `-p` (Dynamic + // Section DT_NEEDED + Version References) and `-T` (undefined symbols). + // `*fail*` exits non-zero (fail-closed branch); `*noutil*` omits libutil.so.1 + // from DT_NEEDED; `*pty*` imports openpty. Match on basename only so the + // (random) temp-dir path cannot collide. + async function writeStubObjdump(dir) { + const stubPath = join(dir, 'objdump-stub.sh') + await writeFile( + stubPath, + [ + '#!/bin/sh', + 'if [ "$1" = "--version" ]; then echo "GNU objdump (stub)"; exit 0; fi', + 'f=$(basename "$2")', + 'case "$f" in', + ' *fail*) echo "objdump: $f: File format not recognized" >&2; exit 1 ;;', + 'esac', + 'if [ "$1" = "-T" ]; then', + ' case "$f" in', + ' *pty*) printf "0000 DF *UND* 0000 (GLIBC_2.2.5) openpty\\n" ;;', + ' esac', + ' exit 0', + 'fi', + 'printf "Dynamic Section:\\n NEEDED libc.so.6\\n"', + 'case "$f" in', + ' *noutil*) : ;;', + ' *) printf " NEEDED libutil.so.1\\n NEEDED libpthread.so.0\\n" ;;', + 'esac', + 'printf "\\nVersion References:\\n required from libc.so.6:\\n"', + 'case "$f" in', + ' *bad*) printf " 0x0 0x00 03 GLIBC_2.34\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;', + ' *relr*) printf " 0x0 0x00 05 GLIBC_ABI_DT_RELR\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;', + ' *weakonly*) printf " 0x0 0x02 06 GLIBC_2.32\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;', + ' *cxx*|*sherpa*)', + ' printf " required from libstdc++.so.6:\\n 0x0 0x00 07 GLIBCXX_3.4.29\\n" ;;', + ' *) printf " 0x0 0x00 08 GLIBC_2.28\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;', + 'esac', + 'exit 0' + ].join('\n'), + { mode: 0o755 } + ) + return stubPath + } + + it('throws listing binaries over the floor (glibc, DT_RELR marker, and libstdc++)', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-glibc-over-')) + try { + const objdumpPath = await writeStubObjdump(root) + await mkdir(join(root, 'app', 'resources'), { recursive: true }) + await writeFile(join(root, 'app', 'resources', 'bad-pty.node'), ELF_HEADER) + await writeFile(join(root, 'app', 'relr-exe.node'), ELF_HEADER) + await writeFile(join(root, 'app', 'cxx-addon.node'), ELF_HEADER) // launch-critical GLIBCXX_3.4.29 + await writeFile(join(root, 'app', 'good.so'), ELF_HEADER) + + let error + try { + verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath }) + } catch (e) { + error = e + } + expect(error).toBeDefined() + expect(error.message).toMatch(/bad-pty\.node needs GLIBC_2\.34/) + expect(error.message).toMatch(/relr-exe\.node needs GLIBC_ABI_DT_RELR/) + expect(error.message).toMatch(/cxx-addon\.node needs GLIBCXX_3\.4\.29/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('throws when a pinned binary imports openpty without libutil.so.1 in DT_NEEDED', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-glibc-noutil-')) + try { + const objdumpPath = await writeStubObjdump(root) + await mkdir(join(root, 'app'), { recursive: true }) + // Below the version floor (so the version check passes) but libutil.so.1 + // is missing from DT_NEEDED — openpty would not resolve on Ubuntu 20.04. + await writeFile(join(root, 'app', 'noutil-pty.node'), ELF_HEADER) + + expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).toThrow( + /noutil-pty\.node imports openpty but libutil\.so\.1 is not in DT_NEEDED/ + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('passes weak/at-floor needs and the exempt sherpa-onnx libstdc++ prebuilt', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-glibc-under-')) + try { + const objdumpPath = await writeStubObjdump(root) + const sherpaDir = join(root, 'app', 'node_modules', 'sherpa-onnx-linux-x64') + await mkdir(sherpaDir, { recursive: true }) + await writeFile(join(root, 'app', 'good-pty.node'), ELF_HEADER) + await writeFile(join(root, 'app', 'weakonly-lib.so'), ELF_HEADER) // weak GLIBC_2.32 → OK + await writeFile(join(root, 'app', 'orca-ide'), ELF_HEADER) + await writeFile(join(sherpaDir, 'sherpa-onnx.node'), ELF_HEADER) // GLIBCXX_3.4.29, exempt + + expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).not.toThrow() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('fails closed when objdump cannot read a binary (non-zero exit)', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-glibc-closed-')) + try { + const objdumpPath = await writeStubObjdump(root) + await mkdir(join(root, 'app'), { recursive: true }) + await writeFile(join(root, 'app', 'unreadable-fail.node'), ELF_HEADER) + + expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).toThrow( + /objdump -p failed/ + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('is a no-op (no objdump needed) when there are no native binaries', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-glibc-empty-')) + try { + await mkdir(join(root, 'app'), { recursive: true }) + await writeFile(join(root, 'app', 'readme.txt'), 'no binaries here') + expect(() => + verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath: '/nonexistent/objdump' }) + ).not.toThrow() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index ef47c6791..1f9ccd162 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -10,8 +10,10 @@ startup. Current Orca builds start Xvfb automatically for `orca serve` when no not required. When `DISPLAY` is set, Orca uses that display instead of starting a competing Xvfb process. -The supported deployment matrix covers Ubuntu 22.04 and 24.04 and current -Debian stable. Package names can differ on other Debian-derived releases. +The supported deployment matrix covers Ubuntu 20.04, 22.04, and 24.04 and +current Debian stable — anything with glibc 2.31 or newer (see +[Linux glibc compatibility](./linux-glibc-compatibility.md)). Package names can +differ on other Debian-derived releases. ## Ubuntu and Debian prerequisites diff --git a/docs/reference/linux-glibc-compatibility.md b/docs/reference/linux-glibc-compatibility.md new file mode 100644 index 000000000..60d855c73 --- /dev/null +++ b/docs/reference/linux-glibc-compatibility.md @@ -0,0 +1,99 @@ +# Linux glibc Compatibility + +Orca's Linux builds target **stock Ubuntu 20.04 and newer** — glibc 2.31 and +libstdc++ `GLIBCXX_3.4.28` (also Debian 11, RHEL 9), on both x64 and arm64. +Packaging enforces this floor automatically; keep it in mind when adding or +upgrading native dependencies. (The optional speech feature is the one +exception — see below.) + +## Why this needs attention + +A native module (`.node`) links against the glibc of the machine that compiled +it. Our release CI compiles node-pty from source on GitHub's `ubuntu-latest` +runner, whose glibc rises over time as the image is bumped. A binary compiled on +a newer glibc can reference symbol versions that do not exist on an older target, +and the dynamic loader then refuses to load it: + +``` +/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by .../pty.node) +``` + +Because the Orca main process loads node-pty at startup, that failure crashes the +whole app before a window appears — this is exactly what shipped in v1.4.150 and +broke launch on Ubuntu 20.04 ([#9902](https://github.com/stablyai/orca/issues/9902)). + +The specific trap is glibc's 2.32–2.34 "libpthread/libutil merge", which moved +several long-stable functions into libc under brand-new symbol versions: + +| Symbol | New version | node-pty use | +| ----------------- | ------------- | ----------------------- | +| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask | +| `openpty` | `GLIBC_2.34` | allocate the pty | +| `forkpty` | `GLIBC_2.34` | fork the shell | + +Electron itself (glibc 2.25) and the other bundled native modules +(`sherpa-onnx`, `@parcel/watcher`, both prebuilt on old glibc) stay well under +the floor, so node-pty was the sole blocker. + +## How we keep the floor + +**1. Pin the relocated symbols (the fix).** +[`config/patches/node-pty@1.1.0.patch`](../../config/patches/node-pty@1.1.0.patch) +adds a `.symver` shim in `src/unix/pty.cc` that binds `openpty`, `forkpty`, and +`pthread_sigmask` to their pre-merge version node — `GLIBC_2.2.5` on x64, +`GLIBC_2.17` on arm64 (each architecture's baseline glibc). glibc still ships +those as compatibility aliases, so the reference resolves on both new build hosts +and old targets. + +The catch: gcc defaults to `--as-needed` and, since the pinned symbols now +resolve from libc's compat aliases at build time, it drops `libutil`/`libpthread` +from `DT_NEEDED`. On the target those libraries are where the symbols actually +live, so the patch's `binding.gyp` `ldflags` force +`-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0` back into `DT_NEEDED`. +The shim is guarded by `#if defined(__linux__)`; macOS and Windows are untouched. + +**2. Gate packaging (the regression guard).** +[`config/scripts/verify-linux-glibc-floor.cjs`](../../config/scripts/verify-linux-glibc-floor.cjs) +runs in the electron-builder `afterPack` hook for Linux. It reads every bundled +native binary's version needs (`objdump -p` "Version References" — the +authoritative load-time list, which also captures symbol-less markers like +`GLIBC_ABI_DT_RELR`) and fails the build if any strong `GLIBC_`/`GLIBCXX_`/ +`CXXABI_` node is newer than stock Ubuntu 20.04 provides, naming the file and the +offending node. Weak needs are ignored (the loader tolerates them). It also +asserts the flip side of the `.symver` fix: any binary that imports +`openpty`/`forkpty` must keep `libutil.so.1` in `DT_NEEDED` — otherwise the +pinned `openpty@GLIBC_2.2.5` resolves from libc's compat alias at build time (so +the version check passes) yet fails to load on 20.04, where those functions live +only in libutil. A future runner bump, a new native dependency, or a dropped +ldflag therefore fails the release build instead of shipping a Linux app that +crashes on launch. + +> The gate is a static invariant, not an integration test. The load path was +> verified by hand for this fix (real Ubuntu 20.04, x64 + arm64: `require` +> node-pty and spawn a shell). A CI smoke test that loads the packaged +> `pty.node` in a glibc-2.31 container and spawns a shell is the recommended +> follow-up — it would make the load path self-verifying and stay valid even if +> the build ever moves to an old-glibc sysroot. + +The one carve-out is the `sherpa-onnx` speech prebuilt, which already requires +`GLIBCXX_3.4.29` (GCC 11). It loads lazily in the speech worker +(`src/main/speech/stt-worker.ts`), never at app launch, so it is exempt from the +libstdc++ floor — its glibc needs are still checked. Speech-to-text therefore +needs a host with libstdc++ from GCC 11+ (Ubuntu 21.10 / 22.04 LTS or newer); the +app itself still launches on stock 20.04. + +## Adding or upgrading a native dependency + +- Prefer packages that ship prebuilt binaries compiled against an old toolchain + (manylinux / `glibc 2.17`-class), like `@parcel/watcher`. +- For a module we compile from source, if the gate flags it, either pin the + offending symbols the way node-pty does, or build it in an old-glibc container. +- To check locally on a Linux host, list what a binary requires (skipping the + weak `0x02`-flagged needs the loader tolerates): + + ```bash + objdump -p path/to/module.node | sed -n '/Version References/,/^$/p' + ``` + + No strong `GLIBC_` node may exceed `2.31`, and no `GLIBCXX_`/`CXXABI_` node may + exceed `3.4.28`/`1.3.12` — what stock Ubuntu 20.04 ships. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e8adb6af..ea1672e89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ patchedDependencies: hash: 9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c path: config/patches/@xterm__xterm@6.1.0-beta.287.patch node-pty@1.1.0: - hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282 + hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8 path: config/patches/node-pty@1.1.0.patch importers: @@ -64,7 +64,7 @@ importers: version: 3.3.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282) + version: 1.1.0(patch_hash=8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8) posthog-node: specifier: ^5.33.3 version: 5.33.3 @@ -11623,7 +11623,7 @@ snapshots: node-int64@0.4.0: {} - node-pty@1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282): + node-pty@1.1.0(patch_hash=8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8): dependencies: node-addon-api: 7.1.1