mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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.
This commit is contained in:
@@ -13,10 +13,14 @@ if (process.platform !== 'darwin') {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const children = new Set()
|
||||
// Why detached: native builds spawn swift/swiftc descendants; killing the pnpm
|
||||
// process alone can leave them writing artifacts. Each build runs in its own
|
||||
// process group so cleanup can signal the whole tree (POSIX only — this file
|
||||
// returns early on win32).
|
||||
const children = new Map()
|
||||
|
||||
process.on('SIGINT', terminateAll)
|
||||
process.on('SIGTERM', terminateAll)
|
||||
process.on('SIGINT', () => terminateAll('SIGINT'))
|
||||
process.on('SIGTERM', () => terminateAll('SIGTERM'))
|
||||
|
||||
const exitCodes = await Promise.all(
|
||||
['build:computer-macos', 'build:keyboard-layout-macos', 'build:notification-status-macos'].map(
|
||||
@@ -25,30 +29,94 @@ const exitCodes = await Promise.all(
|
||||
)
|
||||
process.exit(Math.max(...exitCodes))
|
||||
|
||||
function terminateAll() {
|
||||
for (const child of children) {
|
||||
child.kill('SIGTERM')
|
||||
function terminateAll(signal) {
|
||||
for (const [child, label] of children) {
|
||||
console.log(`[native-build] stopping ${label} (${signal})`)
|
||||
try {
|
||||
// negative pid signals the whole detached process group
|
||||
process.kill(-child.pid, signal)
|
||||
} catch {
|
||||
// group already gone
|
||||
try {
|
||||
child.kill(signal)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runPnpmScript(scriptName) {
|
||||
const label = scriptName.replace(/^build:|-macos$/g, '')
|
||||
const { command, prefixArgs, shell } = resolvePnpmCliInvocation()
|
||||
const child = spawn(command, [...prefixArgs, 'run', scriptName], { stdio: 'inherit', shell })
|
||||
children.add(child)
|
||||
const child = spawn(command, [...prefixArgs, 'run', scriptName], {
|
||||
// Why detached: own process group, see terminateAll above.
|
||||
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) => {
|
||||
child.on('error', () => resolve(1))
|
||||
child.on('close', (code, signal) => {
|
||||
let exitCode = null
|
||||
let exitSignal = null
|
||||
// Why settle only on close: 'error' (e.g. spawn ENOENT) can fire before/without
|
||||
// close; resolving early would let Promise.all exit while children still run.
|
||||
const settle = () => {
|
||||
if (exitCode === null && exitSignal === null) {
|
||||
return
|
||||
}
|
||||
children.delete(child)
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
if (exitSignal) {
|
||||
// Why: restore default disposition so this process dies with the same
|
||||
// signal it received (our handlers would otherwise swallow it).
|
||||
for (const received of ['SIGINT', 'SIGTERM']) {
|
||||
process.removeListener(received, handlerFor(received))
|
||||
}
|
||||
process.kill(process.pid, exitSignal)
|
||||
return
|
||||
}
|
||||
if (code !== 0) {
|
||||
if (exitCode !== 0) {
|
||||
// fail fast: stop sibling builds so they don't keep writing artifacts
|
||||
terminateAll()
|
||||
terminateAll('SIGTERM')
|
||||
}
|
||||
resolve(code ?? 1)
|
||||
resolve(exitCode ?? 1)
|
||||
}
|
||||
child.on('error', () => {
|
||||
exitCode = 1
|
||||
settle()
|
||||
})
|
||||
child.on('close', (code, signal) => {
|
||||
exitCode = code
|
||||
exitSignal = signal
|
||||
settle()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const signalHandlers = new Map()
|
||||
function handlerFor(signal) {
|
||||
if (!signalHandlers.has(signal)) {
|
||||
signalHandlers.set(signal, () => terminateAll(signal))
|
||||
}
|
||||
return signalHandlers.get(signal)
|
||||
}
|
||||
|
||||
function pipePrefixed(stream, label, target) {
|
||||
stream.setEncoding('utf8')
|
||||
let buffer = ''
|
||||
stream.on('data', (chunk) => {
|
||||
buffer += chunk
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
target.write(`[${label}] ${line}\n`)
|
||||
}
|
||||
})
|
||||
stream.on('end', () => {
|
||||
if (buffer.length > 0) {
|
||||
target.write(`[${label}] ${buffer}\n`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user