Skip to content

Commit 18ca63a

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(design-diff): await native Bun process exits in benchmark runner
1 parent e279fdd commit 18ca63a

4 files changed

Lines changed: 111 additions & 20 deletions

File tree

scripts/design-diff/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ scripts/design-diff/
102102
infrastructure.ts Rendering lockfile dependency closure
103103
report.ts Value previews and bounded JSON serialization
104104
benchmark.ts Immutable-engine historical replay
105+
process.ts Native Bun process status and bounded diagnostics
105106
benchmark/comparisons.json Frozen original/holdout comparison manifest
106107
memory.ts Bun parser-batch garbage collection
107108
tailwind.ts Pinned compiler and trusted merge convention
@@ -339,6 +340,8 @@ bun --no-env-file scripts/design-diff/benchmark.ts \
339340
Fetch manifest commit objects beforehand; missing history fails explicitly. The runner verifies
340341
the frozen comparison commits and GitHub file sets. Cache identity includes engine SHA, trusted
341342
config, lockfile, runtime and comparison commits, with report-content verification before reuse.
342-
It records per-comparison elapsed time and peak RSS separately from deterministic reports.
343+
It awaits native Bun process exit status and records per-comparison elapsed time and peak RSS
344+
separately from deterministic reports. Failed runs retain bounded stderr diagnostics in a
345+
separate file, without printing source findings to logs.
343346
`/usr/bin/time` is required (macOS or Linux); source findings are not printed. Review original
344347
and holdout rates separately, and inspect every disagreement against the source label.

scripts/design-diff/benchmark.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { execFileSync, spawn } from 'node:child_process'
1+
import { execFileSync } from 'node:child_process'
22
import { createHash } from 'node:crypto'
33
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
44
import path from 'node:path'
55
import { parseArgs } from 'node:util'
6+
import { runProcess } from '#design-diff/process'
67
import type { Report } from '#design-diff/types'
78

