v0.3.0 99dc266

performance_benchmark_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"io"
8	"sync"
9	"sync/atomic"
10	"testing"
11	"time"
12
13	"github.com/stretchr/testify/require"
14
15	renderer "github.com/bnema/vev-vt"
16	"github.com/bnema/vev/internal/domain"
17	"github.com/bnema/vev/internal/ports"
18	"github.com/bnema/vev/internal/usecase/layout"
19	snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
20)
21
22func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {
23	repository := &countingSnapshotRepository{}
24	payload := []byte{0xff, 0x00, 0xfe}
25	object := []byte{0x01, 0x02}
26
27	require.NoError(t, repository.Publish(context.Background(), ports.SnapshotPublication{Name: "session", Manifest: payload, Objects: []ports.SnapshotObject{{Data: object}}}))
28	require.Equal(t, countingSnapshotMetrics{writes: 1, manifestBytes: uint64(len(payload)), objectBytes: uint64(len(object)), suppliedObjectBytes: uint64(len(object)), headBytes: snapshotHeadBytes}, repository.metrics())
29	require.Equal(t, payload, repository.lastPayload())
30
31	payload[0] = 0x01
32	require.Equal(t, payload, repository.lastPayload(), "the counting sink must retain the payload slice header, not copy it")
33}
34
35func TestCountingSnapshotRepositoryDoesNotRewriteKnownObjects(t *testing.T) {
36	repository := &countingSnapshotRepository{}
37	object, err := snapcodec.MarshalObject(snapcodec.HistoryChunk, []byte{0x01})
38	require.NoError(t, err)
39	publication := ports.SnapshotPublication{Name: "session", Manifest: []byte("manifest"), Objects: []ports.SnapshotObject{object}}
40
41	require.NoError(t, repository.Publish(context.Background(), publication))
42	repository.reset()
43	require.NoError(t, repository.Publish(context.Background(), publication))
44
45	require.Zero(t, repository.metrics().objectBytes, "filesystem repositories retain immutable content-addressed objects")
46}
47
48func TestIncrementalSnapshotMetricsWriteNoUnchangedHistoryBlobsAndBoundCache(t *testing.T) {
49	fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})
50
51	publishBenchmarkSnapshot(t, fixture, 1)
52	fixture.snapshots.reset()
53	publishBenchmarkSnapshot(t, fixture, 2)
54
55	metrics := fixture.snapshots.metrics()
56	require.Zero(t, metrics.historyBlobBytes)
57	require.Zero(t, metrics.suppliedHistoryBytes)
58	require.Zero(t, metrics.objectBytes)
59	require.Positive(t, metrics.manifestBytes)
60	require.Equal(t, uint64(snapshotHeadBytes), metrics.headBytes)
61	require.LessOrEqual(t, benchmarkSnapshotCacheBytes(fixture.sess), snapshotChunkCacheLimit)
62}
63
64func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) {
65	fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})
66
67	// This contract concerns capture and publication bytes, not queue latency.
68	// Publish synchronously so the 10k-row setup cannot race the test deadline.
69	publishBenchmarkSnapshot(t, fixture, 1)
70
71	var copies atomic.Uint64
72	fixture.sess.snapshotMu.Lock()
73	fixture.sess.snapshotChunkCache.copyObject = func(object ports.SnapshotObject) ports.SnapshotObject {
74		copies.Add(1)
75		return copySnapshotObject(object)
76	}
77	fixture.sess.snapshotMu.Unlock()
78	fixture.snapshots.reset()
79	fixture.activePane.mu.Lock()
80	fixture.activePane.screen.Write([]byte("\x1b[1;1Hchanged"))
81	frame := fixture.activePane.screen.Frame.Clone()
82	fixture.activePane.mu.Unlock()
83	for i, r := range "changed" {
84		require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i)
85	}
86	publishBenchmarkSnapshot(t, fixture, 2)
87
88	metrics := fixture.snapshots.metrics()
89	require.Equal(t, uint64(1), metrics.writes)
90	require.Zero(t, metrics.suppliedHistoryBytes, "retained sealed history must not be resupplied")
91	require.Zero(t, copies.Load(), "retained sealed history must not be deep-copied")
92	require.Positive(t, metrics.suppliedObjectBytes, "changed tail and recovery transcript must still be supplied")
93	requireCompleteSnapshotManifest(t, fixture.snapshots, fixture.sess.name)
94}
95
96func requireCompleteSnapshotManifest(t *testing.T, repository *countingSnapshotRepository, name string) {
97	t.Helper()
98	repository.mu.Lock()
99	manifestBytes := append([]byte(nil), repository.last...)
100	objects := repository.objects[name]
101	repository.mu.Unlock()
102	manifest, err := snapcodec.UnmarshalManifest(manifestBytes)
103	require.NoError(t, err)
104	for _, tab := range manifest.Tabs {
105		for _, pane := range tab.Panes {
106			for _, ref := range append(append([]snapcodec.ObjectRef(nil), pane.Sealed...), pane.Tail, pane.Transcript) {
107				_, ok := objects[ref.Digest]
108				require.True(t, ok, "manifest reference %x is not retained", ref.Digest)
109			}
110		}
111	}
112}
113
114func TestIncrementalSnapshotMetricScenarios(t *testing.T) {
115	for _, tt := range []struct {
116		name        string
117		historyRows int
118		mutate      func(*performanceFixture, int)
119		wantHistory bool
120	}{
121		{name: "tail-only", historyRows: 9_999, mutate: mutateBenchmarkTail},
122		{name: "new-sealed-chunk", historyRows: 10_000, mutate: mutateBenchmarkSealedChunk, wantHistory: true},
123	} {
124		t.Run(tt.name, func(t *testing.T) {
125			fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: tt.historyRows})
126			publishBenchmarkSnapshot(t, fixture, 1)
127			fixture.snapshots.reset()
128			tt.mutate(fixture, 0)
129			publishBenchmarkSnapshot(t, fixture, 2)
130
131			metrics := fixture.snapshots.metrics()
132			if tt.wantHistory {
133				require.Positive(t, metrics.historyBlobBytes)
134			} else {
135				require.Zero(t, metrics.historyBlobBytes)
136			}
137			require.LessOrEqual(t, benchmarkSnapshotCacheBytes(fixture.sess), snapshotChunkCacheLimit)
138		})
139	}
140}
141
142func TestPerformanceFixtureCellLimitedHistory(t *testing.T) {
143	fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 293, Rows: 40}, panes: 1, historyRows: 10_000})
144	fixture.activePane.mu.Lock()
145	rows, cells, capCells := fixture.activePane.history.Len(), fixture.activePane.history.Cells(), fixture.activePane.history.CellCap()
146	fixture.activePane.mu.Unlock()
147	require.Less(t, rows, 10_000)
148	require.LessOrEqual(t, cells, capCells)
149}
150
151func TestPerformanceFixtureExplicitCloseReleasesIterationState(t *testing.T) {
152	fixture := newPerformanceFixtureWithCleanup(t, performanceConfig{}, false)
153	d, sess := fixture.d, fixture.sess
154	serveCtx, hardCtx := d.serveCtx, d.hardCtx
155	killErr := errors.New("scripted kill failure")
156	fixture.killSession = func(sess *session, reason uint8, purge bool) error {
157		require.NoError(t, d.killSession(sess, reason, purge))
158		return killErr
159	}
160
161	err := fixture.close()
162
163	require.ErrorIs(t, err, killErr)
164	require.Nil(t, fixture.d)
165	require.Nil(t, fixture.sess)
166	require.Nil(t, fixture.activePane)
167	require.Error(t, sess.ctx.Err())
168	require.Error(t, serveCtx.Err())
169	require.Error(t, hardCtx.Err())
170	d.mu.Lock()
171	sessionIDs := make([]domain.SessionID, 0, len(d.sessions))
172	for id := range d.sessions {
173		sessionIDs = append(sessionIDs, id)
174	}
175	d.mu.Unlock()
176	require.NotContains(t, sessionIDs, sess.id)
177	d.snapshotWorkerMu.Lock()
178	workerCancel := d.snapshotWorkerCancel
179	d.snapshotWorkerMu.Unlock()
180	require.Nil(t, workerCancel)
181}
182
183func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) {
184	transport := &countingOutputTransport{}
185
186	err := transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 23)})
187	require.Error(t, err)
188	require.Equal(t, countingOutputMetrics{}, transport.metrics())
189
190	payload := append(make([]byte, 24), 0xff, 0x00, 0xfe)
191	require.NoError(t, transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: payload}))
192	require.Equal(t, countingOutputMetrics{frames: 1, bytes: 3, payloadBytes: uint64(len(payload))}, transport.metrics())
193	require.Equal(t, payload, transport.lastPayload())
194
195	payload[24] = 0x01
196	require.Equal(t, payload, transport.lastPayload(), "the counting transport must retain the payload slice header, not copy it")
197}
198
199func TestPerformanceFixtureCounters(t *testing.T) {
200	fixture := newPerformanceFixture(t, performanceConfig{})
201
202	fixture.paintLive()
203	fixture.paintLive()
204	metrics := fixture.metrics()
205	require.Equal(t, uint64(2), metrics.outputFrames)
206	require.Equal(t, uint64(2), metrics.renderCaptures, "capture is one immutable state snapshot per live render request")
207	require.Equal(t, uint64(2), metrics.renderCompositions, "composition is one frame built from each captured state")
208	require.Equal(t, uint64(2), metrics.renderEmissions, "emission is one accepted output frame")
209	require.Positive(t, metrics.outputBytes)
210	require.Positive(t, metrics.outputPayloadBytes)
211	require.Equal(t, uint64(2), metrics.coordinatorInvalidations)
212	require.Equal(t, uint64(2), metrics.coordinatorWakes)
213	require.Equal(t, uint64(2), metrics.coordinatorCoalesced)
214	require.Positive(t, metrics.coordinatorWakes, "coordinator metric ratios require a nonzero denominator")
215
216	fixture.captureSnapshot()
217	metrics = fixture.metrics()
218	require.Equal(t, uint64(1), metrics.snapshotWrites)
219	require.Positive(t, metrics.snapshotManifestBytes)
220	require.Positive(t, metrics.snapshotObjectBytes)
221
222	fixture.enterCopySearch()
223	require.Positive(t, fixture.searchMatches())
224
225	fixture.resize()
226	require.True(t, fixture.resized())
227}
228
229func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) {
230	d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
231	d.paint(sess, ac, true, nil)
232	_ = awaitFrame(t, sends, ports.MsgOutput)
233	var captures, compositions, emissions int
234	ac.renderStages = renderStageHooks{
235		capture: func() { captures++ },
236		compose: func() { compositions++ },
237		emit:    func() { emissions++ },
238	}
239	ac.replaceTransport(cacheFailTransport{})
240	p := sess.tabs[0].focusedPane()
241	p.mu.Lock()
242	p.screen.Write([]byte("hook"))
243	p.mu.Unlock()
244	d.paint(sess, ac, false, nil)
245	require.Equal(t, 1, captures, "a completed capture counts even when its emission fails")
246	require.Equal(t, 1, compositions, "a completed composition counts even when its emission fails")
247	require.Zero(t, emissions, "emit counts only prepare and transport success")
248}
249
250func TestPerformanceFixtureResize(t *testing.T) {
251	fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}})
252	sequence := []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}, {Cols: 160, Rows: 50}, {Cols: 80, Rows: 24}}
253
254	for _, want := range sequence {
255		fixture.resizeTo(want)
256		require.Equal(t, want, fixture.ac.size)
257		require.True(t, fixture.resized())
258	}
259	metrics := fixture.metrics()
260	require.Equal(t, uint64(len(sequence)), metrics.resizeRequests)
261	require.Equal(t, uint64(len(sequence)), metrics.resizeCommits)
262	require.Equal(t, uint64(len(sequence)), metrics.outputFrames, "one full frame per accepted epoch")
263	require.Zero(t, metrics.skippedEpochs)
264	require.Zero(t, metrics.ptyFailures)
265	require.Zero(t, metrics.frameGapEpochs)
266	require.NotZero(t, metrics.resizeCommitNanos)
267	require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 60, Rows: 38}, {Cols: 80, Rows: 48}, {Cols: 40, Rows: 22}}, fixture.pty.requested())
268}
269
270func TestTransactionalResizeMetricDefinitionsAreZeroSafe(t *testing.T) {
271	metrics := performanceMetrics{}
272	require.Zero(t, coordinatorCoalescingRatio(metrics))
273	require.Zero(t, metrics.skippedEpochs, "no accepted request means no skipped epoch")
274	require.Zero(t, metrics.frameGapEpochs, "no committed epoch means no frame gap")
275}
276
277func TestTransactionalResizeMetrics(t *testing.T) {
278	fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, ptyErrors: []error{fmt.Errorf("scripted failure"), nil}})
279	fixture.resizeTo(domain.Size{Cols: 100, Rows: 30})
280	// The fixture deliberately invokes the coordinator's committed retry path:
281	// a retry never republishes geometry and its reset produces one extra frame.
282	fixture.retryLatest()
283
284	metrics := fixture.metrics()
285	require.Equal(t, uint64(1), metrics.resizeRequests)
286	require.Equal(t, uint64(1), metrics.resizeCommits)
287	require.Equal(t, uint64(1), metrics.ptyFailures)
288	require.Equal(t, uint64(1), metrics.ptyRetries)
289	require.Equal(t, uint64(3), metrics.outputFrames, "commit, resize-failure notice toast, and successful full-reset retry")
290	require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame")
291	require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 50, Rows: 28}}, fixture.pty.requested())
292	require.Equal(t, domain.Size{Cols: 100, Rows: 30}, fixture.ac.size)
293}
294
295func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
296	fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: 4, panes: 4, historyRows: 10_000})
297
298	require.Len(t, fixture.sess.tabs, 4)
299	for _, tb := range fixture.sess.tabs {
300		require.Len(t, tb.panes, 4)
301		for _, p := range tb.panes {
302			require.Equal(t, 10_000, p.history.Len())
303		}
304	}
305}
306
307func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) {
308	fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000})
309
310	first, ok := fixture.d.captureSnapshotState(fixture.sess, 1)
311	require.True(t, ok)
312	require.Len(t, first.tabs, 1)
313	require.Len(t, first.tabs[0].panes, 2)
314	require.Equal(t, 10_000, first.tabs[0].panes[0].sealed.Len())
315	require.Positive(t, first.tabs[0].panes[0].sealed.ChunkCount())
316
317	second, ok := fixture.d.captureSnapshotState(fixture.sess, 2)
318	require.True(t, ok)
319	secondPane := capturePaneByID(second, first.tabs[0].panes[0].id)
320	if secondPane == nil || first.tabs[0].panes[0].sealed.Chunk(0) != secondPane.sealed.Chunk(0) {
321		t.Fatal("unchanged sealed history chunk was not reused across captures")
322	}
323}
324
325func TestPerformanceFixtureCoordinatorMetricsForCanonicalTopologies(t *testing.T) {
326	for _, topology := range daemonHistoryTopologies {
327		t.Run(topology.name, func(t *testing.T) {
328			fixture := newPerformanceFixture(t, performanceConfig{
329				size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,
330			})
331			fixture.paintLive()
332			metrics := fixture.metrics()
333			require.Equal(t, uint64(1), metrics.coordinatorInvalidations)
334			require.Equal(t, uint64(1), metrics.coordinatorWakes)
335			require.Equal(t, uint64(1), metrics.coordinatorCoalesced)
336			require.Positive(t, metrics.coordinatorWakes, "coalescing ratio denominator")
337		})
338	}
339}
340
341type daemonHistoryTopology struct {
342	name        string
343	tabs, panes int
344}
345
346var daemonHistoryTopologies = []daemonHistoryTopology{
347	{name: "1tab-1pane-control", tabs: 1, panes: 1},
348	{name: "1tab-4panes", tabs: 1, panes: 4},
349	{name: "4tabs-1pane", tabs: 4, panes: 1},
350	{name: "4tabs-4panes", tabs: 4, panes: 4},
351	{name: "8tabs-1pane", tabs: 8, panes: 1},
352}
353
354// v3 bounds a decoded snapshot to 256 MiB. One 120-column 10k-row tab is
355// representative and valid; multi-tab fixtures exceed that durable budget, so
356// snapshot capture/encode benchmark only the valid one-tab topologies.
357var daemonSnapshotTopologies = []daemonHistoryTopology{
358	{name: "1tab-1pane-control", tabs: 1, panes: 1},
359	{name: "1tab-4panes", tabs: 1, panes: 4},
360}
361
362func BenchmarkDaemonHistoryLivePaint(b *testing.B) {
363	benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() })
364}
365
366// BenchmarkDaemonHistorySnapshotCapture measures the synchronous immutable
367// capture boundary only. Encoding and the bounded worker queue are deliberately
368// excluded so scheduler latency cannot affect this benchmark.
369func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) {
370	benchmarkDaemonSnapshotCapture(b)
371}
372
373// BenchmarkDaemonIncrementalSnapshotRepository measures the complete
374// content-addressed publication path against a filesystem-equivalent byte
375// counter. Each scenario reports persisted VEVO, VEVM, and VEVH bytes, plus
376// allocation/time and the per-session encoded-history cache high-water mark.
377func BenchmarkDaemonIncrementalSnapshotRepository(b *testing.B) {
378	for _, scenario := range snapshotBenchmarkScenarios {
379		b.Run(scenario.name, func(b *testing.B) {
380			benchmarkIncrementalSnapshotRepository(b, scenario)
381		})
382	}
383}
384
385// BenchmarkDaemonHistorySnapshotRestore measures terminal-state restoration
386// from a validated incremental manifest without spawning PTY or session workers.
387func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) {
388	benchmarkDaemonSnapshotRestore(b)
389}
390
391// BenchmarkDaemonHistoryCopyEnter uses the parent benchmark's repeated-entry
392// semantics: each operation replaces the active copy document. Exit cleanup is
393// intentionally outside this benchmark, so its allocations cannot be reported
394// as copy-entry work.
395func BenchmarkDaemonHistoryCopyEnter(b *testing.B) {
396	benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) })
397}
398func BenchmarkDaemonHistoryCopySearch(b *testing.B) {
399	benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) })
400}
401
402// BenchmarkDaemonHistoryResizeSweep alternates deterministic grow/shrink
403// geometry. Metrics are fixture counters; no benchmark result depends on wall
404// time or scheduler delivery.
405func BenchmarkDaemonHistoryResizeSweep(b *testing.B) {
406	sequence := []domain.Size{{Cols: 80, Rows: 24}, {Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}, {Cols: 160, Rows: 50}, {Cols: 120, Rows: 40}, {Cols: 100, Rows: 30}}
407	benchmarkDaemonLargeHistory(b, "resize-sweep", func(f *performanceFixture) {
408		f.resizeTo(sequence[f.resizes%len(sequence)])
409	})
410}
411
412// BenchmarkDaemonHistoryResizeRetry measures one deterministic failed apply
413// followed by its successful full-reset retry for each operation.
414func BenchmarkDaemonHistoryResizeRetry(b *testing.B) {
415	benchmarkDaemonLargeHistory(b, "resize-retry", func(f *performanceFixture) {
416		f.pty.setErrors([]error{fmt.Errorf("scripted resize failure"), nil})
417		size := domain.Size{Cols: 100, Rows: 30}
418		if f.resizes%2 != 0 {
419			size = domain.Size{Cols: 120, Rows: 40}
420		}
421		f.resizeTo(size)
422		f.retryLatest()
423	})
424}
425
426func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) {
427	b.Helper()
428	for _, topology := range daemonHistoryTopologies {
429		b.Run(topology.name, func(b *testing.B) {
430			fixture := newPerformanceFixture(b, performanceConfig{
431				size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,
432			})
433			if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {
434				b.Fatal("invalid daemon history fixture")
435			}
436			if workload == "copy-search" {
437				benchmarkDaemonCopySearch(b, fixture, run)
438				return
439			}
440			b.ReportAllocs()
441			fixture.resetMetrics()
442			for b.Loop() {
443				run(fixture)
444				fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
445			}
446			metrics := fixture.metrics()
447			if payload := fixture.output.lastPayload(); payload != nil {
448				if _, err := ports.UnmarshalOutput(payload); err != nil {
449					b.Fatalf("decode last output: %v", err)
450				}
451			}
452			if workload == "live-paint" && metrics.outputFrames != uint64(b.N) {
453				b.Fatalf("live paint emitted %d frames for %d operations", metrics.outputFrames, b.N)
454			}
455			benchmarkReportMetrics(b, metrics, b.N, 10_000)
456		})
457	}
458}
459
460func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) {
461	b.Helper()
462	fixture.d.enterCopyMode(fixture.sess, fixture.ac)
463	fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
464	b.ReportAllocs()
465	b.ResetTimer()
466	for range b.N {
467		run(fixture)
468		fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
469	}
470	b.StopTimer()
471	if fixture.searchMatches() == 0 {
472		b.Fatal("copy search produced no deterministic matches")
473	}
474	b.ReportMetric(1, "copyoperations/op")
475}
476
477func benchmarkDaemonSnapshotCapture(b *testing.B) {
478	for _, topology := range daemonSnapshotTopologies {
479		b.Run(topology.name, func(b *testing.B) {
480			fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000})
481			if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {
482				b.Fatal("invalid daemon snapshot capture fixture")
483			}
484			first, ok := fixture.d.captureSnapshotState(fixture.sess, 1)
485			if !ok || !validBenchmarkCapture(first) {
486				b.Fatal("invalid snapshot capture fixture")
487			}
488			firstPane := first.tabs[0].panes[0]
489			firstChunk := firstPane.sealed.Chunk(0)
490			captures := 0
491			var last *snapshotCapture
492			b.ReportAllocs()
493			b.ResetTimer()
494			for range b.N {
495				last, ok = fixture.d.captureSnapshotState(fixture.sess, uint64(captures+2))
496				if !ok {
497					b.Fatal("snapshot capture unexpectedly rejected fixture")
498				}
499				captures++
500			}
501			b.StopTimer()
502			lastPane := capturePaneByID(last, firstPane.id)
503			if captures != b.N || !validBenchmarkCapture(last) || lastPane == nil || lastPane.sealed.Chunk(0) != firstChunk {
504				b.Fatal("snapshot capture did not preserve immutable sealed history")
505			}
506			b.ReportMetric(float64(capturePaneCount(last)), "capturepanes/op")
507		})
508	}
509}
510
511type snapshotBenchmarkScenario struct {
512	name        string
513	size        domain.Size
514	historyRows int
515	baseline    bool
516	mutate      func(*performanceFixture, int)
517	unchanged   bool
518	cellLimited bool
519}
520
521var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{
522	{name: "initial-10k-x-120", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000},
523	{name: "unchanged", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, unchanged: true},
524	{name: "transcript-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkTranscript},
525	// Leave one row below the retention cap so the mutation changes only the
526	// mutable tail and cannot evict a sealed chunk.
527	{name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail},
528	{name: "new-sealed-chunk", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkSealedChunk},
529	{name: "cell-limited-293-columns", size: domain.Size{Cols: 293, Rows: 40}, historyRows: 10_000, cellLimited: true},
530}
531
532func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBenchmarkScenario) {
533	var (
534		metrics        countingSnapshotMetrics
535		peakCacheBytes int
536	)
537	b.ReportAllocs()
538	b.ResetTimer()
539	for operation := 0; b.Loop(); operation++ {
540		func() {
541			// A new fixture gives every iteration the same filesystem state: initial
542			// has no generation, while changed scenarios begin after one persisted
543			// generation. Keep its expensive 10k-row construction timed so benchmark
544			// calibration accounts for real wall time instead of scheduling thousands
545			// of hidden setup iterations.
546			//
547			// b.Cleanup retains callbacks until the sub-benchmark ends; deferring an
548			// explicit close avoids retaining every fixture and OOM.
549			fixture := newPerformanceFixtureWithCleanup(b, performanceConfig{size: scenario.size, panes: 1, historyRows: scenario.historyRows}, false)
550			b.StopTimer()
551			defer func() {
552				b.StopTimer()
553				if err := fixture.close(); err != nil {
554					b.Fatalf("close performance fixture: %v", err)
555				}
556			}()
557			validateSnapshotBenchmarkFixture(b, fixture, scenario)
558			generation := uint64(1)
559			if scenario.baseline {
560				publishBenchmarkSnapshot(b, fixture, generation)
561				generation++
562				fixture.snapshots.reset()
563			}
564			b.StartTimer()
565			if scenario.mutate != nil {
566				scenario.mutate(fixture, operation)
567			}
568			publishBenchmarkSnapshot(b, fixture, generation)
569			b.StopTimer()
570
571			metrics = addCountingSnapshotMetrics(metrics, fixture.snapshots.metrics())
572			peakCacheBytes = max(peakCacheBytes, benchmarkSnapshotCacheBytes(fixture.sess))
573		}()
574		// B.Loop requires the timer to be running at the next iteration.
575		b.StartTimer()
576	}
577	b.StopTimer()
578	if scenario.unchanged && metrics.historyBlobBytes != 0 {
579		b.Fatalf("unchanged snapshot wrote %d history blob bytes", metrics.historyBlobBytes)
580	}
581	if peakCacheBytes > snapshotChunkCacheLimit {
582		b.Fatalf("snapshot cache peak = %d, limit = %d", peakCacheBytes, snapshotChunkCacheLimit)
583	}
584	benchmarkReportSnapshotRepositoryMetrics(b, metrics, b.N, peakCacheBytes)
585}
586
587func validateSnapshotBenchmarkFixture(b *testing.B, fixture *performanceFixture, scenario snapshotBenchmarkScenario) {
588	b.Helper()
589	if !fixture.hasHistoryTopology(1, 1, scenario.historyRows) && !scenario.cellLimited {
590		b.Fatal("invalid incremental snapshot fixture")
591	}
592	if !scenario.cellLimited {
593		return
594	}
595	fixture.activePane.mu.Lock()
596	rows, cells, capCells := fixture.activePane.history.Len(), fixture.activePane.history.Cells(), fixture.activePane.history.CellCap()
597	fixture.activePane.mu.Unlock()
598	if rows >= scenario.historyRows || cells > capCells {
599		b.Fatalf("293-column fixture did not enforce cell limit: rows=%d cells=%d cap=%d", rows, cells, capCells)
600	}
601}
602
603func addCountingSnapshotMetrics(a, b countingSnapshotMetrics) countingSnapshotMetrics {
604	return countingSnapshotMetrics{
605		writes:               a.writes + b.writes,
606		objectBytes:          a.objectBytes + b.objectBytes,
607		historyBlobBytes:     a.historyBlobBytes + b.historyBlobBytes,
608		suppliedObjectBytes:  a.suppliedObjectBytes + b.suppliedObjectBytes,
609		suppliedHistoryBytes: a.suppliedHistoryBytes + b.suppliedHistoryBytes,
610		manifestBytes:        a.manifestBytes + b.manifestBytes,
611		headBytes:            a.headBytes + b.headBytes,
612	}
613}
614
615func publishBenchmarkSnapshot(b testing.TB, fixture *performanceFixture, generation uint64) {
616	b.Helper()
617	capture, ok := fixture.d.captureSnapshotState(fixture.sess, generation)
618	if !ok {
619		b.Fatal("snapshot capture unexpectedly rejected benchmark fixture")
620	}
621	publication, err := fixture.d.incrementalPublication(capture)
622	if err != nil {
623		b.Fatal(err)
624	}
625	if err := fixture.snapshots.Publish(context.Background(), publication); err != nil {
626		b.Fatal(err)
627	}
628	checkpoint := domain.CheckpointRef{Generation: generation, ManifestDigest: snapcodec.ManifestDigest(publication.Manifest)}
629	capture.checkpoint = checkpoint
630	fixture.sess.snapshotMu.Lock()
631	fixture.sess.snapshotPublishedGeneration = generation
632	fixture.sess.snapshotPublishedCheckpoint = &checkpoint
633	fixture.sess.snapshotMu.Unlock()
634	markSnapshotCaptureObjectsPublished(capture)
635}
636
637func mutateBenchmarkTranscript(fixture *performanceFixture, operation int) {
638	fixture.activePane.mu.Lock()
639	fixture.activePane.screen.Write(fmt.Appendf(nil, "\x1b[1;1Htranscript-%08d", operation))
640	fixture.activePane.mu.Unlock()
641}
642
643func mutateBenchmarkTail(fixture *performanceFixture, operation int) {
644	fixture.activePane.mu.Lock()
645	defer fixture.activePane.mu.Unlock()
646	row := make([]renderer.Cell, fixture.activePane.screen.Frame.Width)
647	for column := range row {
648		row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)}
649	}
650	appendHistoryRow(fixture.t, fixture.activePane.history, row)
651}
652
653func mutateBenchmarkSealedChunk(fixture *performanceFixture, operation int) {
654	for row := range 256 {
655		mutateBenchmarkTail(fixture, operation*256+row)
656	}
657}
658
659func benchmarkSnapshotCacheBytes(sess *session) int {
660	sess.snapshotMu.Lock()
661	defer sess.snapshotMu.Unlock()
662	if sess.snapshotChunkCache == nil {
663		return 0
664	}
665	return sess.snapshotChunkCache.used
666}
667
668func benchmarkReportSnapshotRepositoryMetrics(b *testing.B, metrics countingSnapshotMetrics, operations int, peakCacheBytes int) {
669	b.Helper()
670	if operations == 0 {
671		return
672	}
673	perOperation := float64(operations)
674	b.ReportMetric(float64(metrics.objectBytes)/perOperation, "objectbytes/op")
675	b.ReportMetric(float64(metrics.historyBlobBytes)/perOperation, "historyblobbytes/op")
676	b.ReportMetric(float64(metrics.manifestBytes)/perOperation, "manifestbytes/op")
677	b.ReportMetric(float64(metrics.headBytes)/perOperation, "headbytes/op")
678	b.ReportMetric(float64(peakCacheBytes), "peakcachebytes")
679}
680
681func benchmarkDaemonSnapshotRestore(b *testing.B) {
682	fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})
683	if !fixture.hasHistoryTopology(1, 1, 10_000) {
684		b.Fatal("invalid daemon snapshot restore fixture")
685	}
686	capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1)
687	if !ok || !validBenchmarkCapture(capture) {
688		b.Fatal("invalid snapshot restore fixture")
689	}
690	publication, err := fixture.d.incrementalPublication(capture)
691	if err != nil {
692		b.Fatal(err)
693	}
694	snapshot, err := sessionFromGeneration(snapshotGeneration(publication))
695	if err != nil || len(snapshot.Tabs) != 1 || len(snapshot.Tabs[0].Panes) == 0 {
696		b.Fatalf("invalid incremental snapshot restore fixture: %v", err)
697	}
698	paneSnapshot := snapshot.Tabs[0].Panes[0]
699	b.ReportAllocs()
700	b.SetBytes(int64(snapshotPublicationBytes(publication)))
701	b.ResetTimer()
702	for range b.N {
703		if err := restorePaneTerminal(fixture.activePane, paneSnapshot); err != nil {
704			b.Fatal(err)
705		}
706	}
707	b.StopTimer()
708	fixture.activePane.mu.Lock()
709	restoredRows := fixture.activePane.history.Len()
710	fixture.activePane.mu.Unlock()
711	if restoredRows != 10_000 {
712		b.Fatalf("snapshot restore retained %d history rows, want 10000", restoredRows)
713	}
714	b.ReportMetric(float64(restoredRows), "historyrows/restore")
715}
716
717func validBenchmarkCapture(capture *snapshotCapture) bool {
718	return capture != nil && len(capture.tabs) > 0 && len(capture.tabs[0].panes) > 0 && capture.tabs[0].panes[0].sealed.Len() == 10_000 && capture.tabs[0].panes[0].sealed.ChunkCount() > 0
719}
720
721func snapshotGeneration(publication ports.SnapshotPublication) ports.SnapshotGeneration {
722	objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects))
723	for _, object := range publication.Objects {
724		objects[object.Digest] = object.Data
725	}
726	return ports.SnapshotGeneration{
727		IncarnationID:    publication.IncarnationID,
728		Name:             publication.Name,
729		Generation:       publication.Generation,
730		ParentCheckpoint: publication.ParentCheckpoint,
731		Manifest:         publication.Manifest,
732		Objects:          objects,
733	}
734}
735
736func snapshotObjectBytes(publication ports.SnapshotPublication) uint64 {
737	var bytes uint64
738	for _, object := range publication.Objects {
739		bytes += uint64(len(object.Data))
740	}
741	return bytes
742}
743
744func snapshotPublicationBytes(publication ports.SnapshotPublication) uint64 {
745	return uint64(len(publication.Manifest)) + snapshotObjectBytes(publication)
746}
747
748func capturePaneCount(capture *snapshotCapture) int {
749	count := 0
750	for _, tab := range capture.tabs {
751		count += len(tab.panes)
752	}
753	return count
754}
755
756func capturePaneByID(capture *snapshotCapture, id layout.PaneID) *snapshotCapturePane {
757	if capture == nil {
758		return nil
759	}
760	for tabIndex := range capture.tabs {
761		for paneIndex := range capture.tabs[tabIndex].panes {
762			pane := &capture.tabs[tabIndex].panes[paneIndex]
763			if pane.id == id {
764				return pane
765			}
766		}
767	}
768	return nil
769}
770
771func TestCoordinatorCoalescingRatioReportsInvalidationsPerWake(t *testing.T) {
772	require.Equal(t, float64(3), coordinatorCoalescingRatio(performanceMetrics{
773		coordinatorWakes:     1,
774		coordinatorCoalesced: 3,
775	}))
776	require.Zero(t, coordinatorCoalescingRatio(performanceMetrics{}))
777}
778
779func coordinatorCoalescingRatio(metrics performanceMetrics) float64 {
780	if metrics.coordinatorWakes == 0 {
781		return 0
782	}
783	return float64(metrics.coordinatorCoalesced) / float64(metrics.coordinatorWakes)
784}
785
786func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations, historyRows int) {
787	b.Helper()
788	if operations == 0 {
789		return
790	}
791	perOperation := float64(operations)
792	b.ReportMetric(float64(metrics.outputFrames)/perOperation, "outputframes/op")
793	b.ReportMetric(float64(metrics.outputBytes)/perOperation, "outputbytes/op")
794	b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op")
795	b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op")
796	b.ReportMetric(float64(metrics.snapshotManifestBytes)/perOperation, "snapshotmanifestbytes/op")
797	b.ReportMetric(float64(metrics.snapshotObjectBytes)/perOperation, "snapshotobjectbytes/op")
798	b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op")
799	b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op")
800	b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op")
801	// Split counters have precise fixture definitions: capture is an immutable
802	// state snapshot requested for a live paint; composition builds the frame
803	// from that capture; emission accepts its output frame. Keep bytes/frames
804	// above as transport observables rather than folding them into these rates.
805	b.ReportMetric(float64(metrics.renderCaptures)/perOperation, "rendercaptures/op")
806	b.ReportMetric(float64(metrics.renderCompositions)/perOperation, "rendercompositions/op")
807	b.ReportMetric(float64(metrics.renderEmissions)/perOperation, "renderemissions/op")
808	// resizecommitns is the injected-clock elapsed time from accepted request
809	// to committed epoch. The remaining values are counter deltas: skipped is
810	// accepted minus committed (never negative), framegap is committed epochs
811	// lacking their required output frame (never negative), and retries/failures
812	// are PTY Resize outcomes. All divisions are guarded above.
813	b.ReportMetric(float64(metrics.resizeCommitNanos)/perOperation, "resizecommitns/op")
814	b.ReportMetric(float64(metrics.skippedEpochs)/perOperation, "skippedepochs/op")
815	b.ReportMetric(float64(metrics.ptyFailures)/perOperation, "ptyfailures/op")
816	b.ReportMetric(float64(metrics.ptyRetries)/perOperation, "ptyretries/op")
817	b.ReportMetric(float64(metrics.frameGapEpochs)/perOperation, "framegapepochs/op")
818	b.ReportMetric(coordinatorCoalescingRatio(metrics), "coordinatorcoalescingratio")
819	b.ReportMetric(float64(historyRows), "historyrows/pane")
820}
821
822// Root-cause hypothesis (verified by this guard before the fix): S2 allocates
823// one pane-frame copy during capture plus a new composed frame and its
824// base-frame clone for every live paint. Those client-sized copies, rather
825// than history, account for the ~1 MiB/op regression in the pinned benchmark.
826func TestLivePaintAllocationBudget(t *testing.T) {
827	for _, tt := range []struct {
828		name  string
829		panes int
830	}{
831		{name: "1tab-1pane", panes: 1},
832		{name: "1tab-4panes", panes: 4},
833	} {
834		t.Run(tt.name, func(t *testing.T) {
835			fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000})
836			allocs := testing.AllocsPerRun(20, func() {
837				fixture.paintLive()
838				fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
839			})
840			require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints")
841		})
842	}
843}
844
845// TestCopyEnterAllocationBudget protects the byte-stream attachment baseline
846// plus 10%. Copy rendering borrows sealed VT history rows; allocating a copy
847// per viewport row is a production regression even though capture is immutable.
848func TestCopyEnterAllocationBudget(t *testing.T) {
849	for _, tt := range []struct {
850		name             string
851		tabs, panes, max int
852	}{
853		{name: "1tab-1pane", tabs: 1, panes: 1, max: 66},
854		{name: "1tab-4panes", tabs: 1, panes: 4, max: 85},
855		{name: "4tabs-1pane", tabs: 4, panes: 1, max: 71},
856		{name: "4tabs-4panes", tabs: 4, panes: 4, max: 90},
857		{name: "8tabs-1pane", tabs: 8, panes: 1, max: 79},
858	} {
859		t.Run(tt.name, func(t *testing.T) {
860			fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})
861			run := func() {
862				fixture.d.enterCopyMode(fixture.sess, fixture.ac)
863				fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
864			}
865
866			// Always exercise and validate copy entry. The race detector adds
867			// instrumentation allocations which are not reported by the parent
868			// benchmark, so its allocation count cannot represent this budget.
869			run()
870			require.True(t, fixture.copyModeActive(), "copy entry must install a history-backed mode")
871			if !copyEnterAllocationBudgetEnabled {
872				return
873			}
874
875			allocs := testing.AllocsPerRun(20, run)
876			require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10% of the byte-stream attachment allocation baseline")
877		})
878	}
879}
880
881func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {
882	fixture := newPerformanceFixture(t, performanceConfig{})
883
884	require.NotNil(t, fixture.activePane)
885	require.Equal(t, [][]byte{
886		[]byte("\x1b[1;1HA\x1b[2;2HA"),
887		[]byte("\x1b[1;1HB\x1b[2;2HB"),
888	}, fixture.liveWrites)
889
890	for range 4 {
891		fixture.paintLive()
892	}
893	require.Equal(t, uint64(4), fixture.metrics().outputFrames)
894}
895
896// performanceConfig intentionally describes only an in-process daemon shape:
897// no PTY readers, schedulers, clocks, or transports are started by this fixture.
898type performanceConfig struct {
899	size        domain.Size
900	tabs        int
901	panes       int
902	historyRows int
903	ptyErrors   []error
904}
905
906type performanceMetrics struct {
907	outputFrames             uint64
908	outputBytes              uint64
909	outputPayloadBytes       uint64
910	snapshotWrites           uint64
911	snapshotManifestBytes    uint64
912	snapshotObjectBytes      uint64
913	coordinatorInvalidations uint64
914	coordinatorWakes         uint64
915	coordinatorCoalesced     uint64
916	renderCaptures           uint64
917	renderCompositions       uint64
918	renderEmissions          uint64
919	resizeRequests           uint64
920	resizeCommits            uint64
921	resizeCommitNanos        uint64
922	skippedEpochs            uint64
923	ptyFailures              uint64
924	ptyRetries               uint64
925	frameGapEpochs           uint64
926}
927
928// performanceClock makes transition latency a reproducible counter rather
929// than a wall-clock observation. A nil timer channel deliberately executes
930// coordinator work synchronously.
931type performanceClock struct {
932	mu  sync.Mutex
933	now time.Time
934}
935
936func (c *performanceClock) Now() time.Time {
937	c.mu.Lock()
938	defer c.mu.Unlock()
939	c.now = c.now.Add(time.Microsecond)
940	return c.now
941}
942func (*performanceClock) NewTimer(time.Duration) ports.Timer { return performanceTimer{} }
943
944type performanceTimer struct{}
945
946func (performanceTimer) C() <-chan time.Time      { return nil }
947func (performanceTimer) Reset(time.Duration) bool { return true }
948func (performanceTimer) Stop() bool               { return true }
949
950// scriptedPerformancePTY records every requested terminal size and returns a
951// caller-provided error script. It has no reads or scheduling side effects.
952type scriptedPerformancePTY struct {
953	mu    sync.Mutex
954	sizes []domain.Size
955	errs  []error
956	fails uint64
957}
958
959func (p *scriptedPerformancePTY) Resize(size domain.Size) error {
960	p.mu.Lock()
961	defer p.mu.Unlock()
962	p.sizes = append(p.sizes, size)
963	index := len(p.sizes) - 1
964	if index < len(p.errs) && p.errs[index] != nil {
965		p.fails++
966		return p.errs[index]
967	}
968	return nil
969}
970func (*scriptedPerformancePTY) Read([]byte) (int, error)     { return 0, io.EOF }
971func (*scriptedPerformancePTY) Write(b []byte) (int, error)  { return len(b), nil }
972func (*scriptedPerformancePTY) Close() error                 { return nil }
973func (*scriptedPerformancePTY) Pid() int                     { return 0 }
974func (*scriptedPerformancePTY) ForegroundPgid() (int, error) { return 0, nil }
975func (p *scriptedPerformancePTY) requested() []domain.Size {
976	p.mu.Lock()
977	defer p.mu.Unlock()
978	return append([]domain.Size(nil), p.sizes...)
979}
980func (p *scriptedPerformancePTY) metrics() (uint64, uint64) {
981	p.mu.Lock()
982	defer p.mu.Unlock()
983	return p.fails, uint64(len(p.sizes))
984}
985func (p *scriptedPerformancePTY) setErrors(errs []error) {
986	p.mu.Lock()
987	defer p.mu.Unlock()
988	p.errs = append([]error(nil), errs...)
989	p.sizes = nil
990	p.fails = 0
991}
992
993type performanceFixture struct {
994	t                   testing.TB
995	d                   *Daemon
996	sess                *session
997	ac                  *attachedClient
998	output              *countingOutputTransport
999	snapshots           *countingSnapshotRepository
1000	activePane          *pane
1001	liveWrites          [][]byte
1002	paints              int
1003	resizeSizes         [2]domain.Size
1004	resizes             int
1005	resizedSize         domain.Size
1006	pty                 *scriptedPerformancePTY
1007	clock               *performanceClock
1008	killSession         func(*session, uint8, bool) error
1009	resizeRequests      uint64
1010	resizeCommits       uint64
1011	resizeCommitNanos   uint64
1012	ptyFailures         uint64
1013	ptyRetries          uint64
1014	coordinatorBaseline renderCoordinatorBurstMetricsSnapshot
1015	renderCaptures      uint64
1016	renderCompositions  uint64
1017	renderEmissions     uint64
1018}
1019
1020func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {
1021	t.Helper()
1022	return newPerformanceFixtureWithCleanup(t, config, true)
1023}
1024
1025func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, registerCleanup bool) *performanceFixture {
1026	t.Helper()
1027	if !config.size.Valid() {
1028		config.size = domain.Size{Cols: 80, Rows: 24}
1029	}
1030	if config.tabs == 0 {
1031		config.tabs = 1
1032	}
1033	if config.panes == 0 {
1034		config.panes = 2
1035	}
1036	if config.historyRows == 0 {
1037		config.historyRows = 1
1038	}
1039	pty := &scriptedPerformancePTY{}
1040	ptys := make([]ports.PTY, config.tabs)
1041	ptys[0] = pty
1042	d, sess, ac, _ := newManualSessionWithPTYsCleanup(t, registerCleanup, ptys...)
1043	clock := &performanceClock{}
1044	d.clock = clock
1045	output := &countingOutputTransport{}
1046	ac.tr = output
1047	ac.size = config.size
1048	sess.name = "performance"
1049	sess.ephemeral = false
1050	sess.incarnation = domain.IncarnationID{1}
1051	sess.snapEligible.Store(true)
1052
1053	fixture := &performanceFixture{
1054		t:           t,
1055		d:           d,
1056		sess:        sess,
1057		ac:          ac,
1058		output:      output,
1059		snapshots:   &countingSnapshotRepository{},
1060		pty:         pty,
1061		clock:       clock,
1062		killSession: d.killSession,
1063		liveWrites:  [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
1064		resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
1065	}
1066	WithSnapshotRepository(fixture.snapshots)(d)
1067	d.startSnapshotEncodeWorker()
1068	if registerCleanup {
1069		t.Cleanup(d.stopSnapshotEncodeWorker)
1070	}
1071	for tabIndex, tb := range sess.tabs {
1072		fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))
1073	}
1074	fixture.activePane = fixture.findActivePane()
1075	// Fixture construction may solve the initial layout; benchmark scripts begin
1076	// after that setup work and therefore have an empty call history.
1077	pty.setErrors(config.ptyErrors)
1078
1079	// The fixture uses the production coordinator. performanceClock's nil timer
1080	// channel completes the coordinator path synchronously and deterministically.
1081	ac.renderStages = renderStageHooks{
1082		capture: func() { fixture.renderCaptures++ },
1083		compose: func() { fixture.renderCompositions++ },
1084		emit:    func() { fixture.renderEmissions++ },
1085	}
1086	d.attachCoordinator(sess, nil, ac, true)
1087
1088	// Prime the real renderer shadow before measurements. Subsequent paints use
1089	// actual production diffs rather than the initial full frame.
1090	d.paint(sess, ac, true, nil)
1091	fixture.resetMetrics()
1092	return fixture
1093}
1094
1095func (f *performanceFixture) close() error {
1096	if f.d == nil {
1097		return nil
1098	}
1099	d, sess := f.d, f.sess
1100	sess.mu.Lock()
1101	sess.ephemeral = true
1102	sess.mu.Unlock()
1103	killErr := f.killSession(sess, ports.ReasonServerShutdown, false)
1104	d.stopSnapshotEncodeWorker()
1105	d.serveCancel()
1106	d.hardCancel()
1107
1108	f.d = nil
1109	f.sess = nil
1110	f.ac = nil
1111	f.output = nil
1112	f.snapshots = nil
1113	f.activePane = nil
1114	f.pty = nil
1115	f.clock = nil
1116	f.killSession = nil
1117	return killErr
1118}
1119
1120func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) {
1121	f.t.Helper()
1122	tb.mu.Lock()
1123	tb.size = size
1124	left := tb.focusedPane()
1125	for paneIndex := 1; paneIndex < paneCount; paneIndex++ {
1126		id := layout.PaneID(fmt.Sprintf("pane-%d-%d", tabIndex+1, paneIndex+1))
1127		tb.panes[id] = newPane(id, nil, size)
1128		require.NoError(f.t, tb.tree.Split(left.id, layout.Right, true, id, domain.Rect{Width: size.Cols, Height: size.Rows}))
1129	}
1130	placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})
1131	require.True(f.t, ok, "performance fixture layout must be solvable")
1132	require.Len(f.t, placements, paneCount)
1133	tb.bumpLayoutGenerationLocked()
1134	tb.mu.Unlock()
1135	_, _ = f.d.applyTabLayoutTransaction(f.sess, tb)
1136	tb.mu.Lock()
1137	panes := tb.panesSnapshot()
1138	tb.mu.Unlock()
1139	for _, p := range panes {
1140		p.mu.Lock()
1141		width := p.screen.Frame.Width
1142		for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Frame.Height; row++ {
1143			p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
1144		}
1145		p.mu.Unlock()
1146	}
1147}
1148
1149// performanceFullWidthRow produces stable, full-width visible content without
1150// relying on process output or timing.
1151func performanceFullWidthRow(width, tabIndex, row int) string {
1152	prefix := fmt.Sprintf("needle-%d-%02d ", tabIndex, row)
1153	out := make([]byte, width)
1154	for i := range out {
1155		out[i] = byte('a' + (tabIndex+row+i)%26)
1156	}
1157	copy(out, prefix)
1158	return string(out)
1159}
1160
1161func (f *performanceFixture) findActivePane() *pane {
1162	f.t.Helper()
1163	tb := testAttachmentTab(f.sess)
1164	require.NotNil(f.t, tb)
1165	tb.mu.Lock()
1166	defer tb.mu.Unlock()
1167	return tb.focusedPane()
1168}
1169
1170func (f *performanceFixture) paintLive() {
1171	p := f.activePane
1172	p.mu.Lock()
1173	p.screen.Write(f.liveWrites[f.paints%len(f.liveWrites)])
1174	p.mu.Unlock()
1175	if rc := f.sess.renderCoordinator(); rc != nil {
1176		rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "performance_benchmark_test.go"})
1177	} else {
1178		f.d.paint(f.sess, f.ac, false, nil)
1179	}
1180	f.paints++
1181}
1182
1183func (f *performanceFixture) captureSnapshot() {
1184	f.t.Helper()
1185	require.True(f.t, f.d.captureSession(f.sess))
1186	// Worker finalization clears snapshotPending and signals snapshotChanged on
1187	// every success and failure path after persistence has returned.
1188	awaitSnapshotIdle(f.t, f.sess)
1189}
1190
1191func (f *performanceFixture) enterCopySearch() {
1192	f.t.Helper()
1193	f.d.enterCopyMode(f.sess, f.ac)
1194	f.d.handleInput(f.sess, f.ac, []byte("/needle\r"))
1195}
1196
1197func (f *performanceFixture) hasHistoryTopology(tabs, panes, historyRows int) bool {
1198	if len(f.sess.tabs) != tabs {
1199		return false
1200	}
1201	for _, tb := range f.sess.tabs {
1202		tb.mu.Lock()
1203		if len(tb.panes) != panes {
1204			tb.mu.Unlock()
1205			return false
1206		}
1207		for _, p := range tb.panes {
1208			p.mu.Lock()
1209			rows := p.history.Len()
1210			p.mu.Unlock()
1211			if rows != historyRows {
1212				tb.mu.Unlock()
1213				return false
1214			}
1215		}
1216		tb.mu.Unlock()
1217	}
1218	return true
1219}
1220
1221func (f *performanceFixture) searchMatches() int {
1222	f.ac.overlays.copyMu.Lock()
1223	defer f.ac.overlays.copyMu.Unlock()
1224	if f.ac.overlays.copyMode == nil {
1225		return 0
1226	}
1227	return len(f.ac.overlays.copyMode.Searches)
1228}
1229
1230func (f *performanceFixture) copyModeActive() bool {
1231	f.ac.overlays.copyMu.Lock()
1232	defer f.ac.overlays.copyMu.Unlock()
1233	return f.ac.overlays.copyMode != nil && f.ac.overlays.copyDocument != nil && f.ac.overlays.copyDocument.Len() >= 10_000
1234}
1235
1236func (f *performanceFixture) resize() {
1237	f.resizeTo(f.resizeSizes[f.resizes%len(f.resizeSizes)])
1238}
1239
1240func (f *performanceFixture) resizeTo(size domain.Size) {
1241	f.resizedSize = size
1242	f.resizes++
1243	before := f.sess.renderCoordinator().resizeSnapshot()
1244	start := f.clock.Now()
1245	failuresBefore, callsBefore := f.pty.metrics()
1246	f.d.resize(f.sess, f.ac, size)
1247	after := f.sess.renderCoordinator().resizeSnapshot()
1248	if after.epoch > before.epoch {
1249		f.resizeRequests += after.epoch - before.epoch
1250	}
1251	if after.committed > before.committed {
1252		f.resizeCommits += after.committed - before.committed
1253		f.resizeCommitNanos += uint64(f.clock.Now().Sub(start).Nanoseconds())
1254	}
1255	failuresAfter, callsAfter := f.pty.metrics()
1256	f.ptyFailures += failuresAfter - failuresBefore
1257	if callsAfter > callsBefore+1 {
1258		f.ptyRetries += callsAfter - callsBefore - 1
1259	}
1260}
1261
1262func (f *performanceFixture) retryLatest() {
1263	epoch := f.sess.renderCoordinator().resizeSnapshot().committed
1264	f.sess.mu.Lock()
1265	tabs := append([]*tab(nil), f.sess.tabs...)
1266	f.sess.mu.Unlock()
1267	members := make([]resizeMember, 0)
1268	for _, tb := range tabs {
1269		tb.mu.Lock()
1270		plan := prepareTabLayoutForSizeLocked(f.sess, tb, tabSize(f.ac.size))
1271		tb.mu.Unlock()
1272		members = append(members, plan.members...)
1273	}
1274	failuresBefore, callsBefore := f.pty.metrics()
1275	f.d.retryResizeMembers(f.sess, f.ac, f.sess.renderCoordinator().attachmentLease(f.ac), epoch, members)
1276	failuresAfter, callsAfter := f.pty.metrics()
1277	f.ptyFailures += failuresAfter - failuresBefore
1278	if callsAfter > callsBefore {
1279		f.ptyRetries += callsAfter - callsBefore
1280	}
1281}
1282
1283func (f *performanceFixture) resized() bool {
1284	if f.ac.size != f.resizedSize {
1285		return false
1286	}
1287	for _, tb := range f.sess.tabs {
1288		tb.mu.Lock()
1289		ok := tb.size == tabSize(f.resizedSize)
1290		tb.mu.Unlock()
1291		if !ok {
1292			return false
1293		}
1294	}
1295	return true
1296}
1297
1298func (f *performanceFixture) resetMetrics() {
1299	f.output.reset()
1300	f.snapshots.reset()
1301	f.renderCaptures = 0
1302	f.renderCompositions = 0
1303	f.renderEmissions = 0
1304	f.resizeRequests = 0
1305	f.resizeCommits = 0
1306	f.resizeCommitNanos = 0
1307	f.ptyFailures = 0
1308	f.ptyRetries = 0
1309	if rc := f.sess.renderCoordinator(); rc != nil {
1310		f.coordinatorBaseline = rc.burstMetricsSnapshot()
1311	}
1312}
1313
1314func (f *performanceFixture) metrics() performanceMetrics {
1315	output := f.output.metrics()
1316	snapshots := f.snapshots.metrics()
1317	metrics := performanceMetrics{
1318		outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
1319		snapshotWrites: snapshots.writes, snapshotManifestBytes: snapshots.manifestBytes, snapshotObjectBytes: snapshots.objectBytes,
1320		renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions,
1321		resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos,
1322		ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries,
1323	}
1324	if metrics.resizeRequests > metrics.resizeCommits {
1325		metrics.skippedEpochs = metrics.resizeRequests - metrics.resizeCommits
1326	}
1327	if metrics.resizeCommits > metrics.outputFrames {
1328		metrics.frameGapEpochs = metrics.resizeCommits - metrics.outputFrames
1329	}
1330	if rc := f.sess.renderCoordinator(); rc != nil {
1331		coordinator := rc.burstMetricsSnapshot()
1332		metrics.coordinatorInvalidations = coordinator.invalidations - f.coordinatorBaseline.invalidations
1333		metrics.coordinatorWakes = coordinator.wakes - f.coordinatorBaseline.wakes
1334		metrics.coordinatorCoalesced = coordinator.coalesced - f.coordinatorBaseline.coalesced
1335	}
1336	return metrics
1337}
1338
1339const outputPayloadHeaderBytes = 24
1340
1341type countingOutputMetrics struct{ frames, bytes, payloadBytes uint64 }
1342
1343type countingOutputTransport struct {
1344	mu sync.Mutex
1345	countingOutputMetrics
1346	last []byte
1347}
1348
1349func (t *countingOutputTransport) Send(frame ports.Frame) error {
1350	if frame.Type != ports.MsgOutput {
1351		return nil
1352	}
1353	if len(frame.Payload) < outputPayloadHeaderBytes {
1354		return fmt.Errorf("output payload is %d bytes, want at least %d", len(frame.Payload), outputPayloadHeaderBytes)
1355	}
1356	t.mu.Lock()
1357	t.frames++
1358	t.bytes += uint64(len(frame.Payload) - outputPayloadHeaderBytes)
1359	t.payloadBytes += uint64(len(frame.Payload))
1360	t.last = frame.Payload
1361	t.mu.Unlock()
1362	return nil
1363}
1364func (*countingOutputTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
1365func (*countingOutputTransport) Close() error               { return nil }
1366func (t *countingOutputTransport) reset() {
1367	t.mu.Lock()
1368	t.countingOutputMetrics = countingOutputMetrics{}
1369	t.last = nil
1370	t.mu.Unlock()
1371}
1372func (t *countingOutputTransport) metrics() countingOutputMetrics {
1373	t.mu.Lock()
1374	defer t.mu.Unlock()
1375	return t.countingOutputMetrics
1376}
1377func (t *countingOutputTransport) lastPayload() []byte {
1378	t.mu.Lock()
1379	defer t.mu.Unlock()
1380	return t.last
1381}
1382
1383// snapshotHeadBytes is the on-disk VEVH header (magic, generation, and
1384// manifest digest) written by the filesystem repository for every generation.
1385const snapshotHeadBytes = 4 + 8 + 32
1386
1387type countingSnapshotMetrics struct {
1388	writes, objectBytes, historyBlobBytes, suppliedObjectBytes, suppliedHistoryBytes, manifestBytes, headBytes uint64
1389}
1390type countingSnapshotRepository struct {
1391	noOpSnapshotRepository
1392	mu sync.Mutex
1393	countingSnapshotMetrics
1394	objects map[string]map[ports.SnapshotDigest]struct{}
1395	last    []byte
1396}
1397
1398// Publish models the bytes written by the filesystem repository: immutable
1399// VEVO objects are written once per session and each generation writes a VEVM
1400// manifest plus the mutable VEVH pointer. It intentionally does not measure
1401// directory metadata, temporary files, or fsyncs.
1402func (s *countingSnapshotRepository) Publish(_ context.Context, publication ports.SnapshotPublication) error {
1403	s.mu.Lock()
1404	defer s.mu.Unlock()
1405	if s.objects == nil {
1406		s.objects = make(map[string]map[ports.SnapshotDigest]struct{})
1407	}
1408	objects := s.objects[publication.Name]
1409	if objects == nil {
1410		objects = make(map[ports.SnapshotDigest]struct{})
1411		s.objects[publication.Name] = objects
1412	}
1413	s.writes++
1414	s.manifestBytes += uint64(len(publication.Manifest))
1415	s.headBytes += snapshotHeadBytes
1416	for _, object := range publication.Objects {
1417		s.suppliedObjectBytes += uint64(len(object.Data))
1418		kind, _, err := snapcodec.PreflightObject(object.Data)
1419		if err == nil && kind == snapcodec.HistoryChunk {
1420			s.suppliedHistoryBytes += uint64(len(object.Data))
1421		}
1422		if _, exists := objects[object.Digest]; exists {
1423			continue
1424		}
1425		objects[object.Digest] = struct{}{}
1426		s.objectBytes += uint64(len(object.Data))
1427		if err == nil && kind == snapcodec.HistoryChunk {
1428			s.historyBlobBytes += uint64(len(object.Data))
1429		}
1430	}
1431	s.last = publication.Manifest
1432	return nil
1433}
1434func (s *countingSnapshotRepository) reset() {
1435	s.mu.Lock()
1436	s.countingSnapshotMetrics = countingSnapshotMetrics{}
1437	s.last = nil
1438	s.mu.Unlock()
1439}
1440func (s *countingSnapshotRepository) metrics() countingSnapshotMetrics {
1441	s.mu.Lock()
1442	defer s.mu.Unlock()
1443	return s.countingSnapshotMetrics
1444}
1445func (s *countingSnapshotRepository) lastPayload() []byte {
1446	s.mu.Lock()
1447	defer s.mu.Unlock()
1448	return s.last
1449}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close