From 7352ffac61e62370914807243178fc7403da62fc Mon Sep 17 00:00:00 2001 From: luojiyin Date: Mon, 7 Sep 2026 15:18:05 +0800 Subject: [PATCH 1/3] chore: add source-map heap profiling harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/profile-source-map-heap.mjs for capturing V8 heap snapshots at build/raw/range phases. Verifies lazy lineStarts and sourceGapPrefix do not exist in retained heap until first use. Fixtures: many-nodes (10k paragraphs), segments (256 KiB high density), fenced-code (1 MiB code block), urls (1000 definitions). Documents analysis method in CONTRIBUTING.md. Not added to CI — heap snapshots are large and V8-version-dependent. --- CONTRIBUTING.md | 69 +++++++++++++ package.json | 2 + scripts/profile-source-map-heap.mjs | 148 ++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 scripts/profile-source-map-heap.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2470ef1..fb2c71f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,3 +75,72 @@ downstream source-map integration together. The workflow uses a read-only token and disables persisted checkout credentials because it executes downstream repository code. + +## Source-map heap profiling + +The `profile:source-map` script captures V8 heap snapshots at specific +construction phases to verify that lazy indexes (`lineStarts`, +`sourceGapPrefix`) do not exist in retained heap until first use. + +### Usage + +```bash +# Capture a snapshot +pnpm run profile:source-map -- segments build +pnpm run profile:source-map -- segments raw +pnpm run profile:source-map -- segments range + +# Open in Chrome DevTools → Memory → Load .heapsnapshot +``` + +**Fixtures:** + +| Fixture | Shape | Purpose | +|---|---|---| +| `many-nodes` | 10k short paragraphs | high node count, low segment density | +| `segments` | 256 KiB `&\(` repeats | maximum segment count per node | +| `fenced-code` | 1 MiB code block | single code node, no segments | +| `urls` | 1000 link definitions | URL segment construction | + +**Phases:** + +| Phase | What runs | What should NOT exist in heap | +|---|---|---| +| `build` | `parseMdWithSourceMap()` only | `lineStarts`, `sourceGapPrefix` | +| `raw` | + `getRaw()` on every mapped node | `lineStarts`, `sourceGapPrefix` | +| `range` | + `getSourceRange()` on every text node | (indexes now created on demand) | + +### What to look for in DevTools + +**Retained size dominators** — in the Dominators view, check: + +- AST nodes (`type`, `position`, `children`, `value`) +- Source strings (the original Markdown input) +- Segment objects (`valueStart`, `valueEnd`, `sourceStart`, `sourceEnd`, `kind`) +- Segment arrays (one per mapped node) +- WeakMap state (sourceMap closure → WeakMap → values) +- Lazy indexes (`lineStarts` array, `sourceGapPrefix` arrays) + +The build → raw → range progression should show `lineStarts` and +`sourceGapPrefix` appearing only in the `range` phase. If they appear +in `build` or `raw`, the lazy design is broken. + +### Interpretation + +The goal is a retained-heap breakdown: + +``` +retained heap +├── AST ??% +├── source strings ??% +├── segment objects ??% +├── segment arrays ??% +├── WeakMap state ??% +└── lazy indexes ??% +``` + +If AST + source strings + parser strings dominate, source-map memory +optimization is not worthwhile. If segment objects/arrays are large, +consider a more compact segment representation. + +**Rule: no heap dominator / retained-size evidence → no memory optimization PRs.** diff --git a/package.json b/package.json index 00df19e..b059c4b 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "test:package": "publint && node scripts/test-package.mjs", "prebench:source-map": "pnpm run build", "bench:source-map": "node scripts/bench-source-map.mjs", + "preprofile:source-map": "pnpm run build", + "profile:source-map": "node --expose-gc scripts/profile-source-map-heap.mjs", "test:update": "jest --updateSnapshot", "prepublishOnly": "pnpm run lint && pnpm run build && pnpm run test" }, diff --git a/scripts/profile-source-map-heap.mjs b/scripts/profile-source-map-heap.mjs new file mode 100644 index 0000000..f324565 --- /dev/null +++ b/scripts/profile-source-map-heap.mjs @@ -0,0 +1,148 @@ +// Heap-profile source-map retained structures. +// +// Run: pnpm run profile:source-map -- +// e.g. pnpm run profile:source-map -- segments build +// +// Fixtures: +// many-nodes – 10k small text nodes (flat list) +// segments – 256 KiB high segment-density (&\( repeats) +// fenced-code – 1 MiB fenced code block +// urls – 1000 link definitions +// +// Phases: +// build – parse only (no lazy indexes should exist) +// raw – parse + getRaw on every mapped node +// range – parse + getSourceRange on every mapped node +// +// Outputs a .heapsnapshot to temp/heap-profile/. +// Requires Node >= 19 with --expose-gc. +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; +import v8 from 'node:v8'; + +const require = createRequire(import.meta.url); +const { parseMdWithSourceMap } = require('../dist/lint-md-parser.cjs'); + +// ── Fixtures ──────────────────────────────────────────────────────────── + +function fixtureManyNodes() { + // 10k short paragraphs, each becomes a text node. + // Blank lines required — consecutive lines merge into one paragraph. + const lines = []; + for (let i = 0; i < 10_000; i++) lines.push(`node ${i}`, ''); + return lines.join('\n'); +} + +function fixtureSegments() { + // 256 KiB of alternating & \( — maximizes segment count per node. + const unit = '&\\('; + const count = Math.round((256 * 1024) / unit.length); + return unit.repeat(count); +} + +function fixtureFencedCode() { + // 1 MiB code inside a fenced block — one big code node, no segments. + const line = 'x'.repeat(80); + const lines = []; + const target = 1024 * 1024; + while (lines.join('\n').length < target) lines.push(line); + return '```\n' + lines.join('\n') + '\n```'; +} + +function fixtureUrls() { + // 1000 link definitions with short URLs. + const defs = []; + for (let i = 0; i < 1000; i++) { + defs.push(`[text${i}]: /path/to/resource-${i}?q=${i} "title ${i}"`); + } + return defs.join('\n'); +} + +const FIXTURES = { + 'many-nodes': fixtureManyNodes, + segments: fixtureSegments, + 'fenced-code': fixtureFencedCode, + urls: fixtureUrls, +}; + +// ── Helpers ───────────────────────────────────────────────────────────── + +/** Collect all leaf nodes of a given type from the AST. */ +function collectNodes(root, type) { + const out = []; + (function walk(n) { + if (n.type === type) out.push(n); + for (const c of n.children || []) walk(c); + })(root); + return out; +} + +/** Collect all mapped text/inlineCode/code/link/definition nodes. */ +function collectMappedNodes(root) { + const out = []; + (function walk(n) { + if ( + n.type === 'text' + || n.type === 'inlineCode' + || n.type === 'code' + || n.type === 'link' + || n.type === 'definition' + ) { + out.push(n); + } + for (const c of n.children || []) walk(c); + })(root); + return out; +} + +// ── Main ──────────────────────────────────────────────────────────────── + +const [, , fixtureName, phase] = process.argv; + +if (!fixtureName || !phase || !FIXTURES[fixtureName] || !['build', 'raw', 'range'].includes(phase)) { + console.error( + 'Usage: node --expose-gc scripts/profile-source-map-heap.mjs' + + ` \n` + + ` fixtures: ${Object.keys(FIXTURES).join(', ')}\n` + + ` phases: build, raw, range`, + ); + process.exit(1); +} + +const md = FIXTURES[fixtureName](); + +// Phase 1: build (parse only) +const { ast, sourceMap } = parseMdWithSourceMap(md); + +// Phase 2 or 3: exercise the source map +if (phase === 'raw') { + const nodes = collectMappedNodes(ast); + for (const n of nodes) { + try { sourceMap.getRaw(n); } catch { /* some nodes lack segments */ } + } +} else if (phase === 'range') { + const nodes = collectMappedNodes(ast).filter( + n => n.type === 'text' || n.type === 'inlineCode' || n.type === 'code', + ); + for (const n of nodes) { + if (!n.value) continue; + try { sourceMap.getSourceRange(n, 0, n.value.length); } catch { /* skip */ } + } +} + +// Force GC before snapshot. +if (global.gc) global.gc(); + +// Write snapshot. +const outDir = join(process.cwd(), 'temp', 'heap-profile'); +mkdirSync(outDir, { recursive: true }); +const filename = `${fixtureName}-${phase}-${Date.now()}.heapsnapshot`; +const snapshotPath = join(outDir, filename); +v8.writeHeapSnapshot(snapshotPath); + +console.log(`Snapshot written to ${snapshotPath}`); +console.log(` fixture: ${fixtureName}`); +console.log(` phase: ${phase}`); +console.log(` md size: ${(md.length / 1024).toFixed(1)} KiB (${md.length} chars)`); +console.log(` nodes: ${collectMappedNodes(ast).length} mapped`); From ca325bdfee8cec3666c9456467702f35f17da1ec Mon Sep 17 00:00:00 2001 From: luojiyin Date: Mon, 7 Sep 2026 15:23:19 +0800 Subject: [PATCH 2/3] fix(profiling): address review feedback on heap profile harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fenced-code fixture: replace O(n²) while-loop with calculated repeat count; fixture now produces ~1 MiB without GC pressure - raw phase: remove blanket try-catch on getRaw(); owned nodes with snapshotted offsets should always succeed - range phase: catch only RangeError (non-contiguous ranges); real bugs now propagate instead of being swallowed - fix comment: fenced code does have code-value source mapping --- scripts/profile-source-map-heap.mjs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/scripts/profile-source-map-heap.mjs b/scripts/profile-source-map-heap.mjs index f324565..6d31a64 100644 --- a/scripts/profile-source-map-heap.mjs +++ b/scripts/profile-source-map-heap.mjs @@ -6,7 +6,7 @@ // Fixtures: // many-nodes – 10k small text nodes (flat list) // segments – 256 KiB high segment-density (&\( repeats) -// fenced-code – 1 MiB fenced code block +// fenced-code – 1 MiB fenced code block (single code node with segments) // urls – 1000 link definitions // // Phases: @@ -42,12 +42,12 @@ function fixtureSegments() { } function fixtureFencedCode() { - // 1 MiB code inside a fenced block — one big code node, no segments. + // 1 MiB code inside a fenced block — one large code node with + // code-value source mapping. const line = 'x'.repeat(80); - const lines = []; const target = 1024 * 1024; - while (lines.join('\n').length < target) lines.push(line); - return '```\n' + lines.join('\n') + '\n```'; + const count = Math.ceil(target / (line.length + 1)); + return '```\n' + `${line}\n`.repeat(count) + '```'; } function fixtureUrls() { @@ -119,7 +119,7 @@ const { ast, sourceMap } = parseMdWithSourceMap(md); if (phase === 'raw') { const nodes = collectMappedNodes(ast); for (const n of nodes) { - try { sourceMap.getRaw(n); } catch { /* some nodes lack segments */ } + sourceMap.getRaw(n); } } else if (phase === 'range') { const nodes = collectMappedNodes(ast).filter( @@ -127,7 +127,13 @@ if (phase === 'raw') { ); for (const n of nodes) { if (!n.value) continue; - try { sourceMap.getSourceRange(n, 0, n.value.length); } catch { /* skip */ } + try { + sourceMap.getSourceRange(n, 0, n.value.length); + } catch (error) { + // Only non-contiguous ranges (e.g. across blockquote boundaries) + // are expected to fail. Anything else is a real bug. + if (!(error instanceof RangeError)) throw error; + } } } From d300bd3b832dc46325d6a48f74f5cb249a3552a4 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Mon, 7 Sep 2026 15:26:34 +0800 Subject: [PATCH 3/3] fix(profiling): clean up unused code and fix docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused writeFileSync import and collectNodes function - CONTRIBUTING.md: fenced-code description 'no segments' → 'with source-map segments' - CONTRIBUTING.md: 'every text node' → 'every mapped value node' - CONTRIBUTING.md: qualify sourceGapPrefix with 'for multi-segment ranges' --- CONTRIBUTING.md | 7 ++++--- scripts/profile-source-map-heap.mjs | 12 +----------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb2c71f..f9ef9fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,7 +99,7 @@ pnpm run profile:source-map -- segments range |---|---|---| | `many-nodes` | 10k short paragraphs | high node count, low segment density | | `segments` | 256 KiB `&\(` repeats | maximum segment count per node | -| `fenced-code` | 1 MiB code block | single code node, no segments | +| `fenced-code` | 1 MiB code block | single large code node with source-map segments | | `urls` | 1000 link definitions | URL segment construction | **Phases:** @@ -108,7 +108,7 @@ pnpm run profile:source-map -- segments range |---|---|---| | `build` | `parseMdWithSourceMap()` only | `lineStarts`, `sourceGapPrefix` | | `raw` | + `getRaw()` on every mapped node | `lineStarts`, `sourceGapPrefix` | -| `range` | + `getSourceRange()` on every text node | (indexes now created on demand) | +| `range` | + `getSourceRange()` on every mapped value node | (indexes now created on demand) | ### What to look for in DevTools @@ -122,7 +122,8 @@ pnpm run profile:source-map -- segments range - Lazy indexes (`lineStarts` array, `sourceGapPrefix` arrays) The build → raw → range progression should show `lineStarts` and -`sourceGapPrefix` appearing only in the `range` phase. If they appear +`sourceGapPrefix` (for multi-segment ranges) appearing only in the +`range` phase. If they appear in `build` or `raw`, the lazy design is broken. ### Interpretation diff --git a/scripts/profile-source-map-heap.mjs b/scripts/profile-source-map-heap.mjs index 6d31a64..1962915 100644 --- a/scripts/profile-source-map-heap.mjs +++ b/scripts/profile-source-map-heap.mjs @@ -16,7 +16,7 @@ // // Outputs a .heapsnapshot to temp/heap-profile/. // Requires Node >= 19 with --expose-gc. -import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { createRequire } from 'node:module'; import v8 from 'node:v8'; @@ -68,16 +68,6 @@ const FIXTURES = { // ── Helpers ───────────────────────────────────────────────────────────── -/** Collect all leaf nodes of a given type from the AST. */ -function collectNodes(root, type) { - const out = []; - (function walk(n) { - if (n.type === type) out.push(n); - for (const c of n.children || []) walk(c); - })(root); - return out; -} - /** Collect all mapped text/inlineCode/code/link/definition nodes. */ function collectMappedNodes(root) { const out = [];