Files
orca/config/scripts/build-native-for-platform.mjs
T
81c3d188a4 build(macos): parallelize native helpers with complete cancellation (#19651)
* build(macos): run native module builds concurrently

* fix(build): terminate sibling native builds when one fails

Address coderabbit review: concurrent builds kept writing native
artifacts after a sibling reported failure. Track spawned children,
kill remaining siblings on first nonzero exit, and forward SIGINT/
SIGTERM to all children.

* fix(build): process-group teardown and prefixed output for parallel native builds

Address second coderabbit round:
- Detached process groups + negative-pid kill so SIGTERM reaches swift/
  swiftc descendants, not just the direct pnpm child (they could keep
  writing artifacts after fail-fast)
- Signal handlers preserve the received signal (SIGINT no longer becomes
  SIGTERM for children) and are removed before re-raising, so the parent
  actually dies instead of looping through terminateAll
- runPnpmScript settles only on close, never on error alone, so
  Promise.all cannot exit while children are still running
- Per-module output prefixes ([computer]/[keyboard-layout]/[notification-
  status]) match what the PR description always claimed; interleaved
  swiftc errors are now attributable
- Windows path untouched (early return before any of this runs)

execa/p-limit were considered and rejected: no new runtime deps for a
build script, and detached process groups give strictly stronger cleanup
than execa's direct-child kill.

* fix(build): memoized handler removal and external-vs-sibling signal split

Second-round coderabbit findings on 24392a0:
- Registration now uses the memoized handlerFor() instances so
  removeListener actually removes them (inline arrows were never
  registered, so the parent looped through terminateAll and hung)
- externalSignal is set only by the parent's own signal handlers; a
  sibling's fail-fast SIGTERM no longer masquerades as an external
  signal, so settle() resolves Promise.all with the failing module's
  exit code instead of leaving top-level await unsettled (exit 13)
- Also fixes a TDZ crash: handlerFor() was invoked at registration time
  before the signalHandlers const initialized

Verified: sibling fail-fast resolves failer=7 with no survivors;
external SIGINT kills children then the parent exits 130; real
concurrent macOS build green.

* Wait for native build cancellation before exiting

* Clean up native builds when output streams fail

* fix: bound native build waits, forward SIGHUP, honour output backpressure

- Bound the per-child close wait: two seconds after a child exits, reap
  its process group and destroy its pipes so a descendant that inherited
  stdout/stderr cannot hang `pnpm build:native` forever.
- Handle SIGHUP alongside SIGINT/SIGTERM so a terminal hangup reaches the
  detached compiler sessions instead of orphaning them.
- Pause a compiler's output stream when the launcher's stdout/stderr
  reports backpressure and resume on drain, so prefixed output no longer
  buffers without bound.
- Run build-native-for-platform.test.mjs in the computer-e2e
  mac-native-owner-smoke PR job and trigger that workflow on launcher
  changes; the tests are darwin-only and no other PR job runs on macOS.
- Report the first failing child's status: re-raise its signal, or use
  its exit code instead of Math.max over cancelled siblings.

* fix(native-build): keep output when reap timer overlaps backpressure; fail on ignored re-raised signal

The descendant reap timer started on every child 'exit' and fired even when
'close' was late only because the launcher paused the pipe for its own stdout
backpressure, destroying pipes with compiler output still queued. Arm the
countdown only while the pipes are actually draining: clear it on 'pause' and
re-arm on 'resume' after exit. Write the reap notice to stderr since stdout
is the stream that may be blocked.

Re-raising a child's fatal signal is a no-op when Node ignores it (SIGPIPE),
so set a non-zero exit code first; a failed build no longer exits 0.

Tests: stall the launcher's stdout consumer past the reap timeout and assert
every kernel-accepted compiler line still arrives; kill the computer build
with SIGPIPE and assert the launcher exits 1.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-12 21:18:40 -07:00

205 lines
5.6 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { spawn, spawnSync } from 'node:child_process'
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
if (process.platform === 'win32') {
runNodeScript('config/scripts/build-windows-cli-launcher.mjs')
process.exit(0)
}
if (process.platform !== 'darwin') {
console.log(`[native-build] no macOS native computer build required on ${process.platform}`)
process.exit(0)
}
// Each compiler tree needs its own group so cancellation reaches Swift descendants.
const children = new Map()
let externalSignal = null
let stopping = false
let outputFailed = false
// Status of the child whose failure started cancellation; siblings we stop are not failures.
let firstFailure = null
let forceTimer
const signalHandlers = new Map()
process.on('SIGINT', handlerFor('SIGINT'))
process.on('SIGTERM', handlerFor('SIGTERM'))
// Own sessions do not see a terminal hangup; forward it so compilers do not outlive the shell.
process.on('SIGHUP', handlerFor('SIGHUP'))
for (const target of [process.stdout, process.stderr]) {
target.on('error', () => {
outputFailed = true
process.exitCode = 1
stopBuilds()
})
}
const exitCodes = await Promise.all(
['build:computer-macos', 'build:keyboard-layout-macos', 'build:notification-status-macos'].map(
(scriptName) => runPnpmScript(scriptName)
)
)
clearTimeout(forceTimer)
for (const [signal, handler] of signalHandlers) {
process.removeListener(signal, handler)
}
if (externalSignal) {
process.kill(process.pid, externalSignal)
} else if (firstFailure?.signal) {
// Node ignores some signals (SIGPIPE); the build still failed if the re-raise is a no-op.
process.exitCode = 1
process.kill(process.pid, firstFailure.signal)
} else {
process.exitCode = firstFailure?.code ?? Math.max(outputFailed ? 1 : 0, ...exitCodes)
}
function handlerFor(signal) {
if (!signalHandlers.has(signal)) {
signalHandlers.set(signal, () => {
externalSignal ??= signal
stopBuilds(signal)
})
}
return signalHandlers.get(signal)
}
function stopBuilds(signal = 'SIGTERM') {
if (stopping) {
return
}
stopping = true
terminateAll(signal)
if (children.size > 0) {
forceTimer ??= setTimeout(() => terminateAll('SIGKILL'), 2_000)
}
}
function terminateAll(signal) {
for (const [child, label] of children) {
if (!child.pid) {
continue
}
console.log(`[native-build] stopping ${label} (${signal})`)
try {
process.kill(-child.pid, signal)
} catch {
// group already gone
try {
child.kill(signal)
} catch {}
}
}
}
function runPnpmScript(scriptName) {
if (stopping) {
return Promise.resolve(1)
}
const label = scriptName.replace(/^build:|-macos$/g, '')
const { command, prefixArgs, shell } = resolvePnpmCliInvocation()
const child = spawn(command, [...prefixArgs, 'run', scriptName], {
detached: true,
shell,
stdio: ['ignore', 'pipe', 'pipe']
})
children.set(child, scriptName)
pipePrefixed(child.stdout, label, process.stdout)
pipePrefixed(child.stderr, label, process.stderr)
return new Promise((resolve) => {
let failed = false
child.on('error', (error) => {
failed = true
console.error(`[${label}] ${error.message}`)
if (!stopping) {
firstFailure = { code: 1, signal: null }
}
stopBuilds()
})
let exited = false
let closeTimer
// A descendant that inherited the pipes must not hold the launcher open forever.
const armReap = () => {
clearTimeout(closeTimer)
// A backpressure pause also delays 'close'; only count time spent actually draining.
if (child.stdout.isPaused() || child.stderr.isPaused()) {
return
}
closeTimer = setTimeout(() => {
console.error(`[native-build] ${label} left descendants holding its output; reaping them`)
try {
process.kill(-child.pid, 'SIGKILL')
} catch {}
child.stdout.destroy()
child.stderr.destroy()
}, 2_000)
}
for (const stream of [child.stdout, child.stderr]) {
stream.on('pause', () => clearTimeout(closeTimer))
stream.on('resume', () => {
if (exited) {
armReap()
}
})
}
child.on('exit', (code, signal) => {
if (code !== 0 || signal) {
if (!stopping) {
firstFailure = { code: code ?? 1, signal }
}
stopBuilds()
}
exited = true
armReap()
})
// Re-raise the parent's signal only after every child and its output pipes close.
child.on('close', (code, signal) => {
clearTimeout(closeTimer)
children.delete(child)
resolve(failed || signal ? 1 : (code ?? 1))
})
})
}
function pipePrefixed(stream, label, target) {
stream.setEncoding('utf8')
let buffer = ''
stream.on('data', (chunk) => {
if (target.destroyed) {
return
}
buffer += chunk
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
target.write(`[${label}] ${line}\n`)
}
if (target.writableNeedDrain) {
stream.pause()
const resume = () => {
target.off('drain', resume)
target.off('close', resume)
stream.resume()
}
target.once('drain', resume)
target.once('close', resume)
}
})
stream.on('end', () => {
if (buffer.length > 0 && !target.destroyed) {
target.write(`[${label}] ${buffer}\n`)
}
})
}
function runNodeScript(scriptPath) {
const result = spawnSync(process.execPath, [scriptPath], { stdio: 'inherit' })
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.status !== 0 || result.error) {
process.exit(result.status ?? 1)
}
}