Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 212 additions & 0 deletions config/scripts/build-orcad-prebuilds.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
#!/usr/bin/env node
/**
* Build one node-pty prebuilt for the CURRENT platform/arch/libc and file it in orcad's
* prebuilds matrix, so a deployment target needs no C/C++ toolchain.
*
* node-pty is the only ABI-sensitive native module orcad requires. It is also PATCHED in
* this repo (config/patches/node-pty@1.1.0.patch), and that patch is the glibc-floor fix:
* `.symver` pins on openpty/forkpty/pthread_sigmask plus the `--no-as-needed` ldflags that
* keep libutil/libpthread in DT_NEEDED. An upstream prebuilt has none of it and reproduces
* #9902. So the matrix is compiled from patched sources here, and this script refuses to
* run if the patch is not in the tree it is about to compile.
*
* orcad pins its own Node runtime, so the ABI dimension is fixed and the matrix varies
* only platform/arch/libc:
* linux-x64-glibc, linux-arm64-glibc, linux-x64-musl, linux-arm64-musl,
* darwin-x64, darwin-arm64
*
* CI runs this once per slot, each inside the container that owns that libc/arch, and
* merges the resulting `out/orcad/prebuilds` trees. `--slot=<name>` forces the label so
* the glibc/musl distinction is recorded from the container rather than detected.
*
* Usage:
* node config/scripts/build-orcad-prebuilds.mjs [--slot=linux-x64-musl]
* node config/scripts/build-orcad-prebuilds.mjs --require-slots # release gate
*/
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import process from 'node:process'
import { spawnSync } from 'node:child_process'

const require = createRequire(import.meta.url)
const ROOT = join(import.meta.dirname, '..', '..')
const PREBUILDS_DIR = join(ROOT, 'out', 'orcad', 'prebuilds')

/** Every slot a shipped matrix must fill. The single source of truth for the matrix. */
export const MATRIX_SLOTS = [
'linux-x64-glibc',
'linux-arm64-glibc',
'linux-x64-musl',
'linux-arm64-musl',
'darwin-x64',
'darwin-arm64'
]
Comment on lines +37 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

for target in .github docs README.md package.json; do
  if [ -e "$target" ]; then
    rg -n -i -C 3 'orcad.*windows|windows.*orcad|win32|supported platforms|build-orcad-prebuilds|require-slots' "$target"
  fi
done

Repository: stablyai/orca

Length of output: 2629


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="config/scripts/build-orcad-prebuilds.mjs"
printf '%s\n' '--- file outline ---'
ast-grep outline "$file" || true
printf '%s\n' '--- target file ---'
cat -n "$file"
printf '%s\n' '--- OrcaD and slot references ---'
rg -n -i -C 3 'OrcaD|orcad|MATRIX_SLOTS|slotName|require-slots|prebuild|native-host-abi|win32-(x64|arm64)|linux-(x64|arm64)|darwin-(x64|arm64)' \
  --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' .
printf '%s\n' '--- platform/build workflow references ---'
rg -n -i -C 3 'platform.?win32|win32.*(artifact|slot|native|orcad)|orcad.*win32|windows.*(artifact|slot|native|orcad)' \
  .github config src native package.json pnpm-lock.yaml 2>/dev/null || true

Repository: stablyai/orca

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- relevant source files ---'
git ls-files | rg -i 'orca(d|.*native|.*abi)|prebuild|manifest|platform|install' | head -200
printf '%s\n' '--- package and documentation platform claims ---'
rg -n -i -C 4 'windows|win32|supported platform|platform support|OrcaD|orcad' \
  README.md docs package.json config src native .github 2>/dev/null | head -500

Repository: stablyai/orca

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for file in \
  config/scripts/build-orcad-prebuilds.mjs \
  config/scripts/build-orcad-prebuilds.test.mjs \
  config/scripts/build-orcad.mjs \
  src/main/orcad/node-pty-prebuilt-slot.ts \
  src/main/orcad/native-host-abi.ts \
  src/main/orcad/orcad-native-preflight.ts
do
  printf '\n--- %s ---\n' "$file"
  wc -l "$file"
  sed -n '1,260p' "$file"
done
printf '\n--- focused references ---\n'
rg -n -C 5 'MATRIX_SLOTS|slotName\\(|require.*slot|prebuilt.*slot|node-pty-prebuilt|orcad-prebuilds' \
  config/scripts src/main/orcad .github/workflows package.json \
  -g '!*.map' -g '!*.patch' -g '!*.jsonl' -g '!*.bundle.*'

