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}