feat(orcad): build @parcel/watcher into the glibc 2.17 compat slot, so CentOS 7 runs managed orcad (#25199)

The compat target swapped in only node-pty, so it shipped the base target's upstream
watcher.node, which needs GLIBCXX_3.4.20; CentOS 7 stops at 3.4.19. orcad's preflight
refused it, and relay rung B lost file watching without saying so.

The compat slot now compiles @parcel/watcher from the package's own sources against the
pinned headers with the C++ runtime static, like node-pty. The slot gates (glibc 2.17
symbol floor, no shared libstdc++, N-API 8) and the smoke load cover it, the template
stages it into the compat target, and both orcad and relay rung B pick it up from there.

COMPAT_SLOT_ADDONS names a compat slot's own addons: a compat slot missing one fails
--require-slots, and a compat template target that would ship any native file without a
compat build fails the template build. The CentOS 7 cell expects an activated managed
server again, and every launched relay cell loads its watcher directly.

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-04 14:32:19 -07:00
committed by GitHub
co-authored by m4air
parent 2ddea8736e
commit 70948d597f
11 changed files with 250 additions and 62 deletions
+24 -37
View File
@@ -3,7 +3,8 @@
* 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
* node-pty is the only ABI-sensitive native module every slot builds; a compat slot also
* builds the addons in COMPAT_SLOT_ADDONS (orcad-prebuild-compat-addons.mjs). node-pty is 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
@@ -40,13 +41,14 @@ import {
highestGlibcNeed,
isCompatSlot,
mergeManifest,
prebuildCompileGypi,
readManifest,
sha256Of,
slotGlibcFloor,
slotSourceFiles,
SLOT_NAPI_VERSION
} from './orcad-prebuild-slot-contents.mjs'
import { compileCompatAddons } from './orcad-prebuild-compat-addons.mjs'
import { nodeGypRebuild, stageNodeAddonApi } from './orcad-prebuild-node-gyp.mjs'
import { ensurePinnedNodeExecutable, preparePinnedNodeDir } from './pinned-node-downloads.mjs'
export { readManifest }
@@ -203,44 +205,23 @@ async function compileNodePty(sourceDir, slot) {
)
const ptySourcePath = join(stagedDir, 'src', 'unix', 'pty.cc')
writeFileSync(ptySourcePath, ptySourceForLibc(readFileSync(ptySourcePath, 'utf8'), libc))
const addonApiDir = dirname(
require.resolve('node-addon-api/package.json', { paths: [sourceDir] })
)
cpSync(addonApiDir, join(stagedDir, 'node_modules', 'node-addon-api'), {
recursive: true,
dereference: true
})
stageNodeAddonApi(sourceDir, stagedDir)
if (process.platform === 'win32') {
require('./node-pty-job-ownership.cjs').assertNodePtySourceDeniesMsysBreakaway({
nodePtyDir: stagedDir
})
}
const compileGypi = join(workDir, 'prebuild-compile.gypi')
writeFileSync(compileGypi, prebuildCompileGypi({ staticCxxRuntime: isCompatSlot(slot) }))
const nodeDir = await preparePinnedNodeDir({ target: slot, workDir: join(workDir, 'nodedir') })
console.log(
`[orcad-prebuilds] compiling patched node-pty for ${slot} against Node ${NODE_RUNTIME_PIN.version} headers, N-API ${SLOT_NAPI_VERSION} ...`
)
const { runProcessSync } = await import('./script-child-process.mjs')
const result = runProcessSync({
program: process.execPath,
args: [
join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'),
'rebuild',
`--nodedir=${nodeDir}`,
'--',
'-I',
compileGypi
],
cwd: stagedDir,
stdio: 'inherit',
timeoutMs: null
const buildDir = await nodeGypRebuild({
stagedDir,
workDir,
nodeDir,
staticCxxRuntime: isCompatSlot(slot)
})
if (result.code !== 0) {
throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.code})`)
}
const buildDir = join(stagedDir, 'build', 'Release')
if (process.platform === 'win32') {
require('./node-pty-job-ownership.cjs').assertRebuiltConptyDeniesMsysBreakaway({
nodePtyDir: stagedDir,
@@ -248,7 +229,7 @@ async function compileNodePty(sourceDir, slot) {
crossHost: false
})
}
return buildDir
return { buildDir, nodeDir }
}
function requireSlots(slots) {
@@ -310,16 +291,22 @@ async function build() {
const slot = slotName()
assertCompatSlotHost(slot, { platform: process.platform, arch: process.arch, libc: detectLibc() })
const slotDir = join(PREBUILDS_DIR, slot)
const buildDir = await compileNodePty(sourceDir, slot)
const { buildDir, nodeDir } = await compileNodePty(sourceDir, slot)
const compatAddons = isCompatSlot(slot)
? await compileCompatAddons({ slot, workDir: join(WORK_DIR, slot), nodeDir })
: []
rmSync(slotDir, { recursive: true, force: true })
const files = {}
for (const [relative, source] of slotSourceFiles({
platform: process.platform,
arch: process.arch,
buildDir,
nodePtyDir: sourceDir
})) {
for (const [relative, source] of [
...slotSourceFiles({
platform: process.platform,
arch: process.arch,
buildDir,
nodePtyDir: sourceDir
}),
...compatAddons
]) {
if (!existsSync(source)) {
throw new Error(`[orcad-prebuilds] ${slot} needs ${relative}, but ${source} is missing`)
}
+20 -6
View File
@@ -29,7 +29,12 @@ import {
pinnedNodeRuntimeAsset
} from '../../src/shared/node-runtime-pin.ts'
import { ORCAD_PREBUILDS_DIR } from './build-orcad-prebuilds.mjs'
import { findSlotProblems, readManifest } from './orcad-prebuild-slot-contents.mjs'
import {
COMPAT_SLOT_ADDONS,
findCompatAddonGaps,
findSlotProblems,
readManifest
} from './orcad-prebuild-slot-contents.mjs'
import { runProcessSync } from './script-child-process.mjs'
import { verifyPackagedOrcadTemplate } from './verify-packaged-orcad-template.cjs'
@@ -121,8 +126,8 @@ export function requestedTemplateTargets(argv = process.argv) {
}
/**
* A compat target (design D6 rung B) is its base target's package with the compat node-pty
* slot and runtime marker swapped in; everything else is target-independent or libc-static.
* A compat target (design D6 rung B) is its base target's package with the compat slot's addons
* and runtime marker swapped in; everything else is target-independent or libc-static.
* Omitted, not failed, when this build has no compat slot: rung B then refuses as unavailable.
*/
function stageCompatTarget(compat, basePackageDir) {
@@ -136,12 +141,21 @@ function stageCompatTarget(compat, basePackageDir) {
return null
}
const destination = join(outputDir, ORCAD_TEMPLATE_TARGETS_DIR, compat)
const slotFiles = new Map(
orcadNodePtySlotFiles(compat).map((file) => [
const slotFiles = new Map([
...orcadNodePtySlotFiles(compat).map((file) => [
`${ORCAD_NODE_PTY_DIR}/build/Release/${file}`,
join(ORCAD_PREBUILDS_DIR, compat, ...file.split('/'))
]),
...Object.entries(COMPAT_SLOT_ADDONS).map(([file, shipped]) => [
shipped,
join(ORCAD_PREBUILDS_DIR, compat, ...file.split('/'))
])
)
])
// Why fatal: the base binary would pass every check here and fail only on a compat host.
const gaps = findCompatAddonGaps(orcadTemplateTargetFilenames(compat), slotFiles)
if (gaps.length > 0) {
throw new Error(`compat target ${compat} would ship base-target addons: ${gaps.join(', ')}`)
}
const files = {}
for (const filename of orcadTemplateTargetFilenames(compat)) {
const staged = join(destination, ...filename.split('/'))
+17 -6
View File
@@ -1,9 +1,15 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { mergeOrcadPrebuildTrees } from './merge-orcad-prebuilds.mjs'
import { findSlotProblems, mergeManifest, sha256Of } from './orcad-prebuild-slot-contents.mjs'
import {
COMPAT_SLOT_ADDONS,
findSlotProblems,
isCompatSlot,
mergeManifest,
sha256Of
} from './orcad-prebuild-slot-contents.mjs'
const dirs = []
function temp() {
@@ -20,15 +26,20 @@ afterEach(() => {
/** One CI lane's `out/orcad-prebuilds`: a single slot plus its manifest. */
function laneTree(slot, { version = '1.1.0', nodeHeaders = '24.21.0', bytes = slot } = {}) {
const dir = temp()
mkdirSync(join(dir, slot), { recursive: true })
const binary = join(dir, slot, 'pty.node')
writeFileSync(binary, bytes)
const files = {}
// A compat slot also carries its own addons.
for (const file of ['pty.node', ...(isCompatSlot(slot) ? Object.keys(COMPAT_SLOT_ADDONS) : [])]) {
const binary = join(dir, slot, ...file.split('/'))
mkdirSync(dirname(binary), { recursive: true })
writeFileSync(binary, bytes)
files[file] = sha256Of(binary)
}
const manifest = mergeManifest(null, {
slot,
version,
napi: 8,
nodeHeaders,
entry: { napi: 8, files: { 'pty.node': sha256Of(binary) } }
entry: { napi: 8, files }
})
writeFileSync(join(dir, 'manifest.json'), JSON.stringify(manifest))
return dir
@@ -0,0 +1,52 @@
/**
* The addons a compat slot builds beside node-pty (design D6 rung B). The default slots take
* @parcel/watcher's upstream prebuild, which needs a newer libstdc++ than a glibc 2.17 host has,
* so the compat slot compiles it from the package's own sources with the C++ runtime static.
*/
import { cpSync, mkdirSync, rmSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts'
import { nodeGypRebuild, stageNodeAddonApi } from './orcad-prebuild-node-gyp.mjs'
import { COMPAT_SLOT_ADDONS, SLOT_NAPI_VERSION } from './orcad-prebuild-slot-contents.mjs'
const require = createRequire(import.meta.url)
const BUILDERS = {
'parcel-watcher/watcher.node': compileParcelWatcher
}
/** `[slot-relative path, built file]` for every compat addon, compiled under `workDir`. */
export async function compileCompatAddons({ slot, workDir, nodeDir }) {
const built = []
for (const relative of Object.keys(COMPAT_SLOT_ADDONS)) {
const builder = BUILDERS[relative]
if (!builder) {
throw new Error(`[orcad-prebuilds] no builder for compat addon ${relative}`)
}
built.push([relative, await builder({ slot, workDir, nodeDir })])
}
return built
}
async function compileParcelWatcher({ slot, workDir, nodeDir }) {
const sourceDir = dirname(require.resolve('@parcel/watcher/package.json'))
const addonWorkDir = join(workDir, 'parcel-watcher')
const stagedDir = join(addonWorkDir, 'watcher')
rmSync(addonWorkDir, { recursive: true, force: true })
mkdirSync(stagedDir, { recursive: true })
for (const entry of ['package.json', 'binding.gyp', 'src']) {
cpSync(join(sourceDir, entry), join(stagedDir, entry), { recursive: true })
}
stageNodeAddonApi(sourceDir, stagedDir)
console.log(
`[orcad-prebuilds] compiling @parcel/watcher for ${slot} against Node ${NODE_RUNTIME_PIN.version} headers, N-API ${SLOT_NAPI_VERSION} ...`
)
const buildDir = await nodeGypRebuild({
stagedDir,
workDir: addonWorkDir,
nodeDir,
staticCxxRuntime: true
})
return join(buildDir, 'watcher.node')
}
@@ -0,0 +1,44 @@
// node-gyp rebuild of one staged addon against the pinned Node headers, shared by every slot addon.
import { cpSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import process from 'node:process'
import { prebuildCompileGypi } from './orcad-prebuild-slot-contents.mjs'
const require = createRequire(import.meta.url)
const ROOT = join(import.meta.dirname, '..', '..')
/** Copies node-addon-api beside a staged addon, since scratch copies leave the pnpm tree behind. */
export function stageNodeAddonApi(sourceDir, stagedDir) {
const addonApiDir = dirname(
require.resolve('node-addon-api/package.json', { paths: [sourceDir] })
)
cpSync(addonApiDir, join(stagedDir, 'node_modules', 'node-addon-api'), {
recursive: true,
dereference: true
})
}
export async function nodeGypRebuild({ stagedDir, workDir, nodeDir, staticCxxRuntime }) {
const compileGypi = join(workDir, 'prebuild-compile.gypi')
writeFileSync(compileGypi, prebuildCompileGypi({ staticCxxRuntime }))
const { runProcessSync } = await import('./script-child-process.mjs')
const result = runProcessSync({
program: process.execPath,
args: [
join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'),
'rebuild',
`--nodedir=${nodeDir}`,
'--',
'-I',
compileGypi
],
cwd: stagedDir,
stdio: 'inherit',
timeoutMs: null
})
if (result.code !== 0) {
throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.code})`)
}
return join(stagedDir, 'build', 'Release')
}
@@ -1,5 +1,6 @@
/**
* What goes into one orcad node-pty prebuild slot, and the manifest that records it.
* What goes into one orcad node-pty prebuild slot (plus a compat slot's own addons), and the
* manifest that records it.
*
* The manifest is the loader's contract (src/main/orcad/node-pty-prebuilt-slot.ts): per-slot
* N-API level, libc, the highest glibc symbol version the binaries need, and a sha256 per
@@ -33,6 +34,23 @@ export const COMPAT_SLOTS = Object.freeze({
'linux-x64-glibc217': Object.freeze({ platform: 'linux', arch: 'x64', libc: 'glibc' })
})
/**
* Native addons a compat slot builds beside node-pty, by slot-relative path, mapped to where an
* orcad slot ships them. A compat target ships no native file without a compat build.
*/
export const COMPAT_SLOT_ADDONS = Object.freeze({
'parcel-watcher/watcher.node': 'node_modules/@parcel/watcher/watcher.node'
})
/**
* The native files of a compat target with no compat build behind them: each would be the base
* target's binary, built for a newer glibc and libstdc++ than the compat host has.
* `compatSources` maps orcad slot paths to the compat slot files that fill them.
*/
export function findCompatAddonGaps(targetFilenames, compatSources) {
return targetFilenames.filter((file) => file.endsWith('.node') && !compatSources.has(file))
}
export function isCompatSlot(slot) {
return Object.hasOwn(COMPAT_SLOTS, slot)
}
@@ -217,6 +235,13 @@ export function findSlotProblems(manifest, prebuildsDir, requiredSlots) {
problems.push(`${slot}: not built`)
continue
}
if (isCompatSlot(slot)) {
for (const addon of Object.keys(COMPAT_SLOT_ADDONS)) {
if (!Object.hasOwn(entry.files ?? {}, addon)) {
problems.push(`${slot}/${addon}: not built`)
}
}
}
for (const [file, expected] of Object.entries(entry.files ?? {})) {
const path = join(prebuildsDir, slot, ...file.split('/'))
if (!existsSync(path)) {
@@ -5,7 +5,9 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
assertCompatSlotHost,
COMPAT_SLOT_ADDONS,
COMPAT_SLOTS,
findCompatAddonGaps,
findPostBaselineNodeApiNames,
findSharedCxxRuntimeNeeds,
findSlotProblems,
@@ -19,7 +21,10 @@ import {
SLOT_NAPI_VERSION,
windowsConptyRuntimeDir
} from './orcad-prebuild-slot-contents.mjs'
import { ORCAD_ADDON_NAPI_VERSION } from '../../src/shared/orcad-artifacts.ts'
import {
ORCAD_ADDON_NAPI_VERSION,
orcadTemplateTargetFilenames
} from '../../src/shared/orcad-artifacts.ts'
const floors = createRequire(import.meta.url)('./verify-linux-glibc-floor.cjs')
const dirs = []
@@ -232,4 +237,37 @@ describe('findSlotProblems', () => {
'manifest.json is missing or not schema 2'
])
})
it('refuses a compat slot missing one of its own addons', () => {
const dir = temp()
const slot = 'linux-x64-glibc217'
mkdirSync(join(dir, slot))
writeFileSync(join(dir, slot, 'pty.node'), 'binary')
const manifest = mergeManifest(
null,
next(slot, { entry: entry({ 'pty.node': sha256Of(join(dir, slot, 'pty.node')) }) })
)
expect(findSlotProblems(manifest, dir, [slot])).toEqual([
`${slot}/parcel-watcher/watcher.node: not built`
])
})
})
describe('compat addon coverage', () => {
const nodePtySources = (target) =>
orcadTemplateTargetFilenames(target).filter((file) => file.includes('node-pty/build/Release/'))
it('gives every native addon a compat target ships a compat build', () => {
for (const compat of Object.keys(COMPAT_SLOTS)) {
const sources = new Set([...nodePtySources(compat), ...Object.values(COMPAT_SLOT_ADDONS)])
expect(findCompatAddonGaps(orcadTemplateTargetFilenames(compat), sources)).toEqual([])
}
})
it('names a native file that would ship as the base target build', () => {
const target = 'linux-x64-glibc217'
expect(
findCompatAddonGaps(orcadTemplateTargetFilenames(target), new Set(nodePtySources(target)))
).toEqual(['node_modules/@parcel/watcher/watcher.node'])
})
})
@@ -2,7 +2,7 @@
const { join } = require('node:path')
const { tmpdir } = require('node:os')
const [nodePtyDir, expectedVersion] = process.argv.slice(2)
const [nodePtyDir, expectedVersion, ...addons] = process.argv.slice(2)
if (!nodePtyDir || !expectedVersion) {
throw new Error('usage: orcad-prebuild-smoke-child.cjs <node-pty dir> <expected node version>')
}
@@ -11,6 +11,10 @@ if (process.version !== `v${expectedVersion}`) {
}
const pty = require(nodePtyDir)
// A compat slot's own addons: loading proves their glibc and C++ runtime needs resolve here.
for (const addon of addons) {
require(addon)
}
if (process.platform === 'win32') {
// Loaded only by the non-DLL kill path; prove the shipped module still loads under this Node.
const { loadNativeModule } = require(join(nodePtyDir, 'lib', 'utils'))
+17 -2
View File
@@ -3,7 +3,12 @@ import { chmodSync, cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts'
import { findSlotProblems, readManifest } from './orcad-prebuild-slot-contents.mjs'
import {
COMPAT_SLOT_ADDONS,
findSlotProblems,
isCompatSlot,
readManifest
} from './orcad-prebuild-slot-contents.mjs'
import { ensurePinnedNodeExecutable } from './pinned-node-downloads.mjs'
import { runProcessSync } from './script-child-process.mjs'
@@ -28,6 +33,15 @@ export function stageSmokeNodePty({ slotDir, stageDir }) {
return nodePtyDir
}
/** stageSmokeNodePty copies the whole slot into build/Release, compat addons included. */
function compatAddonPaths(slot, nodePtyDir) {
return isCompatSlot(slot)
? Object.keys(COMPAT_SLOT_ADDONS).map((file) =>
join(nodePtyDir, 'build', 'Release', ...file.split('/'))
)
: []
}
export async function runOrcadPrebuildSmoke({ slot, prebuildsDir }) {
const problems = findSlotProblems(readManifest(prebuildsDir), prebuildsDir, [slot])
if (problems.length > 0) {
@@ -43,7 +57,8 @@ export async function runOrcadPrebuildSmoke({ slot, prebuildsDir }) {
args: [
join(import.meta.dirname, 'orcad-prebuild-smoke-child.cjs'),
nodePtyDir,
NODE_RUNTIME_PIN.version
NODE_RUNTIME_PIN.version,
...compatAddonPaths(slot, nodePtyDir)
],
timeoutMs: 60_000
})
+2 -8
View File
@@ -185,14 +185,8 @@ export const HOSTILE_HOST_CELLS: readonly HostileHostCell[] = [
runtime: 'linux-x64-glibc217',
refusals: [{ step: 'A', reason: 'libc_floor' }]
},
// Managed orcad picks the same compat runtime, but the template's @parcel/watcher needs a newer
// libstdc++ than CentOS 7 ships, so its preflight refuses and the host keeps relay rung B.
managed: {
outcome: 'refused',
runtime: 'linux-x64-glibc217',
code: 'orcad_candidate_preflight_failed',
relayRung: 'B'
}
// Managed orcad runs on the same compat runtime and slot, so an empty CentOS 7 host is managed.
managed: { outcome: 'activated', runtime: 'linux-x64-glibc217' }
},
{
// The client uploads the runtime over SSH, so a host that cannot reach nodejs.org still runs A.
@@ -15,6 +15,7 @@ vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } }))
import { posix } from 'node:path'
import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin'
import { ORCAD_PARCEL_WATCHER_NATIVE } from '../../shared/orcad-artifacts'
import type { SshConnection } from './ssh-connection'
import { HOSTILE_HOST_CELLS, selectHostileHostCells } from './ssh-hostile-host-cells'
import { proveManagedOrcadCell } from './ssh-hostile-host-managed-orcad'
@@ -105,6 +106,9 @@ describe('SSH relay hostile-host matrix', () => {
if (target.kind === 'local-sshd' && target.cell.runsOn.platform === 'darwin') {
await assertRunsWithoutQuarantine(target, launched.nodePath)
}
// The relay runs on without its watcher, so only a direct load proves the slot's build fits.
const watcher = `${first.deployed?.remoteRelayDir}/${ORCAD_PARCEL_WATCHER_NATIVE}`
await hostExec(target, `'${launched.nodePath}' -e "require('${watcher}')"`)
} else if (
cell.expect.outcome !== 'legacy_opt_out' &&
cell.expect.refusals[0]?.reason === 'libc_floor'