Repository: stablyai/orca

Length of output: 34605


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- build-orcad-prebuilds.mjs ---'
cat -n config/scripts/build-orcad-prebuilds.mjs
printf '%s\n' '--- build-orcad-prebuilds.test.mjs ---'
cat -n config/scripts/build-orcad-prebuilds.test.mjs
printf '%s\n' '--- platform slot consumers ---'
rg -n -C 5 'MATRIX_SLOTS|slotName|node-pty-prebuilt-slot|require.*slot|prebuilt.*slot|orcad-prebuilds' \
  config/scripts src/main/orcad .github/workflows package.json \
  -g '!*.map' -g '!*.patch'

Repository: stablyai/orca

Length of output: 39373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
for name in [
    "config/scripts/build-orcad-prebuilds.mjs",
    "config/scripts/build-orcad-prebuilds.test.mjs",
    "config/scripts/build-orcad.mjs",
    "src/main/orcad/node-pty-prebuilt-slot.ts",
    "src/main/orcad/native-host-abi.ts",
    "src/main/orcad/orcad-native-preflight.ts",
]:
    p = Path(name)
    print(f"\n--- {name} ---")
    lines = p.read_text().splitlines()
    needles = ("MATRIX_SLOTS", "slotName", "win32", "darwin", "linux",
               "node-pty", "prebuilt", "require", "manifest", "libc",
               "platform", "arch", "OrcaD", "orcad")
    for i, line in enumerate(lines, 1):
        if any(n.lower() in line.lower() for n in needles):
            lo, hi = max(1, i-2), min(len(lines), i+4)
            print("\n".join(f"{j:4}: {lines[j-1]}" for j in range(lo, hi+1)))
PY

Repository: stablyai/orca

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,240p' config/scripts/build-orcad-prebuilds.mjs
printf '\n--- tests: matrix and platform cases ---\n'
rg -n -C 12 'MATRIX_SLOTS|slotName|win32|darwin|linux|manifest|platform|arch' \
  config/scripts/build-orcad-prebuilds.test.mjs
printf '\n--- consumers of the generated manifest ---\n'
rg -n -C 8 'MATRIX_SLOTS|prebuild.*manifest|node-pty-prebuilt|slotName|platform.*arch|win32' \
  src/main/orcad config/scripts \
  -g '*.ts' -g '*.mjs' -g '*.test.ts' -g '*.test.mjs' \
  --glob '!*.map'

Repository: stablyai/orca

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
import re
from pathlib import Path

source = Path("config/scripts/build-orcad-prebuilds.mjs").read_text()

matrix_match = re.search(
    r"export const MATRIX_SLOTS = \[(.*?)\]",
    source,
    re.S,
)
assert matrix_match, "MATRIX_SLOTS declaration not found"
matrix = re.findall(r"'([^']+)'", matrix_match.group(1))

# Model the pure slotName logic from the source without importing or executing repository code.
platform = "win32"
arch = "x64"
libc = "none" if platform != "linux" else "musl"
slot = f"{platform}-{arch}" if libc == "none" else f"{platform}-{arch}-{libc}"

print("computed slot:", slot)
print("declared matrix:", matrix)
print("slot declared:", slot in matrix)
assert slot == "win32-x64"
assert slot not in matrix
PY

Repository: stablyai/orca

Length of output: 323


Add Windows slots or reject Windows explicitly.

slotName() returns win32-x64, but MATRIX_SLOTS omits it. requireSlots() can report a complete matrix while Windows has no matching prebuild. Add the supported Windows slots or reject Windows before building.


