Live metrics (--profile): - New metricsTracker instruments OnPTYOut, viewport renderer, stdout writes, libghostty-vt Write/Title CGO calls, sidebar / tabbar / status draws (with cache-hit accounting), snapshot replays, and the chrome ticker (so we can see ticker fires that did nothing). - Writes metrics.jsonl (one snapshot per second) and metrics.json + summary.txt on exit, alongside the existing pprof files. - All record* methods are nil-safe so disabled paths pay only a cheap nil check; counters are atomic so the per-PTY-chunk hot path stays lock-free. Benchmark suite (go test -bench=.): - Three workload fixtures — plain ASCII, SGR-styled lines, and a ratatui-style cursor-shuffling burst — plus a containsOSC microbenchmark. Reports ns/op, MB/s, allocs/op, B/op. - Initial baseline numbers added to TODO under the perf-audit section, alongside two new findings (renderer allocs ~1 per 4 bytes on styled chunks; styled throughput tops out near 90 MB/s) those benchmarks surfaced.
117 lines
3.3 KiB
Go
117 lines
3.3 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestMetricsTrackerDisabledByEmptyDir(t *testing.T) {
|
|
m, err := newMetricsTracker("")
|
|
if err != nil {
|
|
t.Fatalf("newMetricsTracker(\"\") err: %v", err)
|
|
}
|
|
if m != nil {
|
|
t.Fatalf("expected nil tracker for empty dir, got %v", m)
|
|
}
|
|
}
|
|
|
|
func TestMetricsTrackerRecordsAndWrites(t *testing.T) {
|
|
dir := t.TempDir()
|
|
m, err := newMetricsTracker(dir)
|
|
if err != nil {
|
|
t.Fatalf("newMetricsTracker: %v", err)
|
|
}
|
|
if m == nil {
|
|
t.Fatal("expected non-nil tracker")
|
|
}
|
|
|
|
m.recordPTYOut(2*time.Millisecond, 1024)
|
|
m.recordPTYOut(5*time.Millisecond, 4096)
|
|
m.recordRender(800 * time.Microsecond)
|
|
m.recordStdout(300*time.Microsecond, 1100)
|
|
m.recordEmuWrite(150 * time.Microsecond)
|
|
m.recordEmuTitle(0, true)
|
|
m.recordEmuTitle(20*time.Microsecond, false)
|
|
m.recordSidebar(100*time.Microsecond, true)
|
|
m.recordSidebar(900*time.Microsecond, false)
|
|
m.recordTabbar(50*time.Microsecond, true)
|
|
m.recordStatus(40*time.Microsecond, true)
|
|
m.recordSnapshot(2 * time.Millisecond)
|
|
m.recordTickerFire(false)
|
|
m.recordTickerFire(true)
|
|
m.recordPTYOutDrop()
|
|
|
|
m.close()
|
|
|
|
// metrics.json should exist and parse, and reflect what we recorded.
|
|
raw, err := os.ReadFile(filepath.Join(dir, "metrics.json"))
|
|
if err != nil {
|
|
t.Fatalf("read metrics.json: %v", err)
|
|
}
|
|
var snap metricsSnapshot
|
|
if err := json.Unmarshal(raw, &snap); err != nil {
|
|
t.Fatalf("parse metrics.json: %v", err)
|
|
}
|
|
if snap.PTYChunks != 2 {
|
|
t.Errorf("PTYChunks = %d, want 2", snap.PTYChunks)
|
|
}
|
|
if snap.PTYBytes != 5120 {
|
|
t.Errorf("PTYBytes = %d, want 5120", snap.PTYBytes)
|
|
}
|
|
if snap.OnPTYOutMaxNs != (5 * time.Millisecond).Nanoseconds() {
|
|
t.Errorf("OnPTYOutMaxNs = %d, want %d",
|
|
snap.OnPTYOutMaxNs, (5 * time.Millisecond).Nanoseconds())
|
|
}
|
|
if snap.SidebarDraws != 2 {
|
|
t.Errorf("SidebarDraws = %d, want 2", snap.SidebarDraws)
|
|
}
|
|
if snap.SidebarCacheHits != 1 {
|
|
t.Errorf("SidebarCacheHits = %d, want 1", snap.SidebarCacheHits)
|
|
}
|
|
if snap.SidebarCacheHitRate != 0.5 {
|
|
t.Errorf("SidebarCacheHitRate = %v, want 0.5", snap.SidebarCacheHitRate)
|
|
}
|
|
if snap.EmuTitleCalls != 1 || snap.EmuTitleSkips != 1 {
|
|
t.Errorf("emu title accounting: calls=%d skips=%d, want 1/1",
|
|
snap.EmuTitleCalls, snap.EmuTitleSkips)
|
|
}
|
|
if snap.TickerFires != 2 || snap.TickerIdleFires != 1 {
|
|
t.Errorf("ticker accounting: fires=%d idle=%d, want 2/1",
|
|
snap.TickerFires, snap.TickerIdleFires)
|
|
}
|
|
if snap.OnPTYOutDrops != 1 {
|
|
t.Errorf("OnPTYOutDrops = %d, want 1", snap.OnPTYOutDrops)
|
|
}
|
|
|
|
// summary.txt should also be present and non-empty.
|
|
info, err := os.Stat(filepath.Join(dir, "summary.txt"))
|
|
if err != nil {
|
|
t.Fatalf("stat summary.txt: %v", err)
|
|
}
|
|
if info.Size() == 0 {
|
|
t.Fatal("summary.txt is empty")
|
|
}
|
|
}
|
|
|
|
func TestMetricsTrackerNilSafe(t *testing.T) {
|
|
// Every record* method must be safe to call on a nil receiver
|
|
// because the hot paths use that to avoid an enabled-check.
|
|
var m *metricsTracker
|
|
m.recordPTYOut(time.Millisecond, 100)
|
|
m.recordPTYOutDrop()
|
|
m.recordRender(time.Microsecond)
|
|
m.recordStdout(time.Microsecond, 50)
|
|
m.recordEmuWrite(time.Microsecond)
|
|
m.recordEmuTitle(time.Microsecond, false)
|
|
m.recordEmuTitle(0, true)
|
|
m.recordSidebar(time.Microsecond, true)
|
|
m.recordTabbar(time.Microsecond, false)
|
|
m.recordStatus(time.Microsecond, true)
|
|
m.recordSnapshot(time.Microsecond)
|
|
m.recordTickerFire(true)
|
|
m.close()
|
|
}
|