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
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}