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}