fix(orcad): stop demanding a spawn-helper only macOS builds (#18122)

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
This commit is contained in:
Neil
2026-09-02 02:49:09 -07:00
committed by GitHub
parent 8197268956
commit 34999e328e
8 changed files with 96 additions and 19 deletions
+4 -3
View File
@@ -177,10 +177,11 @@ function build() {
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,
// Why spawn-helper ships too: on macOS 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') {
// opens a terminal. binding.gyp builds the helper only under OS=="mac"; every other
// platform forks directly, so demanding one there fails a healthy Linux slot build.
if (process.platform === 'darwin') {
const helperSource = join(dirname(builtBinary), 'spawn-helper')
if (!existsSync(helperSource)) {
throw new Error(`[orcad-prebuilds] spawn-helper missing at ${helperSource}`)
+27 -5
View File
@@ -17,6 +17,14 @@ const LINUX_GLIBC: NativeHostAbi = {
nodeAbi: '127'
}
const DARWIN_ARM64: NativeHostAbi = {
platform: 'darwin',
arch: 'arm64',
libc: 'none',
glibcVersion: null,
nodeAbi: '127'
}
const dirs: string[] = []
const temp = (): string => {
const dir = mkdtempSync(join(tmpdir(), 'orcad-slot-'))
@@ -48,18 +56,32 @@ describe('resolveOrcadPrebuildsDir', () => {
})
describe('installPrebuiltSlot', () => {
it('installs the slot binary and spawn-helper into build/Release', () => {
it('installs the slot binary and spawn-helper into build/Release on macOS', () => {
const prebuilds = temp()
const nodePtyDir = temp()
stageSlot(prebuilds, 'darwin-arm64')
const outcome = installPrebuiltSlot({ abi: DARWIN_ARM64, nodePtyDir, prebuildsDir: prebuilds })
expect(outcome).toEqual({ installed: true, slot: 'darwin-arm64', 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('installs a Linux slot without claiming a spawn-helper it never execs', () => {
// node-pty builds spawn-helper only under binding.gyp's OS=="mac"; reporting one off
// macOS is what made every Linux orcad boot degraded on spawn_helper_missing (#17844).
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(outcome).toEqual({ installed: true, slot: 'linux-x64-glibc', spawnHelper: false })
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)
expect(existsSync(join(nodePtyDir, 'build', 'Release', 'spawn-helper'))).toBe(false)
})
it('will not load a glibc slot on a musl host', () => {
+3 -2
View File
@@ -16,6 +16,7 @@
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import process from 'node:process'
import { usesNodePtySpawnHelper } from '../../shared/node-pty-spawn-helper'
import { nativeSlotName, type NativeHostAbi } from './native-host-abi'
export type PrebuiltSlotManifest = {
@@ -103,11 +104,11 @@ export function installPrebuiltSlot(options: {
mkdirSync(releaseDir, { recursive: true })
copyFileSync(source, join(releaseDir, 'pty.node'))
// Why this matters as much as pty.node: on Unix node-pty posix_spawns
// Why this matters as much as pty.node: on macOS 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') {
if (usesNodePtySpawnHelper(options.abi.platform)) {
const helperSource = join(prebuildsDir, slot, 'spawn-helper')
if (existsSync(helperSource)) {
const helperDest = join(releaseDir, 'spawn-helper')
+31 -6
View File
@@ -31,10 +31,10 @@ const realNodePtyLoads = ((): boolean => {
if (!existsSync(REAL_PTY_NODE)) {
return false
}
// Why spawn-helper too: a slot without it is legitimately 'degraded', so a test that
// expects 'ok' has an unsatisfiable premise on a host that lacks it. CI has the
// binding but not the helper, which is what made the previous gate insufficient.
if (process.platform !== 'win32' && !existsSync(REAL_SPAWN_HELPER)) {
// Why spawn-helper too: on macOS a slot without it is legitimately 'degraded', so a
// test that expects 'ok' has an unsatisfiable premise on a host that lacks it. Only
// macOS builds the helper, so gating other platforms on it never lets them run.
if (process.platform === 'darwin' && !existsSync(REAL_SPAWN_HELPER)) {
return false
}
const probe = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(REAL_PTY_NODE)})`], {
@@ -240,8 +240,8 @@ describe('checkNodePtyPrecondition', () => {
// Why gated on the real binding: this asserts a LOAD outcome, so it needs a pty.node
// built for the Node ABI. CI's shard never runs ensure-native-runtime, so the copy
// ENOENT'd there.
it.runIf(process.platform !== 'win32' && realNodePtyLoads)(
// ENOENT'd there. macOS only — it is the only platform that execs spawn-helper.
it.runIf(process.platform === 'darwin' && realNodePtyLoads)(
'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
@@ -258,6 +258,31 @@ describe('checkNodePtyPrecondition', () => {
}
)
// Same staging as above, read through a Linux ABI: node-pty builds spawn-helper only
// under binding.gyp's OS=="mac", so demanding one here called every healthy Linux
// orcad degraded while its terminals worked (#17844). Gated on a loadable binding for
// the same reason as the macOS case; the ABI is what makes it a Linux verdict.
it.runIf(process.platform !== 'win32' && realNodePtyLoads)(
'does not call a Linux host degraded over a spawn-helper it never execs',
() => {
const dir = stageNodePty()
cpSync(
join(REAL_NODE_PTY, 'build', 'Release', 'pty.node'),
join(dir, 'build', 'Release', 'pty.node')
)
expect(existsSync(join(dir, 'build', 'Release', 'spawn-helper'))).toBe(false)
const verdict = checkNodePtyPrecondition({
nodePtyDir: dir,
prebuildsDir: null,
abi: { platform: 'linux', arch: 'x64', libc: 'glibc', glibcVersion: '2.31', nodeAbi: '127' }
})
expect(verdict).toMatchObject({ status: 'ok', slot: 'linux-x64-glibc' })
expect(verdict.reason).toBeUndefined()
}
)
// Why split: the "ok" half needs a REAL loadable pty.node, which only exists after
// `ensure-native-runtime --runtime=node`. CI's shard runs vitest directly, so copying
// from node_modules ENOENT'd there. Slot *placement* is the logic worth checking on
+3 -2
View File
@@ -19,6 +19,7 @@ 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,
@@ -302,12 +303,12 @@ export function checkNodePtyPrecondition(
}
}
// Loaded. The remaining way terminals fail is spawn-time: node-pty posix_spawns
// 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 (abi.platform !== 'win32') {
if (usesNodePtySpawnHelper(abi.platform)) {
const helper = join(loadedDir || join(nodePtyDir, 'build', 'Release'), 'spawn-helper')
if (!isExecutableFile(helper)) {
return {
+3 -1
View File
@@ -1,6 +1,7 @@
import { basename, isAbsolute, join } from 'node:path'
import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs'
import type * as pty from 'node-pty'
import { usesNodePtySpawnHelper } from '../../shared/node-pty-spawn-helper'
import {
hostReportsChildExitStatus,
wrapShellSpawnForMacosTccAttribution
@@ -82,9 +83,10 @@ export function resolveUnixShellPath(shellPath: string): string {
* Why: when Electron packages the app via asar, the native spawn-helper
* binary may lose its +x permission. This function detects and repairs
* that so pty.spawn() does not fail with EACCES on first launch.
* macOS only — no other platform builds or execs the helper.
*/
export function ensureNodePtySpawnHelperExecutable(): void {
if (didEnsureSpawnHelperExecutable || process.platform === 'win32') {
if (didEnsureSpawnHelperExecutable || !usesNodePtySpawnHelper(process.platform)) {
return
}
didEnsureSpawnHelperExecutable = true
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { usesNodePtySpawnHelper } from './node-pty-spawn-helper'
describe('usesNodePtySpawnHelper', () => {
it('is macOS only', () => {
// The predicate this file exists for: node-pty's binding.gyp declares the
// spawn-helper target inside OS=="mac". Reading it as "every non-Windows platform"
// is what reported spawn_helper_missing on healthy Linux hosts (#17844).
expect(usesNodePtySpawnHelper('darwin')).toBe(true)
for (const platform of ['linux', 'win32', 'freebsd', 'openbsd', 'sunos', 'aix']) {
expect(usesNodePtySpawnHelper(platform)).toBe(false)
}
})
})
+11
View File
@@ -0,0 +1,11 @@
/**
* Whether node-pty execs its `spawn-helper` binary on a platform.
*
* Only macOS: binding.gyp declares the `spawn-helper` target inside `OS=="mac"`, and
* `src/unix/pty.cc` reads the helper path only under `#if defined(__APPLE__)`. Every
* other platform forks directly, so requiring the helper there calls a working host
* broken.
*/
export function usesNodePtySpawnHelper(platform: string): boolean {
return platform === 'darwin'
}