feat(orcad): prove node-pty loads before anything requires it

Of the two ways node-pty fails, only one is catchable. A missing module throws
MODULE_NOT_FOUND. A module built against the wrong libc or Node ABI is refused by
the dynamic loader, and in the worst case takes the process down before any handler
exists — that is #9902, which crashed the desktop app on Ubuntu 20.04 before a
window appeared. There was no libc or ABI precondition anywhere in the tree.

So orcad now proves the load in a CHILD process, from main.ts, before anything
requires node-pty. Whatever the child does — throw, abort, die on a signal — is data
rather than our own death, and the operator gets a sentence naming the host's libc,
Node ABI and prebuild slot plus the command to run. Proven-unloadable exits 78
(EX_CONFIG), so a supervisor does not restart an unequippable host forever. A probe
that never answered is unverifiable, not blocked: refusing to boot on an inconclusive
signal would take down hosts that work.

The child dlopens the file node-pty would have chosen, before requiring the package.
node-pty's loader walks several directories and rethrows only the LAST error, so a
refused binary reads as "Cannot find module ./prebuilds/..." — which sends the
operator to install a module that is already there. It also reports through stdout:
node echoes the whole -e source above a stack trace, and matching tokens against
stderr made the probe's own source text answer for the verdict.

Verdicts reach clients as a terminal_unavailable degradation alongside the existing
browser_unavailable one, through the same cause-registry shape. degradations[].code
is now an open vocabulary; clients already render only `message`.

Prebuilds are compiled from PATCHED sources — the patch IS the glibc-floor fix, so an
upstream tarball reproduces #9902 — into linux-{x64,arm64}-{glibc,musl} and
darwin-{x64,arm64} slots. libc is in the slot name because node-pty's loader falls
back to prebuilds/<platform>-<arch> and cannot tell glibc from musl. orcad installs
the matching slot at boot, so a host with no compiler serves terminals.

