Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/core-profiles.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: node --expose-gc --import tsx scripts/profile-core.mjs artifacts/core-profiles
- run: node --expose-gc scripts/profile-core.mjs artifacts/core-profiles
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
if: always()
with:
Expand Down
6 changes: 4 additions & 2 deletions docs/guides/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,12 @@ After running both corpus audits, add `--corpus` to compare both revisions in co
Run the manual **Core workload profiles** workflow for isolated profiles of the current revision, or use Node 24 locally during a reserved quiet slot:

```sh
node --expose-gc --import tsx scripts/profile-core.mjs artifacts/core-profiles
node --expose-gc scripts/profile-core.mjs artifacts/core-profiles
```

Choose a new output directory for each run. Exclusive directory creation prevents simultaneous runs from overwriting one another or earlier results. The output includes raw CPU profiles, compressed sampled-allocation profiles, completed-output hashes, source and lockfile hashes, and runtime metadata. The run fails if its source, lockfile or profiling script changes while it runs. Workloads separate parsing, parsing with HTML rendering, cached-AST rendering, and progressive updates using the existing fixtures. Progressive profiles consume each update without retaining earlier rendered strings; a separate trace hash checks every update outside profiling.
The script uses the installed project TypeScript compiler and build configuration to emit fresh JavaScript into the output directory before profiling. It leaves the working `dist` directory untouched and records the compiler version, build inputs and emitted module hashes. Compilation is outside profiling. Use plain Node without module loaders or preload hooks, since loader transforms can add costs absent from the shipped JavaScript.

Choose a new output directory for each run. Exclusive directory creation prevents simultaneous runs from overwriting one another or earlier results. The output includes raw CPU profiles, compressed sampled-allocation profiles, completed-output hashes, source and lockfile hashes, and runtime metadata. The run fails if its source, build inputs, emitted JavaScript, lockfile or profiling script changes while it runs. Workloads separate parsing, parsing with HTML rendering, cached-AST rendering, and progressive updates using the existing fixtures. Progressive profiles consume each update without retaining earlier rendered strings; a separate trace hash checks every update outside profiling.

These profiles identify leads, not throughput or startup improvements. CPU attribution is sampled separately for each workload. Sampled allocation includes collected objects and does not measure retained heap, RSS or peak memory. Confirm promising changes with repeated equivalent-work comparisons and unchanged size gates.

