mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
364 lines
14 KiB
JavaScript
364 lines
14 KiB
JavaScript
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { spawnProcess } from '../../src/shared/child-process/run-process'
|
|
|
|
const buildScript = fileURLToPath(new URL('./build-native-for-platform.mjs', import.meta.url))
|
|
const directories = []
|
|
const children = []
|
|
const buildPids = new Set()
|
|
|
|
afterEach(() => {
|
|
for (const child of children.splice(0)) {
|
|
child.kill('SIGKILL')
|
|
}
|
|
for (const pid of buildPids) {
|
|
try {
|
|
process.kill(-pid, 'SIGKILL')
|
|
} catch {}
|
|
}
|
|
buildPids.clear()
|
|
for (const directory of directories.splice(0)) {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
function startBuild(mode, options = {}) {
|
|
const directory = mkdtempSync(join(tmpdir(), 'orca-native-build-test-'))
|
|
directories.push(directory)
|
|
const cli = join(directory, 'fake-pnpm.mjs')
|
|
const journal = join(directory, 'events.jsonl')
|
|
writeFileSync(journal, '')
|
|
mkdirSync(join(directory, 'config', 'scripts'), { recursive: true })
|
|
writeFileSync(
|
|
join(directory, 'config', 'scripts', 'build-windows-cli-launcher.mjs'),
|
|
"console.log('windows launcher only')"
|
|
)
|
|
writeFileSync(
|
|
cli,
|
|
`
|
|
import { appendFileSync, existsSync } from 'node:fs'
|
|
import { spawn } from 'node:child_process'
|
|
const name = process.argv.at(-1)
|
|
const delay = name.includes('computer') ? 0 : name.includes('keyboard') ? 200 : 400
|
|
const record = (event, extra = {}) => appendFileSync(process.env.NATIVE_BUILD_JOURNAL, JSON.stringify({ name, event, pid: process.pid, ...extra }) + '\\n')
|
|
const finish = (signal) => { record(signal); process.exit(process.env.NATIVE_BUILD_MODE === 'failure-status' ? 9 : 0) }
|
|
if (process.env.NATIVE_BUILD_MODE !== 'signal-default') process.on('SIGTERM', () => { if (process.env.NATIVE_BUILD_MODE !== 'ignore') setTimeout(() => finish('SIGTERM'), delay) })
|
|
if (process.env.NATIVE_BUILD_MODE !== 'signal-default' || !name.includes('computer')) process.on('SIGINT', () => setTimeout(() => finish('SIGINT'), delay))
|
|
process.on('SIGHUP', () => setTimeout(() => finish('SIGHUP'), delay))
|
|
record('started')
|
|
process.stdout.write('ready ' + process.pid + '\\n')
|
|
if (process.env.NATIVE_BUILD_MODE.startsWith('descendant')) {
|
|
spawn(process.execPath, ['-e', ${JSON.stringify("process.on('SIGTERM', () => {}); console.log('descendant ' + process.pid); setInterval(() => {}, 1000)")}], { stdio: 'inherit' })
|
|
}
|
|
let flooding = false
|
|
setInterval(() => {
|
|
if (!existsSync(process.env.NATIVE_BUILD_GATE)) return
|
|
if (process.env.NATIVE_BUILD_MODE.startsWith('output-closed-')) {
|
|
const target = process.env.NATIVE_BUILD_MODE.endsWith('stderr') ? process.stderr : process.stdout
|
|
if (name.includes('computer')) target.write('compiler progress\\n')
|
|
return
|
|
}
|
|
if (process.env.NATIVE_BUILD_MODE === 'flood') {
|
|
if (flooding) return
|
|
flooding = true
|
|
const chunk = 'f'.repeat(65535) + '\\n'
|
|
const pump = () => { while (process.stdout.write(chunk)) {} ; process.stdout.once('drain', pump) }
|
|
pump()
|
|
return
|
|
}
|
|
if (['success', 'descendant-success'].includes(process.env.NATIVE_BUILD_MODE)) { record('completed'); process.exit(0) }
|
|
if (process.env.NATIVE_BUILD_MODE === 'stalled-consumer') {
|
|
if (!name.includes('computer') || existsSync(process.env.NATIVE_BUILD_GATE + '-exit')) { record('completed'); process.exit(0) }
|
|
if (flooding) return
|
|
flooding = true
|
|
// Each callback means the kernel pipe accepted the line, so it survives our exit.
|
|
const pump = (line) => process.stdout.write('line ' + line + ' ' + 'x'.repeat(190) + '\\n', () => { record('accepted', { line }); pump(line + 1) })
|
|
pump(1)
|
|
return
|
|
}
|
|
if (!name.includes('computer')) return
|
|
record('failed')
|
|
if (process.env.NATIVE_BUILD_MODE === 'failure-signal') process.kill(process.pid, 'SIGALRM')
|
|
else if (process.env.NATIVE_BUILD_MODE === 'failure-sigpipe') { process.on('SIGPIPE', () => {}); process.removeAllListeners('SIGPIPE'); process.kill(process.pid, 'SIGPIPE') }
|
|
else process.exit(7)
|
|
}, 10)
|
|
`
|
|
)
|
|
const child = spawnProcess({
|
|
program: process.execPath,
|
|
args: [
|
|
...(options.platform
|
|
? [
|
|
'--import',
|
|
`data:text/javascript,${encodeURIComponent(`Object.defineProperty(process, 'platform', { value: '${options.platform}' })`)}`
|
|
]
|
|
: []),
|
|
...(options.reportBuffered
|
|
? [
|
|
'--import',
|
|
`data:text/javascript,${encodeURIComponent(`import { appendFileSync } from 'node:fs'; setInterval(() => appendFileSync(process.env.NATIVE_BUILD_JOURNAL, JSON.stringify({ name: 'launcher', event: 'buffered', bytes: process.stdout.writableLength }) + '\\n'), 50).unref()`)}`
|
|
]
|
|
: []),
|
|
...(options.lateOutputError
|
|
? [
|
|
'--import',
|
|
`data:text/javascript,${encodeURIComponent(`process.once('beforeExit', () => process.${options.lateOutputError}.emit('error', new Error('late output failure')))`)}`
|
|
]
|
|
: []),
|
|
buildScript
|
|
],
|
|
cwd: directory,
|
|
env: {
|
|
...process.env,
|
|
ORCA_BACKGROUND_LAUNCH: '1',
|
|
npm_execpath: options.missingCli ? join(directory, 'missing-pnpm') : cli,
|
|
NATIVE_BUILD_JOURNAL: journal,
|
|
NATIVE_BUILD_GATE: join(directory, 'release'),
|
|
NATIVE_BUILD_MODE: mode
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
children.push(child)
|
|
let output = ''
|
|
let stderr = ''
|
|
let descendantPids = []
|
|
let readyResolve
|
|
const ready = new Promise((resolve) => {
|
|
readyResolve = resolve
|
|
})
|
|
child.stdout.on('data', (chunk) => {
|
|
output += chunk.toString()
|
|
const pids = [...output.matchAll(/ready (\d+)/g)].map((match) => Number(match[1]))
|
|
descendantPids = [...output.matchAll(/descendant (\d+)/g)].map((match) => Number(match[1]))
|
|
for (const pid of pids) {
|
|
buildPids.add(pid)
|
|
}
|
|
if (pids.length === 3 && (!mode.startsWith('descendant') || descendantPids.length === 3)) {
|
|
readyResolve()
|
|
}
|
|
})
|
|
child.stderr.on('data', (chunk) => {
|
|
stderr += chunk.toString()
|
|
})
|
|
const closed = new Promise((resolve, reject) => {
|
|
child.on('error', reject)
|
|
child.on('close', (code, signal) => resolve({ code, signal, output, stderr }))
|
|
})
|
|
return {
|
|
child,
|
|
ready,
|
|
closed,
|
|
descendants: () => descendantPids,
|
|
release: () => writeFileSync(join(directory, 'release'), ''),
|
|
releaseExit: () => writeFileSync(join(directory, 'release-exit'), ''),
|
|
events: () =>
|
|
readFileSync(journal, 'utf8')
|
|
.trim()
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line))
|
|
}
|
|
}
|
|
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
|
async function waitFor(condition, timeoutMs = 5_000) {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (!condition()) {
|
|
if (Date.now() > deadline) {
|
|
throw new Error('timed out waiting for condition')
|
|
}
|
|
await sleep(50)
|
|
}
|
|
}
|
|
|
|
describe.skipIf(process.platform !== 'darwin')('parallel native builds', () => {
|
|
it('starts every independent build before any completes', async () => {
|
|
const build = startBuild('success')
|
|
await build.ready
|
|
expect(build.events().map(({ event }) => event)).toEqual(['started', 'started', 'started'])
|
|
build.release()
|
|
expect(await build.closed).toMatchObject({ code: 0, signal: null })
|
|
expect(build.events().filter(({ event }) => event === 'completed')).toHaveLength(3)
|
|
})
|
|
|
|
it.each(['SIGINT', 'SIGTERM', 'SIGHUP'])(
|
|
'waits for every sibling before re-raising %s',
|
|
async (signal) => {
|
|
const build = startBuild('signal')
|
|
await build.ready
|
|
build.child.kill(signal)
|
|
expect(await build.closed).toMatchObject({ code: null, signal })
|
|
expect(build.events().filter(({ event }) => event === signal)).toHaveLength(3)
|
|
}
|
|
)
|
|
|
|
it('waits for sibling cancellation when a build fails', async () => {
|
|
const build = startBuild('failure')
|
|
await build.ready
|
|
build.release()
|
|
expect(await build.closed).toMatchObject({ code: 7, signal: null })
|
|
expect(build.events().filter(({ event }) => event === 'SIGTERM')).toHaveLength(2)
|
|
})
|
|
|
|
it('reports the first failure, not the status of siblings it cancelled', async () => {
|
|
const build = startBuild('failure-status')
|
|
await build.ready
|
|
build.release()
|
|
expect(await build.closed).toMatchObject({ code: 7, signal: null })
|
|
expect(build.events().filter(({ event }) => event === 'SIGTERM')).toHaveLength(2)
|
|
})
|
|
|
|
it('re-raises the signal that killed a build', async () => {
|
|
const build = startBuild('failure-signal')
|
|
await build.ready
|
|
build.release()
|
|
expect(await build.closed).toMatchObject({ code: null, signal: 'SIGALRM' })
|
|
expect(build.events().filter(({ event }) => event === 'SIGTERM')).toHaveLength(2)
|
|
})
|
|
|
|
it('fails when the signal that killed a build is one the launcher ignores', async () => {
|
|
const build = startBuild('failure-sigpipe')
|
|
await build.ready
|
|
build.release()
|
|
expect(await build.closed).toMatchObject({ code: 1, signal: null })
|
|
expect(build.events().filter(({ event }) => event === 'SIGTERM')).toHaveLength(2)
|
|
})
|
|
|
|
it('lets siblings finish SIGINT cleanup when one child uses the default handler', async () => {
|
|
const build = startBuild('signal-default')
|
|
await build.ready
|
|
build.child.kill('SIGINT')
|
|
expect(await build.closed).toMatchObject({ code: null, signal: 'SIGINT' })
|
|
expect(build.events().filter(({ event }) => event === 'SIGINT')).toHaveLength(2)
|
|
})
|
|
|
|
it('forces a sibling that ignores graceful cancellation to exit', async () => {
|
|
const build = startBuild('ignore')
|
|
await build.ready
|
|
build.release()
|
|
expect(await build.closed).toMatchObject({ code: 7, signal: null })
|
|
for (const { pid } of build.events().filter(({ event }) => event === 'started')) {
|
|
expect(() => process.kill(pid, 0)).toThrow()
|
|
}
|
|
})
|
|
|
|
it.each(['stdout', 'stderr'])(
|
|
'stops quiet siblings when the %s consumer closes',
|
|
async (target) => {
|
|
const build = startBuild(`output-closed-${target}`)
|
|
await build.ready
|
|
build.child[target].destroy()
|
|
build.release()
|
|
|
|
const result = await build.closed
|
|
expect(result).toMatchObject({ code: 1, signal: null })
|
|
expect(result.stderr).not.toContain('Unhandled')
|
|
for (const { pid } of build.events().filter(({ event }) => event === 'started')) {
|
|
expect(() => process.kill(pid, 0)).toThrow()
|
|
}
|
|
}
|
|
)
|
|
|
|
it.each(['stdout', 'stderr'])(
|
|
'fails on a late %s error after successful child exits',
|
|
async (target) => {
|
|
const build = startBuild('success', { lateOutputError: target })
|
|
await build.ready
|
|
build.release()
|
|
|
|
const result = await build.closed
|
|
expect(result).toMatchObject({ code: 1, signal: null })
|
|
expect(result.stderr).not.toContain('Unhandled')
|
|
expect(build.events().filter(({ event }) => event === 'completed')).toHaveLength(3)
|
|
}
|
|
)
|
|
|
|
it('reports a missing build command without waiting forever', async () => {
|
|
const build = startBuild('success', { missingCli: true })
|
|
expect(await build.closed).toMatchObject({ code: 1, signal: null })
|
|
})
|
|
|
|
it('reaps compiler descendants that retain pipes after their launcher exits', async () => {
|
|
const build = startBuild('descendant')
|
|
await build.ready
|
|
build.child.kill('SIGTERM')
|
|
expect(await build.closed).toMatchObject({ code: null, signal: 'SIGTERM' })
|
|
for (const pid of build.descendants()) {
|
|
expect(() => process.kill(pid, 0)).toThrow()
|
|
}
|
|
})
|
|
|
|
it("reaps descendants that keep a finished build's pipes open instead of hanging", async () => {
|
|
const build = startBuild('descendant-success')
|
|
await build.ready
|
|
build.release()
|
|
expect(await build.closed).toMatchObject({ code: 0, signal: null })
|
|
expect(build.events().filter(({ event }) => event === 'completed')).toHaveLength(3)
|
|
for (const pid of build.descendants()) {
|
|
expect(() => process.kill(pid, 0)).toThrow()
|
|
}
|
|
})
|
|
|
|
it('stops reading compiler output while its own stdout is blocked', async () => {
|
|
const build = startBuild('flood', { reportBuffered: true })
|
|
await build.ready
|
|
build.child.stdout.pause()
|
|
build.release()
|
|
await new Promise((resolve) => setTimeout(resolve, 1_500))
|
|
const buffered = build
|
|
.events()
|
|
.filter(({ event }) => event === 'buffered')
|
|
.map(({ bytes }) => bytes)
|
|
expect(buffered.length).toBeGreaterThan(0)
|
|
expect(Math.max(...buffered)).toBeLessThan(1_000_000)
|
|
})
|
|
|
|
it('delivers every compiler line when its own stdout consumer stalls past the reap timeout', async () => {
|
|
const build = startBuild('stalled-consumer', { reportBuffered: true })
|
|
await build.ready
|
|
build.child.stdout.pause()
|
|
build.release()
|
|
// Launcher stops reading once its stdout hits the high-water mark; then let the compiler fill its pipe.
|
|
await waitFor(() =>
|
|
build.events().some(({ event, bytes }) => event === 'buffered' && bytes >= 16_384)
|
|
)
|
|
await sleep(300)
|
|
build.releaseExit()
|
|
await waitFor(() =>
|
|
build.events().some(({ event, name }) => event === 'completed' && name.includes('computer'))
|
|
)
|
|
const accepted = Math.max(
|
|
...build
|
|
.events()
|
|
.filter(({ event }) => event === 'accepted')
|
|
.map(({ line }) => line)
|
|
)
|
|
expect(accepted).toBeGreaterThan(0)
|
|
await sleep(3_000)
|
|
build.child.stdout.resume()
|
|
|
|
const result = await build.closed
|
|
expect(result).toMatchObject({ code: 0, signal: null })
|
|
const delivered = [...result.output.matchAll(/^\[computer\] line (\d+) /gm)].map((match) =>
|
|
Number(match[1])
|
|
)
|
|
expect(delivered).toEqual(Array.from({ length: accepted }, (_, index) => index + 1))
|
|
})
|
|
|
|
it.each(['linux', 'win32'])('keeps the %s entry point out of macOS builds', async (platform) => {
|
|
const build = startBuild('success', { platform })
|
|
const result = await build.closed
|
|
expect(result).toMatchObject({ code: 0, signal: null })
|
|
expect(build.events()).toEqual([])
|
|
expect(result.output).toContain(
|
|
platform === 'win32'
|
|
? 'windows launcher only'
|
|
: 'no macOS native computer build required on linux'
|
|
)
|
|
})
|
|
})
|