89
interface Comparison {
@@ -150,10 +151,11 @@ export async function benchmark(args = process.argv.slice(2)): Promise<void> {
150151
const started = performance.now()
151152
const env = { ...process.env }
152153
for (const key of ['DESIGN_DIFF_PR', 'DESIGN_DIFF_ENGINE_SHA', 'HEAD_SHA']) delete env[key]
153-
const timeArgs = process.platform === 'darwin' ? ['-l'] : ['-v']
154-
const proc = spawn(
155-
'/usr/bin/time',
154+
const metricsFile = `${stem}.time.txt`
155+
const timeArgs = process.platform === 'darwin' ? ['-l'] : ['-v', '-o', metricsFile]
156+
const execution = await runProcess(
156157
[
158+
'/usr/bin/time',
157159
...timeArgs,
158160
process.execPath,
159161
'--no-env-file',
@@ -165,22 +167,20 @@ export async function benchmark(args = process.argv.slice(2)): Promise<void> {
165167
'--output',
166168
reportFile,
167169
],
168-
{ cwd: engine, env, stdio: ['ignore', 'ignore', 'pipe'], detached: true }
170+
engine,
171+
env
169172
)
170-
let metrics = ''
171-
proc.stderr.on('data', (chunk: Buffer) => {
172-
if (metrics.length < 65536) metrics += chunk.toString()
173-
})
174-
const timeout = setTimeout(
175-
() => {
176-
if (proc.pid) process.kill(-proc.pid, 'SIGKILL')
177-
},
178-
15 * 60 * 1000
179-
)
180-
result.exitCode = await new Promise<number | null>((resolve, reject) => {
181-
proc.on('error', reject)
182-
proc.on('close', resolve)
183-
}).finally(() => clearTimeout(timeout))
173+
result.exitCode = execution.exitCode
174+
const metrics =
175+
process.platform === 'linux' && existsSync(metricsFile)
176+
? readFileSync(metricsFile, 'utf8')
177+
: execution.stderr
178+
if (execution.exitCode !== 0 || execution.timedOut)
179+
writeFileSync(
180+
`${stem}.stderr.txt`,
181+
execution.stderr + (execution.truncated ? '\n[stderr truncated at 65536 bytes]\n' : ''),
182+
{ mode: 0o600 }
183+
)
184184
result.seconds = Math.round((performance.now() - started) / 10) / 100
185185
const rss =
186186
process.platform === 'darwin'
@@ -189,6 +189,7 @@ export async function benchmark(args = process.argv.slice(2)): Promise<void> {
189189
result.peakMemoryBytes = rss
190190
? Number(rss[1]) * (process.platform === 'darwin' ? 1 : 1024)
191191
: null
192+
if (execution.timedOut) throw new Error('Analysis deadline exceeded')
192193
if (!existsSync(reportFile)) throw new Error('Engine did not produce a report')
193194
const bytes = readFileSync(reportFile)
194195
const report = JSON.parse(bytes.toString()) as Report

scripts/design-diff/process.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/** Native Bun subprocess contract used by the benchmark, independent of Node stream events. */
2+
interface Runtime {
3+
spawn(
4+
args: string[],
5+
options: {
6+
cwd: string
7+
env: NodeJS.ProcessEnv
8+
stdin: 'ignore'
9+
stdout: 'ignore'
10+
stderr: 'pipe'
11+
detached: true
12+
}
13+
): { pid: number; exited: Promise<number>; stderr: ReadableStream<Uint8Array> }
14+
}
15+
16+
/** Await the process exit status itself and drain bounded diagnostics separately. */
17+
export async function runProcess(
18+
args: string[],
19+
cwd: string,
20+
env: NodeJS.ProcessEnv,
21+
milliseconds = 900000
22+
) {
23+
const runtime = (globalThis as typeof globalThis & { Bun?: Runtime }).Bun
24+
if (!runtime) throw new Error('Benchmark subprocesses require Bun')
25+
const proc = runtime.spawn(args, {
26+
cwd,
27+
env,
28+
stdin: 'ignore',
29+
stdout: 'ignore',
30+
stderr: 'pipe',
31+
detached: true,
32+
})
33+
const reader = proc.stderr.getReader()
34+
const chunks: Uint8Array[] = []
35+
let bytes = 0
36+
let truncated = false
37+
const drain = (async () => {
38+
for (;;) {
39+
const { done, value } = await reader.read()
40+
if (done) break
41+
const remaining = Math.max(0, 65536 - bytes)
42+
if (value.length > remaining) truncated = true
43+
if (remaining) {
44+
chunks.push(value.subarray(0, remaining))
45+
bytes += Math.min(remaining, value.length)
46+
}
47+
}
48+
return Buffer.concat(chunks).toString('utf8')
49+
})()
50+
let timedOut = false
51+
const timeout = setTimeout(() => {
52+
timedOut = true
53+
try {
54+
process.kill(-proc.pid, 'SIGKILL')
55+
} catch (error) {
56+
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
57+
}
58+
}, milliseconds)
59+
try {
60+
const [exitCode, stderr] = await Promise.all([proc.exited, drain])
61+
return { exitCode, stderr, truncated, timedOut }
62+
} finally {
63+
clearTimeout(timeout)
64+
}
65+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { execFileSync } from 'node:child_process'
2+
import { fileURLToPath } from 'node:url'
3+
import { expect, it } from 'vitest'
4+
5+
const runner = fileURLToPath(new URL('../process.ts', import.meta.url))
6+
7+
it.each([
8+
['exit status', 'process.stderr.write("metric");process.exit(7)', 5000, 7, false, false, 6],
9+
['bounded diagnostics', 'process.stderr.write("x".repeat(100000))', 5000, 0, false, true, 65536],
10+
['deadline', 'await new Promise(resolve=>setTimeout(resolve,30000))', 50, null, true, false, 0],
11+
])(
12+
'measures native Bun subprocess %s',
13+
(_name, source, deadline, code, timedOut, truncated, bytes) => {
14+
const script = `import {runProcess} from ${JSON.stringify(runner)};const result=await runProcess([process.execPath,'--no-env-file','-e',${JSON.stringify(source)}],process.cwd(),process.env,${deadline});process.stdout.write(JSON.stringify({...result,stderr:result.stderr.length}));`
15+
const result = JSON.parse(
16+
execFileSync('bun', ['--no-env-file', '-e', script], { encoding: 'utf8', timeout: 10000 })
17+
)
18+
expect(result).toMatchObject({ timedOut, truncated, stderr: bytes })
19+
if (code === null) expect(result.exitCode).not.toBe(0)
20+
else expect(result.exitCode).toBe(code)
21+
}
22+
)

0 commit comments

Comments
 (0)