fix(linux): restore Ubuntu 20.04 launch — pin node-pty glibc symbols + add glibc/libstdc++ packaging gate (#9902) (#10019)
* 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.
This commit is contained in:
parent
0326594d52
commit
a0944cc129
|
|
@ -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/*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 <thread>
|
||||
|
||||
#include <sys/types.h>
|
||||
@@ -237,13 +239,23 @@ pty_getproc(int, char *);
|
||||
@@ -47,6 +49,25 @@
|
||||
#include <termios.h>
|
||||
#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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <n> <NAME>`.
|
||||
*/
|
||||
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 <lib>`). */
|
||||
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
|
||||
}
|
||||
|
|
@ -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 <n> 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue