summaryrefslogtreecommitdiff
path: root/internal/flamegraph
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-06 15:35:24 +0200
committerPaul Buetow <paul@buetow.org>2026-03-06 15:35:24 +0200
commit99b02bf8c389a793df5d5986db05eed7e459f7b1 (patch)
treebc4e36cfcd3c9ef9b067beed2eb5b68a75a45aa2 /internal/flamegraph
parent4ff17c30120d657b966f8a55188ba167dc875e64 (diff)
refactor: remove web flamegrapher and keep TUI-only
Diffstat (limited to 'internal/flamegraph')
-rw-r--r--internal/flamegraph/doc.go2
-rw-r--r--internal/flamegraph/iordatacollector.go65
-rw-r--r--internal/flamegraph/layout.go78
-rw-r--r--internal/flamegraph/layout_test.go77
-rw-r--r--internal/flamegraph/livehtml.go842
-rw-r--r--internal/flamegraph/livehtml_browser_test.go314
-rw-r--r--internal/flamegraph/livehtml_interaction_test.go615
-rw-r--r--internal/flamegraph/liveserver.go314
-rw-r--r--internal/flamegraph/liveserver_open_test.go179
-rw-r--r--internal/flamegraph/liveserver_test.go380
-rw-r--r--internal/flamegraph/nativejson.go86
-rw-r--r--internal/flamegraph/nativejson_test.go75
-rw-r--r--internal/flamegraph/nativesvg.go97
-rw-r--r--internal/flamegraph/nativesvg_test.go60
-rw-r--r--internal/flamegraph/svgwriter.go151
-rw-r--r--internal/flamegraph/svgwriter_js.go212
-rw-r--r--internal/flamegraph/svgwriter_jscode.go214
-rw-r--r--internal/flamegraph/svgwriter_test.go112
-rw-r--r--internal/flamegraph/webserver.go199
-rw-r--r--internal/flamegraph/webserver_autoreload_test.go37
-rw-r--r--internal/flamegraph/webserver_timeout_test.go43
-rw-r--r--internal/flamegraph/worker.go34
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, '&amp;')
- .replace(/</g, '&lt;')
- .replace(/>/g, '&gt;')
- .replace(/"/g, '&quot;')
- .replace(/'/g, '&apos;');
- }
-
- 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(
- "&", "&amp;",
- "<", "&lt;",
- ">", "&gt;",
- `"`, "&quot;",
- "'", "&apos;",
-)
-
-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
- }
- }
-}