diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 15:35:24 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 15:35:24 +0200 |
| commit | 99b02bf8c389a793df5d5986db05eed7e459f7b1 (patch) | |
| tree | bc4e36cfcd3c9ef9b067beed2eb5b68a75a45aa2 /internal/flamegraph | |
| parent | 4ff17c30120d657b966f8a55188ba167dc875e64 (diff) | |
refactor: remove web flamegrapher and keep TUI-only
Diffstat (limited to 'internal/flamegraph')
22 files changed, 1 insertions, 4185 deletions
diff --git a/internal/flamegraph/doc.go b/internal/flamegraph/doc.go index 8ff27d2..02429d3 100644 --- a/internal/flamegraph/doc.go +++ b/internal/flamegraph/doc.go @@ -1,2 +1,2 @@ -// Package flamegraph builds aggregated call trees and rendering inputs for I/O flamegraphs. +// Package flamegraph provides TUI flamegraph aggregation primitives. package flamegraph diff --git a/internal/flamegraph/iordatacollector.go b/internal/flamegraph/iordatacollector.go deleted file mode 100644 index a2ae731..0000000 --- a/internal/flamegraph/iordatacollector.go +++ /dev/null @@ -1,65 +0,0 @@ -package flamegraph - -import ( - "context" - "fmt" - "runtime" - "sync" - - "ior/internal/event" -) - -type IorDataCollector struct { - flamegraphName string - Ch chan *event.Pair - Done chan error - workers []worker -} - -func New(flamegraphName ...string) IorDataCollector { - name := "default" - if len(flamegraphName) > 0 && flamegraphName[0] != "" { - name = flamegraphName[0] - } - - f := IorDataCollector{ - flamegraphName: name, - Ch: make(chan *event.Pair, 4096), - Done: make(chan error, 1), - } - numWorkers := runtime.NumCPU() / 4 - if numWorkers == 0 { - numWorkers = 1 - } - for range numWorkers { - f.workers = append(f.workers, newWorker()) - } - return f -} - -func (f IorDataCollector) Start(ctx context.Context) { - go func() { - defer close(f.Done) - var wg sync.WaitGroup - wg.Add(len(f.workers)) - - for i, worker := range f.workers { - fmt.Println("Starting flamegraph worker", i) - go worker.run(ctx, &wg, f.Ch) - } - wg.Wait() - - iod := f.workers[0].iod - if len(f.workers) > 1 { - for i, w := range f.workers[1:] { - iod = iod.merge(w.iod) - fmt.Println("Worker", i+1, "merged") - } - } - if err := iod.serializeToFile(f.flamegraphName); err != nil { - f.Done <- err - return - } - f.Done <- nil - }() -} diff --git a/internal/flamegraph/layout.go b/internal/flamegraph/layout.go deleted file mode 100644 index c319800..0000000 --- a/internal/flamegraph/layout.go +++ /dev/null @@ -1,78 +0,0 @@ -package flamegraph - -import "fmt" - -// FrameLayout captures renderer-agnostic flamegraph geometry for a single frame. -// -// The layout is reusable by non-SVG renderers (for example SDL or WASM UIs) so -// they can render the same hierarchy without depending on SVG internals. -type FrameLayout struct { - Name string - Title string - Fill string - X float64 - Y float64 - Width float64 - Height float64 - Depth int - Total uint64 - Percent float64 -} - -func sanitizeSVGConfig(cfg SVGConfig) SVGConfig { - if cfg.Width <= 0 || cfg.FrameHeight <= 0 || cfg.FontSize <= 0 || cfg.MinWidthPx <= 0 { - return defaultSVGConfig() - } - if cfg.Title == "" { - cfg.Title = defaultSVGConfig().Title - } - return cfg -} - -func canvasHeightFor(cfg SVGConfig, t *trie) int { - return cfg.FrameHeight*(t.maxDepth+1) + 80 -} - -// BuildFrameLayout builds renderer-agnostic frame coordinates from a flamegraph trie. -func BuildFrameLayout(t *trie, cfg SVGConfig) []FrameLayout { - if t == nil || t.root == nil || t.root.total == 0 { - return nil - } - cfg = sanitizeSVGConfig(cfg) - canvasHeight := canvasHeightFor(cfg, t) - out := make([]FrameLayout, 0, len(t.root.children)) - collectFrameLayout(&out, t.root, t.root.total, cfg, 0, 0, canvasHeight, true) - return out -} - -func collectFrameLayout(out *[]FrameLayout, node *trieNode, rootTotal uint64, - cfg SVGConfig, x float64, depth int, canvasHeight int, isRoot bool) { - - if !isRoot { - w := float64(cfg.Width) * (float64(node.total) / float64(rootTotal)) - if w < cfg.MinWidthPx { - return - } - y := float64(canvasHeight - (depth+1)*cfg.FrameHeight) - pct := 100 * float64(node.total) / float64(rootTotal) - *out = append(*out, FrameLayout{ - Name: node.name, - Title: fmt.Sprintf("%s (%d, %.2f%%)", node.name, node.total, pct), - Fill: frameColor(node.name), - X: x, - Y: y, - Width: w, - Height: float64(cfg.FrameHeight - 1), - Depth: depth, - Total: node.total, - Percent: pct, - }) - } - - cursor := x - for _, child := range node.children { - cw := float64(cfg.Width) * (float64(child.total) / float64(rootTotal)) - collectFrameLayout(out, child, rootTotal, cfg, cursor, depth+1, canvasHeight, false) - cursor += cw - } -} diff --git a/internal/flamegraph/layout_test.go b/internal/flamegraph/layout_test.go deleted file mode 100644 index 8fa7398..0000000 --- a/internal/flamegraph/layout_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package flamegraph - -import ( - "math" - "testing" -) - -func almostEqual(a, b float64) bool { - return math.Abs(a-b) < 1e-6 -} - -func TestBuildFrameLayoutBasicGeometry(t *testing.T) { - tr := newTrie() - tr.add([]string{"A"}, 4) - tr.add([]string{"B"}, 1) - tr.computeTotals() - - cfg := defaultSVGConfig() - cfg.Width = 100 - cfg.FrameHeight = 10 - cfg.FontSize = 10 - cfg.MinWidthPx = 1 - - frames := BuildFrameLayout(tr, cfg) - if len(frames) != 2 { - t.Fatalf("frames len = %d, want 2", len(frames)) - } - - a := frames[0] - if a.Name != "A" { - t.Fatalf("first frame name = %q, want %q", a.Name, "A") - } - if !almostEqual(a.X, 0) { - t.Fatalf("A x = %f, want 0", a.X) - } - if !almostEqual(a.Width, 80) { - t.Fatalf("A width = %f, want 80", a.Width) - } - if !almostEqual(a.Percent, 80) { - t.Fatalf("A percent = %f, want 80", a.Percent) - } - if a.Depth != 1 { - t.Fatalf("A depth = %d, want 1", a.Depth) - } - - b := frames[1] - if b.Name != "B" { - t.Fatalf("second frame name = %q, want %q", b.Name, "B") - } - if !almostEqual(b.X, 80) { - t.Fatalf("B x = %f, want 80", b.X) - } - if !almostEqual(b.Width, 20) { - t.Fatalf("B width = %f, want 20", b.Width) - } -} - -func TestBuildFrameLayoutSkipsFramesBelowMinWidth(t *testing.T) { - tr := newTrie() - tr.add([]string{"A"}, 999) - tr.add([]string{"B"}, 1) - tr.computeTotals() - - cfg := defaultSVGConfig() - cfg.Width = 100 - cfg.FrameHeight = 10 - cfg.FontSize = 10 - cfg.MinWidthPx = 1 - - frames := BuildFrameLayout(tr, cfg) - if len(frames) != 1 { - t.Fatalf("frames len = %d, want 1", len(frames)) - } - if frames[0].Name != "A" { - t.Fatalf("remaining frame name = %q, want %q", frames[0].Name, "A") - } -} diff --git a/internal/flamegraph/livehtml.go b/internal/flamegraph/livehtml.go deleted file mode 100644 index 71b955e..0000000 --- a/internal/flamegraph/livehtml.go +++ /dev/null @@ -1,842 +0,0 @@ -package flamegraph - -const liveHTML = `<!doctype html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>I/O Flame Graph (Live)</title> - <style> - :root { - --fg-bg: #f6f1ea; - --fg-panel: #fbf7f1; - --fg-border: #d8cdc0; - --fg-text: #232323; - --fg-muted: #5f5f5f; - --fg-accent: #7b2d1f; - --fg-btn: #efe2d2; - --fg-btn-hover: #e6d5c1; - --fg-paused: #b02222; - } - - * { box-sizing: border-box; } - - body { - margin: 0; - min-height: 100vh; - background: linear-gradient(180deg, #f8f2ea 0%, #f2e9dc 100%); - color: var(--fg-text); - font-family: monospace; - } - - #controls { - position: sticky; - top: 0; - z-index: 1; - display: flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; - padding: 10px 12px; - background: var(--fg-panel); - border-bottom: 1px solid var(--fg-border); - } - - #controls button { - border: 1px solid var(--fg-border); - background: var(--fg-btn); - color: var(--fg-text); - font: inherit; - font-size: 12px; - line-height: 1.2; - padding: 6px 10px; - cursor: pointer; - } - - #controls button:hover { - background: var(--fg-btn-hover); - } - - #controls .order-toggle { - min-width: 220px; - text-align: left; - } - - #status { - margin-left: 8px; - font-size: 12px; - color: var(--fg-muted); - } - - .paused #status { - color: var(--fg-paused); - font-weight: 700; - letter-spacing: 0.03em; - text-transform: uppercase; - } - - #flamegraph { - display: block; - width: 100%; - height: calc(100vh - 56px); - min-height: calc(100vh - 56px); - background: transparent; - } - - .title { - font-size: 14px; - font-family: monospace; - } - - .controls text { - font-size: 12px; - font-family: monospace; - cursor: pointer; - fill: #444; - } - - .frame text { - font-size: 11px; - font-family: monospace; - pointer-events: none; - fill: #111; - } - - .frame rect { - stroke: rgba(0, 0, 0, 0.18); - stroke-width: 0.5; - } - </style> -</head> -<body> - <div id="controls"> - <button id="btn-pause" type="button">Pause</button> - <button id="btn-search" type="button">Search</button> - <button id="btn-reset-search" type="button">Reset Search</button> - <button id="btn-undo-zoom" type="button">Undo Zoom</button> - <button id="btn-reset-zoom" type="button">Reset Zoom</button> - <button id="btn-reset-baseline" type="button">Reset Baseline</button> - <button id="btn-toggle-order" class="order-toggle" type="button">Order: comm > tracepoint > path</button> - <span id="status">LIVE</span> - </div> - - <svg id="flamegraph" xmlns="http://www.w3.org/2000/svg"></svg> - - <script> - (function () { - var fg = { - paused: false, - resetting: false, - lastTreeData: null, - pendingData: null, - searchQuery: '', - zoomStack: [], - zoomRange: null, - frames: [], - rootWidth: 0, - matchColor: 'rgb(220,30,70)', - eventSource: null, - svg: document.getElementById('flamegraph'), - status: document.getElementById('status'), - pauseBtn: document.getElementById('btn-pause'), - searchBtn: document.getElementById('btn-search'), - resetSearchBtn: document.getElementById('btn-reset-search'), - undoZoomBtn: document.getElementById('btn-undo-zoom'), - resetZoomBtn: document.getElementById('btn-reset-zoom'), - resetBaselineBtn: document.getElementById('btn-reset-baseline'), - toggleOrderBtn: document.getElementById('btn-toggle-order'), - orderPresets: [ - 'comm,tracepoint,path', - 'path,tracepoint,comm', - 'tracepoint,comm,path', - 'pid,tracepoint,path', - 'comm,path,tracepoint' - ], - orderIndex: 0, - cfg: { - baseWidth: 1200, - baseFrameHeight: 16, - width: 1200, - frameHeight: 16, - fontSize: 12, - minWidthPx: 1.0 - } - }; - - function fgFrameColor(name) { - var bytes = new TextEncoder().encode(name || ''); - var h = 2166136261 >>> 0; - for (var i = 0; i < bytes.length; i++) { - h ^= bytes[i]; - h = Math.imul(h, 16777619) >>> 0; - } - var r = 200 + (h % 35); - var g = 80 + ((h >>> 8) % 120); - var b = 40 + ((h >>> 16) % 90); - return 'rgb(' + r + ',' + g + ',' + b + ')'; - } - - function fgMaxDepth(node, depth) { - if (!node || !Array.isArray(node.c) || node.c.length === 0) { - return depth; - } - var maxDepth = depth; - for (var i = 0; i < node.c.length; i++) { - var childDepth = fgMaxDepth(node.c[i], depth + 1); - if (childDepth > maxDepth) { - maxDepth = childDepth; - } - } - return maxDepth; - } - - function fgDefaultCanvasHeight(maxDepth) { - return (fg.cfg.baseFrameHeight * (maxDepth + 1)) + 80; - } - - function fgViewportLayout(maxDepth) { - var rows = Math.max(maxDepth + 1, 1); - var defaultCanvasHeight = fgDefaultCanvasHeight(maxDepth); - var viewportWidth = Number(window.innerWidth || 0); - if (viewportWidth <= 0 && document && document.documentElement) { - viewportWidth = Number(document.documentElement.clientWidth || 0); - } - if (viewportWidth <= 0) { - viewportWidth = fg.cfg.baseWidth; - } - var viewportHeight = Number(window.innerHeight || 0); - if (viewportHeight <= 0) { - return { - width: viewportWidth, - frameHeight: fg.cfg.baseFrameHeight, - canvasHeight: defaultCanvasHeight - }; - } - - var controls = document.getElementById('controls'); - var controlsHeight = 56; - if (controls && typeof controls.getBoundingClientRect === 'function') { - controlsHeight = Number(controls.getBoundingClientRect().height || controlsHeight); - } - - var availableHeight = viewportHeight - controlsHeight; - if (availableHeight <= 0) { - return { - width: viewportWidth, - frameHeight: fg.cfg.baseFrameHeight, - canvasHeight: defaultCanvasHeight - }; - } - - var canvasHeight = Math.max(defaultCanvasHeight, availableHeight); - var frameHeight = (canvasHeight - 80) / rows; - if (frameHeight < fg.cfg.baseFrameHeight) { - frameHeight = fg.cfg.baseFrameHeight; - } - return { - width: viewportWidth, - frameHeight: frameHeight, - canvasHeight: canvasHeight - }; - } - - function fgVisibleChildrenTotal(node) { - var children = Array.isArray(node && node.c) ? node.c : []; - var total = 0; - for (var i = 0; i < children.length; i++) { - total += Number(children[i].t || 0); - } - if (total > 0) { - return total; - } - return Number(node && node.t || 0); - } - - function fgBuildFrames(node, rootTotal, x, width, depth, canvasHeight, isRoot, out, path) { - if (!node || rootTotal <= 0 || width <= 0) { - return; - } - var currentPath = path || ''; - if (!isRoot) { - var w = width; - if (w < fg.cfg.minWidthPx) { - return; - } - var name = node.n || ''; - currentPath = currentPath ? (currentPath + '\u001f' + name) : name; - var y = canvasHeight - ((depth + 1) * fg.cfg.frameHeight); - var total = Number(node.t || 0); - var pct = 100 * total / Number(rootTotal); - out.push({ - name: name, - path: currentPath, - x: x, - y: y, - w: w, - h: fg.cfg.frameHeight - 1, - depth: depth, - total: total, - pct: pct, - fill: fgFrameColor(name) - }); - } - var cursor = x; - var children = Array.isArray(node.c) ? node.c : []; - var childrenTotal = fgVisibleChildrenTotal(node); - if (childrenTotal <= 0) { - return; - } - for (var i = 0; i < children.length; i++) { - var child = children[i]; - var childTotal = Number(child.t || 0); - if (childTotal <= 0) { - continue; - } - var childWidth = width * (childTotal / childrenTotal); - fgBuildFrames(child, rootTotal, cursor, childWidth, depth + 1, canvasHeight, false, out, currentPath); - cursor += childWidth; - } - } - - function fgEscape(value) { - return String(value || '') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - function fgSetStatus(suffix) { - var prefix = fg.paused ? 'PAUSED' : 'LIVE'; - fg.status.textContent = suffix ? (prefix + ' | ' + suffix) : prefix; - } - - function fgOrderLabel(csv) { - return String(csv || '').split(',').join(' > '); - } - - function fgOrderFields(csv) { - return String(csv || '').split(',').filter(function (s) { return s; }); - } - - function fgSetOrderIndexByCSV(csv) { - for (var i = 0; i < fg.orderPresets.length; i++) { - if (fg.orderPresets[i] === csv) { - fg.orderIndex = i; - return; - } - } - } - - function fgUpdateOrderButton() { - fg.toggleOrderBtn.textContent = 'Order: ' + fgOrderLabel(fg.orderPresets[fg.orderIndex] || ''); - } - - function fgHover(frame) { - var title = frame.querySelector('title'); - fgSetStatus(title ? title.textContent : ''); - } - - function fgDetectRootWidth() { - var maxEnd = 0; - for (var i = 0; i < fg.frames.length; i++) { - var x = Number(fg.frames[i].dataset.x || '0'); - var w = Number(fg.frames[i].dataset.w || '0'); - if (x + w > maxEnd) { - maxEnd = x + w; - } - } - return maxEnd; - } - - function fgSnapshotOriginalGeometry(frame) { - var rect = frame.querySelector('rect'); - var text = frame.querySelector('text'); - frame.dataset.ox = frame.dataset.x || '0'; - frame.dataset.ow = frame.dataset.w || '0'; - if (rect) { - rect.dataset.ox = rect.getAttribute('x') || '0'; - rect.dataset.ow = rect.getAttribute('width') || '0'; - } - if (text) { - text.dataset.ox = text.getAttribute('x') || '0'; - text.dataset.hidden = text.style.display === 'none' ? '1' : '0'; - text.dataset.full = text.textContent || frame.dataset.name || ''; - } - } - - function fgOriginalX(frame) { - return Number(frame.dataset.ox || frame.dataset.x || '0'); - } - - function fgOriginalW(frame) { - return Number(frame.dataset.ow || frame.dataset.w || '0'); - } - - function fgFitLabel(text, width) { - var full = text.dataset.full || text.textContent || ''; - var maxChars = Math.floor((width - 6) / 7); - if (maxChars < 3) { - text.style.display = 'none'; - text.textContent = full; - return; - } - text.style.display = ''; - if (full.length <= maxChars) { - text.textContent = full; - return; - } - text.textContent = full.slice(0, maxChars - 1) + '...'; - } - - function fgSetFrameGeometry(frame, x, w) { - var rect = frame.querySelector('rect'); - var text = frame.querySelector('text'); - if (rect) { - rect.setAttribute('x', String(x)); - rect.setAttribute('width', String(w)); - } - if (text) { - text.setAttribute('x', String(x + 3)); - fgFitLabel(text, w); - } - } - - function fgRestoreFrameGeometry(frame) { - var rect = frame.querySelector('rect'); - var text = frame.querySelector('text'); - if (rect) { - rect.setAttribute('x', rect.dataset.ox || '0'); - rect.setAttribute('width', rect.dataset.ow || '0'); - } - if (text) { - text.setAttribute('x', text.dataset.ox || '0'); - if (text.dataset.hidden === '1') { - text.style.display = 'none'; - text.textContent = text.dataset.full || ''; - } else { - fgFitLabel(text, Number(rect ? (rect.dataset.ow || '0') : '0')); - } - } - } - - function fgZoom(frame) { - var width = fgOriginalW(frame); - if (width <= 0) { - return; - } - if (fg.zoomRange) { - fg.zoomStack.push(fg.zoomRange); - } - fg.zoomRange = { - x: fgOriginalX(frame), - w: width, - depth: Number(frame.dataset.depth || '0'), - path: frame.dataset.path || '' - }; - fgApplyZoom(); - } - - function fgFindFrameByPath(path) { - for (var i = 0; i < fg.frames.length; i++) { - if ((fg.frames[i].dataset.path || '') === path) { - return fg.frames[i]; - } - } - return null; - } - - function fgRefreshZoomRange() { - if (!fg.zoomRange || !fg.zoomRange.path) { - return; - } - var candidatePath = fg.zoomRange.path; - var match = null; - while (candidatePath) { - match = fgFindFrameByPath(candidatePath); - if (match) { - break; - } - var cut = candidatePath.lastIndexOf('\u001f'); - if (cut < 0) { - break; - } - candidatePath = candidatePath.slice(0, cut); - } - if (!match) { - return; - } - var width = fgOriginalW(match); - if (width <= 0) { - return; - } - fg.zoomRange.path = match.dataset.path || candidatePath; - fg.zoomRange.x = fgOriginalX(match); - fg.zoomRange.w = width; - fg.zoomRange.depth = Number(match.dataset.depth || String(fg.zoomRange.depth || 0)); - } - - function fgApplyZoom() { - if (!fg.zoomRange) { - for (var i = 0; i < fg.frames.length; i++) { - fgRestoreFrameGeometry(fg.frames[i]); - fg.frames[i].style.display = ''; - } - return; - } - fgRefreshZoomRange(); - var x = fg.zoomRange.x; - var end = x + fg.zoomRange.w; - var width = fg.zoomRange.w; - var minDepth = fg.zoomRange.depth; - var scale = fg.rootWidth > 0 ? fg.rootWidth / width : 1; - var eps = 1e-6; - for (var i = 0; i < fg.frames.length; i++) { - var frame = fg.frames[i]; - var ox = fgOriginalX(frame); - var ow = fgOriginalW(frame); - var depth = Number(frame.dataset.depth || '0'); - var inRange = (ox >= x - eps) && (ox + ow <= end + eps); - var isAncestor = depth < minDepth && ox <= x + eps && ox + ow >= end - eps; - if (isAncestor || (depth >= minDepth && inRange)) { - if (isAncestor) { - fgSetFrameGeometry(frame, 0, fg.rootWidth); - } else { - fgSetFrameGeometry(frame, (ox - x) * scale, ow * scale); - } - frame.style.display = ''; - } else { - frame.style.display = 'none'; - } - } - } - - function fgUndoZoom() { - if (fg.zoomStack.length === 0) { - fgResetZoom(); - return; - } - fg.zoomRange = fg.zoomStack.pop(); - fgApplyZoom(); - } - - function fgResetZoom() { - fg.zoomStack = []; - fg.zoomRange = null; - fgApplyZoom(); - } - - function fgResetSearch() { - for (var i = 0; i < fg.frames.length; i++) { - var rect = fg.frames[i].querySelector('rect'); - if (!rect) { - continue; - } - rect.setAttribute('fill', fg.frames[i].dataset.baseFill || ''); - } - } - - function fgApplySearch() { - fgResetSearch(); - if (!fg.searchQuery) { - return; - } - var query = fg.searchQuery.toLowerCase(); - for (var i = 0; i < fg.frames.length; i++) { - var name = (fg.frames[i].dataset.name || '').toLowerCase(); - if (name.indexOf(query) < 0) { - continue; - } - var rect = fg.frames[i].querySelector('rect'); - if (rect) { - rect.setAttribute('fill', fg.matchColor); - } - } - } - - function fgSearch() { - var query = window.prompt('Search frame substring:', fg.searchQuery || ''); - if (query === null) { - return; - } - fg.searchQuery = query.trim(); - fgApplySearch(); - } - - function fgTogglePause() { - fg.paused = !fg.paused; - document.body.classList.toggle('paused', fg.paused); - fg.pauseBtn.textContent = fg.paused ? 'Resume' : 'Pause'; - fgSetStatus(''); - if (!fg.paused && fg.pendingData) { - var pending = fg.pendingData; - fg.pendingData = null; - requestAnimationFrame(function () { - fgProcessUpdate(pending); - }); - } - } - - function fgClearLocalState() { - fg.pendingData = null; - fg.searchQuery = ''; - fg.zoomStack = []; - fg.zoomRange = null; - } - - function fgResetBaseline() { - if (fg.resetting) { - return; - } - fg.resetting = true; - fgSetStatus('resetting baseline...'); - fetch('/reset', { method: 'POST' }) - .then(function (resp) { - if (!resp.ok) { - throw new Error('reset failed'); - } - return resp.text(); - }) - .then(function (payload) { - fgClearLocalState(); - fgProcessUpdate(payload); - fgSetStatus('baseline reset'); - }) - .catch(function () { - fgSetStatus('reset failed'); - }) - .then(function () { - fg.resetting = false; - }); - } - - function fgBindFrameEvents() { - for (var i = 0; i < fg.frames.length; i++) { - fg.frames[i].addEventListener('mouseenter', function () { fgHover(this); }); - fg.frames[i].addEventListener('mouseleave', function () { fgSetStatus(''); }); - fg.frames[i].addEventListener('click', function (ev) { - if (ev.detail > 1) { - return; - } - ev.stopPropagation(); - fgZoom(ev.currentTarget); - }); - fg.frames[i].addEventListener('dblclick', function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - fgResetZoom(); - }); - } - } - - function fgRender(treeData) { - var maxDepth = fgMaxDepth(treeData, 0); - var layout = fgViewportLayout(maxDepth); - fg.cfg.width = layout.width; - fg.cfg.frameHeight = layout.frameHeight; - - if (!treeData || Number(treeData.t || 0) <= 0) { - fg.svg.style.height = String(layout.canvasHeight) + 'px'; - fg.svg.setAttribute('viewBox', '0 0 ' + fg.cfg.width + ' ' + layout.canvasHeight); - fg.svg.setAttribute('preserveAspectRatio', 'xMinYMin meet'); - fg.frames = []; - fg.svg.innerHTML = ''; - fgSetStatus(''); - return; - } - - var rootTotal = fgVisibleChildrenTotal(treeData); - if (rootTotal <= 0) { - rootTotal = Number(treeData.t || 0); - } - var canvasHeight = layout.canvasHeight; - var frames = []; - fgBuildFrames(treeData, rootTotal, 0, fg.cfg.width, 0, canvasHeight, true, frames, ''); - - var parts = []; - parts.push('<text class="title" x="10" y="22">I/O Flame Graph (Live)</text>'); - for (var i = 0; i < frames.length; i++) { - var frame = frames[i]; - var textStyle = frame.w <= (fg.cfg.fontSize * 2) ? ' style="display:none"' : ''; - var title = fgEscape(frame.name + ' (' + frame.total + ', ' + frame.pct.toFixed(2) + '%)'); - parts.push('<g class="frame" data-name="' + fgEscape(frame.name) + '" data-path="' + fgEscape(frame.path) + - '" data-x="' + frame.x.toFixed(3) + '" data-w="' + frame.w.toFixed(3) + - '" data-depth="' + frame.depth + '" data-base-fill="' + frame.fill + '">'); - parts.push('<title>' + title + '</title>'); - parts.push('<rect x="' + frame.x.toFixed(3) + '" y="' + frame.y.toFixed(3) + '" width="' + frame.w.toFixed(3) + - '" height="' + frame.h.toFixed(3) + '" fill="' + frame.fill + '"></rect>'); - parts.push('<text x="' + (frame.x + 3).toFixed(3) + '" y="' + (frame.y + fg.cfg.fontSize).toFixed(3) + '"' + - textStyle + '>' + fgEscape(frame.name) + '</text>'); - parts.push('</g>'); - } - - fg.svg.setAttribute('viewBox', '0 0 ' + fg.cfg.width + ' ' + canvasHeight); - fg.svg.setAttribute('preserveAspectRatio', 'xMinYMin meet'); - fg.svg.style.height = String(canvasHeight) + 'px'; - fg.svg.innerHTML = parts.join(''); - fg.frames = Array.prototype.slice.call(fg.svg.querySelectorAll('g.frame')); - fg.rootWidth = fgDetectRootWidth(); - for (var j = 0; j < fg.frames.length; j++) { - fgSnapshotOriginalGeometry(fg.frames[j]); - } - fgBindFrameEvents(); - } - - function fgProcessUpdate(jsonStr) { - var treeData; - try { - treeData = JSON.parse(jsonStr); - } catch (err) { - fgSetStatus('parse error'); - return; - } - fg.lastTreeData = treeData; - fgRender(treeData); - fgApplyZoom(); - fgApplySearch(); - } - - function fgApplyOrder(csv, expectedIndex) { - if (fg.resetting) { - return; - } - fg.resetting = true; - fgSetStatus('changing order...'); - fetch('/order', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fields: fgOrderFields(csv) }) - }) - .then(function (resp) { - if (!resp.ok) { - throw new Error('order change failed'); - } - return resp.json(); - }) - .then(function (payload) { - fg.orderIndex = expectedIndex; - if (payload && Array.isArray(payload.fields)) { - fgSetOrderIndexByCSV(payload.fields.join(',')); - } - fgUpdateOrderButton(); - fgClearLocalState(); - if (payload && payload.snapshot) { - fg.lastTreeData = payload.snapshot; - fgRender(payload.snapshot); - } else { - fgProcessUpdate('{"n":"","v":0,"t":0}'); - } - fgSetStatus('order: ' + fgOrderLabel(fg.orderPresets[fg.orderIndex] || csv)); - }) - .catch(function () { - fgSetStatus('order change failed'); - }) - .then(function () { - fg.resetting = false; - }); - } - - function fgToggleOrder() { - var nextIndex = (fg.orderIndex + 1) % fg.orderPresets.length; - fgApplyOrder(fg.orderPresets[nextIndex], nextIndex); - } - - function fgConnect() { - fg.eventSource = new EventSource('/events'); - fg.eventSource.onmessage = function (e) { - if (fg.paused) { - fg.pendingData = e.data; - return; - } - requestAnimationFrame(function () { - fgProcessUpdate(e.data); - }); - }; - fg.eventSource.onerror = function () { - fgSetStatus('stream error'); - }; - } - - function fgHandleResize() { - if (!fg.lastTreeData) { - return; - } - requestAnimationFrame(function () { - fgRender(fg.lastTreeData); - fgApplyZoom(); - fgApplySearch(); - }); - } - - function fgIsTextEntryTarget(target) { - if (!target) { - return false; - } - if (target.isContentEditable) { - return true; - } - var tag = (target.tagName || '').toUpperCase(); - return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; - } - - function fgHandleKeydown(ev) { - if (fgIsTextEntryTarget(ev.target)) { - return; - } - if (ev.key === ' ' || ev.code === 'Space') { - ev.preventDefault(); - fgTogglePause(); - return; - } - if (ev.key === '/') { - ev.preventDefault(); - fgSearch(); - return; - } - if (ev.key === 'r') { - ev.preventDefault(); - fgResetBaseline(); - return; - } - if (ev.key === 'Escape') { - ev.preventDefault(); - fgResetZoom(); - fgResetSearch(); - } - } - - fg.pauseBtn.addEventListener('click', fgTogglePause); - fg.searchBtn.addEventListener('click', fgSearch); - fg.resetSearchBtn.addEventListener('click', fgResetSearch); - fg.undoZoomBtn.addEventListener('click', fgUndoZoom); - fg.resetZoomBtn.addEventListener('click', fgResetZoom); - fg.resetBaselineBtn.addEventListener('click', fgResetBaseline); - fg.toggleOrderBtn.addEventListener('click', fgToggleOrder); - document.addEventListener('keydown', fgHandleKeydown); - window.addEventListener('resize', fgHandleResize); - - fgUpdateOrderButton(); - fgSetStatus(''); - fgConnect(); - - window.fgFrameColor = fgFrameColor; - window.fgBuildFrames = fgBuildFrames; - window.fgMaxDepth = fgMaxDepth; - window.fgRender = fgRender; - window.fgProcessUpdate = fgProcessUpdate; - window.fgZoom = fgZoom; - window.fgApplyZoom = fgApplyZoom; - window.fgUndoZoom = fgUndoZoom; - window.fgResetZoom = fgResetZoom; - window.fgSearch = fgSearch; - window.fgResetSearch = fgResetSearch; - window.fgTogglePause = fgTogglePause; - window.fgResetBaseline = fgResetBaseline; - window.fgToggleOrder = fgToggleOrder; - window.liveFlamegraphState = fg; - })(); - </script> -</body> -</html> -` diff --git a/internal/flamegraph/livehtml_browser_test.go b/internal/flamegraph/livehtml_browser_test.go deleted file mode 100644 index 10252a9..0000000 --- a/internal/flamegraph/livehtml_browser_test.go +++ /dev/null @@ -1,314 +0,0 @@ -package flamegraph - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "strings" - "testing" -) - -type jsFrame struct { - Name string `json:"name"` - X float64 `json:"x"` - Y float64 `json:"y"` - W float64 `json:"w"` - H float64 `json:"h"` - Depth int `json:"depth"` -} - -type liveJSResult struct { - Colors map[string]string `json:"colors"` - KnownFrames []jsFrame `json:"knownFrames"` - SVGHTML string `json:"svgHTML"` - ViewBox string `json:"viewBox"` - TallViewBox string `json:"tallViewBox"` - TallHeight string `json:"tallHeight"` - PrunedMaxEnd float64 `json:"prunedMaxEnd"` - SingleCount int `json:"singleCount"` - DeepMaxDepth int `json:"deepMaxDepth"` - WideFrameCount int `json:"wideFrameCount"` -} - -func TestLiveHTMLJSRenderingParity(t *testing.T) { - if _, err := exec.LookPath("node"); err != nil { - t.Skip("node not available") - } - - out := runLiveHTMLJSHarness(t) - var got liveJSResult - if err := json.Unmarshal([]byte(out), &got); err != nil { - t.Fatalf("unmarshal node output: %v\nraw:\n%s", err, out) - } - - names := []string{"read", "write", "io_uring_enter", "nested/path"} - for _, name := range names { - want := frameColor(name) - if got.Colors[name] != want { - t.Fatalf("fgFrameColor(%q) = %q, want %q", name, got.Colors[name], want) - } - } - - if len(got.KnownFrames) != 3 { - t.Fatalf("known frame count = %d, want 3", len(got.KnownFrames)) - } - assertFrame(t, got.KnownFrames[0], "A", 0, 96, 720, 15, 1) - assertFrame(t, got.KnownFrames[1], "A1", 0, 80, 720, 15, 2) - assertFrame(t, got.KnownFrames[2], "B", 720, 96, 480, 15, 1) - - if !strings.Contains(got.SVGHTML, `<g class="frame"`) { - t.Fatalf("svg markup missing frame group") - } - if !strings.Contains(got.SVGHTML, `data-name="A"`) { - t.Fatalf("svg markup missing data-name for A") - } - if !strings.Contains(got.SVGHTML, `data-x="0.000"`) { - t.Fatalf("svg markup missing data-x") - } - if !strings.Contains(got.SVGHTML, `data-w="720.000"`) { - t.Fatalf("svg markup missing data-w") - } - if !strings.Contains(got.SVGHTML, `data-depth="1"`) { - t.Fatalf("svg markup missing data-depth") - } - if !strings.Contains(got.SVGHTML, `data-base-fill="rgb(`) { - t.Fatalf("svg markup missing data-base-fill") - } - if got.ViewBox != "0 0 1200 128" { - t.Fatalf("viewBox = %q, want %q", got.ViewBox, "0 0 1200 128") - } - if got.TallViewBox != "0 0 1600 844" { - t.Fatalf("tall viewBox = %q, want %q", got.TallViewBox, "0 0 1600 844") - } - if got.TallHeight != "844px" { - t.Fatalf("tall style height = %q, want %q", got.TallHeight, "844px") - } - if diff(got.PrunedMaxEnd, 1600) > 0.01 { - t.Fatalf("pruned max end = %f, want 1600", got.PrunedMaxEnd) - } - - if got.SingleCount != 1 { - t.Fatalf("single-frame case count = %d, want 1", got.SingleCount) - } - if got.DeepMaxDepth < 50 { - t.Fatalf("deep max depth = %d, want at least 50", got.DeepMaxDepth) - } - if got.WideFrameCount != 1000 { - t.Fatalf("wide frame count = %d, want 1000", got.WideFrameCount) - } -} - -func assertFrame(t *testing.T, got jsFrame, name string, x, y, w, h float64, depth int) { - t.Helper() - if got.Name != name { - t.Fatalf("frame name = %q, want %q", got.Name, name) - } - if got.Depth != depth { - t.Fatalf("frame %q depth = %d, want %d", got.Name, got.Depth, depth) - } - const eps = 0.001 - if diff(got.X, x) > eps || diff(got.Y, y) > eps || diff(got.W, w) > eps || diff(got.H, h) > eps { - t.Fatalf("frame %q geometry = {x:%f y:%f w:%f h:%f}, want {x:%f y:%f w:%f h:%f}", - got.Name, got.X, got.Y, got.W, got.H, x, y, w, h) - } -} - -func diff(a, b float64) float64 { - if a > b { - return a - b - } - return b - a -} - -func runLiveHTMLJSHarness(t *testing.T) string { - t.Helper() - - script := extractLiveHTMLScript(t) - harness := fmt.Sprintf(` -const vm = require("vm"); -const liveScript = %q; - -function makeElement(id) { - return { - id, - textContent: "", - innerHTML: "", - style: {}, - dataset: {}, - attrs: {}, - classList: { toggle: function(){}, add: function(){}, remove: function(){} }, - addEventListener: function(){}, - getBoundingClientRect: function() { return { height: id === "controls" ? 56 : 0 }; }, - setAttribute: function(k, v) { this.attrs[k] = String(v); }, - getAttribute: function(k) { return this.attrs[k] || ""; }, - querySelectorAll: function() { return []; }, - querySelector: function() { return null; } - }; -} - -const elements = {}; -["controls", "flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline", "btn-toggle-order"].forEach((id) => { - elements[id] = makeElement(id); -}); -elements["body"] = makeElement("body"); - -global.document = { - body: elements["body"], - getElementById: function(id) { - if (!elements[id]) elements[id] = makeElement(id); - return elements[id]; - }, - addEventListener: function(){}, -}; -global.window = global; -global.prompt = function(){ return ""; }; -global.fetch = function() { - return Promise.resolve({ - ok: true, - json: function() { return Promise.resolve({ fields: ["comm", "tracepoint", "path"], snapshot: { n: "", v: 0, t: 0 } }); }, - text: function() { return Promise.resolve("{\"n\":\"\",\"v\":0,\"t\":0}"); } - }); -}; -global.requestAnimationFrame = function(cb){ cb(); }; -global.EventSource = function() { - this.onmessage = null; - this.onerror = null; -}; -window.addEventListener = function(){}; - -vm.runInThisContext(liveScript); - -const names = ["read", "write", "io_uring_enter", "nested/path"]; -const colors = {}; -for (const n of names) { - colors[n] = fgFrameColor(n); -} - -const knownTree = { - n: "", - v: 0, - t: 10, - c: [ - { n: "A", v: 0, t: 6, c: [{ n: "A1", v: 6, t: 6 }] }, - { n: "B", v: 4, t: 4 } - ] -}; -const maxDepth = fgMaxDepth(knownTree, 0); -const canvasHeight = (liveFlamegraphState.cfg.frameHeight * (maxDepth + 1)) + 80; -const knownFramesRaw = []; -fgBuildFrames(knownTree, knownTree.t, 0, 1200, 0, canvasHeight, true, knownFramesRaw, ""); -const knownFrames = knownFramesRaw.map((f) => ({ - name: f.name, - x: Number(f.x.toFixed(3)), - y: Number(f.y.toFixed(3)), - w: Number(f.w.toFixed(3)), - h: Number(f.h.toFixed(3)), - depth: f.depth, -})); - -fgRender(knownTree); -const svgHTML = elements["flamegraph"].innerHTML; -const viewBox = elements["flamegraph"].attrs["viewBox"] || ""; - -window.innerWidth = 1600; -window.innerHeight = 900; -fgRender(knownTree); -const tallViewBox = elements["flamegraph"].attrs["viewBox"] || ""; -const tallHeight = elements["flamegraph"].style.height || ""; - -const singleTree = { n: "", v: 0, t: 1, c: [{ n: "only", v: 1, t: 1 }] }; -const singleFrames = []; -const singleCanvas = (liveFlamegraphState.cfg.frameHeight * (fgMaxDepth(singleTree, 0) + 1)) + 80; -fgBuildFrames(singleTree, singleTree.t, 0, 1200, 0, singleCanvas, true, singleFrames, ""); - -let deepTree = { n: "", v: 0, t: 1, c: [] }; -let cursor = deepTree; -for (let i = 0; i < 55; i++) { - const child = { n: "d" + i, v: i === 54 ? 1 : 0, t: 1, c: [] }; - cursor.c = [child]; - cursor = child; -} -const deepMaxDepth = fgMaxDepth(deepTree, 0); - -const wideChildren = []; -for (let i = 0; i < 1000; i++) { - wideChildren.push({ n: "w" + i, v: 1, t: 1 }); -} -const wideTree = { n: "", v: 0, t: 1000, c: wideChildren }; -const wideCanvas = (liveFlamegraphState.cfg.frameHeight * (fgMaxDepth(wideTree, 0) + 1)) + 80; -const wideFrames = []; -fgBuildFrames(wideTree, wideTree.t, 0, 1200, 0, wideCanvas, true, wideFrames, ""); - -const prunedTree = { - n: "", - v: 0, - t: 100, - c: [ - { n: "A", v: 0, t: 60 }, - { n: "B", v: 0, t: 20 } - ] -}; -fgRender(prunedTree); -const prunedHTML = elements["flamegraph"].innerHTML; -const prunedMatches = prunedHTML.match(/data-x=\"([0-9.]+)\" data-w=\"([0-9.]+)\"/g) || []; -let prunedMaxEnd = 0; -for (const m of prunedMatches) { - const parts = m.match(/data-x=\"([0-9.]+)\" data-w=\"([0-9.]+)\"/); - if (!parts) continue; - const end = Number(parts[1]) + Number(parts[2]); - if (end > prunedMaxEnd) { - prunedMaxEnd = end; - } -} - -console.log(JSON.stringify({ - colors, - knownFrames, - svgHTML, - viewBox, - tallViewBox, - tallHeight, - prunedMaxEnd, - singleCount: singleFrames.length, - deepMaxDepth, - wideFrameCount: wideFrames.length, -})); -`, script) - - tmp, err := os.CreateTemp("", "livehtml-js-*.cjs") - if err != nil { - t.Fatalf("create temp script: %v", err) - } - defer os.Remove(tmp.Name()) - - if _, err := tmp.WriteString(harness); err != nil { - _ = tmp.Close() - t.Fatalf("write temp script: %v", err) - } - if err := tmp.Close(); err != nil { - t.Fatalf("close temp script: %v", err) - } - - out, err := exec.Command("node", tmp.Name()).CombinedOutput() - if err != nil { - t.Fatalf("node harness failed: %v\n%s", err, string(out)) - } - return strings.TrimSpace(string(out)) -} - -func extractLiveHTMLScript(t *testing.T) string { - t.Helper() - const openTag = "<script>" - const closeTag = "</script>" - start := strings.Index(liveHTML, openTag) - if start < 0 { - t.Fatalf("script tag not found in liveHTML") - } - start += len(openTag) - end := strings.Index(liveHTML[start:], closeTag) - if end < 0 { - t.Fatalf("closing script tag not found in liveHTML") - } - return strings.TrimSpace(liveHTML[start : start+end]) -} diff --git a/internal/flamegraph/livehtml_interaction_test.go b/internal/flamegraph/livehtml_interaction_test.go deleted file mode 100644 index 4c947f5..0000000 --- a/internal/flamegraph/livehtml_interaction_test.go +++ /dev/null @@ -1,615 +0,0 @@ -package flamegraph - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "strings" - "testing" -) - -type zoomSearchStateResult struct { - BeforePath string `json:"beforePath"` - AfterPath string `json:"afterPath"` - DeepPathStable bool `json:"deepPathStable"` - SearchPersisted bool `json:"searchPersisted"` - ZoomedBranchStable bool `json:"zoomedBranchStable"` - NonZoomedHidden bool `json:"nonZoomedHidden"` - NewChildVisible bool `json:"newChildVisible"` - PauseUnpauseKeeps bool `json:"pauseUnpauseKeeps"` -} - -type pauseKeyboardResult struct { - PausedBySpace bool `json:"pausedBySpace"` - NoUpdateWhilePaused bool `json:"noUpdateWhilePaused"` - ZoomSearchWhilePaused bool `json:"zoomSearchWhilePaused"` - UnpauseRendersLatest bool `json:"unpauseRendersLatest"` - RapidToggleStable bool `json:"rapidToggleStable"` - SlashSearchWorks bool `json:"slashSearchWorks"` - EscapeResets bool `json:"escapeResets"` - ButtonMatchesKeyboard bool `json:"buttonMatchesKeyboard"` - TypingIgnoresShortcuts bool `json:"typingIgnoresShortcuts"` -} - -type resetBaselineResult struct { - HotkeyPrevented bool `json:"hotkeyPrevented"` - ShiftHotkeyIgnored bool `json:"shiftHotkeyIgnored"` - HotkeyResetApplied bool `json:"hotkeyResetApplied"` - ButtonResetApplied bool `json:"buttonResetApplied"` - ResetCallsValid bool `json:"resetCallsValid"` -} - -type orderToggleResult struct { - OrderButtonUpdated bool `json:"orderButtonUpdated"` - OrderCallValid bool `json:"orderCallValid"` - OrderSnapshotShown bool `json:"orderSnapshotShown"` -} - -func TestLiveHTMLJSZoomSearchStatePreservedAcrossUpdates(t *testing.T) { - if _, err := exec.LookPath("node"); err != nil { - t.Skip("node not available") - } - - snippet := ` -const fg = liveFlamegraphState; - -const frameA = makeFrame("A", "A", 1, 0, 700); -const frameAChild = makeFrame("Achild", "A\u001fAchild", 2, 0, 400); -const frameB = makeFrame("B", "B", 1, 700, 500); -fg.frames = [frameA, frameAChild, frameB]; -fg.rootWidth = 1200; - -fgZoom(frameA); -const beforePath = fg.zoomRange.path; -prompt = function(){ return "A"; }; -fgSearch(); - -const frameA2 = makeFrame("A", "A", 1, 0, 800); -const frameAChild2 = makeFrame("Achild", "A\u001fAchild", 2, 0, 500); -const frameAnew2 = makeFrame("Anew", "A\u001fAnew", 2, 500, 300); -const frameB2 = makeFrame("B", "B", 1, 800, 400); -fg.frames = [frameA2, frameAChild2, frameAnew2, frameB2]; -fg.rootWidth = 1200; -fgApplyZoom(); -prompt = function(_msg, prev){ return prev || "A"; }; -fgSearch(); - -const afterPath = fg.zoomRange.path; -const searchPersisted = frameA2.querySelector("rect").getAttribute("fill") === fg.matchColor; -const nonZoomedHidden = frameB2.style.display === "none"; -const newChildVisible = frameAnew2.style.display !== "none"; -const zoomedBranchStable = frameA2.style.display !== "none" && frameAChild2.style.display !== "none"; - -const deep1 = makeFrame("A2", "A\u001fA1\u001fA2", 3, 100, 200); -fg.frames = [deep1]; -fg.rootWidth = 1200; -fgZoom(deep1); -const deepPath = fg.zoomRange.path; - -const deep2 = makeFrame("A2", "A\u001fA1\u001fA2", 3, 120, 240); -fg.frames = [deep2]; -fgApplyZoom(); -const deep3 = makeFrame("A2", "A\u001fA1\u001fA2", 3, 140, 260); -fg.frames = [deep3]; -fgApplyZoom(); -const deepPathStable = fg.zoomRange.path === deepPath && deep3.style.display !== "none"; - -fg.pendingData = "{\"n\":\"\",\"v\":0,\"t\":0}"; -fgTogglePause(); -fgTogglePause(); -const pauseUnpauseKeeps = fg.zoomRange.path === deepPath; - -console.log(JSON.stringify({ - beforePath, - afterPath, - deepPathStable, - searchPersisted, - zoomedBranchStable, - nonZoomedHidden, - newChildVisible, - pauseUnpauseKeeps -})); -` - - out := runLiveHTMLNodeSnippet(t, snippet) - var got zoomSearchStateResult - if err := json.Unmarshal([]byte(out), &got); err != nil { - t.Fatalf("decode node result: %v\nraw:\n%s", err, out) - } - - if got.BeforePath != "A" || got.AfterPath != "A" { - t.Fatalf("zoom path changed unexpectedly: before=%q after=%q", got.BeforePath, got.AfterPath) - } - if !got.SearchPersisted { - t.Fatalf("expected search highlight to persist across update") - } - if !got.ZoomedBranchStable { - t.Fatalf("expected zoomed branch to remain visible across update") - } - if !got.NonZoomedHidden { - t.Fatalf("expected non-zoomed branch to be hidden while zoomed") - } - if !got.NewChildVisible { - t.Fatalf("expected newly added child in zoomed branch to remain visible") - } - if !got.DeepPathStable { - t.Fatalf("expected deep zoom path to remain stable across multiple updates") - } - if !got.PauseUnpauseKeeps { - t.Fatalf("expected pause/unpause to preserve zoom state") - } -} - -func TestLiveHTMLJSPauseResumeAndKeyboard(t *testing.T) { - if _, err := exec.LookPath("node"); err != nil { - t.Skip("node not available") - } - - snippet := ` -const fg = liveFlamegraphState; -const keydown = __docListeners["keydown"]; - -function keyEvent(key, code, target) { - let prevented = false; - keydown({ - key: key, - code: code, - target: target || { tagName: "BODY", isContentEditable: false }, - preventDefault: function(){ prevented = true; } - }); - return prevented; -} - -let promptCalls = 0; -prompt = function(_msg, prev) { - promptCalls++; - return prev || "needle"; -}; - -const pausePayload = "{\"n\":\"\",\"v\":0,\"t\":10,\"c\":[{\"n\":\"latest\",\"v\":10,\"t\":10}]}"; -const beforeHTML = fg.svg.innerHTML; -const pausedBySpacePrevented = keyEvent(" ", "Space"); -const pausedBySpace = pausedBySpacePrevented && fg.paused && fg.pauseBtn.textContent === "Resume" && fg.status.textContent.indexOf("PAUSED") === 0; - -fg.eventSource.onmessage({ data: pausePayload }); -const noUpdateWhilePaused = fg.pendingData === pausePayload && fg.svg.innerHTML === beforeHTML; - -const pausedFrame = makeFrame("needle", "needle", 1, 0, 1200); -fg.frames = [pausedFrame]; -fg.rootWidth = 1200; -fgZoom(pausedFrame); -prompt = function(_msg, prev) { - promptCalls++; - return prev || "needle"; -}; -fgSearch(); -const zoomSearchWhilePaused = fg.zoomRange && fg.zoomRange.path === "needle" && - pausedFrame.querySelector("rect").getAttribute("fill") === fg.matchColor; - -const resumedBySpacePrevented = keyEvent(" ", "Space"); -const unpauseRendersLatest = resumedBySpacePrevented && !fg.paused && fg.pendingData === null && - fg.pauseBtn.textContent === "Pause" && fg.svg.innerHTML.indexOf('data-name="latest"') >= 0; - -let rapidToggleStable = true; -for (let i = 0; i < 20; i++) { - try { - fgTogglePause(); - } catch (err) { - rapidToggleStable = false; - } -} -if (fg.paused) { - fgTogglePause(); -} -rapidToggleStable = rapidToggleStable && !fg.paused && fg.pauseBtn.textContent === "Pause"; - -promptCalls = 0; -prompt = function() { - promptCalls++; - return "slash"; -}; -const slashPrevented = keyEvent("/", "Slash"); -const slashSearchWorks = slashPrevented && promptCalls === 1 && fg.searchQuery === "slash"; - -const escFrame = makeFrame("slash", "slash", 1, 0, 1200); -fg.frames = [escFrame]; -fg.rootWidth = 1200; -fgZoom(escFrame); -fgSearch(); -const escapePrevented = keyEvent("Escape", "Escape"); -const escapeResets = escapePrevented && fg.zoomRange === null && - escFrame.querySelector("rect").getAttribute("fill") === escFrame.dataset.baseFill; - -let buttonPromptCalls = 0; -prompt = function() { - buttonPromptCalls++; - return "button"; -}; -document.getElementById("btn-pause").listeners.click(); -const pauseViaButton = fg.paused && fg.pauseBtn.textContent === "Resume"; -document.getElementById("btn-pause").listeners.click(); -const resumeViaButton = !fg.paused && fg.pauseBtn.textContent === "Pause"; -document.getElementById("btn-search").listeners.click(); -const searchViaButton = buttonPromptCalls === 1 && fg.searchQuery === "button"; - -const btnFrame = makeFrame("button", "button", 1, 0, 1200); -fg.frames = [btnFrame]; -fg.rootWidth = 1200; -fgZoom(btnFrame); -document.getElementById("btn-reset-search").listeners.click(); -document.getElementById("btn-reset-zoom").listeners.click(); -const resetViaButton = fg.zoomRange === null && - btnFrame.querySelector("rect").getAttribute("fill") === btnFrame.dataset.baseFill; -const buttonMatchesKeyboard = pauseViaButton && resumeViaButton && searchViaButton && resetViaButton; - -const typingTarget = { tagName: "INPUT", isContentEditable: false }; -fg.searchQuery = "typed"; -fg.zoomRange = { path: "typed", x: 0, w: 1200, depth: 1 }; -promptCalls = 0; -const typingSpacePrevented = keyEvent(" ", "Space", typingTarget); -const typingSlashPrevented = keyEvent("/", "Slash", typingTarget); -const typingEscapePrevented = keyEvent("Escape", "Escape", typingTarget); -const typingIgnoresShortcuts = !typingSpacePrevented && !typingSlashPrevented && !typingEscapePrevented && - !fg.paused && promptCalls === 0 && fg.zoomRange !== null && fg.searchQuery === "typed"; - -console.log(JSON.stringify({ - pausedBySpace, - noUpdateWhilePaused, - zoomSearchWhilePaused, - unpauseRendersLatest, - rapidToggleStable, - slashSearchWorks, - escapeResets, - buttonMatchesKeyboard, - typingIgnoresShortcuts -})); -` - - out := runLiveHTMLNodeSnippet(t, snippet) - var got pauseKeyboardResult - if err := json.Unmarshal([]byte(out), &got); err != nil { - t.Fatalf("decode node result: %v\nraw:\n%s", err, out) - } - - if !got.PausedBySpace { - t.Fatalf("expected Space shortcut to pause and update status/button state") - } - if !got.NoUpdateWhilePaused { - t.Fatalf("expected stream updates to queue while paused without rerendering") - } - if !got.ZoomSearchWhilePaused { - t.Fatalf("expected zoom and search to work while paused") - } - if !got.UnpauseRendersLatest { - t.Fatalf("expected unpause to render latest queued update immediately") - } - if !got.RapidToggleStable { - t.Fatalf("expected rapid pause/unpause toggles to remain stable") - } - if !got.SlashSearchWorks { - t.Fatalf("expected '/' shortcut to open search flow") - } - if !got.EscapeResets { - t.Fatalf("expected Escape shortcut to reset zoom/search highlighting") - } - if !got.ButtonMatchesKeyboard { - t.Fatalf("expected button actions to match keyboard behavior") - } - if !got.TypingIgnoresShortcuts { - t.Fatalf("expected keyboard shortcuts to be ignored while typing in an input") - } -} - -func TestLiveHTMLJSResetBaselineHotkeyAndButton(t *testing.T) { - if _, err := exec.LookPath("node"); err != nil { - t.Skip("node not available") - } - - snippet := ` -const fg = liveFlamegraphState; -const keydown = __docListeners["keydown"]; - -function keyEvent(key, code, target) { - let prevented = false; - keydown({ - key: key, - code: code, - target: target || { tagName: "BODY", isContentEditable: false }, - preventDefault: function(){ prevented = true; } - }); - return prevented; -} - -const frame = makeFrame("needle", "needle", 1, 0, 1200); -fg.frames = [frame]; -fg.rootWidth = 1200; -fgZoom(frame); -prompt = function(){ return "needle"; }; -fgSearch(); - -const resetPayload = "{\"n\":\"\",\"v\":0,\"t\":0}"; -const resetCalls = []; -fetch = function(url, opts) { - resetCalls.push({ - url: url, - method: (opts && opts.method) || "GET" - }); - return Promise.resolve({ - ok: true, - text: function() { return Promise.resolve(resetPayload); } - }); -}; - -const hotkeyPrevented = keyEvent("r", "KeyR"); -const shiftHotkeyPrevented = keyEvent("R", "KeyR"); -const shiftHotkeyIgnored = !shiftHotkeyPrevented && resetCalls.length === 1; - -setTimeout(function() { - const hotkeyResetApplied = fg.zoomRange === null && fg.searchQuery === "" && fg.frames.length === 0; - - const frame2 = makeFrame("again", "again", 1, 0, 1200); - fg.frames = [frame2]; - fg.rootWidth = 1200; - fgZoom(frame2); - fg.searchQuery = "again"; - document.getElementById("btn-reset-baseline").listeners.click(); - - setTimeout(function() { - const buttonResetApplied = fg.zoomRange === null && fg.searchQuery === "" && fg.frames.length === 0; - const resetCallsValid = resetCalls.length === 2 && - resetCalls[0].url === "/reset" && resetCalls[0].method === "POST" && - resetCalls[1].url === "/reset" && resetCalls[1].method === "POST"; - - console.log(JSON.stringify({ - hotkeyPrevented, - shiftHotkeyIgnored, - hotkeyResetApplied, - buttonResetApplied, - resetCallsValid - })); - }, 0); -}, 0); -` - - out := runLiveHTMLNodeSnippet(t, snippet) - var got resetBaselineResult - if err := json.Unmarshal([]byte(out), &got); err != nil { - t.Fatalf("decode node result: %v\nraw:\n%s", err, out) - } - - if !got.HotkeyPrevented { - t.Fatalf("expected reset hotkey to prevent default browser handling") - } - if !got.ShiftHotkeyIgnored { - t.Fatalf("expected uppercase 'R' to be ignored for baseline reset") - } - if !got.HotkeyResetApplied { - t.Fatalf("expected 'r' hotkey to reset baseline and clear UI state") - } - if !got.ButtonResetApplied { - t.Fatalf("expected Reset Baseline button to clear UI state") - } - if !got.ResetCallsValid { - t.Fatalf("expected reset interactions to POST /reset") - } -} - -func TestLiveHTMLJSOrderToggle(t *testing.T) { - if _, err := exec.LookPath("node"); err != nil { - t.Skip("node not available") - } - - snippet := ` -const fg = liveFlamegraphState; -const orderCalls = []; -fetch = function(url, opts) { - orderCalls.push({ - url: url, - method: (opts && opts.method) || "GET", - body: (opts && opts.body) || "" - }); - return Promise.resolve({ - ok: true, - json: function() { - return Promise.resolve({ - fields: ["path", "tracepoint", "comm"], - snapshot: { - n: "", - v: 0, - t: 1, - c: [{ n: "/tmp", v: 1, t: 1 }] - } - }); - } - }); -}; - -document.getElementById("btn-toggle-order").listeners.click(); - -setTimeout(function() { - const orderButtonUpdated = document.getElementById("btn-toggle-order").textContent.indexOf("path > tracepoint > comm") >= 0; - const orderSnapshotShown = fg.svg.innerHTML.indexOf('data-name="/tmp"') >= 0; - const req = orderCalls[0] || {}; - let bodyFields = []; - try { - bodyFields = JSON.parse(req.body || "{}").fields || []; - } catch (err) { - bodyFields = []; - } - const orderCallValid = orderCalls.length === 1 && - req.url === "/order" && - req.method === "POST" && - JSON.stringify(bodyFields) === JSON.stringify(["path", "tracepoint", "comm"]); - - console.log(JSON.stringify({ - orderButtonUpdated, - orderCallValid, - orderSnapshotShown - })); -}, 0); -` - - out := runLiveHTMLNodeSnippet(t, snippet) - var got orderToggleResult - if err := json.Unmarshal([]byte(out), &got); err != nil { - t.Fatalf("decode node result: %v\nraw:\n%s", err, out) - } - - if !got.OrderButtonUpdated { - t.Fatalf("expected toggle button label to update to next order") - } - if !got.OrderCallValid { - t.Fatalf("expected toggle to POST /order with next preset fields") - } - if !got.OrderSnapshotShown { - t.Fatalf("expected returned order snapshot to render immediately") - } -} - -func runLiveHTMLNodeSnippet(t *testing.T, snippet string) string { - t.Helper() - - script := extractLiveHTMLScript(t) - harness := fmt.Sprintf(` -const vm = require("vm"); -const liveScript = %q; - -function makeElement(id) { - return { - id, - textContent: "", - innerHTML: "", - style: {}, - dataset: {}, - attrs: {}, - classList: { toggle: function(){}, add: function(){}, remove: function(){} }, - listeners: {}, - addEventListener: function(event, cb) { this.listeners[event] = cb; }, - getBoundingClientRect: function() { return { height: id === "controls" ? 56 : 0 }; }, - setAttribute: function(k, v) { this.attrs[k] = String(v); }, - getAttribute: function(k) { return this.attrs[k] || ""; }, - querySelectorAll: function() { return []; }, - querySelector: function() { return null; } - }; -} - -function makeRect(fill) { - return { - attrs: { fill: fill || "" }, - dataset: {}, - style: {}, - setAttribute: function(k, v) { this.attrs[k] = String(v); }, - getAttribute: function(k) { return this.attrs[k] || ""; } - }; -} - -function makeText(name) { - return { - textContent: name || "", - dataset: { full: name || "", hidden: "0", ox: "0" }, - style: {}, - setAttribute: function(k, v) { - this[k] = String(v); - }, - getAttribute: function(k) { - return this[k] || ""; - } - }; -} - -function makeFrame(name, path, depth, x, w) { - const rect = makeRect("rgb(1,2,3)"); - rect.dataset.ox = String(x); - rect.dataset.ow = String(w); - rect.setAttribute("x", String(x)); - rect.setAttribute("width", String(w)); - - const text = makeText(name); - text.dataset.ox = String(x + 3); - text.setAttribute("x", String(x + 3)); - - const title = { textContent: name + " title" }; - return { - dataset: { - name: name, - path: path, - depth: String(depth), - x: String(x), - w: String(w), - ox: String(x), - ow: String(w), - baseFill: "rgb(1,2,3)" - }, - style: {}, - listeners: {}, - addEventListener: function(event, cb) { this.listeners[event] = cb; }, - querySelector: function(selector) { - if (selector === "rect") return rect; - if (selector === "text") return text; - if (selector === "title") return title; - return null; - }, - querySelectorAll: function() { return []; }, - }; -} - -const elements = {}; -["controls", "flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline", "btn-toggle-order"].forEach((id) => { - elements[id] = makeElement(id); -}); -elements["body"] = makeElement("body"); - -const docListeners = {}; -global.document = { - body: elements["body"], - getElementById: function(id) { - if (!elements[id]) elements[id] = makeElement(id); - return elements[id]; - }, - addEventListener: function(event, cb) { docListeners[event] = cb; }, -}; -global.window = global; -global.prompt = function(){ return ""; }; -global.fetch = function() { - return Promise.resolve({ - ok: true, - json: function() { return Promise.resolve({ fields: ["comm", "tracepoint", "path"], snapshot: { n: "", v: 0, t: 0 } }); }, - text: function() { return Promise.resolve("{\"n\":\"\",\"v\":0,\"t\":0}"); } - }); -}; -global.requestAnimationFrame = function(cb){ cb(); }; -global.EventSource = function() { - this.onmessage = null; - this.onerror = null; -}; -window.addEventListener = function(){}; - -vm.runInThisContext(liveScript); - -global.makeFrame = makeFrame; -global.__docListeners = docListeners; - -%s -`, script, snippet) - - tmp, err := os.CreateTemp("", "livehtml-node-snippet-*.cjs") - if err != nil { - t.Fatalf("create temp script: %v", err) - } - defer os.Remove(tmp.Name()) - - if _, err := tmp.WriteString(harness); err != nil { - _ = tmp.Close() - t.Fatalf("write temp script: %v", err) - } - if err := tmp.Close(); err != nil { - t.Fatalf("close temp script: %v", err) - } - - out, err := exec.Command("node", tmp.Name()).CombinedOutput() - if err != nil { - t.Fatalf("node snippet failed: %v\n%s", err, string(out)) - } - return strings.TrimSpace(string(out)) -} diff --git a/internal/flamegraph/liveserver.go b/internal/flamegraph/liveserver.go deleted file mode 100644 index 8ae2b82..0000000 --- a/internal/flamegraph/liveserver.go +++ /dev/null @@ -1,314 +0,0 @@ -package flamegraph - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "os" - "os/exec" - "os/user" - "path/filepath" - "strconv" - "strings" - "syscall" - "time" -) - -var liveServerTimeouts = serverTimeouts{ - readTimeout: 10 * time.Second, - writeTimeout: 5 * time.Minute, - idleTimeout: 60 * time.Second, -} - -type LiveServerOptions struct { - OpenCommand string - WarningCb func(message string) -} - -var openBrowserURLFn = openBrowserURL - -// ServeLive starts the live flamegraph HTTP server and blocks until ctx is canceled. -func ServeLive(ctx context.Context, lt *LiveTrie, interval time.Duration) error { - return ServeLiveWithOptions(ctx, lt, interval, LiveServerOptions{}) -} - -// ServeLiveWithOptions starts the live flamegraph server with runtime options. -func ServeLiveWithOptions(ctx context.Context, lt *LiveTrie, interval time.Duration, options LiveServerOptions) error { - mux := http.NewServeMux() - mux.HandleFunc("/", handleLivePage()) - mux.HandleFunc("/events", handleSSE(lt, interval)) - mux.HandleFunc("/reset", handleReset(lt)) - mux.HandleFunc("/order", handleOrder(lt)) - return runServer(ctx, mux, liveServerTimeouts, func(hostname string, port int) { - url := fmt.Sprintf("http://%s:%d/", hostname, port) - fmt.Printf("Live flamegraph available at %s\n", url) - if err := maybeOpenLiveBrowser(url, options); err != nil { - notifyLiveWarning(options.WarningCb, fmt.Sprintf("Live flamegraph browser auto-open failed: %v", err)) - } - }) -} - -func maybeOpenLiveBrowser(url string, options LiveServerOptions) error { - if strings.TrimSpace(options.OpenCommand) == "" { - return nil - } - return openBrowserURLFn(url, options.OpenCommand) -} - -func openBrowserURL(url, openCommand string) error { - parts, err := browserOpenCommandParts(openCommand, url) - if err != nil { - return err - } - cmd := exec.Command(parts[0], parts[1:]...) - applySudoInvokerContext(cmd) - if err := cmd.Start(); err != nil { - return err - } - - waitCh := make(chan error, 1) - go func() { waitCh <- cmd.Wait() }() - - timer := time.NewTimer(750 * time.Millisecond) - defer stopAndDrainTimer(timer) - - select { - case waitErr := <-waitCh: - if waitErr != nil { - return fmt.Errorf("browser command exited early: %w", waitErr) - } - case <-timer.C: - } - return nil -} - -func stopAndDrainTimer(timer *time.Timer) { - if timer == nil { - return - } - if timer.Stop() { - return - } - select { - case <-timer.C: - default: - } -} - -func notifyLiveWarning(warningCb func(string), message string) { - if message == "" { - return - } - if warningCb != nil { - warningCb(message) - return - } - fmt.Println(message) -} - -func applySudoInvokerContext(cmd *exec.Cmd) { - applySudoInvokerContextWithEnv(cmd, os.Geteuid(), os.Environ()) -} - -func applySudoInvokerContextWithEnv(cmd *exec.Cmd, euid int, env []string) { - if cmd == nil || euid != 0 { - return - } - - sudoUIDStr, okUID := lookupEnvValue(env, "SUDO_UID") - sudoGIDStr, okGID := lookupEnvValue(env, "SUDO_GID") - if !okUID || !okGID { - return - } - - uid, errUID := strconv.ParseUint(strings.TrimSpace(sudoUIDStr), 10, 32) - gid, errGID := strconv.ParseUint(strings.TrimSpace(sudoGIDStr), 10, 32) - if errUID != nil || errGID != nil { - return - } - - cmd.SysProcAttr = &syscall.SysProcAttr{ - Credential: &syscall.Credential{ - Uid: uint32(uid), - Gid: uint32(gid), - }, - } - - launchEnv := append([]string(nil), env...) - if sudoUser, ok := lookupEnvValue(env, "SUDO_USER"); ok && strings.TrimSpace(sudoUser) != "" { - launchEnv = upsertEnvValue(launchEnv, "USER", sudoUser) - launchEnv = upsertEnvValue(launchEnv, "LOGNAME", sudoUser) - } - - if sudoUser, err := user.LookupId(strconv.FormatUint(uid, 10)); err == nil && strings.TrimSpace(sudoUser.HomeDir) != "" { - launchEnv = upsertEnvValue(launchEnv, "HOME", sudoUser.HomeDir) - if _, ok := lookupEnvValue(launchEnv, "XAUTHORITY"); !ok { - xauth := filepath.Join(sudoUser.HomeDir, ".Xauthority") - if info, statErr := os.Stat(xauth); statErr == nil && !info.IsDir() { - launchEnv = upsertEnvValue(launchEnv, "XAUTHORITY", xauth) - } - } - } - - if _, ok := lookupEnvValue(launchEnv, "XDG_RUNTIME_DIR"); !ok { - runtimeDir := fmt.Sprintf("/run/user/%d", uid) - if info, statErr := os.Stat(runtimeDir); statErr == nil && info.IsDir() { - launchEnv = upsertEnvValue(launchEnv, "XDG_RUNTIME_DIR", runtimeDir) - } - } - - cmd.Env = launchEnv -} - -func lookupEnvValue(env []string, key string) (string, bool) { - prefix := key + "=" - for _, entry := range env { - if strings.HasPrefix(entry, prefix) { - return strings.TrimPrefix(entry, prefix), true - } - } - return "", false -} - -func upsertEnvValue(env []string, key, value string) []string { - prefix := key + "=" - for i := range env { - if strings.HasPrefix(env[i], prefix) { - env[i] = prefix + value - return env - } - } - return append(env, prefix+value) -} - -func browserOpenCommandParts(openCommand, url string) ([]string, error) { - parts := strings.Fields(strings.TrimSpace(openCommand)) - if len(parts) == 0 { - return nil, errors.New("empty browser open command") - } - - containsURL := false - for i := range parts { - if strings.Contains(parts[i], "{url}") { - parts[i] = strings.ReplaceAll(parts[i], "{url}", url) - containsURL = true - } - } - if !containsURL { - parts = append(parts, url) - } - return parts, nil -} - -func handleLivePage() http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write([]byte(liveHTML)) - } -} - -func handleSSE(lt *LiveTrie, interval time.Duration) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "streaming unsupported", http.StatusInternalServerError) - return - } - if interval <= 0 { - interval = 200 * time.Millisecond - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - - lastVersion, err := sendSnapshot(w, flusher, lt, ^uint64(0)) - if err != nil { - return - } - - ticker := time.NewTicker(interval) - defer ticker.Stop() - - for { - select { - case <-r.Context().Done(): - return - case <-ticker.C: - if lt.Version() == lastVersion { - continue - } - lastVersion, err = sendSnapshot(w, flusher, lt, lastVersion) - if err != nil { - return - } - } - } - } -} - -func handleReset(lt *LiveTrie) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.Header().Set("Allow", http.MethodPost) - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - lt.Reset() - payload, _ := lt.SnapshotJSON() - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(payload) - } -} - -type orderRequest struct { - Fields []string `json:"fields"` -} - -type orderResponse struct { - Fields []string `json:"fields"` - Snapshot json.RawMessage `json:"snapshot,omitempty"` -} - -func handleOrder(lt *LiveTrie) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(orderResponse{Fields: lt.Fields()}) - case http.MethodPost: - var req orderRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid json body", http.StatusBadRequest) - return - } - if err := lt.Reconfigure(req.Fields); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - snap, _ := lt.SnapshotJSON() - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(orderResponse{ - Fields: lt.Fields(), - Snapshot: snap, - }) - default: - w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost) - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } - } -} - -func sendSnapshot(w http.ResponseWriter, flusher http.Flusher, lt *LiveTrie, lastVersion uint64) (uint64, error) { - payload, version := lt.SnapshotJSON() - if version == lastVersion { - return lastVersion, nil - } - if _, err := fmt.Fprintf(w, "data: %s\n\n", payload); err != nil { - return lastVersion, err - } - flusher.Flush() - return version, nil -} diff --git a/internal/flamegraph/liveserver_open_test.go b/internal/flamegraph/liveserver_open_test.go deleted file mode 100644 index aa9340a..0000000 --- a/internal/flamegraph/liveserver_open_test.go +++ /dev/null @@ -1,179 +0,0 @@ -package flamegraph - -import ( - "errors" - "os" - "os/exec" - "strconv" - "testing" -) - -func TestBrowserOpenCommandPartsRequiresCommand(t *testing.T) { - _, err := browserOpenCommandParts("", "http://localhost:1234/") - if err == nil { - t.Fatalf("expected error for empty open command") - } -} - -func TestBrowserOpenCommandPartsAppendsURLWhenMissing(t *testing.T) { - got, err := browserOpenCommandParts("chromium --new-window", "http://localhost:1234/") - if err != nil { - t.Fatalf("browserOpenCommandParts returned error: %v", err) - } - want := []string{"chromium", "--new-window", "http://localhost:1234/"} - if len(got) != len(want) { - t.Fatalf("len(parts) = %d, want %d", len(got), len(want)) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("parts[%d] = %q, want %q", i, got[i], want[i]) - } - } -} - -func TestBrowserOpenCommandPartsReplacesURLPlaceholder(t *testing.T) { - got, err := browserOpenCommandParts("open-browser --target={url}", "http://localhost:1234/") - if err != nil { - t.Fatalf("browserOpenCommandParts returned error: %v", err) - } - want := []string{"open-browser", "--target=http://localhost:1234/"} - if len(got) != len(want) { - t.Fatalf("len(parts) = %d, want %d", len(got), len(want)) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("parts[%d] = %q, want %q", i, got[i], want[i]) - } - } -} - -func TestMaybeOpenLiveBrowserWithoutCommandSkipsOpen(t *testing.T) { - called := false - orig := openBrowserURLFn - openBrowserURLFn = func(url, openCommand string) error { - called = true - return nil - } - t.Cleanup(func() { openBrowserURLFn = orig }) - - err := maybeOpenLiveBrowser("http://localhost:1234/", LiveServerOptions{}) - if err != nil { - t.Fatalf("maybeOpenLiveBrowser returned error: %v", err) - } - if called { - t.Fatalf("expected browser opener not to be called without open command") - } -} - -func TestMaybeOpenLiveBrowserWithCommandCallsOpen(t *testing.T) { - called := false - orig := openBrowserURLFn - openBrowserURLFn = func(url, openCommand string) error { - called = true - if url != "http://localhost:1234/" { - t.Fatalf("url = %q, want %q", url, "http://localhost:1234/") - } - if openCommand != "chromium" { - t.Fatalf("openCommand = %q, want %q", openCommand, "chromium") - } - return nil - } - t.Cleanup(func() { openBrowserURLFn = orig }) - - err := maybeOpenLiveBrowser("http://localhost:1234/", LiveServerOptions{ - OpenCommand: "chromium", - }) - if err != nil { - t.Fatalf("maybeOpenLiveBrowser returned error: %v", err) - } - if !called { - t.Fatalf("expected browser opener to be called") - } -} - -func TestMaybeOpenLiveBrowserPropagatesOpenError(t *testing.T) { - orig := openBrowserURLFn - openBrowserURLFn = func(url, openCommand string) error { - return errors.New("launch failed") - } - t.Cleanup(func() { openBrowserURLFn = orig }) - - err := maybeOpenLiveBrowser("http://localhost:1234/", LiveServerOptions{ - OpenCommand: "chromium", - }) - if err == nil || err.Error() != "launch failed" { - t.Fatalf("expected launch failed error, got %v", err) - } -} - -func TestOpenBrowserURLReturnsErrorWhenCommandExitsNonZero(t *testing.T) { - err := openBrowserURL("http://localhost:1234/", "false") - if err == nil { - t.Fatalf("expected non-nil error") - } -} - -func TestOpenBrowserURLReturnsNilWhenCommandExitsZero(t *testing.T) { - err := openBrowserURL("http://localhost:1234/", "true") - if err != nil { - t.Fatalf("expected nil error, got %v", err) - } -} - -func TestApplySudoInvokerContextWithEnvSetsCredential(t *testing.T) { - cmd := exec.Command("echo") - uid := os.Getuid() - gid := os.Getgid() - env := []string{ - "SUDO_UID=" + strconv.Itoa(uid), - "SUDO_GID=" + strconv.Itoa(gid), - "SUDO_USER=tester", - "HOME=/root", - } - - applySudoInvokerContextWithEnv(cmd, 0, env) - - if cmd.SysProcAttr == nil || cmd.SysProcAttr.Credential == nil { - t.Fatalf("expected process credentials to be configured") - } - if got := cmd.SysProcAttr.Credential.Uid; got != uint32(uid) { - t.Fatalf("credential uid = %d, want %d", got, uint32(uid)) - } - if got := cmd.SysProcAttr.Credential.Gid; got != uint32(gid) { - t.Fatalf("credential gid = %d, want %d", got, uint32(gid)) - } - if got, ok := lookupEnvValue(cmd.Env, "USER"); !ok || got != "tester" { - t.Fatalf("USER env = %q (ok=%v), want %q", got, ok, "tester") - } - if got, ok := lookupEnvValue(cmd.Env, "LOGNAME"); !ok || got != "tester" { - t.Fatalf("LOGNAME env = %q (ok=%v), want %q", got, ok, "tester") - } -} - -func TestApplySudoInvokerContextWithEnvSkipsWhenNotRoot(t *testing.T) { - cmd := exec.Command("echo") - env := []string{ - "SUDO_UID=1000", - "SUDO_GID=1000", - "SUDO_USER=tester", - } - - applySudoInvokerContextWithEnv(cmd, 1000, env) - - if cmd.SysProcAttr != nil { - t.Fatalf("expected credentials to remain nil for non-root euid") - } - if cmd.Env != nil { - t.Fatalf("expected environment to remain nil for non-root euid") - } -} - -func TestNotifyLiveWarningUsesCallback(t *testing.T) { - var got string - notifyLiveWarning(func(message string) { - got = message - }, "open failed") - if got != "open failed" { - t.Fatalf("warning callback got %q, want %q", got, "open failed") - } -} diff --git a/internal/flamegraph/liveserver_test.go b/internal/flamegraph/liveserver_test.go deleted file mode 100644 index 59a3782..0000000 --- a/internal/flamegraph/liveserver_test.go +++ /dev/null @@ -1,380 +0,0 @@ -package flamegraph - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "strings" - "sync" - "testing" - "time" -) - -func TestHandleSSEContentTypeFormatAndEmptyTrie(t *testing.T) { - lt := NewLiveTrie([]string{"comm"}, "count") - srv := httptest.NewServer(handleSSE(lt, 5*time.Millisecond)) - defer srv.Close() - - resp := connectSSE(t, srv.URL) - defer resp.Body.Close() - - contentType := resp.Header.Get("Content-Type") - if !strings.HasPrefix(contentType, "text/event-stream") { - t.Fatalf("Content-Type = %q, want text/event-stream", contentType) - } - - data := readFirstSSEData(t, resp.Body) - snap := decodeSSESnapshot(t, data) - if snap.Total != 0 { - t.Fatalf("empty trie snapshot total = %d, want 0", snap.Total) - } -} - -func TestHandleSSEMultipleClientsReceiveInitialSnapshot(t *testing.T) { - lt := NewLiveTrie([]string{"comm"}, "count") - lt.Ingest(newTestPair("multi", 42, 1001, "/tmp/multi", 1, 1, 1)) - srv := httptest.NewServer(handleSSE(lt, 5*time.Millisecond)) - defer srv.Close() - - const clients = 4 - var wg sync.WaitGroup - errCh := make(chan error, clients) - - wg.Add(clients) - for i := 0; i < clients; i++ { - go func() { - defer wg.Done() - resp := connectSSE(t, srv.URL) - defer resp.Body.Close() - data := readFirstSSEData(t, resp.Body) - snap := decodeSSESnapshot(t, data) - if snap.Total == 0 { - errCh <- fmt.Errorf("received empty snapshot") - } - }() - } - - wg.Wait() - close(errCh) - for err := range errCh { - t.Fatal(err) - } -} - -func TestHandleSSEReconnectAfterDisconnectGetsLatestSnapshot(t *testing.T) { - lt := NewLiveTrie([]string{"path"}, "count") - lt.Ingest(newTestPair("reconnect", 1, 1001, "/tmp/a", 1, 1, 1)) - srv := httptest.NewServer(handleSSE(lt, 5*time.Millisecond)) - defer srv.Close() - - resp1 := connectSSE(t, srv.URL) - first := decodeSSESnapshot(t, readFirstSSEData(t, resp1.Body)) - _ = resp1.Body.Close() - if first.Total != 1 { - t.Fatalf("first snapshot total = %d, want 1", first.Total) - } - - lt.Ingest(newTestPair("reconnect", 1, 1002, "/tmp/b", 1, 1, 1)) - - resp2 := connectSSE(t, srv.URL) - defer resp2.Body.Close() - second := decodeSSESnapshot(t, readFirstSSEData(t, resp2.Body)) - if second.Total != 2 { - t.Fatalf("reconnected snapshot total = %d, want 2", second.Total) - } -} - -func TestHandleSSERestartedServerAcceptsNewConnection(t *testing.T) { - lt := NewLiveTrie([]string{"comm"}, "count") - lt.Ingest(newTestPair("restart", 1, 1001, "/tmp/a", 1, 1, 1)) - - srv1 := httptest.NewServer(handleSSE(lt, 5*time.Millisecond)) - resp1 := connectSSE(t, srv1.URL) - first := decodeSSESnapshot(t, readFirstSSEData(t, resp1.Body)) - _ = resp1.Body.Close() - srv1.Close() - if first.Total != 1 { - t.Fatalf("first server snapshot total = %d, want 1", first.Total) - } - - lt.Ingest(newTestPair("restart", 1, 1002, "/tmp/b", 1, 1, 1)) - - srv2 := httptest.NewServer(handleSSE(lt, 5*time.Millisecond)) - defer srv2.Close() - resp2 := connectSSE(t, srv2.URL) - defer resp2.Body.Close() - second := decodeSSESnapshot(t, readFirstSSEData(t, resp2.Body)) - if second.Total != 2 { - t.Fatalf("second server snapshot total = %d, want 2", second.Total) - } -} - -func TestHandleSSEDelayedClientLargeTrieGetsValidSnapshot(t *testing.T) { - lt := NewLiveTrie([]string{"path"}, "count") - const events = 12000 - for i := 0; i < events; i++ { - lt.Ingest(newTestPair("late", 7, uint32(10000+i), fmt.Sprintf("/late/%05d", i), 1, 1, 1)) - } - - srv := httptest.NewServer(handleSSE(lt, 5*time.Millisecond)) - defer srv.Close() - - resp := connectSSE(t, srv.URL) - defer resp.Body.Close() - snap := decodeSSESnapshot(t, readFirstSSEData(t, resp.Body)) - if snap.Total != events { - t.Fatalf("late client snapshot total = %d, want %d", snap.Total, events) - } -} - -func TestHandleResetRequiresPost(t *testing.T) { - lt := NewLiveTrie([]string{"comm"}, "count") - req := httptest.NewRequest(http.MethodGet, "/reset", nil) - rec := httptest.NewRecorder() - - handleReset(lt).ServeHTTP(rec, req) - - if rec.Code != http.StatusMethodNotAllowed { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) - } - if allow := rec.Header().Get("Allow"); allow != http.MethodPost { - t.Fatalf("allow = %q, want %q", allow, http.MethodPost) - } -} - -func TestHandleResetClearsTrieAndReturnsEmptySnapshot(t *testing.T) { - lt := NewLiveTrie([]string{"path"}, "count") - lt.Ingest(newTestPair("reset", 1, 1001, "/tmp/a", 1, 1, 1)) - lt.Ingest(newTestPair("reset", 1, 1002, "/tmp/b", 1, 1, 1)) - if before := decodeLiveSnapshot(t, lt); before.Total == 0 { - t.Fatalf("expected non-empty trie before reset") - } - - req := httptest.NewRequest(http.MethodPost, "/reset", nil) - rec := httptest.NewRecorder() - handleReset(lt).ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) - } - if ctype := rec.Header().Get("Content-Type"); !strings.Contains(ctype, "application/json") { - t.Fatalf("content-type = %q, want application/json", ctype) - } - var snap trieSnapshot - if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil { - t.Fatalf("decode reset snapshot: %v", err) - } - if snap.Total != 0 { - t.Fatalf("reset snapshot total = %d, want 0", snap.Total) - } - - after := decodeLiveSnapshot(t, lt) - if after.Total != 0 { - t.Fatalf("trie total after reset = %d, want 0", after.Total) - } -} - -func TestHandleOrderGetReturnsCurrentFields(t *testing.T) { - lt := NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") - req := httptest.NewRequest(http.MethodGet, "/order", nil) - rec := httptest.NewRecorder() - handleOrder(lt).ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) - } - var resp orderResponse - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode response: %v", err) - } - if strings.Join(resp.Fields, ",") != "comm,path,tracepoint" { - t.Fatalf("fields = %v, want [comm path tracepoint]", resp.Fields) - } -} - -func TestHandleOrderPostReconfiguresAndResets(t *testing.T) { - lt := NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") - lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 1, 1, 1)) - - req := httptest.NewRequest(http.MethodPost, "/order", strings.NewReader(`{"fields":["path","tracepoint","comm"]}`)) - rec := httptest.NewRecorder() - handleOrder(lt).ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) - } - var resp orderResponse - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode response: %v", err) - } - if strings.Join(resp.Fields, ",") != "path,tracepoint,comm" { - t.Fatalf("fields = %v, want [path tracepoint comm]", resp.Fields) - } - var snap trieSnapshot - if err := json.Unmarshal(resp.Snapshot, &snap); err != nil { - t.Fatalf("decode snapshot: %v", err) - } - if snap.Total != 0 { - t.Fatalf("snapshot total after reconfigure = %d, want 0", snap.Total) - } -} - -func TestHandleOrderPostRejectsInvalidRequest(t *testing.T) { - lt := NewLiveTrie([]string{"comm"}, "count") - - req := httptest.NewRequest(http.MethodPost, "/order", strings.NewReader(`{"fields":["comm","bogus"]}`)) - rec := httptest.NewRecorder() - handleOrder(lt).ServeHTTP(rec, req) - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) - } - - req = httptest.NewRequest(http.MethodPost, "/order", strings.NewReader(`{"fields":[}`)) - rec = httptest.NewRecorder() - handleOrder(lt).ServeHTTP(rec, req) - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) - } -} - -func TestHandleOrderRequiresGetOrPost(t *testing.T) { - lt := NewLiveTrie([]string{"comm"}, "count") - req := httptest.NewRequest(http.MethodPut, "/order", nil) - rec := httptest.NewRecorder() - handleOrder(lt).ServeHTTP(rec, req) - - if rec.Code != http.StatusMethodNotAllowed { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) - } - if allow := rec.Header().Get("Allow"); allow != http.MethodGet+", "+http.MethodPost { - t.Fatalf("allow = %q, want %q", allow, http.MethodGet+", "+http.MethodPost) - } -} - -func TestServeLivePrintsURLAndStopsOnCancel(t *testing.T) { - lt := NewLiveTrie([]string{"comm"}, "count") - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - output := captureStdout(t, func() { - errCh := make(chan error, 1) - go func() { - errCh <- ServeLive(ctx, lt, 5*time.Millisecond) - }() - - time.Sleep(40 * time.Millisecond) - cancel() - - select { - case err := <-errCh: - if err != nil { - t.Fatalf("ServeLive returned error: %v", err) - } - case <-time.After(2 * time.Second): - t.Fatalf("timeout waiting for ServeLive to return") - } - }) - - if !strings.Contains(output, "Live flamegraph available at http://") { - t.Fatalf("expected live URL in output, got %q", output) - } -} - -func connectSSE(t *testing.T, url string) *http.Response { - t.Helper() - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Get(url) - if err != nil { - t.Fatalf("connect sse: %v", err) - } - if resp.StatusCode != http.StatusOK { - _ = resp.Body.Close() - t.Fatalf("unexpected status: %s", resp.Status) - } - return resp -} - -func readFirstSSEData(t *testing.T, body io.ReadCloser) string { - t.Helper() - type result struct { - data string - err error - } - ch := make(chan result, 1) - - go func() { - reader := bufio.NewReader(body) - line, err := reader.ReadString('\n') - if err != nil { - ch <- result{err: err} - return - } - if !strings.HasPrefix(line, "data: ") { - ch <- result{err: fmt.Errorf("invalid sse data line: %q", line)} - return - } - separator, err := reader.ReadString('\n') - if err != nil { - ch <- result{err: err} - return - } - if separator != "\n" { - ch <- result{err: fmt.Errorf("missing sse blank-line separator: %q", separator)} - return - } - ch <- result{data: strings.TrimSuffix(strings.TrimPrefix(line, "data: "), "\n")} - }() - - select { - case out := <-ch: - if out.err != nil { - t.Fatalf("read sse event: %v", out.err) - } - return out.data - case <-time.After(3 * time.Second): - _ = body.Close() - t.Fatalf("timeout waiting for first sse event") - return "" - } -} - -func decodeSSESnapshot(t *testing.T, data string) trieSnapshot { - t.Helper() - var snap trieSnapshot - if err := json.Unmarshal([]byte(data), &snap); err != nil { - t.Fatalf("invalid snapshot json: %v", err) - } - return snap -} - -func captureStdout(t *testing.T, fn func()) string { - t.Helper() - - oldStdout := os.Stdout - reader, writer, err := os.Pipe() - if err != nil { - t.Fatalf("create stdout pipe: %v", err) - } - - os.Stdout = writer - defer func() { os.Stdout = oldStdout }() - - outCh := make(chan string, 1) - go func() { - var b strings.Builder - _, _ = io.Copy(&b, reader) - outCh <- b.String() - }() - - fn() - - _ = writer.Close() - out := <-outCh - _ = reader.Close() - return out -} diff --git a/internal/flamegraph/nativejson.go b/internal/flamegraph/nativejson.go deleted file mode 100644 index 088bcfc..0000000 --- a/internal/flamegraph/nativejson.go +++ /dev/null @@ -1,86 +0,0 @@ -package flamegraph - -import ( - "encoding/json" - "fmt" - "io" - "iter" - "os" - "strings" -) - -type jsonNode struct { - Name string `json:"name"` - Value uint64 `json:"value"` - Total uint64 `json:"total"` - Children []jsonNode `json:"children,omitempty"` -} - -type jsonFlamegraph struct { - Fields []string `json:"fields"` - CountField string `json:"countField"` - Root jsonNode `json:"root"` -} - -func (n NativeSVG) WriteJSONFromFile(iorDataFile string) (outFile string, err error) { - outFile = fmt.Sprintf("%s.%s-by-%s.json", - strings.TrimSuffix(iorDataFile, ".ior.zst"), - strings.Join(n.fields, ":"), - n.countField, - ) - defer func() { - if err != nil { - _ = os.Remove(outFile) - } - }() - - iod, err := newIorDataFromFile(iorDataFile) - if err != nil { - return outFile, fmt.Errorf("read ior data: %w", err) - } - - fd, err := os.Create(outFile) - if err != nil { - return outFile, fmt.Errorf("create output %s: %w", outFile, err) - } - defer fd.Close() - - if err := n.WriteJSONFromIter(iod.iter(), fd); err != nil { - return outFile, err - } - return outFile, nil -} - -func (n NativeSVG) WriteJSONFromIter(records iter.Seq[IterRecord], w io.Writer) error { - tr, err := n.buildTrieFromIter(records) - if err != nil { - return err - } - - payload := jsonFlamegraph{ - Fields: append([]string(nil), n.fields...), - CountField: n.countField, - Root: jsonNodeFromTrieNode(tr.root, "root"), - } - - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - return enc.Encode(payload) -} - -func jsonNodeFromTrieNode(node *trieNode, name string) jsonNode { - out := jsonNode{ - Name: name, - Value: node.value, - Total: node.total, - } - if len(node.children) == 0 { - return out - } - - out.Children = make([]jsonNode, 0, len(node.children)) - for _, child := range node.children { - out.Children = append(out.Children, jsonNodeFromTrieNode(child, child.name)) - } - return out -} diff --git a/internal/flamegraph/nativejson_test.go b/internal/flamegraph/nativejson_test.go deleted file mode 100644 index c76d327..0000000 --- a/internal/flamegraph/nativejson_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package flamegraph - -import ( - "encoding/json" - "os" - "testing" -) - -type jsonNodeForTest struct { - Name string `json:"name"` - Value uint64 `json:"value"` - Total uint64 `json:"total"` - Children []jsonNodeForTest `json:"children"` -} - -type jsonFlamegraphForTest struct { - Fields []string `json:"fields"` - CountField string `json:"countField"` - Root jsonNodeForTest `json:"root"` -} - -func TestWriteJSONFromFileContainsFlamegraphTree(t *testing.T) { - dir := t.TempDir() - iorFile := writeTestIorZst(t, dir) - - n := NewNativeSVG([]string{"comm", "path", "tracepoint"}, "count") - outFile, err := n.WriteJSONFromFile(iorFile) - if err != nil { - t.Fatalf("WriteJSONFromFile returned error: %v", err) - } - - data, err := os.ReadFile(outFile) - if err != nil { - t.Fatalf("read output json: %v", err) - } - - var payload jsonFlamegraphForTest - if err := json.Unmarshal(data, &payload); err != nil { - t.Fatalf("unmarshal output json: %v", err) - } - - if payload.CountField != "count" { - t.Fatalf("count field = %q, want %q", payload.CountField, "count") - } - if len(payload.Fields) != 3 { - t.Fatalf("fields len = %d, want 3", len(payload.Fields)) - } - if payload.Root.Name != "root" { - t.Fatalf("root name = %q, want %q", payload.Root.Name, "root") - } - if payload.Root.Total != 1 { - t.Fatalf("root total = %d, want 1", payload.Root.Total) - } - if len(payload.Root.Children) != 1 { - t.Fatalf("root children len = %d, want 1", len(payload.Root.Children)) - } - if payload.Root.Children[0].Name != "tester" { - t.Fatalf("root child name = %q, want %q", payload.Root.Children[0].Name, "tester") - } -} - -func TestWriteJSONFromFileCleansUpPartialOutputOnError(t *testing.T) { - dir := t.TempDir() - iorFile := writeTestIorZst(t, dir) - - n := NewNativeSVG([]string{"invalidField"}, "count") - outFile, err := n.WriteJSONFromFile(iorFile) - if err == nil { - t.Fatal("expected error for invalid field, got nil") - } - - if _, statErr := os.Stat(outFile); !os.IsNotExist(statErr) { - t.Fatalf("expected partial output to be removed, stat err=%v", statErr) - } -} diff --git a/internal/flamegraph/nativesvg.go b/internal/flamegraph/nativesvg.go deleted file mode 100644 index 80061b4..0000000 --- a/internal/flamegraph/nativesvg.go +++ /dev/null @@ -1,97 +0,0 @@ -package flamegraph - -import ( - "fmt" - "io" - "iter" - "os" - "strings" -) - -// NativeSVG generates interactive flamegraph SVGs directly from .ior.zst data files. -// -// Flamegraphs are generated natively by ior from .ior.zst data files; no external -// flamegraph tool is required. The CLI typically drives this via the -ior flag, -// which reads trace data, aggregates it into a trie of stack frames (e.g. comm,path,tracepoint) -// and renders a self-contained SVG that can be viewed in a browser. -type NativeSVG struct { - fields []string - countField string - config SVGConfig -} - -func NewNativeSVG(fields []string, countField string) NativeSVG { - return NativeSVG{ - fields: fields, - countField: countField, - config: defaultSVGConfig(), - } -} - -func (n NativeSVG) WriteSVGFromFile(iorDataFile string) (outFile string, err error) { - outFile = fmt.Sprintf("%s.%s-by-%s.svg", - strings.TrimSuffix(iorDataFile, ".ior.zst"), - strings.Join(n.fields, ":"), - n.countField, - ) - defer func() { - if err != nil { - _ = os.Remove(outFile) - } - }() - - iod, err := newIorDataFromFile(iorDataFile) - if err != nil { - return outFile, fmt.Errorf("read ior data: %w", err) - } - - fd, err := os.Create(outFile) - if err != nil { - return outFile, fmt.Errorf("create output %s: %w", outFile, err) - } - defer fd.Close() - - if err := n.WriteSVGFromIter(iod.iter(), fd); err != nil { - return outFile, err - } - return outFile, nil -} - -func (n NativeSVG) WriteSVGFromIter(records iter.Seq[IterRecord], w io.Writer) error { - tr, err := n.buildTrieFromIter(records) - if err != nil { - return err - } - return WriteSVG(w, tr, n.config) -} - -func (n NativeSVG) buildTrieFromIter(records iter.Seq[IterRecord]) (*trie, error) { - tr := newTrie() - var framesBuf []string - for record := range records { - frames, err := n.recordFrames(record, framesBuf) - if err != nil { - return nil, err - } - framesBuf = frames - tr.add(frames, record.Cnt.ValueByName(n.countField)) - } - tr.computeTotals() - return tr, nil -} - -func (n NativeSVG) recordFrames(record IterRecord, framesBuf []string) ([]string, error) { - frames := framesBuf[:0] - for _, fieldName := range n.fields { - value, err := record.StringByName(fieldName) - if err != nil { - return nil, fmt.Errorf("field %s: %w", fieldName, err) - } - for _, part := range strings.Split(value, ";") { - if part != "" { - frames = append(frames, part) - } - } - } - return frames, nil -} diff --git a/internal/flamegraph/nativesvg_test.go b/internal/flamegraph/nativesvg_test.go deleted file mode 100644 index 36e88bf..0000000 --- a/internal/flamegraph/nativesvg_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package flamegraph - -import ( - "os" - "path/filepath" - "syscall" - "testing" - - "ior/internal/types" - - "github.com/DataDog/zstd" -) - -func writeTestIorZst(t *testing.T, dir string) string { - t.Helper() - - iod := newIorData() - iod.add("/tmp/test", types.SYS_ENTER_OPENAT, "tester", 100, 200, flagsType(syscall.O_RDONLY), Counter{ - Count: 1, - Duration: 10, - DurationToPrev: 2, - Bytes: 0, - }) - serialized, err := iod.serialize() - if err != nil { - t.Fatalf("serialize: %v", err) - } - - path := filepath.Join(dir, "sample.ior.zst") - fd, err := os.Create(path) - if err != nil { - t.Fatalf("create test ior file: %v", err) - } - defer fd.Close() - - enc := zstd.NewWriter(fd) - if _, err := enc.Write(serialized); err != nil { - t.Fatalf("write zstd payload: %v", err) - } - if err := enc.Close(); err != nil { - t.Fatalf("close zstd writer: %v", err) - } - - return path -} - -func TestWriteSVGFromFileCleansUpPartialOutputOnError(t *testing.T) { - dir := t.TempDir() - iorFile := writeTestIorZst(t, dir) - - n := NewNativeSVG([]string{"invalidField"}, "count") - outFile, err := n.WriteSVGFromFile(iorFile) - if err == nil { - t.Fatal("expected error for invalid field, got nil") - } - - if _, statErr := os.Stat(outFile); !os.IsNotExist(statErr) { - t.Fatalf("expected partial output to be removed, stat err=%v", statErr) - } -} diff --git a/internal/flamegraph/svgwriter.go b/internal/flamegraph/svgwriter.go deleted file mode 100644 index 7fd699e..0000000 --- a/internal/flamegraph/svgwriter.go +++ /dev/null @@ -1,151 +0,0 @@ -package flamegraph - -import ( - "bufio" - "fmt" - "hash" - "hash/fnv" - "io" - "strings" - "sync" -) - -var svgEscaper = strings.NewReplacer( - "&", "&", - "<", "<", - ">", ">", - `"`, """, - "'", "'", -) - -var fnv32aPool = sync.Pool{ - New: func() any { - return fnv.New32a() - }, -} - -// SVGConfig controls the layout and styling of generated flamegraph SVGs. -// -// Width is the virtual canvas width in pixels, FrameHeight is the height of each -// stack frame row, FontSize is the base font size, and MinWidthPx controls the -// minimum rendered width for a frame (smaller frames are skipped to avoid noise). -type SVGConfig struct { - Title string - Width int - FrameHeight int - FontSize int - MinWidthPx float64 -} - -func defaultSVGConfig() SVGConfig { - return SVGConfig{ - Title: "I/O Flame Graph", - Width: 1200, - FrameHeight: 16, - FontSize: 12, - MinWidthPx: 1.0, - } -} - -// DefaultSVGConfig returns the default SVG configuration values. -func DefaultSVGConfig() SVGConfig { - return defaultSVGConfig() -} - -// WriteSVG renders a flamegraph trie into an interactive SVG document. -// -// The output is a self-contained SVG that includes embedded CSS and JavaScript -// for zoom, search, and highlighting, and is designed to be served directly to -// a browser (for example via ServeSVG) without any external assets. -func WriteSVG(w io.Writer, t *trie, cfg SVGConfig) error { - cfg = sanitizeSVGConfig(cfg) - - canvasHeight := canvasHeightFor(cfg, t) - bw := bufio.NewWriter(w) - if err := writeSVGHeader(bw, cfg, canvasHeight); err != nil { - return err - } - for _, frame := range BuildFrameLayout(t, cfg) { - if err := writeFrame(bw, frame.Name, frame.Title, frame.Fill, - frame.X, frame.Y, frame.Width, frame.Height, frame.Depth, cfg.FontSize); err != nil { - return err - } - } - if err := writeSVGFooter(bw); err != nil { - return err - } - return bw.Flush() -} - -func writeSVGHeader(bw *bufio.Writer, cfg SVGConfig, height int) error { - _, err := fmt.Fprintf(bw, `<svg xmlns="http://www.w3.org/2000/svg" width="100%%" height="%d" viewBox="0 0 %d %d" preserveAspectRatio="xMinYMin meet">`+"\n", - height, cfg.Width, height) - if err != nil { - return err - } - _, err = fmt.Fprintf(bw, "<style><![CDATA[%s]]></style>\n", flamegraphCSS(cfg)) - if err != nil { - return err - } - _, err = fmt.Fprintf(bw, "<script><![CDATA[%s]]></script>\n", flamegraphJS) - if err != nil { - return err - } - _, err = fmt.Fprintf(bw, `<text class="title" x="10" y="22">%s</text>`+"\n", svgEscape(cfg.Title)) - if err != nil { - return err - } - _, err = fmt.Fprintf(bw, `<g class="controls"><text x="10" y="42" onclick="fgSearch()">Search</text><text x="80" y="42" onclick="fgResetSearch()">Reset Search</text><text x="190" y="42" onclick="fgUndoZoom()">Undo Zoom</text><text x="280" y="42" onclick="fgResetZoom()">Reset Zoom</text><text id="fg-info" x="390" y="42"></text></g>`+"\n") - return err -} - -func writeSVGFooter(bw *bufio.Writer) error { - _, err := fmt.Fprintln(bw, "</svg>") - return err -} - -func writeFrame(bw *bufio.Writer, name, title, fill string, x, y, w, h float64, depth, fontSize int) error { - textStyle := "" - labelStyle := "" - if w <= float64(fontSize*2) { - labelStyle = ` style="display:none"` - } - if labelStyle != "" { - textStyle = labelStyle - } - _, err := fmt.Fprintf(bw, `<g class="frame" data-name="%s" data-x="%.3f" data-w="%.3f" data-depth="%d" data-base-fill="%s"> -<title>%s</title><rect x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="%s"/> -<text x="%.3f" y="%.3f"%s>%s</text> -</g> -`, - svgEscape(name), x, w, depth, fill, - svgEscape(title), x, y, w, h, fill, - x+3, y+float64(fontSize), textStyle, svgEscape(name)) - return err -} - -func frameColor(name string) string { - hasher := fnv32aPool.Get().(hash.Hash32) - hasher.Reset() - _, _ = io.WriteString(hasher, name) - h := hasher.Sum32() - fnv32aPool.Put(hasher) - r := 200 + int(h%35) - g := 80 + int((h>>8)%120) - b := 40 + int((h>>16)%90) - return fmt.Sprintf("rgb(%d,%d,%d)", r, g, b) -} - -func flamegraphCSS(cfg SVGConfig) string { - return fmt.Sprintf(` -.title { font-size: %dpx; font-family: monospace; } -.controls text { font-size: %dpx; font-family: monospace; cursor: pointer; fill: #444; } -.frame text { font-size: %dpx; font-family: monospace; pointer-events: none; fill: #111; } -.frame rect { stroke: rgba(0,0,0,0.18); stroke-width: 0.5; } -.title, .controls text, .frame text { user-select: none; -webkit-user-select: none; } -`, cfg.FontSize+2, cfg.FontSize, cfg.FontSize-1) -} - -func svgEscape(s string) string { - return svgEscaper.Replace(s) -} diff --git a/internal/flamegraph/svgwriter_js.go b/internal/flamegraph/svgwriter_js.go deleted file mode 100644 index bf8bfd2..0000000 --- a/internal/flamegraph/svgwriter_js.go +++ /dev/null @@ -1,212 +0,0 @@ -package flamegraph - -const flamegraphJS = ` -const fg = { - frames: [], - info: null, - matchColor: "rgb(220, 30, 70)", - zoomStack: [], - zoomRange: null, - rootWidth: 0, -}; - -function fgInit() { - fg.frames = Array.from(document.querySelectorAll("g.frame")); - fg.info = document.getElementById("fg-info"); - fg.rootWidth = fgDetectRootWidth(); - fg.frames.forEach((frame) => { - fgSnapshotOriginalGeometry(frame); - frame.addEventListener("click", (ev) => { - if (ev.detail > 1) return; - ev.stopPropagation(); - fgZoom(ev.currentTarget); - }); - frame.addEventListener("dblclick", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - fgResetZoom(); - }); - frame.addEventListener("mouseenter", (ev) => fgHover(ev.currentTarget)); - }); - document.addEventListener("dblclick", (ev) => { - ev.preventDefault(); - fgResetZoom(); - }); -} - -function fgHover(frame) { - if (!fg.info) return; - const title = frame.querySelector("title"); - fg.info.textContent = title ? title.textContent : ""; -} - -function fgZoom(frame) { - const x = fgOriginalX(frame); - const w = fgOriginalW(frame); - if (w <= 0) return; - if (fg.zoomRange) { - fg.zoomStack.push(fg.zoomRange); - } - fg.zoomRange = { x: x, w: w, depth: Number(frame.dataset.depth || "0") }; - fgApplyZoom(); -} - -function fgApplyZoom() { - if (!fg.zoomRange) { - fg.frames.forEach((frame) => { - frame.style.display = ""; - }); - return; - } - const x = fg.zoomRange.x; - const end = x + fg.zoomRange.w; - const width = fg.zoomRange.w; - const minDepth = fg.zoomRange.depth; - const eps = 1e-6; - const scale = fg.rootWidth / width; - fg.frames.forEach((other) => { - const ox = fgOriginalX(other); - const ow = fgOriginalW(other); - const depth = Number(other.dataset.depth || "0"); - const inSelectedRange = ox >= x-eps && ox+ow <= end+eps; - const isAncestor = depth < minDepth && ox <= x+eps && ox+ow >= end-eps; - - if (isAncestor || (depth >= minDepth && inSelectedRange)) { - if (isAncestor) { - fgSetFrameGeometry(other, 0, fg.rootWidth); - } else { - fgSetFrameGeometry(other, (ox-x)*scale, ow*scale); - } - other.style.display = ""; - } else { - other.style.display = "none"; - } - }); -} - -function fgUndoZoom() { - if (fg.zoomStack.length === 0) { - fgResetZoom(); - return; - } - fg.zoomRange = fg.zoomStack.pop(); - fgApplyZoom(); -} - -function fgResetZoom() { - fg.zoomStack = []; - fg.zoomRange = null; - fg.frames.forEach((frame) => { - fgRestoreFrameGeometry(frame); - frame.style.display = ""; - }); -} - -function fgSearch() { - const needle = prompt("Search frames (substring):", ""); - if (needle === null) return; - const q = needle.trim().toLowerCase(); - fg.frames.forEach((frame) => { - const rect = frame.querySelector("rect"); - const base = frame.dataset.baseFill || ""; - const name = (frame.dataset.name || "").toLowerCase(); - if (!rect) return; - if (q !== "" && name.includes(q)) { - rect.style.fill = fg.matchColor; - } else { - rect.style.fill = base; - } - }); -} - -function fgResetSearch() { - fg.frames.forEach((frame) => { - const rect = frame.querySelector("rect"); - if (!rect) return; - rect.style.fill = frame.dataset.baseFill || ""; - }); -} - -function fgDetectRootWidth() { - let maxEnd = 0; - fg.frames.forEach((frame) => { - const x = Number(frame.dataset.x || "0"); - const w = Number(frame.dataset.w || "0"); - maxEnd = Math.max(maxEnd, x + w); - }); - return maxEnd; -} - -function fgSnapshotOriginalGeometry(frame) { - const rect = frame.querySelector("rect"); - const text = frame.querySelector("text"); - frame.dataset.ox = frame.dataset.x || "0"; - frame.dataset.ow = frame.dataset.w || "0"; - if (rect) { - rect.dataset.ox = rect.getAttribute("x") || "0"; - rect.dataset.ow = rect.getAttribute("width") || "0"; - } - if (text) { - text.dataset.ox = text.getAttribute("x") || "0"; - text.dataset.hidden = text.style.display === "none" ? "1" : "0"; - text.dataset.full = text.textContent || frame.dataset.name || ""; - } -} - -function fgOriginalX(frame) { - return Number(frame.dataset.ox || frame.dataset.x || "0"); -} - -function fgOriginalW(frame) { - return Number(frame.dataset.ow || frame.dataset.w || "0"); -} - -function fgSetFrameGeometry(frame, x, w) { - const rect = frame.querySelector("rect"); - const text = frame.querySelector("text"); - if (rect) { - rect.setAttribute("x", String(x)); - rect.setAttribute("width", String(w)); - } - if (text) { - text.setAttribute("x", String(x + 3)); - fgFitLabel(text, w); - } -} - -function fgRestoreFrameGeometry(frame) { - const rect = frame.querySelector("rect"); - const text = frame.querySelector("text"); - if (rect) { - rect.setAttribute("x", rect.dataset.ox || "0"); - rect.setAttribute("width", rect.dataset.ow || "0"); - } - if (text) { - text.setAttribute("x", text.dataset.ox || "0"); - if (text.dataset.hidden === "1") { - text.style.display = "none"; - text.textContent = text.dataset.full || ""; - } else { - fgFitLabel(text, Number(rect ? (rect.dataset.ow || "0") : "0")); - } - } -} - -function fgFitLabel(text, width) { - const full = text.dataset.full || text.textContent || ""; - const maxChars = Math.floor((width - 6) / 7); - if (maxChars < 3) { - text.style.display = "none"; - text.textContent = full; - return; - } - text.style.display = ""; - if (full.length <= maxChars) { - text.textContent = full; - return; - } - text.textContent = full.slice(0, maxChars - 1) + "…"; -} - -window.addEventListener("DOMContentLoaded", fgInit); -` diff --git a/internal/flamegraph/svgwriter_jscode.go b/internal/flamegraph/svgwriter_jscode.go deleted file mode 100644 index 3ac00fd..0000000 --- a/internal/flamegraph/svgwriter_jscode.go +++ /dev/null @@ -1,214 +0,0 @@ -//go:build !js - -package flamegraph - -const flamegraphJS = ` -const fg = { - frames: [], - info: null, - matchColor: "rgb(220, 30, 70)", - zoomStack: [], - zoomRange: null, - rootWidth: 0, -}; - -function fgInit() { - fg.frames = Array.from(document.querySelectorAll("g.frame")); - fg.info = document.getElementById("fg-info"); - fg.rootWidth = fgDetectRootWidth(); - fg.frames.forEach((frame) => { - fgSnapshotOriginalGeometry(frame); - frame.addEventListener("click", (ev) => { - if (ev.detail > 1) return; - ev.stopPropagation(); - fgZoom(ev.currentTarget); - }); - frame.addEventListener("dblclick", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - fgResetZoom(); - }); - frame.addEventListener("mouseenter", (ev) => fgHover(ev.currentTarget)); - }); - document.addEventListener("dblclick", (ev) => { - ev.preventDefault(); - fgResetZoom(); - }); -} - -function fgHover(frame) { - if (!fg.info) return; - const title = frame.querySelector("title"); - fg.info.textContent = title ? title.textContent : ""; -} - -function fgZoom(frame) { - const x = fgOriginalX(frame); - const w = fgOriginalW(frame); - if (w <= 0) return; - if (fg.zoomRange) { - fg.zoomStack.push(fg.zoomRange); - } - fg.zoomRange = { x: x, w: w, depth: Number(frame.dataset.depth || "0") }; - fgApplyZoom(); -} - -function fgApplyZoom() { - if (!fg.zoomRange) { - fg.frames.forEach((frame) => { - frame.style.display = ""; - }); - return; - } - const x = fg.zoomRange.x; - const end = x + fg.zoomRange.w; - const width = fg.zoomRange.w; - const minDepth = fg.zoomRange.depth; - const eps = 1e-6; - const scale = fg.rootWidth / width; - fg.frames.forEach((other) => { - const ox = fgOriginalX(other); - const ow = fgOriginalW(other); - const depth = Number(other.dataset.depth || "0"); - const inSelectedRange = ox >= x-eps && ox+ow <= end+eps; - const isAncestor = depth < minDepth && ox <= x+eps && ox+ow >= end-eps; - - if (isAncestor || (depth >= minDepth && inSelectedRange)) { - if (isAncestor) { - fgSetFrameGeometry(other, 0, fg.rootWidth); - } else { - fgSetFrameGeometry(other, (ox-x)*scale, ow*scale); - } - other.style.display = ""; - } else { - other.style.display = "none"; - } - }); -} - -function fgUndoZoom() { - if (fg.zoomStack.length === 0) { - fgResetZoom(); - return; - } - fg.zoomRange = fg.zoomStack.pop(); - fgApplyZoom(); -} - -function fgResetZoom() { - fg.zoomStack = []; - fg.zoomRange = null; - fg.frames.forEach((frame) => { - fgRestoreFrameGeometry(frame); - frame.style.display = ""; - }); -} - -function fgSearch() { - const needle = prompt("Search frames (substring):", ""); - if (needle === null) return; - const q = needle.trim().toLowerCase(); - fg.frames.forEach((frame) => { - const rect = frame.querySelector("rect"); - const base = frame.dataset.baseFill || ""; - const name = (frame.dataset.name || "").toLowerCase(); - if (!rect) return; - if (q !== "" && name.includes(q)) { - rect.style.fill = fg.matchColor; - } else { - rect.style.fill = base; - } - }); -} - -function fgResetSearch() { - fg.frames.forEach((frame) => { - const rect = frame.querySelector("rect"); - if (!rect) return; - rect.style.fill = frame.dataset.baseFill || ""; - }); -} - -function fgDetectRootWidth() { - let maxEnd = 0; - fg.frames.forEach((frame) => { - const x = Number(frame.dataset.x || "0"); - const w = Number(frame.dataset.w || "0"); - maxEnd = Math.max(maxEnd, x + w); - }); - return maxEnd; -} - -function fgSnapshotOriginalGeometry(frame) { - const rect = frame.querySelector("rect"); - const text = frame.querySelector("text"); - frame.dataset.ox = frame.dataset.x || "0"; - frame.dataset.ow = frame.dataset.w || "0"; - if (rect) { - rect.dataset.ox = rect.getAttribute("x") || "0"; - rect.dataset.ow = rect.getAttribute("width") || "0"; - } - if (text) { - text.dataset.ox = text.getAttribute("x") || "0"; - text.dataset.hidden = text.style.display === "none" ? "1" : "0"; - text.dataset.full = text.textContent || frame.dataset.name || ""; - } -} - -function fgOriginalX(frame) { - return Number(frame.dataset.ox || frame.dataset.x || "0"); -} - -function fgOriginalW(frame) { - return Number(frame.dataset.ow || frame.dataset.w || "0"); -} - -function fgSetFrameGeometry(frame, x, w) { - const rect = frame.querySelector("rect"); - const text = frame.querySelector("text"); - if (rect) { - rect.setAttribute("x", String(x)); - rect.setAttribute("width", String(w)); - } - if (text) { - text.setAttribute("x", String(x + 3)); - fgFitLabel(text, w); - } -} - -function fgRestoreFrameGeometry(frame) { - const rect = frame.querySelector("rect"); - const text = frame.querySelector("text"); - if (rect) { - rect.setAttribute("x", rect.dataset.ox || "0"); - rect.setAttribute("width", rect.dataset.ow || "0"); - } - if (text) { - text.setAttribute("x", text.dataset.ox || "0"); - if (text.dataset.hidden === "1") { - text.style.display = "none"; - text.textContent = text.dataset.full || ""; - } else { - fgFitLabel(text, Number(rect ? (rect.dataset.ow || "0") : "0")); - } - } -} - -function fgFitLabel(text, width) { - const full = text.dataset.full || text.textContent || ""; - const maxChars = Math.floor((width - 6) / 7); - if (maxChars < 3) { - text.style.display = "none"; - text.textContent = full; - return; - } - text.style.display = ""; - if (full.length <= maxChars) { - text.textContent = full; - return; - } - text.textContent = full.slice(0, maxChars - 1) + "…"; -} - -window.addEventListener("DOMContentLoaded", fgInit); -` diff --git a/internal/flamegraph/svgwriter_test.go b/internal/flamegraph/svgwriter_test.go deleted file mode 100644 index 56f2c20..0000000 --- a/internal/flamegraph/svgwriter_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package flamegraph - -import ( - "bytes" - "strings" - "testing" -) - -func renderSVGForTest(t *testing.T, tr *trie, cfg SVGConfig) string { - t.Helper() - var buf bytes.Buffer - if err := WriteSVG(&buf, tr, cfg); err != nil { - t.Fatalf("WriteSVG failed: %v", err) - } - return buf.String() -} - -func TestWriteSVGBasic(t *testing.T) { - tr := newTrie() - tr.add([]string{"a", "b"}, 3) - tr.add([]string{"a", "c"}, 2) - tr.computeTotals() - - svg := renderSVGForTest(t, tr, defaultSVGConfig()) - if !strings.Contains(svg, "<svg") || !strings.Contains(svg, "</svg>") { - t.Fatalf("expected valid svg wrapper, got: %s", svg) - } - if !strings.Contains(svg, "data-name=\"a\"") || !strings.Contains(svg, "data-name=\"b\"") { - t.Fatalf("expected rendered frame names, got: %s", svg) - } -} - -func TestWriteSVGEmptyTrie(t *testing.T) { - tr := newTrie() - tr.computeTotals() - - svg := renderSVGForTest(t, tr, defaultSVGConfig()) - if !strings.Contains(svg, "<svg") || !strings.Contains(svg, "</svg>") { - t.Fatalf("expected valid svg wrapper, got: %s", svg) - } - if strings.Contains(svg, "class=\"frame\"") { - t.Fatalf("expected no rendered frames for empty trie, got: %s", svg) - } -} - -func TestWriteSVGMinWidth(t *testing.T) { - tr := newTrie() - tr.add([]string{"wide"}, 100) - tr.add([]string{"tiny"}, 1) - tr.computeTotals() - - cfg := defaultSVGConfig() - cfg.Width = 120 - cfg.MinWidthPx = 2.0 - svg := renderSVGForTest(t, tr, cfg) - - if !strings.Contains(svg, "data-name=\"wide\"") { - t.Fatalf("expected wide frame to be rendered, got: %s", svg) - } - if strings.Contains(svg, "data-name=\"tiny\"") { - t.Fatalf("expected tiny frame to be skipped by min width, got: %s", svg) - } -} - -func TestWriteSVGTitle(t *testing.T) { - tr := newTrie() - tr.add([]string{"a"}, 1) - tr.computeTotals() - - cfg := defaultSVGConfig() - cfg.Title = "Custom Flamegraph" - svg := renderSVGForTest(t, tr, cfg) - - if !strings.Contains(svg, "Custom Flamegraph") { - t.Fatalf("expected custom title in output, got: %s", svg) - } -} - -func TestFrameColor(t *testing.T) { - colorA1 := frameColor("read") - colorA2 := frameColor("read") - colorB := frameColor("write") - - if colorA1 != colorA2 { - t.Fatalf("expected deterministic color for identical names, got %q vs %q", colorA1, colorA2) - } - if !strings.HasPrefix(colorA1, "rgb(") || !strings.HasSuffix(colorA1, ")") { - t.Fatalf("expected rgb() format, got %q", colorA1) - } - if colorA1 == colorB { - t.Fatalf("expected different colors for different names, got %q", colorA1) - } -} - -func TestWriteSVGInvalidConfigFallsBack(t *testing.T) { - tr := newTrie() - tr.add([]string{"a"}, 1) - tr.computeTotals() - - cfg := SVGConfig{Title: "x", Width: 0, FrameHeight: 0, FontSize: 0, MinWidthPx: 0} - svg := renderSVGForTest(t, tr, cfg) - - if !strings.Contains(svg, `width="100%"`) { - t.Fatalf("expected responsive svg width, got: %s", svg) - } - if !strings.Contains(svg, `viewBox="0 0 1200 `) { - t.Fatalf("expected fallback viewBox width, got: %s", svg) - } - if !strings.Contains(svg, "I/O Flame Graph") { - t.Fatalf("expected fallback title, got: %s", svg) - } -} diff --git a/internal/flamegraph/webserver.go b/internal/flamegraph/webserver.go deleted file mode 100644 index c472dfb..0000000 --- a/internal/flamegraph/webserver.go +++ /dev/null @@ -1,199 +0,0 @@ -package flamegraph - -import ( - "context" - "fmt" - "net" - "net/http" - "os" - "os/signal" - "path/filepath" - "strings" - "syscall" - "time" -) - -type serverTimeouts struct { - readTimeout time.Duration - writeTimeout time.Duration - idleTimeout time.Duration -} - -var defaultServerTimeouts = serverTimeouts{ - readTimeout: 10 * time.Second, - writeTimeout: 30 * time.Second, - idleTimeout: 60 * time.Second, -} - -// ServeSVG starts a small HTTP server that serves a single flamegraph SVG. -// -// It prints a URL of the form http://HOSTNAME:PORT/abs/path/to.svg and blocks until -// the user presses Ctrl+C or the process receives SIGTERM, at which point the server -// is shut down gracefully. -func ServeSVG(svgFile string) error { - absPath, err := filepath.Abs(svgFile) - if err != nil { - return fmt.Errorf("resolve svg path: %w", err) - } - urlPath := buildURLPath(absPath) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - return runServer(ctx, buildSVGHandler(absPath, urlPath), defaultServerTimeouts, func(hostname string, port int) { - printServerURL(hostname, port, urlPath) - }) -} - -// ServeSVGAutoReload serves an SVG viewer page that periodically reloads the SVG. -// -// The SVG file itself is still served directly at its absolute URL path, while "/" -// serves a small HTML wrapper that appends a cache-busting query parameter on each -// refresh interval to pick up newly written SVG content. -func ServeSVGAutoReload(svgFile string, refreshInterval time.Duration) error { - if refreshInterval <= 0 { - return fmt.Errorf("refresh interval must be > 0") - } - - absPath, err := filepath.Abs(svgFile) - if err != nil { - return fmt.Errorf("resolve svg path: %w", err) - } - urlPath := buildURLPath(absPath) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - mux := buildSVGAutoReloadHandler(absPath, urlPath, refreshInterval) - return runServer(ctx, mux, defaultServerTimeouts, func(hostname string, port int) { - printServerURL(hostname, port, "/") - }) -} - -func buildURLPath(absPath string) string { - urlPath := filepath.ToSlash(absPath) - if !strings.HasPrefix(urlPath, "/") { - return "/" + urlPath - } - return urlPath -} - -func buildSVGHandler(absPath, urlPath string) *http.ServeMux { - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, urlPath, http.StatusFound) - }) - mux.HandleFunc(urlPath, func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, absPath) - }) - return mux -} - -func buildSVGAutoReloadHandler(absPath, urlPath string, refreshInterval time.Duration) *http.ServeMux { - intervalMs := refreshInterval.Milliseconds() - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = fmt.Fprintf(w, `<!doctype html> -<html> -<head> - <meta charset="utf-8"/> - <meta name="viewport" content="width=device-width, initial-scale=1"/> - <title>I/O Flamegraph (Auto-Reload)</title> - <style> - body { margin: 0; font-family: monospace; } - .bar { padding: 8px 12px; border-bottom: 1px solid #ddd; } - .viewer { width: 100%%; height: calc(100vh - 42px); border: 0; display: block; } - </style> -</head> -<body> - <div class="bar"> - Auto-refresh every %d ms. - <button type="button" onclick="refreshNow()">Refresh now</button> - </div> - <iframe id="fg" class="viewer" src="%s"></iframe> - <script> - const base = %q; - function refreshNow() { - document.getElementById("fg").src = base + "?t=" + Date.now(); - } - setInterval(refreshNow, %d); - </script> -</body> -</html> -`, intervalMs, urlPath, urlPath, intervalMs) - }) - mux.HandleFunc(urlPath, func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, absPath) - }) - return mux -} - -func listenRandomPort() (net.Listener, error) { - listener, err := net.Listen("tcp", ":0") - if err != nil { - return nil, fmt.Errorf("start web server: %w", err) - } - return listener, nil -} - -func serverHostPort(listener net.Listener) (string, int) { - hostname, err := os.Hostname() - if err != nil { - hostname = "localhost" - } - port := listener.Addr().(*net.TCPAddr).Port - return hostname, port -} - -func printServerURL(hostname string, port int, urlPath string) { - fmt.Printf("Flamegraph available at http://%s:%d%s\n", hostname, port, urlPath) - fmt.Println("Press Ctrl+C to stop the web server.") -} - -func newHTTPServer(mux *http.ServeMux, timeouts serverTimeouts) *http.Server { - return &http.Server{ - Handler: mux, - ReadTimeout: timeouts.readTimeout, - WriteTimeout: timeouts.writeTimeout, - IdleTimeout: timeouts.idleTimeout, - } -} - -func runServer(ctx context.Context, mux *http.ServeMux, timeouts serverTimeouts, printURL func(hostname string, port int)) error { - srv := newHTTPServer(mux, timeouts) - - listener, err := listenRandomPort() - if err != nil { - return err - } - defer listener.Close() - - hostname, port := serverHostPort(listener) - if printURL != nil { - printURL(hostname, port) - } - - errCh := make(chan error, 1) - go func() { - errCh <- srv.Serve(listener) - }() - - select { - case <-ctx.Done(): - case serveErr := <-errCh: - if serveErr != nil && serveErr != http.ErrServerClosed { - return serveErr - } - return nil - } - - shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - if err := srv.Shutdown(shutdownCtx); err != nil { - return fmt.Errorf("shutdown web server: %w", err) - } - - serveErr := <-errCh - if serveErr != nil && serveErr != http.ErrServerClosed { - return serveErr - } - return nil -} diff --git a/internal/flamegraph/webserver_autoreload_test.go b/internal/flamegraph/webserver_autoreload_test.go deleted file mode 100644 index ed4c907..0000000 --- a/internal/flamegraph/webserver_autoreload_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package flamegraph - -import ( - "net/http/httptest" - "strings" - "testing" - "time" -) - -func TestBuildSVGAutoReloadHandlerServesViewerPage(t *testing.T) { - mux := buildSVGAutoReloadHandler("/tmp/fake.svg", "/tmp/fake.svg", 2*time.Second) - - rec := httptest.NewRecorder() - req := httptest.NewRequest("GET", "http://localhost/", nil) - mux.ServeHTTP(rec, req) - - if rec.Code != 200 { - t.Fatalf("status code = %d, want 200", rec.Code) - } - if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") { - t.Fatalf("content type = %q, want text/html", got) - } - body := rec.Body.String() - if !strings.Contains(body, "Auto-refresh every 2000 ms.") { - t.Fatalf("viewer page missing refresh interval, body=%q", body) - } - if !strings.Contains(body, `id="fg"`) { - t.Fatalf("viewer page missing iframe, body=%q", body) - } -} - -func TestServeSVGAutoReloadRejectsNonPositiveInterval(t *testing.T) { - err := ServeSVGAutoReload("ignored.svg", 0) - if err == nil { - t.Fatal("expected error for non-positive interval") - } -} diff --git a/internal/flamegraph/webserver_timeout_test.go b/internal/flamegraph/webserver_timeout_test.go deleted file mode 100644 index c1df7e5..0000000 --- a/internal/flamegraph/webserver_timeout_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package flamegraph - -import ( - "net/http" - "testing" - "time" -) - -func TestNewHTTPServerUsesConfiguredTimeouts(t *testing.T) { - mux := http.NewServeMux() - timeouts := serverTimeouts{ - readTimeout: 11 * time.Second, - writeTimeout: 44 * time.Second, - idleTimeout: 66 * time.Second, - } - - srv := newHTTPServer(mux, timeouts) - - if srv.Handler != mux { - t.Fatalf("Handler not set from mux") - } - if srv.ReadTimeout != timeouts.readTimeout { - t.Fatalf("ReadTimeout = %v, want %v", srv.ReadTimeout, timeouts.readTimeout) - } - if srv.WriteTimeout != timeouts.writeTimeout { - t.Fatalf("WriteTimeout = %v, want %v", srv.WriteTimeout, timeouts.writeTimeout) - } - if srv.IdleTimeout != timeouts.idleTimeout { - t.Fatalf("IdleTimeout = %v, want %v", srv.IdleTimeout, timeouts.idleTimeout) - } -} - -func TestLiveServerWriteTimeoutIsLongerThanDefault(t *testing.T) { - if liveServerTimeouts.readTimeout != defaultServerTimeouts.readTimeout { - t.Fatalf("read timeout mismatch: live=%v default=%v", liveServerTimeouts.readTimeout, defaultServerTimeouts.readTimeout) - } - if liveServerTimeouts.idleTimeout != defaultServerTimeouts.idleTimeout { - t.Fatalf("idle timeout mismatch: live=%v default=%v", liveServerTimeouts.idleTimeout, defaultServerTimeouts.idleTimeout) - } - if liveServerTimeouts.writeTimeout <= defaultServerTimeouts.writeTimeout { - t.Fatalf("expected live write timeout > default write timeout, got live=%v default=%v", liveServerTimeouts.writeTimeout, defaultServerTimeouts.writeTimeout) - } -} diff --git a/internal/flamegraph/worker.go b/internal/flamegraph/worker.go deleted file mode 100644 index 1e8c639..0000000 --- a/internal/flamegraph/worker.go +++ /dev/null @@ -1,34 +0,0 @@ -package flamegraph - -import ( - "context" - "sync" - - "ior/internal/event" -) - -type worker struct { - iod iorData - done chan struct{} -} - -func newWorker() worker { - return worker{iod: newIorData()} -} - -func (w worker) run(ctx context.Context, wg *sync.WaitGroup, ch <-chan *event.Pair) { - defer wg.Done() - - for { - select { - case ev, ok := <-ch: - if !ok { - return - } - w.iod.addEventPair(ev) - ev.Recycle() - case <-ctx.Done(): - return - } - } -} |