The relay's five pure toolchain-diagnosis functions moved to a transport-free module
so the Node bundle can reuse them without dragging ssh2 in behind them; the relay
keeps its API by re-export. macOS gets `xcode-select --install` rather than the
cross-distro apt/dnf/pacman/apk menu, every line of which is wrong there.
This commit is contained in:
Neil
2026-08-24 23:02:15 -07:00
parent dc7056ab24
commit 73e316a67f
21 changed files with 2134 additions and 160 deletions
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env node
/**
* Build one node-pty prebuilt for the CURRENT platform/arch/libc and file it in orcad's
* prebuilds matrix, so a deployment target needs no C/C++ toolchain.
*
* node-pty is the only ABI-sensitive native module orcad requires. It is also PATCHED in
* this repo (config/patches/node-pty@1.1.0.patch), and that patch is the glibc-floor fix:
* `.symver` pins on openpty/forkpty/pthread_sigmask plus the `--no-as-needed` ldflags that
* keep libutil/libpthread in DT_NEEDED. An upstream prebuilt has none of it and reproduces
* #9902. So the matrix is compiled from patched sources here, and this script refuses to
* run if the patch is not in the tree it is about to compile.
*
* orcad pins its own Node runtime, so the ABI dimension is fixed and the matrix varies
* only platform/arch/libc:
* linux-x64-glibc, linux-arm64-glibc, linux-x64-musl, linux-arm64-musl,
* darwin-x64, darwin-arm64
*
* CI runs this once per slot, each inside the container that owns that libc/arch, and
* merges the resulting `out/orcad/prebuilds` trees. `--slot=<name>` forces the label so
* the glibc/musl distinction is recorded from the container rather than detected.
*
* Usage:
* node config/scripts/build-orcad-prebuilds.mjs [--slot=linux-x64-musl]
* node config/scripts/build-orcad-prebuilds.mjs --require-slots # release gate
*/
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import process from 'node:process'
import { spawnSync } from 'node:child_process'
const require = createRequire(import.meta.url)
const ROOT = join(import.meta.dirname, '..', '..')
const PREBUILDS_DIR = join(ROOT, 'out', 'orcad', 'prebuilds')
/** Every slot a shipped matrix must fill. The single source of truth for the matrix. */
export const MATRIX_SLOTS = [
'linux-x64-glibc',
'linux-arm64-glibc',
'linux-x64-musl',
'linux-arm64-musl',
'darwin-x64',
'darwin-arm64'
]
/**
* Why the report header and not `ldd`: `glibcVersionRuntime` is present only when the
* process is linked against glibc, and musl images have no `ldd` worth parsing.
*/
export function detectLibc(platform = process.platform, header = readReportHeader()) {
if (platform !== 'linux') {
return 'none'
}
return header && typeof header === 'object' && 'glibcVersionRuntime' in header ? 'glibc' : 'musl'
}
function readReportHeader() {
try {
return process.report?.getReport?.()?.header
} catch {
return undefined
}
}
export function slotName(argv = process.argv, platform = process.platform, arch = process.arch) {
const forced = argv.find((arg) => arg.startsWith('--slot='))
if (forced) {
return forced.slice('--slot='.length)
}
const libc = detectLibc(platform)
return libc === 'none' ? `${platform}-${arch}` : `${platform}-${arch}-${libc}`
}
/**
* The patch is what holds the Ubuntu 20.04 floor. Compiling without it produces a binary
* that loads fine on the build host and dies on the target — the exact failure the matrix
* exists to prevent, now baked into a shipped artifact instead of a first-connect error.
*/
export function assertNodePtyPatchApplied(nodePtyDir) {
const bindingGyp = readFileSync(join(nodePtyDir, 'binding.gyp'), 'utf8')
const ptySource = readFileSync(join(nodePtyDir, 'src', 'unix', 'pty.cc'), 'utf8')
const missing = []
if (!bindingGyp.includes('--no-as-needed,-l:libutil.so.1')) {
missing.push("binding.gyp is missing the '--no-as-needed,-l:libutil.so.1' ldflag")
}
if (!ptySource.includes('.symver openpty,openpty@')) {
missing.push('src/unix/pty.cc is missing the .symver glibc pins')
}
if (missing.length > 0) {
throw new Error(
[
'[orcad-prebuilds] refusing to build: config/patches/node-pty@1.1.0.patch is not applied.',
...missing.map((line) => ` - ${line}`),
'A prebuilt compiled without it will not load on Ubuntu 20.04 (see',
'docs/reference/linux-glibc-compatibility.md and #9902). Run `pnpm install` to apply patches.'
].join('\n')
)
}
}
export function readManifest(prebuildsDir) {
try {
return JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8'))
} catch {
return null
}
}
/**
* Why merge rather than overwrite: CI builds one slot per container and merges the trees.
* A manifest that records only the last slot would erase every other container's record,
* and `--require-slots` would then reject a complete matrix.
*/
export function mergeManifest(existing, next) {
const slots = new Set([...(existing?.slots ?? []), next.slot])
return {
module: 'node-pty',
version: next.version,
nodeAbi: next.nodeAbi,
slots: [...slots].sort()
}
}
function nodePtyDir() {
return dirname(require.resolve('node-pty/package.json'))
}
function compileNodePty(dir) {
const built = join(dir, 'build', 'Release', 'pty.node')
if (existsSync(built)) {
console.log(`[orcad-prebuilds] reusing existing build at ${built}`)
return built
}
console.log('[orcad-prebuilds] compiling node-pty from patched source ...')
const result = spawnSync(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['node-gyp', 'rebuild'], {
cwd: dir,
stdio: 'inherit',
env: process.env,
windowsHide: true
})
if (result.status !== 0) {
throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.status})`)
}
if (!existsSync(built)) {
throw new Error(`[orcad-prebuilds] node-gyp succeeded but ${built} is missing`)
}
return built
}
function requireSlots() {
const manifest = readManifest(PREBUILDS_DIR)
const have = new Set(manifest?.slots ?? [])
const missing = MATRIX_SLOTS.filter((slot) => !have.has(slot))
if (missing.length > 0) {
console.error(
`[orcad-prebuilds] matrix incomplete — missing ${missing.join(', ')}. ` +
'Hosts on those slots fall back to a source build and need a C/C++ toolchain.'
)
process.exitCode = 1
return
}
console.log(`[orcad-prebuilds] matrix complete — ${MATRIX_SLOTS.length} slots`)
}
function build() {
const dir = nodePtyDir()
assertNodePtyPatchApplied(dir)
const slot = slotName()
const slotDir = join(PREBUILDS_DIR, slot)
mkdirSync(slotDir, { recursive: true })
const builtBinary = compileNodePty(dir)
copyFileSync(builtBinary, join(slotDir, 'pty.node'))
console.log(`[orcad-prebuilds] stored ${slot}/pty.node`)
// Why spawn-helper ships too: on Unix node-pty posix_spawns build/Release/spawn-helper,
// so a slot without it installs cleanly and then fails ENOENT the first time a user
// opens a terminal. Windows has no spawn-helper.
if (process.platform !== 'win32') {
const helperSource = join(dirname(builtBinary), 'spawn-helper')
if (!existsSync(helperSource)) {
throw new Error(`[orcad-prebuilds] spawn-helper missing at ${helperSource}`)
}
copyFileSync(helperSource, join(slotDir, 'spawn-helper'))
console.log(`[orcad-prebuilds] stored ${slot}/spawn-helper`)
}
// The static floor gate, applied to the artifact we are about to ship rather than only
// to the packaged desktop app. objdump is Linux-only, which is where the floor lives.
if (process.platform === 'linux') {
const { verifyLinuxGlibcFloor } = require('./verify-linux-glibc-floor.cjs')
verifyLinuxGlibcFloor(slotDir)
}
const manifest = mergeManifest(readManifest(PREBUILDS_DIR), {
slot,
version: require('node-pty/package.json').version,
nodeAbi: process.versions.modules
})
writeFileSync(join(PREBUILDS_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
console.log(
`[orcad-prebuilds] manifest: node-pty ${manifest.version}, ABI ${manifest.nodeAbi}, slots ${manifest.slots.join(', ')}`
)
}
if (process.argv[1] && process.argv[1].endsWith('build-orcad-prebuilds.mjs')) {
if (process.argv.includes('--require-slots')) {
requireSlots()
} else {
build()
}
}
@@ -0,0 +1,121 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
assertNodePtyPatchApplied,
detectLibc,
MATRIX_SLOTS,
mergeManifest,
readManifest,
slotName
} from './build-orcad-prebuilds.mjs'
const PATCHED_BINDING_GYP = "'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']"
const PATCHED_PTY_CC = '__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);'
const dirs = []
const stage = (bindingGyp, ptyCc) => {
const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-src-'))
dirs.push(dir)
mkdirSync(join(dir, 'src', 'unix'), { recursive: true })
writeFileSync(join(dir, 'binding.gyp'), bindingGyp)
writeFileSync(join(dir, 'src', 'unix', 'pty.cc'), ptyCc)
return dir
}
afterEach(() => {
for (const dir of dirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
describe('assertNodePtyPatchApplied', () => {
it('accepts a tree with both halves of the glibc-floor fix', () => {
expect(() =>
assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, PATCHED_PTY_CC))
).not.toThrow()
})
it('refuses to build when the ldflags half is missing', () => {
// The .symver pins alone let gcc's --as-needed drop libutil/libpthread from
// DT_NEEDED, which loads on the build host and fails on Ubuntu 20.04 — #9902 again,
// this time baked into a shipped prebuilt.
expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", PATCHED_PTY_CC))).toThrow(
/--no-as-needed,-l:libutil\.so\.1/
)
})
it('refuses to build when the .symver pins are missing', () => {
expect(() => assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, '// upstream'))).toThrow(
/\.symver glibc pins/
)
})
it('names the patch and the doc so the fix is findable', () => {
expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", '// upstream'))).toThrow(
/config\/patches\/node-pty@1\.1\.0\.patch/
)
})
})
describe('slot naming', () => {
it('covers every platform orcad ships to', () => {
expect([...MATRIX_SLOTS].sort()).toEqual([
'darwin-arm64',
'darwin-x64',
'linux-arm64-glibc',
'linux-arm64-musl',
'linux-x64-glibc',
'linux-x64-musl'
])
})
it('lets CI force the label so the container decides glibc vs musl', () => {
// Detection inside a container that happens to run a differently-linked Node would
// file the build under the wrong slot. The forced label must beat detection outright,
// so assert against one detection could never produce for this platform/arch.
expect(slotName(['node', 'x', '--slot=linux-x64-glibc'], 'linux', 'arm64')).toBe(
'linux-x64-glibc'
)
})
it('omits the libc dimension off Linux', () => {
expect(slotName([], 'darwin', 'arm64')).toBe('darwin-arm64')
})
it('reads glibc from the report header and musl from its absence', () => {
expect(detectLibc('linux', { glibcVersionRuntime: '2.31' })).toBe('glibc')
expect(detectLibc('linux', {})).toBe('musl')
expect(detectLibc('darwin', { glibcVersionRuntime: '2.31' })).toBe('none')
})
})
describe('mergeManifest', () => {
it('accumulates slots across the per-container CI runs that build them', () => {
// Overwriting would erase every other container's record, and the release gate would
// then reject a matrix that is actually complete.
const first = mergeManifest(null, { slot: 'linux-x64-glibc', version: '1.1.0', nodeAbi: '127' })
const second = mergeManifest(first, {
slot: 'linux-arm64-musl',
version: '1.1.0',
nodeAbi: '127'
})
expect(second.slots).toEqual(['linux-arm64-musl', 'linux-x64-glibc'])
expect(second).toMatchObject({ module: 'node-pty', version: '1.1.0', nodeAbi: '127' })
})
it('does not duplicate a slot rebuilt twice', () => {
const once = mergeManifest(null, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' })
expect(mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots)
.toEqual(['darwin-arm64'])
})
})
describe('readManifest', () => {
it('returns null instead of throwing when no matrix has been built', () => {
const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-manifest-'))
dirs.push(dir)
expect(readManifest(dir)).toBeNull()
})
})
@@ -82,6 +82,32 @@ 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.
**3. Check before loading, on hosts that ship without a compiler (`orcad`).**
The two gates above protect the packaged desktop app, where the binary is built and
verified by the same pipeline. `orcad` is deployed to hosts Orca never built on, so it
adds a runtime precondition
([`src/main/orcad/node-pty-precondition.ts`](../../src/main/orcad/node-pty-precondition.ts)),
run from `main.ts` before anything requires `node-pty`. It loads the addon in a **child
process**, so a binary the loader refuses — or one that aborts outright — is data rather
than this process's death, and the operator gets a sentence naming the host's libc, its
Node ABI, its prebuild slot and the command to run. A proven-unloadable binary exits 78
(`EX_CONFIG`) instead of reaching the `require`; a probe that never answered is reported
as unverifiable and boots anyway, because a silent probe is not evidence. Whatever it
finds is published in `status.get`'s `degradations[]` under `terminal_unavailable`.
**4. Ship the binary, built from patched sources.**
[`config/scripts/build-orcad-prebuilds.mjs`](../../config/scripts/build-orcad-prebuilds.mjs)
(`pnpm run build:orcad-prebuilds`, after `build:orcad`) compiles node-pty for the current
host and files it under `out/orcad/prebuilds/<slot>/`, where a slot is
`linux-{x64,arm64}-{glibc,musl}` or `darwin-{x64,arm64}`. libc is part of the slot name
because node-pty's own loader falls back to `prebuilds/<platform>-<arch>` and cannot tell
glibc from musl — a glibc binary parked there is loaded on Alpine and dies at `dlopen`.
The script refuses to compile a tree where `config/patches/node-pty@1.1.0.patch` is not
applied: without the patch the prebuilt is a #9902 crash shipped as an artifact rather
than a first-connect error. CI runs it once per slot inside the matching container
(`--slot=` forces the label), merges the trees, and `--require-slots` fails a release with
a hole in the matrix.
## Adding or upgrading a native dependency
- Prefer packages that ship prebuilt binaries compiled against an old toolchain
+1
View File
@@ -31,6 +31,7 @@
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs",
"build:orcad": "node config/scripts/build-orcad.mjs",
"build:orcad-prebuilds": "node config/scripts/build-orcad-prebuilds.mjs",
"smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad",
"smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs",
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest'
/**
* The precondition is only worth anything if it runs first. A loader failure is not
* catchable, so a preflight that lands after `main()` has already reached
* `await import('../ipc/pty')` prevents nothing.
*/
const order: string[] = []
vi.mock('./orcad-native-preflight', () => ({
runOrcadNativePreflight: () => {
order.push('preflight')
return true
}
}))
vi.mock('./orcad-entry', () => ({
main: async () => {
order.push('main')
}
}))
describe('orcad entry', () => {
it('runs the native preflight before starting the runtime', async () => {
await import('./main')
await vi.waitFor(() => expect(order).toContain('main'))
expect(order).toEqual(['preflight', 'main'])
})
})
+8
View File
@@ -1,6 +1,14 @@
/** Executable entry for `orcad`. See `./orcad-entry.ts`. */
import process from 'node:process'
import { main } from './orcad-entry'
import { runOrcadNativePreflight } from './orcad-native-preflight'
// Why here and not inside startOrcad: this must run before anything requires node-pty,
// and `orcad-entry` reaches it through `await import('../ipc/pty')`. Static imports are
// evaluated before this statement, so the guarantee is that no module in the graph
// requires node-pty at import time — which the bundle's lazy `require("node-pty")` in
// local-pty-provider satisfies. See ./node-pty-precondition.ts for why a child process.
runOrcadNativePreflight()
main().catch((error: unknown) => {
console.error('orcad: failed to start:', error)
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
import {
compareDottedVersions,
detectLibcFromReportHeader,
detectNativeHostAbi,
GLIBC_FLOOR,
isBelowGlibcFloor,
nativeSlotName,
parseNodeAbiMismatch,
parseUnmetGlibcVersion
} from './native-host-abi'
describe('detectLibcFromReportHeader', () => {
it('reads glibc and its version from a glibc host report', () => {
expect(detectLibcFromReportHeader('linux', { glibcVersionRuntime: '2.31' })).toEqual({
libc: 'glibc',
glibcVersion: '2.31'
})
})
it('calls a Linux report with no glibcVersionRuntime musl', () => {
// Alpine's Node omits the key entirely; that absence is the only signal available
// without shelling out to ldd, which musl images do not usefully provide.
expect(detectLibcFromReportHeader('linux', { arch: 'x64' })).toEqual({
libc: 'musl',
glibcVersion: null
})
})
it('does not invent a libc dimension for macOS or Windows', () => {
// A darwin-arm64-glibc slot would never match anything CI builds.
expect(detectLibcFromReportHeader('darwin', { glibcVersionRuntime: '2.31' }).libc).toBe('none')
expect(detectLibcFromReportHeader('win32', undefined).libc).toBe('none')
})
it('treats an unreadable report as glibc with an unknown version, not as musl', () => {
// Guessing musl would send a glibc host looking for a slot that does not exist.
expect(detectLibcFromReportHeader('linux', undefined)).toEqual({
libc: 'glibc',
glibcVersion: null
})
})
})
describe('nativeSlotName', () => {
it('carries libc on Linux and omits it elsewhere', () => {
expect(nativeSlotName({ platform: 'linux', arch: 'x64', libc: 'glibc' })).toBe(
'linux-x64-glibc'
)
expect(nativeSlotName({ platform: 'linux', arch: 'arm64', libc: 'musl' })).toBe(
'linux-arm64-musl'
)
expect(nativeSlotName({ platform: 'darwin', arch: 'arm64', libc: 'none' })).toBe('darwin-arm64')
})
it('never lets a glibc slot answer for a musl host', () => {
// node-pty's own loader checks prebuilds/<platform>-<arch> with no libc, which is the
// exact confusion this name exists to prevent.
expect(nativeSlotName({ platform: 'linux', arch: 'x64', libc: 'glibc' })).not.toBe(
nativeSlotName({ platform: 'linux', arch: 'x64', libc: 'musl' })
)
})
})
describe('glibc floor', () => {
it('compares dotted versions numerically, not lexically', () => {
// '2.9' > '2.31' under string compare; that ordering would pass a broken host.
expect(compareDottedVersions('2.9', '2.31')).toBe(-1)
expect(compareDottedVersions('2.31', '2.31.0')).toBe(0)
expect(compareDottedVersions('2.34', GLIBC_FLOOR)).toBe(1)
})
it('answers null when the version is unknown rather than claiming the floor is met', () => {
expect(isBelowGlibcFloor(null)).toBeNull()
expect(isBelowGlibcFloor('2.28')).toBe(true)
expect(isBelowGlibcFloor('2.31')).toBe(false)
})
})
describe('loader error parsing', () => {
it('extracts the unmet symbol version from the #9902 message', () => {
expect(
parseUnmetGlibcVersion(
"/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by /app/node_modules/node-pty/build/Release/pty.node)"
)
).toBe('2.34')
})
it('ignores unrelated loader noise', () => {
expect(parseUnmetGlibcVersion('Error: Cannot find module ./pty.node')).toBeNull()
})
it('extracts both ABI numbers from a NODE_MODULE_VERSION mismatch', () => {
expect(
parseNodeAbiMismatch(
'was compiled against a different Node.js version using NODE_MODULE_VERSION 115. This version of Node.js requires NODE_MODULE_VERSION 127.'
)
).toEqual({ built: '115', host: '127' })
})
})
describe('detectNativeHostAbi', () => {
it('describes the host this test is running on', () => {
const abi = detectNativeHostAbi()
expect(abi.platform).toBe(process.platform)
expect(abi.arch).toBe(process.arch)
expect(abi.nodeAbi).toBe(process.versions.modules)
expect(abi.libc).toBe(process.platform === 'linux' ? abi.libc : 'none')
})
})
+127
View File
@@ -0,0 +1,127 @@
/**
* What this host would load a native addon against: platform, arch, libc flavour and
* Node's ABI number, plus the prebuild slot name those four pick.
*
* Why libc is a first-class dimension: node-pty's own loader
* (`node_modules/node-pty/lib/utils.js`) falls back to `prebuilds/<platform>-<arch>`,
* which does NOT distinguish glibc from musl. A glibc binary dropped in that directory
* is loaded on Alpine and dies inside the dynamic loader. Slot names carry the libc so
* a mismatch is a miss rather than a crash.
*
* Everything here is pure apart from `detectNativeHostAbi`, so the classification can be
* tested for hosts this machine is not.
*/
import process from 'node:process'
export type LibcFlavor = 'glibc' | 'musl' | 'none'
export type NativeHostAbi = {
platform: NodeJS.Platform
arch: string
libc: LibcFlavor
/** Runtime glibc version ('2.31'), or null on musl, non-Linux, and unreadable reports. */
glibcVersion: string | null
/** `NODE_MODULE_VERSION` — the addon ABI this runtime accepts. */
nodeAbi: string
}
/** Stock Ubuntu 20.04. See docs/reference/linux-glibc-compatibility.md. */
export const GLIBC_FLOOR = '2.31'
type ReportHeader = { glibcVersionRuntime?: unknown }
/**
* Why absence means musl: `glibcVersionRuntime` is written by Node's report only when the
* process is linked against glibc. Alpine's Node omits it. This avoids shelling out to
* `ldd`, which is not present on every image and prints to stderr on musl.
*
* Non-Linux hosts get 'none': macOS and Windows have one system libc, so the dimension
* carries no information and must not widen the slot matrix.
*/
export function detectLibcFromReportHeader(
platform: NodeJS.Platform,
header: unknown
): { libc: LibcFlavor; glibcVersion: string | null } {
if (platform !== 'linux') {
return { libc: 'none', glibcVersion: null }
}
if (!header || typeof header !== 'object') {
// Why glibc and not 'unknown': an unreadable report is not evidence of musl, and
// glibc is the overwhelmingly common Linux case. The null version keeps the floor
// check from claiming a number it does not have.
return { libc: 'glibc', glibcVersion: null }
}
const runtime = (header as ReportHeader).glibcVersionRuntime
if (typeof runtime === 'string' && runtime.length > 0) {
return { libc: 'glibc', glibcVersion: runtime }
}
if ('glibcVersionRuntime' in (header as object)) {
return { libc: 'glibc', glibcVersion: null }
}
return { libc: 'musl', glibcVersion: null }
}
export function detectNativeHostAbi(): NativeHostAbi {
let header: unknown
try {
header = (process.report?.getReport?.() as { header?: unknown } | undefined)?.header
} catch {
header = undefined
}
const { libc, glibcVersion } = detectLibcFromReportHeader(process.platform, header)
return {
platform: process.platform,
arch: process.arch,
libc,
glibcVersion,
nodeAbi: process.versions.modules
}
}
/** `linux-x64-glibc`, `linux-arm64-musl`, `darwin-arm64`, `win32-x64`. */
export function nativeSlotName(abi: Pick<NativeHostAbi, 'platform' | 'arch' | 'libc'>): string {
return abi.libc === 'none'
? `${abi.platform}-${abi.arch}`
: `${abi.platform}-${abi.arch}-${abi.libc}`
}
/** Numeric dotted compare: -1 / 0 / 1. Missing components read as 0, so '2.31' === '2.31.0'. */
export function compareDottedVersions(left: string, right: string): number {
const a = left.split('.').map((part) => Number.parseInt(part, 10) || 0)
const b = right.split('.').map((part) => Number.parseInt(part, 10) || 0)
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
const diff = (a[i] ?? 0) - (b[i] ?? 0)
if (diff !== 0) {
return diff > 0 ? 1 : -1
}
}
return 0
}
/** Null when the version is unknown — an unread report must not read as "below the floor". */
export function isBelowGlibcFloor(glibcVersion: string | null): boolean | null {
if (!glibcVersion) {
return null
}
return compareDottedVersions(glibcVersion, GLIBC_FLOOR) < 0
}
/**
* The symbol version node's loader complained about, e.g.
* `libc.so.6: version 'GLIBC_2.34' not found (required by .../pty.node)` -> '2.34'.
* This is the fingerprint of #9902: a binary built on a newer glibc than the target.
*/
export function parseUnmetGlibcVersion(loaderError: string): string | null {
const match = loaderError.match(/version `?GLIBC_([0-9][0-9.]*)'? not found/)
return match ? match[1] : null
}
/** `NODE_MODULE_VERSION 115 ... requires NODE_MODULE_VERSION 127` -> { built: '115', host: '127' }. */
export function parseNodeAbiMismatch(
loaderError: string
): { built: string; host: string } | null {
const match = loaderError.match(
/NODE_MODULE_VERSION\s+(\d+)\D+NODE_MODULE_VERSION\s+(\d+)/
)
return match ? { built: match[1], host: match[2] } : null
}
@@ -0,0 +1,119 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
installPrebuiltSlot,
readPrebuiltSlotManifest,
resolveOrcadPrebuildsDir
} from './node-pty-prebuilt-slot'
import type { NativeHostAbi } from './native-host-abi'
const LINUX_GLIBC: NativeHostAbi = {
platform: 'linux',
arch: 'x64',
libc: 'glibc',
glibcVersion: '2.31',
nodeAbi: '127'
}
const dirs: string[] = []
const temp = (): string => {
const dir = mkdtempSync(join(tmpdir(), 'orcad-slot-'))
dirs.push(dir)
return dir
}
afterEach(() => {
for (const dir of dirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
delete process.env.ORCA_ORCAD_PREBUILDS_DIR
})
const stageSlot = (prebuilds: string, slot: string): void => {
mkdirSync(join(prebuilds, slot), { recursive: true })
writeFileSync(join(prebuilds, slot, 'pty.node'), 'binary')
writeFileSync(join(prebuilds, slot, 'spawn-helper'), 'helper')
}
describe('resolveOrcadPrebuildsDir', () => {
it('looks beside the running bundle', () => {
expect(resolveOrcadPrebuildsDir('/opt/orcad/orcad.js')).toBe(join('/opt/orcad', 'prebuilds'))
})
it('honours an explicit override', () => {
process.env.ORCA_ORCAD_PREBUILDS_DIR = '/custom/prebuilds'
expect(resolveOrcadPrebuildsDir('/opt/orcad/orcad.js')).toBe('/custom/prebuilds')
})
})
describe('installPrebuiltSlot', () => {
it('installs the slot binary and spawn-helper into build/Release', () => {
const prebuilds = temp()
const nodePtyDir = temp()
stageSlot(prebuilds, 'linux-x64-glibc')
const outcome = installPrebuiltSlot({ abi: LINUX_GLIBC, nodePtyDir, prebuildsDir: prebuilds })
expect(outcome).toEqual({ installed: true, slot: 'linux-x64-glibc', spawnHelper: true })
expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(true)
// Without the executable bit every spawn fails EACCES at the moment a user opens a terminal.
const helper = statSync(join(nodePtyDir, 'build', 'Release', 'spawn-helper'))
expect(helper.mode & 0o111).not.toBe(0)
})
it('will not load a glibc slot on a musl host', () => {
// node-pty's own loader cannot tell these apart; the slot name is the only thing that can.
const prebuilds = temp()
const nodePtyDir = temp()
stageSlot(prebuilds, 'linux-x64-glibc')
const outcome = installPrebuiltSlot({
abi: { ...LINUX_GLIBC, libc: 'musl' },
nodePtyDir,
prebuildsDir: prebuilds
})
expect(outcome).toEqual({ installed: false, slot: 'linux-x64-musl', why: 'no-slot' })
expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(false)
})
it('refuses an ABI-mismatched matrix instead of installing a binary that cannot load', () => {
// Installing it would turn "no prebuilt for this host" into a loader failure that
// reads as a corrupt install.
const prebuilds = temp()
const nodePtyDir = temp()
stageSlot(prebuilds, 'linux-x64-glibc')
writeFileSync(
join(prebuilds, 'manifest.json'),
JSON.stringify({ module: 'node-pty', version: '1.1.0', nodeAbi: '115', slots: [] })
)
const outcome = installPrebuiltSlot({ abi: LINUX_GLIBC, nodePtyDir, prebuildsDir: prebuilds })
expect(outcome).toMatchObject({ installed: false, why: 'abi-mismatch' })
expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(false)
})
it('reports a missing prebuilds directory distinctly from a missing slot', () => {
// They mean different things: no matrix shipped at all, versus a matrix with a hole.
expect(
installPrebuiltSlot({
abi: LINUX_GLIBC,
nodePtyDir: temp(),
prebuildsDir: join(temp(), 'absent')
})
).toEqual({ installed: false, slot: 'linux-x64-glibc', why: 'no-prebuilds-dir' })
})
})
describe('readPrebuiltSlotManifest', () => {
it('returns null for absent or malformed manifests rather than a half-built object', () => {
const prebuilds = temp()
expect(readPrebuiltSlotManifest(prebuilds)).toBeNull()
writeFileSync(join(prebuilds, 'manifest.json'), '{ not json')
expect(readPrebuiltSlotManifest(prebuilds)).toBeNull()
writeFileSync(join(prebuilds, 'manifest.json'), JSON.stringify({ version: '1.1.0' }))
expect(readPrebuiltSlotManifest(prebuilds)).toBeNull()
})
})
+113
View File
@@ -0,0 +1,113 @@
/**
* Install the shipped node-pty prebuilt that matches this host, so a deployment needs
* no C/C++ toolchain.
*
* Why copy into `build/Release` rather than leave it in `prebuilds/`: node-pty's own
* loader falls back to `prebuilds/<platform>-<arch>` with no libc in the name, so a
* glibc binary parked there is loaded on Alpine and dies in the dynamic loader. Putting
* the chosen slot's binary in `build/Release` is what makes the libc dimension real —
* node-pty only ever sees the one we picked.
*
* The prebuilds themselves are built from the PATCHED source (config/patches/node-pty@1.1.0.patch)
* by config/scripts/build-orcad-prebuilds.mjs. An upstream tarball would not do: the patch
* carries the `.symver` pins and the `--no-as-needed` libutil/libpthread flags that hold the
* Ubuntu 20.04 / glibc 2.31 floor (docs/reference/linux-glibc-compatibility.md).
*/
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import process from 'node:process'
import { nativeSlotName, type NativeHostAbi } from './native-host-abi'
export type PrebuiltSlotManifest = {
module: string
version: string
nodeAbi: string
slots: string[]
}
export type PrebuiltSlotOutcome =
| { installed: true; slot: string; spawnHelper: boolean }
| { installed: false; slot: string; why: 'no-slot' | 'no-prebuilds-dir' | 'abi-mismatch'; detail?: string }
/**
* Where a deployment's prebuilds live: beside the bundle that is running. `argv[1]` is
* `orcad.js` itself, so this stays correct wherever the install directory ends up.
*/
export function resolveOrcadPrebuildsDir(entryScript = process.argv[1]): string | null {
const override = process.env.ORCA_ORCAD_PREBUILDS_DIR
if (override) {
return override
}
return entryScript ? join(dirname(entryScript), 'prebuilds') : null
}
export function readPrebuiltSlotManifest(prebuildsDir: string): PrebuiltSlotManifest | null {
try {
const parsed = JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8')) as unknown
if (!parsed || typeof parsed !== 'object') {
return null
}
const manifest = parsed as Partial<PrebuiltSlotManifest>
if (typeof manifest.nodeAbi !== 'string' || typeof manifest.version !== 'string') {
return null
}
return {
module: typeof manifest.module === 'string' ? manifest.module : 'node-pty',
version: manifest.version,
nodeAbi: manifest.nodeAbi,
slots: Array.isArray(manifest.slots) ? manifest.slots.filter((s) => typeof s === 'string') : []
}
} catch {
return null
}
}
/**
* Copy `<prebuilds>/<slot>/pty.node` (and `spawn-helper`) into node-pty's `build/Release`.
*
* Refuses on an ABI mismatch instead of copying: a binary built for another
* `NODE_MODULE_VERSION` cannot load, and installing it would replace a "no prebuilt"
* diagnosis with a loader failure that reads as a corrupt install.
*/
export function installPrebuiltSlot(options: {
abi: NativeHostAbi
nodePtyDir: string
prebuildsDir?: string | null
}): PrebuiltSlotOutcome {
const slot = nativeSlotName(options.abi)
const prebuildsDir = options.prebuildsDir ?? resolveOrcadPrebuildsDir()
if (!prebuildsDir || !existsSync(prebuildsDir)) {
return { installed: false, slot, why: 'no-prebuilds-dir' }
}
const manifest = readPrebuiltSlotManifest(prebuildsDir)
if (manifest && manifest.nodeAbi !== options.abi.nodeAbi) {
return {
installed: false,
slot,
why: 'abi-mismatch',
detail: `shipped prebuilds target Node ABI ${manifest.nodeAbi}, this host runs ABI ${options.abi.nodeAbi}`
}
}
const source = join(prebuildsDir, slot, 'pty.node')
if (!existsSync(source)) {
return { installed: false, slot, why: 'no-slot' }
}
const releaseDir = join(options.nodePtyDir, 'build', 'Release')
mkdirSync(releaseDir, { recursive: true })
copyFileSync(source, join(releaseDir, 'pty.node'))
// Why this matters as much as pty.node: on Unix node-pty posix_spawns
// build/Release/spawn-helper. Without it every spawn fails with ENOENT at the moment
// a user opens a terminal, long after the "install succeeded" line.
let spawnHelper = false
if (options.abi.platform !== 'win32') {
const helperSource = join(prebuildsDir, slot, 'spawn-helper')
if (existsSync(helperSource)) {
const helperDest = join(releaseDir, 'spawn-helper')
copyFileSync(helperSource, helperDest)
chmodSync(helperDest, 0o755)
spawnHelper = true
}
}
return { installed: true, slot, spawnHelper }
}
@@ -0,0 +1,279 @@
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
buildNodePtyLoadProbeScript,
checkNodePtyPrecondition,
classifyNodePtyProbeResult,
formatNodePtyPreconditionReport,
probeLocalBuildToolchainHints
} from './node-pty-precondition'
import { detectNativeHostAbi } from './native-host-abi'
const require = createRequire(import.meta.url)
const REAL_NODE_PTY = dirname(require.resolve('node-pty/package.json'))
const probe = (overrides: Partial<Parameters<typeof classifyNodePtyProbeResult>[0]> = {}) =>
classifyNodePtyProbeResult({
code: 1,
signal: null,
stdout: '',
stderr: '',
timedOut: false,
...overrides
})
const reported = (message: string) =>
classifyNodePtyProbeResult({
code: 4,
signal: null,
stdout: `ORCA_NODE_PTY_LOAD_ERROR ${JSON.stringify(message)}\n`,
stderr: '',
timedOut: false
})
describe('classifyNodePtyProbeResult', () => {
it('accepts only a clean exit that printed the token', () => {
expect(probe({ code: 0, stdout: 'ORCA_NODE_PTY_LOAD_OK /x/build/Release' })).toBeNull()
// A zero exit with no token means the probe never reached the load.
expect(probe({ code: 0, stdout: '' })?.reason).toBe('load_failed')
})
it('names the glibc floor for the #9902 loader message', () => {
expect(
reported(
"/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by /app/node_modules/node-pty/build/Release/pty.node)"
)
).toEqual({
status: 'blocked',
reason: 'libc_floor',
detail: 'the binary requires GLIBC_2.34'
})
})
it('names a libstdc++ floor break too', () => {
expect(reported("version `GLIBCXX_3.4.29' not found")?.reason).toBe('libc_floor')
})
it('separates a Node ABI mismatch from a libc mismatch', () => {
// These need different fixes — rebuild against this Node vs. build on an older libc —
// so collapsing them sends the operator to the wrong one.
expect(
reported(
'was compiled against a different Node.js version using NODE_MODULE_VERSION 115. This version of Node.js requires NODE_MODULE_VERSION 127.'
)
).toEqual({
status: 'blocked',
reason: 'abi_mismatch',
detail: 'built for Node ABI 115, this host runs ABI 127'
})
})
it('reports a signalled probe as a crash, even with no output at all', () => {
// The uncatchable case: a binary that aborts inside the loader never reaches the
// child's catch and often prints nothing.
expect(probe({ code: null, signal: 'SIGSEGV' })).toEqual({
status: 'blocked',
reason: 'load_crashed',
detail: 'the load probe was killed by SIGSEGV'
})
})
it('distinguishes no binary anywhere from a binary the loader refused', () => {
// "install node-pty" and "rebuild node-pty for this libc" are different instructions.
expect(probe({ code: 3, stdout: 'ORCA_NODE_PTY_NO_BINARY\n' })).toEqual({
status: 'blocked',
reason: 'dependency_missing',
detail: 'node-pty is installed but has no compiled binary for this platform'
})
expect(reported('dlopen(...): slice is not valid mach-o file')?.reason).toBe('load_failed')
})
it('ignores its own token strings echoed back inside the child stderr', () => {
// node prints the whole `-e` source above the stack trace, and that source contains
// every token below. Matching on stderr made a refused binary read as "not installed".
const echoedSource =
'[eval]:1\nif(!f){console.log("ORCA_NODE_PTY_NO_BINARY");process.exit(3)}\n' +
' ^\n\nError: dlopen(/app/pty.node): slice is not valid mach-o file\n'
expect(
classifyNodePtyProbeResult({
code: 4,
signal: null,
stdout: `ORCA_NODE_PTY_LOAD_ERROR ${JSON.stringify('dlopen(/app/pty.node): slice is not valid mach-o file')}`,
stderr: echoedSource,
timedOut: false
})
).toMatchObject({ reason: 'load_failed' })
})
it('reads past node\u2019s echoed source line when it can only use stderr', () => {
const failure = probe({
stderr:
"[eval]:1\nprocess.dlopen({exports:{}},f);\n ^\n\nError: something specific went wrong\n"
})
expect(failure?.detail).toBe('Error: something specific went wrong')
})
it('calls a timeout unverifiable rather than blocked', () => {
// A probe that never answered is not evidence that node-pty is broken, and refusing
// to boot on it would take down hosts that work.
expect(probe({ timedOut: true })).toEqual({
status: 'unverifiable',
reason: 'unknown',
detail: 'the node-pty load probe did not finish in time, so nothing was established'
})
})
})
describe('buildNodePtyLoadProbeScript', () => {
it('loads through absolute paths so the child cannot resolve a different copy', () => {
const script = buildNodePtyLoadProbeScript('/opt/app/node_modules/node-pty')
expect(script).toContain('"/opt/app/node_modules/node-pty/lib/index.js"')
expect(script).toContain('"/opt/app/node_modules/node-pty/lib/utils.js"')
expect(script).toContain('loadNativeModule')
// Windows defers conpty.node to first spawn, so requiring the package proves nothing there.
expect(script).toContain('conpty')
// The raw dlopen must precede requiring the package, or node-pty's own loader
// re-wraps the loader error into a misleading "Cannot find module".
expect(script.indexOf('process.dlopen')).toBeLessThan(script.indexOf('lib/index.js'))
})
})
describe('checkNodePtyPrecondition', () => {
const temporaryDirs: string[] = []
const stageNodePty = (): string => {
const root = mkdtempSync(join(tmpdir(), 'orcad-node-pty-'))
temporaryDirs.push(root)
const dir = join(root, 'node-pty')
mkdirSync(join(dir, 'build', 'Release'), { recursive: true })
cpSync(join(REAL_NODE_PTY, 'lib'), join(dir, 'lib'), { recursive: true })
cpSync(join(REAL_NODE_PTY, 'package.json'), join(dir, 'package.json'))
return dir
}
afterEach(() => {
for (const dir of temporaryDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
it('survives a native binary that the dynamic loader refuses', () => {
// The whole reason the probe is a child process: this file is loaded with dlopen, and
// an in-process require of it can take the host down before any handler runs. Reaching
// the assertion below at all is the evidence.
const dir = stageNodePty()
writeFileSync(join(dir, 'build', 'Release', 'pty.node'), Buffer.from('not a native addon'))
const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir: null })
expect(verdict.status).toBe('blocked')
expect(verdict.reason).toBeDefined()
expect(verdict.reason).not.toBe('spawn_helper_missing')
})
it('blocks when node-pty is not resolvable at all', () => {
const verdict = checkNodePtyPrecondition({ nodePtyDir: null, prebuildsDir: null })
expect(verdict).toMatchObject({ status: 'blocked', reason: 'dependency_missing' })
})
it('passes against the node-pty this repo actually builds', () => {
const verdict = checkNodePtyPrecondition({ prebuildsDir: null })
expect(verdict.status).toBe('ok')
expect(verdict.slot).toBe(
process.platform === 'linux'
? `linux-${process.arch}-${verdict.abi.libc}`
: `${process.platform}-${process.arch}`
)
})
it.runIf(process.platform !== 'win32')(
'degrades rather than blocks when only spawn-helper is missing',
() => {
// node-pty posix_spawns spawn-helper, so this host loads fine and then fails ENOENT
// the first time someone opens a terminal. Everything else it serves still works.
const dir = stageNodePty()
cpSync(
join(REAL_NODE_PTY, 'build', 'Release', 'pty.node'),
join(dir, 'build', 'Release', 'pty.node')
)
const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir: null })
expect(verdict).toMatchObject({ status: 'degraded', reason: 'spawn_helper_missing' })
}
)
it('installs the matching shipped prebuilt slot when nothing is compiled', () => {
const dir = stageNodePty()
const abi = detectNativeHostAbi()
const slot =
abi.libc === 'none' ? `${abi.platform}-${abi.arch}` : `${abi.platform}-${abi.arch}-${abi.libc}`
const prebuildsDir = mkdtempSync(join(tmpdir(), 'orcad-prebuilds-'))
temporaryDirs.push(prebuildsDir)
mkdirSync(join(prebuildsDir, slot), { recursive: true })
cpSync(
join(REAL_NODE_PTY, 'build', 'Release', 'pty.node'),
join(prebuildsDir, slot, 'pty.node')
)
if (process.platform !== 'win32') {
cpSync(
join(REAL_NODE_PTY, 'build', 'Release', 'spawn-helper'),
join(prebuildsDir, slot, 'spawn-helper')
)
}
const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir })
expect(verdict.prebuilt).toMatchObject({ installed: true, slot })
expect(verdict.status).toBe('ok')
})
})
describe('formatNodePtyPreconditionReport', () => {
it('names the host, the slot and the action', () => {
const report = formatNodePtyPreconditionReport(
{
status: 'blocked',
slot: 'linux-x64-musl',
abi: {
platform: 'linux',
arch: 'x64',
libc: 'musl',
glibcVersion: null,
nodeAbi: '127'
},
reason: 'dependency_missing',
prebuilt: { installed: false, slot: 'linux-x64-musl', why: 'no-slot' }
},
'Terminals are unavailable on this host.',
[' sudo apk add build-base python3']
)
expect(report).toContain('platform linux/x64')
expect(report).toContain('libc musl')
expect(report).toContain('prebuild slot linux-x64-musl')
expect(report).toContain('No shipped prebuilt matches slot linux-x64-musl.')
expect(report).toContain('sudo apk add build-base python3')
})
})
describe('probeLocalBuildToolchainHints', () => {
it('gives macOS the Xcode command line tools, not a Linux package manager', () => {
// The relay's hint list answers with a cross-distro apt/dnf/pacman/apk menu when it
// finds no package manager. On macOS every line of that menu is wrong.
const hints = probeLocalBuildToolchainHints('darwin')
expect(hints).toEqual([' xcode-select --install'])
expect(hints.join('\n')).not.toMatch(/apt-get|dnf|pacman|apk/)
})
it('says nothing on Windows, where node-pty ships prebuilds', () => {
expect(probeLocalBuildToolchainHints('win32')).toEqual([])
})
it.runIf(process.platform === 'linux')('reuses the relay diagnosis on Linux', () => {
expect(probeLocalBuildToolchainHints('linux').length).toBeGreaterThan(0)
})
})
+362
View File
@@ -0,0 +1,362 @@
/**
* Prove `node-pty` can be loaded on this host BEFORE anything in the process requires it.
*
* Why this exists: of the two ways node-pty fails, only one is catchable. A missing
* module throws `MODULE_NOT_FOUND` and a caller can degrade. A module that is present but
* built against the wrong libc or Node ABI is refused by the dynamic loader, and in the
* worst case takes the process down before any handler exists — that is #9902, which
* crashed the desktop app on Ubuntu 20.04 before a window appeared
* (docs/reference/linux-glibc-compatibility.md).
*
* So the load happens in a CHILD process. Whatever the child does — throw, abort, die on
* a signal — is data to us rather than our own death, and the operator gets a sentence
* naming what to change instead of a loader stack trace.
*
* The cost is one short-lived `node -e` at startup. That is the price of turning an
* uncatchable failure into a catchable one, and it is paid once per boot.
*/
import { existsSync, accessSync, constants } from 'node:fs'
import { dirname, join } from 'node:path'
import process from 'node:process'
import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process'
import type { RuntimeTerminalUnavailableReason } from '../../shared/runtime-types'
import {
buildToolchainProbeCommand,
parseBuildToolchainProbe,
toolchainInstallHintLines
} from '../ssh/build-toolchain-diagnosis'
import {
detectNativeHostAbi,
nativeSlotName,
parseNodeAbiMismatch,
parseUnmetGlibcVersion,
type NativeHostAbi
} from './native-host-abi'
import { installPrebuiltSlot, type PrebuiltSlotOutcome } from './node-pty-prebuilt-slot'
// Why every verdict travels on STDOUT: node echoes the whole `-e` source into stderr
// before the stack trace, so any substring test against stderr also matches this file's
// own token strings. stdout carries only what the child chose to print.
const PROBE_OK_TOKEN = 'ORCA_NODE_PTY_LOAD_OK'
const NO_BINARY_TOKEN = 'ORCA_NODE_PTY_NO_BINARY'
const LOAD_ERROR_TOKEN = 'ORCA_NODE_PTY_LOAD_ERROR'
const PROBE_TIMEOUT_MS = 20_000
/**
* `ok` — proved loadable. `degraded` — loads, but something only spawn-time needs is
* broken, so the host should still serve everything else. `blocked` — proved unloadable,
* so nothing in this process may require it. `unverifiable` — the probe itself did not
* answer, which is not evidence either way.
*
* Why `unverifiable` is separate from `blocked`: a probe that times out or cannot spawn
* says nothing about node-pty, and refusing to boot on it would brick working hosts for
* a reason that was never established. Same verdict discipline as
* docs/reference/ssh-execution-boundary.md — loss of contact is not proof of death.
*/
export type NodePtyPreconditionStatus = 'ok' | 'degraded' | 'blocked' | 'unverifiable'
export type NodePtyPreconditionVerdict = {
status: NodePtyPreconditionStatus
slot: string
abi: NativeHostAbi
reason?: RuntimeTerminalUnavailableReason
detail?: string
/** What the slot install did, when one was attempted. */
prebuilt?: PrebuiltSlotOutcome
}
export type NodePtyProbeFailure = {
status: 'blocked' | 'unverifiable'
reason: RuntimeTerminalUnavailableReason
detail: string
}
/**
* Read the child's exit into a cause. Pure, so every failure shape is testable from a
* host that cannot reproduce it — the whole point, since the shapes that matter belong
* to Alpine and Ubuntu 20.04.
*/
export function classifyNodePtyProbeResult(
result: Pick<ProcessResult, 'code' | 'signal' | 'stdout' | 'stderr' | 'timedOut'>
): NodePtyProbeFailure | null {
const stdout = result.stdout
if (result.code === 0 && stdout.includes(PROBE_OK_TOKEN)) {
return null
}
if (result.timedOut) {
return {
status: 'unverifiable',
reason: 'unknown',
detail: 'the node-pty load probe did not finish in time, so nothing was established'
}
}
// Why signal before anything the child said: a binary that aborts or segfaults inside
// the loader never reaches the catch, and often prints nothing at all. That silence is
// exactly the uncatchable case this probe is a separate process for.
if (result.signal) {
return {
status: 'blocked',
reason: 'load_crashed',
detail: `the load probe was killed by ${result.signal}`
}
}
if (stdout.includes(NO_BINARY_TOKEN)) {
return {
status: 'blocked',
reason: 'dependency_missing',
detail: 'node-pty is installed but has no compiled binary for this platform'
}
}
const reported = readReportedLoadError(stdout)
if (reported !== null) {
return classifyLoaderMessage(reported)
}
return {
status: 'blocked',
reason: 'load_failed',
detail: firstLine(result.stderr) || `the load probe exited with code ${result.code}`
}
}
/** The message the child caught, or null when it never got that far. */
function readReportedLoadError(stdout: string): string | null {
const line = stdout.split('\n').find((candidate) => candidate.startsWith(LOAD_ERROR_TOKEN))
if (!line) {
return null
}
try {
return JSON.parse(line.slice(LOAD_ERROR_TOKEN.length).trim()) as string
} catch {
return line.slice(LOAD_ERROR_TOKEN.length).trim()
}
}
/** Read a dynamic-loader message. Pure, so shapes this host cannot reproduce are testable. */
export function classifyLoaderMessage(message: string): NodePtyProbeFailure {
const abiMismatch = parseNodeAbiMismatch(message)
if (abiMismatch) {
return {
status: 'blocked',
reason: 'abi_mismatch',
detail: `built for Node ABI ${abiMismatch.built}, this host runs ABI ${abiMismatch.host}`
}
}
const unmetGlibc = parseUnmetGlibcVersion(message)
if (unmetGlibc) {
return {
status: 'blocked',
reason: 'libc_floor',
detail: `the binary requires GLIBC_${unmetGlibc}`
}
}
if (/(GLIBCXX_|CXXABI_)[0-9.]+'? not found/.test(message)) {
return { status: 'blocked', reason: 'libc_floor', detail: firstLine(message) }
}
if (/MODULE_NOT_FOUND|Cannot find module/.test(message)) {
return { status: 'blocked', reason: 'dependency_missing', detail: firstLine(message) }
}
return { status: 'blocked', reason: 'load_failed', detail: firstLine(message) }
}
/**
* Why not simply the first non-empty line: when the child dies without catching, node
* prints the offending source line and a caret before the error, so line one is the
* script rather than the diagnosis. Prefer the first line that reads as an error.
*/
function firstLine(text: string): string {
const lines = text.split('\n').filter((candidate) => candidate.trim().length > 0)
const errorLine = lines.find((candidate) => /^[A-Za-z]*(Error|Exception):/.test(candidate.trim()))
return (errorLine ?? lines[0] ?? text).trim().slice(0, 400)
}
/**
* The script the child runs.
*
* Why it dlopens the file itself rather than trusting node-pty's loader: that loader
* tries several directories and rethrows only the LAST error, so a `pty.node` the
* dynamic loader refused is reported as `Cannot find module './prebuilds/...'`. Acting on
* that sends the operator to install a module that is already there. The dlopen has to
* come BEFORE `require(index.js)` for the same reason: node-pty's unixTerminal calls the
* loader at module scope, so requiring the package first re-wraps the error we came for.
*
* Why it catches and prints instead of throwing: a thrown error reaches us as a stack
* trace with the script source echoed above it, and the message we need is then one line
* inside a blob that also contains these very tokens. What the child cannot catch — a
* loader that aborts the process — still reaches us as a signal, which is the case this
* whole indirection exists for.
*/
export function buildNodePtyLoadProbeScript(nodePtyDir: string): string {
const entry = JSON.stringify(join(nodePtyDir, 'lib', 'index.js'))
const utils = JSON.stringify(join(nodePtyDir, 'lib', 'utils.js'))
const root = JSON.stringify(nodePtyDir)
// Same directory order node-pty's own loader walks, so the file opened here is the file
// it would load. Windows defers conpty.node to the first spawn, which is why the name is
// chosen the way node-pty chooses it rather than always being 'pty'.
return [
`const fs=require('fs'),p=require('path');`,
`const n=process.platform==='win32'&&Number(require('os').release().split('.')[2])>=18309?'conpty':'pty';`,
`let f=null;`,
`for(const d of ['build/Release','build/Debug','prebuilds/'+process.platform+'-'+process.arch]){`,
`for(const r of [${root},p.join(${root},'lib')]){`,
`const c=p.join(r,d,n+'.node');if(fs.existsSync(c)){f=c;break}}if(f)break}`,
`if(!f){console.log(${JSON.stringify(NO_BINARY_TOKEN)});process.exit(3)}`,
`try{`,
`process.dlopen({exports:{}},f);`,
`require(${entry});`,
`require(${utils}).loadNativeModule(n);`,
`console.log(${JSON.stringify(PROBE_OK_TOKEN)}+' '+p.dirname(f));`,
`}catch(e){`,
`console.log(${JSON.stringify(LOAD_ERROR_TOKEN)}+' '+JSON.stringify(String((e&&e.message)||e)));`,
`process.exit(4)}`
].join('')
}
function resolveNodePtyDir(): string | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports -- resolution only; the load itself happens in the child.
return dirname(require.resolve('node-pty/package.json'))
} catch {
return null
}
}
/** Local equivalent of the relay's remote toolchain probe, reusing its pure half. */
export function probeLocalBuildToolchainHints(platform: NodeJS.Platform): string[] {
if (platform === 'win32') {
return []
}
// Why macOS is not routed through the relay's hints: that function answers with a
// cross-distro apt/dnf/pacman/apk menu when it finds no package manager, and none of
// those lines is the macOS answer. Printing them here would be confidently wrong.
if (platform === 'darwin') {
return [' xcode-select --install']
}
try {
const result = runProcessSync({
program: '/bin/sh',
args: ['-c', buildToolchainProbeCommand()],
timeoutMs: 10_000
})
return toolchainInstallHintLines(parseBuildToolchainProbe(result.stdout))
} catch {
return []
}
}
export function checkNodePtyPrecondition(
options: { nodePtyDir?: string | null; abi?: NativeHostAbi; prebuildsDir?: string | null } = {}
): NodePtyPreconditionVerdict {
const abi = options.abi ?? detectNativeHostAbi()
const slot = nativeSlotName(abi)
// Why `in` and not `??`: an explicit `null` means "this host cannot resolve node-pty",
// which is a case tests must be able to state. `??` would silently re-detect instead.
const nodePtyDir = 'nodePtyDir' in options ? options.nodePtyDir : resolveNodePtyDir()
if (!nodePtyDir) {
return {
status: 'blocked',
slot,
abi,
reason: 'dependency_missing',
detail: 'node-pty is not resolvable from this install'
}
}
// Why install before probing: on a toolchain-free deployment the compiled binary does
// not exist yet, and the shipped slot is the only thing that can make the probe pass.
let prebuilt: PrebuiltSlotOutcome | undefined
if (!existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))) {
prebuilt = installPrebuiltSlot({
abi,
nodePtyDir,
...(options.prebuildsDir === undefined ? {} : { prebuildsDir: options.prebuildsDir })
})
}
let result: ProcessResult
try {
result = runProcessSync({
program: process.execPath,
args: ['-e', buildNodePtyLoadProbeScript(nodePtyDir)],
timeoutMs: PROBE_TIMEOUT_MS
})
} catch (error) {
return {
status: 'unverifiable',
slot,
abi,
reason: 'unknown',
detail: `the node-pty load probe could not be started: ${(error as Error).message}`,
...(prebuilt ? { prebuilt } : {})
}
}
const failure = classifyNodePtyProbeResult(result)
if (failure) {
return {
status: failure.status,
slot,
abi,
reason: failure.reason,
detail: failure.detail,
...(prebuilt ? { prebuilt } : {})
}
}
// Loaded. The remaining way terminals fail is spawn-time: node-pty posix_spawns
// build/Release/spawn-helper, and a missing one turns every terminal.create into ENOENT
// on a host that otherwise looks healthy. That is a degradation, not a boot blocker.
const loadedDir = result.stdout.split(PROBE_OK_TOKEN)[1]?.trim().split('\n')[0]?.trim()
if (abi.platform !== 'win32') {
const helper = join(loadedDir || join(nodePtyDir, 'build', 'Release'), 'spawn-helper')
if (!isExecutableFile(helper)) {
return {
status: 'degraded',
slot,
abi,
reason: 'spawn_helper_missing',
detail: `expected an executable at ${helper}`,
...(prebuilt ? { prebuilt } : {})
}
}
}
return { status: 'ok', slot, abi, ...(prebuilt ? { prebuilt } : {}) }
}
function isExecutableFile(path: string): boolean {
try {
accessSync(path, constants.X_OK)
return true
} catch {
return false
}
}
/** The operator-facing report. Names the host, the cause, and the next action. */
export function formatNodePtyPreconditionReport(
verdict: NodePtyPreconditionVerdict,
message: string,
toolchainHints: string[] = []
): string {
const { abi } = verdict
const host = [
`platform ${abi.platform}/${abi.arch}`,
abi.libc === 'none' ? null : `libc ${abi.libc}${abi.glibcVersion ? ` ${abi.glibcVersion}` : ''}`,
`Node ABI ${abi.nodeAbi}`,
`prebuild slot ${verdict.slot}`
]
.filter((part): part is string => part !== null)
.join(', ')
const lines = [message, '', `Host: ${host}`]
if (verdict.prebuilt && !verdict.prebuilt.installed) {
lines.push(
verdict.prebuilt.why === 'no-slot'
? `No shipped prebuilt matches slot ${verdict.slot}.`
: verdict.prebuilt.why === 'no-prebuilds-dir'
? 'This install ships no prebuilds directory.'
: `Shipped prebuilds are unusable here: ${verdict.prebuilt.detail ?? 'ABI mismatch'}.`
)
}
if (toolchainHints.length > 0) {
lines.push('', 'To build node-pty on this host, install a C/C++ toolchain:', ...toolchainHints)
}
return lines.join('\n')
}
@@ -0,0 +1,91 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { runProcessSync } from '../../shared/child-process/run-process'
/**
* The preflight only prevents a loader crash if nothing in the bundle's import graph has
* already required node-pty by the time it runs. esbuild wraps `local-pty-provider` in a
* lazy initializer because `orcad-entry` reaches it through `await import('../ipc/pty')`,
* and that laziness is load-bearing rather than incidental — a single top-level static
* import anywhere in the graph would hoist `require("node-pty")` above every statement in
* `main.ts`, including the preflight.
*
* Why this builds the bundle instead of skipping without one: no CI job builds orcad and
* runs vitest. `smoke:orcad-terminal` builds it in the static-analysis job, which never
* runs vitest; the `orcad_browser` job runs vitest but deliberately does not build orcad.
* A `runIf(existsSync(...))` guard therefore skips in every shard, forever — the same way
* an unset ORCA_BROWSER_EXECUTABLE kept the browser provider uncovered.
*
* Why not fail-when-CI instead: the wiring that would satisfy it lives in `.github/`, so
* that turns a silent gap into a red build someone else has to fix. Building costs well
* under a second (esbuild), works in every shard and on every machine, and needs no job
* to cooperate. What it must never do is skip.
*/
const REPO_ROOT = join(__dirname, '..', '..', '..')
const BUNDLE = join(REPO_ROOT, 'out', 'orcad', 'orcad.js')
const BUILD_SCRIPT = join(REPO_ROOT, 'config', 'scripts', 'build-orcad.mjs')
/**
* Why it throws rather than skipping when the build fails: a bundle that cannot be built
* is a louder problem than the one this test checks, and swallowing it here is exactly
* how the assertion would go missing.
*/
function ensureOrcadBundle(): void {
if (existsSync(BUNDLE)) {
return
}
const build = runProcessSync({
program: process.execPath,
args: [BUILD_SCRIPT],
cwd: REPO_ROOT,
timeoutMs: 300_000
})
if (!existsSync(BUNDLE)) {
const output = `${build.stdout}${build.stderr}`.slice(0, 4000)
throw new Error(
`could not build ${BUNDLE} (exit ${build.code}); the load-order assertion cannot run:\n${output}`
)
}
}
describe('orcad bundle native load order', () => {
const dirs: string[] = []
afterEach(() => {
for (const dir of dirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
it('does not require node-pty in-process before the entry rejects its argv', () => {
ensureOrcadBundle()
const dir = mkdtempSync(join(tmpdir(), 'orcad-load-order-'))
dirs.push(dir)
const harness = join(dir, 'harness.cjs')
writeFileSync(
harness,
[
"const Module = require('module')",
'const original = Module._load',
'Module._load = function (request, ...rest) {',
" if (request === 'node-pty') { console.log('IN_PROCESS_NODE_PTY_REQUIRE') }",
' return original.call(this, request, ...rest)',
'}',
"process.argv.push('--orcad-load-order-check')",
`require(${JSON.stringify(BUNDLE)})`
].join('\n')
)
const result = runProcessSync({
program: process.execPath,
args: [harness],
timeoutMs: 120_000
})
const output = `${result.stdout}${result.stderr}`
// Proof the graph fully loaded and reached argv parsing rather than dying early.
expect(output).toContain('Unknown argument: --orcad-load-order-check')
expect(output).not.toContain('IN_PROCESS_NODE_PTY_REQUIRE')
}, 360_000)
})
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ORCAD_NATIVE_PRECONDITION_EXIT_CODE,
runOrcadNativePreflight
} from './orcad-native-preflight'
import {
runtimeTerminalDegradation,
setRuntimeTerminalUnavailableCause
} from '../runtime/native-terminal-availability'
import type { NodePtyPreconditionVerdict } from './node-pty-precondition'
const ABI = {
platform: 'linux' as NodeJS.Platform,
arch: 'x64',
libc: 'glibc' as const,
glibcVersion: '2.31',
nodeAbi: '127'
}
const verdict = (over: Partial<NodePtyPreconditionVerdict>): NodePtyPreconditionVerdict => ({
status: 'ok',
slot: 'linux-x64-glibc',
abi: ABI,
...over
})
const harness = (given: NodePtyPreconditionVerdict) => {
const warn = vi.fn()
const fail = vi.fn()
const exit = vi.fn(() => undefined as never)
const continued = runOrcadNativePreflight({
check: () => given,
toolchainHints: () => [' sudo apt-get install -y build-essential python3'],
warn,
fail,
exit
})
return { warn, fail, exit, continued }
}
afterEach(() => {
setRuntimeTerminalUnavailableCause(null)
})
describe('runOrcadNativePreflight', () => {
it('stops the boot on a proven-unloadable binary instead of reaching the require', () => {
// Continuing here would hit the very dlopen the probe just proved fatal, and the
// operator would get the loader's stack trace instead of the sentence below.
const { fail, exit, warn } = harness(
verdict({ status: 'blocked', reason: 'libc_floor', detail: 'the binary requires GLIBC_2.34' })
)
expect(exit).toHaveBeenCalledWith(ORCAD_NATIVE_PRECONDITION_EXIT_CODE)
expect(warn).not.toHaveBeenCalled()
const message = fail.mock.calls[0][0] as string
expect(message).toContain('newer C library')
expect(message).toContain('the binary requires GLIBC_2.34')
expect(message).toContain('libc glibc 2.31')
// The toolchain hint is what makes it actionable rather than merely accurate.
expect(message).toContain('sudo apt-get install -y build-essential python3')
})
it('exits with EX_CONFIG so a supervisor does not restart an unequippable host forever', () => {
expect(ORCAD_NATIVE_PRECONDITION_EXIT_CODE).toBe(78)
expect(ORCAD_NATIVE_PRECONDITION_EXIT_CODE).not.toBe(1)
})
it('publishes the blocked cause as a status degradation', () => {
harness(verdict({ status: 'blocked', reason: 'abi_mismatch', detail: 'built for ABI 115' }))
expect(runtimeTerminalDegradation()).toEqual({
code: 'terminal_unavailable',
capability: 'terminal.pty.v1',
reason: 'abi_mismatch',
detail: 'built for ABI 115',
message:
"This host's node-pty binary was built for a different Node ABI than the running Node, so it cannot be loaded. Rebuild node-pty against this Node version. (built for ABI 115)"
})
})
it('boots on a spawn-time-only fault and reports it rather than refusing to serve', () => {
const { continued, exit, warn } = harness(
verdict({ status: 'degraded', reason: 'spawn_helper_missing', detail: 'no spawn-helper' })
)
expect(continued).toBe(true)
expect(exit).not.toHaveBeenCalled()
expect(warn).toHaveBeenCalledOnce()
expect(runtimeTerminalDegradation()?.reason).toBe('spawn_helper_missing')
})
it('boots when the probe established nothing, because that is not evidence of a fault', () => {
const { continued, exit } = harness(
verdict({ status: 'unverifiable', reason: 'unknown', detail: 'probe timed out' })
)
expect(continued).toBe(true)
expect(exit).not.toHaveBeenCalled()
// Still reported: an unverifiable host must not look identical to a proven-healthy one.
expect(runtimeTerminalDegradation()?.reason).toBe('unknown')
})
it('reports nothing when the load was proved good', () => {
setRuntimeTerminalUnavailableCause({ reason: 'load_failed' })
const { continued, warn, fail } = harness(verdict({ status: 'ok' }))
expect(continued).toBe(true)
expect(warn).not.toHaveBeenCalled()
expect(fail).not.toHaveBeenCalled()
expect(runtimeTerminalDegradation()).toBeNull()
})
it('does not run the toolchain probe for a verdict that is not about a missing build', () => {
const toolchainHints = vi.fn(() => [] as string[])
runOrcadNativePreflight({
check: () => verdict({ status: 'degraded', reason: 'spawn_helper_missing' }),
toolchainHints,
warn: vi.fn(),
fail: vi.fn(),
exit: vi.fn(() => undefined as never)
})
expect(toolchainHints).not.toHaveBeenCalled()
})
})
+72
View File
@@ -0,0 +1,72 @@
/**
* The native precondition orcad runs before its runtime loads anything native.
*
* Split from `node-pty-precondition.ts` so the decision (what to do about a verdict) is
* testable apart from the detection (what the verdict is).
*/
import process from 'node:process'
import { setRuntimeTerminalUnavailableCause } from '../runtime/native-terminal-availability'
import { terminalUnavailableMessage } from '../../shared/runtime-types'
import {
checkNodePtyPrecondition,
formatNodePtyPreconditionReport,
probeLocalBuildToolchainHints,
type NodePtyPreconditionVerdict
} from './node-pty-precondition'
/**
* EX_CONFIG. Why not 1: a supervisor that restarts on 1 would restart forever against a
* host that can never load this binary. This code says "the host is not equipped", which
* is a different instruction from "it crashed".
*/
export const ORCAD_NATIVE_PRECONDITION_EXIT_CODE = 78
export type NativePreflightHooks = {
check?: () => NodePtyPreconditionVerdict
toolchainHints?: (platform: NodeJS.Platform) => string[]
warn?: (message: string) => void
fail?: (message: string) => void
exit?: (code: number) => never
}
/**
* Returns true when boot may continue.
*
* A `blocked` verdict never returns: continuing would reach the very `require` the probe
* just proved fatal, and the operator would get the loader's stack trace instead of the
* sentence printed here.
*/
export function runOrcadNativePreflight(hooks: NativePreflightHooks = {}): boolean {
const check = hooks.check ?? checkNodePtyPrecondition
const warn = hooks.warn ?? ((message: string) => console.warn(message))
const fail = hooks.fail ?? ((message: string) => console.error(message))
const exit = hooks.exit ?? ((code: number) => process.exit(code) as never)
const verdict = check()
if (verdict.status === 'ok') {
// Why clear rather than leave alone: a previous run in this process may have recorded
// a cause, and status.get must not keep reporting a degradation that no longer holds.
setRuntimeTerminalUnavailableCause(null)
return true
}
const reason = verdict.reason ?? 'unknown'
setRuntimeTerminalUnavailableCause({
reason,
...(verdict.detail ? { detail: verdict.detail } : {})
})
const message = terminalUnavailableMessage(reason, verdict.detail)
if (verdict.status === 'blocked') {
const hints = (hooks.toolchainHints ?? probeLocalBuildToolchainHints)(verdict.abi.platform)
fail(`orcad: ${formatNodePtyPreconditionReport(verdict, message, hints)}`)
exit(ORCAD_NATIVE_PRECONDITION_EXIT_CODE)
return false
}
// `degraded` and `unverifiable` both boot. The first is a proven spawn-time fault the
// host can still serve around; the second established nothing, and refusing to boot on
// an inconclusive probe would take down hosts that work.
warn(`orcad: ${formatNodePtyPreconditionReport(verdict, message)}`)
return true
}
@@ -0,0 +1,51 @@
import {
type RuntimeTerminalUnavailableReason,
terminalUnavailableMessage,
TERMINAL_PTY_DEGRADATION_CAPABILITY,
TERMINAL_UNAVAILABLE_ERROR_CODE,
type RuntimeDegradation
} from '../../shared/runtime-types'
/**
* A note left by whoever proved this host cannot load or spawn PTYs, read back when
* `status.get` assembles `degradations[]`.
*
* Why a module-level note rather than a constructor argument: only the host entry point
* can run the out-of-process load probe, and it must run before anything requires
* `node-pty` — long before `OrcaRuntimeService` exists. This is the same shape
* `runtime-browser-commands-factory` uses for `browser_unavailable`, for the same reason.
*
* Silence means "nothing proved it broken", never "proved working". Hosts that never
* run a precondition therefore report no degradation, which is the honest answer.
*/
export type RuntimeTerminalUnavailableCause = {
reason: RuntimeTerminalUnavailableReason
detail?: string
}
let unavailableCause: RuntimeTerminalUnavailableCause | null = null
export function setRuntimeTerminalUnavailableCause(
cause: RuntimeTerminalUnavailableCause | null
): void {
unavailableCause = cause
}
export function runtimeTerminalUnavailableCause(): RuntimeTerminalUnavailableCause | null {
return unavailableCause
}
/** The degradation entry for the recorded cause, or null when nothing is degraded. */
export function runtimeTerminalDegradation(): RuntimeDegradation | null {
const cause = unavailableCause
if (!cause) {
return null
}
return {
code: TERMINAL_UNAVAILABLE_ERROR_CODE,
capability: TERMINAL_PTY_DEGRADATION_CAPABILITY,
message: terminalUnavailableMessage(cause.reason, cause.detail),
reason: cause.reason,
...(cause.detail ? { detail: cause.detail } : {})
}
}
+39
View File
@@ -5,6 +5,7 @@ import {
setRuntimeBrowserCommandsFactory,
setRuntimeBrowserUnavailableCause
} from './runtime-browser-commands-factory'
import { setRuntimeTerminalUnavailableCause } from './native-terminal-availability'
import { setRuntimeDesktopSurface } from './runtime-desktop-surface'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import type * as GitUsernameModule from '../git/git-username'
@@ -691,6 +692,7 @@ function resetRuntimeTestMocks(): void {
// browser RPCs reject rather than silently succeeding.
setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host))
setRuntimeBrowserUnavailableCause(null)
setRuntimeTerminalUnavailableCause(null)
// Why: the runtime's notification, window lookup and tab-create-reply channel are
// injected now, so the electron mock alone is inert. Back the surface with the same
// mocks so every existing expectation still holds.
@@ -2592,6 +2594,43 @@ describe('OrcaRuntimeService', () => {
)
})
it('reports a host that cannot load node-pty, instead of that host never answering', () => {
// The alternative to reporting it is the process dying inside the dynamic loader,
// which reaches a client as a dropped connection with no cause attached.
setRuntimeTerminalUnavailableCause({
reason: 'libc_floor',
detail: 'the binary requires GLIBC_2.34'
})
const degradations = createRuntime().getStatus().degradations ?? []
expect(degradations).toContainEqual({
code: 'terminal_unavailable',
capability: 'terminal.pty.v1',
reason: 'libc_floor',
detail: 'the binary requires GLIBC_2.34',
message:
"This host's node-pty binary was built against a newer C library than the host provides, so the dynamic loader refuses it. Rebuild node-pty on this host, or deploy a build whose prebuilt binary matches this platform's libc. (the binary requires GLIBC_2.34)"
})
})
it('reports browser and terminal loss together, because they fail independently', () => {
setRuntimeBrowserCommandsFactory(null)
setRuntimeBrowserUnavailableCause({ reason: 'unconfigured' })
setRuntimeTerminalUnavailableCause({ reason: 'dependency_missing' })
const codes = (createRuntime().getStatus().degradations ?? []).map((entry) => entry.code)
expect(codes).toEqual(['browser_unavailable', 'terminal_unavailable'])
})
it('says nothing about terminals when no precondition proved them broken', () => {
// Silence must mean "nothing proved it broken", never "proved working" — a host that
// never ran the precondition has no verdict to publish.
const degradations = createRuntime().getStatus().degradations ?? []
expect(degradations.map((entry) => entry.code)).not.toContain('terminal_unavailable')
})
it('closes a worktree’s offscreen browser pages when its metadata is removed (leak fix)', () => {
const runtime = createRuntime()
const closeTab = vi.fn().mockResolvedValue(undefined)
+7
View File
@@ -424,6 +424,7 @@ import type {
LinearTeamStatesResult,
LinearStatusSetResult
} from '../../shared/linear/agent-access'
import { runtimeTerminalDegradation } from './native-terminal-availability'
import {
BROWSER_UNAVAILABLE_ERROR_CODE,
browserUnavailableMessage,
@@ -6174,6 +6175,12 @@ export class OrcaRuntimeService {
}
]
: []
// Why appended rather than merged into the ternary: PTY loss and browser loss are
// independent, and a host can be degraded on both at once.
const terminalDegradation = runtimeTerminalDegradation()
if (terminalDegradation) {
degradations.push(terminalDegradation)
}
return {
runtimeId: this.runtimeId,
rendererGraphEpoch: this.rendererGraphEpoch,
+166
View File
@@ -0,0 +1,166 @@
/**
* Diagnose a host's C/C++ build toolchain from a POSIX-sh probe.
*
* Why this is separate from `ssh-relay-build-toolchain.ts`: everything here is pure —
* it builds a shell command, parses its output, and formats messages. The relay reaches
* it over SSH; `orcad` reaches it over a local shell. Keeping it transport-free is what
* lets the Node-only bundle reuse it without pulling `ssh2` in behind it.
*/
// Why: node-pty@1.1.0 ships no Linux prebuild, so the remote `npm install` falls
// back to `node-gyp rebuild` and needs a C/C++ toolchain. A missing toolchain is
// the dominant first-connect failure on Linux relays (#1693); node-gyp surfaces
// it as an opaque `not found: make`. We probe for the tools so we can replace
// that with an actionable "install build-essential" message.
const PROBED_TOOLS = [
'make',
'gcc',
'g++',
'cc',
'c++',
'clang',
'clang++',
'python3',
'python'
] as const
// Package managers mapped to the one-liner that installs a C/C++ toolchain on
// the matching distro family. Ordered by detection priority.
const PACKAGE_MANAGER_HINTS: readonly { bin: string; install: string }[] = [
{ bin: 'apt-get', install: 'sudo apt-get install -y build-essential python3' },
{ bin: 'dnf', install: 'sudo dnf install -y make gcc gcc-c++ python3' },
{ bin: 'yum', install: 'sudo yum install -y make gcc gcc-c++ python3' },
{ bin: 'pacman', install: 'sudo pacman -S --needed base-devel python' },
{ bin: 'apk', install: 'sudo apk add build-base python3' },
{ bin: 'zypper', install: 'sudo zypper install -y gcc gcc-c++ make python3' }
]
export type BuildToolchainStatus = {
present: string[]
packageManager: string | null
// node-gyp needs make, Python, and a C++ compiler. The caller only uses this
// verdict after npm/node-gyp output already points at a native-build failure,
// so custom Python paths do not make unrelated npm failures look toolchainy.
toolchainMissing: boolean
}
function hasCxxCompiler(present: ReadonlySet<string>): boolean {
return present.has('g++') || present.has('c++') || present.has('clang++')
}
function hasPython(present: ReadonlySet<string>): boolean {
return present.has('python3') || present.has('python')
}
// POSIX-sh probe: echo a `HAVE <tool>` line per resolvable build tool and a
// single `PKG <manager>` line for the host's package manager. Runs under
// `/bin/sh -c` (see wrapRemoteCommandForPosixShell), so it stays portable.
export function buildToolchainProbeCommand(): string {
const toolLoop = `for t in ${PROBED_TOOLS.join(
' '
)}; do if command -v "$t" >/dev/null 2>&1; then echo "HAVE $t"; fi; done`
const pkgList = PACKAGE_MANAGER_HINTS.map((hint) => hint.bin).join(' ')
const pkgLoop = `for p in ${pkgList}; do if command -v "$p" >/dev/null 2>&1; then echo "PKG $p"; break; fi; done`
return `${toolLoop}; ${pkgLoop}`
}
export function parseBuildToolchainProbe(output: string): BuildToolchainStatus {
const present = new Set<string>()
let packageManager: string | null = null
for (const line of output.split('\n')) {
const haveMatch = line.trim().match(/^HAVE (\S+)$/)
if (haveMatch) {
present.add(haveMatch[1])
continue
}
const pkgMatch = line.trim().match(/^PKG (\S+)$/)
if (pkgMatch && !packageManager) {
packageManager = pkgMatch[1]
}
}
return {
present: PROBED_TOOLS.filter((tool) => present.has(tool)),
packageManager,
toolchainMissing: !present.has('make') || !hasCxxCompiler(present) || !hasPython(present)
}
}
export function shouldProbeBuildToolchainAfterNativeDepsFailure(message: string): boolean {
const lower = message.toLowerCase()
if (!lower.includes('gyp') && !lower.includes('node-gyp')) {
return false
}
return (
/\bnot found:\s*(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b/i.test(
message
) ||
/\b(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b.*\bnot found\b/i.test(
message
) ||
lower.includes('could not find any python installation') ||
lower.includes('no xcode or clt version detected')
)
}
function missingToolNames(status: BuildToolchainStatus): string[] {
const present = new Set(status.present)
const missing: string[] = []
if (!present.has('make')) {
missing.push('make')
}
if (!hasCxxCompiler(present)) {
missing.push('a C++ compiler (g++ or clang++)')
}
if (!hasPython(present)) {
missing.push('python3')
}
return missing
}
/** Install hint for the host's package manager, or the cross-distro list when it is unknown. */
export function toolchainInstallHintLines(status: BuildToolchainStatus): string[] {
const tailored = status.packageManager
? PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === status.packageManager)?.install
: null
if (tailored) {
return [` ${tailored}`]
}
return [
' Debian/Ubuntu: sudo apt-get install -y build-essential python3',
' Fedora/RHEL: sudo dnf install -y make gcc gcc-c++ python3',
' Arch: sudo pacman -S --needed base-devel python',
' Alpine: sudo apk add build-base python3'
]
}
/** One-line summary for the deploy log when node-pty is skipped rather than compiled. */
export function formatSkippedNodePtyWarning(status: BuildToolchainStatus): string {
// Why: with no package manager detected the hint list is the cross-distro menu, whose first line
// is Debian's — quoting it alone would name the wrong distro, so stay neutral instead.
const hintLines = toolchainInstallHintLines(status)
const hint =
hintLines.length === 1
? hintLines[0].trim()
: 'install a C/C++ toolchain (make, a C++ compiler, python3)'
return (
`missing build tools (${missingToolNames(status).join(', ')}); skipping node-pty so the ` +
`connection still serves files and git. Remote terminals need: ${hint}`
)
}
export function formatMissingToolchainError(
status: BuildToolchainStatus,
underlyingError: string
): string {
const lines = [
`The remote host is missing the C/C++ build tools (${missingToolNames(status).join(', ')}) ` +
`needed to compile Orca's relay native modules (node-pty, @parcel/watcher). node-pty has no ` +
`prebuilt binary for Linux, so they must be compiled on the remote host.`,
'',
'Install the build tools on the remote host, then reconnect:',
...toolchainInstallHintLines(status),
'',
`Underlying install error: ${underlyingError}`
]
return lines.join('\n')
}
+17 -157
View File
@@ -1,164 +1,24 @@
import { execCommand } from './ssh-relay-deploy-helpers'
import type { SshConnection } from './ssh-connection'
import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform'
import {
buildToolchainProbeCommand,
parseBuildToolchainProbe,
type BuildToolchainStatus
} from './build-toolchain-diagnosis'
// Why: node-pty@1.1.0 ships no Linux prebuild, so the remote `npm install` falls
// back to `node-gyp rebuild` and needs a C/C++ toolchain. A missing toolchain is
// the dominant first-connect failure on Linux relays (#1693); node-gyp surfaces
// it as an opaque `not found: make`. We probe for the tools so we can replace
// that with an actionable "install build-essential" message.
const PROBED_TOOLS = [
'make',
'gcc',
'g++',
'cc',
'c++',
'clang',
'clang++',
'python3',
'python'
] as const
// Package managers mapped to the one-liner that installs a C/C++ toolchain on
// the matching distro family. Ordered by detection priority.
const PACKAGE_MANAGER_HINTS: readonly { bin: string; install: string }[] = [
{ bin: 'apt-get', install: 'sudo apt-get install -y build-essential python3' },
{ bin: 'dnf', install: 'sudo dnf install -y make gcc gcc-c++ python3' },
{ bin: 'yum', install: 'sudo yum install -y make gcc gcc-c++ python3' },
{ bin: 'pacman', install: 'sudo pacman -S --needed base-devel python' },
{ bin: 'apk', install: 'sudo apk add build-base python3' },
{ bin: 'zypper', install: 'sudo zypper install -y gcc gcc-c++ make python3' }
]
export type BuildToolchainStatus = {
present: string[]
packageManager: string | null
// node-gyp needs make, Python, and a C++ compiler. The caller only uses this
// verdict after npm/node-gyp output already points at a native-build failure,
// so custom Python paths do not make unrelated npm failures look toolchainy.
toolchainMissing: boolean
}
function hasCxxCompiler(present: ReadonlySet<string>): boolean {
return present.has('g++') || present.has('c++') || present.has('clang++')
}
function hasPython(present: ReadonlySet<string>): boolean {
return present.has('python3') || present.has('python')
}
// POSIX-sh probe: echo a `HAVE <tool>` line per resolvable build tool and a
// single `PKG <manager>` line for the host's package manager. Runs under
// `/bin/sh -c` (see wrapRemoteCommandForPosixShell), so it stays portable.
export function buildToolchainProbeCommand(): string {
const toolLoop = `for t in ${PROBED_TOOLS.join(
' '
)}; do if command -v "$t" >/dev/null 2>&1; then echo "HAVE $t"; fi; done`
const pkgList = PACKAGE_MANAGER_HINTS.map((hint) => hint.bin).join(' ')
const pkgLoop = `for p in ${pkgList}; do if command -v "$p" >/dev/null 2>&1; then echo "PKG $p"; break; fi; done`
return `${toolLoop}; ${pkgLoop}`
}
export function parseBuildToolchainProbe(output: string): BuildToolchainStatus {
const present = new Set<string>()
let packageManager: string | null = null
for (const line of output.split('\n')) {
const haveMatch = line.trim().match(/^HAVE (\S+)$/)
if (haveMatch) {
present.add(haveMatch[1])
continue
}
const pkgMatch = line.trim().match(/^PKG (\S+)$/)
if (pkgMatch && !packageManager) {
packageManager = pkgMatch[1]
}
}
return {
present: PROBED_TOOLS.filter((tool) => present.has(tool)),
packageManager,
toolchainMissing: !present.has('make') || !hasCxxCompiler(present) || !hasPython(present)
}
}
export function shouldProbeBuildToolchainAfterNativeDepsFailure(message: string): boolean {
const lower = message.toLowerCase()
if (!lower.includes('gyp') && !lower.includes('node-gyp')) {
return false
}
return (
/\bnot found:\s*(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b/i.test(
message
) ||
/\b(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b.*\bnot found\b/i.test(
message
) ||
lower.includes('could not find any python installation') ||
lower.includes('no xcode or clt version detected')
)
}
function missingToolNames(status: BuildToolchainStatus): string[] {
const present = new Set(status.present)
const missing: string[] = []
if (!present.has('make')) {
missing.push('make')
}
if (!hasCxxCompiler(present)) {
missing.push('a C++ compiler (g++ or clang++)')
}
if (!hasPython(present)) {
missing.push('python3')
}
return missing
}
/** Install hint for the host's package manager, or the cross-distro list when it is unknown. */
export function toolchainInstallHintLines(status: BuildToolchainStatus): string[] {
const tailored = status.packageManager
? PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === status.packageManager)?.install
: null
if (tailored) {
return [` ${tailored}`]
}
return [
' Debian/Ubuntu: sudo apt-get install -y build-essential python3',
' Fedora/RHEL: sudo dnf install -y make gcc gcc-c++ python3',
' Arch: sudo pacman -S --needed base-devel python',
' Alpine: sudo apk add build-base python3'
]
}
/** One-line summary for the deploy log when node-pty is skipped rather than compiled. */
export function formatSkippedNodePtyWarning(status: BuildToolchainStatus): string {
// Why: with no package manager detected the hint list is the cross-distro menu, whose first line
// is Debian's — quoting it alone would name the wrong distro, so stay neutral instead.
const hintLines = toolchainInstallHintLines(status)
const hint =
hintLines.length === 1
? hintLines[0].trim()
: 'install a C/C++ toolchain (make, a C++ compiler, python3)'
return (
`missing build tools (${missingToolNames(status).join(', ')}); skipping node-pty so the ` +
`connection still serves files and git. Remote terminals need: ${hint}`
)
}
export function formatMissingToolchainError(
status: BuildToolchainStatus,
underlyingError: string
): string {
const lines = [
`The remote host is missing the C/C++ build tools (${missingToolNames(status).join(', ')}) ` +
`needed to compile Orca's relay native modules (node-pty, @parcel/watcher). node-pty has no ` +
`prebuilt binary for Linux, so they must be compiled on the remote host.`,
'',
'Install the build tools on the remote host, then reconnect:',
...toolchainInstallHintLines(status),
'',
`Underlying install error: ${underlyingError}`
]
return lines.join('\n')
}
// Why re-exported rather than moved outright: `ssh-relay-deploy.ts` and the relay tests
// import the whole diagnosis surface from here, and the split exists for bundle reasons,
// not to redraw the relay's own API.
export {
buildToolchainProbeCommand,
parseBuildToolchainProbe,
shouldProbeBuildToolchainAfterNativeDepsFailure,
toolchainInstallHintLines,
formatSkippedNodePtyWarning,
formatMissingToolchainError
} from './build-toolchain-diagnosis'
export type { BuildToolchainStatus } from './build-toolchain-diagnosis'
// Best-effort: returns null on Windows hosts (node-pty ships win32 prebuilds, so
// a missing toolchain isn't the failure there) or if the probe itself errors —
+58 -3
View File
@@ -110,15 +110,70 @@ export function browserUnavailableMessage(
return detail ? `${base} (${detail})` : base
}
export const TERMINAL_UNAVAILABLE_ERROR_CODE = 'terminal_unavailable' as const
/**
* Label for the thing a `terminal_unavailable` degradation degrades. Deliberately NOT a
* member of `RUNTIME_CAPABILITIES`: capabilities are things a host advertises it HAS, and
* adding one here would make every host start claiming a capability name it never had.
*/
export const TERMINAL_PTY_DEGRADATION_CAPABILITY = 'terminal.pty.v1' as const
/**
* Why this host cannot spawn PTYs. Same opacity contract as
* `RuntimeBrowserUnavailableReason`: render `message`, never switch exhaustively.
*
* The libc/ABI members exist because they are the failure the dynamic loader owns —
* `node-pty` is present and resolvable, and loading it kills the process before any
* `try` can see it (docs/reference/linux-glibc-compatibility.md, #9902). A host that
* proved this out of process reports it here instead of dying.
*/
export type RuntimeTerminalUnavailableReason =
| 'dependency_missing'
| 'libc_floor'
| 'abi_mismatch'
| 'load_failed'
| 'load_crashed'
| 'spawn_helper_missing'
| 'unknown'
const TERMINAL_UNAVAILABLE_MESSAGES: Record<RuntimeTerminalUnavailableReason, string> = {
dependency_missing:
'Terminals are unavailable on this host: node-pty has no native binary for this platform. Install or rebuild it, or deploy a build that ships a prebuilt binary for this platform.',
libc_floor:
"This host's node-pty binary was built against a newer C library than the host provides, so the dynamic loader refuses it. Rebuild node-pty on this host, or deploy a build whose prebuilt binary matches this platform's libc.",
abi_mismatch:
"This host's node-pty binary was built for a different Node ABI than the running Node, so it cannot be loaded. Rebuild node-pty against this Node version.",
load_failed: 'Terminals are unavailable on this host: node-pty failed to load.',
load_crashed:
'Terminals are unavailable on this host: loading node-pty terminated the probe process, which means the binary is incompatible with this host rather than merely missing.',
spawn_helper_missing:
"node-pty loaded, but its spawn-helper executable is missing or not executable, so every terminal spawn would fail. Reinstall node-pty on this host.",
unknown: 'Terminals are unavailable on this host, and the cause could not be determined.'
}
export function terminalUnavailableMessage(
reason: RuntimeTerminalUnavailableReason,
detail?: string
): string {
const base = TERMINAL_UNAVAILABLE_MESSAGES[reason]
return detail ? `${base} (${detail})` : base
}
export type RuntimeDegradation = {
code: typeof BROWSER_UNAVAILABLE_ERROR_CODE
capability: 'browser.headless.v1'
/**
* Open vocabulary. New codes ship without a protocol bump, so a client must render
* `message` and must not switch exhaustively on this — the same contract the `reason`
* fields carry.
*/
code: typeof BROWSER_UNAVAILABLE_ERROR_CODE | typeof TERMINAL_UNAVAILABLE_ERROR_CODE
capability: 'browser.headless.v1' | typeof TERMINAL_PTY_DEGRADATION_CAPABILITY
message: string
/**
* Machine-readable cause. Optional for mixed-version peers: absence means the host
* predates structured causes, NOT that the cause is 'unconfigured'.
*/
reason?: RuntimeBrowserUnavailableReason
reason?: RuntimeBrowserUnavailableReason | RuntimeTerminalUnavailableReason
/** Underlying error text when the host has one. Diagnostic only; never load-bearing. */
detail?: string
}