From 73e316a67f8d275785d48e8a5eec05965d0454b5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:31:01 -0700 Subject: [PATCH] feat(orcad): prove node-pty loads before anything requires it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/- 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. --- config/scripts/build-orcad-prebuilds.mjs | 212 ++++++++++ config/scripts/build-orcad-prebuilds.test.mjs | 121 ++++++ docs/reference/linux-glibc-compatibility.md | 26 ++ package.json | 1 + src/main/orcad/main-preflight-order.test.ts | 30 ++ src/main/orcad/main.ts | 8 + src/main/orcad/native-host-abi.test.ts | 110 ++++++ src/main/orcad/native-host-abi.ts | 127 ++++++ src/main/orcad/node-pty-prebuilt-slot.test.ts | 119 ++++++ src/main/orcad/node-pty-prebuilt-slot.ts | 113 ++++++ src/main/orcad/node-pty-precondition.test.ts | 279 ++++++++++++++ src/main/orcad/node-pty-precondition.ts | 362 ++++++++++++++++++ .../orcad-bundle-native-load-order.test.ts | 91 +++++ src/main/orcad/orcad-native-preflight.test.ts | 125 ++++++ src/main/orcad/orcad-native-preflight.ts | 72 ++++ .../runtime/native-terminal-availability.ts | 51 +++ src/main/runtime/orca-runtime.test.ts | 39 ++ src/main/runtime/orca-runtime.ts | 7 + src/main/ssh/build-toolchain-diagnosis.ts | 166 ++++++++ src/main/ssh/ssh-relay-build-toolchain.ts | 174 +-------- src/shared/runtime-types.ts | 61 ++- 21 files changed, 2134 insertions(+), 160 deletions(-) create mode 100644 config/scripts/build-orcad-prebuilds.mjs create mode 100644 config/scripts/build-orcad-prebuilds.test.mjs create mode 100644 src/main/orcad/main-preflight-order.test.ts create mode 100644 src/main/orcad/native-host-abi.test.ts create mode 100644 src/main/orcad/native-host-abi.ts create mode 100644 src/main/orcad/node-pty-prebuilt-slot.test.ts create mode 100644 src/main/orcad/node-pty-prebuilt-slot.ts create mode 100644 src/main/orcad/node-pty-precondition.test.ts create mode 100644 src/main/orcad/node-pty-precondition.ts create mode 100644 src/main/orcad/orcad-bundle-native-load-order.test.ts create mode 100644 src/main/orcad/orcad-native-preflight.test.ts create mode 100644 src/main/orcad/orcad-native-preflight.ts create mode 100644 src/main/runtime/native-terminal-availability.ts create mode 100644 src/main/ssh/build-toolchain-diagnosis.ts diff --git a/config/scripts/build-orcad-prebuilds.mjs b/config/scripts/build-orcad-prebuilds.mjs new file mode 100644 index 00000000000..679625b9f6d --- /dev/null +++ b/config/scripts/build-orcad-prebuilds.mjs @@ -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=` 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() + } +} diff --git a/config/scripts/build-orcad-prebuilds.test.mjs b/config/scripts/build-orcad-prebuilds.test.mjs new file mode 100644 index 00000000000..84c0e83c18f --- /dev/null +++ b/config/scripts/build-orcad-prebuilds.test.mjs @@ -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() + }) +}) diff --git a/docs/reference/linux-glibc-compatibility.md b/docs/reference/linux-glibc-compatibility.md index 60d855c7372..bf98dbdcb80 100644 --- a/docs/reference/linux-glibc-compatibility.md +++ b/docs/reference/linux-glibc-compatibility.md @@ -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//`, 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/-` 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 diff --git a/package.json b/package.json index 933741f8cc4..1ef901e6772 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts new file mode 100644 index 00000000000..bd130bfb33f --- /dev/null +++ b/src/main/orcad/main-preflight-order.test.ts @@ -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']) + }) +}) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index e76ed873cb0..bc59ddb19ab 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -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) diff --git a/src/main/orcad/native-host-abi.test.ts b/src/main/orcad/native-host-abi.test.ts new file mode 100644 index 00000000000..44f469f0faa --- /dev/null +++ b/src/main/orcad/native-host-abi.test.ts @@ -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/- 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') + }) +}) diff --git a/src/main/orcad/native-host-abi.ts b/src/main/orcad/native-host-abi.ts new file mode 100644 index 00000000000..4afb8ac7bfa --- /dev/null +++ b/src/main/orcad/native-host-abi.ts @@ -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/-`, + * 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): 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 +} diff --git a/src/main/orcad/node-pty-prebuilt-slot.test.ts b/src/main/orcad/node-pty-prebuilt-slot.test.ts new file mode 100644 index 00000000000..00880eee1e5 --- /dev/null +++ b/src/main/orcad/node-pty-prebuilt-slot.test.ts @@ -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() + }) +}) diff --git a/src/main/orcad/node-pty-prebuilt-slot.ts b/src/main/orcad/node-pty-prebuilt-slot.ts new file mode 100644 index 00000000000..992cbe705ee --- /dev/null +++ b/src/main/orcad/node-pty-prebuilt-slot.ts @@ -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/-` 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 + 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 `//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 } +} diff --git a/src/main/orcad/node-pty-precondition.test.ts b/src/main/orcad/node-pty-precondition.test.ts new file mode 100644 index 00000000000..3854a5bd3b6 --- /dev/null +++ b/src/main/orcad/node-pty-precondition.test.ts @@ -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[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) + }) +}) diff --git a/src/main/orcad/node-pty-precondition.ts b/src/main/orcad/node-pty-precondition.ts new file mode 100644 index 00000000000..07ecb2c3645 --- /dev/null +++ b/src/main/orcad/node-pty-precondition.ts @@ -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 +): 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') +} diff --git a/src/main/orcad/orcad-bundle-native-load-order.test.ts b/src/main/orcad/orcad-bundle-native-load-order.test.ts new file mode 100644 index 00000000000..1c26501b6a6 --- /dev/null +++ b/src/main/orcad/orcad-bundle-native-load-order.test.ts @@ -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) +}) diff --git a/src/main/orcad/orcad-native-preflight.test.ts b/src/main/orcad/orcad-native-preflight.test.ts new file mode 100644 index 00000000000..6c697a3ecce --- /dev/null +++ b/src/main/orcad/orcad-native-preflight.test.ts @@ -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 => ({ + 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() + }) +}) diff --git a/src/main/orcad/orcad-native-preflight.ts b/src/main/orcad/orcad-native-preflight.ts new file mode 100644 index 00000000000..0f3c2cf8970 --- /dev/null +++ b/src/main/orcad/orcad-native-preflight.ts @@ -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 +} diff --git a/src/main/runtime/native-terminal-availability.ts b/src/main/runtime/native-terminal-availability.ts new file mode 100644 index 00000000000..85eb3df57f0 --- /dev/null +++ b/src/main/runtime/native-terminal-availability.ts @@ -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 } : {}) + } +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index fd0645f5274..ab5d3a80553 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 98f138a3567..04c54fa429d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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, diff --git a/src/main/ssh/build-toolchain-diagnosis.ts b/src/main/ssh/build-toolchain-diagnosis.ts new file mode 100644 index 00000000000..446601518c9 --- /dev/null +++ b/src/main/ssh/build-toolchain-diagnosis.ts @@ -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): boolean { + return present.has('g++') || present.has('c++') || present.has('clang++') +} + +function hasPython(present: ReadonlySet): boolean { + return present.has('python3') || present.has('python') +} + +// POSIX-sh probe: echo a `HAVE ` line per resolvable build tool and a +// single `PKG ` 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() + 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') +} + diff --git a/src/main/ssh/ssh-relay-build-toolchain.ts b/src/main/ssh/ssh-relay-build-toolchain.ts index 608fca5c6eb..bcb64d3bdf9 100644 --- a/src/main/ssh/ssh-relay-build-toolchain.ts +++ b/src/main/ssh/ssh-relay-build-toolchain.ts @@ -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): boolean { - return present.has('g++') || present.has('c++') || present.has('clang++') -} - -function hasPython(present: ReadonlySet): boolean { - return present.has('python3') || present.has('python') -} - -// POSIX-sh probe: echo a `HAVE ` line per resolvable build tool and a -// single `PKG ` 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() - 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 — diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 423c1fe6191..6a0ad601c22 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -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 = { + 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 }