also looking at this
test: retain bounded browser launch evidence on failure
#70
18 files
+620
-23
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -30,6 +30,34 @@ jobs: | ||
| 30 | 30 | - run: bun run parity:check |
| 31 | 31 | - run: python3 -m py_compile resources/functions/drone/camera/imx500_worker.py |
| 32 | 32 | - 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 | |
| 33 | 61 | - name: Verify a kernel-confirmed full input pipe on Linux |
| 34 | 62 | run: bun test tests/unit/runtime.test.ts --test-name-pattern 'kernel-confirmed full input FIFO' |
| 35 | 63 | - name: Verify an actual partial host image write and fresh-process recovery |
| @@ -28,6 +28,35 @@ stream. Native Stacks process capture drains stdout and stderr concurrently and | ||
| 28 | 28 | cleans up the owned process group on POSIX before reporting failure. This also |
| 29 | 29 | applies to SDK inspection and Pico 2 builds; it does not install firmware. |
| 30 | 30 | |
| 31 | Native runtime and sensor tests await build initialization before their test timers | |
| 32 | start. An unrelated setup hook must not interrupt the build API's owned command | |
| 33 | cleanup. Runtime response and test deadlines are unchanged. Build failures identify | |
| 34 | the configure, compile, contracts or SDK inspection stage. | |
| 35 | ||
| 36 | CI also compiles a fresh disposable source copy whose real CMake configure takes | |
| 37 | at least 21 seconds, crossing the former 20-second setup hook. It then runs the | |
| 38 | original Linux full-input FIFO case, retains real tool exit observations and checks | |
| 39 | that no owned tool groups remain. Run the same qualification locally with a new | |
| 40 | evidence directory: | |
| 41 | ||
| 42 | ```sh | |
| 43 | bun scripts/qualify-runtime-cold-build.ts /absolute/path/to/new-cold-build-evidence | |
| 44 | ``` | |
| 45 | ||
| 46 | On macOS the qualification uses the native boot/clean-exit case; the kernel FIFO | |
| 47 | case is Linux-only. The qualification retains its owned source and logs, including | |
| 48 | failures. It does not access a board or measure flight timing. | |
| 49 | ||
| 50 | The qualification supervisor derives its aggregate budget from the three separate | |
| 51 | 120-second build commands, their settlement allowance, the selected test's existing | |
| 52 | deadline and startup allowance. It captures at most 2 MB per stream concurrently, | |
| 53 | retains failed output and checks both parent and recorded tool process groups. | |
| 54 | This test-only aggregate budget does not extend the build API or runtime deadlines. | |
| 55 | CI separately interrupts the actual test parent while CMake is active and makes | |
| 56 | the early capture receipt path a directory. That expected reporting refusal must | |
| 57 | still 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 | ||
| 31 | 60 | ```sh |
| 32 | 61 | ./buddy runtime:build |
| 33 | 62 | ./buddy runtime:bench --ticks 1000 |
| @@ -3,9 +3,9 @@ import { runBoundedHostProcess } from '../../host/process' | ||
| 3 | 3 | |
| 4 | 4 | export const picoSdkRevision = '079c6f39023649b154152db30f1d781e884879bc' |
| 5 | 5 | export interface RuntimeBuildOptions { project?: string, target?: 'host' | 'pico2', sdk?: string, toolchain?: string, watchdogTest?: boolean, sensors?: boolean } |
| 6 | async 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 }) | |
| 6 | async 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 }) | |
| 9 | 9 | return result.stdout.trim() |
| 10 | 10 | } |
| 11 | 11 | /** Local tools only; no shell interpolation, SDK download, firmware flashing or device access. */ |
| @@ -20,14 +20,14 @@ export async function buildRuntime(options: RuntimeBuildOptions = {}) { | ||
| 20 | 20 | const sdk = options.sdk ?? env.PICO_SDK_PATH |
| 21 | 21 | if (!sdk) throw new Error('Provide --sdk or PICO_SDK_PATH with the pinned Pico SDK 2.3.1 checkout') |
| 22 | 22 | 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') | |
| 24 | 24 | // 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') | |
| 26 | 26 | if (options.toolchain) env.PICO_TOOLCHAIN_PATH = resolve(options.toolchain) |
| 27 | 27 | } |
| 28 | 28 | 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) | |
| 32 | 32 | return { target, directory, artifact: join(directory, target === 'host' ? `drone_runtime_host${process.platform === 'win32' ? '.exe' : ''}` : 'drone_runtime.uf2'), physicalFlightEnabled: false as const } |
| 33 | 33 | } |
| @@ -0,0 +1,134 @@ | ||
| 1 | import assert from 'node:assert/strict' | |
| 2 | import { cp, mkdir, readFile, writeFile } from 'node:fs/promises' | |
| 3 | import { join, resolve } from 'node:path' | |
| 4 | import process from 'node:process' | |
| 5 | import { 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. | |
| 9 | interface ToolEvent { event: 'start' | 'spawn' | 'exit', stage: string, groupId: number, pid: number, exitCode?: number, elapsedMs?: number } | |
| 10 | assert(process.platform !== 'win32', 'Cold build qualification requires POSIX process groups') | |
| 11 | assert(process.argv[2], 'Provide a new owned evidence directory') | |
| 12 | const reportingFault = process.argv[3] === '--interrupt-before-capture-report' | |
| 13 | assert(process.argv.length <= 4 && (!process.argv[3] || reportingFault), 'Unknown qualification mode') | |
| 14 | const repository = resolve(import.meta.dir, '..'), evidence = resolve(process.argv[2]!) | |
| 15 | await mkdir(evidence) | |
| 16 | if (reportingFault) await mkdir(join(evidence, 'capture-result.json')) | |
| 17 | const project = join(evidence, 'project'), tools = join(evidence, 'tools'), eventsPath = join(evidence, 'tool-events.ndjson') | |
| 18 | await mkdir(join(project, 'firmware'), { recursive: true }) | |
| 19 | await mkdir(tools) | |
| 20 | await cp(join(repository, 'firmware/runtime'), join(project, 'firmware/runtime'), { recursive: true }) | |
| 21 | await writeFile(join(project, 'bunfig.toml'), '[test]\npreload = []\n') | |
| 22 | const cmakePath = join(project, 'firmware/runtime/CMakeLists.txt'), cmake = await readFile(cmakePath, 'utf8') | |
| 23 | assert(cmake.startsWith('cmake_minimum_required(VERSION 3.20)'), 'Expected recorded runtime CMake entrypoint') | |
| 24 | await 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)')) | |
| 25 | assert(!await Bun.file(join(project, 'storage/runtime-build/host/CMakeCache.txt')).exists(), 'Qualification must start cold') | |
| 26 | assert(!/\s/.test(process.execPath), 'Tool witness needs a single executable shebang path') | |
| 27 | for (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} | |
| 31 | import { appendFileSync } from 'node:fs' | |
| 32 | const stage = ${JSON.stringify(tool)} === 'ctest' ? 'contracts' : process.argv[2] === '-S' ? 'configure' : 'compile' | |
| 33 | const began = performance.now() | |
| 34 | const owner = { stage, groupId: process.pid, pid: process.pid } | |
| 35 | appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify({ ...owner, event: 'start' }) + '\n') | |
| 36 | const child = Bun.spawn([${JSON.stringify(executable)}, ...process.argv.slice(2)], { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }) | |
| 37 | const event = { stage, groupId: process.pid, pid: child.pid } | |
| 38 | appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify({ ...event, event: 'spawn' }) + '\n') | |
| 39 | if (${reportingFault} && stage === 'configure') { | |
| 40 | process.kill(child.pid, 0) | |
| 41 | process.kill(process.ppid, 'SIGKILL') | |
| 42 | } | |
| 43 | const exitCode = await child.exited | |
| 44 | appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify({ ...event, event: 'exit', exitCode, elapsedMs: performance.now() - began }) + '\n') | |
| 45 | process.exit(exitCode) | |
| 46 | ` | |
| 47 | await writeFile(join(tools, tool), source, { mode: 0o700 }) | |
| 48 | } | |
| 49 | const pattern = process.platform === 'linux' ? 'kernel-confirmed full input FIFO' : 'separate supervised boots' | |
| 50 | const buildCommandTimeoutMs = 120000, selectedTestTimeoutMs = process.platform === 'linux' ? 20000 : 5000 | |
| 51 | const qualificationTimeoutMs = coldBuildQualificationTimeout(buildCommandTimeoutMs, selectedTestTimeoutMs) | |
| 52 | let result: Awaited<ReturnType<typeof captureColdBuild>> | undefined, failure: unknown | |
| 53 | const began = performance.now() | |
| 54 | try { | |
| 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 | } | |
| 58 | catch (error) { failure = error } | |
| 59 | // Retain the capture outcome even if a later cleanup observation itself fails. | |
| 60 | const reportingErrors: { stage: string, code: string | null }[] = [] | |
| 61 | try { | |
| 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 | } | |
| 64 | catch (error) { | |
| 65 | failure ??= error | |
| 66 | reportingErrors.push({ stage: 'capture-result', code: (error as NodeJS.ErrnoException).code ?? null }) | |
| 67 | } | |
| 68 | let eventText = '' | |
| 69 | try { eventText = await readFile(eventsPath, 'utf8') } | |
| 70 | catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } | |
| 71 | const events: ToolEvent[] = [] | |
| 72 | for (const line of eventText.split('\n').filter(Boolean)) { | |
| 73 | try { events.push(JSON.parse(line)) } | |
| 74 | catch (error) { failure ??= error } | |
| 75 | } | |
| 76 | const groups = [...new Set(events.map(event => event.groupId))] | |
| 77 | assert(groups.every(group => Number.isSafeInteger(group) && group > 1), 'Tool witnesses must identify owned process groups') | |
| 78 | const probeErrors: { group: number, code: string }[] = [] | |
| 79 | const cleanupErrors: { group: number, code: string | null }[] = [] | |
| 80 | function 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 | } | |
| 94 | const beforeCleanup = groups.filter(alive) | |
| 95 | for (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 | } | |
| 104 | const cleanupDeadline = performance.now() + 5000 | |
| 105 | while (groups.some(alive) && performance.now() < cleanupDeadline) await Bun.sleep(25) | |
| 106 | const afterCleanup = groups.filter(alive) | |
| 107 | await writeFile(join(evidence, 'stdout.log'), result?.stdout ?? '') | |
| 108 | await writeFile(join(evidence, 'stderr.log'), result?.stderr ?? '') | |
| 109 | const 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 } | |
| 110 | await writeFile(join(evidence, 'receipt.json'), `${JSON.stringify(report, null, 2)}\n`) | |
| 111 | assert.equal(afterCleanup.length, 0, 'Owned tool groups must settle before any evidence removal') | |
| 112 | assert.equal(cleanupErrors.length, 0, 'Unexpected tool cleanup errors must remain qualification failures') | |
| 113 | assert(result, 'Actual parent capture must be available') | |
| 114 | assert.equal(result.rootGroupRemaining, false, 'The actual parent process group must settle') | |
| 115 | if (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 | } | |
| 121 | else { | |
| 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 | } | |
| 134 | console.log(JSON.stringify(report)) | |
| @@ -0,0 +1,114 @@ | ||
| 1 | import { Buffer } from 'node:buffer' | |
| 2 | import process from 'node:process' | |
| 3 | ||
| 4 | /** Test-only ownership of the aggregate build/test process, beyond a single command's budget. */ | |
| 5 | export 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 | ||
| 19 | export const coldBuildCaptureBytes = 2_000_000 | |
| 20 | export const coldBuildSettlementMs = 5000 | |
| 21 | ||
| 22 | /** Three build commands, each with its own settlement allowance, then the unchanged selected test. */ | |
| 23 | export 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 | ||
| 31 | export 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 | } | |
| @@ -25,6 +25,9 @@ | ||
| 25 | 25 | "./unit/estimation.test.ts", |
| 26 | 26 | "./fixtures/sensors/generate.ts", |
| 27 | 27 | "./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", | |
| 28 | 31 | "./unit/sensors.test.ts", |
| 29 | 32 | "../resources/functions/drone/payloads/**/*.ts", |
| 30 | 33 | "./unit/payloads.test.ts", |
| @@ -0,0 +1,15 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { buildRuntime } from '../../resources/functions/drone/runtime/build' | |
| 6 | ||
| 7 | test('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) | |
| @@ -0,0 +1,62 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { Buffer } from 'node:buffer' | |
| 3 | import process from 'node:process' | |
| 4 | import { captureColdBuild, coldBuildCaptureBytes, coldBuildQualificationTimeout } from '../helpers/runtime-cold-build-supervisor' | |
| 5 | ||
| 6 | const options = { cwd: process.cwd(), env: process.env, timeoutMs: 15000 } | |
| 7 | const run = (source: string, timeoutMs = options.timeoutMs) => captureColdBuild([process.execPath, '-e', source], { ...options, timeoutMs }) | |
| 8 | function 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 | ||
| 13 | test.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 | ||
| 25 | test.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 | ||
| 36 | test.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 | ||
| 47 | test.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 | ||
| 58 | test('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 | }) | |
| @@ -1,4 +1,4 @@ | ||
| 1 | import { beforeAll, describe, expect, test } from 'bun:test' | |
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | 2 | import { mkdtemp, rm, writeFile } from 'node:fs/promises' |
| 3 | 3 | import { tmpdir } from 'node:os' |
| 4 | 4 | import { join } from 'node:path' |
| @@ -82,8 +82,8 @@ describe('Pi bench session authority', () => { | ||
| 82 | 82 | }) |
| 83 | 83 | }) |
| 84 | 84 | |
| 85 | let artifact: string | |
| 86 | beforeAll(async () => { artifact = (await buildRuntime()).artifact }, 20000) | |
| 85 | // Build setup uses the build API's bounded command lifecycle, before test timers start. | |
| 86 | const { artifact } = await buildRuntime() | |
| 87 | 87 | async function withNative(run: (harness: ReturnType<typeof openRuntimeHarness>) => Promise<void>) { |
| 88 | 88 | const harness = openRuntimeHarness(artifact) |
| 89 | 89 | try { await run(harness) } |
| @@ -1,4 +1,4 @@ | ||
| 1 | import { beforeAll, describe, expect, test } from 'bun:test' | |
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | 2 | import { command } from '@stacksjs/testing' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import fixture from '../fixtures/sensors/bench-v1.json' |
| @@ -199,8 +199,8 @@ describe('sensor clocks and independent health', () => { | ||
| 199 | 199 | }) |
| 200 | 200 | }) |
| 201 | 201 | |
| 202 | let nativeDirectory: string | |
| 203 | beforeAll(async () => { nativeDirectory = (await buildRuntime()).directory }, 20000) | |
| 202 | // A cold native build must settle before the sensor test's own timing begins. | |
| 203 | const { directory: nativeDirectory } = await buildRuntime() | |
| 204 | 204 | describe('native capture and simulator replay', () => { |
| 205 | 205 | test('the native C++ codec preserves independently encoded records byte-for-byte', async () => { |
| 206 | 206 | const wire = Buffer.concat(['imu', 'barometer', 'gnss'].map(kind => Buffer.from(bytes(kind as SensorKind)))) |