Expand Down
61 changes: 54 additions & 7 deletions scripts/profile-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,42 @@ import assert from 'node:assert/strict'
import { Session } from 'node:inspector'
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { resolve, relative, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { mkdir, readFile, writeFile, readdir } from 'node:fs/promises'
import { resolve, relative, dirname, sep } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { gzipSync } from 'node:zlib'

const root = fileURLToPath(new URL('..', import.meta.url))
const directory = resolve(process.argv[2] || 'artifacts/core-profiles')
const smoke = process.argv.includes('--smoke')
assert.equal(typeof global.gc, 'function', 'Run Node with --expose-gc')
// NODE_OPTIONS uses spaces and double quotes, with backslash escapes inside quotes.
function nodeOptionsArguments(value) {
const args = []
let token = '', quoted = false
for (let index = 0; index < value.length; index++) {
const char = value[index]
if (char === '\\' && quoted) {
assert.ok(index + 1 < value.length, 'Invalid NODE_OPTIONS escape')
token += value[++index]
} else if (char === '"') {
quoted = !quoted
} else if (char === ' ' && !quoted) {
if (token) args.push(token)
token = ''
} else token += char
}
assert.ok(!quoted, 'Unterminated NODE_OPTIONS quote')
if (token) args.push(token)
return args
}
const preloadFlags = ['--import', '--loader', '--experimental-loader', '--require', '-r']
const nodeArguments = [...process.execArgv, ...nodeOptionsArguments(process.env.NODE_OPTIONS ?? '')]
const hasPreload = nodeArguments.some(arg => {
const name = arg.startsWith('--') ? arg.split('=', 1)[0].replaceAll('_', '-') : arg
return preloadFlags.includes(name)
})
assert.ok(!hasPreload, 'Run with plain Node, without module loaders or preload hooks')
await mkdir(dirname(directory), { recursive: true })
try {
await mkdir(directory)
Expand All @@ -19,20 +46,33 @@ try {
throw error
}
const sha = value => createHash('sha256').update(value).digest('hex')
async function snapshot() {
const runtimeDirectory = resolve(directory, 'runtime')
async function snapshot(includeRuntime = true) {
const sourceFiles = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', 'src'], { cwd: root, encoding: 'utf8' }).trim().split('\n').filter(Boolean).sort()
return {
commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(),
trackedDirtyState: execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], { cwd: root, encoding: 'utf8' }),
programSHA256: sha(await readFile(fileURLToPath(import.meta.url))),
lockfileSHA256: sha(await readFile(resolve(root, 'pnpm-lock.yaml'))),
buildInputs: Object.fromEntries(await Promise.all(['package.json', 'tsconfig.json', 'tsconfig.build.json'].map(async path => [path, sha(await readFile(resolve(root, path)))]))),
compilerVersion: JSON.parse(await readFile(resolve(root, 'node_modules/typescript/package.json'), 'utf8')).version,
...(includeRuntime && { runtime: 'native-js', runtimeModules: Object.fromEntries(await Promise.all(
(await readdir(runtimeDirectory, { recursive: true })).filter(path => path.endsWith('.js')).sort()
.map(async path => [path, sha(await readFile(resolve(runtimeDirectory, path)))]),
)) }),
sources: Object.fromEntries(await Promise.all(sourceFiles.map(async path => [path, sha(await readFile(resolve(root, path)))]))),
}
}
const beforeBuild = await snapshot(false)
execFileSync(process.execPath, [resolve(root, 'node_modules/typescript/bin/tsc'), '-p', resolve(root, 'tsconfig.build.json'), '--outDir', runtimeDirectory], { cwd: root, stdio: 'pipe' })
await writeFile(resolve(runtimeDirectory, 'package.json'), '{"type":"module"}\n')
assert.deepEqual(await snapshot(false), beforeBuild, 'Build inputs changed while compiling')
const frozen = await snapshot()
await writeFile(resolve(directory, 'source.json'), JSON.stringify(frozen, null, 2))
const [{ parseMarkdown }, { renderHtml }, { streamingMarkdownExtension }] = await Promise.all([
import('../src/parser.ts'), import('../src/html.ts'), import('../src/extensions/streaming.ts'),
import(pathToFileURL(resolve(runtimeDirectory, 'parser.js')).href),
import(pathToFileURL(resolve(runtimeDirectory, 'html.js')).href),
import(pathToFileURL(resolve(runtimeDirectory, 'extensions/streaming.js')).href),
])
const fixtures = []
for (const name of smoke ? ['small-doc.md'] : ['small-doc.md', 'prose-heavy.md', 'code-heavy.md', 'tables-lists.md', 'malformed.md', 'ai-response.md']) {
Expand Down Expand Up @@ -74,7 +114,14 @@ for (const fixture of smoke ? [{ name: 'ai-prefixes', source: response.slice(0,
const session = new Session()
session.connect()
const post = (method, params = {}) => new Promise((yes, no) => session.post(method, params, (error, result) => error ? no(error) : yes(result)))
const frame = value => ({ function: value.functionName || '(anonymous)', url: value.url?.startsWith('file:') ? relative(root, fileURLToPath(value.url)) : value.url, line: value.lineNumber })
const frame = value => {
let url = value.url
if (url?.startsWith('file:')) {
const path = fileURLToPath(url)
url = path.startsWith(runtimeDirectory + sep) ? 'runtime/' + relative(runtimeDirectory, path).split(sep).join('/') : relative(root, path).split(sep).join('/')
}
return { function: value.functionName || '(anonymous)', url, line: value.lineNumber }
}
const consume = output => { sink += typeof output === 'string' ? output.length : output.children.length }
const rows = []
try {
Expand Down Expand Up @@ -128,7 +175,7 @@ const receipt = {
schemaVersion: 1, smoke, ...frozen, sourceStable: true,
node: process.version, versions: process.versions,
rows, sink, recordedAt: new Date().toISOString(),
limitations: 'Discovery profiles, not throughput or startup comparisons. CPU attribution is sampled exclusive time per workload, never add percentages across workloads. Allocation is sampled including collected objects, not retained heap, RSS, peak memory or an exact allocation count. TS source instrumentation and inspector overhead can differ from production bundles. Repeat promising leads with equivalent completed-work controls before changing production.',
limitations: 'Discovery profiles, not throughput or startup comparisons. CPU attribution is sampled exclusive time per workload, never add percentages across workloads. Allocation is sampled including collected objects, not retained heap, RSS, peak memory or an exact allocation count. The runtime directory is compiled from the frozen checkout before sampling, without TS loaders. Compilation is outside profiling. Inspector overhead can still differ from production execution. Repeat promising leads with equivalent completed-work controls before changing production.',
}
await writeFile(resolve(directory, 'summary.json'), JSON.stringify(receipt, null, 2))
console.log(JSON.stringify({ commit: receipt.commit, workloads: rows.length, directory, smoke }))
17 changes: 15 additions & 2 deletions tests/core-profiles.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
Expand All @@ -9,16 +10,27 @@ it('retains readable CPU/allocation profiles and completed-output metadata', ()
const root = mkdtempSync(join(tmpdir(), 'markdown-profile-'))
const directory = join(root, 'profiles')
try {
execFileSync(process.execPath, ['--expose-gc', '--import', 'tsx', 'scripts/profile-core.mjs', directory, '--smoke'], { timeout: 15000, stdio: 'pipe' })
execFileSync(process.execPath, ['--expose-gc', 'scripts/profile-core.mjs', directory, '--smoke'], { timeout: 15000, stdio: 'pipe' })
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const receipt = JSON.parse(readFileSync(join(directory, 'summary.json'), 'utf8'))
expect(receipt.smoke).toBe(true)
expect(receipt.sourceStable).toBe(true)
expect(receipt.runtime).toBe('native-js')
expect(receipt.compilerVersion).toMatch(/^\d+\.\d+\.\d+/)
const parserBytes = readFileSync(join(directory, 'runtime/parser.js'))
expect(receipt.runtimeModules['parser.js']).toBe(createHash('sha256').update(parserBytes).digest('hex'))
expect(() => execFileSync(process.execPath, ['--expose-gc', '--import', 'tsx', 'scripts/profile-core.mjs', join(root, 'instrumented'), '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('without module loaders or preload hooks')
expect(() => execFileSync(process.execPath, ['--expose-gc', '-r', 'node:fs', 'scripts/profile-core.mjs', join(root, 'preloaded'), '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('without module loaders or preload hooks')
expect(() => execFileSync(process.execPath, ['--expose-gc', '--experimental_loader=node:fs', 'scripts/profile-core.mjs', join(root, 'loader-alias'), '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('without module loaders or preload hooks')
for (const options of ['"--require" node:fs', '--"require"=node:fs', String.raw`"--requ\ire" node:fs`, '"--experimental_loader" node:fs']) {
expect(() => execFileSync(process.execPath, ['--expose-gc', 'scripts/profile-core.mjs', join(root, 'environment-preload'), '--smoke'], { timeout: 15000, stdio: 'pipe', env: { ...process.env, NODE_OPTIONS: options } })).toThrow('without module loaders or preload hooks')
}
expect(() => execFileSync(process.execPath, ['--expose-gc', 'scripts/profile-core.mjs', directory, '--smoke'], { timeout: 15000, stdio: 'pipe', env: { ...process.env, NODE_OPTIONS: '--diagnostic-dir="/tmp/a --require-looking path"' } })).toThrow('Output directory already exists')
expect(receipt.programSHA256).toMatch(/^[a-f0-9]{64}$/)
expect(receipt.sources).toHaveProperty("src/parser.ts")
expect(receipt.rows).toHaveLength(4)
expect(receipt.commit).toMatch(/^[a-f0-9]{40}$/)
const preservedSummary = readFileSync(join(directory, 'summary.json'), 'utf8')
expect(() => execFileSync(process.execPath, ['--expose-gc', '--import', 'tsx', 'scripts/profile-core.mjs', directory, '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('Output directory already exists')
expect(() => execFileSync(process.execPath, ['--expose-gc', 'scripts/profile-core.mjs', directory, '--smoke'], { timeout: 15000, stdio: 'pipe' })).toThrow('Output directory already exists')
expect(readFileSync(join(directory, 'summary.json'), 'utf8')).toBe(preservedSummary)
for (const row of receipt.rows) {
expect(row.cpuCompletedOperations).toBeGreaterThan(0)
Expand All @@ -27,6 +39,7 @@ it('retains readable CPU/allocation profiles and completed-output metadata', ()
if (row.name.endsWith('-stream')) expect(row.streamTraceSHA256).toMatch(/^[a-f0-9]{64}$/)
const cpu = JSON.parse(readFileSync(join(directory, row.name + '.cpuprofile'), 'utf8'))
expect(cpu.nodes.length).toBeGreaterThan(0)
expect(cpu.nodes.some((node: { callFrame: { functionName: string; url?: string } }) => node.callFrame.functionName === '__name' && node.callFrame.url?.includes('/runtime/'))).toBe(false)
expect(cpu.samples.length).toBe(cpu.timeDeltas.length)
const heap = JSON.parse(gunzipSync(readFileSync(join(directory, row.name + '.heapprofile.json.gz'))).toString())
expect(heap.head.callFrame).toBeDefined()
Expand Down
Loading