ReviewOS

also looking at this

stacks/drone

fix: separate native runtime builds from test deadlines

#69
Merged chris wants to merge codex/runtime-cold-build-setup into main
10 files +399 -14
.github/workflows/ci.ymlmodified+28-0
Changes to .github/workflows/ci.yml
@@ -30,6 +30,34 @@ jobs:
3030 - run: bun run parity:check
3131 - run: python3 -m py_compile resources/functions/drone/camera/imx500_worker.py
3232 - run: python3 -m unittest discover -s tests/hardware
33 - name: Qualify cold build setup across the former hook deadline
34 run: bun scripts/qualify-runtime-cold-build.ts "$RUNNER_TEMP/runtime-cold-build"
35 - uses: actions/upload-artifact@v4
36 if: always()
37 with:
38 name: runtime-cold-build-evidence
39 if-no-files-found: ignore
40 retention-days: 7
41 path: |
42 ${{ runner.temp }}/runtime-cold-build/receipt.json
43 ${{ runner.temp }}/runtime-cold-build/capture-result.json
44 ${{ runner.temp }}/runtime-cold-build/tool-events.ndjson
45 ${{ runner.temp }}/runtime-cold-build/stdout.log
46 ${{ runner.temp }}/runtime-cold-build/stderr.log
47 ${{ runner.temp }}/runtime-cold-build/project/firmware/runtime/CMakeLists.txt
48 - name: Verify cold-build evidence failure cannot bypass detached tool cleanup
49 run: bun scripts/qualify-runtime-cold-build.ts "$RUNNER_TEMP/runtime-cold-report-failure" --interrupt-before-capture-report
50 - uses: actions/upload-artifact@v4
51 if: always()
52 with:
53 name: runtime-cold-report-failure-evidence
54 if-no-files-found: ignore
55 retention-days: 7
56 path: |
57 ${{ runner.temp }}/runtime-cold-report-failure/receipt.json
58 ${{ runner.temp }}/runtime-cold-report-failure/tool-events.ndjson
59 ${{ runner.temp }}/runtime-cold-report-failure/stdout.log
60 ${{ runner.temp }}/runtime-cold-report-failure/stderr.log
3361 - name: Verify a kernel-confirmed full input pipe on Linux
3462 run: bun test tests/unit/runtime.test.ts --test-name-pattern 'kernel-confirmed full input FIFO'
3563 - name: Verify an actual partial host image write and fresh-process recovery
docs/runtime.mdmodified+29-0
Changes to docs/runtime.md
@@ -28,6 +28,35 @@ stream. Native Stacks process capture drains stdout and stderr concurrently and
2828cleans up the owned process group on POSIX before reporting failure. This also
2929applies to SDK inspection and Pico 2 builds; it does not install firmware.
3030
31Native runtime and sensor tests await build initialization before their test timers
32start. An unrelated setup hook must not interrupt the build API's owned command
33cleanup. Runtime response and test deadlines are unchanged. Build failures identify
34the configure, compile, contracts or SDK inspection stage.
35
36CI also compiles a fresh disposable source copy whose real CMake configure takes
37at least 21 seconds, crossing the former 20-second setup hook. It then runs the
38original Linux full-input FIFO case, retains real tool exit observations and checks
39that no owned tool groups remain. Run the same qualification locally with a new
40evidence directory:
41
42```sh
43bun scripts/qualify-runtime-cold-build.ts /absolute/path/to/new-cold-build-evidence
44```
45
46On macOS the qualification uses the native boot/clean-exit case; the kernel FIFO
47case is Linux-only. The qualification retains its owned source and logs, including
48failures. It does not access a board or measure flight timing.
49
50The qualification supervisor derives its aggregate budget from the three separate
51120-second build commands, their settlement allowance, the selected test's existing
52deadline and startup allowance. It captures at most 2 MB per stream concurrently,
53retains failed output and checks both parent and recorded tool process groups.
54This test-only aggregate budget does not extend the build API or runtime deadlines.
55CI separately interrupts the actual test parent while CMake is active and makes
56the early capture receipt path a directory. That expected reporting refusal must
57still retain its error and settle the recorded detached tool group. Run it with
58`--interrupt-before-capture-report` after a new evidence directory argument.
59
3160```sh
3261./buddy runtime:build
3362./buddy runtime:bench --ticks 1000
resources/functions/drone/runtime/build.tsmodified+8-8
Changes to resources/functions/drone/runtime/build.ts
@@ -3,9 +3,9 @@ import { runBoundedHostProcess } from '../../host/process'
33
44export const picoSdkRevision = '079c6f39023649b154152db30f1d781e884879bc'
55export interface RuntimeBuildOptions { project?: string, target?: 'host' | 'pico2', sdk?: string, toolchain?: string, watchdogTest?: boolean, sensors?: boolean }
6async function run(args: string[], cwd: string, env = process.env) {
7 const result = await runBoundedHostProcess(args, { cwd, env, timeoutMs: 120000, stdoutBytes: 2_000_000, stderrBytes: 2_000_000, label: `Runtime build ${args[0]}` })
8 if (result.exitCode !== 0) throw new Error(`${args[0]} exited ${result.exitCode}:\n${(result.stdout + result.stderr).slice(-8000)}`, { cause: result })
6async function run(stage: string, args: string[], cwd: string, env = process.env) {
7 const result = await runBoundedHostProcess(args, { cwd, env, timeoutMs: 120000, stdoutBytes: 2_000_000, stderrBytes: 2_000_000, label: `Runtime build ${stage}` })
8 if (result.exitCode !== 0) throw new Error(`Runtime build ${stage}: ${args[0]} exited ${result.exitCode}:\n${(result.stdout + result.stderr).slice(-8000)}`, { cause: result })
99 return result.stdout.trim()
1010}
1111/** Local tools only; no shell interpolation, SDK download, firmware flashing or device access. */
@@ -20,14 +20,14 @@ export async function buildRuntime(options: RuntimeBuildOptions = {}) {
2020 const sdk = options.sdk ?? env.PICO_SDK_PATH
2121 if (!sdk) throw new Error('Provide --sdk or PICO_SDK_PATH with the pinned Pico SDK 2.3.1 checkout')
2222 env.PICO_SDK_PATH = resolve(sdk)
23 if (await run(['git', 'rev-parse', 'HEAD'], env.PICO_SDK_PATH) !== picoSdkRevision) throw new Error('Pico SDK revision does not match the retained build pin')
23 if (await run('SDK revision', ['git', 'rev-parse', 'HEAD'], env.PICO_SDK_PATH) !== picoSdkRevision) throw new Error('Pico SDK revision does not match the retained build pin')
2424 // A dirty SDK is not a reproducible input.
25 if (await run(['git', 'status', '--porcelain', '--untracked-files=no'], env.PICO_SDK_PATH)) throw new Error('Pico SDK tracked files must be clean')
25 if (await run('SDK worktree', ['git', 'status', '--porcelain', '--untracked-files=no'], env.PICO_SDK_PATH)) throw new Error('Pico SDK tracked files must be clean')
2626 if (options.toolchain) env.PICO_TOOLCHAIN_PATH = resolve(options.toolchain)
2727 }
2828 else if (options.watchdogTest || options.sensors) throw new Error('Sensor and watchdog firmware profiles are only available for pico2')
29 await run(['cmake', '-S', join(project, 'firmware/runtime'), '-B', directory, '-DCMAKE_BUILD_TYPE=Release', `-DDRONE_PICO2=${target === 'pico2' ? 'ON' : 'OFF'}`, `-DDRONE_WATCHDOG_TEST=${options.watchdogTest ? 'ON' : 'OFF'}`, `-DDRONE_SENSOR_BENCH=${options.sensors ? 'ON' : 'OFF'}`], project, env)
30 await run(['cmake', '--build', directory, '--parallel', '2'], project, env)
31 if (target === 'host') await run(['ctest', '--test-dir', directory, '--output-on-failure'], project, env)
29 await run(`${target} configure`, ['cmake', '-S', join(project, 'firmware/runtime'), '-B', directory, '-DCMAKE_BUILD_TYPE=Release', `-DDRONE_PICO2=${target === 'pico2' ? 'ON' : 'OFF'}`, `-DDRONE_WATCHDOG_TEST=${options.watchdogTest ? 'ON' : 'OFF'}`, `-DDRONE_SENSOR_BENCH=${options.sensors ? 'ON' : 'OFF'}`], project, env)
30 await run(`${target} compile`, ['cmake', '--build', directory, '--parallel', '2'], project, env)
31 if (target === 'host') await run('host contracts', ['ctest', '--test-dir', directory, '--output-on-failure'], project, env)
3232 return { target, directory, artifact: join(directory, target === 'host' ? `drone_runtime_host${process.platform === 'win32' ? '.exe' : ''}` : 'drone_runtime.uf2'), physicalFlightEnabled: false as const }
3333}
scripts/qualify-runtime-cold-build.tsadded+134-0
Changes to scripts/qualify-runtime-cold-build.ts
@@ -0,0 +1,134 @@
1import assert from 'node:assert/strict'
2import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
3import { join, resolve } from 'node:path'
4import process from 'node:process'
5import { captureColdBuild, coldBuildQualificationTimeout } from '../tests/helpers/runtime-cold-build-supervisor'
6
7// Exercise actual build/test initialization across the former 20-second hook.
8// Tool witnesses forward to real CMake/CTest and retain actual child settlement.
9interface ToolEvent { event: 'start' | 'spawn' | 'exit', stage: string, groupId: number, pid: number, exitCode?: number, elapsedMs?: number }
10assert(process.platform !== 'win32', 'Cold build qualification requires POSIX process groups')
11assert(process.argv[2], 'Provide a new owned evidence directory')
12const reportingFault = process.argv[3] === '--interrupt-before-capture-report'
13assert(process.argv.length <= 4 && (!process.argv[3] || reportingFault), 'Unknown qualification mode')
14const repository = resolve(import.meta.dir, '..'), evidence = resolve(process.argv[2]!)
15await mkdir(evidence)
16if (reportingFault) await mkdir(join(evidence, 'capture-result.json'))
17const project = join(evidence, 'project'), tools = join(evidence, 'tools'), eventsPath = join(evidence, 'tool-events.ndjson')
18await mkdir(join(project, 'firmware'), { recursive: true })
19await mkdir(tools)
20await cp(join(repository, 'firmware/runtime'), join(project, 'firmware/runtime'), { recursive: true })
21await writeFile(join(project, 'bunfig.toml'), '[test]\npreload = []\n')
22const cmakePath = join(project, 'firmware/runtime/CMakeLists.txt'), cmake = await readFile(cmakePath, 'utf8')
23assert(cmake.startsWith('cmake_minimum_required(VERSION 3.20)'), 'Expected recorded runtime CMake entrypoint')
24await writeFile(cmakePath, cmake.replace('cmake_minimum_required(VERSION 3.20)', 'cmake_minimum_required(VERSION 3.20)\nexecute_process(COMMAND "${CMAKE_COMMAND}" -E sleep 21 COMMAND_ERROR_IS_FATAL ANY)'))
25assert(!await Bun.file(join(project, 'storage/runtime-build/host/CMakeCache.txt')).exists(), 'Qualification must start cold')
26assert(!/\s/.test(process.execPath), 'Tool witness needs a single executable shebang path')
27for (const tool of ['cmake', 'ctest']) {
28 const executable = Bun.which(tool)
29 assert(executable, `Missing native tool: ${tool}`)
30 const source = String.raw`#!${process.execPath}
31import { appendFileSync } from 'node:fs'
32const stage = ${JSON.stringify(tool)} === 'ctest' ? 'contracts' : process.argv[2] === '-S' ? 'configure' : 'compile'
33const began = performance.now()
34const owner = { stage, groupId: process.pid, pid: process.pid }
35appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify({ ...owner, event: 'start' }) + '\n')
36const child = Bun.spawn([${JSON.stringify(executable)}, ...process.argv.slice(2)], { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' })
37const event = { stage, groupId: process.pid, pid: child.pid }
38appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify({ ...event, event: 'spawn' }) + '\n')
39if (${reportingFault} && stage === 'configure') {
40 process.kill(child.pid, 0)
41 process.kill(process.ppid, 'SIGKILL')
42}
43const exitCode = await child.exited
44appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify({ ...event, event: 'exit', exitCode, elapsedMs: performance.now() - began }) + '\n')
45process.exit(exitCode)
46`
47 await writeFile(join(tools, tool), source, { mode: 0o700 })
48}
49const pattern = process.platform === 'linux' ? 'kernel-confirmed full input FIFO' : 'separate supervised boots'
50const buildCommandTimeoutMs = 120000, selectedTestTimeoutMs = process.platform === 'linux' ? 20000 : 5000
51const qualificationTimeoutMs = coldBuildQualificationTimeout(buildCommandTimeoutMs, selectedTestTimeoutMs)
52let result: Awaited<ReturnType<typeof captureColdBuild>> | undefined, failure: unknown
53const began = performance.now()
54try {
55 result = await captureColdBuild([process.execPath, 'test', join(repository, 'tests/unit/runtime.test.ts'), '--test-name-pattern', pattern], { cwd: project, env: { ...process.env, PATH: `${tools}:${process.env.PATH ?? ''}` }, timeoutMs: qualificationTimeoutMs })
56 failure = result.failure
57}
58catch (error) { failure = error }
59// Retain the capture outcome even if a later cleanup observation itself fails.
60const reportingErrors: { stage: string, code: string | null }[] = []
61try {
62 await writeFile(join(evidence, 'capture-result.json'), `${JSON.stringify({ exitCode: result?.exitCode ?? null, elapsedMs: performance.now() - began, failure: failure instanceof Error ? failure.message : failure ? String(failure) : null }, null, 2)}\n`)
63}
64catch (error) {
65 failure ??= error
66 reportingErrors.push({ stage: 'capture-result', code: (error as NodeJS.ErrnoException).code ?? null })
67}
68let eventText = ''
69try { eventText = await readFile(eventsPath, 'utf8') }
70catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
71const events: ToolEvent[] = []
72for (const line of eventText.split('\n').filter(Boolean)) {
73 try { events.push(JSON.parse(line)) }
74 catch (error) { failure ??= error }
75}
76const groups = [...new Set(events.map(event => event.groupId))]
77assert(groups.every(group => Number.isSafeInteger(group) && group > 1), 'Tool witnesses must identify owned process groups')
78const probeErrors: { group: number, code: string }[] = []
79const cleanupErrors: { group: number, code: string | null }[] = []
80function alive(group: number): boolean {
81 try { process.kill(-group, 0); return true }
82 catch (error) {
83 const code = (error as NodeJS.ErrnoException).code
84 if (code === 'ESRCH') return false
85 if (code === 'EPERM') {
86 if (probeErrors.length < 20) probeErrors.push({ group, code })
87 return true // Unknown membership remains live until an actual absence probe.
88 }
89 failure ??= error
90 if (cleanupErrors.length < 20) cleanupErrors.push({ group, code: code ?? null })
91 return true
92 }
93}
94const beforeCleanup = groups.filter(alive)
95for (const group of beforeCleanup) {
96 try { process.kill(-group, 'SIGKILL') }
97 catch (error) {
98 if ((error as NodeJS.ErrnoException).code !== 'ESRCH') {
99 failure ??= error
100 cleanupErrors.push({ group, code: (error as NodeJS.ErrnoException).code ?? null })
101 }
102 }
103}
104const cleanupDeadline = performance.now() + 5000
105while (groups.some(alive) && performance.now() < cleanupDeadline) await Bun.sleep(25)
106const afterCleanup = groups.filter(alive)
107await writeFile(join(evidence, 'stdout.log'), result?.stdout ?? '')
108await writeFile(join(evidence, 'stderr.log'), result?.stderr ?? '')
109const report = { evidence: reportingFault ? 'controlled-cold-native-report-refusal' : 'controlled-cold-native-build', platform: process.platform, bun: Bun.version, pattern, reportingFault, configureDelaySeconds: 21, buildCommandTimeoutMs, selectedTestTimeoutMs, qualificationTimeoutMs, subjectExitCode: result?.exitCode ?? null, captureAvailable: result !== undefined, capture: result ? { pid: result.pid, deadlineReached: result.deadlineReached, stopRequested: result.stopRequested, rootCleanupRequired: result.rootCleanupRequired, rootGroupRemaining: result.rootGroupRemaining, probeErrors: result.probeErrors } : null, elapsedMs: performance.now() - began, events, beforeCleanup, afterCleanup, probeErrors, cleanupErrors, reportingErrors, failure: failure instanceof Error ? failure.message : failure ? String(failure) : null, physicalFlightEnabled: false }
110await writeFile(join(evidence, 'receipt.json'), `${JSON.stringify(report, null, 2)}\n`)
111assert.equal(afterCleanup.length, 0, 'Owned tool groups must settle before any evidence removal')
112assert.equal(cleanupErrors.length, 0, 'Unexpected tool cleanup errors must remain qualification failures')
113assert(result, 'Actual parent capture must be available')
114assert.equal(result.rootGroupRemaining, false, 'The actual parent process group must settle')
115if (reportingFault) {
116 assert.notEqual(result.exitCode, 0, 'The actual test parent must have been interrupted')
117 assert(failure instanceof Error && (failure as NodeJS.ErrnoException).code === 'EISDIR', 'Retain the actual report refusal')
118 assert(reportingErrors.some(error => error.stage === 'capture-result' && error.code === 'EISDIR'))
119 assert(beforeCleanup.length > 0, 'Reporting refusal must encounter a real live detached tool group')
120}
121else {
122 if (failure) throw failure
123 assert.equal(result.exitCode, 0, 'Original selected native runtime test must pass')
124 assert.equal(result.rootCleanupRequired, false, 'A successful qualification must require no parent group cleanup')
125 assert.match(result.stderr, /1 pass/)
126 assert.match(result.stderr, /0 fail/)
127 assert.equal(beforeCleanup.length, 0, 'A successful build must require no controller cleanup')
128 const exits = events.filter(event => event.event === 'exit')
129 assert.deepEqual(exits.map(event => event.stage), ['configure', 'compile', 'contracts'])
130 assert(exits.every(event => event.exitCode === 0), 'Real CMake and CTest must exit cleanly')
131 assert(exits[0]!.elapsedMs! >= 21000, 'Actual configure must cross the former setup hook')
132 assert(await Bun.file(join(project, 'storage/runtime-build/host/drone_runtime_host')).exists(), 'Real compiler output must exist')
133}
134console.log(JSON.stringify(report))
tests/helpers/runtime-cold-build-supervisor.tsadded+114-0
Changes to tests/helpers/runtime-cold-build-supervisor.ts
@@ -0,0 +1,114 @@
1import { Buffer } from 'node:buffer'
2import process from 'node:process'
3
4/** Test-only ownership of the aggregate build/test process, beyond a single command's budget. */
5export interface ColdBuildCapture {
6 exitCode: number
7 stdout: string
8 stderr: string
9 pid: number
10 timeoutMs: number
11 deadlineReached: boolean
12 stopRequested: boolean
13 rootCleanupRequired: boolean
14 rootGroupRemaining: boolean
15 probeErrors: string[]
16 failure: unknown
17}
18
19export const coldBuildCaptureBytes = 2_000_000
20export const coldBuildSettlementMs = 5000
21
22/** Three build commands, each with its own settlement allowance, then the unchanged selected test. */
23export function coldBuildQualificationTimeout(commandTimeoutMs: number, selectedTestTimeoutMs: number): number {
24 if (!Number.isSafeInteger(commandTimeoutMs) || commandTimeoutMs < 1 || commandTimeoutMs > 120000)
25 throw new Error('Invalid cold-build command budget')
26 if (![5000, 20000].includes(selectedTestTimeoutMs))
27 throw new Error('Invalid selected runtime test budget')
28 return 3 * (commandTimeoutMs + coldBuildSettlementMs) + selectedTestTimeoutMs + 10000
29}
30
31export async function captureColdBuild(command: string[], options: { cwd: string, env: NodeJS.ProcessEnv, timeoutMs: number }): Promise<ColdBuildCapture> {
32 if (process.platform === 'win32') throw new Error('Cold build capture requires POSIX process groups')
33 if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > 405000)
34 throw new Error('Invalid aggregate cold-build budget')
35 const child = Bun.spawn(command, { cwd: options.cwd, env: options.env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', detached: true })
36 const readers = [child.stdout.getReader(), child.stderr.getReader()]
37 const chunks: Uint8Array[][] = [[], []], sizes = [0, 0]
38 const cancellations: Promise<unknown>[] = [], cleanupErrors: unknown[] = [], probeErrors: string[] = []
39 let exited = false, groupTerminated = false
40 let failure: unknown, deadlineReached = false, stopped = false, rootCleanupRequired = false, pipeTimer: ReturnType<typeof setTimeout> | undefined
41 const group = (signal: NodeJS.Signals | 0): boolean => {
42 try { process.kill(-child.pid, signal); return true }
43 catch (error) {
44 const code = (error as NodeJS.ErrnoException).code
45 if (code === 'ESRCH') return false
46 if (signal === 0 && code === 'EPERM') {
47 if (probeErrors.length < 20) probeErrors.push(code)
48 return true // Permission uncertainty never proves that ownership has settled.
49 }
50 throw error
51 }
52 }
53 const groupExists = (): boolean => {
54 try { return group(0) }
55 catch (error) { cleanupErrors.push(error); return true }
56 }
57 const terminate = () => {
58 if (!groupTerminated) {
59 try { group('SIGKILL'); groupTerminated = true }
60 catch (error) { cleanupErrors.push(error) }
61 }
62 if (!exited && !groupTerminated) {
63 try { child.kill('SIGKILL') }
64 catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') cleanupErrors.push(error) }
65 }
66 }
67 const stop = (error: unknown) => {
68 failure ??= error
69 if (stopped) return
70 stopped = true
71 terminate()
72 for (const reader of readers) cancellations.push(reader.cancel().catch(error => { cleanupErrors.push(error) }))
73 }
74 async function consume(index: number): Promise<void> {
75 try {
76 while (true) {
77 const { done, value } = await readers[index]!.read()
78 if (done) return
79 const room = coldBuildCaptureBytes - sizes[index]!
80 const retained = value.subarray(0, room)
81 if (retained.byteLength) { chunks[index]!.push(retained); sizes[index]! += retained.byteLength }
82 if (value.byteLength > room) { stop(new Error(`Cold build ${index === 0 ? 'stdout' : 'stderr'} exceeds byte limit`)); return }
83 }
84 }
85 catch (error) { stop(error) }
86 }
87 const timer = setTimeout(() => { deadlineReached = true; stop(new Error('Aggregate cold build qualification timed out')) }, options.timeoutMs)
88 const outputs = [consume(0), consume(1)]
89 let exitCode: number
90 try {
91 exitCode = await child.exited
92 exited = true
93 clearTimeout(timer)
94 pipeTimer = setTimeout(() => stop(new Error('Cold build inherited output pipes did not settle')), coldBuildSettlementMs)
95 await Promise.all(outputs)
96 }
97 finally {
98 clearTimeout(timer)
99 clearTimeout(pipeTimer)
100 rootCleanupRequired = groupExists()
101 terminate()
102 await Promise.allSettled([child.exited, ...outputs, ...cancellations])
103 for (const reader of readers) {
104 try { reader.releaseLock() }
105 catch (error) { cleanupErrors.push(error) }
106 }
107 }
108 const end = performance.now() + coldBuildSettlementMs
109 while (groupExists() && performance.now() < end) await Bun.sleep(25)
110 const rootGroupRemaining = groupExists()
111 if (rootGroupRemaining) cleanupErrors.push(new Error('Owned cold-build parent group did not settle'))
112 if (cleanupErrors.length) failure = new AggregateError(failure ? [failure, ...cleanupErrors] : cleanupErrors, 'Cold-build capture cleanup failed')
113 return { exitCode, stdout: Buffer.concat(chunks[0]!, sizes[0]).toString('utf8'), stderr: Buffer.concat(chunks[1]!, sizes[1]).toString('utf8'), pid: child.pid, timeoutMs: options.timeoutMs, deadlineReached, stopRequested: stopped, rootCleanupRequired, rootGroupRemaining, probeErrors, failure }
114}
tests/tsconfig.runtime.jsonmodified+3-0
Changes to tests/tsconfig.runtime.json
@@ -25,6 +25,9 @@
2525 "./unit/estimation.test.ts",
2626 "./fixtures/sensors/generate.ts",
2727 "./unit/runtime.test.ts",
28 "./unit/runtime-build.test.ts",
29 "./unit/runtime-cold-build-supervisor.test.ts",
30 "../scripts/qualify-runtime-cold-build.ts",
2831 "./unit/sensors.test.ts",
2932 "../resources/functions/drone/payloads/**/*.ts",
3033 "./unit/payloads.test.ts",
tests/unit/runtime-build.test.tsadded+15-0
Changes to tests/unit/runtime-build.test.ts
@@ -0,0 +1,15 @@
1import { expect, test } from 'bun:test'
2import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { buildRuntime } from '../../resources/functions/drone/runtime/build'
6
7test('actual CMake refusal identifies the build stage before any runtime test starts', async () => {
8 const project = await mkdtemp(join(tmpdir(), 'drone-build-refusal-'))
9 try {
10 await mkdir(join(project, 'firmware/runtime'), { recursive: true })
11 await writeFile(join(project, 'firmware/runtime/CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.20)\nproject(controlled_refusal NONE)\nmessage(FATAL_ERROR "Controlled configure refusal")\n')
12 await expect(buildRuntime({ project })).rejects.toThrow('Runtime build host configure: cmake exited 1')
13 }
14 finally { await rm(project, { recursive: true, force: true }) }
15}, 10000)
tests/unit/runtime-cold-build-supervisor.test.tsadded+62-0
Changes to tests/unit/runtime-cold-build-supervisor.test.ts
@@ -0,0 +1,62 @@
1import { expect, test } from 'bun:test'
2import { Buffer } from 'node:buffer'
3import process from 'node:process'
4import { captureColdBuild, coldBuildCaptureBytes, coldBuildQualificationTimeout } from '../helpers/runtime-cold-build-supervisor'
5
6const options = { cwd: process.cwd(), env: process.env, timeoutMs: 15000 }
7const run = (source: string, timeoutMs = options.timeoutMs) => captureColdBuild([process.execPath, '-e', source], { ...options, timeoutMs })
8function groupExists(pid: number): boolean {
9 try { process.kill(-pid, 0); return true }
10 catch (error) { if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false; throw error }
11}
12
13test.skipIf(process.platform === 'win32')('aggregate capture drains both real streams and preserves a native nonzero exit', async () => {
14 const result = await run("await Promise.all([Bun.write(Bun.stdout, 'o'.repeat(262144)), Bun.write(Bun.stderr, 'e'.repeat(262144))]); process.exit(7)")
15 expect(result.exitCode).toBe(7)
16 expect(result.stdout).toBe('o'.repeat(262144))
17 expect(result.stderr).toBe('e'.repeat(262144))
18 expect(result.failure).toBeUndefined()
19 expect(result.stopRequested).toBe(false)
20 expect(result.rootCleanupRequired).toBe(false)
21 expect(result.rootGroupRemaining).toBe(false)
22 expect(groupExists(result.pid)).toBe(false)
23}, 25000)
24
25test.skipIf(process.platform === 'win32')('aggregate deadline retains actual child output and settles its owned group', async () => {
26 const result = await run("await Bun.write(Bun.stdout, 'ready\\n'); setInterval(() => {}, 1000)", 2000)
27 expect(result.stdout).toBe('ready\n')
28 expect(result.exitCode).not.toBe(0)
29 expect(result.deadlineReached).toBe(true)
30 expect(result.stopRequested).toBe(true)
31 expect(String(result.failure)).toContain('timed out')
32 expect(result.rootGroupRemaining).toBe(false)
33 expect(groupExists(result.pid)).toBe(false)
34}, 15000)
35
36test.skipIf(process.platform === 'win32')('actual output overflow retains the bounded prefix and terminates the writer', async () => {
37 const result = await run("await Bun.write(Bun.stdout, 'x'.repeat(2000001)); setInterval(() => {}, 1000)")
38 expect(Buffer.byteLength(result.stdout)).toBe(coldBuildCaptureBytes)
39 expect(result.stdout).toBe('x'.repeat(coldBuildCaptureBytes))
40 expect(result.exitCode).not.toBe(0)
41 expect(String(result.failure)).toContain('stdout exceeds byte limit')
42 expect(result.deadlineReached).toBe(false)
43 expect(result.rootGroupRemaining).toBe(false)
44 expect(groupExists(result.pid)).toBe(false)
45}, 25000)
46
47test.skipIf(process.platform === 'win32')('exited parent with an actual inherited pipe is refused and its descendant group settles', async () => {
48 const result = await run("const child = Bun.spawn([process.execPath, '-e', 'setInterval(() => {}, 1000)'], { stdout: 'inherit', stderr: 'inherit' }); console.log(child.pid); process.exit(0)")
49 expect(result.exitCode).toBe(0)
50 expect(result.stdout).toMatch(/^\d+\n$/)
51 expect(result.deadlineReached).toBe(false)
52 expect(result.stopRequested).toBe(true)
53 expect(String(result.failure)).toContain('inherited output pipes did not settle')
54 expect(result.rootGroupRemaining).toBe(false)
55 expect(groupExists(result.pid)).toBe(false)
56}, 25000)
57
58test('qualification derives its aggregate budget without changing the individual command limit', () => {
59 expect(coldBuildQualificationTimeout(120000, 5000)).toBe(390000)
60 expect(coldBuildQualificationTimeout(120000, 20000)).toBe(405000)
61 expect(() => coldBuildQualificationTimeout(120001, 5000)).toThrow('command budget')
62})
tests/unit/runtime.test.tsmodified+3-3
Changes to tests/unit/runtime.test.ts
@@ -1,4 +1,4 @@
1import { beforeAll, describe, expect, test } from 'bun:test'
1import { describe, expect, test } from 'bun:test'
22import { mkdtemp, rm, writeFile } from 'node:fs/promises'
33import { tmpdir } from 'node:os'
44import { join } from 'node:path'
@@ -82,8 +82,8 @@ describe('Pi bench session authority', () => {
8282 })
8383})
8484
85let artifact: string
86beforeAll(async () => { artifact = (await buildRuntime()).artifact }, 20000)
85// Build setup uses the build API's bounded command lifecycle, before test timers start.
86const { artifact } = await buildRuntime()
8787async function withNative(run: (harness: ReturnType<typeof openRuntimeHarness>) => Promise<void>) {
8888 const harness = openRuntimeHarness(artifact)
8989 try { await run(harness) }
tests/unit/sensors.test.tsmodified+3-3
Changes to tests/unit/sensors.test.ts
@@ -1,4 +1,4 @@
1import { beforeAll, describe, expect, test } from 'bun:test'
1import { describe, expect, test } from 'bun:test'
22import { command } from '@stacksjs/testing'
33import { join } from 'node:path'
44import fixture from '../fixtures/sensors/bench-v1.json'
@@ -199,8 +199,8 @@ describe('sensor clocks and independent health', () => {
199199 })
200200})
201201
202let nativeDirectory: string
203beforeAll(async () => { nativeDirectory = (await buildRuntime()).directory }, 20000)
202// A cold native build must settle before the sensor test's own timing begins.
203const { directory: nativeDirectory } = await buildRuntime()
204204describe('native capture and simulator replay', () => {
205205 test('the native C++ codec preserves independently encoded records byte-for-byte', async () => {
206206 const wire = Buffer.concat(['imu', 'barometer', 'gnss'].map(kind => Buffer.from(bytes(kind as SensorKind))))