mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user