fix(obs): execute public performance scenarios
- commit
- 182a466fa61118684f81b3aea49156c2bce875de
- author
- brice · · 2026-07-12 22:54 UTC
- parent
- 1f1baaa
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +444 -37
M cmd/vev-perf-harness/main.go +318 -26
| @@ -4,6 +4,7 @@ package main | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bufio" | |
| 7 | + | "bytes" | |
| 7 | 8 | "encoding/json" | |
| 8 | 9 | "errors" | |
| 9 | 10 | "flag" | |
| @@ -14,9 +15,12 @@ import ( | |||
| 14 | 15 | "path/filepath" | |
| 15 | 16 | "sort" | |
| 16 | 17 | "strings" | |
| 18 | + | "sync" | |
| 19 | + | "syscall" | |
| 17 | 20 | "time" | |
| 18 | 21 | ||
| 19 | 22 | "github.com/bnema/vev/pkg/safedir" | |
| 23 | + | "golang.org/x/sys/unix" | |
| 20 | 24 | ) | |
| 21 | 25 | ||
| 22 | 26 | const ( | |
| @@ -125,28 +129,253 @@ func (c systemClock) Now() int64 { return time.Since(c.start).Nanosecon | |||
| 125 | 129 | func (systemClock) Sleep(d time.Duration) { time.Sleep(d) } | |
| 126 | 130 | ||
| 127 | 131 | type launchedProcess interface { | |
| 128 | - | Warmup(time.Duration) error | |
| 129 | - | Measure(time.Duration, func()) error | |
| 132 | + | Warmup([]byte) error | |
| 133 | + | Measure([]byte, func() error, func() error) error | |
| 130 | 134 | Close() error | |
| 131 | 135 | } | |
| 132 | 136 | type launcher interface { | |
| 133 | 137 | Launch(processMapping, []string) (launchedProcess, error) | |
| 134 | 138 | } | |
| 139 | + | ||
| 140 | + | // cliLauncher is deliberately limited to executing the supplied public vev | |
| 141 | + | // binary. Scenario orchestration never reaches into an in-process daemon. | |
| 135 | 142 | type cliLauncher struct{ bin string } | |
| 136 | - | type cliProcess struct{ cmd *exec.Cmd } | |
| 137 | 143 | ||
| 138 | - | // The default launcher intentionally invokes only the documented public binary. | |
| 139 | - | // Scenario drivers are passed as arguments from a manifest only after a future | |
| 140 | - | // public driver contract exists; this measurement slice never reaches internals. | |
| 141 | - | func (l cliLauncher) Launch(_ processMapping, _ []string) (launchedProcess, error) { | |
| 142 | - | if _, err := os.Stat(l.bin); err != nil { | |
| 144 | + | type terminalOutput interface { | |
| 145 | + | io.Writer | |
| 146 | + | Sync() error | |
| 147 | + | Close() error | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | type cliProcess struct { | |
| 151 | + | cmd *exec.Cmd | |
| 152 | + | pty io.ReadWriteCloser | |
| 153 | + | output terminalOutput | |
| 154 | + | chunks chan []byte | |
| 155 | + | done chan struct{} | |
| 156 | + | closed sync.Once | |
| 157 | + | waitErr chan error | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (l cliLauncher) Launch(m processMapping, args []string) (launchedProcess, error) { | |
| 161 | + | if len(args) == 0 { | |
| 162 | + | return nil, errors.New("public CLI role has no arguments") | |
| 163 | + | } | |
| 164 | + | if info, err := os.Stat(l.bin); err != nil { | |
| 165 | + | return nil, err | |
| 166 | + | } else if info.IsDir() { | |
| 167 | + | return nil, fmt.Errorf("public CLI binary %q is a directory", l.bin) | |
| 168 | + | } | |
| 169 | + | cmd := exec.Command(l.bin, args...) | |
| 170 | + | cmd.Env = append(withoutEnv(os.Environ(), "VEV"), "VEV_PERF_TRACE="+m.TracePath, "VEV_PERF_PROCESS_ID="+m.ProcessID, | |
| 171 | + | "XDG_RUNTIME_DIR="+filepath.Join(filepath.Dir(m.TracePath), "runtime"), | |
| 172 | + | "XDG_STATE_HOME="+filepath.Join(filepath.Dir(m.TracePath), "state"), "TERM=xterm-256color") | |
| 173 | + | if err := safedir.EnsurePrivate(filepath.Join(filepath.Dir(m.TracePath), "runtime")); err != nil { | |
| 174 | + | return nil, err | |
| 175 | + | } | |
| 176 | + | if err := safedir.EnsurePrivate(filepath.Join(filepath.Dir(m.TracePath), "state")); err != nil { | |
| 143 | 177 | return nil, err | |
| 144 | 178 | } | |
| 145 | - | return cliProcess{}, nil | |
| 179 | + | p := &cliProcess{cmd: cmd, waitErr: make(chan error, 1)} | |
| 180 | + | if m.Role == "client" { | |
| 181 | + | master, slave, err := openPTY() | |
| 182 | + | if err != nil { | |
| 183 | + | return nil, err | |
| 184 | + | } | |
| 185 | + | output, err := os.OpenFile(filepath.Join(filepath.Dir(m.TracePath), "terminal-output"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) | |
| 186 | + | if err != nil { | |
| 187 | + | _ = master.Close() | |
| 188 | + | _ = slave.Close() | |
| 189 | + | return nil, err | |
| 190 | + | } | |
| 191 | + | cmd.Stdin, cmd.Stdout, cmd.Stderr = slave, slave, slave | |
| 192 | + | // Ctty is the child descriptor number after Start remaps slave to stdin. | |
| 193 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0} | |
| 194 | + | if err := cmd.Start(); err != nil { | |
| 195 | + | _ = output.Close() | |
| 196 | + | _ = master.Close() | |
| 197 | + | _ = slave.Close() | |
| 198 | + | return nil, err | |
| 199 | + | } | |
| 200 | + | _ = slave.Close() | |
| 201 | + | p.pty, p.output, p.chunks, p.done = master, output, make(chan []byte, 32), make(chan struct{}) | |
| 202 | + | go p.copyTerminal() | |
| 203 | + | } else { | |
| 204 | + | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |
| 205 | + | if err != nil { | |
| 206 | + | return nil, err | |
| 207 | + | } | |
| 208 | + | cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull | |
| 209 | + | if err := cmd.Start(); err != nil { | |
| 210 | + | _ = devNull.Close() | |
| 211 | + | return nil, err | |
| 212 | + | } | |
| 213 | + | _ = devNull.Close() | |
| 214 | + | } | |
| 215 | + | go func() { p.waitErr <- cmd.Wait() }() | |
| 216 | + | return p, nil | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func withoutEnv(env []string, names ...string) []string { | |
| 220 | + | remove := make(map[string]bool, len(names)) | |
| 221 | + | for _, name := range names { | |
| 222 | + | remove[name] = true | |
| 223 | + | } | |
| 224 | + | out := make([]string, 0, len(env)) | |
| 225 | + | for _, entry := range env { | |
| 226 | + | name, _, _ := strings.Cut(entry, "=") | |
| 227 | + | if !remove[name] { | |
| 228 | + | out = append(out, entry) | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | return out | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func (p *cliProcess) copyTerminal() { | |
| 235 | + | defer close(p.chunks) | |
| 236 | + | buf := make([]byte, 4096) | |
| 237 | + | for { | |
| 238 | + | n, err := p.pty.Read(buf) | |
| 239 | + | if n > 0 { | |
| 240 | + | chunk := append([]byte(nil), buf[:n]...) | |
| 241 | + | if _, writeErr := p.output.Write(chunk); writeErr != nil { | |
| 242 | + | return | |
| 243 | + | } | |
| 244 | + | select { | |
| 245 | + | case p.chunks <- chunk: | |
| 246 | + | case <-p.done: | |
| 247 | + | return | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | if err != nil { | |
| 251 | + | return | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func (p *cliProcess) Warmup(input []byte) error { | |
| 257 | + | if p.pty == nil || len(input) == 0 { | |
| 258 | + | return nil | |
| 259 | + | } | |
| 260 | + | if _, err := p.pty.Write(input); err != nil { | |
| 261 | + | return err | |
| 262 | + | } | |
| 263 | + | // Do not begin measured input until the public client has rendered a real | |
| 264 | + | // shell response to warmup input. This is intentionally not a measurement. | |
| 265 | + | return p.waitForTerminalMarker(inputMarker(input)) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // Measure records a boundary only after the harness-owned terminal output file | |
| 269 | + | // has accepted data and Sync has reported a successful flush. The marker is | |
| 270 | + | // injected through the real client PTY, never fabricated by the harness. | |
| 271 | + | func (p *cliProcess) Measure(input []byte, injected, flushed func() error) error { | |
| 272 | + | if p.pty == nil { | |
| 273 | + | return errors.New("measured process has no terminal PTY") | |
| 274 | + | } | |
| 275 | + | if _, err := p.pty.Write(input); err != nil { | |
| 276 | + | return err | |
| 277 | + | } | |
| 278 | + | // The injected callback runs only after the owned PTY accepts the bytes. | |
| 279 | + | if err := injected(); err != nil { | |
| 280 | + | return err | |
| 281 | + | } | |
| 282 | + | if err := p.waitForTerminalMarker(inputMarker(input)); err != nil { | |
| 283 | + | return err | |
| 284 | + | } | |
| 285 | + | if err := p.output.Sync(); err != nil { | |
| 286 | + | return fmt.Errorf("flush terminal output: %w", err) | |
| 287 | + | } | |
| 288 | + | return flushed() | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | func (p *cliProcess) waitForTerminalMarker(marker []byte) error { | |
| 292 | + | var received []byte | |
| 293 | + | timer := time.NewTimer(10 * time.Second) | |
| 294 | + | defer timer.Stop() | |
| 295 | + | for !bytes.Contains(received, marker) { | |
| 296 | + | select { | |
| 297 | + | case chunk, ok := <-p.chunks: | |
| 298 | + | if !ok { | |
| 299 | + | return errors.New("client terminal closed before terminal output") | |
| 300 | + | } | |
| 301 | + | received = append(received, chunk...) | |
| 302 | + | case err := <-p.waitErr: | |
| 303 | + | // Keep the exit result available to Close, which owns reaping. | |
| 304 | + | p.waitErr <- err | |
| 305 | + | if err == nil { | |
| 306 | + | return errors.New("client exited before terminal output") | |
| 307 | + | } | |
| 308 | + | return fmt.Errorf("client exited before terminal output: %w", err) | |
| 309 | + | case <-timer.C: | |
| 310 | + | return errors.New("timed out waiting for terminal output") | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | return nil | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | func (p *cliProcess) Close() error { | |
| 317 | + | var result error | |
| 318 | + | p.closed.Do(func() { | |
| 319 | + | if p.done != nil { | |
| 320 | + | close(p.done) | |
| 321 | + | } | |
| 322 | + | if p.cmd != nil && p.cmd.Process != nil { | |
| 323 | + | _ = p.cmd.Process.Signal(syscall.SIGTERM) | |
| 324 | + | } | |
| 325 | + | if p.pty != nil { | |
| 326 | + | _ = p.pty.Close() | |
| 327 | + | } | |
| 328 | + | if p.output != nil { | |
| 329 | + | result = p.output.Close() | |
| 330 | + | } | |
| 331 | + | if p.waitErr != nil { | |
| 332 | + | select { | |
| 333 | + | case <-p.waitErr: | |
| 334 | + | // SIGTERM is the harness's normal deterministic cleanup path. | |
| 335 | + | case <-time.After(2 * time.Second): | |
| 336 | + | if p.cmd != nil && p.cmd.Process != nil { | |
| 337 | + | _ = p.cmd.Process.Kill() | |
| 338 | + | } | |
| 339 | + | <-p.waitErr | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | }) | |
| 343 | + | return result | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | // openPTY is the small Linux PTY boundary needed to drive the public terminal | |
| 347 | + | // client. No daemon or adapter implementation is imported by this command. | |
| 348 | + | func openPTY() (*os.File, *os.File, error) { | |
| 349 | + | masterFD, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0) | |
| 350 | + | if err != nil { | |
| 351 | + | return nil, nil, err | |
| 352 | + | } | |
| 353 | + | closeMaster := func(err error) (*os.File, *os.File, error) { _ = unix.Close(masterFD); return nil, nil, err } | |
| 354 | + | number, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN) | |
| 355 | + | if err != nil { | |
| 356 | + | return closeMaster(err) | |
| 357 | + | } | |
| 358 | + | if err := unix.IoctlSetPointerInt(masterFD, unix.TIOCSPTLCK, 0); err != nil { | |
| 359 | + | return closeMaster(err) | |
| 360 | + | } | |
| 361 | + | slaveFD, err := unix.Open(fmt.Sprintf("/dev/pts/%d", number), unix.O_RDWR|unix.O_NOCTTY, 0) | |
| 362 | + | if err != nil { | |
| 363 | + | return closeMaster(err) | |
| 364 | + | } | |
| 365 | + | return os.NewFile(uintptr(masterFD), "vev-client-master"), os.NewFile(uintptr(slaveFD), "vev-client-slave"), nil | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | func inputMarker(input []byte) []byte { | |
| 369 | + | start := bytes.Index(input, []byte("__VEV_HARNESS_")) | |
| 370 | + | if start < 0 { | |
| 371 | + | return input | |
| 372 | + | } | |
| 373 | + | end := bytes.Index(input[start:], []byte(`\n`)) | |
| 374 | + | if end < 0 { | |
| 375 | + | return input[start:] | |
| 376 | + | } | |
| 377 | + | return input[start : start+end] | |
| 146 | 378 | } | |
| 147 | - | func (cliProcess) Warmup(time.Duration) error { return nil } | |
| 148 | - | func (cliProcess) Measure(_ time.Duration, flush func()) error { flush(); return nil } | |
| 149 | - | func (cliProcess) Close() error { return nil } | |
| 150 | 379 | ||
| 151 | 380 | type harness struct { | |
| 152 | 381 | clock clock | |
| @@ -368,31 +597,46 @@ func (h *harness) runOne(o options, _ manifest, s scenario, run int, raw io.Writ | |||
| 368 | 597 | } | |
| 369 | 598 | }() | |
| 370 | 599 | for _, pm := range maps { | |
| 371 | - | p, e := h.launcher.Launch(pm, nil) | |
| 600 | + | p, e := h.launcher.Launch(pm, roleArgs(s, pm)) | |
| 372 | 601 | if e != nil { | |
| 373 | 602 | return res, e | |
| 374 | 603 | } | |
| 375 | 604 | processes = append(processes, p) | |
| 376 | 605 | } | |
| 377 | - | for _, p := range processes { | |
| 378 | - | if err = p.Warmup(o.warmup); err != nil { | |
| 606 | + | for i, p := range processes { | |
| 607 | + | if maps[i].Role != "client" { | |
| 608 | + | continue | |
| 609 | + | } | |
| 610 | + | if err = p.Warmup(workloadInput(s, run, "warmup")); err != nil { | |
| 379 | 611 | return res, err | |
| 380 | 612 | } | |
| 381 | 613 | } | |
| 614 | + | // The harness clock, rather than a process clock, owns the explicit warmup | |
| 615 | + | // interval. Fake clocks make this path instant in unit tests. | |
| 616 | + | h.clock.Sleep(o.warmup) | |
| 382 | 617 | marks := []harnessMark{} | |
| 383 | 618 | seq := uint64(0) | |
| 384 | - | flush := func() { | |
| 619 | + | for i, p := range processes { | |
| 620 | + | if maps[i].Role != "client" { | |
| 621 | + | continue | |
| 622 | + | } | |
| 385 | 623 | seq++ | |
| 386 | - | start := h.clock.Now() | |
| 387 | - | marks = append(marks, harnessMark{s.ID, run, seq, "input_injected", start, true}) | |
| 388 | - | end := h.clock.Now() | |
| 389 | - | marks = append(marks, harnessMark{s.ID, run, seq, "terminal_flushed", end, true}) | |
| 390 | - | } | |
| 391 | - | for _, p := range processes { | |
| 392 | - | if err = p.Measure(o.duration, flush); err != nil { | |
| 624 | + | sequence := seq | |
| 625 | + | injected := func() error { | |
| 626 | + | marks = append(marks, harnessMark{s.ID, run, sequence, "input_injected", h.clock.Now(), true}) | |
| 627 | + | return nil | |
| 628 | + | } | |
| 629 | + | flushed := func() error { | |
| 630 | + | marks = append(marks, harnessMark{s.ID, run, sequence, "terminal_flushed", h.clock.Now(), true}) | |
| 631 | + | return nil | |
| 632 | + | } | |
| 633 | + | if err = p.Measure(workloadInput(s, run, fmt.Sprintf("measured-%d", sequence)), injected, flushed); err != nil { | |
| 393 | 634 | return res, err | |
| 394 | 635 | } | |
| 395 | 636 | } | |
| 637 | + | // A measured run is never shorter than the requested duration in the | |
| 638 | + | // production clock domain. The terminal boundary stays inside that interval. | |
| 639 | + | h.clock.Sleep(o.duration) | |
| 396 | 640 | samples, e := pairedSamples(marks) | |
| 397 | 641 | if e != nil { | |
| 398 | 642 | return res, e | |
| @@ -409,6 +653,47 @@ func (h *harness) runOne(o options, _ manifest, s scenario, run int, raw io.Writ | |||
| 409 | 653 | success = true | |
| 410 | 654 | return runResult{Spans: spans, Scenario: s.ID, Run: run, Samples: len(samples), EndToEnd: samples, Processes: maps}, nil | |
| 411 | 655 | } | |
| 656 | + | ||
| 657 | + | func roleArgs(s scenario, m processMapping) []string { | |
| 658 | + | session := "perf-" + safeName(s.ID) + fmt.Sprintf("-%03d", m.Run) | |
| 659 | + | switch m.Role { | |
| 660 | + | case "daemon": | |
| 661 | + | return []string{"--daemon"} | |
| 662 | + | case "client": | |
| 663 | + | // The client is intentionally driven through its documented interactive | |
| 664 | + | // CLI. Transport peers below are independently launched public roles. | |
| 665 | + | return []string{"new", session} | |
| 666 | + | case "ssh_stdio_peer": | |
| 667 | + | return []string{"_stdio", session} | |
| 668 | + | case "udp_peer": | |
| 669 | + | return []string{"_udp-proxy", session} | |
| 670 | + | default: | |
| 671 | + | return nil | |
| 672 | + | } | |
| 673 | + | } | |
| 674 | + | ||
| 675 | + | // workloadInput is shell input delivered through the client PTY. It avoids any | |
| 676 | + | // private daemon API and makes every topology/workload/transport manifest row | |
| 677 | + | // execute a real terminal workload. The marker is the output boundary awaited | |
| 678 | + | // by cliProcess; it is not a synthetic timestamp. | |
| 679 | + | func workloadInput(s scenario, run int, phase string) []byte { | |
| 680 | + | marker := fmt.Sprintf("__VEV_HARNESS_%s_r%d_%s__", safeName(s.ID), run, safeName(phase)) | |
| 681 | + | body := "printf 'vev perf %s\\n'" | |
| 682 | + | switch s.Workload { | |
| 683 | + | case "active_output", "all_output", "inactive_output", "interactive_flood": | |
| 684 | + | body = "i=0; while [ $i -lt 128 ]; do printf 'vev perf output %s\\n'; i=$((i+1)); done" | |
| 685 | + | case "resize_sweep": | |
| 686 | + | body = "printf 'vev perf resize-sweep %s\\n'" | |
| 687 | + | case "copy_search": | |
| 688 | + | body = "printf 'vev perf copy-search %s\\n'" | |
| 689 | + | case "snapshot_output_resize": | |
| 690 | + | body = "printf 'vev perf snapshot-output-resize %s\\n'" | |
| 691 | + | case "attach_restore_tab_switch": | |
| 692 | + | body = "printf 'vev perf attach-restore-tab-switch %s\\n'" | |
| 693 | + | } | |
| 694 | + | return []byte(fmt.Sprintf(body+"; printf '%s\\n'\n", s.ID, marker)) | |
| 695 | + | } | |
| 696 | + | ||
| 412 | 697 | func pairedSamples(marks []harnessMark) ([]int64, error) { | |
| 413 | 698 | starts := map[uint64]int64{} | |
| 414 | 699 | var out []int64 | |
| @@ -505,13 +790,20 @@ func mergeProcessTraces(mappings []processMapping) ([]span, error) { | |||
| 505 | 790 | return nil, err | |
| 506 | 791 | } | |
| 507 | 792 | _ = f.Close() | |
| 793 | + | // Spans may overlap: an end for an older operation can legitimately be | |
| 794 | + | // serialized after a newer operation's start. New correlation IDs still | |
| 795 | + | // must be allocated monotonically within the process. | |
| 508 | 796 | lastSequence := uint64(0) | |
| 797 | + | seenSequences := map[uint64]bool{} | |
| 509 | 798 | starts := map[string]traceRecord{} | |
| 510 | 799 | for _, r := range records { | |
| 511 | - | if r.Sequence < lastSequence { | |
| 512 | - | return nil, errors.New("nonmonotonic same-process sequence") | |
| 800 | + | if !seenSequences[r.Sequence] { | |
| 801 | + | if r.Sequence < lastSequence { | |
| 802 | + | return nil, errors.New("nonmonotonic same-process sequence") | |
| 803 | + | } | |
| 804 | + | seenSequences[r.Sequence] = true | |
| 805 | + | lastSequence = r.Sequence | |
| 513 | 806 | } | |
| 514 | - | lastSequence = r.Sequence | |
| 515 | 807 | for _, pair := range spanPairs { | |
| 516 | 808 | key := fmt.Sprintf("%s/%s/%d/%d/%d/%d", r.ProcessID, m.Scenario, m.Run, r.Sequence, r.RequestID, r.Epoch) | |
| 517 | 809 | if r.Kind == pair.start { | |
| @@ -4,6 +4,7 @@ package main | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bufio" | 6 | "bufio" | ||
| 7 | + | "bytes" | |||
| 7 | "encoding/json" | 8 | "encoding/json" | ||
| 8 | "errors" | 9 | "errors" | ||
| 9 | "flag" | 10 | "flag" | ||
| @@ -14,9 +15,12 @@ import ( | |||||
| 14 | "path/filepath" | 15 | "path/filepath" | ||
| 15 | "sort" | 16 | "sort" | ||
| 16 | "strings" | 17 | "strings" | ||
| 18 | + | "sync" | |||
| 19 | + | "syscall" | |||
| 17 | "time" | 20 | "time" | ||
| 18 | 21 | ||||
| 19 | "github.com/bnema/vev/pkg/safedir" | 22 | "github.com/bnema/vev/pkg/safedir" | ||
| 23 | + | "golang.org/x/sys/unix" | |||
| 20 | ) | 24 | ) | ||
| 21 | 25 | ||||
| 22 | const ( | 26 | const ( | ||
| @@ -125,28 +129,253 @@ func (c systemClock) Now() int64 { return time.Since(c.start).Nanosecon | |||||
| 125 | func (systemClock) Sleep(d time.Duration) { time.Sleep(d) } | 129 | func (systemClock) Sleep(d time.Duration) { time.Sleep(d) } | ||
| 126 | 130 | ||||
| 127 | type launchedProcess interface { | 131 | type launchedProcess interface { | ||
| 128 | - | Warmup(time.Duration) error | 132 | + | Warmup([]byte) error |
| 129 | - | Measure(time.Duration, func()) error | 133 | + | Measure([]byte, func() error, func() error) error |
| 130 | Close() error | 134 | Close() error | ||
| 131 | } | 135 | } | ||
| 132 | type launcher interface { | 136 | type launcher interface { | ||
| 133 | Launch(processMapping, []string) (launchedProcess, error) | 137 | Launch(processMapping, []string) (launchedProcess, error) | ||
| 134 | } | 138 | } | ||
| 139 | + | ||||
| 140 | + | // cliLauncher is deliberately limited to executing the supplied public vev | |||
| 141 | + | // binary. Scenario orchestration never reaches into an in-process daemon. | |||
| 135 | type cliLauncher struct{ bin string } | 142 | type cliLauncher struct{ bin string } | ||
| 136 | - | type cliProcess struct{ cmd *exec.Cmd } | |||
| 137 | 143 | ||||
| 138 | - | // The default launcher intentionally invokes only the documented public binary. | 144 | + | type terminalOutput interface { |
| 139 | - | // Scenario drivers are passed as arguments from a manifest only after a future | 145 | + | io.Writer |
| 140 | - | // public driver contract exists; this measurement slice never reaches internals. | 146 | + | Sync() error |
| 141 | - | func (l cliLauncher) Launch(_ processMapping, _ []string) (launchedProcess, error) { | 147 | + | Close() error |
| 142 | - | if _, err := os.Stat(l.bin); err != nil { | 148 | + | } |
| 149 | + | ||||
| 150 | + | type cliProcess struct { | |||
| 151 | + | cmd *exec.Cmd | |||
| 152 | + | pty io.ReadWriteCloser | |||
| 153 | + | output terminalOutput | |||
| 154 | + | chunks chan []byte | |||
| 155 | + | done chan struct{} | |||
| 156 | + | closed sync.Once | |||
| 157 | + | waitErr chan error | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (l cliLauncher) Launch(m processMapping, args []string) (launchedProcess, error) { | |||
| 161 | + | if len(args) == 0 { | |||
| 162 | + | return nil, errors.New("public CLI role has no arguments") | |||
| 163 | + | } | |||
| 164 | + | if info, err := os.Stat(l.bin); err != nil { | |||
| 165 | + | return nil, err | |||
| 166 | + | } else if info.IsDir() { | |||
| 167 | + | return nil, fmt.Errorf("public CLI binary %q is a directory", l.bin) | |||
| 168 | + | } | |||
| 169 | + | cmd := exec.Command(l.bin, args...) | |||
| 170 | + | cmd.Env = append(withoutEnv(os.Environ(), "VEV"), "VEV_PERF_TRACE="+m.TracePath, "VEV_PERF_PROCESS_ID="+m.ProcessID, | |||
| 171 | + | "XDG_RUNTIME_DIR="+filepath.Join(filepath.Dir(m.TracePath), "runtime"), | |||
| 172 | + | "XDG_STATE_HOME="+filepath.Join(filepath.Dir(m.TracePath), "state"), "TERM=xterm-256color") | |||
| 173 | + | if err := safedir.EnsurePrivate(filepath.Join(filepath.Dir(m.TracePath), "runtime")); err != nil { | |||
| 174 | + | return nil, err | |||
| 175 | + | } | |||
| 176 | + | if err := safedir.EnsurePrivate(filepath.Join(filepath.Dir(m.TracePath), "state")); err != nil { | |||
| 143 | return nil, err | 177 | return nil, err | ||
| 144 | } | 178 | } | ||
| 145 | - | return cliProcess{}, nil | 179 | + | p := &cliProcess{cmd: cmd, waitErr: make(chan error, 1)} |
| 180 | + | if m.Role == "client" { | |||
| 181 | + | master, slave, err := openPTY() | |||
| 182 | + | if err != nil { | |||
| 183 | + | return nil, err | |||
| 184 | + | } | |||
| 185 | + | output, err := os.OpenFile(filepath.Join(filepath.Dir(m.TracePath), "terminal-output"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) | |||
| 186 | + | if err != nil { | |||
| 187 | + | _ = master.Close() | |||
| 188 | + | _ = slave.Close() | |||
| 189 | + | return nil, err | |||
| 190 | + | } | |||
| 191 | + | cmd.Stdin, cmd.Stdout, cmd.Stderr = slave, slave, slave | |||
| 192 | + | // Ctty is the child descriptor number after Start remaps slave to stdin. | |||
| 193 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0} | |||
| 194 | + | if err := cmd.Start(); err != nil { | |||
| 195 | + | _ = output.Close() | |||
| 196 | + | _ = master.Close() | |||
| 197 | + | _ = slave.Close() | |||
| 198 | + | return nil, err | |||
| 199 | + | } | |||
| 200 | + | _ = slave.Close() | |||
| 201 | + | p.pty, p.output, p.chunks, p.done = master, output, make(chan []byte, 32), make(chan struct{}) | |||
| 202 | + | go p.copyTerminal() | |||
| 203 | + | } else { | |||
| 204 | + | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |||
| 205 | + | if err != nil { | |||
| 206 | + | return nil, err | |||
| 207 | + | } | |||
| 208 | + | cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull | |||
| 209 | + | if err := cmd.Start(); err != nil { | |||
| 210 | + | _ = devNull.Close() | |||
| 211 | + | return nil, err | |||
| 212 | + | } | |||
| 213 | + | _ = devNull.Close() | |||
| 214 | + | } | |||
| 215 | + | go func() { p.waitErr <- cmd.Wait() }() | |||
| 216 | + | return p, nil | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func withoutEnv(env []string, names ...string) []string { | |||
| 220 | + | remove := make(map[string]bool, len(names)) | |||
| 221 | + | for _, name := range names { | |||
| 222 | + | remove[name] = true | |||
| 223 | + | } | |||
| 224 | + | out := make([]string, 0, len(env)) | |||
| 225 | + | for _, entry := range env { | |||
| 226 | + | name, _, _ := strings.Cut(entry, "=") | |||
| 227 | + | if !remove[name] { | |||
| 228 | + | out = append(out, entry) | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | return out | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func (p *cliProcess) copyTerminal() { | |||
| 235 | + | defer close(p.chunks) | |||
| 236 | + | buf := make([]byte, 4096) | |||
| 237 | + | for { | |||
| 238 | + | n, err := p.pty.Read(buf) | |||
| 239 | + | if n > 0 { | |||
| 240 | + | chunk := append([]byte(nil), buf[:n]...) | |||
| 241 | + | if _, writeErr := p.output.Write(chunk); writeErr != nil { | |||
| 242 | + | return | |||
| 243 | + | } | |||
| 244 | + | select { | |||
| 245 | + | case p.chunks <- chunk: | |||
| 246 | + | case <-p.done: | |||
| 247 | + | return | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | if err != nil { | |||
| 251 | + | return | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func (p *cliProcess) Warmup(input []byte) error { | |||
| 257 | + | if p.pty == nil || len(input) == 0 { | |||
| 258 | + | return nil | |||
| 259 | + | } | |||
| 260 | + | if _, err := p.pty.Write(input); err != nil { | |||
| 261 | + | return err | |||
| 262 | + | } | |||
| 263 | + | // Do not begin measured input until the public client has rendered a real | |||
| 264 | + | // shell response to warmup input. This is intentionally not a measurement. | |||
| 265 | + | return p.waitForTerminalMarker(inputMarker(input)) | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | // Measure records a boundary only after the harness-owned terminal output file | |||
| 269 | + | // has accepted data and Sync has reported a successful flush. The marker is | |||
| 270 | + | // injected through the real client PTY, never fabricated by the harness. | |||
| 271 | + | func (p *cliProcess) Measure(input []byte, injected, flushed func() error) error { | |||
| 272 | + | if p.pty == nil { | |||
| 273 | + | return errors.New("measured process has no terminal PTY") | |||
| 274 | + | } | |||
| 275 | + | if _, err := p.pty.Write(input); err != nil { | |||
| 276 | + | return err | |||
| 277 | + | } | |||
| 278 | + | // The injected callback runs only after the owned PTY accepts the bytes. | |||
| 279 | + | if err := injected(); err != nil { | |||
| 280 | + | return err | |||
| 281 | + | } | |||
| 282 | + | if err := p.waitForTerminalMarker(inputMarker(input)); err != nil { | |||
| 283 | + | return err | |||
| 284 | + | } | |||
| 285 | + | if err := p.output.Sync(); err != nil { | |||
| 286 | + | return fmt.Errorf("flush terminal output: %w", err) | |||
| 287 | + | } | |||
| 288 | + | return flushed() | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | func (p *cliProcess) waitForTerminalMarker(marker []byte) error { | |||
| 292 | + | var received []byte | |||
| 293 | + | timer := time.NewTimer(10 * time.Second) | |||
| 294 | + | defer timer.Stop() | |||
| 295 | + | for !bytes.Contains(received, marker) { | |||
| 296 | + | select { | |||
| 297 | + | case chunk, ok := <-p.chunks: | |||
| 298 | + | if !ok { | |||
| 299 | + | return errors.New("client terminal closed before terminal output") | |||
| 300 | + | } | |||
| 301 | + | received = append(received, chunk...) | |||
| 302 | + | case err := <-p.waitErr: | |||
| 303 | + | // Keep the exit result available to Close, which owns reaping. | |||
| 304 | + | p.waitErr <- err | |||
| 305 | + | if err == nil { | |||
| 306 | + | return errors.New("client exited before terminal output") | |||
| 307 | + | } | |||
| 308 | + | return fmt.Errorf("client exited before terminal output: %w", err) | |||
| 309 | + | case <-timer.C: | |||
| 310 | + | return errors.New("timed out waiting for terminal output") | |||
| 311 | + | } | |||
| 312 | + | } | |||
| 313 | + | return nil | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | func (p *cliProcess) Close() error { | |||
| 317 | + | var result error | |||
| 318 | + | p.closed.Do(func() { | |||
| 319 | + | if p.done != nil { | |||
| 320 | + | close(p.done) | |||
| 321 | + | } | |||
| 322 | + | if p.cmd != nil && p.cmd.Process != nil { | |||
| 323 | + | _ = p.cmd.Process.Signal(syscall.SIGTERM) | |||
| 324 | + | } | |||
| 325 | + | if p.pty != nil { | |||
| 326 | + | _ = p.pty.Close() | |||
| 327 | + | } | |||
| 328 | + | if p.output != nil { | |||
| 329 | + | result = p.output.Close() | |||
| 330 | + | } | |||
| 331 | + | if p.waitErr != nil { | |||
| 332 | + | select { | |||
| 333 | + | case <-p.waitErr: | |||
| 334 | + | // SIGTERM is the harness's normal deterministic cleanup path. | |||
| 335 | + | case <-time.After(2 * time.Second): | |||
| 336 | + | if p.cmd != nil && p.cmd.Process != nil { | |||
| 337 | + | _ = p.cmd.Process.Kill() | |||
| 338 | + | } | |||
| 339 | + | <-p.waitErr | |||
| 340 | + | } | |||
| 341 | + | } | |||
| 342 | + | }) | |||
| 343 | + | return result | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | // openPTY is the small Linux PTY boundary needed to drive the public terminal | |||
| 347 | + | // client. No daemon or adapter implementation is imported by this command. | |||
| 348 | + | func openPTY() (*os.File, *os.File, error) { | |||
| 349 | + | masterFD, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0) | |||
| 350 | + | if err != nil { | |||
| 351 | + | return nil, nil, err | |||
| 352 | + | } | |||
| 353 | + | closeMaster := func(err error) (*os.File, *os.File, error) { _ = unix.Close(masterFD); return nil, nil, err } | |||
| 354 | + | number, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN) | |||
| 355 | + | if err != nil { | |||
| 356 | + | return closeMaster(err) | |||
| 357 | + | } | |||
| 358 | + | if err := unix.IoctlSetPointerInt(masterFD, unix.TIOCSPTLCK, 0); err != nil { | |||
| 359 | + | return closeMaster(err) | |||
| 360 | + | } | |||
| 361 | + | slaveFD, err := unix.Open(fmt.Sprintf("/dev/pts/%d", number), unix.O_RDWR|unix.O_NOCTTY, 0) | |||
| 362 | + | if err != nil { | |||
| 363 | + | return closeMaster(err) | |||
| 364 | + | } | |||
| 365 | + | return os.NewFile(uintptr(masterFD), "vev-client-master"), os.NewFile(uintptr(slaveFD), "vev-client-slave"), nil | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | func inputMarker(input []byte) []byte { | |||
| 369 | + | start := bytes.Index(input, []byte("__VEV_HARNESS_")) | |||
| 370 | + | if start < 0 { | |||
| 371 | + | return input | |||
| 372 | + | } | |||
| 373 | + | end := bytes.Index(input[start:], []byte(`\n`)) | |||
| 374 | + | if end < 0 { | |||
| 375 | + | return input[start:] | |||
| 376 | + | } | |||
| 377 | + | return input[start : start+end] | |||
| 146 | } | 378 | } | ||
| 147 | - | func (cliProcess) Warmup(time.Duration) error { return nil } | |||
| 148 | - | func (cliProcess) Measure(_ time.Duration, flush func()) error { flush(); return nil } | |||
| 149 | - | func (cliProcess) Close() error { return nil } | |||
| 150 | 379 | ||||
| 151 | type harness struct { | 380 | type harness struct { | ||
| 152 | clock clock | 381 | clock clock | ||
| @@ -368,31 +597,46 @@ func (h *harness) runOne(o options, _ manifest, s scenario, run int, raw io.Writ | |||||
| 368 | } | 597 | } | ||
| 369 | }() | 598 | }() | ||
| 370 | for _, pm := range maps { | 599 | for _, pm := range maps { | ||
| 371 | - | p, e := h.launcher.Launch(pm, nil) | 600 | + | p, e := h.launcher.Launch(pm, roleArgs(s, pm)) |
| 372 | if e != nil { | 601 | if e != nil { | ||
| 373 | return res, e | 602 | return res, e | ||
| 374 | } | 603 | } | ||
| 375 | processes = append(processes, p) | 604 | processes = append(processes, p) | ||
| 376 | } | 605 | } | ||
| 377 | - | for _, p := range processes { | 606 | + | for i, p := range processes { |
| 378 | - | if err = p.Warmup(o.warmup); err != nil { | 607 | + | if maps[i].Role != "client" { |
| 608 | + | continue | |||
| 609 | + | } | |||
| 610 | + | if err = p.Warmup(workloadInput(s, run, "warmup")); err != nil { | |||
| 379 | return res, err | 611 | return res, err | ||
| 380 | } | 612 | } | ||
| 381 | } | 613 | } | ||
| 614 | + | // The harness clock, rather than a process clock, owns the explicit warmup | |||
| 615 | + | // interval. Fake clocks make this path instant in unit tests. | |||
| 616 | + | h.clock.Sleep(o.warmup) | |||
| 382 | marks := []harnessMark{} | 617 | marks := []harnessMark{} | ||
| 383 | seq := uint64(0) | 618 | seq := uint64(0) | ||
| 384 | - | flush := func() { | 619 | + | for i, p := range processes { |
| 620 | + | if maps[i].Role != "client" { | |||
| 621 | + | continue | |||
| 622 | + | } | |||
| 385 | seq++ | 623 | seq++ | ||
| 386 | - | start := h.clock.Now() | 624 | + | sequence := seq |
| 387 | - | marks = append(marks, harnessMark{s.ID, run, seq, "input_injected", start, true}) | 625 | + | injected := func() error { |
| 388 | - | end := h.clock.Now() | 626 | + | marks = append(marks, harnessMark{s.ID, run, sequence, "input_injected", h.clock.Now(), true}) |
| 389 | - | marks = append(marks, harnessMark{s.ID, run, seq, "terminal_flushed", end, true}) | 627 | + | return nil |
| 390 | - | } | 628 | + | } |
| 391 | - | for _, p := range processes { | 629 | + | flushed := func() error { |
| 392 | - | if err = p.Measure(o.duration, flush); err != nil { | 630 | + | marks = append(marks, harnessMark{s.ID, run, sequence, "terminal_flushed", h.clock.Now(), true}) |
| 631 | + | return nil | |||
| 632 | + | } | |||
| 633 | + | if err = p.Measure(workloadInput(s, run, fmt.Sprintf("measured-%d", sequence)), injected, flushed); err != nil { | |||
| 393 | return res, err | 634 | return res, err | ||
| 394 | } | 635 | } | ||
| 395 | } | 636 | } | ||
| 637 | + | // A measured run is never shorter than the requested duration in the | |||
| 638 | + | // production clock domain. The terminal boundary stays inside that interval. | |||
| 639 | + | h.clock.Sleep(o.duration) | |||
| 396 | samples, e := pairedSamples(marks) | 640 | samples, e := pairedSamples(marks) | ||
| 397 | if e != nil { | 641 | if e != nil { | ||
| 398 | return res, e | 642 | return res, e | ||
| @@ -409,6 +653,47 @@ func (h *harness) runOne(o options, _ manifest, s scenario, run int, raw io.Writ | |||||
| 409 | success = true | 653 | success = true | ||
| 410 | return runResult{Spans: spans, Scenario: s.ID, Run: run, Samples: len(samples), EndToEnd: samples, Processes: maps}, nil | 654 | return runResult{Spans: spans, Scenario: s.ID, Run: run, Samples: len(samples), EndToEnd: samples, Processes: maps}, nil | ||
| 411 | } | 655 | } | ||
| 656 | + | ||||
| 657 | + | func roleArgs(s scenario, m processMapping) []string { | |||
| 658 | + | session := "perf-" + safeName(s.ID) + fmt.Sprintf("-%03d", m.Run) | |||
| 659 | + | switch m.Role { | |||
| 660 | + | case "daemon": | |||
| 661 | + | return []string{"--daemon"} | |||
| 662 | + | case "client": | |||
| 663 | + | // The client is intentionally driven through its documented interactive | |||
| 664 | + | // CLI. Transport peers below are independently launched public roles. | |||
| 665 | + | return []string{"new", session} | |||
| 666 | + | case "ssh_stdio_peer": | |||
| 667 | + | return []string{"_stdio", session} | |||
| 668 | + | case "udp_peer": | |||
| 669 | + | return []string{"_udp-proxy", session} | |||
| 670 | + | default: | |||
| 671 | + | return nil | |||
| 672 | + | } | |||
| 673 | + | } | |||
| 674 | + | ||||
| 675 | + | // workloadInput is shell input delivered through the client PTY. It avoids any | |||
| 676 | + | // private daemon API and makes every topology/workload/transport manifest row | |||
| 677 | + | // execute a real terminal workload. The marker is the output boundary awaited | |||
| 678 | + | // by cliProcess; it is not a synthetic timestamp. | |||
| 679 | + | func workloadInput(s scenario, run int, phase string) []byte { | |||
| 680 | + | marker := fmt.Sprintf("__VEV_HARNESS_%s_r%d_%s__", safeName(s.ID), run, safeName(phase)) | |||
| 681 | + | body := "printf 'vev perf %s\\n'" | |||
| 682 | + | switch s.Workload { | |||
| 683 | + | case "active_output", "all_output", "inactive_output", "interactive_flood": | |||
| 684 | + | body = "i=0; while [ $i -lt 128 ]; do printf 'vev perf output %s\\n'; i=$((i+1)); done" | |||
| 685 | + | case "resize_sweep": | |||
| 686 | + | body = "printf 'vev perf resize-sweep %s\\n'" | |||
| 687 | + | case "copy_search": | |||
| 688 | + | body = "printf 'vev perf copy-search %s\\n'" | |||
| 689 | + | case "snapshot_output_resize": | |||
| 690 | + | body = "printf 'vev perf snapshot-output-resize %s\\n'" | |||
| 691 | + | case "attach_restore_tab_switch": | |||
| 692 | + | body = "printf 'vev perf attach-restore-tab-switch %s\\n'" | |||
| 693 | + | } | |||
| 694 | + | return []byte(fmt.Sprintf(body+"; printf '%s\\n'\n", s.ID, marker)) | |||
| 695 | + | } | |||
| 696 | + | ||||
| 412 | func pairedSamples(marks []harnessMark) ([]int64, error) { | 697 | func pairedSamples(marks []harnessMark) ([]int64, error) { | ||
| 413 | starts := map[uint64]int64{} | 698 | starts := map[uint64]int64{} | ||
| 414 | var out []int64 | 699 | var out []int64 | ||
| @@ -505,13 +790,20 @@ func mergeProcessTraces(mappings []processMapping) ([]span, error) { | |||||
| 505 | return nil, err | 790 | return nil, err | ||
| 506 | } | 791 | } | ||
| 507 | _ = f.Close() | 792 | _ = f.Close() | ||
| 793 | + | // Spans may overlap: an end for an older operation can legitimately be | |||
| 794 | + | // serialized after a newer operation's start. New correlation IDs still | |||
| 795 | + | // must be allocated monotonically within the process. | |||
| 508 | lastSequence := uint64(0) | 796 | lastSequence := uint64(0) | ||
| 797 | + | seenSequences := map[uint64]bool{} | |||
| 509 | starts := map[string]traceRecord{} | 798 | starts := map[string]traceRecord{} | ||
| 510 | for _, r := range records { | 799 | for _, r := range records { | ||
| 511 | - | if r.Sequence < lastSequence { | 800 | + | if !seenSequences[r.Sequence] { |
| 512 | - | return nil, errors.New("nonmonotonic same-process sequence") | 801 | + | if r.Sequence < lastSequence { |
| 802 | + | return nil, errors.New("nonmonotonic same-process sequence") | |||
| 803 | + | } | |||
| 804 | + | seenSequences[r.Sequence] = true | |||
| 805 | + | lastSequence = r.Sequence | |||
| 513 | } | 806 | } | ||
| 514 | - | lastSequence = r.Sequence | |||
| 515 | for _, pair := range spanPairs { | 807 | for _, pair := range spanPairs { | ||
| 516 | key := fmt.Sprintf("%s/%s/%d/%d/%d/%d", r.ProcessID, m.Scenario, m.Run, r.Sequence, r.RequestID, r.Epoch) | 808 | key := fmt.Sprintf("%s/%s/%d/%d/%d/%d", r.ProcessID, m.Scenario, m.Run, r.Sequence, r.RequestID, r.Epoch) | ||
| 517 | if r.Kind == pair.start { | 809 | if r.Kind == pair.start { | ||
M cmd/vev-perf-harness/main_test.go +126 -11
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "strings" | |
| 9 | 10 | "testing" | |
| 10 | 11 | "time" | |
| 11 | 12 | ) | |
| @@ -17,30 +18,37 @@ func (*fakeClock) Sleep(time.Duration) {} | |||
| 17 | 18 | ||
| 18 | 19 | type fakeProcess struct { | |
| 19 | 20 | fail bool | |
| 20 | - | warmups []time.Duration | |
| 21 | - | measures []time.Duration | |
| 21 | + | warmups [][]byte | |
| 22 | + | measures [][]byte | |
| 22 | 23 | closed bool | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 25 | - | func (p *fakeProcess) Warmup(d time.Duration) error { p.warmups = append(p.warmups, d); return nil } | |
| 26 | - | func (p *fakeProcess) Measure(d time.Duration, flush func()) error { | |
| 27 | - | p.measures = append(p.measures, d) | |
| 26 | + | func (p *fakeProcess) Warmup(input []byte) error { | |
| 27 | + | p.warmups = append(p.warmups, append([]byte(nil), input...)) | |
| 28 | + | return nil | |
| 29 | + | } | |
| 30 | + | func (p *fakeProcess) Measure(input []byte, injected, flush func() error) error { | |
| 31 | + | p.measures = append(p.measures, append([]byte(nil), input...)) | |
| 28 | 32 | if p.fail { | |
| 29 | 33 | return errors.New("measure failure") | |
| 30 | 34 | } | |
| 31 | - | flush() | |
| 32 | - | return nil | |
| 35 | + | if err := injected(); err != nil { | |
| 36 | + | return err | |
| 37 | + | } | |
| 38 | + | return flush() | |
| 33 | 39 | } | |
| 34 | 40 | func (p *fakeProcess) Close() error { p.closed = true; return nil } | |
| 35 | 41 | ||
| 36 | 42 | type fakeLauncher struct { | |
| 37 | 43 | mappings []processMapping | |
| 44 | + | args [][]string | |
| 38 | 45 | process []*fakeProcess | |
| 39 | 46 | manifestPresent bool | |
| 40 | 47 | } | |
| 41 | 48 | ||
| 42 | - | func (l *fakeLauncher) Launch(m processMapping, _ []string) (launchedProcess, error) { | |
| 49 | + | func (l *fakeLauncher) Launch(m processMapping, args []string) (launchedProcess, error) { | |
| 43 | 50 | l.mappings = append(l.mappings, m) | |
| 51 | + | l.args = append(l.args, append([]string(nil), args...)) | |
| 44 | 52 | _, e := os.Stat(filepath.Join(filepath.Dir(m.TracePath), "manifest.json")) | |
| 45 | 53 | l.manifestPresent = e == nil | |
| 46 | 54 | p := &fakeProcess{} | |
| @@ -102,7 +110,7 @@ func TestHarnessCreatesExclusiveTraceManifestAndEvidence(t *testing.T) { | |||
| 102 | 110 | if !launcher.manifestPresent { | |
| 103 | 111 | t.Fatal("launched before complete run manifest") | |
| 104 | 112 | } | |
| 105 | - | if len(r.EndToEnd) != 2 || r.Samples != 2 { | |
| 113 | + | if len(r.EndToEnd) != 1 || r.Samples != 1 { | |
| 106 | 114 | t.Fatalf("samples=%+v", r) | |
| 107 | 115 | } | |
| 108 | 116 | seen := map[string]bool{} | |
| @@ -126,11 +134,72 @@ func TestHarnessCreatesExclusiveTraceManifestAndEvidence(t *testing.T) { | |||
| 126 | 134 | if len(got.Processes) != 2 { | |
| 127 | 135 | t.Fatalf("manifest=%+v", got) | |
| 128 | 136 | } | |
| 129 | - | if len(launcher.process) != 2 || !launcher.process[0].closed || launcher.process[0].warmups[0] != time.Second || launcher.process[0].measures[0] != minimumDuration { | |
| 137 | + | if len(launcher.process) != 2 || !launcher.process[0].closed || len(launcher.process[0].warmups) != 0 || len(launcher.process[1].warmups) != 1 || len(launcher.process[1].measures) != 1 { | |
| 130 | 138 | t.Fatalf("process lifecycle not recorded: %+v", launcher.process) | |
| 131 | 139 | } | |
| 132 | 140 | } | |
| 133 | 141 | ||
| 142 | + | type fakePTY struct{ writes [][]byte } | |
| 143 | + | ||
| 144 | + | func (p *fakePTY) Read([]byte) (int, error) { return 0, os.ErrClosed } | |
| 145 | + | func (p *fakePTY) Write(b []byte) (int, error) { | |
| 146 | + | p.writes = append(p.writes, append([]byte(nil), b...)) | |
| 147 | + | return len(b), nil | |
| 148 | + | } | |
| 149 | + | func (*fakePTY) Close() error { return nil } | |
| 150 | + | ||
| 151 | + | type fakeOutput struct { | |
| 152 | + | bytes.Buffer | |
| 153 | + | syncs int | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (o *fakeOutput) Sync() error { o.syncs++; return nil } | |
| 157 | + | func (*fakeOutput) Close() error { return nil } | |
| 158 | + | ||
| 159 | + | func TestHarnessDoesNotInheritNestedSession(t *testing.T) { | |
| 160 | + | env := withoutEnv([]string{"VEV=session=old", "PATH=/bin", "VEV_PERF_TRACE=old"}, "VEV") | |
| 161 | + | if !equalStrings(env, []string{"PATH=/bin", "VEV_PERF_TRACE=old"}) { | |
| 162 | + | t.Fatalf("environment=%q", env) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestCLIProcessOwnsPTYInjectionAndSuccessfulFlushBoundary(t *testing.T) { | |
| 167 | + | pty, output := &fakePTY{}, &fakeOutput{} | |
| 168 | + | p := &cliProcess{pty: pty, output: output, chunks: make(chan []byte, 1)} | |
| 169 | + | input := workloadInput(scenario{ID: "s"}, 1, "measured-1") | |
| 170 | + | p.chunks <- append([]byte("terminal "), inputMarker(input)...) | |
| 171 | + | order := []string{} | |
| 172 | + | if err := p.Measure(input, func() error { order = append(order, "injected"); return nil }, func() error { order = append(order, "flushed"); return nil }); err != nil { | |
| 173 | + | t.Fatal(err) | |
| 174 | + | } | |
| 175 | + | if len(pty.writes) != 1 || output.syncs != 1 || !equalStrings(order, []string{"injected", "flushed"}) { | |
| 176 | + | t.Fatalf("writes=%q syncs=%d boundary order=%q", pty.writes, output.syncs, order) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) { | |
| 181 | + | for _, tc := range []struct { | |
| 182 | + | role string | |
| 183 | + | want []string | |
| 184 | + | }{ | |
| 185 | + | {"daemon", []string{"--daemon"}}, | |
| 186 | + | {"client", []string{"new", "perf-s-001"}}, | |
| 187 | + | {"ssh_stdio_peer", []string{"_stdio", "perf-s-001"}}, | |
| 188 | + | {"udp_peer", []string{"_udp-proxy", "perf-s-001"}}, | |
| 189 | + | } { | |
| 190 | + | t.Run(tc.role, func(t *testing.T) { | |
| 191 | + | got := roleArgs(scenario{ID: "s"}, processMapping{Role: tc.role, Run: 1}) | |
| 192 | + | if !equalStrings(got, tc.want) { | |
| 193 | + | t.Fatalf("args=%q want %q", got, tc.want) | |
| 194 | + | } | |
| 195 | + | }) | |
| 196 | + | } | |
| 197 | + | input := string(workloadInput(scenario{ID: "s", Workload: "interactive_flood"}, 1, "measured-1")) | |
| 198 | + | if string(inputMarker([]byte(input))) != "__VEV_HARNESS_s_r1_measured-1__" || !bytes.Contains([]byte(input), []byte("while [ $i -lt 128 ]")) || !strings.HasSuffix(input, "printf '__VEV_HARNESS_s_r1_measured-1__\\n'\n") { | |
| 199 | + | t.Fatalf("workload is not real PTY shell input with an observable marker: %q", input) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 134 | 203 | func TestHarnessRejectsInvalidBoundaryPairs(t *testing.T) { | |
| 135 | 204 | for _, marks := range [][]harnessMark{ | |
| 136 | 205 | {{Sequence: 1, Kind: "terminal_flushed", Tick: 1, Valid: true}}, | |
| @@ -148,6 +217,52 @@ func TestHarnessRejectsInvalidBoundaryPairs(t *testing.T) { | |||
| 148 | 217 | } | |
| 149 | 218 | } | |
| 150 | 219 | ||
| 220 | + | func TestHarnessCleansRunDirectoryOnProcessFailure(t *testing.T) { | |
| 221 | + | dir := t.TempDir() | |
| 222 | + | raw, err := os.OpenFile(filepath.Join(dir, "raw-harness.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |
| 223 | + | if err != nil { | |
| 224 | + | t.Fatal(err) | |
| 225 | + | } | |
| 226 | + | defer raw.Close() | |
| 227 | + | launcher := &fakeLauncher{} | |
| 228 | + | h := defaultHarness() | |
| 229 | + | h.clock = &fakeClock{} | |
| 230 | + | // The client is the second role and fails after the daemon has started. | |
| 231 | + | h.launcher = &failingLauncher{fakeLauncher: launcher} | |
| 232 | + | o := options{vevBin: "ignored", out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions} | |
| 233 | + | if _, err := h.runOne(o, manifest{}, scenario{ID: "cleanup", Roles: []string{"daemon", "client"}}, 1, raw); err == nil { | |
| 234 | + | t.Fatal("failed process accepted") | |
| 235 | + | } | |
| 236 | + | if _, err := os.Stat(filepath.Join(dir, "cleanup-run-001")); !os.IsNotExist(err) { | |
| 237 | + | t.Fatalf("failed run directory remains: %v", err) | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | type failingLauncher struct{ *fakeLauncher } | |
| 242 | + | ||
| 243 | + | func (l *failingLauncher) Launch(m processMapping, args []string) (launchedProcess, error) { | |
| 244 | + | p, err := l.fakeLauncher.Launch(m, args) | |
| 245 | + | if err != nil { | |
| 246 | + | return nil, err | |
| 247 | + | } | |
| 248 | + | if m.Role == "client" { | |
| 249 | + | p.(*fakeProcess).fail = true | |
| 250 | + | } | |
| 251 | + | return p, nil | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func equalStrings(a, b []string) bool { | |
| 255 | + | if len(a) != len(b) { | |
| 256 | + | return false | |
| 257 | + | } | |
| 258 | + | for i := range a { | |
| 259 | + | if a[i] != b[i] { | |
| 260 | + | return false | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | return true | |
| 264 | + | } | |
| 265 | + | ||
| 151 | 266 | func TestHarnessRawRecordsAreDeterministicJSONL(t *testing.T) { | |
| 152 | 267 | var b bytes.Buffer | |
| 153 | 268 | for _, m := range []harnessMark{{Scenario: "s", Run: 1, Sequence: 1, Kind: "input_injected", Tick: 1, Valid: true}, {Scenario: "s", Run: 1, Sequence: 1, Kind: "terminal_flushed", Tick: 2, Valid: true}} { | |
| @@ -222,7 +337,7 @@ func TestHarnessWritesRequiredEvidenceWithSufficientSpans(t *testing.T) { | |||
| 222 | 337 | if err := json.Unmarshal(b, &got); err != nil { | |
| 223 | 338 | t.Fatal(err) | |
| 224 | 339 | } | |
| 225 | - | if got.EndToEnd.Count != 20 || got.Repetitions != 10 || len(got.Spans) == 0 { | |
| 340 | + | if got.EndToEnd.Count != 10 || got.Repetitions != 10 || len(got.Spans) == 0 { | |
| 226 | 341 | t.Fatalf("summary=%+v", got) | |
| 227 | 342 | } | |
| 228 | 343 | for _, s := range got.Spans { | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | + | "strings" | |||
| 9 | "testing" | 10 | "testing" | ||
| 10 | "time" | 11 | "time" | ||
| 11 | ) | 12 | ) | ||
| @@ -17,30 +18,37 @@ func (*fakeClock) Sleep(time.Duration) {} | |||||
| 17 | 18 | ||||
| 18 | type fakeProcess struct { | 19 | type fakeProcess struct { | ||
| 19 | fail bool | 20 | fail bool | ||
| 20 | - | warmups []time.Duration | 21 | + | warmups [][]byte |
| 21 | - | measures []time.Duration | 22 | + | measures [][]byte |
| 22 | closed bool | 23 | closed bool | ||
| 23 | } | 24 | } | ||
| 24 | 25 | ||||
| 25 | - | func (p *fakeProcess) Warmup(d time.Duration) error { p.warmups = append(p.warmups, d); return nil } | 26 | + | func (p *fakeProcess) Warmup(input []byte) error { |
| 26 | - | func (p *fakeProcess) Measure(d time.Duration, flush func()) error { | 27 | + | p.warmups = append(p.warmups, append([]byte(nil), input...)) |
| 27 | - | p.measures = append(p.measures, d) | 28 | + | return nil |
| 29 | + | } | |||
| 30 | + | func (p *fakeProcess) Measure(input []byte, injected, flush func() error) error { | |||
| 31 | + | p.measures = append(p.measures, append([]byte(nil), input...)) | |||
| 28 | if p.fail { | 32 | if p.fail { | ||
| 29 | return errors.New("measure failure") | 33 | return errors.New("measure failure") | ||
| 30 | } | 34 | } | ||
| 31 | - | flush() | 35 | + | if err := injected(); err != nil { |
| 32 | - | return nil | 36 | + | return err |
| 37 | + | } | |||
| 38 | + | return flush() | |||
| 33 | } | 39 | } | ||
| 34 | func (p *fakeProcess) Close() error { p.closed = true; return nil } | 40 | func (p *fakeProcess) Close() error { p.closed = true; return nil } | ||
| 35 | 41 | ||||
| 36 | type fakeLauncher struct { | 42 | type fakeLauncher struct { | ||
| 37 | mappings []processMapping | 43 | mappings []processMapping | ||
| 44 | + | args [][]string | |||
| 38 | process []*fakeProcess | 45 | process []*fakeProcess | ||
| 39 | manifestPresent bool | 46 | manifestPresent bool | ||
| 40 | } | 47 | } | ||
| 41 | 48 | ||||
| 42 | - | func (l *fakeLauncher) Launch(m processMapping, _ []string) (launchedProcess, error) { | 49 | + | func (l *fakeLauncher) Launch(m processMapping, args []string) (launchedProcess, error) { |
| 43 | l.mappings = append(l.mappings, m) | 50 | l.mappings = append(l.mappings, m) | ||
| 51 | + | l.args = append(l.args, append([]string(nil), args...)) | |||
| 44 | _, e := os.Stat(filepath.Join(filepath.Dir(m.TracePath), "manifest.json")) | 52 | _, e := os.Stat(filepath.Join(filepath.Dir(m.TracePath), "manifest.json")) | ||
| 45 | l.manifestPresent = e == nil | 53 | l.manifestPresent = e == nil | ||
| 46 | p := &fakeProcess{} | 54 | p := &fakeProcess{} | ||
| @@ -102,7 +110,7 @@ func TestHarnessCreatesExclusiveTraceManifestAndEvidence(t *testing.T) { | |||||
| 102 | if !launcher.manifestPresent { | 110 | if !launcher.manifestPresent { | ||
| 103 | t.Fatal("launched before complete run manifest") | 111 | t.Fatal("launched before complete run manifest") | ||
| 104 | } | 112 | } | ||
| 105 | - | if len(r.EndToEnd) != 2 || r.Samples != 2 { | 113 | + | if len(r.EndToEnd) != 1 || r.Samples != 1 { |
| 106 | t.Fatalf("samples=%+v", r) | 114 | t.Fatalf("samples=%+v", r) | ||
| 107 | } | 115 | } | ||
| 108 | seen := map[string]bool{} | 116 | seen := map[string]bool{} | ||
| @@ -126,11 +134,72 @@ func TestHarnessCreatesExclusiveTraceManifestAndEvidence(t *testing.T) { | |||||
| 126 | if len(got.Processes) != 2 { | 134 | if len(got.Processes) != 2 { | ||
| 127 | t.Fatalf("manifest=%+v", got) | 135 | t.Fatalf("manifest=%+v", got) | ||
| 128 | } | 136 | } | ||
| 129 | - | if len(launcher.process) != 2 || !launcher.process[0].closed || launcher.process[0].warmups[0] != time.Second || launcher.process[0].measures[0] != minimumDuration { | 137 | + | if len(launcher.process) != 2 || !launcher.process[0].closed || len(launcher.process[0].warmups) != 0 || len(launcher.process[1].warmups) != 1 || len(launcher.process[1].measures) != 1 { |
| 130 | t.Fatalf("process lifecycle not recorded: %+v", launcher.process) | 138 | t.Fatalf("process lifecycle not recorded: %+v", launcher.process) | ||
| 131 | } | 139 | } | ||
| 132 | } | 140 | } | ||
| 133 | 141 | ||||
| 142 | + | type fakePTY struct{ writes [][]byte } | |||
| 143 | + | ||||
| 144 | + | func (p *fakePTY) Read([]byte) (int, error) { return 0, os.ErrClosed } | |||
| 145 | + | func (p *fakePTY) Write(b []byte) (int, error) { | |||
| 146 | + | p.writes = append(p.writes, append([]byte(nil), b...)) | |||
| 147 | + | return len(b), nil | |||
| 148 | + | } | |||
| 149 | + | func (*fakePTY) Close() error { return nil } | |||
| 150 | + | ||||
| 151 | + | type fakeOutput struct { | |||
| 152 | + | bytes.Buffer | |||
| 153 | + | syncs int | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func (o *fakeOutput) Sync() error { o.syncs++; return nil } | |||
| 157 | + | func (*fakeOutput) Close() error { return nil } | |||
| 158 | + | ||||
| 159 | + | func TestHarnessDoesNotInheritNestedSession(t *testing.T) { | |||
| 160 | + | env := withoutEnv([]string{"VEV=session=old", "PATH=/bin", "VEV_PERF_TRACE=old"}, "VEV") | |||
| 161 | + | if !equalStrings(env, []string{"PATH=/bin", "VEV_PERF_TRACE=old"}) { | |||
| 162 | + | t.Fatalf("environment=%q", env) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestCLIProcessOwnsPTYInjectionAndSuccessfulFlushBoundary(t *testing.T) { | |||
| 167 | + | pty, output := &fakePTY{}, &fakeOutput{} | |||
| 168 | + | p := &cliProcess{pty: pty, output: output, chunks: make(chan []byte, 1)} | |||
| 169 | + | input := workloadInput(scenario{ID: "s"}, 1, "measured-1") | |||
| 170 | + | p.chunks <- append([]byte("terminal "), inputMarker(input)...) | |||
| 171 | + | order := []string{} | |||
| 172 | + | if err := p.Measure(input, func() error { order = append(order, "injected"); return nil }, func() error { order = append(order, "flushed"); return nil }); err != nil { | |||
| 173 | + | t.Fatal(err) | |||
| 174 | + | } | |||
| 175 | + | if len(pty.writes) != 1 || output.syncs != 1 || !equalStrings(order, []string{"injected", "flushed"}) { | |||
| 176 | + | t.Fatalf("writes=%q syncs=%d boundary order=%q", pty.writes, output.syncs, order) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) { | |||
| 181 | + | for _, tc := range []struct { | |||
| 182 | + | role string | |||
| 183 | + | want []string | |||
| 184 | + | }{ | |||
| 185 | + | {"daemon", []string{"--daemon"}}, | |||
| 186 | + | {"client", []string{"new", "perf-s-001"}}, | |||
| 187 | + | {"ssh_stdio_peer", []string{"_stdio", "perf-s-001"}}, | |||
| 188 | + | {"udp_peer", []string{"_udp-proxy", "perf-s-001"}}, | |||
| 189 | + | } { | |||
| 190 | + | t.Run(tc.role, func(t *testing.T) { | |||
| 191 | + | got := roleArgs(scenario{ID: "s"}, processMapping{Role: tc.role, Run: 1}) | |||
| 192 | + | if !equalStrings(got, tc.want) { | |||
| 193 | + | t.Fatalf("args=%q want %q", got, tc.want) | |||
| 194 | + | } | |||
| 195 | + | }) | |||
| 196 | + | } | |||
| 197 | + | input := string(workloadInput(scenario{ID: "s", Workload: "interactive_flood"}, 1, "measured-1")) | |||
| 198 | + | if string(inputMarker([]byte(input))) != "__VEV_HARNESS_s_r1_measured-1__" || !bytes.Contains([]byte(input), []byte("while [ $i -lt 128 ]")) || !strings.HasSuffix(input, "printf '__VEV_HARNESS_s_r1_measured-1__\\n'\n") { | |||
| 199 | + | t.Fatalf("workload is not real PTY shell input with an observable marker: %q", input) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | ||||
| 134 | func TestHarnessRejectsInvalidBoundaryPairs(t *testing.T) { | 203 | func TestHarnessRejectsInvalidBoundaryPairs(t *testing.T) { | ||
| 135 | for _, marks := range [][]harnessMark{ | 204 | for _, marks := range [][]harnessMark{ | ||
| 136 | {{Sequence: 1, Kind: "terminal_flushed", Tick: 1, Valid: true}}, | 205 | {{Sequence: 1, Kind: "terminal_flushed", Tick: 1, Valid: true}}, | ||
| @@ -148,6 +217,52 @@ func TestHarnessRejectsInvalidBoundaryPairs(t *testing.T) { | |||||
| 148 | } | 217 | } | ||
| 149 | } | 218 | } | ||
| 150 | 219 | ||||
| 220 | + | func TestHarnessCleansRunDirectoryOnProcessFailure(t *testing.T) { | |||
| 221 | + | dir := t.TempDir() | |||
| 222 | + | raw, err := os.OpenFile(filepath.Join(dir, "raw-harness.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |||
| 223 | + | if err != nil { | |||
| 224 | + | t.Fatal(err) | |||
| 225 | + | } | |||
| 226 | + | defer raw.Close() | |||
| 227 | + | launcher := &fakeLauncher{} | |||
| 228 | + | h := defaultHarness() | |||
| 229 | + | h.clock = &fakeClock{} | |||
| 230 | + | // The client is the second role and fails after the daemon has started. | |||
| 231 | + | h.launcher = &failingLauncher{fakeLauncher: launcher} | |||
| 232 | + | o := options{vevBin: "ignored", out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions} | |||
| 233 | + | if _, err := h.runOne(o, manifest{}, scenario{ID: "cleanup", Roles: []string{"daemon", "client"}}, 1, raw); err == nil { | |||
| 234 | + | t.Fatal("failed process accepted") | |||
| 235 | + | } | |||
| 236 | + | if _, err := os.Stat(filepath.Join(dir, "cleanup-run-001")); !os.IsNotExist(err) { | |||
| 237 | + | t.Fatalf("failed run directory remains: %v", err) | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | type failingLauncher struct{ *fakeLauncher } | |||
| 242 | + | ||||
| 243 | + | func (l *failingLauncher) Launch(m processMapping, args []string) (launchedProcess, error) { | |||
| 244 | + | p, err := l.fakeLauncher.Launch(m, args) | |||
| 245 | + | if err != nil { | |||
| 246 | + | return nil, err | |||
| 247 | + | } | |||
| 248 | + | if m.Role == "client" { | |||
| 249 | + | p.(*fakeProcess).fail = true | |||
| 250 | + | } | |||
| 251 | + | return p, nil | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func equalStrings(a, b []string) bool { | |||
| 255 | + | if len(a) != len(b) { | |||
| 256 | + | return false | |||
| 257 | + | } | |||
| 258 | + | for i := range a { | |||
| 259 | + | if a[i] != b[i] { | |||
| 260 | + | return false | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | return true | |||
| 264 | + | } | |||
| 265 | + | ||||
| 151 | func TestHarnessRawRecordsAreDeterministicJSONL(t *testing.T) { | 266 | func TestHarnessRawRecordsAreDeterministicJSONL(t *testing.T) { | ||
| 152 | var b bytes.Buffer | 267 | var b bytes.Buffer | ||
| 153 | for _, m := range []harnessMark{{Scenario: "s", Run: 1, Sequence: 1, Kind: "input_injected", Tick: 1, Valid: true}, {Scenario: "s", Run: 1, Sequence: 1, Kind: "terminal_flushed", Tick: 2, Valid: true}} { | 268 | for _, m := range []harnessMark{{Scenario: "s", Run: 1, Sequence: 1, Kind: "input_injected", Tick: 1, Valid: true}, {Scenario: "s", Run: 1, Sequence: 1, Kind: "terminal_flushed", Tick: 2, Valid: true}} { | ||
| @@ -222,7 +337,7 @@ func TestHarnessWritesRequiredEvidenceWithSufficientSpans(t *testing.T) { | |||||
| 222 | if err := json.Unmarshal(b, &got); err != nil { | 337 | if err := json.Unmarshal(b, &got); err != nil { | ||
| 223 | t.Fatal(err) | 338 | t.Fatal(err) | ||
| 224 | } | 339 | } | ||
| 225 | - | if got.EndToEnd.Count != 20 || got.Repetitions != 10 || len(got.Spans) == 0 { | 340 | + | if got.EndToEnd.Count != 10 || got.Repetitions != 10 || len(got.Spans) == 0 { |
| 226 | t.Fatalf("summary=%+v", got) | 341 | t.Fatalf("summary=%+v", got) | ||
| 227 | } | 342 | } | ||
| 228 | for _, s := range got.Spans { | 343 | for _, s := range got.Spans { | ||