detached 4815382

perf(daemon): measure transactional resize sweeps

commit
481538211fb70c30fcd7022c9636106ca0713bc8
author
brice · · 2026-07-12 08:44 UTC
parent
290f538
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +204 -11
M internal/usecase/daemon/performance_benchmark_test.go +204 -11
@@ -5,6 +5,7 @@ import (
55 "io"
66 "sync"
77 "testing"
8+ "time"
89
910 "github.com/stretchr/testify/require"
1011
@@ -92,14 +93,42 @@ func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) {
9293 require.Zero(t, emissions, "emit counts only prepare and transport success")
9394 }
9495
95-func TestPerformanceFixtureResizeAlternatesRealDimensions(t *testing.T) {
96- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}})
96+func TestPerformanceFixtureResize(t *testing.T) {
97+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}})
98+ sequence := []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}, {Cols: 160, Rows: 50}, {Cols: 80, Rows: 24}}
9799
98- for _, want := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}} {
99- fixture.resize()
100+ for _, want := range sequence {
101+ fixture.resizeTo(want)
100102 require.Equal(t, want, fixture.ac.size)
101103 require.True(t, fixture.resized())
102104 }
105+ metrics := fixture.metrics()
106+ require.Equal(t, uint64(len(sequence)), metrics.resizeRequests)
107+ require.Equal(t, uint64(len(sequence)), metrics.resizeCommits)
108+ require.Equal(t, uint64(len(sequence)), metrics.outputFrames, "one full frame per accepted epoch")
109+ require.Zero(t, metrics.skippedEpochs)
110+ require.Zero(t, metrics.ptyFailures)
111+ require.Zero(t, metrics.frameGapEpochs)
112+ require.NotZero(t, metrics.resizeCommitNanos)
113+ require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 60, Rows: 38}, {Cols: 80, Rows: 48}, {Cols: 40, Rows: 22}}, fixture.pty.requested())
114+}
115+
116+func TestTransactionalResizeMetrics(t *testing.T) {
117+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, ptyErrors: []error{fmt.Errorf("scripted failure"), nil}})
118+ fixture.resizeTo(domain.Size{Cols: 100, Rows: 30})
119+ // The fixture deliberately invokes the coordinator's committed retry path:
120+ // a retry never republishes geometry and its reset produces one extra frame.
121+ fixture.retryLatest()
122+
123+ metrics := fixture.metrics()
124+ require.Equal(t, uint64(1), metrics.resizeRequests)
125+ require.Equal(t, uint64(1), metrics.resizeCommits)
126+ require.Equal(t, uint64(1), metrics.ptyFailures)
127+ require.Equal(t, uint64(1), metrics.ptyRetries)
128+ require.Equal(t, uint64(2), metrics.outputFrames, "commit plus successful full-reset retry")
129+ require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame")
130+ require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 50, Rows: 28}}, fixture.pty.requested())
131+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, fixture.ac.size)
103132 }
104133
105134 func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
@@ -155,8 +184,29 @@ func BenchmarkDaemonHistoryCopyEnter(b *testing.B) {
155184 func BenchmarkDaemonHistoryCopySearch(b *testing.B) {
156185 benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) })
157186 }
158-func BenchmarkDaemonHistoryResize(b *testing.B) {
159- benchmarkDaemonLargeHistory(b, "resize", func(f *performanceFixture) { f.resize() })
187+
188+// BenchmarkDaemonHistoryResizeSweep alternates deterministic grow/shrink
189+// geometry. Metrics are fixture counters; no benchmark result depends on wall
190+// time or scheduler delivery.
191+func BenchmarkDaemonHistoryResizeSweep(b *testing.B) {
192+ 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}}
193+ benchmarkDaemonLargeHistory(b, "resize-sweep", func(f *performanceFixture) {
194+ f.resizeTo(sequence[f.resizes%len(sequence)])
195+ })
196+}
197+
198+// BenchmarkDaemonHistoryResizeRetry measures one deterministic failed apply
199+// followed by its successful full-reset retry for each operation.
200+func BenchmarkDaemonHistoryResizeRetry(b *testing.B) {
201+ benchmarkDaemonLargeHistory(b, "resize-retry", func(f *performanceFixture) {
202+ f.pty.setErrors([]error{fmt.Errorf("scripted resize failure"), nil})
203+ size := domain.Size{Cols: 100, Rows: 30}
204+ if f.resizes%2 != 0 {
205+ size = domain.Size{Cols: 120, Rows: 40}
206+ }
207+ f.resizeTo(size)
208+ f.retryLatest()
209+ })
160210 }
161211
162212 func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) {
@@ -237,6 +287,16 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
237287 b.ReportMetric(float64(metrics.renderCaptures)/perOperation, "rendercaptures/op")
238288 b.ReportMetric(float64(metrics.renderCompositions)/perOperation, "rendercompositions/op")
239289 b.ReportMetric(float64(metrics.renderEmissions)/perOperation, "renderemissions/op")
290+ // resizecommitns is the injected-clock elapsed time from accepted request
291+ // to committed epoch. The remaining values are counter deltas: skipped is
292+ // accepted minus committed (never negative), framegap is committed epochs
293+ // lacking their required output frame (never negative), and retries/failures
294+ // are PTY Resize outcomes. All divisions are guarded above.
295+ b.ReportMetric(float64(metrics.resizeCommitNanos)/perOperation, "resizecommitns/op")
296+ b.ReportMetric(float64(metrics.skippedEpochs)/perOperation, "skippedepochs/op")
297+ b.ReportMetric(float64(metrics.ptyFailures)/perOperation, "ptyfailures/op")
298+ b.ReportMetric(float64(metrics.ptyRetries)/perOperation, "ptyretries/op")
299+ b.ReportMetric(float64(metrics.frameGapEpochs)/perOperation, "framegapepochs/op")
240300 b.ReportMetric(coordinatorCoalescingRatio(metrics), "coordinatorcoalescingratio")
241301 b.ReportMetric(float64(historyRows), "historyrows/pane")
242302 }
@@ -286,6 +346,7 @@ type performanceConfig struct {
286346 tabs int
287347 panes int
288348 historyRows int
349+ ptyErrors []error
289350 }
290351
291352 type performanceMetrics struct {
@@ -300,6 +361,78 @@ type performanceMetrics struct {
300361 renderCaptures uint64
301362 renderCompositions uint64
302363 renderEmissions uint64
364+ resizeRequests uint64
365+ resizeCommits uint64
366+ resizeCommitNanos uint64
367+ skippedEpochs uint64
368+ ptyFailures uint64
369+ ptyRetries uint64
370+ frameGapEpochs uint64
371+}
372+
373+// performanceClock makes transition latency a reproducible counter rather
374+// than a wall-clock observation. A nil timer channel deliberately executes
375+// coordinator work synchronously.
376+type performanceClock struct {
377+ mu sync.Mutex
378+ now time.Time
379+}
380+
381+func (c *performanceClock) Now() time.Time {
382+ c.mu.Lock()
383+ defer c.mu.Unlock()
384+ c.now = c.now.Add(time.Microsecond)
385+ return c.now
386+}
387+func (*performanceClock) NewTimer(time.Duration) ports.Timer { return performanceTimer{} }
388+
389+type performanceTimer struct{}
390+
391+func (performanceTimer) C() <-chan time.Time { return nil }
392+func (performanceTimer) Reset(time.Duration) bool { return true }
393+func (performanceTimer) Stop() bool { return true }
394+
395+// scriptedPerformancePTY records every requested terminal size and returns a
396+// caller-provided error script. It has no reads or scheduling side effects.
397+type scriptedPerformancePTY struct {
398+ mu sync.Mutex
399+ sizes []domain.Size
400+ errs []error
401+ fails uint64
402+}
403+
404+func (p *scriptedPerformancePTY) Resize(size domain.Size) error {
405+ p.mu.Lock()
406+ defer p.mu.Unlock()
407+ p.sizes = append(p.sizes, size)
408+ index := len(p.sizes) - 1
409+ if index < len(p.errs) && p.errs[index] != nil {
410+ p.fails++
411+ return p.errs[index]
412+ }
413+ return nil
414+}
415+func (*scriptedPerformancePTY) Read([]byte) (int, error) { return 0, io.EOF }
416+func (*scriptedPerformancePTY) Write(b []byte) (int, error) { return len(b), nil }
417+func (*scriptedPerformancePTY) Close() error { return nil }
418+func (*scriptedPerformancePTY) Pid() int { return 0 }
419+func (*scriptedPerformancePTY) ForegroundPgid() (int, error) { return 0, nil }
420+func (p *scriptedPerformancePTY) requested() []domain.Size {
421+ p.mu.Lock()
422+ defer p.mu.Unlock()
423+ return append([]domain.Size(nil), p.sizes...)
424+}
425+func (p *scriptedPerformancePTY) metrics() (uint64, uint64) {
426+ p.mu.Lock()
427+ defer p.mu.Unlock()
428+ return p.fails, uint64(len(p.sizes))
429+}
430+func (p *scriptedPerformancePTY) setErrors(errs []error) {
431+ p.mu.Lock()
432+ defer p.mu.Unlock()
433+ p.errs = append([]error(nil), errs...)
434+ p.sizes = nil
435+ p.fails = 0
303436 }
304437
305438 type performanceFixture struct {
@@ -315,6 +448,13 @@ type performanceFixture struct {
315448 resizeSizes [2]domain.Size
316449 resizes int
317450 resizedSize domain.Size
451+ pty *scriptedPerformancePTY
452+ clock *performanceClock
453+ resizeRequests uint64
454+ resizeCommits uint64
455+ resizeCommitNanos uint64
456+ ptyFailures uint64
457+ ptyRetries uint64
318458 coordinatorBaseline renderCoordinatorBurstMetricsSnapshot
319459 renderCaptures uint64
320460 renderCompositions uint64
@@ -335,8 +475,12 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
335475 if config.historyRows == 0 {
336476 config.historyRows = 1
337477 }
478+ pty := &scriptedPerformancePTY{}
338479 ptys := make([]ports.PTY, config.tabs)
480+ ptys[0] = pty
339481 d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...)
482+ clock := &performanceClock{}
483+ d.clock = clock
340484 output := &countingOutputTransport{}
341485 ac.tr = output
342486 ac.size = config.size
@@ -351,6 +495,8 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
351495 ac: ac,
352496 output: output,
353497 snaps: &countingSnapshotStore{},
498+ pty: pty,
499+ clock: clock,
354500 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
355501 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
356502 }
@@ -359,10 +505,12 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
359505 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))
360506 }
361507 fixture.activePane = fixture.findActivePane()
508+ // Fixture construction may solve the initial layout; benchmark scripts begin
509+ // after that setup work and therefore have an empty call history.
510+ pty.setErrors(config.ptyErrors)
362511
363- // The fixture uses the production coordinator. stubClock's inert timer is
364- // completed synchronously by the coordinator, keeping benchmark operations
365- // deterministic while retaining their real invalidation path.
512+ // The fixture uses the production coordinator. performanceClock's nil timer
513+ // channel completes the coordinator path synchronously and deterministically.
366514 ac.renderStages = renderStageHooks{
367515 capture: func() { fixture.renderCaptures++ },
368516 compose: func() { fixture.renderCompositions++ },
@@ -457,9 +605,41 @@ func (f *performanceFixture) searchMatches() int {
457605 }
458606
459607 func (f *performanceFixture) resize() {
460- f.resizedSize = f.resizeSizes[f.resizes%len(f.resizeSizes)]
608+ f.resizeTo(f.resizeSizes[f.resizes%len(f.resizeSizes)])
609+}
610+
611+func (f *performanceFixture) resizeTo(size domain.Size) {
612+ f.resizedSize = size
461613 f.resizes++
462- f.d.resize(f.sess, f.ac, f.resizedSize)
614+ before := f.sess.renderCoordinator().resizeSnapshot()
615+ start := f.clock.Now()
616+ failuresBefore, callsBefore := f.pty.metrics()
617+ f.d.resize(f.sess, f.ac, size)
618+ after := f.sess.renderCoordinator().resizeSnapshot()
619+ if after.epoch > before.epoch {
620+ f.resizeRequests += after.epoch - before.epoch
621+ }
622+ if after.committed > before.committed {
623+ f.resizeCommits += after.committed - before.committed
624+ f.resizeCommitNanos += uint64(f.clock.Now().Sub(start).Nanoseconds())
625+ }
626+ failuresAfter, callsAfter := f.pty.metrics()
627+ f.ptyFailures += failuresAfter - failuresBefore
628+ if callsAfter > callsBefore+1 {
629+ f.ptyRetries += callsAfter - callsBefore - 1
630+ }
631+}
632+
633+func (f *performanceFixture) retryLatest() {
634+ epoch := f.sess.renderCoordinator().resizeSnapshot().committed
635+ plan := f.d.prepareResize(f.sess, f.ac.size)
636+ failuresBefore, callsBefore := f.pty.metrics()
637+ f.d.retryResizeMembers(f.sess, f.ac, epoch, plan.members)
638+ failuresAfter, callsAfter := f.pty.metrics()
639+ f.ptyFailures += failuresAfter - failuresBefore
640+ if callsAfter > callsBefore {
641+ f.ptyRetries += callsAfter - callsBefore
642+ }
463643 }
464644
465645 func (f *performanceFixture) resized() bool {
@@ -483,6 +663,11 @@ func (f *performanceFixture) resetMetrics() {
483663 f.renderCaptures = 0
484664 f.renderCompositions = 0
485665 f.renderEmissions = 0
666+ f.resizeRequests = 0
667+ f.resizeCommits = 0
668+ f.resizeCommitNanos = 0
669+ f.ptyFailures = 0
670+ f.ptyRetries = 0
486671 if rc := f.sess.renderCoordinator(); rc != nil {
487672 f.coordinatorBaseline = rc.burstMetricsSnapshot()
488673 }
@@ -495,6 +680,14 @@ func (f *performanceFixture) metrics() performanceMetrics {
495680 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
496681 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,
497682 renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions,
683+ resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos,
684+ ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries,
685+ }
686+ if metrics.resizeRequests > metrics.resizeCommits {
687+ metrics.skippedEpochs = metrics.resizeRequests - metrics.resizeCommits
688+ }
689+ if metrics.resizeCommits > metrics.outputFrames {
690+ metrics.frameGapEpochs = metrics.resizeCommits - metrics.outputFrames
498691 }
499692 if rc := f.sess.renderCoordinator(); rc != nil {
500693 coordinator := rc.burstMetricsSnapshot()
@@ -5,6 +5,7 @@ import (
5 "io"5 "io"
6 "sync"6 "sync"
7 "testing"7 "testing"
8+ "time"
8 9
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 11
@@ -92,14 +93,42 @@ func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) {
92 require.Zero(t, emissions, "emit counts only prepare and transport success")93 require.Zero(t, emissions, "emit counts only prepare and transport success")
93 }94 }
94 95
95-func TestPerformanceFixtureResizeAlternatesRealDimensions(t *testing.T) {96+func TestPerformanceFixtureResize(t *testing.T) {
96- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}})97+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}})
98+ sequence := []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}, {Cols: 160, Rows: 50}, {Cols: 80, Rows: 24}}
97 99
98- for _, want := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}} {100+ for _, want := range sequence {
99- fixture.resize()101+ fixture.resizeTo(want)
100 require.Equal(t, want, fixture.ac.size)102 require.Equal(t, want, fixture.ac.size)
101 require.True(t, fixture.resized())103 require.True(t, fixture.resized())
102 }104 }
105+ metrics := fixture.metrics()
106+ require.Equal(t, uint64(len(sequence)), metrics.resizeRequests)
107+ require.Equal(t, uint64(len(sequence)), metrics.resizeCommits)
108+ require.Equal(t, uint64(len(sequence)), metrics.outputFrames, "one full frame per accepted epoch")
109+ require.Zero(t, metrics.skippedEpochs)
110+ require.Zero(t, metrics.ptyFailures)
111+ require.Zero(t, metrics.frameGapEpochs)
112+ require.NotZero(t, metrics.resizeCommitNanos)
113+ require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 60, Rows: 38}, {Cols: 80, Rows: 48}, {Cols: 40, Rows: 22}}, fixture.pty.requested())
114+}
115+
116+func TestTransactionalResizeMetrics(t *testing.T) {
117+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, ptyErrors: []error{fmt.Errorf("scripted failure"), nil}})
118+ fixture.resizeTo(domain.Size{Cols: 100, Rows: 30})
119+ // The fixture deliberately invokes the coordinator's committed retry path:
120+ // a retry never republishes geometry and its reset produces one extra frame.
121+ fixture.retryLatest()
122+
123+ metrics := fixture.metrics()
124+ require.Equal(t, uint64(1), metrics.resizeRequests)
125+ require.Equal(t, uint64(1), metrics.resizeCommits)
126+ require.Equal(t, uint64(1), metrics.ptyFailures)
127+ require.Equal(t, uint64(1), metrics.ptyRetries)
128+ require.Equal(t, uint64(2), metrics.outputFrames, "commit plus successful full-reset retry")
129+ require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame")
130+ require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 50, Rows: 28}}, fixture.pty.requested())
131+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, fixture.ac.size)
103 }132 }
104 133
105 func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {134 func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
@@ -155,8 +184,29 @@ func BenchmarkDaemonHistoryCopyEnter(b *testing.B) {
155 func BenchmarkDaemonHistoryCopySearch(b *testing.B) {184 func BenchmarkDaemonHistoryCopySearch(b *testing.B) {
156 benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) })185 benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) })
157 }186 }
158-func BenchmarkDaemonHistoryResize(b *testing.B) {187+
159- benchmarkDaemonLargeHistory(b, "resize", func(f *performanceFixture) { f.resize() })188+// BenchmarkDaemonHistoryResizeSweep alternates deterministic grow/shrink
189+// geometry. Metrics are fixture counters; no benchmark result depends on wall
190+// time or scheduler delivery.
191+func BenchmarkDaemonHistoryResizeSweep(b *testing.B) {
192+ 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}}
193+ benchmarkDaemonLargeHistory(b, "resize-sweep", func(f *performanceFixture) {
194+ f.resizeTo(sequence[f.resizes%len(sequence)])
195+ })
196+}
197+
198+// BenchmarkDaemonHistoryResizeRetry measures one deterministic failed apply
199+// followed by its successful full-reset retry for each operation.
200+func BenchmarkDaemonHistoryResizeRetry(b *testing.B) {
201+ benchmarkDaemonLargeHistory(b, "resize-retry", func(f *performanceFixture) {
202+ f.pty.setErrors([]error{fmt.Errorf("scripted resize failure"), nil})
203+ size := domain.Size{Cols: 100, Rows: 30}
204+ if f.resizes%2 != 0 {
205+ size = domain.Size{Cols: 120, Rows: 40}
206+ }
207+ f.resizeTo(size)
208+ f.retryLatest()
209+ })
160 }210 }
161 211
162 func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) {212 func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) {
@@ -237,6 +287,16 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
237 b.ReportMetric(float64(metrics.renderCaptures)/perOperation, "rendercaptures/op")287 b.ReportMetric(float64(metrics.renderCaptures)/perOperation, "rendercaptures/op")
238 b.ReportMetric(float64(metrics.renderCompositions)/perOperation, "rendercompositions/op")288 b.ReportMetric(float64(metrics.renderCompositions)/perOperation, "rendercompositions/op")
239 b.ReportMetric(float64(metrics.renderEmissions)/perOperation, "renderemissions/op")289 b.ReportMetric(float64(metrics.renderEmissions)/perOperation, "renderemissions/op")
290+ // resizecommitns is the injected-clock elapsed time from accepted request
291+ // to committed epoch. The remaining values are counter deltas: skipped is
292+ // accepted minus committed (never negative), framegap is committed epochs
293+ // lacking their required output frame (never negative), and retries/failures
294+ // are PTY Resize outcomes. All divisions are guarded above.
295+ b.ReportMetric(float64(metrics.resizeCommitNanos)/perOperation, "resizecommitns/op")
296+ b.ReportMetric(float64(metrics.skippedEpochs)/perOperation, "skippedepochs/op")
297+ b.ReportMetric(float64(metrics.ptyFailures)/perOperation, "ptyfailures/op")
298+ b.ReportMetric(float64(metrics.ptyRetries)/perOperation, "ptyretries/op")
299+ b.ReportMetric(float64(metrics.frameGapEpochs)/perOperation, "framegapepochs/op")
240 b.ReportMetric(coordinatorCoalescingRatio(metrics), "coordinatorcoalescingratio")300 b.ReportMetric(coordinatorCoalescingRatio(metrics), "coordinatorcoalescingratio")
241 b.ReportMetric(float64(historyRows), "historyrows/pane")301 b.ReportMetric(float64(historyRows), "historyrows/pane")
242 }302 }
@@ -286,6 +346,7 @@ type performanceConfig struct {
286 tabs int346 tabs int
287 panes int347 panes int
288 historyRows int348 historyRows int
349+ ptyErrors []error
289 }350 }
290 351
291 type performanceMetrics struct {352 type performanceMetrics struct {
@@ -300,6 +361,78 @@ type performanceMetrics struct {
300 renderCaptures uint64361 renderCaptures uint64
301 renderCompositions uint64362 renderCompositions uint64
302 renderEmissions uint64363 renderEmissions uint64
364+ resizeRequests uint64
365+ resizeCommits uint64
366+ resizeCommitNanos uint64
367+ skippedEpochs uint64
368+ ptyFailures uint64
369+ ptyRetries uint64
370+ frameGapEpochs uint64
371+}
372+
373+// performanceClock makes transition latency a reproducible counter rather
374+// than a wall-clock observation. A nil timer channel deliberately executes
375+// coordinator work synchronously.
376+type performanceClock struct {
377+ mu sync.Mutex
378+ now time.Time
379+}
380+
381+func (c *performanceClock) Now() time.Time {
382+ c.mu.Lock()
383+ defer c.mu.Unlock()
384+ c.now = c.now.Add(time.Microsecond)
385+ return c.now
386+}
387+func (*performanceClock) NewTimer(time.Duration) ports.Timer { return performanceTimer{} }
388+
389+type performanceTimer struct{}
390+
391+func (performanceTimer) C() <-chan time.Time { return nil }
392+func (performanceTimer) Reset(time.Duration) bool { return true }
393+func (performanceTimer) Stop() bool { return true }
394+
395+// scriptedPerformancePTY records every requested terminal size and returns a
396+// caller-provided error script. It has no reads or scheduling side effects.
397+type scriptedPerformancePTY struct {
398+ mu sync.Mutex
399+ sizes []domain.Size
400+ errs []error
401+ fails uint64
402+}
403+
404+func (p *scriptedPerformancePTY) Resize(size domain.Size) error {
405+ p.mu.Lock()
406+ defer p.mu.Unlock()
407+ p.sizes = append(p.sizes, size)
408+ index := len(p.sizes) - 1
409+ if index < len(p.errs) && p.errs[index] != nil {
410+ p.fails++
411+ return p.errs[index]
412+ }
413+ return nil
414+}
415+func (*scriptedPerformancePTY) Read([]byte) (int, error) { return 0, io.EOF }
416+func (*scriptedPerformancePTY) Write(b []byte) (int, error) { return len(b), nil }
417+func (*scriptedPerformancePTY) Close() error { return nil }
418+func (*scriptedPerformancePTY) Pid() int { return 0 }
419+func (*scriptedPerformancePTY) ForegroundPgid() (int, error) { return 0, nil }
420+func (p *scriptedPerformancePTY) requested() []domain.Size {
421+ p.mu.Lock()
422+ defer p.mu.Unlock()
423+ return append([]domain.Size(nil), p.sizes...)
424+}
425+func (p *scriptedPerformancePTY) metrics() (uint64, uint64) {
426+ p.mu.Lock()
427+ defer p.mu.Unlock()
428+ return p.fails, uint64(len(p.sizes))
429+}
430+func (p *scriptedPerformancePTY) setErrors(errs []error) {
431+ p.mu.Lock()
432+ defer p.mu.Unlock()
433+ p.errs = append([]error(nil), errs...)
434+ p.sizes = nil
435+ p.fails = 0
303 }436 }
304 437
305 type performanceFixture struct {438 type performanceFixture struct {
@@ -315,6 +448,13 @@ type performanceFixture struct {
315 resizeSizes [2]domain.Size448 resizeSizes [2]domain.Size
316 resizes int449 resizes int
317 resizedSize domain.Size450 resizedSize domain.Size
451+ pty *scriptedPerformancePTY
452+ clock *performanceClock
453+ resizeRequests uint64
454+ resizeCommits uint64
455+ resizeCommitNanos uint64
456+ ptyFailures uint64
457+ ptyRetries uint64
318 coordinatorBaseline renderCoordinatorBurstMetricsSnapshot458 coordinatorBaseline renderCoordinatorBurstMetricsSnapshot
319 renderCaptures uint64459 renderCaptures uint64
320 renderCompositions uint64460 renderCompositions uint64
@@ -335,8 +475,12 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
335 if config.historyRows == 0 {475 if config.historyRows == 0 {
336 config.historyRows = 1476 config.historyRows = 1
337 }477 }
478+ pty := &scriptedPerformancePTY{}
338 ptys := make([]ports.PTY, config.tabs)479 ptys := make([]ports.PTY, config.tabs)
480+ ptys[0] = pty
339 d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...)481 d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...)
482+ clock := &performanceClock{}
483+ d.clock = clock
340 output := &countingOutputTransport{}484 output := &countingOutputTransport{}
341 ac.tr = output485 ac.tr = output
342 ac.size = config.size486 ac.size = config.size
@@ -351,6 +495,8 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
351 ac: ac,495 ac: ac,
352 output: output,496 output: output,
353 snaps: &countingSnapshotStore{},497 snaps: &countingSnapshotStore{},
498+ pty: pty,
499+ clock: clock,
354 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},500 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
355 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},501 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
356 }502 }
@@ -359,10 +505,12 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
359 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))505 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))
360 }506 }
361 fixture.activePane = fixture.findActivePane()507 fixture.activePane = fixture.findActivePane()
508+ // Fixture construction may solve the initial layout; benchmark scripts begin
509+ // after that setup work and therefore have an empty call history.
510+ pty.setErrors(config.ptyErrors)
362 511
363- // The fixture uses the production coordinator. stubClock's inert timer is512+ // The fixture uses the production coordinator. performanceClock's nil timer
364- // completed synchronously by the coordinator, keeping benchmark operations513+ // channel completes the coordinator path synchronously and deterministically.
365- // deterministic while retaining their real invalidation path.
366 ac.renderStages = renderStageHooks{514 ac.renderStages = renderStageHooks{
367 capture: func() { fixture.renderCaptures++ },515 capture: func() { fixture.renderCaptures++ },
368 compose: func() { fixture.renderCompositions++ },516 compose: func() { fixture.renderCompositions++ },
@@ -457,9 +605,41 @@ func (f *performanceFixture) searchMatches() int {
457 }605 }
458 606
459 func (f *performanceFixture) resize() {607 func (f *performanceFixture) resize() {
460- f.resizedSize = f.resizeSizes[f.resizes%len(f.resizeSizes)]608+ f.resizeTo(f.resizeSizes[f.resizes%len(f.resizeSizes)])
609+}
610+
611+func (f *performanceFixture) resizeTo(size domain.Size) {
612+ f.resizedSize = size
461 f.resizes++613 f.resizes++
462- f.d.resize(f.sess, f.ac, f.resizedSize)614+ before := f.sess.renderCoordinator().resizeSnapshot()
615+ start := f.clock.Now()
616+ failuresBefore, callsBefore := f.pty.metrics()
617+ f.d.resize(f.sess, f.ac, size)
618+ after := f.sess.renderCoordinator().resizeSnapshot()
619+ if after.epoch > before.epoch {
620+ f.resizeRequests += after.epoch - before.epoch
621+ }
622+ if after.committed > before.committed {
623+ f.resizeCommits += after.committed - before.committed
624+ f.resizeCommitNanos += uint64(f.clock.Now().Sub(start).Nanoseconds())
625+ }
626+ failuresAfter, callsAfter := f.pty.metrics()
627+ f.ptyFailures += failuresAfter - failuresBefore
628+ if callsAfter > callsBefore+1 {
629+ f.ptyRetries += callsAfter - callsBefore - 1
630+ }
631+}
632+
633+func (f *performanceFixture) retryLatest() {
634+ epoch := f.sess.renderCoordinator().resizeSnapshot().committed
635+ plan := f.d.prepareResize(f.sess, f.ac.size)
636+ failuresBefore, callsBefore := f.pty.metrics()
637+ f.d.retryResizeMembers(f.sess, f.ac, epoch, plan.members)
638+ failuresAfter, callsAfter := f.pty.metrics()
639+ f.ptyFailures += failuresAfter - failuresBefore
640+ if callsAfter > callsBefore {
641+ f.ptyRetries += callsAfter - callsBefore
642+ }
463 }643 }
464 644
465 func (f *performanceFixture) resized() bool {645 func (f *performanceFixture) resized() bool {
@@ -483,6 +663,11 @@ func (f *performanceFixture) resetMetrics() {
483 f.renderCaptures = 0663 f.renderCaptures = 0
484 f.renderCompositions = 0664 f.renderCompositions = 0
485 f.renderEmissions = 0665 f.renderEmissions = 0
666+ f.resizeRequests = 0
667+ f.resizeCommits = 0
668+ f.resizeCommitNanos = 0
669+ f.ptyFailures = 0
670+ f.ptyRetries = 0
486 if rc := f.sess.renderCoordinator(); rc != nil {671 if rc := f.sess.renderCoordinator(); rc != nil {
487 f.coordinatorBaseline = rc.burstMetricsSnapshot()672 f.coordinatorBaseline = rc.burstMetricsSnapshot()
488 }673 }
@@ -495,6 +680,14 @@ func (f *performanceFixture) metrics() performanceMetrics {
495 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,680 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
496 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,681 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,
497 renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions,682 renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions,
683+ resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos,
684+ ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries,
685+ }
686+ if metrics.resizeRequests > metrics.resizeCommits {
687+ metrics.skippedEpochs = metrics.resizeRequests - metrics.resizeCommits
688+ }
689+ if metrics.resizeCommits > metrics.outputFrames {
690+ metrics.frameGapEpochs = metrics.resizeCommits - metrics.outputFrames
498 }691 }
499 if rc := f.sess.renderCoordinator(); rc != nil {692 if rc := f.sess.renderCoordinator(); rc != nil {
500 coordinator := rc.burstMetricsSnapshot()693 coordinator := rc.burstMetricsSnapshot()

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close