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

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