Merge pull request #164 from bnema/perf/proxy-screen-baseline
perf(proxy): establish screen-stream baseline
13 files · +663 -45
M cmd/vev-perf-harness/evidence.go +15 -10
| @@ -134,16 +134,21 @@ func pairedSamples(marks []harnessMark) ([]int64, error) { | |||
| 134 | 134 | // traceRecord is intentionally local to the command. It mirrors JSONL fields | |
| 135 | 135 | // needed for correlation but never exposes a clock to any production component. | |
| 136 | 136 | type traceRecord struct { | |
| 137 | - | ProcessID string `json:"process_id"` | |
| 138 | - | Component string `json:"component"` | |
| 139 | - | Scenario string `json:"scenario"` | |
| 140 | - | Run uint64 `json:"run"` | |
| 141 | - | Sequence uint64 `json:"sequence"` | |
| 142 | - | RequestID uint64 `json:"request_id"` | |
| 143 | - | Epoch uint64 `json:"epoch"` | |
| 144 | - | Kind string `json:"kind"` | |
| 145 | - | Tick int64 `json:"tick"` | |
| 146 | - | Valid bool `json:"valid"` | |
| 137 | + | ProcessID string `json:"process_id"` | |
| 138 | + | Component string `json:"component"` | |
| 139 | + | Scenario string `json:"scenario"` | |
| 140 | + | Run uint64 `json:"run"` | |
| 141 | + | Sequence uint64 `json:"sequence"` | |
| 142 | + | RequestID uint64 `json:"request_id"` | |
| 143 | + | Epoch uint64 `json:"epoch"` | |
| 144 | + | Kind string `json:"kind"` | |
| 145 | + | Tick int64 `json:"tick"` | |
| 146 | + | Bytes uint64 `json:"bytes"` | |
| 147 | + | Fragments uint64 `json:"fragments"` | |
| 148 | + | Retransmits uint64 `json:"retransmits"` | |
| 149 | + | Pending uint64 `json:"pending"` | |
| 150 | + | AckRTTNanos int64 `json:"ack_rtt_nanos"` | |
| 151 | + | Valid bool `json:"valid"` | |
| 147 | 152 | } | |
| 148 | 153 | ||
| 149 | 154 | type spanPair struct{ start, end, name string } | |
| @@ -134,16 +134,21 @@ func pairedSamples(marks []harnessMark) ([]int64, error) { | |||||
| 134 | // traceRecord is intentionally local to the command. It mirrors JSONL fields | 134 | // traceRecord is intentionally local to the command. It mirrors JSONL fields | ||
| 135 | // needed for correlation but never exposes a clock to any production component. | 135 | // needed for correlation but never exposes a clock to any production component. | ||
| 136 | type traceRecord struct { | 136 | type traceRecord struct { | ||
| 137 | - | ProcessID string `json:"process_id"` | 137 | + | ProcessID string `json:"process_id"` |
| 138 | - | Component string `json:"component"` | 138 | + | Component string `json:"component"` |
| 139 | - | Scenario string `json:"scenario"` | 139 | + | Scenario string `json:"scenario"` |
| 140 | - | Run uint64 `json:"run"` | 140 | + | Run uint64 `json:"run"` |
| 141 | - | Sequence uint64 `json:"sequence"` | 141 | + | Sequence uint64 `json:"sequence"` |
| 142 | - | RequestID uint64 `json:"request_id"` | 142 | + | RequestID uint64 `json:"request_id"` |
| 143 | - | Epoch uint64 `json:"epoch"` | 143 | + | Epoch uint64 `json:"epoch"` |
| 144 | - | Kind string `json:"kind"` | 144 | + | Kind string `json:"kind"` |
| 145 | - | Tick int64 `json:"tick"` | 145 | + | Tick int64 `json:"tick"` |
| 146 | - | Valid bool `json:"valid"` | 146 | + | Bytes uint64 `json:"bytes"` |
| 147 | + | Fragments uint64 `json:"fragments"` | |||
| 148 | + | Retransmits uint64 `json:"retransmits"` | |||
| 149 | + | Pending uint64 `json:"pending"` | |||
| 150 | + | AckRTTNanos int64 `json:"ack_rtt_nanos"` | |||
| 151 | + | Valid bool `json:"valid"` | |||
| 147 | } | 152 | } | ||
| 148 | 153 | ||||
| 149 | type spanPair struct{ start, end, name string } | 154 | type spanPair struct{ start, end, name string } | ||
M cmd/vev-perf-harness/evidence_test.go +23 -1
| @@ -11,6 +11,8 @@ import ( | |||
| 11 | 11 | "strings" | |
| 12 | 12 | "testing" | |
| 13 | 13 | "time" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/require" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | 18 | func TestCLITransportSeamOwnsExclusivePeerTraceAndCleanup(t *testing.T) { | |
| @@ -252,7 +254,13 @@ func TestHarnessWritesRequiredEvidenceWithSufficientSpans(t *testing.T) { | |||
| 252 | 254 | h := defaultHarness() | |
| 253 | 255 | h.clock = &fakeClock{} | |
| 254 | 256 | h.launcher = l | |
| 255 | - | h.gitSHA = func() string { return "test-sha" } | |
| 257 | + | h.gitSHA = func() string { return "harness-sha" } | |
| 258 | + | h.vevGitSHA = func(path string) string { | |
| 259 | + | if !filepath.IsAbs(path) { | |
| 260 | + | t.Fatalf("measured binary path is not resolved: %q", path) | |
| 261 | + | } | |
| 262 | + | return "measured-vev-sha" | |
| 263 | + | } | |
| 256 | 264 | if err := run([]string{"--vev-bin", "ignored", "--manifest", manifestPath, "--out", filepath.Join(dir, "out"), "--warmup", "1s", "--duration", "30s", "--repetitions", "10"}, h); err != nil { | |
| 257 | 265 | t.Fatal(err) | |
| 258 | 266 | } | |
| @@ -269,6 +277,9 @@ func TestHarnessWritesRequiredEvidenceWithSufficientSpans(t *testing.T) { | |||
| 269 | 277 | if err := json.Unmarshal(b, &got); err != nil { | |
| 270 | 278 | t.Fatal(err) | |
| 271 | 279 | } | |
| 280 | + | if got.GitSHA != "harness-sha" || got.VevGitSHA != "measured-vev-sha" { | |
| 281 | + | t.Fatalf("summary provenance = harness %q measured vev %q", got.GitSHA, got.VevGitSHA) | |
| 282 | + | } | |
| 272 | 283 | if got.EndToEnd.Count < 10*minimumInIntervalEventSamples || got.Repetitions != 10 || got.RunP50Dispersion.Count != 10 || got.Cadence.Count == 0 || got.MaxGap == 0 || len(got.Spans) == 0 { | |
| 273 | 284 | t.Fatalf("summary=%+v", got) | |
| 274 | 285 | } | |
| @@ -279,6 +290,17 @@ func TestHarnessWritesRequiredEvidenceWithSufficientSpans(t *testing.T) { | |||
| 279 | 290 | } | |
| 280 | 291 | } | |
| 281 | 292 | ||
| 293 | + | func TestTraceRecordRetainsTransportDiagnostics(t *testing.T) { | |
| 294 | + | line := []byte(`{"process_id":"p","component":"udp","scenario":"s","run":1,"sequence":1,"request_id":1,"epoch":1,"kind":"transport_diagnostic","tick":2,"bytes":3,"fragments":4,"retransmits":5,"pending":6,"ack_rtt_nanos":7,"valid":true}`) | |
| 295 | + | var got traceRecord | |
| 296 | + | require.NoError(t, json.Unmarshal(line, &got)) | |
| 297 | + | require.Equal(t, uint64(3), got.Bytes) | |
| 298 | + | require.Equal(t, uint64(4), got.Fragments) | |
| 299 | + | require.Equal(t, uint64(5), got.Retransmits) | |
| 300 | + | require.Equal(t, uint64(6), got.Pending) | |
| 301 | + | require.Equal(t, int64(7), got.AckRTTNanos) | |
| 302 | + | } | |
| 303 | + | ||
| 282 | 304 | func TestHarnessAcceptsConcurrentProductionTraceInterleaving(t *testing.T) { | |
| 283 | 305 | path := filepath.Join(t.TempDir(), "local-client.jsonl") | |
| 284 | 306 | if err := os.WriteFile(path, []byte(boundedLocalConcurrentTrace), 0o600); err != nil { | |
| @@ -11,6 +11,8 @@ import ( | |||||
| 11 | "strings" | 11 | "strings" | ||
| 12 | "testing" | 12 | "testing" | ||
| 13 | "time" | 13 | "time" | ||
| 14 | + | ||||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 14 | ) | 16 | ) | ||
| 15 | 17 | ||||
| 16 | func TestCLITransportSeamOwnsExclusivePeerTraceAndCleanup(t *testing.T) { | 18 | func TestCLITransportSeamOwnsExclusivePeerTraceAndCleanup(t *testing.T) { | ||
| @@ -252,7 +254,13 @@ func TestHarnessWritesRequiredEvidenceWithSufficientSpans(t *testing.T) { | |||||
| 252 | h := defaultHarness() | 254 | h := defaultHarness() | ||
| 253 | h.clock = &fakeClock{} | 255 | h.clock = &fakeClock{} | ||
| 254 | h.launcher = l | 256 | h.launcher = l | ||
| 255 | - | h.gitSHA = func() string { return "test-sha" } | 257 | + | h.gitSHA = func() string { return "harness-sha" } |
| 258 | + | h.vevGitSHA = func(path string) string { | |||
| 259 | + | if !filepath.IsAbs(path) { | |||
| 260 | + | t.Fatalf("measured binary path is not resolved: %q", path) | |||
| 261 | + | } | |||
| 262 | + | return "measured-vev-sha" | |||
| 263 | + | } | |||
| 256 | if err := run([]string{"--vev-bin", "ignored", "--manifest", manifestPath, "--out", filepath.Join(dir, "out"), "--warmup", "1s", "--duration", "30s", "--repetitions", "10"}, h); err != nil { | 264 | if err := run([]string{"--vev-bin", "ignored", "--manifest", manifestPath, "--out", filepath.Join(dir, "out"), "--warmup", "1s", "--duration", "30s", "--repetitions", "10"}, h); err != nil { | ||
| 257 | t.Fatal(err) | 265 | t.Fatal(err) | ||
| 258 | } | 266 | } | ||
| @@ -269,6 +277,9 @@ func TestHarnessWritesRequiredEvidenceWithSufficientSpans(t *testing.T) { | |||||
| 269 | if err := json.Unmarshal(b, &got); err != nil { | 277 | if err := json.Unmarshal(b, &got); err != nil { | ||
| 270 | t.Fatal(err) | 278 | t.Fatal(err) | ||
| 271 | } | 279 | } | ||
| 280 | + | if got.GitSHA != "harness-sha" || got.VevGitSHA != "measured-vev-sha" { | |||
| 281 | + | t.Fatalf("summary provenance = harness %q measured vev %q", got.GitSHA, got.VevGitSHA) | |||
| 282 | + | } | |||
| 272 | if got.EndToEnd.Count < 10*minimumInIntervalEventSamples || got.Repetitions != 10 || got.RunP50Dispersion.Count != 10 || got.Cadence.Count == 0 || got.MaxGap == 0 || len(got.Spans) == 0 { | 283 | if got.EndToEnd.Count < 10*minimumInIntervalEventSamples || got.Repetitions != 10 || got.RunP50Dispersion.Count != 10 || got.Cadence.Count == 0 || got.MaxGap == 0 || len(got.Spans) == 0 { | ||
| 273 | t.Fatalf("summary=%+v", got) | 284 | t.Fatalf("summary=%+v", got) | ||
| 274 | } | 285 | } | ||
| @@ -279,6 +290,17 @@ func TestHarnessWritesRequiredEvidenceWithSufficientSpans(t *testing.T) { | |||||
| 279 | } | 290 | } | ||
| 280 | } | 291 | } | ||
| 281 | 292 | ||||
| 293 | + | func TestTraceRecordRetainsTransportDiagnostics(t *testing.T) { | |||
| 294 | + | line := []byte(`{"process_id":"p","component":"udp","scenario":"s","run":1,"sequence":1,"request_id":1,"epoch":1,"kind":"transport_diagnostic","tick":2,"bytes":3,"fragments":4,"retransmits":5,"pending":6,"ack_rtt_nanos":7,"valid":true}`) | |||
| 295 | + | var got traceRecord | |||
| 296 | + | require.NoError(t, json.Unmarshal(line, &got)) | |||
| 297 | + | require.Equal(t, uint64(3), got.Bytes) | |||
| 298 | + | require.Equal(t, uint64(4), got.Fragments) | |||
| 299 | + | require.Equal(t, uint64(5), got.Retransmits) | |||
| 300 | + | require.Equal(t, uint64(6), got.Pending) | |||
| 301 | + | require.Equal(t, int64(7), got.AckRTTNanos) | |||
| 302 | + | } | |||
| 303 | + | ||||
| 282 | func TestHarnessAcceptsConcurrentProductionTraceInterleaving(t *testing.T) { | 304 | func TestHarnessAcceptsConcurrentProductionTraceInterleaving(t *testing.T) { | ||
| 283 | path := filepath.Join(t.TempDir(), "local-client.jsonl") | 305 | path := filepath.Join(t.TempDir(), "local-client.jsonl") | ||
| 284 | if err := os.WriteFile(path, []byte(boundedLocalConcurrentTrace), 0o600); err != nil { | 306 | if err := os.WriteFile(path, []byte(boundedLocalConcurrentTrace), 0o600); err != nil { | ||
M cmd/vev-perf-harness/launcher.go +25 -6
| @@ -116,6 +116,8 @@ type terminalOutput interface { | |||
| 116 | 116 | Close() error | |
| 117 | 117 | } | |
| 118 | 118 | ||
| 119 | + | var setPTYWinsize = rawterm.SetWinsize | |
| 120 | + | ||
| 119 | 121 | type cliProcess struct { | |
| 120 | 122 | cmd *exec.Cmd | |
| 121 | 123 | pty io.ReadWriteCloser | |
| @@ -135,19 +137,31 @@ type cliProcess struct { | |||
| 135 | 137 | closeResult error | |
| 136 | 138 | } | |
| 137 | 139 | ||
| 140 | + | func (p *cliProcess) Resize(cols, rows uint16) error { | |
| 141 | + | fd, ok := p.pty.(interface{ Fd() uintptr }) | |
| 142 | + | if !ok { | |
| 143 | + | return errors.New("client PTY does not expose a file descriptor") | |
| 144 | + | } | |
| 145 | + | return setPTYWinsize(int(fd.Fd()), cols, rows) | |
| 146 | + | } | |
| 147 | + | ||
| 138 | 148 | func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProcess, error) { | |
| 139 | 149 | if len(role.Args) == 0 { | |
| 140 | 150 | return nil, errors.New("public CLI role has no arguments") | |
| 141 | 151 | } | |
| 142 | - | if info, err := os.Stat(l.bin); err != nil { | |
| 152 | + | bin, err := filepath.Abs(l.bin) | |
| 153 | + | if err != nil { | |
| 154 | + | return nil, err | |
| 155 | + | } | |
| 156 | + | if info, err := os.Stat(bin); err != nil { | |
| 143 | 157 | return nil, err | |
| 144 | 158 | } else if info.IsDir() { | |
| 145 | - | return nil, fmt.Errorf("public CLI binary %q is a directory", l.bin) | |
| 159 | + | return nil, fmt.Errorf("public CLI binary %q is a directory", bin) | |
| 146 | 160 | } | |
| 147 | 161 | if m.Role == "ssh_stdio_peer" || m.Role == "udp_peer" { | |
| 148 | 162 | return l.preparePeer(m, role) | |
| 149 | 163 | } | |
| 150 | - | cmd := exec.Command(l.bin, role.Args...) | |
| 164 | + | cmd := exec.Command(bin, role.Args...) | |
| 151 | 165 | runDir := filepath.Dir(m.TracePath) | |
| 152 | 166 | runtimeDir, err := l.runtimeDir(runDir) | |
| 153 | 167 | if err != nil { | |
| @@ -157,9 +171,9 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||
| 157 | 171 | // a role working directory: daemon-owned subprocess cleanup may otherwise | |
| 158 | 172 | // treat the evidence directory as its working tree and remove a preallocated | |
| 159 | 173 | // trace while a later repetition is being merged. | |
| 160 | - | cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_REMOTE_TRANSPORT", "XDG_RUNTIME_DIR", "XDG_STATE_HOME"), traceEnvironment(m)...) | |
| 174 | + | cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_PERF_BIN", "VEV_REMOTE_TRANSPORT", "XDG_RUNTIME_DIR", "XDG_STATE_HOME"), traceEnvironment(m)...) | |
| 161 | 175 | cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir, | |
| 162 | - | "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "TERM=xterm-256color") | |
| 176 | + | "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "TERM=xterm-256color", "VEV_PERF_BIN="+bin) | |
| 163 | 177 | if m.Role == "client" { | |
| 164 | 178 | l.mu.Lock() | |
| 165 | 179 | peer, routed := l.peers[runDir] | |
| @@ -184,6 +198,11 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||
| 184 | 198 | if err != nil { | |
| 185 | 199 | return nil, err | |
| 186 | 200 | } | |
| 201 | + | if err := setPTYWinsize(int(master.Fd()), 120, 40); err != nil { | |
| 202 | + | _ = master.Close() | |
| 203 | + | _ = slave.Close() | |
| 204 | + | return nil, fmt.Errorf("set canonical client PTY geometry: %w", err) | |
| 205 | + | } | |
| 187 | 206 | output, err := os.OpenFile(filepath.Join(filepath.Dir(m.TracePath), "terminal-output"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) | |
| 188 | 207 | if err != nil { | |
| 189 | 208 | _ = master.Close() | |
| @@ -236,7 +255,7 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||
| 236 | 255 | p.shutdown = func() error { | |
| 237 | 256 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 238 | 257 | defer cancel() | |
| 239 | - | shutdown := exec.CommandContext(ctx, l.bin, "kill", "--daemon") | |
| 258 | + | shutdown := exec.CommandContext(ctx, bin, "kill", "--daemon") | |
| 240 | 259 | shutdown.Env = withoutEnv(cmd.Env, "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN") | |
| 241 | 260 | return shutdown.Run() | |
| 242 | 261 | } | |
| @@ -116,6 +116,8 @@ type terminalOutput interface { | |||||
| 116 | Close() error | 116 | Close() error | ||
| 117 | } | 117 | } | ||
| 118 | 118 | ||||
| 119 | + | var setPTYWinsize = rawterm.SetWinsize | |||
| 120 | + | ||||
| 119 | type cliProcess struct { | 121 | type cliProcess struct { | ||
| 120 | cmd *exec.Cmd | 122 | cmd *exec.Cmd | ||
| 121 | pty io.ReadWriteCloser | 123 | pty io.ReadWriteCloser | ||
| @@ -135,19 +137,31 @@ type cliProcess struct { | |||||
| 135 | closeResult error | 137 | closeResult error | ||
| 136 | } | 138 | } | ||
| 137 | 139 | ||||
| 140 | + | func (p *cliProcess) Resize(cols, rows uint16) error { | |||
| 141 | + | fd, ok := p.pty.(interface{ Fd() uintptr }) | |||
| 142 | + | if !ok { | |||
| 143 | + | return errors.New("client PTY does not expose a file descriptor") | |||
| 144 | + | } | |||
| 145 | + | return setPTYWinsize(int(fd.Fd()), cols, rows) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 138 | func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProcess, error) { | 148 | func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProcess, error) { | ||
| 139 | if len(role.Args) == 0 { | 149 | if len(role.Args) == 0 { | ||
| 140 | return nil, errors.New("public CLI role has no arguments") | 150 | return nil, errors.New("public CLI role has no arguments") | ||
| 141 | } | 151 | } | ||
| 142 | - | if info, err := os.Stat(l.bin); err != nil { | 152 | + | bin, err := filepath.Abs(l.bin) |
| 153 | + | if err != nil { | |||
| 154 | + | return nil, err | |||
| 155 | + | } | |||
| 156 | + | if info, err := os.Stat(bin); err != nil { | |||
| 143 | return nil, err | 157 | return nil, err | ||
| 144 | } else if info.IsDir() { | 158 | } else if info.IsDir() { | ||
| 145 | - | return nil, fmt.Errorf("public CLI binary %q is a directory", l.bin) | 159 | + | return nil, fmt.Errorf("public CLI binary %q is a directory", bin) |
| 146 | } | 160 | } | ||
| 147 | if m.Role == "ssh_stdio_peer" || m.Role == "udp_peer" { | 161 | if m.Role == "ssh_stdio_peer" || m.Role == "udp_peer" { | ||
| 148 | return l.preparePeer(m, role) | 162 | return l.preparePeer(m, role) | ||
| 149 | } | 163 | } | ||
| 150 | - | cmd := exec.Command(l.bin, role.Args...) | 164 | + | cmd := exec.Command(bin, role.Args...) |
| 151 | runDir := filepath.Dir(m.TracePath) | 165 | runDir := filepath.Dir(m.TracePath) | ||
| 152 | runtimeDir, err := l.runtimeDir(runDir) | 166 | runtimeDir, err := l.runtimeDir(runDir) | ||
| 153 | if err != nil { | 167 | if err != nil { | ||
| @@ -157,9 +171,9 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||||
| 157 | // a role working directory: daemon-owned subprocess cleanup may otherwise | 171 | // a role working directory: daemon-owned subprocess cleanup may otherwise | ||
| 158 | // treat the evidence directory as its working tree and remove a preallocated | 172 | // treat the evidence directory as its working tree and remove a preallocated | ||
| 159 | // trace while a later repetition is being merged. | 173 | // trace while a later repetition is being merged. | ||
| 160 | - | cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_REMOTE_TRANSPORT", "XDG_RUNTIME_DIR", "XDG_STATE_HOME"), traceEnvironment(m)...) | 174 | + | cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_PERF_BIN", "VEV_REMOTE_TRANSPORT", "XDG_RUNTIME_DIR", "XDG_STATE_HOME"), traceEnvironment(m)...) |
| 161 | cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir, | 175 | cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir, | ||
| 162 | - | "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "TERM=xterm-256color") | 176 | + | "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "TERM=xterm-256color", "VEV_PERF_BIN="+bin) |
| 163 | if m.Role == "client" { | 177 | if m.Role == "client" { | ||
| 164 | l.mu.Lock() | 178 | l.mu.Lock() | ||
| 165 | peer, routed := l.peers[runDir] | 179 | peer, routed := l.peers[runDir] | ||
| @@ -184,6 +198,11 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||||
| 184 | if err != nil { | 198 | if err != nil { | ||
| 185 | return nil, err | 199 | return nil, err | ||
| 186 | } | 200 | } | ||
| 201 | + | if err := setPTYWinsize(int(master.Fd()), 120, 40); err != nil { | |||
| 202 | + | _ = master.Close() | |||
| 203 | + | _ = slave.Close() | |||
| 204 | + | return nil, fmt.Errorf("set canonical client PTY geometry: %w", err) | |||
| 205 | + | } | |||
| 187 | output, err := os.OpenFile(filepath.Join(filepath.Dir(m.TracePath), "terminal-output"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) | 206 | output, err := os.OpenFile(filepath.Join(filepath.Dir(m.TracePath), "terminal-output"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) | ||
| 188 | if err != nil { | 207 | if err != nil { | ||
| 189 | _ = master.Close() | 208 | _ = master.Close() | ||
| @@ -236,7 +255,7 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||||
| 236 | p.shutdown = func() error { | 255 | p.shutdown = func() error { | ||
| 237 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | 256 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | ||
| 238 | defer cancel() | 257 | defer cancel() | ||
| 239 | - | shutdown := exec.CommandContext(ctx, l.bin, "kill", "--daemon") | 258 | + | shutdown := exec.CommandContext(ctx, bin, "kill", "--daemon") |
| 240 | shutdown.Env = withoutEnv(cmd.Env, "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN") | 259 | shutdown.Env = withoutEnv(cmd.Env, "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN") | ||
| 241 | return shutdown.Run() | 260 | return shutdown.Run() | ||
| 242 | } | 261 | } | ||
M cmd/vev-perf-harness/launcher_test.go +114 -0
| @@ -5,6 +5,7 @@ package main | |||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | 7 | "context" | |
| 8 | + | "errors" | |
| 8 | 9 | "fmt" | |
| 9 | 10 | "net" | |
| 10 | 11 | "os" | |
| @@ -12,6 +13,7 @@ import ( | |||
| 12 | 13 | "path/filepath" | |
| 13 | 14 | "strings" | |
| 14 | 15 | "sync" | |
| 16 | + | "syscall" | |
| 15 | 17 | "testing" | |
| 16 | 18 | "time" | |
| 17 | 19 | ||
| @@ -24,6 +26,118 @@ func (discardTerminalOutput) Write(p []byte) (int, error) { return len(p), nil } | |||
| 24 | 26 | func (discardTerminalOutput) Sync() error { return nil } | |
| 25 | 27 | func (discardTerminalOutput) Close() error { return nil } | |
| 26 | 28 | ||
| 29 | + | type fakeFDPTY struct { | |
| 30 | + | *fakePTY | |
| 31 | + | fd uintptr | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (p *fakeFDPTY) Fd() uintptr { return p.fd } | |
| 35 | + | ||
| 36 | + | func TestCLILauncherSetsCanonicalClientGeometryBeforeStart(t *testing.T) { | |
| 37 | + | original := setPTYWinsize | |
| 38 | + | t.Cleanup(func() { setPTYWinsize = original }) | |
| 39 | + | ||
| 40 | + | for _, tt := range []struct { | |
| 41 | + | name string | |
| 42 | + | setSize func(started string, fd int, cols, rows uint16) error | |
| 43 | + | wantErr bool | |
| 44 | + | }{ | |
| 45 | + | { | |
| 46 | + | name: "starts after canonical geometry", | |
| 47 | + | setSize: func(started string, _ int, cols, rows uint16) error { | |
| 48 | + | if _, err := os.Stat(started); !errors.Is(err, os.ErrNotExist) { | |
| 49 | + | return fmt.Errorf("client started before geometry was set: %w", err) | |
| 50 | + | } | |
| 51 | + | if cols != 120 || rows != 40 { | |
| 52 | + | return fmt.Errorf("geometry = %dx%d, want 120x40", cols, rows) | |
| 53 | + | } | |
| 54 | + | return nil | |
| 55 | + | }, | |
| 56 | + | }, | |
| 57 | + | { | |
| 58 | + | name: "winsize failure closes PTY before returning", | |
| 59 | + | setSize: func(started string, fd int, _, _ uint16) error { | |
| 60 | + | if _, err := os.Stat(started); !errors.Is(err, os.ErrNotExist) { | |
| 61 | + | return fmt.Errorf("client started after winsize failure: %w", err) | |
| 62 | + | } | |
| 63 | + | return errTestWinsize | |
| 64 | + | }, | |
| 65 | + | wantErr: true, | |
| 66 | + | }, | |
| 67 | + | } { | |
| 68 | + | t.Run(tt.name, func(t *testing.T) { | |
| 69 | + | started := filepath.Join(t.TempDir(), "started") | |
| 70 | + | t.Setenv("STARTED", started) | |
| 71 | + | bin := filepath.Join(t.TempDir(), "client") | |
| 72 | + | if err := os.WriteFile(bin, []byte("#!/bin/sh\ntouch \"$STARTED\"\n"), 0o700); err != nil { | |
| 73 | + | t.Fatal(err) | |
| 74 | + | } | |
| 75 | + | runDir := filepath.Join(t.TempDir(), "run") | |
| 76 | + | if err := safedir.EnsurePrivate(runDir); err != nil { | |
| 77 | + | t.Fatal(err) | |
| 78 | + | } | |
| 79 | + | setFD := -1 | |
| 80 | + | setPTYWinsize = func(fd int, cols, rows uint16) error { | |
| 81 | + | setFD = fd | |
| 82 | + | return tt.setSize(started, fd, cols, rows) | |
| 83 | + | } | |
| 84 | + | launcher := &cliLauncher{bin: bin} | |
| 85 | + | t.Cleanup(func() { | |
| 86 | + | if err := launcher.releaseRuntime(runDir); err != nil { | |
| 87 | + | t.Error(err) | |
| 88 | + | } | |
| 89 | + | }) | |
| 90 | + | p, err := launcher.Launch(processMapping{Role: "client", TracePath: filepath.Join(runDir, "client.jsonl")}, roleCommand{Args: []string{"test"}}) | |
| 91 | + | if tt.wantErr { | |
| 92 | + | if !errors.Is(err, errTestWinsize) || p != nil { | |
| 93 | + | t.Fatalf("Launch() = (%T, %v), want winsize error", p, err) | |
| 94 | + | } | |
| 95 | + | if setFD < 0 { | |
| 96 | + | t.Fatal("winsize setter was not called") | |
| 97 | + | } | |
| 98 | + | if closeErr := syscall.Close(setFD); !errors.Is(closeErr, syscall.EBADF) { | |
| 99 | + | t.Fatalf("winsize failure left PTY fd %d open: close error %v", setFD, closeErr) | |
| 100 | + | } | |
| 101 | + | return | |
| 102 | + | } | |
| 103 | + | if err != nil { | |
| 104 | + | t.Fatal(err) | |
| 105 | + | } | |
| 106 | + | if setFD < 0 { | |
| 107 | + | t.Fatal("winsize setter was not called before successful launch") | |
| 108 | + | } | |
| 109 | + | if err := p.Close(); err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | }) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | var errTestWinsize = errors.New("test winsize failure") | |
| 117 | + | ||
| 118 | + | func TestCLIProcessResize(t *testing.T) { | |
| 119 | + | original := setPTYWinsize | |
| 120 | + | t.Cleanup(func() { setPTYWinsize = original }) | |
| 121 | + | ||
| 122 | + | var gotFD int | |
| 123 | + | var gotCols, gotRows uint16 | |
| 124 | + | setPTYWinsize = func(fd int, cols, rows uint16) error { | |
| 125 | + | gotFD, gotCols, gotRows = fd, cols, rows | |
| 126 | + | return nil | |
| 127 | + | } | |
| 128 | + | p := &cliProcess{pty: &fakeFDPTY{fakePTY: &fakePTY{}, fd: 42}} | |
| 129 | + | if err := p.Resize(119, 40); err != nil { | |
| 130 | + | t.Fatal(err) | |
| 131 | + | } | |
| 132 | + | if gotFD != 42 || gotCols != 119 || gotRows != 40 { | |
| 133 | + | t.Fatalf("winsize setter got fd=%d cols=%d rows=%d", gotFD, gotCols, gotRows) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | if err := (&cliProcess{pty: &fakePTY{}}).Resize(119, 40); err == nil || !strings.Contains(err.Error(), "file descriptor") { | |
| 137 | + | t.Fatalf("Resize without FD error = %v", err) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 27 | 141 | func TestCLIProcessCloseDrainsQueuedTerminalOutputBeforeClientExit(t *testing.T) { | |
| 28 | 142 | harnessPTY, clientPTY := net.Pipe() | |
| 29 | 143 | t.Cleanup(func() { _ = clientPTY.Close() }) | |
| @@ -5,6 +5,7 @@ package main | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bytes" | 6 | "bytes" | ||
| 7 | "context" | 7 | "context" | ||
| 8 | + | "errors" | |||
| 8 | "fmt" | 9 | "fmt" | ||
| 9 | "net" | 10 | "net" | ||
| 10 | "os" | 11 | "os" | ||
| @@ -12,6 +13,7 @@ import ( | |||||
| 12 | "path/filepath" | 13 | "path/filepath" | ||
| 13 | "strings" | 14 | "strings" | ||
| 14 | "sync" | 15 | "sync" | ||
| 16 | + | "syscall" | |||
| 15 | "testing" | 17 | "testing" | ||
| 16 | "time" | 18 | "time" | ||
| 17 | 19 | ||||
| @@ -24,6 +26,118 @@ func (discardTerminalOutput) Write(p []byte) (int, error) { return len(p), nil } | |||||
| 24 | func (discardTerminalOutput) Sync() error { return nil } | 26 | func (discardTerminalOutput) Sync() error { return nil } | ||
| 25 | func (discardTerminalOutput) Close() error { return nil } | 27 | func (discardTerminalOutput) Close() error { return nil } | ||
| 26 | 28 | ||||
| 29 | + | type fakeFDPTY struct { | |||
| 30 | + | *fakePTY | |||
| 31 | + | fd uintptr | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (p *fakeFDPTY) Fd() uintptr { return p.fd } | |||
| 35 | + | ||||
| 36 | + | func TestCLILauncherSetsCanonicalClientGeometryBeforeStart(t *testing.T) { | |||
| 37 | + | original := setPTYWinsize | |||
| 38 | + | t.Cleanup(func() { setPTYWinsize = original }) | |||
| 39 | + | ||||
| 40 | + | for _, tt := range []struct { | |||
| 41 | + | name string | |||
| 42 | + | setSize func(started string, fd int, cols, rows uint16) error | |||
| 43 | + | wantErr bool | |||
| 44 | + | }{ | |||
| 45 | + | { | |||
| 46 | + | name: "starts after canonical geometry", | |||
| 47 | + | setSize: func(started string, _ int, cols, rows uint16) error { | |||
| 48 | + | if _, err := os.Stat(started); !errors.Is(err, os.ErrNotExist) { | |||
| 49 | + | return fmt.Errorf("client started before geometry was set: %w", err) | |||
| 50 | + | } | |||
| 51 | + | if cols != 120 || rows != 40 { | |||
| 52 | + | return fmt.Errorf("geometry = %dx%d, want 120x40", cols, rows) | |||
| 53 | + | } | |||
| 54 | + | return nil | |||
| 55 | + | }, | |||
| 56 | + | }, | |||
| 57 | + | { | |||
| 58 | + | name: "winsize failure closes PTY before returning", | |||
| 59 | + | setSize: func(started string, fd int, _, _ uint16) error { | |||
| 60 | + | if _, err := os.Stat(started); !errors.Is(err, os.ErrNotExist) { | |||
| 61 | + | return fmt.Errorf("client started after winsize failure: %w", err) | |||
| 62 | + | } | |||
| 63 | + | return errTestWinsize | |||
| 64 | + | }, | |||
| 65 | + | wantErr: true, | |||
| 66 | + | }, | |||
| 67 | + | } { | |||
| 68 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 69 | + | started := filepath.Join(t.TempDir(), "started") | |||
| 70 | + | t.Setenv("STARTED", started) | |||
| 71 | + | bin := filepath.Join(t.TempDir(), "client") | |||
| 72 | + | if err := os.WriteFile(bin, []byte("#!/bin/sh\ntouch \"$STARTED\"\n"), 0o700); err != nil { | |||
| 73 | + | t.Fatal(err) | |||
| 74 | + | } | |||
| 75 | + | runDir := filepath.Join(t.TempDir(), "run") | |||
| 76 | + | if err := safedir.EnsurePrivate(runDir); err != nil { | |||
| 77 | + | t.Fatal(err) | |||
| 78 | + | } | |||
| 79 | + | setFD := -1 | |||
| 80 | + | setPTYWinsize = func(fd int, cols, rows uint16) error { | |||
| 81 | + | setFD = fd | |||
| 82 | + | return tt.setSize(started, fd, cols, rows) | |||
| 83 | + | } | |||
| 84 | + | launcher := &cliLauncher{bin: bin} | |||
| 85 | + | t.Cleanup(func() { | |||
| 86 | + | if err := launcher.releaseRuntime(runDir); err != nil { | |||
| 87 | + | t.Error(err) | |||
| 88 | + | } | |||
| 89 | + | }) | |||
| 90 | + | p, err := launcher.Launch(processMapping{Role: "client", TracePath: filepath.Join(runDir, "client.jsonl")}, roleCommand{Args: []string{"test"}}) | |||
| 91 | + | if tt.wantErr { | |||
| 92 | + | if !errors.Is(err, errTestWinsize) || p != nil { | |||
| 93 | + | t.Fatalf("Launch() = (%T, %v), want winsize error", p, err) | |||
| 94 | + | } | |||
| 95 | + | if setFD < 0 { | |||
| 96 | + | t.Fatal("winsize setter was not called") | |||
| 97 | + | } | |||
| 98 | + | if closeErr := syscall.Close(setFD); !errors.Is(closeErr, syscall.EBADF) { | |||
| 99 | + | t.Fatalf("winsize failure left PTY fd %d open: close error %v", setFD, closeErr) | |||
| 100 | + | } | |||
| 101 | + | return | |||
| 102 | + | } | |||
| 103 | + | if err != nil { | |||
| 104 | + | t.Fatal(err) | |||
| 105 | + | } | |||
| 106 | + | if setFD < 0 { | |||
| 107 | + | t.Fatal("winsize setter was not called before successful launch") | |||
| 108 | + | } | |||
| 109 | + | if err := p.Close(); err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | }) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | var errTestWinsize = errors.New("test winsize failure") | |||
| 117 | + | ||||
| 118 | + | func TestCLIProcessResize(t *testing.T) { | |||
| 119 | + | original := setPTYWinsize | |||
| 120 | + | t.Cleanup(func() { setPTYWinsize = original }) | |||
| 121 | + | ||||
| 122 | + | var gotFD int | |||
| 123 | + | var gotCols, gotRows uint16 | |||
| 124 | + | setPTYWinsize = func(fd int, cols, rows uint16) error { | |||
| 125 | + | gotFD, gotCols, gotRows = fd, cols, rows | |||
| 126 | + | return nil | |||
| 127 | + | } | |||
| 128 | + | p := &cliProcess{pty: &fakeFDPTY{fakePTY: &fakePTY{}, fd: 42}} | |||
| 129 | + | if err := p.Resize(119, 40); err != nil { | |||
| 130 | + | t.Fatal(err) | |||
| 131 | + | } | |||
| 132 | + | if gotFD != 42 || gotCols != 119 || gotRows != 40 { | |||
| 133 | + | t.Fatalf("winsize setter got fd=%d cols=%d rows=%d", gotFD, gotCols, gotRows) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | if err := (&cliProcess{pty: &fakePTY{}}).Resize(119, 40); err == nil || !strings.Contains(err.Error(), "file descriptor") { | |||
| 137 | + | t.Fatalf("Resize without FD error = %v", err) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 27 | func TestCLIProcessCloseDrainsQueuedTerminalOutputBeforeClientExit(t *testing.T) { | 141 | func TestCLIProcessCloseDrainsQueuedTerminalOutputBeforeClientExit(t *testing.T) { | ||
| 28 | harnessPTY, clientPTY := net.Pipe() | 142 | harnessPTY, clientPTY := net.Pipe() | ||
| 29 | t.Cleanup(func() { _ = clientPTY.Close() }) | 143 | t.Cleanup(func() { _ = clientPTY.Close() }) | ||
M cmd/vev-perf-harness/main.go +1 -1
| @@ -18,5 +18,5 @@ func main() { | |||
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | 20 | func defaultHarness() *harness { | |
| 21 | - | return &harness{clock: systemClock{time.Now()}, mkdir: safedir.EnsurePrivate, createRunDir: createExclusiveRunDir, create: func(p string) (*os.File, error) { return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) }, removeAll: os.RemoveAll, gitSHA: recordedGitSHA} | |
| 21 | + | return &harness{clock: systemClock{time.Now()}, mkdir: safedir.EnsurePrivate, createRunDir: createExclusiveRunDir, create: func(p string) (*os.File, error) { return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) }, removeAll: os.RemoveAll, gitSHA: recordedGitSHA, vevGitSHA: recordedBinaryGitSHA} | |
| 22 | 22 | } | |
| @@ -18,5 +18,5 @@ func main() { | |||||
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| 20 | func defaultHarness() *harness { | 20 | func defaultHarness() *harness { | ||
| 21 | - | return &harness{clock: systemClock{time.Now()}, mkdir: safedir.EnsurePrivate, createRunDir: createExclusiveRunDir, create: func(p string) (*os.File, error) { return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) }, removeAll: os.RemoveAll, gitSHA: recordedGitSHA} | 21 | + | return &harness{clock: systemClock{time.Now()}, mkdir: safedir.EnsurePrivate, createRunDir: createExclusiveRunDir, create: func(p string) (*os.File, error) { return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) }, removeAll: os.RemoveAll, gitSHA: recordedGitSHA, vevGitSHA: recordedBinaryGitSHA} |
| 22 | } | 22 | } | ||
M cmd/vev-perf-harness/model.go +1 -0
| @@ -99,6 +99,7 @@ type runResult struct { | |||
| 99 | 99 | type summary struct { | |
| 100 | 100 | Schema uint16 `json:"schema"` | |
| 101 | 101 | GitSHA string `json:"git_sha"` | |
| 102 | + | VevGitSHA string `json:"vev_git_sha"` | |
| 102 | 103 | Warmup string `json:"warmup"` | |
| 103 | 104 | Duration string `json:"duration"` | |
| 104 | 105 | Repetitions int `json:"repetitions"` | |
| @@ -99,6 +99,7 @@ type runResult struct { | |||||
| 99 | type summary struct { | 99 | type summary struct { | ||
| 100 | Schema uint16 `json:"schema"` | 100 | Schema uint16 `json:"schema"` | ||
| 101 | GitSHA string `json:"git_sha"` | 101 | GitSHA string `json:"git_sha"` | ||
| 102 | + | VevGitSHA string `json:"vev_git_sha"` | |||
| 102 | Warmup string `json:"warmup"` | 103 | Warmup string `json:"warmup"` | ||
| 103 | Duration string `json:"duration"` | 104 | Duration string `json:"duration"` | ||
| 104 | Repetitions int `json:"repetitions"` | 105 | Repetitions int `json:"repetitions"` | ||
M cmd/vev-perf-harness/runner.go +79 -7
| @@ -4,6 +4,7 @@ package main | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "debug/buildinfo" | |
| 7 | 8 | "encoding/json" | |
| 8 | 9 | "errors" | |
| 9 | 10 | "fmt" | |
| @@ -13,6 +14,7 @@ import ( | |||
| 13 | 14 | "path/filepath" | |
| 14 | 15 | "runtime/debug" | |
| 15 | 16 | "sort" | |
| 17 | + | "strconv" | |
| 16 | 18 | "strings" | |
| 17 | 19 | "time" | |
| 18 | 20 | ||
| @@ -51,6 +53,7 @@ type harness struct { | |||
| 51 | 53 | create func(string) (*os.File, error) | |
| 52 | 54 | removeAll func(string) error | |
| 53 | 55 | gitSHA func() string | |
| 56 | + | vevGitSHA func(string) string | |
| 54 | 57 | selectScenarios func(manifest) []scenario // test-only bounded fixture seam | |
| 55 | 58 | } | |
| 56 | 59 | ||
| @@ -71,6 +74,19 @@ func createExclusiveRunDir(path string) error { | |||
| 71 | 74 | // go run evidence attributable as well. | |
| 72 | 75 | const gitSHACommandTimeout = time.Second | |
| 73 | 76 | ||
| 77 | + | func recordedBinaryGitSHA(path string) string { | |
| 78 | + | info, err := buildinfo.ReadFile(path) | |
| 79 | + | if err != nil { | |
| 80 | + | return "unknown" | |
| 81 | + | } | |
| 82 | + | for _, setting := range info.Settings { | |
| 83 | + | if setting.Key == "vcs.revision" && setting.Value != "" { | |
| 84 | + | return setting.Value | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | return "unknown" | |
| 88 | + | } | |
| 89 | + | ||
| 74 | 90 | func recordedGitSHA() string { | |
| 75 | 91 | if info, ok := debug.ReadBuildInfo(); ok { | |
| 76 | 92 | for _, setting := range info.Settings { | |
| @@ -148,7 +164,7 @@ func run(args []string, h *harness) (err error) { | |||
| 148 | 164 | if err := writeJSON(filepath.Join(opt.out, "runs.json"), results); err != nil { | |
| 149 | 165 | return err | |
| 150 | 166 | } | |
| 151 | - | return writeJSON(filepath.Join(opt.out, "summary.json"), summary{Schema: 1, GitSHA: h.gitSHA(), Warmup: opt.warmup.String(), Duration: opt.duration.String(), Repetitions: opt.repetitions, EndToEnd: percentiles(all), Cadence: percentiles(cadence), MaxGap: maxGap, RunP50Dispersion: percentiles(runP50s), Spans: summarizeSpans(localSpans), Runs: len(results)}) | |
| 167 | + | return writeJSON(filepath.Join(opt.out, "summary.json"), summary{Schema: 1, GitSHA: h.gitSHA(), VevGitSHA: h.vevGitSHA(opt.vevBin), Warmup: opt.warmup.String(), Duration: opt.duration.String(), Repetitions: opt.repetitions, EndToEnd: percentiles(all), Cadence: percentiles(cadence), MaxGap: maxGap, RunP50Dispersion: percentiles(runP50s), Spans: summarizeSpans(localSpans), Runs: len(results)}) | |
| 152 | 168 | } | |
| 153 | 169 | ||
| 154 | 170 | // aggregateEventResults deliberately preserves raw event latencies for | |
| @@ -307,7 +323,22 @@ func (h *harness) runOne(o options, mat manifest, s scenario, run int, raw io.Wr | |||
| 307 | 323 | flushedRecorded = true | |
| 308 | 324 | return nil | |
| 309 | 325 | } | |
| 310 | - | if measureErr := p.process.Measure(workloadInput(s, run, fmt.Sprintf("measured-%d", sequence)), injected, flushed); measureErr != nil { | |
| 326 | + | measureInjected := injected | |
| 327 | + | if cols, rows, resize := workloadResize(s, sequence); resize { | |
| 328 | + | resizer, ok := p.process.(processResizer) | |
| 329 | + | if !ok { | |
| 330 | + | return res, errors.New("client process does not support resize workload") | |
| 331 | + | } | |
| 332 | + | if err := injected(); err != nil { | |
| 333 | + | return res, err | |
| 334 | + | } | |
| 335 | + | measureInjected = func() error { return nil } | |
| 336 | + | if resizeErr := resizer.Resize(cols, rows); resizeErr != nil { | |
| 337 | + | diagnosticErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "terminal_flushed", Tick: h.clock.Now(), Valid: false}) | |
| 338 | + | return res, errors.Join(fmt.Errorf("resize client PTY: %w", resizeErr), diagnosticErr) | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | if measureErr := p.process.Measure(workloadInput(s, run, fmt.Sprintf("measured-%d", sequence)), measureInjected, flushed); measureErr != nil { | |
| 311 | 342 | if injectedRecorded && !flushedRecorded { | |
| 312 | 343 | diagnosticErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "terminal_flushed", Tick: h.clock.Now(), Valid: false}) | |
| 313 | 344 | return res, errors.Join(measureErr, diagnosticErr) | |
| @@ -418,6 +449,20 @@ func launchOrder(mappings []processMapping) []processMapping { | |||
| 418 | 449 | return out | |
| 419 | 450 | } | |
| 420 | 451 | ||
| 452 | + | type processResizer interface { | |
| 453 | + | Resize(cols, rows uint16) error | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | func workloadResize(s scenario, sequence uint64) (uint16, uint16, bool) { | |
| 457 | + | if s.Workload != "resize_sweep" && s.Workload != "snapshot_output_resize" { | |
| 458 | + | return 0, 0, false | |
| 459 | + | } | |
| 460 | + | if sequence%2 == 1 { | |
| 461 | + | return 119, 40, true | |
| 462 | + | } | |
| 463 | + | return 120, 40, true | |
| 464 | + | } | |
| 465 | + | ||
| 421 | 466 | // workloadInput is shell input delivered through the client PTY. It avoids any | |
| 422 | 467 | // private daemon API and makes every topology/workload/transport manifest row | |
| 423 | 468 | // execute a real terminal workload. The marker is the output boundary awaited | |
| @@ -425,17 +470,44 @@ func launchOrder(mappings []processMapping) []processMapping { | |||
| 425 | 470 | func workloadInput(s scenario, run int, phase string) []byte { | |
| 426 | 471 | marker := fmt.Sprintf("__VEV_HARNESS_%s_r%d_%s__", safeName(s.ID), run, safeName(phase)) | |
| 427 | 472 | body := "printf 'vev perf %s\\n'" | |
| 473 | + | markerSeparator := "; " | |
| 428 | 474 | switch s.Workload { | |
| 429 | - | case "active_output", "all_output", "inactive_output", "interactive_flood": | |
| 475 | + | case "active_output": | |
| 476 | + | cell := byte('X') | |
| 477 | + | if sequence, err := strconv.ParseUint(strings.TrimPrefix(phase, "measured-"), 10, 64); err == nil && strings.HasPrefix(phase, "measured-") { | |
| 478 | + | cell = '0' + byte(sequence%2) | |
| 479 | + | } | |
| 480 | + | body = fmt.Sprintf(`printf '\033[10;20H%c'; : '%%s'`, cell) | |
| 481 | + | case "all_output": | |
| 482 | + | body = `printf '%%-120s' 'vev perf line %s'` | |
| 483 | + | case "inactive_output": | |
| 484 | + | body = `printf '\033[38;2;20;120;220mred'; printf '\033[38;2;220;80;40mgreen'; printf '\033[38;2;80;220;120m%s'; printf '\033[0m'` | |
| 485 | + | case "interactive_flood": | |
| 430 | 486 | body = "i=0; while [ $i -lt 128 ]; do printf 'vev perf output %s\\n'; i=$((i+1)); done" | |
| 431 | 487 | case "resize_sweep": | |
| 432 | - | body = "printf 'vev perf resize-sweep %s\\n'" | |
| 488 | + | cols, _ := resizeWorkloadGeometry(s, phase) | |
| 489 | + | body = fmt.Sprintf(`i=0; while [ "$i" -lt 100 ]; do set -- $(stty size); [ "$2" = "%d" ] && break; i=$((i+1)); sleep 0.01; done; set -- $(stty size); [ "$2" = "%d" ] && printf 'vev perf resize-sweep %%s\n'`, cols, cols) | |
| 490 | + | markerSeparator = " && " | |
| 433 | 491 | case "copy_search": | |
| 434 | 492 | body = "printf 'vev perf copy-search %s\\n'" | |
| 435 | 493 | case "snapshot_output_resize": | |
| 436 | - | body = "printf 'vev perf snapshot-output-resize %s\\n'" | |
| 494 | + | cols, _ := resizeWorkloadGeometry(s, phase) | |
| 495 | + | body = fmt.Sprintf(`i=0; while [ "$i" -lt 100 ]; do set -- $(stty size); [ "$2" = "%d" ] && break; i=$((i+1)); sleep 0.01; done; set -- $(stty size); [ "$2" = "%d" ] && printf '\033[2J\033[Hvev perf snapshot-output-resize %%s\n'`, cols, cols) | |
| 496 | + | markerSeparator = " && " | |
| 437 | 497 | case "attach_restore_tab_switch": | |
| 438 | - | body = "printf 'vev perf attach-restore-tab-switch %s\\n'" | |
| 498 | + | body = `"$VEV_PERF_BIN" cmd next-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch %s\n'` | |
| 499 | + | if phase == "warmup" { | |
| 500 | + | body = `"$VEV_PERF_BIN" cmd new-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch %s\n'` | |
| 501 | + | } | |
| 502 | + | markerSeparator = " && " | |
| 503 | + | } | |
| 504 | + | return []byte(fmt.Sprintf(body+markerSeparator+"printf '%s\\n'\n", s.ID, marker)) | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | func resizeWorkloadGeometry(s scenario, phase string) (uint16, uint16) { | |
| 508 | + | if sequence, err := strconv.ParseUint(strings.TrimPrefix(phase, "measured-"), 10, 64); err == nil && strings.HasPrefix(phase, "measured-") { | |
| 509 | + | cols, rows, _ := workloadResize(s, sequence) | |
| 510 | + | return cols, rows | |
| 439 | 511 | } | |
| 440 | - | return []byte(fmt.Sprintf(body+"; printf '%s\\n'\n", s.ID, marker)) | |
| 512 | + | return 120, 40 | |
| 441 | 513 | } | |
| @@ -4,6 +4,7 @@ package main | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "debug/buildinfo" | |||
| 7 | "encoding/json" | 8 | "encoding/json" | ||
| 8 | "errors" | 9 | "errors" | ||
| 9 | "fmt" | 10 | "fmt" | ||
| @@ -13,6 +14,7 @@ import ( | |||||
| 13 | "path/filepath" | 14 | "path/filepath" | ||
| 14 | "runtime/debug" | 15 | "runtime/debug" | ||
| 15 | "sort" | 16 | "sort" | ||
| 17 | + | "strconv" | |||
| 16 | "strings" | 18 | "strings" | ||
| 17 | "time" | 19 | "time" | ||
| 18 | 20 | ||||
| @@ -51,6 +53,7 @@ type harness struct { | |||||
| 51 | create func(string) (*os.File, error) | 53 | create func(string) (*os.File, error) | ||
| 52 | removeAll func(string) error | 54 | removeAll func(string) error | ||
| 53 | gitSHA func() string | 55 | gitSHA func() string | ||
| 56 | + | vevGitSHA func(string) string | |||
| 54 | selectScenarios func(manifest) []scenario // test-only bounded fixture seam | 57 | selectScenarios func(manifest) []scenario // test-only bounded fixture seam | ||
| 55 | } | 58 | } | ||
| 56 | 59 | ||||
| @@ -71,6 +74,19 @@ func createExclusiveRunDir(path string) error { | |||||
| 71 | // go run evidence attributable as well. | 74 | // go run evidence attributable as well. | ||
| 72 | const gitSHACommandTimeout = time.Second | 75 | const gitSHACommandTimeout = time.Second | ||
| 73 | 76 | ||||
| 77 | + | func recordedBinaryGitSHA(path string) string { | |||
| 78 | + | info, err := buildinfo.ReadFile(path) | |||
| 79 | + | if err != nil { | |||
| 80 | + | return "unknown" | |||
| 81 | + | } | |||
| 82 | + | for _, setting := range info.Settings { | |||
| 83 | + | if setting.Key == "vcs.revision" && setting.Value != "" { | |||
| 84 | + | return setting.Value | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | return "unknown" | |||
| 88 | + | } | |||
| 89 | + | ||||
| 74 | func recordedGitSHA() string { | 90 | func recordedGitSHA() string { | ||
| 75 | if info, ok := debug.ReadBuildInfo(); ok { | 91 | if info, ok := debug.ReadBuildInfo(); ok { | ||
| 76 | for _, setting := range info.Settings { | 92 | for _, setting := range info.Settings { | ||
| @@ -148,7 +164,7 @@ func run(args []string, h *harness) (err error) { | |||||
| 148 | if err := writeJSON(filepath.Join(opt.out, "runs.json"), results); err != nil { | 164 | if err := writeJSON(filepath.Join(opt.out, "runs.json"), results); err != nil { | ||
| 149 | return err | 165 | return err | ||
| 150 | } | 166 | } | ||
| 151 | - | return writeJSON(filepath.Join(opt.out, "summary.json"), summary{Schema: 1, GitSHA: h.gitSHA(), Warmup: opt.warmup.String(), Duration: opt.duration.String(), Repetitions: opt.repetitions, EndToEnd: percentiles(all), Cadence: percentiles(cadence), MaxGap: maxGap, RunP50Dispersion: percentiles(runP50s), Spans: summarizeSpans(localSpans), Runs: len(results)}) | 167 | + | return writeJSON(filepath.Join(opt.out, "summary.json"), summary{Schema: 1, GitSHA: h.gitSHA(), VevGitSHA: h.vevGitSHA(opt.vevBin), Warmup: opt.warmup.String(), Duration: opt.duration.String(), Repetitions: opt.repetitions, EndToEnd: percentiles(all), Cadence: percentiles(cadence), MaxGap: maxGap, RunP50Dispersion: percentiles(runP50s), Spans: summarizeSpans(localSpans), Runs: len(results)}) |
| 152 | } | 168 | } | ||
| 153 | 169 | ||||
| 154 | // aggregateEventResults deliberately preserves raw event latencies for | 170 | // aggregateEventResults deliberately preserves raw event latencies for | ||
| @@ -307,7 +323,22 @@ func (h *harness) runOne(o options, mat manifest, s scenario, run int, raw io.Wr | |||||
| 307 | flushedRecorded = true | 323 | flushedRecorded = true | ||
| 308 | return nil | 324 | return nil | ||
| 309 | } | 325 | } | ||
| 310 | - | if measureErr := p.process.Measure(workloadInput(s, run, fmt.Sprintf("measured-%d", sequence)), injected, flushed); measureErr != nil { | 326 | + | measureInjected := injected |
| 327 | + | if cols, rows, resize := workloadResize(s, sequence); resize { | |||
| 328 | + | resizer, ok := p.process.(processResizer) | |||
| 329 | + | if !ok { | |||
| 330 | + | return res, errors.New("client process does not support resize workload") | |||
| 331 | + | } | |||
| 332 | + | if err := injected(); err != nil { | |||
| 333 | + | return res, err | |||
| 334 | + | } | |||
| 335 | + | measureInjected = func() error { return nil } | |||
| 336 | + | if resizeErr := resizer.Resize(cols, rows); resizeErr != nil { | |||
| 337 | + | diagnosticErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "terminal_flushed", Tick: h.clock.Now(), Valid: false}) | |||
| 338 | + | return res, errors.Join(fmt.Errorf("resize client PTY: %w", resizeErr), diagnosticErr) | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | if measureErr := p.process.Measure(workloadInput(s, run, fmt.Sprintf("measured-%d", sequence)), measureInjected, flushed); measureErr != nil { | |||
| 311 | if injectedRecorded && !flushedRecorded { | 342 | if injectedRecorded && !flushedRecorded { | ||
| 312 | diagnosticErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "terminal_flushed", Tick: h.clock.Now(), Valid: false}) | 343 | diagnosticErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "terminal_flushed", Tick: h.clock.Now(), Valid: false}) | ||
| 313 | return res, errors.Join(measureErr, diagnosticErr) | 344 | return res, errors.Join(measureErr, diagnosticErr) | ||
| @@ -418,6 +449,20 @@ func launchOrder(mappings []processMapping) []processMapping { | |||||
| 418 | return out | 449 | return out | ||
| 419 | } | 450 | } | ||
| 420 | 451 | ||||
| 452 | + | type processResizer interface { | |||
| 453 | + | Resize(cols, rows uint16) error | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | func workloadResize(s scenario, sequence uint64) (uint16, uint16, bool) { | |||
| 457 | + | if s.Workload != "resize_sweep" && s.Workload != "snapshot_output_resize" { | |||
| 458 | + | return 0, 0, false | |||
| 459 | + | } | |||
| 460 | + | if sequence%2 == 1 { | |||
| 461 | + | return 119, 40, true | |||
| 462 | + | } | |||
| 463 | + | return 120, 40, true | |||
| 464 | + | } | |||
| 465 | + | ||||
| 421 | // workloadInput is shell input delivered through the client PTY. It avoids any | 466 | // workloadInput is shell input delivered through the client PTY. It avoids any | ||
| 422 | // private daemon API and makes every topology/workload/transport manifest row | 467 | // private daemon API and makes every topology/workload/transport manifest row | ||
| 423 | // execute a real terminal workload. The marker is the output boundary awaited | 468 | // execute a real terminal workload. The marker is the output boundary awaited | ||
| @@ -425,17 +470,44 @@ func launchOrder(mappings []processMapping) []processMapping { | |||||
| 425 | func workloadInput(s scenario, run int, phase string) []byte { | 470 | func workloadInput(s scenario, run int, phase string) []byte { | ||
| 426 | marker := fmt.Sprintf("__VEV_HARNESS_%s_r%d_%s__", safeName(s.ID), run, safeName(phase)) | 471 | marker := fmt.Sprintf("__VEV_HARNESS_%s_r%d_%s__", safeName(s.ID), run, safeName(phase)) | ||
| 427 | body := "printf 'vev perf %s\\n'" | 472 | body := "printf 'vev perf %s\\n'" | ||
| 473 | + | markerSeparator := "; " | |||
| 428 | switch s.Workload { | 474 | switch s.Workload { | ||
| 429 | - | case "active_output", "all_output", "inactive_output", "interactive_flood": | 475 | + | case "active_output": |
| 476 | + | cell := byte('X') | |||
| 477 | + | if sequence, err := strconv.ParseUint(strings.TrimPrefix(phase, "measured-"), 10, 64); err == nil && strings.HasPrefix(phase, "measured-") { | |||
| 478 | + | cell = '0' + byte(sequence%2) | |||
| 479 | + | } | |||
| 480 | + | body = fmt.Sprintf(`printf '\033[10;20H%c'; : '%%s'`, cell) | |||
| 481 | + | case "all_output": | |||
| 482 | + | body = `printf '%%-120s' 'vev perf line %s'` | |||
| 483 | + | case "inactive_output": | |||
| 484 | + | body = `printf '\033[38;2;20;120;220mred'; printf '\033[38;2;220;80;40mgreen'; printf '\033[38;2;80;220;120m%s'; printf '\033[0m'` | |||
| 485 | + | case "interactive_flood": | |||
| 430 | body = "i=0; while [ $i -lt 128 ]; do printf 'vev perf output %s\\n'; i=$((i+1)); done" | 486 | body = "i=0; while [ $i -lt 128 ]; do printf 'vev perf output %s\\n'; i=$((i+1)); done" | ||
| 431 | case "resize_sweep": | 487 | case "resize_sweep": | ||
| 432 | - | body = "printf 'vev perf resize-sweep %s\\n'" | 488 | + | cols, _ := resizeWorkloadGeometry(s, phase) |
| 489 | + | body = fmt.Sprintf(`i=0; while [ "$i" -lt 100 ]; do set -- $(stty size); [ "$2" = "%d" ] && break; i=$((i+1)); sleep 0.01; done; set -- $(stty size); [ "$2" = "%d" ] && printf 'vev perf resize-sweep %%s\n'`, cols, cols) | |||
| 490 | + | markerSeparator = " && " | |||
| 433 | case "copy_search": | 491 | case "copy_search": | ||
| 434 | body = "printf 'vev perf copy-search %s\\n'" | 492 | body = "printf 'vev perf copy-search %s\\n'" | ||
| 435 | case "snapshot_output_resize": | 493 | case "snapshot_output_resize": | ||
| 436 | - | body = "printf 'vev perf snapshot-output-resize %s\\n'" | 494 | + | cols, _ := resizeWorkloadGeometry(s, phase) |
| 495 | + | body = fmt.Sprintf(`i=0; while [ "$i" -lt 100 ]; do set -- $(stty size); [ "$2" = "%d" ] && break; i=$((i+1)); sleep 0.01; done; set -- $(stty size); [ "$2" = "%d" ] && printf '\033[2J\033[Hvev perf snapshot-output-resize %%s\n'`, cols, cols) | |||
| 496 | + | markerSeparator = " && " | |||
| 437 | case "attach_restore_tab_switch": | 497 | case "attach_restore_tab_switch": | ||
| 438 | - | body = "printf 'vev perf attach-restore-tab-switch %s\\n'" | 498 | + | body = `"$VEV_PERF_BIN" cmd next-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch %s\n'` |
| 499 | + | if phase == "warmup" { | |||
| 500 | + | body = `"$VEV_PERF_BIN" cmd new-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch %s\n'` | |||
| 501 | + | } | |||
| 502 | + | markerSeparator = " && " | |||
| 503 | + | } | |||
| 504 | + | return []byte(fmt.Sprintf(body+markerSeparator+"printf '%s\\n'\n", s.ID, marker)) | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | func resizeWorkloadGeometry(s scenario, phase string) (uint16, uint16) { | |||
| 508 | + | if sequence, err := strconv.ParseUint(strings.TrimPrefix(phase, "measured-"), 10, 64); err == nil && strings.HasPrefix(phase, "measured-") { | |||
| 509 | + | cols, rows, _ := workloadResize(s, sequence) | |||
| 510 | + | return cols, rows | |||
| 439 | } | 511 | } | ||
| 440 | - | return []byte(fmt.Sprintf(body+"; printf '%s\\n'\n", s.ID, marker)) | 512 | + | return 120, 40 |
| 441 | } | 513 | } | ||
M cmd/vev-perf-harness/runner_test.go +177 -0
| @@ -309,6 +309,183 @@ func TestHarnessFakeRunnerRoutesClientToPeerAndCleansEveryRole(t *testing.T) { | |||
| 309 | 309 | } | |
| 310 | 310 | } | |
| 311 | 311 | ||
| 312 | + | func TestWorkloadInputShapes(t *testing.T) { | |
| 313 | + | tests := []struct { | |
| 314 | + | workload string | |
| 315 | + | contains []string | |
| 316 | + | }{ | |
| 317 | + | {"active_output", []string{`printf '\033[10;20H1'`}}, | |
| 318 | + | {"all_output", []string{"120", "vev perf line"}}, | |
| 319 | + | {"inactive_output", []string{ | |
| 320 | + | `printf '\033[38;2;20;120;220mred'`, | |
| 321 | + | `printf '\033[38;2;220;80;40mgreen'`, | |
| 322 | + | `printf '\033[38;2;80;220;120ms'`, | |
| 323 | + | }}, | |
| 324 | + | {"interactive_flood", []string{"while [ $i -lt 128 ]"}}, | |
| 325 | + | {"snapshot_output_resize", []string{`\033[2J`, "vev perf snapshot-output-resize"}}, | |
| 326 | + | {"attach_restore_tab_switch", []string{`"$VEV_PERF_BIN" cmd next-tab`, `"$VEV_PERF_BIN" cmd previous-tab`}}, | |
| 327 | + | } | |
| 328 | + | for _, tt := range tests { | |
| 329 | + | t.Run(tt.workload, func(t *testing.T) { | |
| 330 | + | got := string(workloadInput(scenario{ID: "s", Workload: tt.workload}, 1, "measured-1")) | |
| 331 | + | for _, want := range tt.contains { | |
| 332 | + | if !strings.Contains(got, want) { | |
| 333 | + | t.Errorf("workload input %q does not contain %q", got, want) | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | if !strings.Contains(got, "__VEV_HARNESS_s_r1_measured-1__") { | |
| 337 | + | t.Errorf("workload input has no terminal marker: %q", got) | |
| 338 | + | } | |
| 339 | + | }) | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | activeWarmup := string(workloadInput(scenario{ID: "s", Workload: "active_output"}, 1, "warmup")) | |
| 343 | + | activeOne := string(workloadInput(scenario{ID: "s", Workload: "active_output"}, 1, "measured-1")) | |
| 344 | + | activeTwo := string(workloadInput(scenario{ID: "s", Workload: "active_output"}, 1, "measured-2")) | |
| 345 | + | for phase, got := range map[string]string{"warmup": activeWarmup, "measured-1": activeOne, "measured-2": activeTwo} { | |
| 346 | + | marker := fmt.Sprintf("__VEV_HARNESS_s_r1_%s__", phase) | |
| 347 | + | wantByte := map[string]byte{"warmup": 'X', "measured-1": '1', "measured-2": '0'}[phase] | |
| 348 | + | want := fmt.Sprintf("printf '\\033[10;20H%c'; : 's'; printf '%s\\n'\n", wantByte, marker) | |
| 349 | + | if got != want { | |
| 350 | + | t.Errorf("active %s input = %q, want exactly one changing cell write %q", phase, got, want) | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | if activeWarmup == activeOne || activeOne == activeTwo { | |
| 354 | + | t.Fatalf("active one-cell payload did not change by sequence: %q %q %q", activeWarmup, activeOne, activeTwo) | |
| 355 | + | } | |
| 356 | + | inactive := string(workloadInput(scenario{ID: "s", Workload: "inactive_output"}, 1, "measured-1")) | |
| 357 | + | if count := strings.Count(inactive, `\033[38;2;`); count != 3 { | |
| 358 | + | t.Fatalf("inactive output truecolor runs = %d, want 3: %q", count, inactive) | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | warmup := string(workloadInput(scenario{ID: "s", Workload: "attach_restore_tab_switch"}, 1, "warmup")) | |
| 362 | + | if count := strings.Count(warmup, `"$VEV_PERF_BIN" cmd new-tab`); count != 1 { | |
| 363 | + | t.Fatalf("attach warmup new-tab commands = %d, want 1: %q", count, warmup) | |
| 364 | + | } | |
| 365 | + | wantWarmup := `"$VEV_PERF_BIN" cmd new-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch s\n' && printf '__VEV_HARNESS_s_r1_warmup__\n'` + "\n" | |
| 366 | + | if warmup != wantWarmup { | |
| 367 | + | t.Fatalf("attach warmup success marker is not gated by every command:\n got %q\nwant %q", warmup, wantWarmup) | |
| 368 | + | } | |
| 369 | + | measured := string(workloadInput(scenario{ID: "s", Workload: "attach_restore_tab_switch"}, 1, "measured-1")) | |
| 370 | + | wantMeasured := `"$VEV_PERF_BIN" cmd next-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch s\n' && printf '__VEV_HARNESS_s_r1_measured-1__\n'` + "\n" | |
| 371 | + | if measured != wantMeasured { | |
| 372 | + | t.Fatalf("attach measured success marker is not gated by every command:\n got %q\nwant %q", measured, wantMeasured) | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | func TestWorkloadResize(t *testing.T) { | |
| 377 | + | for _, tt := range []struct { | |
| 378 | + | workload string | |
| 379 | + | sequence uint64 | |
| 380 | + | wantCols uint16 | |
| 381 | + | wantOK bool | |
| 382 | + | }{ | |
| 383 | + | {"active_output", 1, 0, false}, | |
| 384 | + | {"resize_sweep", 1, 119, true}, | |
| 385 | + | {"resize_sweep", 2, 120, true}, | |
| 386 | + | {"snapshot_output_resize", 1, 119, true}, | |
| 387 | + | } { | |
| 388 | + | t.Run(fmt.Sprintf("%s-%d", tt.workload, tt.sequence), func(t *testing.T) { | |
| 389 | + | cols, rows, ok := workloadResize(scenario{Workload: tt.workload}, tt.sequence) | |
| 390 | + | if ok != tt.wantOK || cols != tt.wantCols { | |
| 391 | + | t.Fatalf("workloadResize() = (%d, %d, %t), want cols=%d ok=%t", cols, rows, ok, tt.wantCols, tt.wantOK) | |
| 392 | + | } | |
| 393 | + | if ok && rows != 40 { | |
| 394 | + | t.Fatalf("workloadResize() rows = %d, want 40", rows) | |
| 395 | + | } | |
| 396 | + | }) | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | type resizeOrderingWriter struct { | |
| 401 | + | bytes.Buffer | |
| 402 | + | order *[]string | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | func (w *resizeOrderingWriter) Write(p []byte) (int, error) { | |
| 406 | + | if bytes.Contains(p, []byte(`"sequence":2`)) { | |
| 407 | + | switch { | |
| 408 | + | case bytes.Contains(p, []byte(`"kind":"input_injected"`)): | |
| 409 | + | *w.order = append(*w.order, "injected") | |
| 410 | + | case bytes.Contains(p, []byte(`"kind":"terminal_flushed"`)): | |
| 411 | + | *w.order = append(*w.order, "terminal_flushed") | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | return w.Buffer.Write(p) | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | type resizeOrderingProcess struct { | |
| 418 | + | order *[]string | |
| 419 | + | inputs [][]byte | |
| 420 | + | resizes int | |
| 421 | + | measures int | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | func (*resizeOrderingProcess) Warmup([]byte) error { return nil } | |
| 425 | + | ||
| 426 | + | func (p *resizeOrderingProcess) Resize(cols, rows uint16) error { | |
| 427 | + | p.resizes++ | |
| 428 | + | if p.resizes == 1 { | |
| 429 | + | *p.order = append(*p.order, fmt.Sprintf("resize:%dx%d", cols, rows)) | |
| 430 | + | } | |
| 431 | + | return nil | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | func (p *resizeOrderingProcess) Measure(input []byte, injected, flushed func() error) error { | |
| 435 | + | p.measures++ | |
| 436 | + | p.inputs = append(p.inputs, append([]byte(nil), input...)) | |
| 437 | + | if p.measures == 1 { | |
| 438 | + | *p.order = append(*p.order, "workload_write") | |
| 439 | + | } | |
| 440 | + | if err := injected(); err != nil { | |
| 441 | + | return err | |
| 442 | + | } | |
| 443 | + | if p.measures == 1 { | |
| 444 | + | *p.order = append(*p.order, "workload_flush") | |
| 445 | + | } | |
| 446 | + | return flushed() | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func (*resizeOrderingProcess) Close() error { return nil } | |
| 450 | + | ||
| 451 | + | type resizeOrderingLauncher struct{ client *resizeOrderingProcess } | |
| 452 | + | ||
| 453 | + | func (l resizeOrderingLauncher) Launch(m processMapping, _ roleCommand) (launchedProcess, error) { | |
| 454 | + | if m.Role == "client" { | |
| 455 | + | return l.client, nil | |
| 456 | + | } | |
| 457 | + | return &fakeProcess{}, nil | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | func TestResizeBoundaryPrecedesResizeAndMarkerWaitsForShellGeometry(t *testing.T) { | |
| 461 | + | dir := t.TempDir() | |
| 462 | + | order := []string{} | |
| 463 | + | client := &resizeOrderingProcess{order: &order} | |
| 464 | + | h := defaultHarness() | |
| 465 | + | h.clock, h.launcher = &fakeClock{}, resizeOrderingLauncher{client: client} | |
| 466 | + | raw := &resizeOrderingWriter{order: &order} | |
| 467 | + | _, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "resize", Workload: "resize_sweep", Roles: []string{"daemon", "client"}}, 1, raw) | |
| 468 | + | if err != nil { | |
| 469 | + | t.Fatal(err) | |
| 470 | + | } | |
| 471 | + | wantOrder := []string{"injected", "resize:120x40", "workload_write", "workload_flush", "terminal_flushed"} | |
| 472 | + | if len(order) < len(wantOrder) { | |
| 473 | + | t.Fatalf("resize event order = %q, want prefix %q", order, wantOrder) | |
| 474 | + | } | |
| 475 | + | if !equalStrings(order[:len(wantOrder)], wantOrder) { | |
| 476 | + | t.Fatalf("first resize event order = %q, want %q", order[:len(wantOrder)], wantOrder) | |
| 477 | + | } | |
| 478 | + | first := string(client.inputs[0]) | |
| 479 | + | for _, want := range []string{`set -- $(stty size)`, `[ "$2" = "120" ]`, `sleep 0.01`, `&& printf '__VEV_HARNESS_resize_r1_measured-2__\n'`} { | |
| 480 | + | if !strings.Contains(first, want) { | |
| 481 | + | t.Errorf("resize input has no bounded expected-column gate %q: %q", want, first) | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | if strings.Contains(first, `"40 120"`) { | |
| 485 | + | t.Fatalf("resize input incorrectly gates on client rows that shell chrome does not expose: %q", first) | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | ||
| 312 | 489 | func TestHarnessAggregatesRawEventsSeparatelyFromRunDispersion(t *testing.T) { | |
| 313 | 490 | first := append([]int64(nil), make([]int64, minimumInIntervalEventSamples+5)...) | |
| 314 | 491 | for i := range first { | |
| @@ -309,6 +309,183 @@ func TestHarnessFakeRunnerRoutesClientToPeerAndCleansEveryRole(t *testing.T) { | |||||
| 309 | } | 309 | } | ||
| 310 | } | 310 | } | ||
| 311 | 311 | ||||
| 312 | + | func TestWorkloadInputShapes(t *testing.T) { | |||
| 313 | + | tests := []struct { | |||
| 314 | + | workload string | |||
| 315 | + | contains []string | |||
| 316 | + | }{ | |||
| 317 | + | {"active_output", []string{`printf '\033[10;20H1'`}}, | |||
| 318 | + | {"all_output", []string{"120", "vev perf line"}}, | |||
| 319 | + | {"inactive_output", []string{ | |||
| 320 | + | `printf '\033[38;2;20;120;220mred'`, | |||
| 321 | + | `printf '\033[38;2;220;80;40mgreen'`, | |||
| 322 | + | `printf '\033[38;2;80;220;120ms'`, | |||
| 323 | + | }}, | |||
| 324 | + | {"interactive_flood", []string{"while [ $i -lt 128 ]"}}, | |||
| 325 | + | {"snapshot_output_resize", []string{`\033[2J`, "vev perf snapshot-output-resize"}}, | |||
| 326 | + | {"attach_restore_tab_switch", []string{`"$VEV_PERF_BIN" cmd next-tab`, `"$VEV_PERF_BIN" cmd previous-tab`}}, | |||
| 327 | + | } | |||
| 328 | + | for _, tt := range tests { | |||
| 329 | + | t.Run(tt.workload, func(t *testing.T) { | |||
| 330 | + | got := string(workloadInput(scenario{ID: "s", Workload: tt.workload}, 1, "measured-1")) | |||
| 331 | + | for _, want := range tt.contains { | |||
| 332 | + | if !strings.Contains(got, want) { | |||
| 333 | + | t.Errorf("workload input %q does not contain %q", got, want) | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | if !strings.Contains(got, "__VEV_HARNESS_s_r1_measured-1__") { | |||
| 337 | + | t.Errorf("workload input has no terminal marker: %q", got) | |||
| 338 | + | } | |||
| 339 | + | }) | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | activeWarmup := string(workloadInput(scenario{ID: "s", Workload: "active_output"}, 1, "warmup")) | |||
| 343 | + | activeOne := string(workloadInput(scenario{ID: "s", Workload: "active_output"}, 1, "measured-1")) | |||
| 344 | + | activeTwo := string(workloadInput(scenario{ID: "s", Workload: "active_output"}, 1, "measured-2")) | |||
| 345 | + | for phase, got := range map[string]string{"warmup": activeWarmup, "measured-1": activeOne, "measured-2": activeTwo} { | |||
| 346 | + | marker := fmt.Sprintf("__VEV_HARNESS_s_r1_%s__", phase) | |||
| 347 | + | wantByte := map[string]byte{"warmup": 'X', "measured-1": '1', "measured-2": '0'}[phase] | |||
| 348 | + | want := fmt.Sprintf("printf '\\033[10;20H%c'; : 's'; printf '%s\\n'\n", wantByte, marker) | |||
| 349 | + | if got != want { | |||
| 350 | + | t.Errorf("active %s input = %q, want exactly one changing cell write %q", phase, got, want) | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | if activeWarmup == activeOne || activeOne == activeTwo { | |||
| 354 | + | t.Fatalf("active one-cell payload did not change by sequence: %q %q %q", activeWarmup, activeOne, activeTwo) | |||
| 355 | + | } | |||
| 356 | + | inactive := string(workloadInput(scenario{ID: "s", Workload: "inactive_output"}, 1, "measured-1")) | |||
| 357 | + | if count := strings.Count(inactive, `\033[38;2;`); count != 3 { | |||
| 358 | + | t.Fatalf("inactive output truecolor runs = %d, want 3: %q", count, inactive) | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | warmup := string(workloadInput(scenario{ID: "s", Workload: "attach_restore_tab_switch"}, 1, "warmup")) | |||
| 362 | + | if count := strings.Count(warmup, `"$VEV_PERF_BIN" cmd new-tab`); count != 1 { | |||
| 363 | + | t.Fatalf("attach warmup new-tab commands = %d, want 1: %q", count, warmup) | |||
| 364 | + | } | |||
| 365 | + | wantWarmup := `"$VEV_PERF_BIN" cmd new-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch s\n' && printf '__VEV_HARNESS_s_r1_warmup__\n'` + "\n" | |||
| 366 | + | if warmup != wantWarmup { | |||
| 367 | + | t.Fatalf("attach warmup success marker is not gated by every command:\n got %q\nwant %q", warmup, wantWarmup) | |||
| 368 | + | } | |||
| 369 | + | measured := string(workloadInput(scenario{ID: "s", Workload: "attach_restore_tab_switch"}, 1, "measured-1")) | |||
| 370 | + | wantMeasured := `"$VEV_PERF_BIN" cmd next-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch s\n' && printf '__VEV_HARNESS_s_r1_measured-1__\n'` + "\n" | |||
| 371 | + | if measured != wantMeasured { | |||
| 372 | + | t.Fatalf("attach measured success marker is not gated by every command:\n got %q\nwant %q", measured, wantMeasured) | |||
| 373 | + | } | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | func TestWorkloadResize(t *testing.T) { | |||
| 377 | + | for _, tt := range []struct { | |||
| 378 | + | workload string | |||
| 379 | + | sequence uint64 | |||
| 380 | + | wantCols uint16 | |||
| 381 | + | wantOK bool | |||
| 382 | + | }{ | |||
| 383 | + | {"active_output", 1, 0, false}, | |||
| 384 | + | {"resize_sweep", 1, 119, true}, | |||
| 385 | + | {"resize_sweep", 2, 120, true}, | |||
| 386 | + | {"snapshot_output_resize", 1, 119, true}, | |||
| 387 | + | } { | |||
| 388 | + | t.Run(fmt.Sprintf("%s-%d", tt.workload, tt.sequence), func(t *testing.T) { | |||
| 389 | + | cols, rows, ok := workloadResize(scenario{Workload: tt.workload}, tt.sequence) | |||
| 390 | + | if ok != tt.wantOK || cols != tt.wantCols { | |||
| 391 | + | t.Fatalf("workloadResize() = (%d, %d, %t), want cols=%d ok=%t", cols, rows, ok, tt.wantCols, tt.wantOK) | |||
| 392 | + | } | |||
| 393 | + | if ok && rows != 40 { | |||
| 394 | + | t.Fatalf("workloadResize() rows = %d, want 40", rows) | |||
| 395 | + | } | |||
| 396 | + | }) | |||
| 397 | + | } | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | type resizeOrderingWriter struct { | |||
| 401 | + | bytes.Buffer | |||
| 402 | + | order *[]string | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | func (w *resizeOrderingWriter) Write(p []byte) (int, error) { | |||
| 406 | + | if bytes.Contains(p, []byte(`"sequence":2`)) { | |||
| 407 | + | switch { | |||
| 408 | + | case bytes.Contains(p, []byte(`"kind":"input_injected"`)): | |||
| 409 | + | *w.order = append(*w.order, "injected") | |||
| 410 | + | case bytes.Contains(p, []byte(`"kind":"terminal_flushed"`)): | |||
| 411 | + | *w.order = append(*w.order, "terminal_flushed") | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 414 | + | return w.Buffer.Write(p) | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | type resizeOrderingProcess struct { | |||
| 418 | + | order *[]string | |||
| 419 | + | inputs [][]byte | |||
| 420 | + | resizes int | |||
| 421 | + | measures int | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | func (*resizeOrderingProcess) Warmup([]byte) error { return nil } | |||
| 425 | + | ||||
| 426 | + | func (p *resizeOrderingProcess) Resize(cols, rows uint16) error { | |||
| 427 | + | p.resizes++ | |||
| 428 | + | if p.resizes == 1 { | |||
| 429 | + | *p.order = append(*p.order, fmt.Sprintf("resize:%dx%d", cols, rows)) | |||
| 430 | + | } | |||
| 431 | + | return nil | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | func (p *resizeOrderingProcess) Measure(input []byte, injected, flushed func() error) error { | |||
| 435 | + | p.measures++ | |||
| 436 | + | p.inputs = append(p.inputs, append([]byte(nil), input...)) | |||
| 437 | + | if p.measures == 1 { | |||
| 438 | + | *p.order = append(*p.order, "workload_write") | |||
| 439 | + | } | |||
| 440 | + | if err := injected(); err != nil { | |||
| 441 | + | return err | |||
| 442 | + | } | |||
| 443 | + | if p.measures == 1 { | |||
| 444 | + | *p.order = append(*p.order, "workload_flush") | |||
| 445 | + | } | |||
| 446 | + | return flushed() | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | func (*resizeOrderingProcess) Close() error { return nil } | |||
| 450 | + | ||||
| 451 | + | type resizeOrderingLauncher struct{ client *resizeOrderingProcess } | |||
| 452 | + | ||||
| 453 | + | func (l resizeOrderingLauncher) Launch(m processMapping, _ roleCommand) (launchedProcess, error) { | |||
| 454 | + | if m.Role == "client" { | |||
| 455 | + | return l.client, nil | |||
| 456 | + | } | |||
| 457 | + | return &fakeProcess{}, nil | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | func TestResizeBoundaryPrecedesResizeAndMarkerWaitsForShellGeometry(t *testing.T) { | |||
| 461 | + | dir := t.TempDir() | |||
| 462 | + | order := []string{} | |||
| 463 | + | client := &resizeOrderingProcess{order: &order} | |||
| 464 | + | h := defaultHarness() | |||
| 465 | + | h.clock, h.launcher = &fakeClock{}, resizeOrderingLauncher{client: client} | |||
| 466 | + | raw := &resizeOrderingWriter{order: &order} | |||
| 467 | + | _, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "resize", Workload: "resize_sweep", Roles: []string{"daemon", "client"}}, 1, raw) | |||
| 468 | + | if err != nil { | |||
| 469 | + | t.Fatal(err) | |||
| 470 | + | } | |||
| 471 | + | wantOrder := []string{"injected", "resize:120x40", "workload_write", "workload_flush", "terminal_flushed"} | |||
| 472 | + | if len(order) < len(wantOrder) { | |||
| 473 | + | t.Fatalf("resize event order = %q, want prefix %q", order, wantOrder) | |||
| 474 | + | } | |||
| 475 | + | if !equalStrings(order[:len(wantOrder)], wantOrder) { | |||
| 476 | + | t.Fatalf("first resize event order = %q, want %q", order[:len(wantOrder)], wantOrder) | |||
| 477 | + | } | |||
| 478 | + | first := string(client.inputs[0]) | |||
| 479 | + | for _, want := range []string{`set -- $(stty size)`, `[ "$2" = "120" ]`, `sleep 0.01`, `&& printf '__VEV_HARNESS_resize_r1_measured-2__\n'`} { | |||
| 480 | + | if !strings.Contains(first, want) { | |||
| 481 | + | t.Errorf("resize input has no bounded expected-column gate %q: %q", want, first) | |||
| 482 | + | } | |||
| 483 | + | } | |||
| 484 | + | if strings.Contains(first, `"40 120"`) { | |||
| 485 | + | t.Fatalf("resize input incorrectly gates on client rows that shell chrome does not expose: %q", first) | |||
| 486 | + | } | |||
| 487 | + | } | |||
| 488 | + | ||||
| 312 | func TestHarnessAggregatesRawEventsSeparatelyFromRunDispersion(t *testing.T) { | 489 | func TestHarnessAggregatesRawEventsSeparatelyFromRunDispersion(t *testing.T) { | ||
| 313 | first := append([]int64(nil), make([]int64, minimumInIntervalEventSamples+5)...) | 490 | first := append([]int64(nil), make([]int64, minimumInIntervalEventSamples+5)...) | ||
| 314 | for i := range first { | 491 | for i := range first { | ||
M docs/performance.md +91 -19
| @@ -42,30 +42,102 @@ marks are paired within one process and summarized by component/adapter with | |||
| 42 | 42 | raw records, counts, p50/p95/p99, and max. Diagnostic fields (bytes, fragments, | |
| 43 | 43 | retransmits, pending, ACK RTT) remain separate from duration calculations. | |
| 44 | 44 | ||
| 45 | - | ### Baseline procedure and validity | |
| 45 | + | ### Proxy ANSI baseline | |
| 46 | 46 | ||
| 47 | - | Use the public CLI harness (not internal packages): | |
| 47 | + | The current proxy path transports ANSI and applies it through the local proxy | |
| 48 | + | VT parser. Build the fixed baseline binary and harness, then verify the harness | |
| 49 | + | and parser fixtures: | |
| 48 | 50 | ||
| 49 | 51 | ```sh | |
| 50 | - | go build -o /tmp/vev-perf ./ | |
| 51 | - | rm -rf testdata/perf/results | |
| 52 | - | go run ./cmd/vev-perf-harness --vev-bin /tmp/vev-perf \ | |
| 53 | - | --manifest testdata/perf/manifest.json --out testdata/perf/results \ | |
| 52 | + | go build -o /tmp/vev-proxy-baseline . | |
| 53 | + | go build -o /tmp/vev-perf-harness ./cmd/vev-perf-harness | |
| 54 | + | go test ./cmd/vev-perf-harness -count=1 | |
| 55 | + | go test ./internal/usecase/daemon -run '^TestProxyANSIBenchmarkFixtures$' -count=1 | |
| 56 | + | go test ./internal/usecase/daemon -run '^$' \ | |
| 57 | + | -bench '^BenchmarkProxyANSIApply$' -benchmem -count=10 | |
| 58 | + | ``` | |
| 59 | + | ||
| 60 | + | The pre-change parser results below are medians of ten repetitions on | |
| 61 | + | Linux/amd64 with an AMD Ryzen 9 7900X3D. The fixed | |
| 62 | + | `/tmp/vev-proxy-baseline` binary was built from exact Phase 1 commit | |
| 63 | + | `1f2c802d0707785cab979134db6a95645250a82c`. Runtime production code remains | |
| 64 | + | based on `919bcd18c428ef90abfda6c2381ba1cfbb06b0dd` because Phase 1 changes | |
| 65 | + | only benchmarks, harness code, tests, and documentation. The benchmark command | |
| 66 | + | above ran with Go 1.26.5. | |
| 67 | + | ||
| 68 | + | | ANSI workload | ns/op | B/op | allocs/op | ANSI bytes/op | | |
| 69 | + | | --- | ---: | ---: | ---: | ---: | | |
| 70 | + | | absolute-position one-cell | 91.06 | 16 | 1 | 9 | | |
| 71 | + | | 120-column full line | 4,659.5 | 129 | 1 | 126 | | |
| 72 | + | | fragmented truecolor styled line | 5,861 | 450 | 1 | 419 | | |
| 73 | + | | full-width 40-row scroll | 233,271.5 | 5,472 | 1 | 4,880 | | |
| 74 | + | ||
| 75 | + | Keep the ordinary local ANSI renderer as a separate guardrail: | |
| 76 | + | ||
| 77 | + | ```sh | |
| 78 | + | go test ./pkg/renderer -run '^$' -bench '^BenchmarkRenderer' -benchmem -count=10 | |
| 79 | + | ``` | |
| 80 | + | ||
| 81 | + | ### Public-CLI transport runs | |
| 82 | + | ||
| 83 | + | Use a 10-second warmup, a 30-second measured interval, and ten independent | |
| 84 | + | repetitions. This representative matrix covers one-cell active output, | |
| 85 | + | 120-column full-line output, fragmented truecolor-styled output, sustained | |
| 86 | + | interactive flood, resize sweep, snapshot/output/resize, and attach/tab-switch | |
| 87 | + | across local, SSH stdio, UDP baseline, UDP 25 ms, and UDP 1% loss (35 | |
| 88 | + | scenarios): | |
| 89 | + | ||
| 90 | + | ```sh | |
| 91 | + | rm -rf /tmp/vev-proxy-selected | |
| 92 | + | mkdir -m 700 /tmp/vev-proxy-selected | |
| 93 | + | for workload in \ | |
| 94 | + | active_output all_output inactive_output interactive_flood resize_sweep \ | |
| 95 | + | snapshot_output_resize attach_restore_tab_switch | |
| 96 | + | do | |
| 97 | + | for transport in local ssh_stdio udp_baseline udp_25ms udp_loss_1pct | |
| 98 | + | do | |
| 99 | + | scenario="1x4-${workload}-${transport}" | |
| 100 | + | /tmp/vev-perf-harness --vev-bin /tmp/vev-proxy-baseline \ | |
| 101 | + | --manifest testdata/perf/manifest.json --scenario "$scenario" \ | |
| 102 | + | --out "/tmp/vev-proxy-selected/$scenario" \ | |
| 103 | + | --warmup 10s --duration 30s --repetitions 10 | |
| 104 | + | done | |
| 105 | + | done | |
| 106 | + | ``` | |
| 107 | + | ||
| 108 | + | `all_output` and `inactive_output` provide full-line and styled payload shapes; | |
| 109 | + | these runs do not claim to activate every pane in their manifest topologies. | |
| 110 | + | Run the complete canonical matrix only when full-matrix evidence is required: | |
| 111 | + | ||
| 112 | + | ```sh | |
| 113 | + | rm -rf /tmp/vev-proxy-matrix | |
| 114 | + | /tmp/vev-perf-harness --vev-bin /tmp/vev-proxy-baseline \ | |
| 115 | + | --manifest testdata/perf/manifest.json --out /tmp/vev-proxy-matrix \ | |
| 54 | 116 | --warmup 10s --duration 30s --repetitions 10 | |
| 55 | 117 | ``` | |
| 56 | 118 | ||
| 57 | - | The minimums are 30 seconds measured and 10 independent repetitions; warmup is | |
| 58 | - | excluded. During the measured interval the harness injects at most one event per | |
| 59 | - | second. At least 10 complete event pairs are required: both injection and | |
| 60 | - | successful terminal flush must fall inside the measured interval; straddling or | |
| 61 | - | failed events are retained only as raw diagnostics and are not samples. Each run must contain `manifest.json`, `raw-harness.jsonl`, per-process | |
| 62 | - | JSONL, `runs.json`, and `summary.json`. Reject results with missing input/flush | |
| 63 | - | boundaries, unmatched IDs, bad correlation, nonmonotonic same-process sequence, | |
| 64 | - | negative same-domain spans, fewer than 10 complete in-interval event pairs, | |
| 65 | - | invalid denominators, | |
| 66 | - | any cross-process tick arithmetic, or unmet duration/repetition minima. Raw | |
| 67 | - | JSONL and run manifests are the evidence files; no result authorizes a policy | |
| 68 | - | or optimization change. | |
| 119 | + | Warmup is excluded. During the measured interval the harness injects at most | |
| 120 | + | one event per second. At least 10 complete event pairs are required: both input | |
| 121 | + | injection and successful terminal flush must fall inside the interval. | |
| 122 | + | Straddling or failed events remain raw diagnostics, not samples. Each run must | |
| 123 | + | contain `manifest.json`, `raw-harness.jsonl`, per-process JSONL, `runs.json`, and | |
| 124 | + | `summary.json`. | |
| 125 | + | ||
| 126 | + | `summary.json` records the harness source as `git_sha`, the measured vev binary | |
| 127 | + | source as `vev_git_sha`, and the measurement parameters; | |
| 128 | + | `runs.json`, run manifests, and JSONL identify scenario, run, process, component, | |
| 129 | + | and correlation IDs. Preserve those files with the benchmark output and record | |
| 130 | + | `go version -m /tmp/vev-proxy-baseline`; do not commit result directories. CPU | |
| 131 | + | and allocation evidence comes from Go microbenchmarks and process-local span | |
| 132 | + | durations. End-to-end latency, terminal flush, and byte evidence comes from the | |
| 133 | + | public-CLI harness. Reset, snapshot, and span counters for structured output are | |
| 134 | + | candidate-only until that output exists. | |
| 135 | + | ||
| 136 | + | Reject results with missing input/flush boundaries, unmatched IDs, bad | |
| 137 | + | correlation, nonmonotonic same-process sequence, negative same-domain spans, | |
| 138 | + | fewer than 10 complete in-interval event pairs, invalid denominators, any | |
| 139 | + | cross-process tick arithmetic, or unmet duration/repetition minima. Raw JSONL | |
| 140 | + | and run manifests are evidence; no result alone authorizes an optimization. | |
| 69 | 141 | ||
| 70 | 142 | ## Existing in-process smoke checks | |
| 71 | 143 | ||
| @@ -75,5 +147,5 @@ results: | |||
| 75 | 147 | ```sh | |
| 76 | 148 | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | |
| 77 | 149 | go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem | |
| 78 | - | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeFloatingFrameCached$' -benchmem | |
| 150 | + | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeCapturedFloatingFrameCached$' -benchmem | |
| 79 | 151 | ``` | |
| @@ -42,30 +42,102 @@ marks are paired within one process and summarized by component/adapter with | |||||
| 42 | raw records, counts, p50/p95/p99, and max. Diagnostic fields (bytes, fragments, | 42 | raw records, counts, p50/p95/p99, and max. Diagnostic fields (bytes, fragments, | ||
| 43 | retransmits, pending, ACK RTT) remain separate from duration calculations. | 43 | retransmits, pending, ACK RTT) remain separate from duration calculations. | ||
| 44 | 44 | ||||
| 45 | - | ### Baseline procedure and validity | 45 | + | ### Proxy ANSI baseline |
| 46 | 46 | ||||
| 47 | - | Use the public CLI harness (not internal packages): | 47 | + | The current proxy path transports ANSI and applies it through the local proxy |
| 48 | + | VT parser. Build the fixed baseline binary and harness, then verify the harness | |||
| 49 | + | and parser fixtures: | |||
| 48 | 50 | ||||
| 49 | ```sh | 51 | ```sh | ||
| 50 | - | go build -o /tmp/vev-perf ./ | 52 | + | go build -o /tmp/vev-proxy-baseline . |
| 51 | - | rm -rf testdata/perf/results | 53 | + | go build -o /tmp/vev-perf-harness ./cmd/vev-perf-harness |
| 52 | - | go run ./cmd/vev-perf-harness --vev-bin /tmp/vev-perf \ | 54 | + | go test ./cmd/vev-perf-harness -count=1 |
| 53 | - | --manifest testdata/perf/manifest.json --out testdata/perf/results \ | 55 | + | go test ./internal/usecase/daemon -run '^TestProxyANSIBenchmarkFixtures$' -count=1 |
| 56 | + | go test ./internal/usecase/daemon -run '^$' \ | |||
| 57 | + | -bench '^BenchmarkProxyANSIApply$' -benchmem -count=10 | |||
| 58 | + | ``` | |||
| 59 | + | ||||
| 60 | + | The pre-change parser results below are medians of ten repetitions on | |||
| 61 | + | Linux/amd64 with an AMD Ryzen 9 7900X3D. The fixed | |||
| 62 | + | `/tmp/vev-proxy-baseline` binary was built from exact Phase 1 commit | |||
| 63 | + | `1f2c802d0707785cab979134db6a95645250a82c`. Runtime production code remains | |||
| 64 | + | based on `919bcd18c428ef90abfda6c2381ba1cfbb06b0dd` because Phase 1 changes | |||
| 65 | + | only benchmarks, harness code, tests, and documentation. The benchmark command | |||
| 66 | + | above ran with Go 1.26.5. | |||
| 67 | + | ||||
| 68 | + | | ANSI workload | ns/op | B/op | allocs/op | ANSI bytes/op | | |||
| 69 | + | | --- | ---: | ---: | ---: | ---: | | |||
| 70 | + | | absolute-position one-cell | 91.06 | 16 | 1 | 9 | | |||
| 71 | + | | 120-column full line | 4,659.5 | 129 | 1 | 126 | | |||
| 72 | + | | fragmented truecolor styled line | 5,861 | 450 | 1 | 419 | | |||
| 73 | + | | full-width 40-row scroll | 233,271.5 | 5,472 | 1 | 4,880 | | |||
| 74 | + | ||||
| 75 | + | Keep the ordinary local ANSI renderer as a separate guardrail: | |||
| 76 | + | ||||
| 77 | + | ```sh | |||
| 78 | + | go test ./pkg/renderer -run '^$' -bench '^BenchmarkRenderer' -benchmem -count=10 | |||
| 79 | + | ``` | |||
| 80 | + | ||||
| 81 | + | ### Public-CLI transport runs | |||
| 82 | + | ||||
| 83 | + | Use a 10-second warmup, a 30-second measured interval, and ten independent | |||
| 84 | + | repetitions. This representative matrix covers one-cell active output, | |||
| 85 | + | 120-column full-line output, fragmented truecolor-styled output, sustained | |||
| 86 | + | interactive flood, resize sweep, snapshot/output/resize, and attach/tab-switch | |||
| 87 | + | across local, SSH stdio, UDP baseline, UDP 25 ms, and UDP 1% loss (35 | |||
| 88 | + | scenarios): | |||
| 89 | + | ||||
| 90 | + | ```sh | |||
| 91 | + | rm -rf /tmp/vev-proxy-selected | |||
| 92 | + | mkdir -m 700 /tmp/vev-proxy-selected | |||
| 93 | + | for workload in \ | |||
| 94 | + | active_output all_output inactive_output interactive_flood resize_sweep \ | |||
| 95 | + | snapshot_output_resize attach_restore_tab_switch | |||
| 96 | + | do | |||
| 97 | + | for transport in local ssh_stdio udp_baseline udp_25ms udp_loss_1pct | |||
| 98 | + | do | |||
| 99 | + | scenario="1x4-${workload}-${transport}" | |||
| 100 | + | /tmp/vev-perf-harness --vev-bin /tmp/vev-proxy-baseline \ | |||
| 101 | + | --manifest testdata/perf/manifest.json --scenario "$scenario" \ | |||
| 102 | + | --out "/tmp/vev-proxy-selected/$scenario" \ | |||
| 103 | + | --warmup 10s --duration 30s --repetitions 10 | |||
| 104 | + | done | |||
| 105 | + | done | |||
| 106 | + | ``` | |||
| 107 | + | ||||
| 108 | + | `all_output` and `inactive_output` provide full-line and styled payload shapes; | |||
| 109 | + | these runs do not claim to activate every pane in their manifest topologies. | |||
| 110 | + | Run the complete canonical matrix only when full-matrix evidence is required: | |||
| 111 | + | ||||
| 112 | + | ```sh | |||
| 113 | + | rm -rf /tmp/vev-proxy-matrix | |||
| 114 | + | /tmp/vev-perf-harness --vev-bin /tmp/vev-proxy-baseline \ | |||
| 115 | + | --manifest testdata/perf/manifest.json --out /tmp/vev-proxy-matrix \ | |||
| 54 | --warmup 10s --duration 30s --repetitions 10 | 116 | --warmup 10s --duration 30s --repetitions 10 | ||
| 55 | ``` | 117 | ``` | ||
| 56 | 118 | ||||
| 57 | - | The minimums are 30 seconds measured and 10 independent repetitions; warmup is | 119 | + | Warmup is excluded. During the measured interval the harness injects at most |
| 58 | - | excluded. During the measured interval the harness injects at most one event per | 120 | + | one event per second. At least 10 complete event pairs are required: both input |
| 59 | - | second. At least 10 complete event pairs are required: both injection and | 121 | + | injection and successful terminal flush must fall inside the interval. |
| 60 | - | successful terminal flush must fall inside the measured interval; straddling or | 122 | + | Straddling or failed events remain raw diagnostics, not samples. Each run must |
| 61 | - | failed events are retained only as raw diagnostics and are not samples. Each run must contain `manifest.json`, `raw-harness.jsonl`, per-process | 123 | + | contain `manifest.json`, `raw-harness.jsonl`, per-process JSONL, `runs.json`, and |
| 62 | - | JSONL, `runs.json`, and `summary.json`. Reject results with missing input/flush | 124 | + | `summary.json`. |
| 63 | - | boundaries, unmatched IDs, bad correlation, nonmonotonic same-process sequence, | 125 | + | |
| 64 | - | negative same-domain spans, fewer than 10 complete in-interval event pairs, | 126 | + | `summary.json` records the harness source as `git_sha`, the measured vev binary |
| 65 | - | invalid denominators, | 127 | + | source as `vev_git_sha`, and the measurement parameters; |
| 66 | - | any cross-process tick arithmetic, or unmet duration/repetition minima. Raw | 128 | + | `runs.json`, run manifests, and JSONL identify scenario, run, process, component, |
| 67 | - | JSONL and run manifests are the evidence files; no result authorizes a policy | 129 | + | and correlation IDs. Preserve those files with the benchmark output and record |
| 68 | - | or optimization change. | 130 | + | `go version -m /tmp/vev-proxy-baseline`; do not commit result directories. CPU |
| 131 | + | and allocation evidence comes from Go microbenchmarks and process-local span | |||
| 132 | + | durations. End-to-end latency, terminal flush, and byte evidence comes from the | |||
| 133 | + | public-CLI harness. Reset, snapshot, and span counters for structured output are | |||
| 134 | + | candidate-only until that output exists. | |||
| 135 | + | ||||
| 136 | + | Reject results with missing input/flush boundaries, unmatched IDs, bad | |||
| 137 | + | correlation, nonmonotonic same-process sequence, negative same-domain spans, | |||
| 138 | + | fewer than 10 complete in-interval event pairs, invalid denominators, any | |||
| 139 | + | cross-process tick arithmetic, or unmet duration/repetition minima. Raw JSONL | |||
| 140 | + | and run manifests are evidence; no result alone authorizes an optimization. | |||
| 69 | 141 | ||||
| 70 | ## Existing in-process smoke checks | 142 | ## Existing in-process smoke checks | ||
| 71 | 143 | ||||
| @@ -75,5 +147,5 @@ results: | |||||
| 75 | ```sh | 147 | ```sh | ||
| 76 | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | 148 | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | ||
| 77 | go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem | 149 | go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem | ||
| 78 | - | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeFloatingFrameCached$' -benchmem | 150 | + | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeCapturedFloatingFrameCached$' -benchmem |
| 79 | ``` | 151 | ``` | ||
M internal/usecase/daemon/daemon.go +3 -0
| @@ -835,6 +835,9 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho | |||
| 835 | 835 | } | |
| 836 | 836 | } | |
| 837 | 837 | } | |
| 838 | + | // Signal Serve even when a concurrent role transition made this initial | |
| 839 | + | // pass abort; Serve owns the bounded defensive teardown passes. | |
| 840 | + | d.doneOnce.Do(func() { close(d.done) }) | |
| 838 | 841 | return checkpointIncomplete | |
| 839 | 842 | } | |
| 840 | 843 | ||
| @@ -835,6 +835,9 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho | |||||
| 835 | } | 835 | } | ||
| 836 | } | 836 | } | ||
| 837 | } | 837 | } | ||
| 838 | + | // Signal Serve even when a concurrent role transition made this initial | |||
| 839 | + | // pass abort; Serve owns the bounded defensive teardown passes. | |||
| 840 | + | d.doneOnce.Do(func() { close(d.done) }) | |||
| 838 | return checkpointIncomplete | 841 | return checkpointIncomplete | ||
| 839 | } | 842 | } | ||
| 840 | 843 | ||||
M internal/usecase/daemon/performance_benchmark_test.go +101 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "fmt" | |
| @@ -196,6 +197,81 @@ func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *tes | |||
| 196 | 197 | require.Equal(t, payload, transport.lastPayload(), "the counting transport must retain the payload slice header, not copy it") | |
| 197 | 198 | } | |
| 198 | 199 | ||
| 200 | + | func TestProxyANSIBenchmarkFixtures(t *testing.T) { | |
| 201 | + | fixtures := proxyANSIBenchmarkFixtures() | |
| 202 | + | require.Len(t, fixtures, 4) | |
| 203 | + | require.Equal(t, []string{ | |
| 204 | + | "absolute-position-one-cell", | |
| 205 | + | "120-column-full-line", | |
| 206 | + | "fragmented-truecolor-styled-line", | |
| 207 | + | "full-width-40-row-scroll", | |
| 208 | + | }, []string{fixtures[0].name, fixtures[1].name, fixtures[2].name, fixtures[3].name}) | |
| 209 | + | require.Equal(t, fixtures, proxyANSIBenchmarkFixtures(), "fixtures must be deterministic") | |
| 210 | + | require.Equal(t, []byte("\x1b[20;60HX"), fixtures[0].data) | |
| 211 | + | require.Equal(t, append([]byte("\x1b[1;1H"), bytes.Repeat([]byte("l"), 120)...), fixtures[1].data) | |
| 212 | + | require.Equal(t, 12, bytes.Count(fixtures[2].data, []byte("\x1b[38;2;"))) | |
| 213 | + | require.Equal(t, 40, bytes.Count(fixtures[3].data, []byte("\r\n"))) | |
| 214 | + | ||
| 215 | + | for _, fixture := range fixtures { | |
| 216 | + | t.Run(fixture.name, func(t *testing.T) { | |
| 217 | + | proxy := newProxyANSIBenchmarkSession(t) | |
| 218 | + | ||
| 219 | + | ack, reset, changed := proxy.applyOutputForGeneration(proxy.linkGeneration, ports.Output{ | |
| 220 | + | BaseStateNum: 0, | |
| 221 | + | NewStateNum: 1, | |
| 222 | + | Data: fixture.data, | |
| 223 | + | }) | |
| 224 | + | ||
| 225 | + | require.Equal(t, uint64(1), ack) | |
| 226 | + | require.False(t, reset) | |
| 227 | + | require.True(t, changed) | |
| 228 | + | proxy.mu.Lock() | |
| 229 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, proxy.contentSize) | |
| 230 | + | proxy.mu.Unlock() | |
| 231 | + | }) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func newProxyANSIBenchmarkSession(t testing.TB) *proxySession { | |
| 236 | + | t.Helper() | |
| 237 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "benchmark", Name: "ansi"}, domain.Size{Cols: 120, Rows: 40 + tabChromeRows}) | |
| 238 | + | require.NoError(t, err) | |
| 239 | + | proxy.mu.Lock() | |
| 240 | + | contentSize := proxy.contentSize | |
| 241 | + | proxy.mu.Unlock() | |
| 242 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, contentSize) | |
| 243 | + | return proxy | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | type proxyANSIBenchmarkFixture struct { | |
| 247 | + | name string | |
| 248 | + | data []byte | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func proxyANSIBenchmarkFixtures() []proxyANSIBenchmarkFixture { | |
| 252 | + | fullLine := append([]byte("\x1b[1;1H"), bytes.Repeat([]byte("l"), 120)...) | |
| 253 | + | ||
| 254 | + | var styledLine []byte | |
| 255 | + | for fragment := range 12 { | |
| 256 | + | styledLine = fmt.Appendf(styledLine, "\x1b[12;%dH\x1b[38;2;%d;%d;%dm", fragment*10+1, fragment*17, fragment*13, fragment*7) | |
| 257 | + | styledLine = append(styledLine, bytes.Repeat([]byte{byte('a' + fragment)}, 10)...) | |
| 258 | + | } | |
| 259 | + | styledLine = append(styledLine, "\x1b[0m"...) | |
| 260 | + | ||
| 261 | + | scroll := make([]byte, 0, 40*122) | |
| 262 | + | for row := range 40 { | |
| 263 | + | scroll = append(scroll, performanceFullWidthRow(120, 0, row)...) | |
| 264 | + | scroll = append(scroll, '\r', '\n') | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | return []proxyANSIBenchmarkFixture{ | |
| 268 | + | {name: "absolute-position-one-cell", data: []byte("\x1b[20;60HX")}, | |
| 269 | + | {name: "120-column-full-line", data: fullLine}, | |
| 270 | + | {name: "fragmented-truecolor-styled-line", data: styledLine}, | |
| 271 | + | {name: "full-width-40-row-scroll", data: scroll}, | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 199 | 275 | func TestPerformanceFixtureCounters(t *testing.T) { | |
| 200 | 276 | fixture := newPerformanceFixture(t, performanceConfig{}) | |
| 201 | 277 | ||
| @@ -359,6 +435,31 @@ var daemonSnapshotTopologies = []daemonHistoryTopology{ | |||
| 359 | 435 | {name: "1tab-4panes", tabs: 1, panes: 4}, | |
| 360 | 436 | } | |
| 361 | 437 | ||
| 438 | + | func BenchmarkProxyANSIApply(b *testing.B) { | |
| 439 | + | for _, fixture := range proxyANSIBenchmarkFixtures() { | |
| 440 | + | b.Run(fixture.name, func(b *testing.B) { | |
| 441 | + | proxy := newProxyANSIBenchmarkSession(b) | |
| 442 | + | generation := proxy.linkGeneration | |
| 443 | + | state := uint64(0) | |
| 444 | + | b.ReportAllocs() | |
| 445 | + | b.SetBytes(int64(len(fixture.data))) | |
| 446 | + | for b.Loop() { | |
| 447 | + | next := state + 1 | |
| 448 | + | ack, reset, changed := proxy.applyOutputForGeneration(generation, ports.Output{ | |
| 449 | + | BaseStateNum: state, | |
| 450 | + | NewStateNum: next, | |
| 451 | + | Data: fixture.data, | |
| 452 | + | }) | |
| 453 | + | if ack != next || reset || !changed { | |
| 454 | + | b.Fatalf("apply output = ack %d, reset %t, changed %t; want ack %d, reset false, changed true", ack, reset, changed, next) | |
| 455 | + | } | |
| 456 | + | state = next | |
| 457 | + | } | |
| 458 | + | b.ReportMetric(float64(len(fixture.data)), "ansibytes/op") | |
| 459 | + | }) | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | ||
| 362 | 463 | func BenchmarkDaemonHistoryLivePaint(b *testing.B) { | |
| 363 | 464 | benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() }) | |
| 364 | 465 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "context" | 5 | "context" | ||
| 5 | "errors" | 6 | "errors" | ||
| 6 | "fmt" | 7 | "fmt" | ||
| @@ -196,6 +197,81 @@ func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *tes | |||||
| 196 | require.Equal(t, payload, transport.lastPayload(), "the counting transport must retain the payload slice header, not copy it") | 197 | require.Equal(t, payload, transport.lastPayload(), "the counting transport must retain the payload slice header, not copy it") | ||
| 197 | } | 198 | } | ||
| 198 | 199 | ||||
| 200 | + | func TestProxyANSIBenchmarkFixtures(t *testing.T) { | |||
| 201 | + | fixtures := proxyANSIBenchmarkFixtures() | |||
| 202 | + | require.Len(t, fixtures, 4) | |||
| 203 | + | require.Equal(t, []string{ | |||
| 204 | + | "absolute-position-one-cell", | |||
| 205 | + | "120-column-full-line", | |||
| 206 | + | "fragmented-truecolor-styled-line", | |||
| 207 | + | "full-width-40-row-scroll", | |||
| 208 | + | }, []string{fixtures[0].name, fixtures[1].name, fixtures[2].name, fixtures[3].name}) | |||
| 209 | + | require.Equal(t, fixtures, proxyANSIBenchmarkFixtures(), "fixtures must be deterministic") | |||
| 210 | + | require.Equal(t, []byte("\x1b[20;60HX"), fixtures[0].data) | |||
| 211 | + | require.Equal(t, append([]byte("\x1b[1;1H"), bytes.Repeat([]byte("l"), 120)...), fixtures[1].data) | |||
| 212 | + | require.Equal(t, 12, bytes.Count(fixtures[2].data, []byte("\x1b[38;2;"))) | |||
| 213 | + | require.Equal(t, 40, bytes.Count(fixtures[3].data, []byte("\r\n"))) | |||
| 214 | + | ||||
| 215 | + | for _, fixture := range fixtures { | |||
| 216 | + | t.Run(fixture.name, func(t *testing.T) { | |||
| 217 | + | proxy := newProxyANSIBenchmarkSession(t) | |||
| 218 | + | ||||
| 219 | + | ack, reset, changed := proxy.applyOutputForGeneration(proxy.linkGeneration, ports.Output{ | |||
| 220 | + | BaseStateNum: 0, | |||
| 221 | + | NewStateNum: 1, | |||
| 222 | + | Data: fixture.data, | |||
| 223 | + | }) | |||
| 224 | + | ||||
| 225 | + | require.Equal(t, uint64(1), ack) | |||
| 226 | + | require.False(t, reset) | |||
| 227 | + | require.True(t, changed) | |||
| 228 | + | proxy.mu.Lock() | |||
| 229 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, proxy.contentSize) | |||
| 230 | + | proxy.mu.Unlock() | |||
| 231 | + | }) | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func newProxyANSIBenchmarkSession(t testing.TB) *proxySession { | |||
| 236 | + | t.Helper() | |||
| 237 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "benchmark", Name: "ansi"}, domain.Size{Cols: 120, Rows: 40 + tabChromeRows}) | |||
| 238 | + | require.NoError(t, err) | |||
| 239 | + | proxy.mu.Lock() | |||
| 240 | + | contentSize := proxy.contentSize | |||
| 241 | + | proxy.mu.Unlock() | |||
| 242 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, contentSize) | |||
| 243 | + | return proxy | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | type proxyANSIBenchmarkFixture struct { | |||
| 247 | + | name string | |||
| 248 | + | data []byte | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func proxyANSIBenchmarkFixtures() []proxyANSIBenchmarkFixture { | |||
| 252 | + | fullLine := append([]byte("\x1b[1;1H"), bytes.Repeat([]byte("l"), 120)...) | |||
| 253 | + | ||||
| 254 | + | var styledLine []byte | |||
| 255 | + | for fragment := range 12 { | |||
| 256 | + | styledLine = fmt.Appendf(styledLine, "\x1b[12;%dH\x1b[38;2;%d;%d;%dm", fragment*10+1, fragment*17, fragment*13, fragment*7) | |||
| 257 | + | styledLine = append(styledLine, bytes.Repeat([]byte{byte('a' + fragment)}, 10)...) | |||
| 258 | + | } | |||
| 259 | + | styledLine = append(styledLine, "\x1b[0m"...) | |||
| 260 | + | ||||
| 261 | + | scroll := make([]byte, 0, 40*122) | |||
| 262 | + | for row := range 40 { | |||
| 263 | + | scroll = append(scroll, performanceFullWidthRow(120, 0, row)...) | |||
| 264 | + | scroll = append(scroll, '\r', '\n') | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | return []proxyANSIBenchmarkFixture{ | |||
| 268 | + | {name: "absolute-position-one-cell", data: []byte("\x1b[20;60HX")}, | |||
| 269 | + | {name: "120-column-full-line", data: fullLine}, | |||
| 270 | + | {name: "fragmented-truecolor-styled-line", data: styledLine}, | |||
| 271 | + | {name: "full-width-40-row-scroll", data: scroll}, | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | ||||
| 199 | func TestPerformanceFixtureCounters(t *testing.T) { | 275 | func TestPerformanceFixtureCounters(t *testing.T) { | ||
| 200 | fixture := newPerformanceFixture(t, performanceConfig{}) | 276 | fixture := newPerformanceFixture(t, performanceConfig{}) | ||
| 201 | 277 | ||||
| @@ -359,6 +435,31 @@ var daemonSnapshotTopologies = []daemonHistoryTopology{ | |||||
| 359 | {name: "1tab-4panes", tabs: 1, panes: 4}, | 435 | {name: "1tab-4panes", tabs: 1, panes: 4}, | ||
| 360 | } | 436 | } | ||
| 361 | 437 | ||||
| 438 | + | func BenchmarkProxyANSIApply(b *testing.B) { | |||
| 439 | + | for _, fixture := range proxyANSIBenchmarkFixtures() { | |||
| 440 | + | b.Run(fixture.name, func(b *testing.B) { | |||
| 441 | + | proxy := newProxyANSIBenchmarkSession(b) | |||
| 442 | + | generation := proxy.linkGeneration | |||
| 443 | + | state := uint64(0) | |||
| 444 | + | b.ReportAllocs() | |||
| 445 | + | b.SetBytes(int64(len(fixture.data))) | |||
| 446 | + | for b.Loop() { | |||
| 447 | + | next := state + 1 | |||
| 448 | + | ack, reset, changed := proxy.applyOutputForGeneration(generation, ports.Output{ | |||
| 449 | + | BaseStateNum: state, | |||
| 450 | + | NewStateNum: next, | |||
| 451 | + | Data: fixture.data, | |||
| 452 | + | }) | |||
| 453 | + | if ack != next || reset || !changed { | |||
| 454 | + | b.Fatalf("apply output = ack %d, reset %t, changed %t; want ack %d, reset false, changed true", ack, reset, changed, next) | |||
| 455 | + | } | |||
| 456 | + | state = next | |||
| 457 | + | } | |||
| 458 | + | b.ReportMetric(float64(len(fixture.data)), "ansibytes/op") | |||
| 459 | + | }) | |||
| 460 | + | } | |||
| 461 | + | } | |||
| 462 | + | ||||
| 362 | func BenchmarkDaemonHistoryLivePaint(b *testing.B) { | 463 | func BenchmarkDaemonHistoryLivePaint(b *testing.B) { | ||
| 363 | benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() }) | 464 | benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() }) | ||
| 364 | } | 465 | } | ||
M internal/usecase/daemon/role_effect_gate_test.go +31 -0
| @@ -353,6 +353,37 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test | |||
| 353 | 353 | requireRoleGateRetired(t, ac) | |
| 354 | 354 | } | |
| 355 | 355 | ||
| 356 | + | func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T) { | |
| 357 | + | d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 358 | + | transport := &closeTrackingTransport{} | |
| 359 | + | active.replaceTransport(transport) | |
| 360 | + | d.attachCoordinator(sess, nil, active, true) | |
| 361 | + | active.publishRoleCapability(sess.attachmentToken(active, transport)) | |
| 362 | + | ||
| 363 | + | owner := freezeRoleEffectGates(active) | |
| 364 | + | require.True(t, owner.acquired) | |
| 365 | + | defer owner.unfreeze() | |
| 366 | + | ||
| 367 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 368 | + | d.clock = clock | |
| 369 | + | shutdownDone := make(chan bool, 1) | |
| 370 | + | go func() { shutdownDone <- d.shutdownAll(ports.ReasonServerShutdown) }() | |
| 371 | + | ||
| 372 | + | deadline := awaitTestValue(t, clock.timers, "shutdown did not arm the gate acquisition deadline") | |
| 373 | + | deadline.ch <- clock.Now() | |
| 374 | + | require.False(t, awaitTestValue(t, shutdownDone, "shutdown did not return after its gate acquisition deadline")) | |
| 375 | + | ||
| 376 | + | d.mu.Lock() | |
| 377 | + | registered := d.sessions[sess.id] | |
| 378 | + | d.mu.Unlock() | |
| 379 | + | require.Same(t, sess, registered, "timed-out initial teardown should leave the session for Serve's shutdown pass") | |
| 380 | + | select { | |
| 381 | + | case <-d.done: | |
| 382 | + | case <-time.After(time.Second): | |
| 383 | + | t.Fatal("shutdown request did not signal Serve after the initial teardown pass timed out") | |
| 384 | + | } | |
| 385 | + | } | |
| 386 | + | ||
| 356 | 387 | func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) { | |
| 357 | 388 | d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 358 | 389 | blocked := newTeardownBlockingTransport() | |
| @@ -353,6 +353,37 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test | |||||
| 353 | requireRoleGateRetired(t, ac) | 353 | requireRoleGateRetired(t, ac) | ||
| 354 | } | 354 | } | ||
| 355 | 355 | ||||
| 356 | + | func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T) { | |||
| 357 | + | d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |||
| 358 | + | transport := &closeTrackingTransport{} | |||
| 359 | + | active.replaceTransport(transport) | |||
| 360 | + | d.attachCoordinator(sess, nil, active, true) | |||
| 361 | + | active.publishRoleCapability(sess.attachmentToken(active, transport)) | |||
| 362 | + | ||||
| 363 | + | owner := freezeRoleEffectGates(active) | |||
| 364 | + | require.True(t, owner.acquired) | |||
| 365 | + | defer owner.unfreeze() | |||
| 366 | + | ||||
| 367 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 368 | + | d.clock = clock | |||
| 369 | + | shutdownDone := make(chan bool, 1) | |||
| 370 | + | go func() { shutdownDone <- d.shutdownAll(ports.ReasonServerShutdown) }() | |||
| 371 | + | ||||
| 372 | + | deadline := awaitTestValue(t, clock.timers, "shutdown did not arm the gate acquisition deadline") | |||
| 373 | + | deadline.ch <- clock.Now() | |||
| 374 | + | require.False(t, awaitTestValue(t, shutdownDone, "shutdown did not return after its gate acquisition deadline")) | |||
| 375 | + | ||||
| 376 | + | d.mu.Lock() | |||
| 377 | + | registered := d.sessions[sess.id] | |||
| 378 | + | d.mu.Unlock() | |||
| 379 | + | require.Same(t, sess, registered, "timed-out initial teardown should leave the session for Serve's shutdown pass") | |||
| 380 | + | select { | |||
| 381 | + | case <-d.done: | |||
| 382 | + | case <-time.After(time.Second): | |||
| 383 | + | t.Fatal("shutdown request did not signal Serve after the initial teardown pass timed out") | |||
| 384 | + | } | |||
| 385 | + | } | |||
| 386 | + | ||||
| 356 | func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) { | 387 | func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) { | ||
| 357 | d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | 388 | d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | ||
| 358 | blocked := newTeardownBlockingTransport() | 389 | blocked := newTeardownBlockingTransport() | ||
M internal/usecase/daemon/session_test.go +2 -1
| @@ -310,8 +310,9 @@ func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) { | |||
| 310 | 310 | sess.mu.Lock() | |
| 311 | 311 | require.NotNil(t, sess.client) | |
| 312 | 312 | require.NotSame(t, acA, sess.client) | |
| 313 | - | require.Contains(t, sess.snatched, acA) | |
| 313 | + | _, oldSnatched := sess.snatched[acA] | |
| 314 | 314 | sess.mu.Unlock() | |
| 315 | + | require.True(t, oldSnatched) | |
| 315 | 316 | require.Same(t, sess, acA.currentSession()) | |
| 316 | 317 | ||
| 317 | 318 | releaseA() | |
| @@ -310,8 +310,9 @@ func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) { | |||||
| 310 | sess.mu.Lock() | 310 | sess.mu.Lock() | ||
| 311 | require.NotNil(t, sess.client) | 311 | require.NotNil(t, sess.client) | ||
| 312 | require.NotSame(t, acA, sess.client) | 312 | require.NotSame(t, acA, sess.client) | ||
| 313 | - | require.Contains(t, sess.snatched, acA) | 313 | + | _, oldSnatched := sess.snatched[acA] |
| 314 | sess.mu.Unlock() | 314 | sess.mu.Unlock() | ||
| 315 | + | require.True(t, oldSnatched) | |||
| 315 | require.Same(t, sess, acA.currentSession()) | 316 | require.Same(t, sess, acA.currentSession()) | ||
| 316 | 317 | ||||
| 317 | releaseA() | 318 | releaseA() | ||