mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
node-pty declares the spawn-helper target inside binding.gyp's OS=="mac" block and pty.cc execs it only under __APPLE__. Asserting it on `!== 'win32'` made every Linux orcad boot degraded with spawn_helper_missing while its terminals worked fine. Route all four sites through one shared `usesNodePtySpawnHelper` predicate: the precondition verdict, the prebuilt slot install, the +x repair, and the prebuilds build script (which threw outright on a Linux slot build). Fixes #17844
368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
/**
|
|
* 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 { usesNodePtySpawnHelper } from '../../shared/node-pty-spawn-helper'
|
|
import type { RuntimeTerminalUnavailableReason } from '../../shared/runtime-types'
|
|
import {
|
|
buildToolchainProbeCommand,
|
|
parseBuildToolchainProbe,
|
|
toolchainInstallHintLines
|
|
} from '../ssh/build-toolchain-diagnosis'
|
|
import { detectNativeHostAbi, nativeSlotName, type NativeHostAbi } from './native-host-abi'
|
|
import { classifyNodePtyLoaderMessage, firstErrorLine } from './node-pty-loader-diagnosis'
|
|
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
|
|
}
|
|
|
|
/**
|
|
* What the child actually reported, before any judgement is made about it.
|
|
*
|
|
* Split from the classification so callers that need the loader's own words — the relay,
|
|
* which quotes them back when nothing recognizes the shape — do not have to re-derive
|
|
* them from a formatted verdict.
|
|
*/
|
|
export type NodePtyProbeOutcome =
|
|
| { kind: 'loaded'; loadedDir: string | null }
|
|
| { kind: 'noBinary' }
|
|
| { kind: 'loaderError'; message: string }
|
|
| { kind: 'signalled'; signal: NodeJS.Signals }
|
|
/** The probe never answered. Not evidence about node-pty either way. */
|
|
| { kind: 'unanswered'; detail: string }
|
|
/** It answered, but with nothing that names a cause. */
|
|
| { kind: 'unexplained'; detail: string }
|
|
|
|
export function readNodePtyProbeOutcome(
|
|
result: Pick<ProcessResult, 'code' | 'signal' | 'stdout' | 'stderr' | 'timedOut'>
|
|
): NodePtyProbeOutcome {
|
|
const stdout = result.stdout
|
|
if (result.code === 0 && stdout.includes(PROBE_OK_TOKEN)) {
|
|
return {
|
|
kind: 'loaded',
|
|
loadedDir: stdout.split(PROBE_OK_TOKEN)[1]?.trim().split('\n')[0]?.trim() || null
|
|
}
|
|
}
|
|
if (result.timedOut) {
|
|
return {
|
|
kind: 'unanswered',
|
|
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 { kind: 'signalled', signal: result.signal }
|
|
}
|
|
if (stdout.includes(NO_BINARY_TOKEN)) {
|
|
return { kind: 'noBinary' }
|
|
}
|
|
const reported = readReportedLoadError(stdout)
|
|
if (reported !== null) {
|
|
return { kind: 'loaderError', message: reported }
|
|
}
|
|
return {
|
|
kind: 'unexplained',
|
|
detail: firstErrorLine(result.stderr) || `the load probe exited with code ${result.code}`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the child's exit into a cause. Pure, so every failure shape is testable from a
|
|
* host that cannot reproduce it — the whole point, since the shapes that matter belong
|
|
* to Alpine and Ubuntu 20.04.
|
|
*/
|
|
export function classifyNodePtyProbeResult(
|
|
result: Pick<ProcessResult, 'code' | 'signal' | 'stdout' | 'stderr' | 'timedOut'>
|
|
): NodePtyProbeFailure | null {
|
|
const outcome = readNodePtyProbeOutcome(result)
|
|
switch (outcome.kind) {
|
|
case 'loaded':
|
|
return null
|
|
case 'unanswered':
|
|
return { status: 'unverifiable', reason: 'unknown', detail: outcome.detail }
|
|
case 'signalled':
|
|
return {
|
|
status: 'blocked',
|
|
reason: 'load_crashed',
|
|
detail: `the load probe was killed by ${outcome.signal}`
|
|
}
|
|
case 'noBinary':
|
|
return {
|
|
status: 'blocked',
|
|
reason: 'dependency_missing',
|
|
detail: 'node-pty is installed but has no compiled binary for this platform'
|
|
}
|
|
case 'loaderError':
|
|
return classifyLoaderMessage(outcome.message)
|
|
case 'unexplained':
|
|
return { status: 'blocked', reason: 'load_failed', detail: outcome.detail }
|
|
}
|
|
}
|
|
|
|
/** 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 {
|
|
return { status: 'blocked', ...classifyNodePtyLoaderMessage(message) }
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
// Why require.resolve and not import: resolution only — the load itself happens in
|
|
// the child process, which is the whole point of the precondition.
|
|
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: on macOS 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 outcome = readNodePtyProbeOutcome(result)
|
|
const loadedDir = outcome.kind === 'loaded' ? outcome.loadedDir : null
|
|
if (usesNodePtySpawnHelper(abi.platform)) {
|
|
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')
|
|
}
|