detached 1cb3d81

fix(obs): route remote harness transports

commit
1cb3d81ba404fa5398492b7bbad551c315ee2089
author
brice · · 2026-07-12 23:03 UTC
parent
182a466
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +816 -46
M cmd/vev-perf-harness/main.go +289 -34
@@ -14,6 +14,7 @@ import (
1414 "os/exec"
1515 "path/filepath"
1616 "sort"
17+ "strconv"
1718 "strings"
1819 "sync"
1920 "syscall"
@@ -133,13 +134,57 @@ type launchedProcess interface {
133134 Measure([]byte, func() error, func() error) error
134135 Close() error
135136 }
137+
138+// roleCommand contains only argv accepted by app.parseArgs. Transport routing
139+// metadata configures the harness's public-command seam; it is never passed as
140+// an unrecognised vev flag.
141+type roleCommand struct {
142+ Args []string
143+ Transport transport
144+}
145+
136146 type launcher interface {
137- Launch(processMapping, []string) (launchedProcess, error)
147+ Launch(processMapping, roleCommand) (launchedProcess, error)
138148 }
139149
140150 // cliLauncher is deliberately limited to executing the supplied public vev
141151 // binary. Scenario orchestration never reaches into an in-process daemon.
142-type cliLauncher struct{ bin string }
152+type cliLauncher struct {
153+ bin string
154+ mu sync.Mutex
155+ peers map[string]peerRoute
156+}
157+
158+type peerRoute struct {
159+ mapping processMapping
160+ command roleCommand
161+ pidPath string
162+}
163+
164+type preparedPeer struct{ route peerRoute }
165+
166+func (p preparedPeer) Warmup([]byte) error { return nil }
167+func (p preparedPeer) Measure([]byte, func() error, func() error) error { return nil }
168+func (p preparedPeer) Close() error {
169+ if p.route.pidPath == "" {
170+ return nil
171+ }
172+ b, err := os.ReadFile(p.route.pidPath)
173+ if errors.Is(err, os.ErrNotExist) {
174+ return nil
175+ }
176+ if err != nil {
177+ return err
178+ }
179+ pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
180+ if err != nil || pid <= 0 {
181+ return fmt.Errorf("invalid owned peer pid: %q", b)
182+ }
183+ if err := syscall.Kill(pid, syscall.SIGTERM); err != nil && !errors.Is(err, syscall.ESRCH) {
184+ return err
185+ }
186+ return nil
187+}
143188
144189 type terminalOutput interface {
145190 io.Writer
@@ -157,8 +202,8 @@ type cliProcess struct {
157202 waitErr chan error
158203 }
159204
160-func (l cliLauncher) Launch(m processMapping, args []string) (launchedProcess, error) {
161- if len(args) == 0 {
205+func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProcess, error) {
206+ if len(role.Args) == 0 {
162207 return nil, errors.New("public CLI role has no arguments")
163208 }
164209 if info, err := os.Stat(l.bin); err != nil {
@@ -166,14 +211,30 @@ func (l cliLauncher) Launch(m processMapping, args []string) (launchedProcess, e
166211 } else if info.IsDir() {
167212 return nil, fmt.Errorf("public CLI binary %q is a directory", l.bin)
168213 }
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 {
214+ if m.Role == "ssh_stdio_peer" || m.Role == "udp_peer" {
215+ return l.preparePeer(m, role)
216+ }
217+ cmd := exec.Command(l.bin, role.Args...)
218+ runDir := filepath.Dir(m.TracePath)
219+ cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_REMOTE_TRANSPORT"), "VEV_PERF_TRACE="+m.TracePath, "VEV_PERF_PROCESS_ID="+m.ProcessID,
220+ "XDG_RUNTIME_DIR="+filepath.Join(runDir, "runtime"),
221+ "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "TERM=xterm-256color")
222+ if m.Role == "client" {
223+ l.mu.Lock()
224+ peer, routed := l.peers[runDir]
225+ l.mu.Unlock()
226+ if routed {
227+ mode, err := remoteMode(peer.command.Transport)
228+ if err != nil {
229+ return nil, err
230+ }
231+ cmd.Env = append(cmd.Env, "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode)
232+ }
233+ }
234+ if err := safedir.EnsurePrivate(filepath.Join(runDir, "runtime")); err != nil {
174235 return nil, err
175236 }
176- if err := safedir.EnsurePrivate(filepath.Join(filepath.Dir(m.TracePath), "state")); err != nil {
237+ if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
177238 return nil, err
178239 }
179240 p := &cliProcess{cmd: cmd, waitErr: make(chan error, 1)}
@@ -216,6 +277,82 @@ func (l cliLauncher) Launch(m processMapping, args []string) (launchedProcess, e
216277 return p, nil
217278 }
218279
280+// preparePeer installs a per-run ssh command seam. The ordinary public client
281+// still executes its documented remote attach command; its ssh child invokes
282+// only the parsed public _stdio or _udp-proxy command. This makes the peer
283+// carrying client traffic the declared, exclusively traced role rather than a
284+// separately launched but unused process.
285+func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedProcess, error) {
286+ runDir := filepath.Dir(m.TracePath)
287+ if err := safedir.EnsurePrivate(runDir); err != nil {
288+ return nil, err
289+ }
290+ if err := safedir.EnsurePrivate(filepath.Join(runDir, "runtime")); err != nil {
291+ return nil, err
292+ }
293+ if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
294+ return nil, err
295+ }
296+ if len(role.Args) != 2 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") {
297+ return nil, fmt.Errorf("unsupported public peer command %q", role.Args)
298+ }
299+ route := peerRoute{mapping: m, command: role}
300+ if m.Role == "udp_peer" {
301+ route.pidPath = filepath.Join(runDir, "udp-peer.pid")
302+ }
303+ shim := filepath.Join(runDir, "ssh")
304+ ready := filepath.Join(runDir, "udp-peer.ready")
305+ stderr := filepath.Join(runDir, "udp-peer.stderr")
306+ // The bootstrap protocol requires the SSH command to exit after forwarding
307+ // the readiness line. The long-lived proxy is owned by pidPath and receives
308+ // the role's unique trace identity, never the bootstrap/client identity.
309+ body := fmt.Sprintf(`#!/bin/sh
310+set -eu
311+case "$*" in
312+ *"_stdio"*)
313+ exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _stdio %[6]q
314+ ;;
315+ *"_udp-bootstrap"*)
316+ rm -f %[7]q %[8]q
317+ env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color VEV_PERF_UDP_RTT_MS=%[9]q VEV_PERF_UDP_LOSS_PERCENT=%[10]q %[5]q _udp-proxy %[6]q >%[7]q 2>%[8]q &
318+ peer=$!
319+ printf '%%s\\n' "$peer" > %[11]q
320+ i=0
321+ while [ ! -s %[7]q ] && [ "$i" -lt 1000 ]; do sleep 0.01; i=$((i+1)); done
322+ test -s %[7]q
323+ head -n 1 %[7]q
324+ exit 0
325+ ;;
326+ *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;;
327+esac
328+`, m.TracePath, m.ProcessID, filepath.Join(runDir, "runtime"), filepath.Join(runDir, "state"), l.bin, role.Args[1], ready, stderr, strconv.Itoa(role.Transport.RTTMS), strconv.Itoa(role.Transport.LossPercent), route.pidPath)
329+ if err := os.WriteFile(shim, []byte(body), 0o700); err != nil {
330+ return nil, err
331+ }
332+ l.mu.Lock()
333+ if l.peers == nil {
334+ l.peers = make(map[string]peerRoute)
335+ }
336+ if _, exists := l.peers[runDir]; exists {
337+ l.mu.Unlock()
338+ return nil, fmt.Errorf("duplicate transport peer for %s", runDir)
339+ }
340+ l.peers[runDir] = route
341+ l.mu.Unlock()
342+ return preparedPeer{route: route}, nil
343+}
344+
345+func remoteMode(t transport) (string, error) {
346+ switch t.Kind {
347+ case "ssh_stdio":
348+ return "stdio", nil
349+ case "udp":
350+ return "udp", nil
351+ default:
352+ return "", fmt.Errorf("transport %q cannot route through a remote peer", t.ID)
353+ }
354+}
355+
219356 func withoutEnv(env []string, names ...string) []string {
220357 remove := make(map[string]bool, len(names))
221358 for _, name := range names {
@@ -320,7 +457,9 @@ func (p *cliProcess) Close() error {
320457 close(p.done)
321458 }
322459 if p.cmd != nil && p.cmd.Process != nil {
323- _ = p.cmd.Process.Signal(syscall.SIGTERM)
460+ // The client is a session/process-group leader; terminate its ssh
461+ // seam/_stdio descendants as well as the terminal client itself.
462+ _ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGTERM)
324463 }
325464 if p.pty != nil {
326465 _ = p.pty.Close()
@@ -512,9 +651,12 @@ func validateManifest(m manifest) error {
512651 if t.ID == "" {
513652 return errors.New("transport missing id")
514653 }
654+ if err := validateTransportFixture(t); err != nil {
655+ return err
656+ }
515657 trans[t.ID] = true
516658 }
517- for _, v := range []string{"local", "ssh_stdio", "udp_baseline", "udp_25ms", "udp_100ms", "udp_loss_1pct"} {
659+ for _, v := range []string{"local", "ssh_stdio", "udp_baseline", "udp_25ms", "udp_100ms", "udp_loss_0pct", "udp_loss_1pct"} {
518660 if !trans[v] {
519661 return fmt.Errorf("missing canonical transport %s", v)
520662 }
@@ -535,6 +677,9 @@ func validateManifest(m manifest) error {
535677 if s.InapplicableReason != "" && len(s.Roles) != 0 {
536678 return fmt.Errorf("inapplicable scenario %s must not launch roles", s.ID)
537679 }
680+ if s.InapplicableReason == "" && !equalRoleSet(s.Roles, requiredRoles(s.Transport)) {
681+ return fmt.Errorf("scenario %s roles do not route transport %s", s.ID, s.Transport)
682+ }
538683 covered[s.Topology+"\x00"+s.Workload+"\x00"+s.Transport] = true
539684 }
540685 for topologyID := range tops {
@@ -549,6 +694,50 @@ func validateManifest(m manifest) error {
549694 }
550695 return nil
551696 }
697+func requiredRoles(transportID string) []string {
698+ switch transportID {
699+ case "local":
700+ return []string{"daemon", "client"}
701+ case "ssh_stdio":
702+ return []string{"daemon", "client", "ssh_stdio_peer"}
703+ default:
704+ return []string{"daemon", "client", "udp_peer"}
705+ }
706+}
707+
708+func equalRoleSet(got, want []string) bool {
709+ if len(got) != len(want) {
710+ return false
711+ }
712+ seen := make(map[string]bool, len(got))
713+ for _, role := range got {
714+ seen[role] = true
715+ }
716+ for _, role := range want {
717+ if !seen[role] {
718+ return false
719+ }
720+ }
721+ return true
722+}
723+
724+func validateTransportFixture(t transport) error {
725+ want := map[string]transport{
726+ "local": {ID: "local", Kind: "local"},
727+ "ssh_stdio": {ID: "ssh_stdio", Kind: "ssh_stdio"},
728+ "udp_baseline": {ID: "udp_baseline", Kind: "udp"},
729+ "udp_25ms": {ID: "udp_25ms", Kind: "udp", RTTMS: 25},
730+ "udp_100ms": {ID: "udp_100ms", Kind: "udp", RTTMS: 100},
731+ "udp_loss_0pct": {ID: "udp_loss_0pct", Kind: "udp", LossPercent: 0},
732+ "udp_loss_1pct": {ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1},
733+ }
734+ w, ok := want[t.ID]
735+ if !ok || t.Kind != w.Kind || t.RTTMS != w.RTTMS || t.LossPercent != w.LossPercent {
736+ return fmt.Errorf("invalid canonical transport fixture %+v", t)
737+ }
738+ return nil
739+}
740+
552741 func set(v []string) map[string]bool {
553742 r := map[string]bool{}
554743 for _, x := range v {
@@ -556,7 +745,7 @@ func set(v []string) map[string]bool {
556745 }
557746 return r
558747 }
559-func (h *harness) runOne(o options, _ manifest, s scenario, run int, raw io.Writer) (res runResult, err error) {
748+func (h *harness) runOne(o options, mat manifest, s scenario, run int, raw io.Writer) (res runResult, err error) {
560749 dir := filepath.Join(o.out, fmt.Sprintf("%s-run-%03d", safeName(s.ID), run))
561750 if err = h.mkdir(dir); err != nil {
562751 return res, err
@@ -586,28 +775,38 @@ func (h *harness) runOne(o options, _ manifest, s scenario, run int, raw io.Writ
586775 return res, err
587776 }
588777 if h.launcher == nil {
589- h.launcher = cliLauncher{o.vevBin}
778+ h.launcher = &cliLauncher{bin: o.vevBin}
779+ }
780+ type launchedRole struct {
781+ mapping processMapping
782+ process launchedProcess
590783 }
591- processes := make([]launchedProcess, 0, len(maps))
784+ processes := make([]launchedRole, 0, len(maps))
592785 defer func() {
593- for _, p := range processes {
594- if e := p.Close(); err == nil && e != nil {
786+ // Clients own SSH-stdio descendants; close them before the deferred UDP
787+ // peer and daemon so no role survives a failed or completed run.
788+ for i := len(processes) - 1; i >= 0; i-- {
789+ if e := processes[i].process.Close(); err == nil && e != nil {
595790 err = e
596791 }
597792 }
598793 }()
599- for _, pm := range maps {
600- p, e := h.launcher.Launch(pm, roleArgs(s, pm))
794+ selectedTransport, e := manifestTransport(mat, s.Transport)
795+ if e != nil {
796+ return res, e
797+ }
798+ for _, pm := range launchOrder(maps) {
799+ p, e := h.launcher.Launch(pm, routeRoleArgs(s, pm, selectedTransport))
601800 if e != nil {
602801 return res, e
603802 }
604- processes = append(processes, p)
803+ processes = append(processes, launchedRole{mapping: pm, process: p})
605804 }
606- for i, p := range processes {
607- if maps[i].Role != "client" {
805+ for _, p := range processes {
806+ if p.mapping.Role != "client" {
608807 continue
609808 }
610- if err = p.Warmup(workloadInput(s, run, "warmup")); err != nil {
809+ if err = p.process.Warmup(workloadInput(s, run, "warmup")); err != nil {
611810 return res, err
612811 }
613812 }
@@ -616,8 +815,8 @@ func (h *harness) runOne(o options, _ manifest, s scenario, run int, raw io.Writ
616815 h.clock.Sleep(o.warmup)
617816 marks := []harnessMark{}
618817 seq := uint64(0)
619- for i, p := range processes {
620- if maps[i].Role != "client" {
818+ for _, p := range processes {
819+ if p.mapping.Role != "client" {
621820 continue
622821 }
623822 seq++
@@ -630,7 +829,7 @@ func (h *harness) runOne(o options, _ manifest, s scenario, run int, raw io.Writ
630829 marks = append(marks, harnessMark{s.ID, run, sequence, "terminal_flushed", h.clock.Now(), true})
631830 return nil
632831 }
633- if err = p.Measure(workloadInput(s, run, fmt.Sprintf("measured-%d", sequence)), injected, flushed); err != nil {
832+ if err = p.process.Measure(workloadInput(s, run, fmt.Sprintf("measured-%d", sequence)), injected, flushed); err != nil {
634833 return res, err
635834 }
636835 }
@@ -654,22 +853,78 @@ func (h *harness) runOne(o options, _ manifest, s scenario, run int, raw io.Writ
654853 return runResult{Spans: spans, Scenario: s.ID, Run: run, Samples: len(samples), EndToEnd: samples, Processes: maps}, nil
655854 }
656855
657-func roleArgs(s scenario, m processMapping) []string {
856+func roleArgs(s scenario, m processMapping) roleCommand {
857+ return routeRoleArgs(s, m, scenarioTransport(s))
858+}
859+
860+func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand {
658861 session := "perf-" + safeName(s.ID) + fmt.Sprintf("-%03d", m.Run)
862+ remote := []string{"attach", "harness@127.0.0.1:" + session}
659863 switch m.Role {
660864 case "daemon":
661- return []string{"--daemon"}
865+ return roleCommand{Args: []string{"--daemon"}}
662866 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}
867+ switch s.Transport {
868+ case "local":
869+ return roleCommand{Args: []string{"new", session}}
870+ case "ssh_stdio", "udp_baseline", "udp_25ms", "udp_100ms", "udp_loss_0pct", "udp_loss_1pct":
871+ return roleCommand{Args: remote}
872+ }
666873 case "ssh_stdio_peer":
667- return []string{"_stdio", session}
874+ return roleCommand{Args: []string{"_stdio", session}, Transport: transport{ID: s.Transport, Kind: "ssh_stdio"}}
668875 case "udp_peer":
669- return []string{"_udp-proxy", session}
876+ return roleCommand{Args: []string{"_udp-proxy", session}, Transport: selected}
877+ }
878+ return roleCommand{}
879+}
880+
881+func manifestTransport(m manifest, id string) (transport, error) {
882+ for _, t := range m.Transports {
883+ if t.ID == id {
884+ return t, nil
885+ }
886+ }
887+ // Focused unit tests may call runOne with a minimal manifest; production
888+ // already validates every scenario reference before this point.
889+ if m.Schema == 0 {
890+ return scenarioTransport(scenario{Transport: id}), nil
891+ }
892+ return transport{}, fmt.Errorf("scenario references missing transport %q", id)
893+}
894+
895+func scenarioTransport(s scenario) transport {
896+ switch s.Transport {
897+ case "udp_baseline":
898+ return transport{ID: s.Transport, Kind: "udp"}
899+ case "udp_25ms":
900+ return transport{ID: s.Transport, Kind: "udp", RTTMS: 25}
901+ case "udp_100ms":
902+ return transport{ID: s.Transport, Kind: "udp", RTTMS: 100}
903+ case "udp_loss_0pct":
904+ return transport{ID: s.Transport, Kind: "udp", LossPercent: 0}
905+ case "udp_loss_1pct":
906+ return transport{ID: s.Transport, Kind: "udp", LossPercent: 1}
670907 default:
671- return nil
908+ return transport{ID: s.Transport}
909+ }
910+}
911+
912+func launchOrder(mappings []processMapping) []processMapping {
913+ out := append([]processMapping(nil), mappings...)
914+ priority := func(role string) int {
915+ switch role {
916+ case "daemon":
917+ return 0
918+ case "ssh_stdio_peer", "udp_peer":
919+ return 1
920+ case "client":
921+ return 2
922+ default:
923+ return 3
924+ }
672925 }
926+ sort.SliceStable(out, func(i, j int) bool { return priority(out[i].Role) < priority(out[j].Role) })
927+ return out
673928 }
674929
675930 // workloadInput is shell input delivered through the client PTY. It avoids any
@@ -14,6 +14,7 @@ import (
14 "os/exec"14 "os/exec"
15 "path/filepath"15 "path/filepath"
16 "sort"16 "sort"
17+ "strconv"
17 "strings"18 "strings"
18 "sync"19 "sync"
19 "syscall"20 "syscall"
@@ -133,13 +134,57 @@ type launchedProcess interface {
133 Measure([]byte, func() error, func() error) error134 Measure([]byte, func() error, func() error) error
134 Close() error135 Close() error
135 }136 }
137+
138+// roleCommand contains only argv accepted by app.parseArgs. Transport routing
139+// metadata configures the harness's public-command seam; it is never passed as
140+// an unrecognised vev flag.
141+type roleCommand struct {
142+ Args []string
143+ Transport transport
144+}
145+
136 type launcher interface {146 type launcher interface {
137- Launch(processMapping, []string) (launchedProcess, error)147+ Launch(processMapping, roleCommand) (launchedProcess, error)
138 }148 }
139 149
140 // cliLauncher is deliberately limited to executing the supplied public vev150 // cliLauncher is deliberately limited to executing the supplied public vev
141 // binary. Scenario orchestration never reaches into an in-process daemon.151 // binary. Scenario orchestration never reaches into an in-process daemon.
142-type cliLauncher struct{ bin string }152+type cliLauncher struct {
153+ bin string
154+ mu sync.Mutex
155+ peers map[string]peerRoute
156+}
157+
158+type peerRoute struct {
159+ mapping processMapping
160+ command roleCommand
161+ pidPath string
162+}
163+
164+type preparedPeer struct{ route peerRoute }
165+
166+func (p preparedPeer) Warmup([]byte) error { return nil }
167+func (p preparedPeer) Measure([]byte, func() error, func() error) error { return nil }
168+func (p preparedPeer) Close() error {
169+ if p.route.pidPath == "" {
170+ return nil
171+ }
172+ b, err := os.ReadFile(p.route.pidPath)
173+ if errors.Is(err, os.ErrNotExist) {
174+ return nil
175+ }
176+ if err != nil {
177+ return err
178+ }
179+ pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
180+ if err != nil || pid <= 0 {
181+ return fmt.Errorf("invalid owned peer pid: %q", b)
182+ }
183+ if err := syscall.Kill(pid, syscall.SIGTERM); err != nil && !errors.Is(err, syscall.ESRCH) {
184+ return err
185+ }
186+ return nil
187+}
143 188
144 type terminalOutput interface {189 type terminalOutput interface {
145 io.Writer190 io.Writer
@@ -157,8 +202,8 @@ type cliProcess struct {
157 waitErr chan error202 waitErr chan error
158 }203 }
159 204
160-func (l cliLauncher) Launch(m processMapping, args []string) (launchedProcess, error) {205+func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProcess, error) {
161- if len(args) == 0 {206+ if len(role.Args) == 0 {
162 return nil, errors.New("public CLI role has no arguments")207 return nil, errors.New("public CLI role has no arguments")
163 }208 }
164 if info, err := os.Stat(l.bin); err != nil {209 if info, err := os.Stat(l.bin); err != nil {
@@ -166,14 +211,30 @@ func (l cliLauncher) Launch(m processMapping, args []string) (launchedProcess, e
166 } else if info.IsDir() {211 } else if info.IsDir() {
167 return nil, fmt.Errorf("public CLI binary %q is a directory", l.bin)212 return nil, fmt.Errorf("public CLI binary %q is a directory", l.bin)
168 }213 }
169- cmd := exec.Command(l.bin, args...)214+ if m.Role == "ssh_stdio_peer" || m.Role == "udp_peer" {
170- cmd.Env = append(withoutEnv(os.Environ(), "VEV"), "VEV_PERF_TRACE="+m.TracePath, "VEV_PERF_PROCESS_ID="+m.ProcessID,215+ return l.preparePeer(m, role)
171- "XDG_RUNTIME_DIR="+filepath.Join(filepath.Dir(m.TracePath), "runtime"),216+ }
172- "XDG_STATE_HOME="+filepath.Join(filepath.Dir(m.TracePath), "state"), "TERM=xterm-256color")217+ cmd := exec.Command(l.bin, role.Args...)
173- if err := safedir.EnsurePrivate(filepath.Join(filepath.Dir(m.TracePath), "runtime")); err != nil {218+ runDir := filepath.Dir(m.TracePath)
219+ cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_REMOTE_TRANSPORT"), "VEV_PERF_TRACE="+m.TracePath, "VEV_PERF_PROCESS_ID="+m.ProcessID,
220+ "XDG_RUNTIME_DIR="+filepath.Join(runDir, "runtime"),
221+ "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "TERM=xterm-256color")
222+ if m.Role == "client" {
223+ l.mu.Lock()
224+ peer, routed := l.peers[runDir]
225+ l.mu.Unlock()
226+ if routed {
227+ mode, err := remoteMode(peer.command.Transport)
228+ if err != nil {
229+ return nil, err
230+ }
231+ cmd.Env = append(cmd.Env, "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode)
232+ }
233+ }
234+ if err := safedir.EnsurePrivate(filepath.Join(runDir, "runtime")); err != nil {
174 return nil, err235 return nil, err
175 }236 }
176- if err := safedir.EnsurePrivate(filepath.Join(filepath.Dir(m.TracePath), "state")); err != nil {237+ if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
177 return nil, err238 return nil, err
178 }239 }
179 p := &cliProcess{cmd: cmd, waitErr: make(chan error, 1)}240 p := &cliProcess{cmd: cmd, waitErr: make(chan error, 1)}
@@ -216,6 +277,82 @@ func (l cliLauncher) Launch(m processMapping, args []string) (launchedProcess, e
216 return p, nil277 return p, nil
217 }278 }
218 279
280+// preparePeer installs a per-run ssh command seam. The ordinary public client
281+// still executes its documented remote attach command; its ssh child invokes
282+// only the parsed public _stdio or _udp-proxy command. This makes the peer
283+// carrying client traffic the declared, exclusively traced role rather than a
284+// separately launched but unused process.
285+func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedProcess, error) {
286+ runDir := filepath.Dir(m.TracePath)
287+ if err := safedir.EnsurePrivate(runDir); err != nil {
288+ return nil, err
289+ }
290+ if err := safedir.EnsurePrivate(filepath.Join(runDir, "runtime")); err != nil {
291+ return nil, err
292+ }
293+ if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
294+ return nil, err
295+ }
296+ if len(role.Args) != 2 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") {
297+ return nil, fmt.Errorf("unsupported public peer command %q", role.Args)
298+ }
299+ route := peerRoute{mapping: m, command: role}
300+ if m.Role == "udp_peer" {
301+ route.pidPath = filepath.Join(runDir, "udp-peer.pid")
302+ }
303+ shim := filepath.Join(runDir, "ssh")
304+ ready := filepath.Join(runDir, "udp-peer.ready")
305+ stderr := filepath.Join(runDir, "udp-peer.stderr")
306+ // The bootstrap protocol requires the SSH command to exit after forwarding
307+ // the readiness line. The long-lived proxy is owned by pidPath and receives
308+ // the role's unique trace identity, never the bootstrap/client identity.
309+ body := fmt.Sprintf(`#!/bin/sh
310+set -eu
311+case "$*" in
312+ *"_stdio"*)
313+ exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _stdio %[6]q
314+ ;;
315+ *"_udp-bootstrap"*)
316+ rm -f %[7]q %[8]q
317+ env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color VEV_PERF_UDP_RTT_MS=%[9]q VEV_PERF_UDP_LOSS_PERCENT=%[10]q %[5]q _udp-proxy %[6]q >%[7]q 2>%[8]q &
318+ peer=$!
319+ printf '%%s\\n' "$peer" > %[11]q
320+ i=0
321+ while [ ! -s %[7]q ] && [ "$i" -lt 1000 ]; do sleep 0.01; i=$((i+1)); done
322+ test -s %[7]q
323+ head -n 1 %[7]q
324+ exit 0
325+ ;;
326+ *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;;
327+esac
328+`, m.TracePath, m.ProcessID, filepath.Join(runDir, "runtime"), filepath.Join(runDir, "state"), l.bin, role.Args[1], ready, stderr, strconv.Itoa(role.Transport.RTTMS), strconv.Itoa(role.Transport.LossPercent), route.pidPath)
329+ if err := os.WriteFile(shim, []byte(body), 0o700); err != nil {
330+ return nil, err
331+ }
332+ l.mu.Lock()
333+ if l.peers == nil {
334+ l.peers = make(map[string]peerRoute)
335+ }
336+ if _, exists := l.peers[runDir]; exists {
337+ l.mu.Unlock()
338+ return nil, fmt.Errorf("duplicate transport peer for %s", runDir)
339+ }
340+ l.peers[runDir] = route
341+ l.mu.Unlock()
342+ return preparedPeer{route: route}, nil
343+}
344+
345+func remoteMode(t transport) (string, error) {
346+ switch t.Kind {
347+ case "ssh_stdio":
348+ return "stdio", nil
349+ case "udp":
350+ return "udp", nil
351+ default:
352+ return "", fmt.Errorf("transport %q cannot route through a remote peer", t.ID)
353+ }
354+}
355+
219 func withoutEnv(env []string, names ...string) []string {356 func withoutEnv(env []string, names ...string) []string {
220 remove := make(map[string]bool, len(names))357 remove := make(map[string]bool, len(names))
221 for _, name := range names {358 for _, name := range names {
@@ -320,7 +457,9 @@ func (p *cliProcess) Close() error {
320 close(p.done)457 close(p.done)
321 }458 }
322 if p.cmd != nil && p.cmd.Process != nil {459 if p.cmd != nil && p.cmd.Process != nil {
323- _ = p.cmd.Process.Signal(syscall.SIGTERM)460+ // The client is a session/process-group leader; terminate its ssh
461+ // seam/_stdio descendants as well as the terminal client itself.
462+ _ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGTERM)
324 }463 }
325 if p.pty != nil {464 if p.pty != nil {
326 _ = p.pty.Close()465 _ = p.pty.Close()
@@ -512,9 +651,12 @@ func validateManifest(m manifest) error {
512 if t.ID == "" {651 if t.ID == "" {
513 return errors.New("transport missing id")652 return errors.New("transport missing id")
514 }653 }
654+ if err := validateTransportFixture(t); err != nil {
655+ return err
656+ }
515 trans[t.ID] = true657 trans[t.ID] = true
516 }658 }
517- for _, v := range []string{"local", "ssh_stdio", "udp_baseline", "udp_25ms", "udp_100ms", "udp_loss_1pct"} {659+ for _, v := range []string{"local", "ssh_stdio", "udp_baseline", "udp_25ms", "udp_100ms", "udp_loss_0pct", "udp_loss_1pct"} {
518 if !trans[v] {660 if !trans[v] {
519 return fmt.Errorf("missing canonical transport %s", v)661 return fmt.Errorf("missing canonical transport %s", v)
520 }662 }
@@ -535,6 +677,9 @@ func validateManifest(m manifest) error {
535 if s.InapplicableReason != "" && len(s.Roles) != 0 {677 if s.InapplicableReason != "" && len(s.Roles) != 0 {
536 return fmt.Errorf("inapplicable scenario %s must not launch roles", s.ID)678 return fmt.Errorf("inapplicable scenario %s must not launch roles", s.ID)
537 }679 }
680+ if s.InapplicableReason == "" && !equalRoleSet(s.Roles, requiredRoles(s.Transport)) {
681+ return fmt.Errorf("scenario %s roles do not route transport %s", s.ID, s.Transport)
682+ }
538 covered[s.Topology+"\x00"+s.Workload+"\x00"+s.Transport] = true683 covered[s.Topology+"\x00"+s.Workload+"\x00"+s.Transport] = true
539 }684 }
540 for topologyID := range tops {685 for topologyID := range tops {
@@ -549,6 +694,50 @@ func validateManifest(m manifest) error {
549 }694 }
550 return nil695 return nil
551 }696 }
697+func requiredRoles(transportID string) []string {
698+ switch transportID {
699+ case "local":
700+ return []string{"daemon", "client"}
701+ case "ssh_stdio":
702+ return []string{"daemon", "client", "ssh_stdio_peer"}
703+ default:
704+ return []string{"daemon", "client", "udp_peer"}
705+ }
706+}
707+
708+func equalRoleSet(got, want []string) bool {
709+ if len(got) != len(want) {
710+ return false
711+ }
712+ seen := make(map[string]bool, len(got))
713+ for _, role := range got {
714+ seen[role] = true
715+ }
716+ for _, role := range want {
717+ if !seen[role] {
718+ return false
719+ }
720+ }
721+ return true
722+}
723+
724+func validateTransportFixture(t transport) error {
725+ want := map[string]transport{
726+ "local": {ID: "local", Kind: "local"},
727+ "ssh_stdio": {ID: "ssh_stdio", Kind: "ssh_stdio"},
728+ "udp_baseline": {ID: "udp_baseline", Kind: "udp"},
729+ "udp_25ms": {ID: "udp_25ms", Kind: "udp", RTTMS: 25},
730+ "udp_100ms": {ID: "udp_100ms", Kind: "udp", RTTMS: 100},
731+ "udp_loss_0pct": {ID: "udp_loss_0pct", Kind: "udp", LossPercent: 0},
732+ "udp_loss_1pct": {ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1},
733+ }
734+ w, ok := want[t.ID]
735+ if !ok || t.Kind != w.Kind || t.RTTMS != w.RTTMS || t.LossPercent != w.LossPercent {
736+ return fmt.Errorf("invalid canonical transport fixture %+v", t)
737+ }
738+ return nil
739+}
740+
552 func set(v []string) map[string]bool {741 func set(v []string) map[string]bool {
553 r := map[string]bool{}742 r := map[string]bool{}
554 for _, x := range v {743 for _, x := range v {
@@ -556,7 +745,7 @@ func set(v []string) map[string]bool {
556 }745 }
557 return r746 return r
558 }747 }
559-func (h *harness) runOne(o options, _ manifest, s scenario, run int, raw io.Writer) (res runResult, err error) {748+func (h *harness) runOne(o options, mat manifest, s scenario, run int, raw io.Writer) (res runResult, err error) {
560 dir := filepath.Join(o.out, fmt.Sprintf("%s-run-%03d", safeName(s.ID), run))749 dir := filepath.Join(o.out, fmt.Sprintf("%s-run-%03d", safeName(s.ID), run))
561 if err = h.mkdir(dir); err != nil {750 if err = h.mkdir(dir); err != nil {
562 return res, err751 return res, err
@@ -586,28 +775,38 @@ func (h *harness) runOne(o options, _ manifest, s scenario, run int, raw io.Writ
586 return res, err775 return res, err
587 }776 }
588 if h.launcher == nil {777 if h.launcher == nil {
589- h.launcher = cliLauncher{o.vevBin}778+ h.launcher = &cliLauncher{bin: o.vevBin}
779+ }
780+ type launchedRole struct {
781+ mapping processMapping
782+ process launchedProcess
590 }783 }
591- processes := make([]launchedProcess, 0, len(maps))784+ processes := make([]launchedRole, 0, len(maps))
592 defer func() {785 defer func() {
593- for _, p := range processes {786+ // Clients own SSH-stdio descendants; close them before the deferred UDP
594- if e := p.Close(); err == nil && e != nil {787+ // peer and daemon so no role survives a failed or completed run.
788+ for i := len(processes) - 1; i >= 0; i-- {
789+ if e := processes[i].process.Close(); err == nil && e != nil {
595 err = e790 err = e
596 }791 }
597 }792 }
598 }()793 }()
599- for _, pm := range maps {794+ selectedTransport, e := manifestTransport(mat, s.Transport)
600- p, e := h.launcher.Launch(pm, roleArgs(s, pm))795+ if e != nil {
796+ return res, e
797+ }
798+ for _, pm := range launchOrder(maps) {
799+ p, e := h.launcher.Launch(pm, routeRoleArgs(s, pm, selectedTransport))
601 if e != nil {800 if e != nil {
602 return res, e801 return res, e
603 }802 }
604- processes = append(processes, p)803+ processes = append(processes, launchedRole{mapping: pm, process: p})
605 }804 }
606- for i, p := range processes {805+ for _, p := range processes {
607- if maps[i].Role != "client" {806+ if p.mapping.Role != "client" {
608 continue807 continue
609 }808 }
610- if err = p.Warmup(workloadInput(s, run, "warmup")); err != nil {809+ if err = p.process.Warmup(workloadInput(s, run, "warmup")); err != nil {
611 return res, err810 return res, err
612 }811 }
613 }812 }
@@ -616,8 +815,8 @@ func (h *harness) runOne(o options, _ manifest, s scenario, run int, raw io.Writ
616 h.clock.Sleep(o.warmup)815 h.clock.Sleep(o.warmup)
617 marks := []harnessMark{}816 marks := []harnessMark{}
618 seq := uint64(0)817 seq := uint64(0)
619- for i, p := range processes {818+ for _, p := range processes {
620- if maps[i].Role != "client" {819+ if p.mapping.Role != "client" {
621 continue820 continue
622 }821 }
623 seq++822 seq++
@@ -630,7 +829,7 @@ func (h *harness) runOne(o options, _ manifest, s scenario, run int, raw io.Writ
630 marks = append(marks, harnessMark{s.ID, run, sequence, "terminal_flushed", h.clock.Now(), true})829 marks = append(marks, harnessMark{s.ID, run, sequence, "terminal_flushed", h.clock.Now(), true})
631 return nil830 return nil
632 }831 }
633- if err = p.Measure(workloadInput(s, run, fmt.Sprintf("measured-%d", sequence)), injected, flushed); err != nil {832+ if err = p.process.Measure(workloadInput(s, run, fmt.Sprintf("measured-%d", sequence)), injected, flushed); err != nil {
634 return res, err833 return res, err
635 }834 }
636 }835 }
@@ -654,22 +853,78 @@ func (h *harness) runOne(o options, _ manifest, s scenario, run int, raw io.Writ
654 return runResult{Spans: spans, Scenario: s.ID, Run: run, Samples: len(samples), EndToEnd: samples, Processes: maps}, nil853 return runResult{Spans: spans, Scenario: s.ID, Run: run, Samples: len(samples), EndToEnd: samples, Processes: maps}, nil
655 }854 }
656 855
657-func roleArgs(s scenario, m processMapping) []string {856+func roleArgs(s scenario, m processMapping) roleCommand {
857+ return routeRoleArgs(s, m, scenarioTransport(s))
858+}
859+
860+func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand {
658 session := "perf-" + safeName(s.ID) + fmt.Sprintf("-%03d", m.Run)861 session := "perf-" + safeName(s.ID) + fmt.Sprintf("-%03d", m.Run)
862+ remote := []string{"attach", "harness@127.0.0.1:" + session}
659 switch m.Role {863 switch m.Role {
660 case "daemon":864 case "daemon":
661- return []string{"--daemon"}865+ return roleCommand{Args: []string{"--daemon"}}
662 case "client":866 case "client":
663- // The client is intentionally driven through its documented interactive867+ switch s.Transport {
664- // CLI. Transport peers below are independently launched public roles.868+ case "local":
665- return []string{"new", session}869+ return roleCommand{Args: []string{"new", session}}
870+ case "ssh_stdio", "udp_baseline", "udp_25ms", "udp_100ms", "udp_loss_0pct", "udp_loss_1pct":
871+ return roleCommand{Args: remote}
872+ }
666 case "ssh_stdio_peer":873 case "ssh_stdio_peer":
667- return []string{"_stdio", session}874+ return roleCommand{Args: []string{"_stdio", session}, Transport: transport{ID: s.Transport, Kind: "ssh_stdio"}}
668 case "udp_peer":875 case "udp_peer":
669- return []string{"_udp-proxy", session}876+ return roleCommand{Args: []string{"_udp-proxy", session}, Transport: selected}
877+ }
878+ return roleCommand{}
879+}
880+
881+func manifestTransport(m manifest, id string) (transport, error) {
882+ for _, t := range m.Transports {
883+ if t.ID == id {
884+ return t, nil
885+ }
886+ }
887+ // Focused unit tests may call runOne with a minimal manifest; production
888+ // already validates every scenario reference before this point.
889+ if m.Schema == 0 {
890+ return scenarioTransport(scenario{Transport: id}), nil
891+ }
892+ return transport{}, fmt.Errorf("scenario references missing transport %q", id)
893+}
894+
895+func scenarioTransport(s scenario) transport {
896+ switch s.Transport {
897+ case "udp_baseline":
898+ return transport{ID: s.Transport, Kind: "udp"}
899+ case "udp_25ms":
900+ return transport{ID: s.Transport, Kind: "udp", RTTMS: 25}
901+ case "udp_100ms":
902+ return transport{ID: s.Transport, Kind: "udp", RTTMS: 100}
903+ case "udp_loss_0pct":
904+ return transport{ID: s.Transport, Kind: "udp", LossPercent: 0}
905+ case "udp_loss_1pct":
906+ return transport{ID: s.Transport, Kind: "udp", LossPercent: 1}
670 default:907 default:
671- return nil908+ return transport{ID: s.Transport}
909+ }
910+}
911+
912+func launchOrder(mappings []processMapping) []processMapping {
913+ out := append([]processMapping(nil), mappings...)
914+ priority := func(role string) int {
915+ switch role {
916+ case "daemon":
917+ return 0
918+ case "ssh_stdio_peer", "udp_peer":
919+ return 1
920+ case "client":
921+ return 2
922+ default:
923+ return 3
924+ }
672 }925 }
926+ sort.SliceStable(out, func(i, j int) bool { return priority(out[i].Role) < priority(out[j].Role) })
927+ return out
673 }928 }
674 929
675 // workloadInput is shell input delivered through the client PTY. It avoids any930 // workloadInput is shell input delivered through the client PTY. It avoids any
M cmd/vev-perf-harness/main_test.go +124 -10
@@ -42,13 +42,15 @@ func (p *fakeProcess) Close() error { p.closed = true; return nil }
4242 type fakeLauncher struct {
4343 mappings []processMapping
4444 args [][]string
45+ commands []roleCommand
4546 process []*fakeProcess
4647 manifestPresent bool
4748 }
4849
49-func (l *fakeLauncher) Launch(m processMapping, args []string) (launchedProcess, error) {
50+func (l *fakeLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) {
5051 l.mappings = append(l.mappings, m)
51- l.args = append(l.args, append([]string(nil), args...))
52+ l.args = append(l.args, append([]string(nil), command.Args...))
53+ l.commands = append(l.commands, command)
5254 _, e := os.Stat(filepath.Join(filepath.Dir(m.TracePath), "manifest.json"))
5355 l.manifestPresent = e == nil
5456 p := &fakeProcess{}
@@ -82,7 +84,7 @@ func TestHarnessManifestCoversCanonicalMatrix(t *testing.T) {
8284 if err := validateManifest(m); err != nil {
8385 t.Fatal(err)
8486 }
85- if len(m.Scenarios) != 4*9*6 {
87+ if len(m.Scenarios) != 4*9*7 {
8688 t.Fatalf("scenarios=%d", len(m.Scenarios))
8789 }
8890 m.Scenarios = m.Scenarios[:len(m.Scenarios)-1]
@@ -188,9 +190,9 @@ func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) {
188190 {"udp_peer", []string{"_udp-proxy", "perf-s-001"}},
189191 } {
190192 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)
193+ got := roleArgs(scenario{ID: "s", Transport: "local"}, processMapping{Role: tc.role, Run: 1})
194+ if !equalStrings(got.Args, tc.want) {
195+ t.Fatalf("args=%q want %q", got.Args, tc.want)
194196 }
195197 })
196198 }
@@ -200,6 +202,118 @@ func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) {
200202 }
201203 }
202204
205+func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) {
206+ cases := []struct {
207+ name string
208+ transport transport
209+ peer string
210+ wantRTT int
211+ wantLoss int
212+ }{
213+ {"ssh", transport{ID: "ssh_stdio", Kind: "ssh_stdio"}, "ssh_stdio_peer", 0, 0},
214+ {"udp baseline", transport{ID: "udp_baseline", Kind: "udp"}, "udp_peer", 0, 0},
215+ {"udp 25ms", transport{ID: "udp_25ms", Kind: "udp", RTTMS: 25}, "udp_peer", 25, 0},
216+ {"udp 100ms", transport{ID: "udp_100ms", Kind: "udp", RTTMS: 100}, "udp_peer", 100, 0},
217+ {"udp loss zero", transport{ID: "udp_loss_0pct", Kind: "udp", LossPercent: 0}, "udp_peer", 0, 0},
218+ {"udp loss", transport{ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1}, "udp_peer", 0, 1},
219+ }
220+ for _, tc := range cases {
221+ t.Run(tc.name, func(t *testing.T) {
222+ s := scenario{ID: "route", Transport: tc.transport.ID}
223+ client := routeRoleArgs(s, processMapping{Role: "client", Run: 2}, tc.transport)
224+ peer := routeRoleArgs(s, processMapping{Role: tc.peer, Run: 2}, tc.transport)
225+ if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1:perf-route-002"}) {
226+ t.Fatalf("client did not use public remote attach: %q", client.Args)
227+ }
228+ if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio", "perf-route-002"}) {
229+ t.Fatalf("ssh peer command=%q", peer.Args)
230+ }
231+ if tc.peer == "udp_peer" && !equalStrings(peer.Args, []string{"_udp-proxy", "perf-route-002"}) {
232+ t.Fatalf("udp peer command=%q", peer.Args)
233+ }
234+ if peer.Transport.RTTMS != tc.wantRTT || peer.Transport.LossPercent != tc.wantLoss {
235+ t.Fatalf("peer lost manifest network settings: %+v", peer.Transport)
236+ }
237+ })
238+ }
239+}
240+
241+func TestHarnessFakeRunnerRoutesClientToPeerAndCleansEveryRole(t *testing.T) {
242+ for _, tc := range []struct {
243+ name, transport, peer, peerCommand string
244+ }{
245+ {"ssh", "ssh_stdio", "ssh_stdio_peer", "_stdio"},
246+ {"udp baseline", "udp_baseline", "udp_peer", "_udp-proxy"},
247+ {"udp 25ms", "udp_25ms", "udp_peer", "_udp-proxy"},
248+ {"udp 100ms", "udp_100ms", "udp_peer", "_udp-proxy"},
249+ {"udp loss zero", "udp_loss_0pct", "udp_peer", "_udp-proxy"},
250+ {"udp loss", "udp_loss_1pct", "udp_peer", "_udp-proxy"},
251+ } {
252+ t.Run(tc.name, func(t *testing.T) {
253+ dir := t.TempDir()
254+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
255+ if err != nil {
256+ t.Fatal(err)
257+ }
258+ defer raw.Close()
259+ l := &fakeLauncher{}
260+ h := defaultHarness()
261+ h.clock, h.launcher = &fakeClock{}, l
262+ s := scenario{ID: "routed", Transport: tc.transport, Roles: []string{"daemon", "client", tc.peer}}
263+ if _, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, s, 1, raw); err != nil {
264+ t.Fatal(err)
265+ }
266+ if len(l.mappings) != 3 || l.mappings[1].Role != tc.peer || l.mappings[2].Role != "client" {
267+ t.Fatalf("dependency launch order=%+v", l.mappings)
268+ }
269+ if l.commands[1].Args[0] != tc.peerCommand || l.commands[2].Args[0] != "attach" {
270+ t.Fatalf("commands do not connect client through declared peer: %+v", l.commands)
271+ }
272+ for i, p := range l.process {
273+ if !p.closed {
274+ t.Fatalf("role %s was not cleaned up", l.mappings[i].Role)
275+ }
276+ }
277+ })
278+ }
279+}
280+
281+func TestCLITransportSeamOwnsExclusivePeerTraceAndCleanup(t *testing.T) {
282+ dir := t.TempDir()
283+ if err := os.Chmod(dir, 0o700); err != nil {
284+ t.Fatal(err)
285+ }
286+ l := &cliLauncher{bin: "/bin/true"}
287+ m := processMapping{ProcessID: "udp-peer", TracePath: filepath.Join(dir, "udp.jsonl"), Role: "udp_peer"}
288+ if err := os.WriteFile(m.TracePath, nil, 0o600); err != nil {
289+ t.Fatal(err)
290+ }
291+ p, err := l.preparePeer(m, roleCommand{Args: []string{"_udp-proxy", "work"}, Transport: transport{ID: "udp_25ms", Kind: "udp", RTTMS: 25}})
292+ if err != nil {
293+ t.Fatal(err)
294+ }
295+ shim, err := os.ReadFile(filepath.Join(dir, "ssh"))
296+ if err != nil {
297+ t.Fatal(err)
298+ }
299+ text := string(shim)
300+ for _, want := range []string{"_udp-proxy", m.TracePath, m.ProcessID, "VEV_PERF_UDP_RTT_MS=\"25\"", "udp-peer.pid"} {
301+ if !strings.Contains(text, want) {
302+ t.Errorf("seam does not retain %q:\n%s", want, text)
303+ }
304+ }
305+ // A nonexistent pid is already-cleaned-up; Close must treat it as such.
306+ if err := p.Close(); err != nil {
307+ t.Fatal(err)
308+ }
309+ l.mu.Lock()
310+ route := l.peers[dir]
311+ l.mu.Unlock()
312+ if route.mapping.TracePath != m.TracePath || route.mapping.ProcessID != m.ProcessID || route.command.Transport.RTTMS != 25 {
313+ t.Fatalf("peer route is not exclusively correlated: %+v", route)
314+ }
315+}
316+
203317 func TestHarnessRejectsInvalidBoundaryPairs(t *testing.T) {
204318 for _, marks := range [][]harnessMark{
205319 {{Sequence: 1, Kind: "terminal_flushed", Tick: 1, Valid: true}},
@@ -240,8 +354,8 @@ func TestHarnessCleansRunDirectoryOnProcessFailure(t *testing.T) {
240354
241355 type failingLauncher struct{ *fakeLauncher }
242356
243-func (l *failingLauncher) Launch(m processMapping, args []string) (launchedProcess, error) {
244- p, err := l.fakeLauncher.Launch(m, args)
357+func (l *failingLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) {
358+ p, err := l.fakeLauncher.Launch(m, command)
245359 if err != nil {
246360 return nil, err
247361 }
@@ -280,8 +394,8 @@ func TestHarnessRawRecordsAreDeterministicJSONL(t *testing.T) {
280394
281395 type traceLauncher struct{ fakeLauncher }
282396
283-func (l *traceLauncher) Launch(m processMapping, args []string) (launchedProcess, error) {
284- p, err := l.fakeLauncher.Launch(m, args)
397+func (l *traceLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) {
398+ p, err := l.fakeLauncher.Launch(m, command)
285399 if err != nil {
286400 return nil, err
287401 }
@@ -42,13 +42,15 @@ func (p *fakeProcess) Close() error { p.closed = true; return nil }
42 type fakeLauncher struct {42 type fakeLauncher struct {
43 mappings []processMapping43 mappings []processMapping
44 args [][]string44 args [][]string
45+ commands []roleCommand
45 process []*fakeProcess46 process []*fakeProcess
46 manifestPresent bool47 manifestPresent bool
47 }48 }
48 49
49-func (l *fakeLauncher) Launch(m processMapping, args []string) (launchedProcess, error) {50+func (l *fakeLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) {
50 l.mappings = append(l.mappings, m)51 l.mappings = append(l.mappings, m)
51- l.args = append(l.args, append([]string(nil), args...))52+ l.args = append(l.args, append([]string(nil), command.Args...))
53+ l.commands = append(l.commands, command)
52 _, e := os.Stat(filepath.Join(filepath.Dir(m.TracePath), "manifest.json"))54 _, e := os.Stat(filepath.Join(filepath.Dir(m.TracePath), "manifest.json"))
53 l.manifestPresent = e == nil55 l.manifestPresent = e == nil
54 p := &fakeProcess{}56 p := &fakeProcess{}
@@ -82,7 +84,7 @@ func TestHarnessManifestCoversCanonicalMatrix(t *testing.T) {
82 if err := validateManifest(m); err != nil {84 if err := validateManifest(m); err != nil {
83 t.Fatal(err)85 t.Fatal(err)
84 }86 }
85- if len(m.Scenarios) != 4*9*6 {87+ if len(m.Scenarios) != 4*9*7 {
86 t.Fatalf("scenarios=%d", len(m.Scenarios))88 t.Fatalf("scenarios=%d", len(m.Scenarios))
87 }89 }
88 m.Scenarios = m.Scenarios[:len(m.Scenarios)-1]90 m.Scenarios = m.Scenarios[:len(m.Scenarios)-1]
@@ -188,9 +190,9 @@ func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) {
188 {"udp_peer", []string{"_udp-proxy", "perf-s-001"}},190 {"udp_peer", []string{"_udp-proxy", "perf-s-001"}},
189 } {191 } {
190 t.Run(tc.role, func(t *testing.T) {192 t.Run(tc.role, func(t *testing.T) {
191- got := roleArgs(scenario{ID: "s"}, processMapping{Role: tc.role, Run: 1})193+ got := roleArgs(scenario{ID: "s", Transport: "local"}, processMapping{Role: tc.role, Run: 1})
192- if !equalStrings(got, tc.want) {194+ if !equalStrings(got.Args, tc.want) {
193- t.Fatalf("args=%q want %q", got, tc.want)195+ t.Fatalf("args=%q want %q", got.Args, tc.want)
194 }196 }
195 })197 })
196 }198 }
@@ -200,6 +202,118 @@ func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) {
200 }202 }
201 }203 }
202 204
205+func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) {
206+ cases := []struct {
207+ name string
208+ transport transport
209+ peer string
210+ wantRTT int
211+ wantLoss int
212+ }{
213+ {"ssh", transport{ID: "ssh_stdio", Kind: "ssh_stdio"}, "ssh_stdio_peer", 0, 0},
214+ {"udp baseline", transport{ID: "udp_baseline", Kind: "udp"}, "udp_peer", 0, 0},
215+ {"udp 25ms", transport{ID: "udp_25ms", Kind: "udp", RTTMS: 25}, "udp_peer", 25, 0},
216+ {"udp 100ms", transport{ID: "udp_100ms", Kind: "udp", RTTMS: 100}, "udp_peer", 100, 0},
217+ {"udp loss zero", transport{ID: "udp_loss_0pct", Kind: "udp", LossPercent: 0}, "udp_peer", 0, 0},
218+ {"udp loss", transport{ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1}, "udp_peer", 0, 1},
219+ }
220+ for _, tc := range cases {
221+ t.Run(tc.name, func(t *testing.T) {
222+ s := scenario{ID: "route", Transport: tc.transport.ID}
223+ client := routeRoleArgs(s, processMapping{Role: "client", Run: 2}, tc.transport)
224+ peer := routeRoleArgs(s, processMapping{Role: tc.peer, Run: 2}, tc.transport)
225+ if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1:perf-route-002"}) {
226+ t.Fatalf("client did not use public remote attach: %q", client.Args)
227+ }
228+ if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio", "perf-route-002"}) {
229+ t.Fatalf("ssh peer command=%q", peer.Args)
230+ }
231+ if tc.peer == "udp_peer" && !equalStrings(peer.Args, []string{"_udp-proxy", "perf-route-002"}) {
232+ t.Fatalf("udp peer command=%q", peer.Args)
233+ }
234+ if peer.Transport.RTTMS != tc.wantRTT || peer.Transport.LossPercent != tc.wantLoss {
235+ t.Fatalf("peer lost manifest network settings: %+v", peer.Transport)
236+ }
237+ })
238+ }
239+}
240+
241+func TestHarnessFakeRunnerRoutesClientToPeerAndCleansEveryRole(t *testing.T) {
242+ for _, tc := range []struct {
243+ name, transport, peer, peerCommand string
244+ }{
245+ {"ssh", "ssh_stdio", "ssh_stdio_peer", "_stdio"},
246+ {"udp baseline", "udp_baseline", "udp_peer", "_udp-proxy"},
247+ {"udp 25ms", "udp_25ms", "udp_peer", "_udp-proxy"},
248+ {"udp 100ms", "udp_100ms", "udp_peer", "_udp-proxy"},
249+ {"udp loss zero", "udp_loss_0pct", "udp_peer", "_udp-proxy"},
250+ {"udp loss", "udp_loss_1pct", "udp_peer", "_udp-proxy"},
251+ } {
252+ t.Run(tc.name, func(t *testing.T) {
253+ dir := t.TempDir()
254+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
255+ if err != nil {
256+ t.Fatal(err)
257+ }
258+ defer raw.Close()
259+ l := &fakeLauncher{}
260+ h := defaultHarness()
261+ h.clock, h.launcher = &fakeClock{}, l
262+ s := scenario{ID: "routed", Transport: tc.transport, Roles: []string{"daemon", "client", tc.peer}}
263+ if _, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, s, 1, raw); err != nil {
264+ t.Fatal(err)
265+ }
266+ if len(l.mappings) != 3 || l.mappings[1].Role != tc.peer || l.mappings[2].Role != "client" {
267+ t.Fatalf("dependency launch order=%+v", l.mappings)
268+ }
269+ if l.commands[1].Args[0] != tc.peerCommand || l.commands[2].Args[0] != "attach" {
270+ t.Fatalf("commands do not connect client through declared peer: %+v", l.commands)
271+ }
272+ for i, p := range l.process {
273+ if !p.closed {
274+ t.Fatalf("role %s was not cleaned up", l.mappings[i].Role)
275+ }
276+ }
277+ })
278+ }
279+}
280+
281+func TestCLITransportSeamOwnsExclusivePeerTraceAndCleanup(t *testing.T) {
282+ dir := t.TempDir()
283+ if err := os.Chmod(dir, 0o700); err != nil {
284+ t.Fatal(err)
285+ }
286+ l := &cliLauncher{bin: "/bin/true"}
287+ m := processMapping{ProcessID: "udp-peer", TracePath: filepath.Join(dir, "udp.jsonl"), Role: "udp_peer"}
288+ if err := os.WriteFile(m.TracePath, nil, 0o600); err != nil {
289+ t.Fatal(err)
290+ }
291+ p, err := l.preparePeer(m, roleCommand{Args: []string{"_udp-proxy", "work"}, Transport: transport{ID: "udp_25ms", Kind: "udp", RTTMS: 25}})
292+ if err != nil {
293+ t.Fatal(err)
294+ }
295+ shim, err := os.ReadFile(filepath.Join(dir, "ssh"))
296+ if err != nil {
297+ t.Fatal(err)
298+ }
299+ text := string(shim)
300+ for _, want := range []string{"_udp-proxy", m.TracePath, m.ProcessID, "VEV_PERF_UDP_RTT_MS=\"25\"", "udp-peer.pid"} {
301+ if !strings.Contains(text, want) {
302+ t.Errorf("seam does not retain %q:\n%s", want, text)
303+ }
304+ }
305+ // A nonexistent pid is already-cleaned-up; Close must treat it as such.
306+ if err := p.Close(); err != nil {
307+ t.Fatal(err)
308+ }
309+ l.mu.Lock()
310+ route := l.peers[dir]
311+ l.mu.Unlock()
312+ if route.mapping.TracePath != m.TracePath || route.mapping.ProcessID != m.ProcessID || route.command.Transport.RTTMS != 25 {
313+ t.Fatalf("peer route is not exclusively correlated: %+v", route)
314+ }
315+}
316+
203 func TestHarnessRejectsInvalidBoundaryPairs(t *testing.T) {317 func TestHarnessRejectsInvalidBoundaryPairs(t *testing.T) {
204 for _, marks := range [][]harnessMark{318 for _, marks := range [][]harnessMark{
205 {{Sequence: 1, Kind: "terminal_flushed", Tick: 1, Valid: true}},319 {{Sequence: 1, Kind: "terminal_flushed", Tick: 1, Valid: true}},
@@ -240,8 +354,8 @@ func TestHarnessCleansRunDirectoryOnProcessFailure(t *testing.T) {
240 354
241 type failingLauncher struct{ *fakeLauncher }355 type failingLauncher struct{ *fakeLauncher }
242 356
243-func (l *failingLauncher) Launch(m processMapping, args []string) (launchedProcess, error) {357+func (l *failingLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) {
244- p, err := l.fakeLauncher.Launch(m, args)358+ p, err := l.fakeLauncher.Launch(m, command)
245 if err != nil {359 if err != nil {
246 return nil, err360 return nil, err
247 }361 }
@@ -280,8 +394,8 @@ func TestHarnessRawRecordsAreDeterministicJSONL(t *testing.T) {
280 394
281 type traceLauncher struct{ fakeLauncher }395 type traceLauncher struct{ fakeLauncher }
282 396
283-func (l *traceLauncher) Launch(m processMapping, args []string) (launchedProcess, error) {397+func (l *traceLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) {
284- p, err := l.fakeLauncher.Launch(m, args)398+ p, err := l.fakeLauncher.Launch(m, command)
285 if err != nil {399 if err != nil {
286 return nil, err400 return nil, err
287 }401 }
M testdata/perf/README.md +2 -2
@@ -2,8 +2,8 @@
22
33 `manifest.json` is the complete, measurement-only public-CLI matrix: four
44 120x40 topologies (`1x4`, `4x1`, `4x4`, `8x1`), 10,000 history rows per pane,
5-nine workloads, and six transports (local, SSH stdio, UDP baseline, UDP 25 ms,
6-UDP 100 ms, and UDP with 1% loss). It contains 216 scenario combinations;
5+nine workloads, and seven transports (local, SSH stdio, UDP baseline, UDP 25 ms,
6+UDP 100 ms, and UDP with 0% or 1% loss). It contains 252 scenario combinations;
77 there are no omitted combinations. A genuinely unavailable combination must
88 remain listed with `inapplicable_reason` and no launched roles.
99
@@ -2,8 +2,8 @@
2 2
3 `manifest.json` is the complete, measurement-only public-CLI matrix: four3 `manifest.json` is the complete, measurement-only public-CLI matrix: four
4 120x40 topologies (`1x4`, `4x1`, `4x4`, `8x1`), 10,000 history rows per pane,4 120x40 topologies (`1x4`, `4x1`, `4x4`, `8x1`), 10,000 history rows per pane,
5-nine workloads, and six transports (local, SSH stdio, UDP baseline, UDP 25 ms,5+nine workloads, and seven transports (local, SSH stdio, UDP baseline, UDP 25 ms,
6-UDP 100 ms, and UDP with 1% loss). It contains 216 scenario combinations;6+UDP 100 ms, and UDP with 0% or 1% loss). It contains 252 scenario combinations;
7 there are no omitted combinations. A genuinely unavailable combination must7 there are no omitted combinations. A genuinely unavailable combination must
8 remain listed with `inapplicable_reason` and no launched roles.8 remain listed with `inapplicable_reason` and no launched roles.
9 9
M testdata/perf/manifest.json +401 -0
@@ -46,6 +46,11 @@
4646 "id": "udp_baseline",
4747 "kind": "udp"
4848 },
49+ {
50+ "id": "udp_loss_0pct",
51+ "kind": "udp",
52+ "loss_percent": 0
53+ },
4954 {
5055 "id": "udp_25ms",
5156 "kind": "udp",
@@ -2402,6 +2407,402 @@
24022407 "client",
24032408 "udp_peer"
24042409 ]
2410+ },
2411+ {
2412+ "id": "1x4-idle-udp_loss_0pct",
2413+ "topology": "1x4",
2414+ "workload": "idle",
2415+ "transport": "udp_loss_0pct",
2416+ "roles": [
2417+ "daemon",
2418+ "client",
2419+ "udp_peer"
2420+ ]
2421+ },
2422+ {
2423+ "id": "1x4-active_output-udp_loss_0pct",
2424+ "topology": "1x4",
2425+ "workload": "active_output",
2426+ "transport": "udp_loss_0pct",
2427+ "roles": [
2428+ "daemon",
2429+ "client",
2430+ "udp_peer"
2431+ ]
2432+ },
2433+ {
2434+ "id": "1x4-all_output-udp_loss_0pct",
2435+ "topology": "1x4",
2436+ "workload": "all_output",
2437+ "transport": "udp_loss_0pct",
2438+ "roles": [
2439+ "daemon",
2440+ "client",
2441+ "udp_peer"
2442+ ]
2443+ },
2444+ {
2445+ "id": "1x4-inactive_output-udp_loss_0pct",
2446+ "topology": "1x4",
2447+ "workload": "inactive_output",
2448+ "transport": "udp_loss_0pct",
2449+ "roles": [
2450+ "daemon",
2451+ "client",
2452+ "udp_peer"
2453+ ]
2454+ },
2455+ {
2456+ "id": "1x4-interactive_flood-udp_loss_0pct",
2457+ "topology": "1x4",
2458+ "workload": "interactive_flood",
2459+ "transport": "udp_loss_0pct",
2460+ "roles": [
2461+ "daemon",
2462+ "client",
2463+ "udp_peer"
2464+ ]
2465+ },
2466+ {
2467+ "id": "1x4-copy_search-udp_loss_0pct",
2468+ "topology": "1x4",
2469+ "workload": "copy_search",
2470+ "transport": "udp_loss_0pct",
2471+ "roles": [
2472+ "daemon",
2473+ "client",
2474+ "udp_peer"
2475+ ]
2476+ },
2477+ {
2478+ "id": "1x4-resize_sweep-udp_loss_0pct",
2479+ "topology": "1x4",
2480+ "workload": "resize_sweep",
2481+ "transport": "udp_loss_0pct",
2482+ "roles": [
2483+ "daemon",
2484+ "client",
2485+ "udp_peer"
2486+ ]
2487+ },
2488+ {
2489+ "id": "1x4-snapshot_output_resize-udp_loss_0pct",
2490+ "topology": "1x4",
2491+ "workload": "snapshot_output_resize",
2492+ "transport": "udp_loss_0pct",
2493+ "roles": [
2494+ "daemon",
2495+ "client",
2496+ "udp_peer"
2497+ ]
2498+ },
2499+ {
2500+ "id": "1x4-attach_restore_tab_switch-udp_loss_0pct",
2501+ "topology": "1x4",
2502+ "workload": "attach_restore_tab_switch",
2503+ "transport": "udp_loss_0pct",
2504+ "roles": [
2505+ "daemon",
2506+ "client",
2507+ "udp_peer"
2508+ ]
2509+ },
2510+ {
2511+ "id": "4x1-idle-udp_loss_0pct",
2512+ "topology": "4x1",
2513+ "workload": "idle",
2514+ "transport": "udp_loss_0pct",
2515+ "roles": [
2516+ "daemon",
2517+ "client",
2518+ "udp_peer"
2519+ ]
2520+ },
2521+ {
2522+ "id": "4x1-active_output-udp_loss_0pct",
2523+ "topology": "4x1",
2524+ "workload": "active_output",
2525+ "transport": "udp_loss_0pct",
2526+ "roles": [
2527+ "daemon",
2528+ "client",
2529+ "udp_peer"
2530+ ]
2531+ },
2532+ {
2533+ "id": "4x1-all_output-udp_loss_0pct",
2534+ "topology": "4x1",
2535+ "workload": "all_output",
2536+ "transport": "udp_loss_0pct",
2537+ "roles": [
2538+ "daemon",
2539+ "client",
2540+ "udp_peer"
2541+ ]
2542+ },
2543+ {
2544+ "id": "4x1-inactive_output-udp_loss_0pct",
2545+ "topology": "4x1",
2546+ "workload": "inactive_output",
2547+ "transport": "udp_loss_0pct",
2548+ "roles": [
2549+ "daemon",
2550+ "client",
2551+ "udp_peer"
2552+ ]
2553+ },
2554+ {
2555+ "id": "4x1-interactive_flood-udp_loss_0pct",
2556+ "topology": "4x1",
2557+ "workload": "interactive_flood",
2558+ "transport": "udp_loss_0pct",
2559+ "roles": [
2560+ "daemon",
2561+ "client",
2562+ "udp_peer"
2563+ ]
2564+ },
2565+ {
2566+ "id": "4x1-copy_search-udp_loss_0pct",
2567+ "topology": "4x1",
2568+ "workload": "copy_search",
2569+ "transport": "udp_loss_0pct",
2570+ "roles": [
2571+ "daemon",
2572+ "client",
2573+ "udp_peer"
2574+ ]
2575+ },
2576+ {
2577+ "id": "4x1-resize_sweep-udp_loss_0pct",
2578+ "topology": "4x1",
2579+ "workload": "resize_sweep",
2580+ "transport": "udp_loss_0pct",
2581+ "roles": [
2582+ "daemon",
2583+ "client",
2584+ "udp_peer"
2585+ ]
2586+ },
2587+ {
2588+ "id": "4x1-snapshot_output_resize-udp_loss_0pct",
2589+ "topology": "4x1",
2590+ "workload": "snapshot_output_resize",
2591+ "transport": "udp_loss_0pct",
2592+ "roles": [
2593+ "daemon",
2594+ "client",
2595+ "udp_peer"
2596+ ]
2597+ },
2598+ {
2599+ "id": "4x1-attach_restore_tab_switch-udp_loss_0pct",
2600+ "topology": "4x1",
2601+ "workload": "attach_restore_tab_switch",
2602+ "transport": "udp_loss_0pct",
2603+ "roles": [
2604+ "daemon",
2605+ "client",
2606+ "udp_peer"
2607+ ]
2608+ },
2609+ {
2610+ "id": "4x4-idle-udp_loss_0pct",
2611+ "topology": "4x4",
2612+ "workload": "idle",
2613+ "transport": "udp_loss_0pct",
2614+ "roles": [
2615+ "daemon",
2616+ "client",
2617+ "udp_peer"
2618+ ]
2619+ },
2620+ {
2621+ "id": "4x4-active_output-udp_loss_0pct",
2622+ "topology": "4x4",
2623+ "workload": "active_output",
2624+ "transport": "udp_loss_0pct",
2625+ "roles": [
2626+ "daemon",
2627+ "client",
2628+ "udp_peer"
2629+ ]
2630+ },
2631+ {
2632+ "id": "4x4-all_output-udp_loss_0pct",
2633+ "topology": "4x4",
2634+ "workload": "all_output",
2635+ "transport": "udp_loss_0pct",
2636+ "roles": [
2637+ "daemon",
2638+ "client",
2639+ "udp_peer"
2640+ ]
2641+ },
2642+ {
2643+ "id": "4x4-inactive_output-udp_loss_0pct",
2644+ "topology": "4x4",
2645+ "workload": "inactive_output",
2646+ "transport": "udp_loss_0pct",
2647+ "roles": [
2648+ "daemon",
2649+ "client",
2650+ "udp_peer"
2651+ ]
2652+ },
2653+ {
2654+ "id": "4x4-interactive_flood-udp_loss_0pct",
2655+ "topology": "4x4",
2656+ "workload": "interactive_flood",
2657+ "transport": "udp_loss_0pct",
2658+ "roles": [
2659+ "daemon",
2660+ "client",
2661+ "udp_peer"
2662+ ]
2663+ },
2664+ {
2665+ "id": "4x4-copy_search-udp_loss_0pct",
2666+ "topology": "4x4",
2667+ "workload": "copy_search",
2668+ "transport": "udp_loss_0pct",
2669+ "roles": [
2670+ "daemon",
2671+ "client",
2672+ "udp_peer"
2673+ ]
2674+ },
2675+ {
2676+ "id": "4x4-resize_sweep-udp_loss_0pct",
2677+ "topology": "4x4",
2678+ "workload": "resize_sweep",
2679+ "transport": "udp_loss_0pct",
2680+ "roles": [
2681+ "daemon",
2682+ "client",
2683+ "udp_peer"
2684+ ]
2685+ },
2686+ {
2687+ "id": "4x4-snapshot_output_resize-udp_loss_0pct",
2688+ "topology": "4x4",
2689+ "workload": "snapshot_output_resize",
2690+ "transport": "udp_loss_0pct",
2691+ "roles": [
2692+ "daemon",
2693+ "client",
2694+ "udp_peer"
2695+ ]
2696+ },
2697+ {
2698+ "id": "4x4-attach_restore_tab_switch-udp_loss_0pct",
2699+ "topology": "4x4",
2700+ "workload": "attach_restore_tab_switch",
2701+ "transport": "udp_loss_0pct",
2702+ "roles": [
2703+ "daemon",
2704+ "client",
2705+ "udp_peer"
2706+ ]
2707+ },
2708+ {
2709+ "id": "8x1-idle-udp_loss_0pct",
2710+ "topology": "8x1",
2711+ "workload": "idle",
2712+ "transport": "udp_loss_0pct",
2713+ "roles": [
2714+ "daemon",
2715+ "client",
2716+ "udp_peer"
2717+ ]
2718+ },
2719+ {
2720+ "id": "8x1-active_output-udp_loss_0pct",
2721+ "topology": "8x1",
2722+ "workload": "active_output",
2723+ "transport": "udp_loss_0pct",
2724+ "roles": [
2725+ "daemon",
2726+ "client",
2727+ "udp_peer"
2728+ ]
2729+ },
2730+ {
2731+ "id": "8x1-all_output-udp_loss_0pct",
2732+ "topology": "8x1",
2733+ "workload": "all_output",
2734+ "transport": "udp_loss_0pct",
2735+ "roles": [
2736+ "daemon",
2737+ "client",
2738+ "udp_peer"
2739+ ]
2740+ },
2741+ {
2742+ "id": "8x1-inactive_output-udp_loss_0pct",
2743+ "topology": "8x1",
2744+ "workload": "inactive_output",
2745+ "transport": "udp_loss_0pct",
2746+ "roles": [
2747+ "daemon",
2748+ "client",
2749+ "udp_peer"
2750+ ]
2751+ },
2752+ {
2753+ "id": "8x1-interactive_flood-udp_loss_0pct",
2754+ "topology": "8x1",
2755+ "workload": "interactive_flood",
2756+ "transport": "udp_loss_0pct",
2757+ "roles": [
2758+ "daemon",
2759+ "client",
2760+ "udp_peer"
2761+ ]
2762+ },
2763+ {
2764+ "id": "8x1-copy_search-udp_loss_0pct",
2765+ "topology": "8x1",
2766+ "workload": "copy_search",
2767+ "transport": "udp_loss_0pct",
2768+ "roles": [
2769+ "daemon",
2770+ "client",
2771+ "udp_peer"
2772+ ]
2773+ },
2774+ {
2775+ "id": "8x1-resize_sweep-udp_loss_0pct",
2776+ "topology": "8x1",
2777+ "workload": "resize_sweep",
2778+ "transport": "udp_loss_0pct",
2779+ "roles": [
2780+ "daemon",
2781+ "client",
2782+ "udp_peer"
2783+ ]
2784+ },
2785+ {
2786+ "id": "8x1-snapshot_output_resize-udp_loss_0pct",
2787+ "topology": "8x1",
2788+ "workload": "snapshot_output_resize",
2789+ "transport": "udp_loss_0pct",
2790+ "roles": [
2791+ "daemon",
2792+ "client",
2793+ "udp_peer"
2794+ ]
2795+ },
2796+ {
2797+ "id": "8x1-attach_restore_tab_switch-udp_loss_0pct",
2798+ "topology": "8x1",
2799+ "workload": "attach_restore_tab_switch",
2800+ "transport": "udp_loss_0pct",
2801+ "roles": [
2802+ "daemon",
2803+ "client",
2804+ "udp_peer"
2805+ ]
24052806 }
24062807 ]
24072808 }
@@ -46,6 +46,11 @@
46 "id": "udp_baseline",46 "id": "udp_baseline",
47 "kind": "udp"47 "kind": "udp"
48 },48 },
49+ {
50+ "id": "udp_loss_0pct",
51+ "kind": "udp",
52+ "loss_percent": 0
53+ },
49 {54 {
50 "id": "udp_25ms",55 "id": "udp_25ms",
51 "kind": "udp",56 "kind": "udp",
@@ -2402,6 +2407,402 @@
2402 "client",2407 "client",
2403 "udp_peer"2408 "udp_peer"
2404 ]2409 ]
2410+ },
2411+ {
2412+ "id": "1x4-idle-udp_loss_0pct",
2413+ "topology": "1x4",
2414+ "workload": "idle",
2415+ "transport": "udp_loss_0pct",
2416+ "roles": [
2417+ "daemon",
2418+ "client",
2419+ "udp_peer"
2420+ ]
2421+ },
2422+ {
2423+ "id": "1x4-active_output-udp_loss_0pct",
2424+ "topology": "1x4",
2425+ "workload": "active_output",
2426+ "transport": "udp_loss_0pct",
2427+ "roles": [
2428+ "daemon",
2429+ "client",
2430+ "udp_peer"
2431+ ]
2432+ },
2433+ {
2434+ "id": "1x4-all_output-udp_loss_0pct",
2435+ "topology": "1x4",
2436+ "workload": "all_output",
2437+ "transport": "udp_loss_0pct",
2438+ "roles": [
2439+ "daemon",
2440+ "client",
2441+ "udp_peer"
2442+ ]
2443+ },
2444+ {
2445+ "id": "1x4-inactive_output-udp_loss_0pct",
2446+ "topology": "1x4",
2447+ "workload": "inactive_output",
2448+ "transport": "udp_loss_0pct",
2449+ "roles": [
2450+ "daemon",
2451+ "client",
2452+ "udp_peer"
2453+ ]
2454+ },
2455+ {
2456+ "id": "1x4-interactive_flood-udp_loss_0pct",
2457+ "topology": "1x4",
2458+ "workload": "interactive_flood",
2459+ "transport": "udp_loss_0pct",
2460+ "roles": [
2461+ "daemon",
2462+ "client",
2463+ "udp_peer"
2464+ ]
2465+ },
2466+ {
2467+ "id": "1x4-copy_search-udp_loss_0pct",
2468+ "topology": "1x4",
2469+ "workload": "copy_search",
2470+ "transport": "udp_loss_0pct",
2471+ "roles": [
2472+ "daemon",
2473+ "client",
2474+ "udp_peer"
2475+ ]
2476+ },
2477+ {
2478+ "id": "1x4-resize_sweep-udp_loss_0pct",
2479+ "topology": "1x4",
2480+ "workload": "resize_sweep",
2481+ "transport": "udp_loss_0pct",
2482+ "roles": [
2483+ "daemon",
2484+ "client",
2485+ "udp_peer"
2486+ ]
2487+ },
2488+ {
2489+ "id": "1x4-snapshot_output_resize-udp_loss_0pct",
2490+ "topology": "1x4",
2491+ "workload": "snapshot_output_resize",
2492+ "transport": "udp_loss_0pct",
2493+ "roles": [
2494+ "daemon",
2495+ "client",
2496+ "udp_peer"
2497+ ]
2498+ },
2499+ {
2500+ "id": "1x4-attach_restore_tab_switch-udp_loss_0pct",
2501+ "topology": "1x4",
2502+ "workload": "attach_restore_tab_switch",
2503+ "transport": "udp_loss_0pct",
2504+ "roles": [
2505+ "daemon",
2506+ "client",
2507+ "udp_peer"
2508+ ]
2509+ },
2510+ {
2511+ "id": "4x1-idle-udp_loss_0pct",
2512+ "topology": "4x1",
2513+ "workload": "idle",
2514+ "transport": "udp_loss_0pct",
2515+ "roles": [
2516+ "daemon",
2517+ "client",
2518+ "udp_peer"
2519+ ]
2520+ },
2521+ {
2522+ "id": "4x1-active_output-udp_loss_0pct",
2523+ "topology": "4x1",
2524+ "workload": "active_output",
2525+ "transport": "udp_loss_0pct",
2526+ "roles": [
2527+ "daemon",
2528+ "client",
2529+ "udp_peer"
2530+ ]
2531+ },
2532+ {
2533+ "id": "4x1-all_output-udp_loss_0pct",
2534+ "topology": "4x1",
2535+ "workload": "all_output",
2536+ "transport": "udp_loss_0pct",
2537+ "roles": [
2538+ "daemon",
2539+ "client",
2540+ "udp_peer"
2541+ ]
2542+ },
2543+ {
2544+ "id": "4x1-inactive_output-udp_loss_0pct",
2545+ "topology": "4x1",
2546+ "workload": "inactive_output",
2547+ "transport": "udp_loss_0pct",
2548+ "roles": [
2549+ "daemon",
2550+ "client",
2551+ "udp_peer"
2552+ ]
2553+ },
2554+ {
2555+ "id": "4x1-interactive_flood-udp_loss_0pct",
2556+ "topology": "4x1",
2557+ "workload": "interactive_flood",
2558+ "transport": "udp_loss_0pct",
2559+ "roles": [
2560+ "daemon",
2561+ "client",
2562+ "udp_peer"
2563+ ]
2564+ },
2565+ {
2566+ "id": "4x1-copy_search-udp_loss_0pct",
2567+ "topology": "4x1",
2568+ "workload": "copy_search",
2569+ "transport": "udp_loss_0pct",
2570+ "roles": [
2571+ "daemon",
2572+ "client",
2573+ "udp_peer"
2574+ ]
2575+ },
2576+ {
2577+ "id": "4x1-resize_sweep-udp_loss_0pct",
2578+ "topology": "4x1",
2579+ "workload": "resize_sweep",
2580+ "transport": "udp_loss_0pct",
2581+ "roles": [
2582+ "daemon",
2583+ "client",
2584+ "udp_peer"
2585+ ]
2586+ },
2587+ {
2588+ "id": "4x1-snapshot_output_resize-udp_loss_0pct",
2589+ "topology": "4x1",
2590+ "workload": "snapshot_output_resize",
2591+ "transport": "udp_loss_0pct",
2592+ "roles": [
2593+ "daemon",
2594+ "client",
2595+ "udp_peer"
2596+ ]
2597+ },
2598+ {
2599+ "id": "4x1-attach_restore_tab_switch-udp_loss_0pct",
2600+ "topology": "4x1",
2601+ "workload": "attach_restore_tab_switch",
2602+ "transport": "udp_loss_0pct",
2603+ "roles": [
2604+ "daemon",
2605+ "client",
2606+ "udp_peer"
2607+ ]
2608+ },
2609+ {
2610+ "id": "4x4-idle-udp_loss_0pct",
2611+ "topology": "4x4",
2612+ "workload": "idle",
2613+ "transport": "udp_loss_0pct",
2614+ "roles": [
2615+ "daemon",
2616+ "client",
2617+ "udp_peer"
2618+ ]
2619+ },
2620+ {
2621+ "id": "4x4-active_output-udp_loss_0pct",
2622+ "topology": "4x4",
2623+ "workload": "active_output",
2624+ "transport": "udp_loss_0pct",
2625+ "roles": [
2626+ "daemon",
2627+ "client",
2628+ "udp_peer"
2629+ ]
2630+ },
2631+ {
2632+ "id": "4x4-all_output-udp_loss_0pct",
2633+ "topology": "4x4",
2634+ "workload": "all_output",
2635+ "transport": "udp_loss_0pct",
2636+ "roles": [
2637+ "daemon",
2638+ "client",
2639+ "udp_peer"
2640+ ]
2641+ },
2642+ {
2643+ "id": "4x4-inactive_output-udp_loss_0pct",
2644+ "topology": "4x4",
2645+ "workload": "inactive_output",
2646+ "transport": "udp_loss_0pct",
2647+ "roles": [
2648+ "daemon",
2649+ "client",
2650+ "udp_peer"
2651+ ]
2652+ },
2653+ {
2654+ "id": "4x4-interactive_flood-udp_loss_0pct",
2655+ "topology": "4x4",
2656+ "workload": "interactive_flood",
2657+ "transport": "udp_loss_0pct",
2658+ "roles": [
2659+ "daemon",
2660+ "client",
2661+ "udp_peer"
2662+ ]
2663+ },
2664+ {
2665+ "id": "4x4-copy_search-udp_loss_0pct",
2666+ "topology": "4x4",
2667+ "workload": "copy_search",
2668+ "transport": "udp_loss_0pct",
2669+ "roles": [
2670+ "daemon",
2671+ "client",
2672+ "udp_peer"
2673+ ]
2674+ },
2675+ {
2676+ "id": "4x4-resize_sweep-udp_loss_0pct",
2677+ "topology": "4x4",
2678+ "workload": "resize_sweep",
2679+ "transport": "udp_loss_0pct",
2680+ "roles": [
2681+ "daemon",
2682+ "client",
2683+ "udp_peer"
2684+ ]
2685+ },
2686+ {
2687+ "id": "4x4-snapshot_output_resize-udp_loss_0pct",
2688+ "topology": "4x4",
2689+ "workload": "snapshot_output_resize",
2690+ "transport": "udp_loss_0pct",
2691+ "roles": [
2692+ "daemon",
2693+ "client",
2694+ "udp_peer"
2695+ ]
2696+ },
2697+ {
2698+ "id": "4x4-attach_restore_tab_switch-udp_loss_0pct",
2699+ "topology": "4x4",
2700+ "workload": "attach_restore_tab_switch",
2701+ "transport": "udp_loss_0pct",
2702+ "roles": [
2703+ "daemon",
2704+ "client",
2705+ "udp_peer"
2706+ ]
2707+ },
2708+ {
2709+ "id": "8x1-idle-udp_loss_0pct",
2710+ "topology": "8x1",
2711+ "workload": "idle",
2712+ "transport": "udp_loss_0pct",
2713+ "roles": [
2714+ "daemon",
2715+ "client",
2716+ "udp_peer"
2717+ ]
2718+ },
2719+ {
2720+ "id": "8x1-active_output-udp_loss_0pct",
2721+ "topology": "8x1",
2722+ "workload": "active_output",
2723+ "transport": "udp_loss_0pct",
2724+ "roles": [
2725+ "daemon",
2726+ "client",
2727+ "udp_peer"
2728+ ]
2729+ },
2730+ {
2731+ "id": "8x1-all_output-udp_loss_0pct",
2732+ "topology": "8x1",
2733+ "workload": "all_output",
2734+ "transport": "udp_loss_0pct",
2735+ "roles": [
2736+ "daemon",
2737+ "client",
2738+ "udp_peer"
2739+ ]
2740+ },
2741+ {
2742+ "id": "8x1-inactive_output-udp_loss_0pct",
2743+ "topology": "8x1",
2744+ "workload": "inactive_output",
2745+ "transport": "udp_loss_0pct",
2746+ "roles": [
2747+ "daemon",
2748+ "client",
2749+ "udp_peer"
2750+ ]
2751+ },
2752+ {
2753+ "id": "8x1-interactive_flood-udp_loss_0pct",
2754+ "topology": "8x1",
2755+ "workload": "interactive_flood",
2756+ "transport": "udp_loss_0pct",
2757+ "roles": [
2758+ "daemon",
2759+ "client",
2760+ "udp_peer"
2761+ ]
2762+ },
2763+ {
2764+ "id": "8x1-copy_search-udp_loss_0pct",
2765+ "topology": "8x1",
2766+ "workload": "copy_search",
2767+ "transport": "udp_loss_0pct",
2768+ "roles": [
2769+ "daemon",
2770+ "client",
2771+ "udp_peer"
2772+ ]
2773+ },
2774+ {
2775+ "id": "8x1-resize_sweep-udp_loss_0pct",
2776+ "topology": "8x1",
2777+ "workload": "resize_sweep",
2778+ "transport": "udp_loss_0pct",
2779+ "roles": [
2780+ "daemon",
2781+ "client",
2782+ "udp_peer"
2783+ ]
2784+ },
2785+ {
2786+ "id": "8x1-snapshot_output_resize-udp_loss_0pct",
2787+ "topology": "8x1",
2788+ "workload": "snapshot_output_resize",
2789+ "transport": "udp_loss_0pct",
2790+ "roles": [
2791+ "daemon",
2792+ "client",
2793+ "udp_peer"
2794+ ]
2795+ },
2796+ {
2797+ "id": "8x1-attach_restore_tab_switch-udp_loss_0pct",
2798+ "topology": "8x1",
2799+ "workload": "attach_restore_tab_switch",
2800+ "transport": "udp_loss_0pct",
2801+ "roles": [
2802+ "daemon",
2803+ "client",
2804+ "udp_peer"
2805+ ]
2405 }2806 }
2406 ]2807 ]
2407 }2808 }

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close