mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-11-01 20:10:58 +00:00
React, loaded by Ink, picks its development build unless NODE_ENV is exactly "production". Since React 19.2 that build records a performance.measure() entry on every render (its DevTools "Scheduler" and "Components" tracks), and Node keeps user-timing marks and measures in a process-wide buffer that nothing ever trims. The interactive dashboard re-renders on every progress tick and refresh, so the buffer grows by roughly 700 entries a minute; after a few days the heap hits V8's 4 GB default and the process aborts with "Ineffective mark-compacts near heap limit ... JavaScript heap out of memory". The launcher (dist/cli.js, the entry every client spawns) now defaults NODE_ENV to "production" before importing main.js, which selects the build that records nothing; an explicit NODE_ENV still wins. For that explicit case, a small guard drops the user-timing buffer every 30 s for the lifetime of each Ink UI (dashboard, sessions, context, compare). codeburn records no marks or measures of its own, so nothing is lost. Tests spawn the real launcher against a stub main.js to pin the default, and drive a ticking Ink app in a child Node process to show the development build's backlog, the production build's absence of one, and the guard keeping it bounded.
77 lines
3.1 KiB
TypeScript
77 lines
3.1 KiB
TypeScript
import { spawnSync } from 'node:child_process'
|
|
import { performance } from 'node:perf_hooks'
|
|
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
|
|
import { dropUserTimingEntries, startUserTimingGuard, userTimingEntryCount } from '../src/user-timing-guard.js'
|
|
|
|
// React's development build - the one Ink loads whenever NODE_ENV is not
|
|
// "production" - writes a performance.measure() entry per render into Node's
|
|
// user-timing buffer, and Node never trims that buffer, so a long-lived Ink UI
|
|
// grows without bound until V8 aborts with "JavaScript heap out of memory".
|
|
// The launcher defaults NODE_ENV to "production"; the guard covers an explicit
|
|
// NODE_ENV=development by dropping the entries on a timer.
|
|
|
|
type ProbeReport = { nodeEnv: string | null; renders: number; entriesWhileRunning: number; entriesAfterStop: number }
|
|
|
|
function runProbe(nodeEnv: string, mode: 'guard' | 'plain' = 'plain', durationMs = 500): ProbeReport {
|
|
const result = spawnSync(process.execPath, ['--import', 'tsx', 'tests/fixtures/ink-user-timing-probe.tsx', mode, String(durationMs)], {
|
|
cwd: process.cwd(),
|
|
encoding: 'utf8',
|
|
env: { ...process.env, NODE_ENV: nodeEnv },
|
|
})
|
|
expect(result.status, result.stderr).toBe(0)
|
|
return JSON.parse(result.stdout.trim().split('\n').at(-1)!) as ProbeReport
|
|
}
|
|
|
|
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
|
|
|
afterEach(() => {
|
|
dropUserTimingEntries()
|
|
})
|
|
|
|
describe('user-timing guard', () => {
|
|
it('counts and drops marks and measures', () => {
|
|
performance.mark('codeburn-test-mark')
|
|
performance.measure('codeburn-test-measure')
|
|
expect(userTimingEntryCount()).toBeGreaterThanOrEqual(2)
|
|
|
|
const dropped = dropUserTimingEntries()
|
|
|
|
expect(dropped).toBeGreaterThanOrEqual(2)
|
|
expect(userTimingEntryCount()).toBe(0)
|
|
})
|
|
|
|
it('stops dropping once stopped', async () => {
|
|
const stop = startUserTimingGuard(5)
|
|
stop()
|
|
performance.measure('codeburn-after-stop')
|
|
await sleep(25)
|
|
expect(userTimingEntryCount()).toBe(1)
|
|
})
|
|
|
|
describe('against a real Ink render loop', () => {
|
|
it('React development build leaves an entry backlog behind (the premise)', () => {
|
|
const plain = runProbe('development')
|
|
expect(plain.renders).toBeGreaterThan(1)
|
|
expect(plain.entriesWhileRunning).toBeGreaterThan(plain.renders)
|
|
// Nothing drops them without the guard.
|
|
expect(plain.entriesAfterStop).toBe(plain.entriesWhileRunning)
|
|
})
|
|
|
|
it('React production build (the launcher default) records nothing', () => {
|
|
const production = runProbe('production')
|
|
expect(production.renders).toBeGreaterThan(1)
|
|
expect(production.entriesWhileRunning).toBe(0)
|
|
})
|
|
|
|
it('the guard keeps the backlog bounded while rendering and empties it on stop', () => {
|
|
const plain = runProbe('development')
|
|
const guarded = runProbe('development', 'guard')
|
|
expect(guarded.renders).toBeGreaterThan(1)
|
|
// At most one 10ms tick's worth of entries is ever pending.
|
|
expect(guarded.entriesWhileRunning).toBeLessThan(plain.entriesWhileRunning / 2)
|
|
expect(guarded.entriesAfterStop).toBe(0)
|
|
})
|
|
})
|
|
})
|