also looking at this
fix: separate native runtime builds from test deadlines
#69
10 files
+399
-14
| @@ -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 | } | |