/**
* 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'
}
Comment on lines +50 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the runtime fallback for an unreadable report.

If readReportHeader() fails, this files a glibc build as musl, but detectNativeHostAbi() selects glibc; the runtime then misses the built slot.

Proposed fix
 export function detectLibc(platform = process.platform, header = readReportHeader()) {
   if (platform !== 'linux') {
     return 'none'
   }
-  return header && typeof header === 'object' && 'glibcVersionRuntime' in header ? 'glibc' : 'musl'
+  if (!header || typeof header !== 'object') {
+    return 'glibc'
+  }
+  return 'glibcVersionRuntime' in header ? 'glibc' : 'musl'
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function detectLibc(platform = process.platform, header = readReportHeader()) {
if (platform !== 'linux') {
return 'none'
}
return header && typeof header === 'object' && 'glibcVersionRuntime' in header ? 'glibc' : 'musl'
}
export function detectLibc(platform = process.platform, header = readReportHeader()) {
if (platform !== 'linux') {
return 'none'
}
if (!header || typeof header !== 'object') {
return 'glibc'
}
return '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()
}
Comment on lines +114 to +121

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

for target in .github config package.json; do
  if [ -e "$target" ]; then
    rg -n -C 3 'build-orcad-prebuilds|build:orcad-prebuilds|--slot=|--require-slots|node-version|setup-node|out/orcad/prebuilds' "$target"
  fi
done

Repository: stablyai/orca

Length of output: 39350


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- outline ---'
ast-grep outline config/scripts/build-orcad-prebuilds.mjs
printf '%s\n' '--- implementation ---'
sed -n '1,230p' config/scripts/build-orcad-prebuilds.mjs
printf '%s\n' '--- tests ---'
sed -n '1,180p' config/scripts/build-orcad-prebuilds.test.mjs
printf '%s\n' '--- references ---'
rg -n -C 5 'orcad-prebuilds|MATRIX_SLOTS|require-slots|manifest\.json|nodeAbi|node-pty' .github config package.json
printf '%s\n' '--- behavioral probe ---'
python3 - <<'PY'
from pathlib import Path
import re

source = Path("config/scripts/build-orcad-prebuilds.mjs").read_text()
merge = re.search(
    r"export function mergeManifest\(existing, next\) \{(?P<body>.*?)\n\}",
    source,
    re.S,
)
assert merge, "mergeManifest was not found"
body = merge.group("body")
print("mergeManifest body:")
print(body)

# Evaluate the exact data transformation represented by the function.
existing = {
    "module": "node-pty",
    "version": "1.1.0",
    "nodeAbi": "old-abi",
    "slots": ["linux-x64-glibc"],
}
next_manifest = {
    "slot": "linux-x64-musl",
    "version": "1.1.0",
    "nodeAbi": "new-abi",
}
slots = sorted(set([*existing["slots"], next_manifest["slot"]]))
merged = {
    "module": "node-pty",
    "version": next_manifest["version"],
    "nodeAbi": next_manifest["nodeAbi"],
    "slots": slots,
}
print("mixed-ABI input:", existing, next_manifest)
print("merged output:", merged)
assert merged["slots"] == ["linux-x64-glibc", "linux-x64-musl"]
assert merged["nodeAbi"] == "new-abi"

# Inspect whether requireSlots compares ABI metadata or only required slot names.
gate_start = source.index("export function requireSlots")
gate_end = source.index("\nif (process.argv[1]", gate_start)
gate = source[gate_start:gate_end]
print("requireSlots implementation:")
print(gate)
print("ABI check present:", "nodeAbi" in gate)
print("slot-membership check present:", "required.every" in gate or ".every(" in gate)
PY

Repository: stablyai/orca

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- tracked candidate files ---'
git ls-files | rg '(^|/)(build-orcad-prebuilds|.*orcad.*prebuild|.*prebuild.*orcad|.*release.*|.*build.*)\.(mjs|cjs|yml|yaml|json)$' | head -200

printf '%s\n' '--- exact matrix/build/manifest references ---'
rg -n -C 3 --glob '*.mjs' --glob '*.cjs' --glob '*.yml' --glob '*.yaml' --glob '*.json' \
  'build-orcad-prebuilds|MATRIX_SLOTS|require-slots|out/orcad/prebuilds|prebuilds/manifest\.json|manifest\.nodeAbi|nodeAbi' \
  .github config package.json 2>/dev/null | head -400

printf '%s\n' '--- output cleanup and artifact movement ---'
rg -n -C 3 --glob '*.mjs' --glob '*.cjs' --glob '*.yml' --glob '*.yaml' --glob '*.json' \
  'rmSync|rm -rf|remove.*out|clean.*out|upload-artifact|download-artifact|orcad/prebuilds|prebuilds' \
  .github config package.json 2>/dev/null | head -500

printf '%s\n' '--- standalone invariant probe ---'
python3 - <<'PY'
from pathlib import Path
import re

path = Path("config/scripts/build-orcad-prebuilds.mjs")
source = path.read_text()

merge_match = re.search(
    r"export function mergeManifest\(existing, next\) \{(?P<body>.*?)\n\}",
    source,
    re.S,
)
if not merge_match:
    raise SystemExit("mergeManifest not found")
merge_body = merge_match.group("body")
print("mergeManifest assigns existing nodeAbi:", "existing.nodeAbi" in merge_body)
print("mergeManifest assigns next nodeAbi:", "next.nodeAbi" in merge_body)
print("mergeManifest unions existing slots:", "existing?.slots" in merge_body)

gate_match = re.search(
    r"function requireSlots\(\) \{(?P<body>.*?)\n\}",
    source,
    re.S,
)
if not gate_match:
    raise SystemExit("requireSlots not found")
gate_body = gate_match.group("body")
print("requireSlots reads manifest.nodeAbi:", "manifest?.nodeAbi" in gate_body or "manifest.nodeAbi" in gate_body)
print("requireSlots reads per-slot metadata:", "slot" in gate_body and "nodeAbi" in gate_body)
print("requireSlots checks required slot membership:", "MATRIX_SLOTS.filter" in gate_body)

# Model the exact transformation for a partial rebuild after an ABI change.
existing = {
    "module": "node-pty",
    "version": "1.1.0",
    "nodeAbi": "old-abi",
    "slots": ["linux-x64-glibc"],
}
next_manifest = {
    "slot": "linux-x64-musl",
    "version": "1.1.0",
    "nodeAbi": "new-abi",
}
merged_slots = sorted(set([*existing["slots"], next_manifest["slot"]]))
merged = {
    "module": "node-pty",
    "version": next_manifest["version"],
    "nodeAbi": next_manifest["nodeAbi"],
    "slots": merged_slots,
}
print("modeled merged manifest:", merged)
print("old slot retained:", "linux-x64-glibc" in merged["slots"])
print("old ABI replaced globally:", merged["nodeAbi"] == "new-abi")
print("all required slots could pass membership gate:",
      all(slot in merged["slots"] for slot in merged["slots"]))
PY

Repository: stablyai/orca

Length of output: 49482


Reject mixed Node ABI manifests.

mergeManifest() retains old slots but replaces the shared nodeAbi. requireSlots() checks only slot names, so a partial ABI update can accept stale pty.node files as compatible.

}

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()
}
}
121 changes: 121 additions & 0 deletions config/scripts/build-orcad-prebuilds.test.mjs
Original file line number Diff line number Diff line change
@@ -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()
})
})
26 changes: 26 additions & 0 deletions docs/reference/linux-glibc-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,32 @@ libstdc++ floor — its glibc needs are still checked. Speech-to-text therefore
needs a host with libstdc++ from GCC 11+ (Ubuntu 21.10 / 22.04 LTS or newer); the
app itself still launches on stock 20.04.

**3. Check before loading, on hosts that ship without a compiler (`orcad`).**
The two gates above protect the packaged desktop app, where the binary is built and
verified by the same pipeline. `orcad` is deployed to hosts Orca never built on, so it
adds a runtime precondition
([`src/main/orcad/node-pty-precondition.ts`](../../src/main/orcad/node-pty-precondition.ts)),
run from `main.ts` before anything requires `node-pty`. It loads the addon in a **child
process**, so a binary the loader refuses — or one that aborts outright — is data rather
than this process's death, and the operator gets a sentence naming the host's libc, its
Node ABI, its prebuild slot and the command to run. A proven-unloadable binary exits 78
(`EX_CONFIG`) instead of reaching the `require`; a probe that never answered is reported
as unverifiable and boots anyway, because a silent probe is not evidence. Whatever it
finds is published in `status.get`'s `degradations[]` under `terminal_unavailable`.

**4. Ship the binary, built from patched sources.**
[`config/scripts/build-orcad-prebuilds.mjs`](../../config/scripts/build-orcad-prebuilds.mjs)
(`pnpm run build:orcad-prebuilds`, after `build:orcad`) compiles node-pty for the current
host and files it under `out/orcad/prebuilds/<slot>/`, where a slot is
`linux-{x64,arm64}-{glibc,musl}` or `darwin-{x64,arm64}`. libc is part of the slot name
because node-pty's own loader falls back to `prebuilds/<platform>-<arch>` and cannot tell
glibc from musl — a glibc binary parked there is loaded on Alpine and dies at `dlopen`.
The script refuses to compile a tree where `config/patches/node-pty@1.1.0.patch` is not
applied: without the patch the prebuilt is a #9902 crash shipped as an artifact rather
than a first-connect error. CI runs it once per slot inside the matching container
(`--slot=` forces the label), merges the trees, and `--require-slots` fails a release with
a hole in the matrix.

## Adding or upgrading a native dependency

- Prefer packages that ship prebuilt binaries compiled against an old toolchain
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading