detached f106d97

Merge pull request #78 from bnema/perf/transport-observability

perf(obs): add transport runtime measurement harness
commit
f106d97fc96812b53243cdc4fc249d9e6bcdae85
author
bnema · · 2026-07-13 14:24 UTC
committer
GitHub · · 2026-07-13 14:24 UTC
merge of
187a82b + 6b1947d · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
46 files · +9917 -201
A cmd/vev-perf-harness/evidence.go +324 -0
@@ -0,0 +1,324 @@
1+package main
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "sort"
10+ "strings"
11+)
12+
13+func measuredEventSamples(marks []harnessMark, interval measuredInterval) ([]eventSample, error) {
14+ if interval.End < interval.Start {
15+ return nil, errors.New("negative measured interval")
16+ }
17+ starts := map[uint64]int64{}
18+ events := make([]eventSample, 0, len(marks)/2)
19+ for _, m := range marks {
20+ if !m.Valid {
21+ return nil, errors.New("invalid harness boundary")
22+ }
23+ switch m.Kind {
24+ case "input_injected":
25+ if _, exists := starts[m.Sequence]; exists {
26+ return nil, errors.New("duplicate input boundary")
27+ }
28+ starts[m.Sequence] = m.Tick
29+ case "terminal_flushed":
30+ start, ok := starts[m.Sequence]
31+ if !ok {
32+ return nil, errors.New("terminal flush without input pair")
33+ }
34+ if m.Tick < start {
35+ return nil, errors.New("negative harness duration")
36+ }
37+ delete(starts, m.Sequence)
38+ if start >= interval.Start && m.Tick < interval.End {
39+ events = append(events, eventSample{Sequence: m.Sequence, Injected: start, Flushed: m.Tick, Latency: m.Tick - start})
40+ }
41+ default:
42+ return nil, errors.New("unknown harness mark")
43+ }
44+ }
45+ if len(starts) != 0 {
46+ return nil, errors.New("missing terminal flush pair")
47+ }
48+ sort.Slice(events, func(i, j int) bool {
49+ if events[i].Injected == events[j].Injected {
50+ return events[i].Sequence < events[j].Sequence
51+ }
52+ return events[i].Injected < events[j].Injected
53+ })
54+ return events, nil
55+}
56+
57+func requireMinimumEventSamples(events []eventSample) error {
58+ if len(events) < minimumInIntervalEventSamples {
59+ return fmt.Errorf("insufficient in-interval event samples: got %d, need at least %d", len(events), minimumInIntervalEventSamples)
60+ }
61+ return nil
62+}
63+
64+func eventLatencies(events []eventSample) []int64 {
65+ out := make([]int64, len(events))
66+ for i, event := range events {
67+ out[i] = event.Latency
68+ }
69+ return out
70+}
71+
72+func eventCadenceSamples(events []eventSample) ([]int64, int64) {
73+ if len(events) < 2 {
74+ return nil, 0
75+ }
76+ gaps := make([]int64, 0, len(events)-1)
77+ var maxGap int64
78+ for i := 1; i < len(events); i++ {
79+ gap := events[i].Injected - events[i-1].Injected
80+ gaps = append(gaps, gap)
81+ if gap > maxGap {
82+ maxGap = gap
83+ }
84+ }
85+ return gaps, maxGap
86+}
87+
88+func eventCadence(events []eventSample) (distribution, int64) {
89+ gaps, maxGap := eventCadenceSamples(events)
90+ if len(gaps) == 0 {
91+ return distribution{}, maxGap
92+ }
93+ return percentiles(gaps), maxGap
94+}
95+
96+func pairedSamples(marks []harnessMark) ([]int64, error) {
97+ starts := map[uint64]int64{}
98+ var out []int64
99+ for _, m := range marks {
100+ if !m.Valid {
101+ return nil, errors.New("invalid harness boundary")
102+ }
103+ switch m.Kind {
104+ case "input_injected":
105+ if _, ok := starts[m.Sequence]; ok {
106+ return nil, errors.New("duplicate input boundary")
107+ }
108+ starts[m.Sequence] = m.Tick
109+ case "terminal_flushed":
110+ start, ok := starts[m.Sequence]
111+ if !ok {
112+ return nil, errors.New("terminal flush without input pair")
113+ }
114+ if m.Tick < start {
115+ return nil, errors.New("negative harness duration")
116+ }
117+ out = append(out, m.Tick-start)
118+ delete(starts, m.Sequence)
119+ default:
120+ return nil, errors.New("unknown harness mark")
121+ }
122+ }
123+ if len(starts) != 0 {
124+ return nil, errors.New("missing terminal flush pair")
125+ }
126+ if len(out) == 0 {
127+ return nil, errors.New("insufficient harness samples")
128+ }
129+ return out, nil
130+}
131+
132+// traceRecord is intentionally local to the command. It mirrors JSONL fields
133+// needed for correlation but never exposes a clock to any production component.
134+type traceRecord struct {
135+ ProcessID string `json:"process_id"`
136+ Component string `json:"component"`
137+ Scenario string `json:"scenario"`
138+ Run uint64 `json:"run"`
139+ Sequence uint64 `json:"sequence"`
140+ RequestID uint64 `json:"request_id"`
141+ Epoch uint64 `json:"epoch"`
142+ Kind string `json:"kind"`
143+ Tick int64 `json:"tick"`
144+ Valid bool `json:"valid"`
145+}
146+
147+type spanPair struct{ start, end, name string }
148+
149+// mergeProcessTraces permits only records from one manifest process in a span.
150+// In particular, the correlation key includes process_id before ticks are read.
151+func mergeProcessTraces(mappings []processMapping) ([]span, error) {
152+ known := map[string]processMapping{}
153+ for _, m := range mappings {
154+ if m.ProcessID == "" || m.ClockDomain == "" || m.TracePath == "" || known[m.ProcessID].ProcessID != "" {
155+ return nil, errors.New("duplicate or incomplete manifest process mapping")
156+ }
157+ known[m.ProcessID] = m
158+ }
159+ out := []span{}
160+ for _, m := range mappings {
161+ f, err := os.Open(m.TracePath)
162+ if err != nil {
163+ return nil, fmt.Errorf("open manifest trace for %s role: %w", m.Role, err)
164+ }
165+ var records []traceRecord
166+ scan := bufio.NewScanner(f)
167+ for scan.Scan() {
168+ var r traceRecord
169+ if err := json.Unmarshal(scan.Bytes(), &r); err != nil {
170+ return nil, closeFile(f, err)
171+ }
172+ if r.ProcessID != m.ProcessID || r.Scenario != m.Scenario || r.Run != uint64(m.Run) {
173+ return nil, closeFile(f, errors.New("trace record identity does not match manifest"))
174+ }
175+ if r.Scenario == "" || r.Run == 0 || r.Component == "" || r.Sequence == 0 || r.RequestID == 0 || r.Epoch == 0 {
176+ return nil, closeFile(f, errors.New("trace record has invalid correlation fields"))
177+ }
178+ if _, ok := known[r.ProcessID]; !ok {
179+ return nil, closeFile(f, errors.New("unknown trace process_id"))
180+ }
181+ records = append(records, r)
182+ }
183+ if err := scan.Err(); err != nil {
184+ return nil, closeFile(f, err)
185+ }
186+ if err := f.Close(); err != nil {
187+ return nil, err
188+ }
189+ // A process shares one observer across concurrent components. Correlation
190+ // IDs are allocated before a goroutine reaches its first mark, so a later
191+ // ID can be serialized before an earlier one. Their first appearance is
192+ // consequently not an ordering contract. Only marks in the same component
193+ // and exact correlation domain may pair, and each pair's ticks must retain
194+ // its own start-before-end ordering.
195+ starts := map[string]traceRecord{}
196+ for _, r := range records {
197+ for _, pair := range spanPairs {
198+ key := fmt.Sprintf("%s/%s/%s/%d/%d/%d/%d", r.ProcessID, r.Component, m.Scenario, m.Run, r.Sequence, r.RequestID, r.Epoch)
199+ if r.Kind == pair.start {
200+ k := pair.name + "/" + key
201+ if _, exists := starts[k]; exists {
202+ return nil, errors.New("duplicate process-local span start")
203+ }
204+ starts[k] = r
205+ }
206+ if r.Kind == pair.end {
207+ k := pair.name + "/" + key
208+ start, ok := starts[k]
209+ if !ok {
210+ return nil, errors.New("span end without same-process start")
211+ }
212+ if r.Tick < start.Tick {
213+ return nil, errors.New("negative same-process span")
214+ }
215+ // Validity affects only measurement eligibility, never structural
216+ // pairing: every start/end must still match in its exact process,
217+ // component, and correlation domain. A failed start, failed end, or
218+ // both is a paired diagnostic fact but contributes no latency sample.
219+ if start.Valid && r.Valid {
220+ out = append(out, span{Component: r.Component, Name: pair.name, Samples: []int64{r.Tick - start.Tick}})
221+ }
222+ delete(starts, k)
223+ }
224+ }
225+ }
226+ if len(starts) != 0 {
227+ keys := make([]string, 0, len(starts))
228+ for key := range starts {
229+ keys = append(keys, key)
230+ }
231+ sort.Strings(keys)
232+ return nil, fmt.Errorf("missing process-local span pair for %s: %s", m.ProcessID, strings.Join(keys, ", "))
233+ }
234+ }
235+ return out, nil
236+}
237+
238+func summarizeSpans(all []span) []spanSummary {
239+ grouped := map[string]*span{}
240+ for _, s := range all {
241+ k := s.Component + "\x00" + s.Name
242+ if grouped[k] == nil {
243+ grouped[k] = &span{Component: s.Component, Name: s.Name}
244+ }
245+ grouped[k].Samples = append(grouped[k].Samples, s.Samples...)
246+ }
247+ keys := make([]string, 0, len(grouped))
248+ for k := range grouped {
249+ keys = append(keys, k)
250+ }
251+ sort.Strings(keys)
252+ out := make([]spanSummary, 0, len(keys))
253+ for _, k := range keys {
254+ s := grouped[k]
255+ if len(s.Samples) < minimumRepetitions {
256+ out = append(out, spanSummary{Component: s.Component, Name: s.Name, Distribution: distribution{Count: len(s.Samples)}})
257+ continue
258+ }
259+ out = append(out, spanSummary{Component: s.Component, Name: s.Name, Distribution: percentiles(s.Samples)})
260+ }
261+ return out
262+}
263+
264+func percentiles(samples []int64) distribution {
265+ v := append([]int64(nil), samples...)
266+ sort.Slice(v, func(i, j int) bool { return v[i] < v[j] })
267+ at := func(p int) int64 { return v[(len(v)-1)*p/100] }
268+ return distribution{len(v), at(50), at(95), at(99), v[len(v)-1]}
269+}
270+
271+func closeFile(f *os.File, err error) error {
272+ return errors.Join(err, f.Close())
273+}
274+
275+func writeJSON(path string, v any) error {
276+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
277+ if err != nil {
278+ return err
279+ }
280+ if err := json.NewEncoder(f).Encode(v); err != nil {
281+ return closeFile(f, err)
282+ }
283+ return f.Close()
284+}
285+
286+func mustJSON(v any) string {
287+ b, err := json.Marshal(v)
288+ if err != nil {
289+ panic(fmt.Sprintf("marshal JSON: %v", err))
290+ }
291+ return string(b)
292+}
293+
294+func safeName(s string) string {
295+ return strings.Map(func(r rune) rune {
296+ if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' {
297+ return r
298+ }
299+ return '-'
300+ }, s)
301+}
302+
303+func readJSONL(path string) ([]map[string]json.RawMessage, error) {
304+ f, err := os.Open(path)
305+ if err != nil {
306+ return nil, err
307+ }
308+ var r []map[string]json.RawMessage
309+ scan := bufio.NewScanner(f)
310+ for scan.Scan() {
311+ var v map[string]json.RawMessage
312+ if err := json.Unmarshal(scan.Bytes(), &v); err != nil {
313+ return nil, closeFile(f, err)
314+ }
315+ r = append(r, v)
316+ }
317+ if err := scan.Err(); err != nil {
318+ return nil, closeFile(f, err)
319+ }
320+ if err := f.Close(); err != nil {
321+ return nil, err
322+ }
323+ return r, nil
324+}
A cmd/vev-perf-harness/evidence_test.go +387 -0
@@ -0,0 +1,387 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+)
13+
14+func TestCLITransportSeamOwnsExclusivePeerTraceAndCleanup(t *testing.T) {
15+ fixtures := []struct {
16+ name string
17+ tr transport
18+ rtt time.Duration
19+ loss int
20+ }{
21+ {"baseline", transport{ID: "udp_baseline", Kind: "udp"}, 0, 0},
22+ {"25ms", transport{ID: "udp_25ms", Kind: "udp", RTTMS: 25}, 25 * time.Millisecond, 0},
23+ {"100ms", transport{ID: "udp_100ms", Kind: "udp", RTTMS: 100}, 100 * time.Millisecond, 0},
24+ {"loss0", transport{ID: "udp_loss_0pct", Kind: "udp"}, 0, 0},
25+ {"loss1", transport{ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1}, 0, 1},
26+ }
27+ for _, tc := range fixtures {
28+ t.Run(tc.name, func(t *testing.T) {
29+ dir := t.TempDir()
30+ if err := os.Chmod(dir, 0o700); err != nil {
31+ t.Fatal(err)
32+ }
33+ var configs []udpNetemConfig
34+ netem := &fakeUDPNetem{port: 45678}
35+ l := &cliLauncher{bin: "/bin/true", netemFactory: func(c udpNetemConfig) (udpNetem, error) {
36+ configs = append(configs, c)
37+ return netem, nil
38+ }}
39+ m := processMapping{ProcessID: "udp-peer", TracePath: filepath.Join(dir, "udp.jsonl"), Role: "udp_peer"}
40+ if err := os.WriteFile(m.TracePath, nil, 0o600); err != nil {
41+ t.Fatal(err)
42+ }
43+ p, err := l.preparePeer(m, roleCommand{Args: []string{"_udp-proxy", "work"}, Transport: tc.tr})
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ if len(configs) != 1 || configs[0].RTT != tc.rtt || configs[0].LossPercent != tc.loss || configs[0].TargetPath != filepath.Join(dir, "udp-peer.target") {
48+ t.Fatalf("fixture did not reach netem seam: %+v", configs)
49+ }
50+ shim, err := os.ReadFile(filepath.Join(dir, "ssh"))
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ text := string(shim)
55+ for _, want := range []string{"_udp-proxy", m.TracePath, m.ProcessID, "udp-peer.target", "VEV-UDP %s %s\\n' 45678", "udp-peer.pid"} {
56+ if !strings.Contains(text, want) {
57+ t.Errorf("seam does not retain %q:\n%s", want, text)
58+ }
59+ }
60+ if strings.Contains(text, "VEV_PERF_UDP_") {
61+ t.Fatalf("ignored vev UDP environment was used instead of netem: %s", text)
62+ }
63+ // A nonexistent pid is already-cleaned-up; Close must still close the
64+ // harness-owned emulator.
65+ if err := p.Close(); err != nil {
66+ t.Fatal(err)
67+ }
68+ if !netem.closed {
69+ t.Fatal("harness netem was not cleaned up")
70+ }
71+ })
72+ }
73+}
74+
75+func TestHarnessRejectsInvalidBoundaryPairs(t *testing.T) {
76+ for _, marks := range [][]harnessMark{
77+ {{Sequence: 1, Kind: "terminal_flushed", Tick: 1, Valid: true}},
78+ {{Sequence: 1, Kind: "input_injected", Tick: 2, Valid: true}, {Sequence: 1, Kind: "terminal_flushed", Tick: 1, Valid: true}},
79+ {{Sequence: 1, Kind: "input_injected", Tick: 1, Valid: true}},
80+ {{Sequence: 1, Kind: "input_injected", Tick: 1, Valid: true}, {Sequence: 1, Kind: "input_injected", Tick: 2, Valid: true}},
81+ } {
82+ if _, err := pairedSamples(marks); err == nil {
83+ t.Fatalf("invalid marks accepted: %+v", marks)
84+ }
85+ }
86+ got, err := pairedSamples([]harnessMark{{Sequence: 9, Kind: "input_injected", Tick: 1, Valid: true}, {Sequence: 9, Kind: "terminal_flushed", Tick: 5, Valid: true}})
87+ if err != nil || len(got) != 1 || got[0] != 4 {
88+ t.Fatalf("got %v %v", got, err)
89+ }
90+}
91+
92+func TestHarnessCollectsOnlyCompleteMeasuredIntervalEvents(t *testing.T) {
93+ interval := measuredInterval{Start: 100, End: 200}
94+ marks := []harnessMark{
95+ {Sequence: 1, Kind: "input_injected", Tick: 10, Valid: true},
96+ {Sequence: 1, Kind: "terminal_flushed", Tick: 20, Valid: true}, // warmup
97+ {Sequence: 2, Kind: "input_injected", Tick: 100, Valid: true},
98+ {Sequence: 2, Kind: "terminal_flushed", Tick: 120, Valid: true},
99+ {Sequence: 3, Kind: "input_injected", Tick: 150, Valid: true},
100+ {Sequence: 3, Kind: "terminal_flushed", Tick: 201, Valid: true}, // exits interval
101+ {Sequence: 4, Kind: "input_injected", Tick: 99, Valid: true},
102+ {Sequence: 4, Kind: "terminal_flushed", Tick: 110, Valid: true}, // enters interval
103+ {Sequence: 5, Kind: "input_injected", Tick: 180, Valid: true},
104+ {Sequence: 5, Kind: "terminal_flushed", Tick: 200, Valid: true}, // end is excluded
105+ }
106+ events, err := measuredEventSamples(marks, interval)
107+ if err != nil {
108+ t.Fatal(err)
109+ }
110+ if len(events) != 1 || events[0].Sequence != 2 || events[0].Latency != 20 {
111+ t.Fatalf("measured events=%+v, want only complete sequence 2", events)
112+ }
113+}
114+
115+func TestHarnessRejectsInsufficientMeasuredEventSamples(t *testing.T) {
116+ marks := make([]harnessMark, 0, 2*(minimumInIntervalEventSamples-1))
117+ for i := 0; i < minimumInIntervalEventSamples-1; i++ {
118+ marks = append(marks,
119+ harnessMark{Sequence: uint64(i + 1), Kind: "input_injected", Tick: int64(i * 100), Valid: true},
120+ harnessMark{Sequence: uint64(i + 1), Kind: "terminal_flushed", Tick: int64(i*100 + 10), Valid: true},
121+ )
122+ }
123+ events, err := measuredEventSamples(marks, measuredInterval{Start: 0, End: int64(minimumInIntervalEventSamples * 100)})
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ if err := requireMinimumEventSamples(events); err == nil {
128+ t.Fatal("insufficient measured event samples accepted")
129+ }
130+}
131+
132+func TestHarnessRunRejectsStraddlingEventsAsInsufficient(t *testing.T) {
133+ dir := t.TempDir()
134+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ t.Cleanup(func() {
139+ if err := raw.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
140+ t.Error(err)
141+ }
142+ })
143+ clock := &fakeClock{}
144+ h := defaultHarness()
145+ h.clock, h.launcher = clock, insufficientEventLauncher{clock: clock}
146+ _, err = h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "late", Roles: []string{"daemon", "client"}}, 1, raw)
147+ if err == nil || !strings.Contains(err.Error(), "insufficient in-interval event samples") {
148+ t.Fatalf("straddling run error=%v, want insufficient in-interval sample rejection", err)
149+ }
150+ if err := raw.Close(); err != nil {
151+ t.Fatal(err)
152+ }
153+ marks := readHarnessMarks(t, filepath.Join(dir, "raw.jsonl"))
154+ if got, want := len(marks), 4; got != want {
155+ t.Fatalf("raw marks=%d, want warmup and straddling pair=%d: %+v", got, want, marks)
156+ }
157+ if straddling := marks[len(marks)-1]; straddling.Sequence != 2 || straddling.Kind != "terminal_flushed" || !straddling.Valid {
158+ t.Fatalf("straddling boundary=%+v", straddling)
159+ }
160+}
161+
162+func TestHarnessCalculatesEventCadenceAndMaxGapFromRawPairs(t *testing.T) {
163+ events := []eventSample{
164+ {Sequence: 1, Injected: 100, Flushed: 110, Latency: 10},
165+ {Sequence: 2, Injected: 130, Flushed: 140, Latency: 10},
166+ {Sequence: 3, Injected: 180, Flushed: 190, Latency: 10},
167+ }
168+ cadence, maxGap := eventCadence(events)
169+ if cadence.Count != 2 || cadence.P50 != 30 || cadence.P95 != 30 || cadence.P99 != 30 || maxGap != 50 {
170+ t.Fatalf("cadence=%+v maxGap=%d", cadence, maxGap)
171+ }
172+}
173+
174+func TestHarnessIncludesAllRequiredProcessLocalSpans(t *testing.T) {
175+ got := make([]string, 0, len(spanPairs))
176+ for _, pair := range spanPairs {
177+ got = append(got, pair.name)
178+ }
179+ want := []string{"capture_duration", "compose_duration", "diff_duration", "queue_wait", "ack_blocked_interval", "emit_duration", "adapter_send_duration", "adapter_receive_duration"}
180+ if !equalStrings(got, want) {
181+ t.Fatalf("span pairs = %v, want %v", got, want)
182+ }
183+}
184+
185+func TestHarnessRawRecordsAreDeterministicJSONL(t *testing.T) {
186+ var b bytes.Buffer
187+ 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}} {
188+ b.WriteString(mustJSON(m))
189+ b.WriteByte('\n')
190+ }
191+ path := filepath.Join(t.TempDir(), "raw.jsonl")
192+ if err := os.WriteFile(path, b.Bytes(), 0o600); err != nil {
193+ t.Fatal(err)
194+ }
195+ if got, err := readJSONL(path); err != nil || len(got) != 2 {
196+ t.Fatalf("records=%d err=%v", len(got), err)
197+ }
198+}
199+
200+func TestHarnessClosesRolesBeforeMergingReceiveSpans(t *testing.T) {
201+ dir := t.TempDir()
202+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
203+ if err != nil {
204+ t.Fatal(err)
205+ }
206+ t.Cleanup(func() {
207+ if err := raw.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
208+ t.Error(err)
209+ }
210+ })
211+ launcher := &closeTraceLauncher{}
212+ h := defaultHarness()
213+ h.clock, h.launcher = &fakeClock{}, launcher
214+ result, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, scenario{ID: "receive-cleanup", Roles: []string{"daemon", "client"}}, 1, raw)
215+ if err != nil {
216+ t.Fatal(err)
217+ }
218+ if len(result.Spans) != 2 {
219+ t.Fatalf("post-cleanup receive spans=%+v", result.Spans)
220+ }
221+ for _, s := range result.Spans {
222+ if s.Name != "adapter_receive_duration" || len(s.Samples) != 1 || s.Samples[0] != 10 {
223+ t.Fatalf("unmatched or wrong receive span: %+v", s)
224+ }
225+ }
226+ for i, p := range launcher.process {
227+ if !p.closed {
228+ t.Fatalf("role %s was not closed before trace merge", launcher.mappings[i].Role)
229+ }
230+ }
231+}
232+
233+func TestHarnessWritesRequiredEvidenceWithSufficientSpans(t *testing.T) {
234+ base, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json"))
235+ if err != nil {
236+ t.Fatal(err)
237+ }
238+ for i := range base.Scenarios {
239+ if i > 0 {
240+ base.Scenarios[i].InapplicableReason = "fixture limits this test to one public topology"
241+ base.Scenarios[i].Roles = nil
242+ }
243+ }
244+ dir := t.TempDir()
245+ manifestPath := filepath.Join(dir, "manifest.json")
246+ if err := os.WriteFile(manifestPath, []byte(mustJSON(base)), 0o600); err != nil {
247+ t.Fatal(err)
248+ }
249+ l := &traceLauncher{}
250+ h := defaultHarness()
251+ h.clock = &fakeClock{}
252+ h.launcher = l
253+ h.gitSHA = func() string { return "test-sha" }
254+ if err := run([]string{"--vev-bin", "ignored", "--manifest", manifestPath, "--out", filepath.Join(dir, "out"), "--warmup", "1s", "--duration", "30s", "--repetitions", "10"}, h); err != nil {
255+ t.Fatal(err)
256+ }
257+ for _, name := range []string{"raw-harness.jsonl", "runs.json", "summary.json"} {
258+ if _, err := os.Stat(filepath.Join(dir, "out", name)); err != nil {
259+ t.Errorf("%s: %v", name, err)
260+ }
261+ }
262+ var got summary
263+ b, err := os.ReadFile(filepath.Join(dir, "out", "summary.json"))
264+ if err != nil {
265+ t.Fatal(err)
266+ }
267+ if err := json.Unmarshal(b, &got); err != nil {
268+ t.Fatal(err)
269+ }
270+ if got.EndToEnd.Count < 10*minimumInIntervalEventSamples || got.Repetitions != 10 || got.RunP50Dispersion.Count != 10 || got.Cadence.Count == 0 || got.MaxGap == 0 || len(got.Spans) == 0 {
271+ t.Fatalf("summary=%+v", got)
272+ }
273+ for _, s := range got.Spans {
274+ if s.Distribution.Count < 10 {
275+ t.Fatalf("insufficient span summary: %+v", s)
276+ }
277+ }
278+}
279+
280+func TestHarnessAcceptsConcurrentProductionTraceInterleaving(t *testing.T) {
281+ path := filepath.Join(t.TempDir(), "local-client.jsonl")
282+ if err := os.WriteFile(path, []byte(boundedLocalConcurrentTrace), 0o600); err != nil {
283+ t.Fatal(err)
284+ }
285+ spans, err := mergeProcessTraces([]processMapping{{
286+ ProcessID: "1x4-idle-local-r002-client-02", ClockDomain: "1x4-idle-local-r002-client-02", TracePath: path,
287+ Scenario: "1x4-idle-local", Run: 2,
288+ }})
289+ if err != nil {
290+ t.Fatalf("valid concurrent production trace rejected: %v", err)
291+ }
292+ if len(spans) != 2 || spans[0].Component != "ipc" || spans[0].Name != "adapter_send_duration" || spans[0].Samples[0] != 21900 || spans[1].Component != "ipc" || spans[1].Name != "adapter_receive_duration" || spans[1].Samples[0] != 178550 {
293+ t.Fatalf("spans=%+v", spans)
294+ }
295+}
296+
297+func TestHarnessRejectsCrossProcessAndBadTraceSpans(t *testing.T) {
298+ write := func(t *testing.T, records ...traceRecord) processMapping {
299+ t.Helper()
300+ path := filepath.Join(t.TempDir(), "trace.jsonl")
301+ f, err := os.Create(path)
302+ if err != nil {
303+ t.Fatal(err)
304+ }
305+ for _, r := range records {
306+ b, err := json.Marshal(r)
307+ if err != nil {
308+ t.Fatal(err)
309+ }
310+ if _, err := f.Write(append(b, '\n')); err != nil {
311+ t.Fatal(err)
312+ }
313+ }
314+ if err := f.Close(); err != nil {
315+ t.Fatal(err)
316+ }
317+ return processMapping{ProcessID: "one", ClockDomain: "one", TracePath: path, Scenario: "s", Run: 1}
318+ }
319+ base := func(kind string, tick int64) traceRecord {
320+ return traceRecord{ProcessID: "one", Component: "daemon", Scenario: "s", Run: 1, Sequence: 1, RequestID: 1, Epoch: 1, Kind: kind, Tick: tick, Valid: true}
321+ }
322+ for _, records := range [][]traceRecord{
323+ {base("diff_end", 1)},
324+ {base("diff_start", 2), base("diff_end", 1)},
325+ {base("diff_start", 1)},
326+ {base("diff_start", 1), base("diff_start", 2)},
327+ {base("diff_start", 1), func() traceRecord { r := base("diff_end", 2); r.Component = "ipc"; return r }()},
328+ {func() traceRecord { r := base("diff_start", 1); r.ProcessID = "other"; return r }()},
329+ {func() traceRecord { r := base("diff_start", 1); r.Scenario = "other-scenario"; return r }()},
330+ {func() traceRecord { r := base("diff_start", 1); r.Run = 2; return r }()},
331+ } {
332+ if _, err := mergeProcessTraces([]processMapping{write(t, records...)}); err == nil {
333+ t.Fatalf("invalid trace accepted: %+v", records)
334+ }
335+ }
336+ m := write(t, base("diff_start", 1), base("diff_end", 3))
337+ spans, err := mergeProcessTraces([]processMapping{m})
338+ if err != nil || len(spans) != 1 || spans[0].Samples[0] != 2 {
339+ t.Fatalf("spans=%+v err=%v", spans, err)
340+ }
341+}
342+
343+func TestHarnessExcludesFailedSpanDurationsWhileValidatingPairing(t *testing.T) {
344+ base := func(kind string, tick int64, valid bool) traceRecord {
345+ return traceRecord{ProcessID: "one", Component: "adapter", Scenario: "s", Run: 1, Sequence: 1, RequestID: 1, Epoch: 1, Kind: kind, Tick: tick, Valid: valid}
346+ }
347+ for _, tc := range []struct {
348+ name string
349+ records []traceRecord
350+ wantSpans int
351+ wantErr bool
352+ }{
353+ {"successful adapter send is sampled", []traceRecord{base("adapter_send_start", 10, true), base("adapter_send_end", 20, true)}, 1, false},
354+ {"failed adapter send end is excluded", []traceRecord{base("adapter_send_start", 10, true), base("adapter_send_end", 20, false)}, 0, false},
355+ {"failed adapter receive end is excluded", []traceRecord{base("adapter_receive_start", 10, true), base("adapter_receive_end", 20, false)}, 0, false},
356+ {"failed adapter send start is excluded", []traceRecord{base("adapter_send_start", 10, false), base("adapter_send_end", 20, true)}, 0, false},
357+ {"failed adapter receive boundaries are excluded", []traceRecord{base("adapter_receive_start", 10, false), base("adapter_receive_end", 20, false)}, 0, false},
358+ {"failed end without start is rejected", []traceRecord{base("adapter_send_end", 20, false)}, 0, true},
359+ {"duplicate failed start is rejected", []traceRecord{base("adapter_send_start", 10, false), base("adapter_send_start", 15, false)}, 0, true},
360+ {"negative failed span is rejected", []traceRecord{base("adapter_receive_start", 20, false), base("adapter_receive_end", 10, false)}, 0, true},
361+ {"cross-component failed end is rejected", []traceRecord{base("adapter_send_start", 10, true), func() traceRecord { r := base("adapter_send_end", 20, false); r.Component = "other"; return r }()}, 0, true},
362+ } {
363+ t.Run(tc.name, func(t *testing.T) {
364+ path := filepath.Join(t.TempDir(), "trace.jsonl")
365+ var lines []string
366+ for _, record := range tc.records {
367+ lines = append(lines, mustJSON(record))
368+ }
369+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
370+ t.Fatal(err)
371+ }
372+ spans, err := mergeProcessTraces([]processMapping{{ProcessID: "one", ClockDomain: "one", TracePath: path, Scenario: "s", Run: 1}})
373+ if tc.wantErr {
374+ if err == nil {
375+ t.Fatalf("invalid trace accepted: %+v", tc.records)
376+ }
377+ return
378+ }
379+ if err != nil || len(spans) != tc.wantSpans {
380+ t.Fatalf("spans=%+v err=%v, want %d spans", spans, err, tc.wantSpans)
381+ }
382+ if tc.wantSpans == 1 && spans[0].Samples[0] != 10 {
383+ t.Fatalf("successful span=%+v, want 10ns", spans[0])
384+ }
385+ })
386+ }
387+}
A cmd/vev-perf-harness/launcher.go +752 -0
@@ -0,0 +1,752 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "strconv"
13+ "strings"
14+ "sync"
15+ "syscall"
16+ "time"
17+
18+ "github.com/bnema/vev/pkg/safedir"
19+ "golang.org/x/sys/unix"
20+)
21+
22+type launchedProcess interface {
23+ Warmup([]byte) error
24+ Measure([]byte, func() error, func() error) error
25+ Close() error
26+}
27+
28+// roleCommand contains only argv accepted by app.parseArgs. Transport routing
29+// metadata configures the harness's public-command seam; it is never passed as
30+// an unrecognised vev flag.
31+type roleCommand struct {
32+ Args []string
33+ Transport transport
34+}
35+
36+type launcher interface {
37+ Launch(processMapping, roleCommand) (launchedProcess, error)
38+}
39+
40+type launchedRole struct {
41+ mapping processMapping
42+ process launchedProcess
43+}
44+
45+// closeLaunchedRoles closes every role in reverse launch order. errors.Join
46+// retains every cleanup failure in that stable order.
47+func closeLaunchedRoles(processes []launchedRole) error {
48+ var failures []error
49+ for i := len(processes) - 1; i >= 0; i-- {
50+ if err := processes[i].process.Close(); err != nil {
51+ failures = append(failures, fmt.Errorf("close %s role: %w", processes[i].mapping.Role, err))
52+ }
53+ }
54+ return errors.Join(failures...)
55+}
56+
57+// cliLauncher is deliberately limited to executing the supplied public vev
58+// binary. Scenario orchestration never reaches into an in-process daemon.
59+type cliLauncher struct {
60+ bin string
61+ netemFactory udpNetemFactory
62+ mu sync.Mutex
63+ peers map[string]peerRoute
64+ runtimes map[string]string
65+}
66+
67+type peerRoute struct {
68+ mapping processMapping
69+ command roleCommand
70+ pidPath string
71+ netem udpNetem
72+}
73+
74+type preparedPeer struct {
75+ route peerRoute
76+ cleanupRuntime func() error
77+}
78+
79+func (p preparedPeer) Warmup([]byte) error { return nil }
80+
81+func (p preparedPeer) Measure([]byte, func() error, func() error) error { return nil }
82+
83+func (p preparedPeer) Close() error {
84+ var errs []error
85+ if p.route.pidPath != "" {
86+ b, err := os.ReadFile(p.route.pidPath)
87+ if err == nil {
88+ pid, parseErr := strconv.Atoi(strings.TrimSpace(string(b)))
89+ if parseErr != nil || pid <= 0 {
90+ errs = append(errs, fmt.Errorf("invalid owned peer pid: %q", b))
91+ } else if killErr := syscall.Kill(pid, syscall.SIGTERM); killErr != nil && !errors.Is(killErr, syscall.ESRCH) {
92+ errs = append(errs, killErr)
93+ }
94+ } else if !errors.Is(err, os.ErrNotExist) {
95+ errs = append(errs, err)
96+ }
97+ }
98+ if p.route.netem != nil {
99+ if err := p.route.netem.Close(); err != nil {
100+ errs = append(errs, err)
101+ }
102+ }
103+ if p.cleanupRuntime != nil {
104+ if err := p.cleanupRuntime(); err != nil {
105+ errs = append(errs, err)
106+ }
107+ }
108+ return errors.Join(errs...)
109+}
110+
111+type terminalOutput interface {
112+ io.Writer
113+ Sync() error
114+ Close() error
115+}
116+
117+type cliProcess struct {
118+ cmd *exec.Cmd
119+ pty io.ReadWriteCloser
120+ output terminalOutput
121+ chunks chan []byte
122+ done chan struct{}
123+ closed sync.Once
124+ waitErr chan error
125+ waitTimeout func() <-chan time.Time
126+ forceCleanup func()
127+ forceKill func()
128+ shutdown func() error
129+ cleanupRuntime func() error
130+ terminalReady bool
131+ readyPath string
132+}
133+
134+func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProcess, error) {
135+ if len(role.Args) == 0 {
136+ return nil, errors.New("public CLI role has no arguments")
137+ }
138+ if info, err := os.Stat(l.bin); err != nil {
139+ return nil, err
140+ } else if info.IsDir() {
141+ return nil, fmt.Errorf("public CLI binary %q is a directory", l.bin)
142+ }
143+ if m.Role == "ssh_stdio_peer" || m.Role == "udp_peer" {
144+ return l.preparePeer(m, role)
145+ }
146+ cmd := exec.Command(l.bin, role.Args...)
147+ runDir := filepath.Dir(m.TracePath)
148+ runtimeDir, err := l.runtimeDir(runDir)
149+ if err != nil {
150+ return nil, err
151+ }
152+ // All role inputs are absolute paths resolved at harness startup. Do not set
153+ // a role working directory: daemon-owned subprocess cleanup may otherwise
154+ // treat the evidence directory as its working tree and remove a preallocated
155+ // trace while a later repetition is being merged.
156+ cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_REMOTE_TRANSPORT", "XDG_RUNTIME_DIR", "XDG_STATE_HOME"), traceEnvironment(m)...)
157+ cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir,
158+ "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "TERM=xterm-256color")
159+ if m.Role == "client" {
160+ l.mu.Lock()
161+ peer, routed := l.peers[runDir]
162+ l.mu.Unlock()
163+ if routed {
164+ mode, err := remoteMode(peer.command.Transport)
165+ if err != nil {
166+ return nil, err
167+ }
168+ cmd.Env = append(cmd.Env, "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode)
169+ }
170+ }
171+ if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
172+ return nil, err
173+ }
174+ p := &cliProcess{cmd: cmd, waitErr: make(chan error, 1), readyPath: filepath.Join(runtimeDir, "vev", "daemon.sock")}
175+ if m.Role == "daemon" {
176+ p.cleanupRuntime = func() error { return l.releaseRuntime(runDir) }
177+ }
178+ if m.Role == "client" {
179+ master, slave, err := openPTY()
180+ if err != nil {
181+ return nil, err
182+ }
183+ output, err := os.OpenFile(filepath.Join(filepath.Dir(m.TracePath), "terminal-output"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
184+ if err != nil {
185+ _ = master.Close()
186+ _ = slave.Close()
187+ return nil, err
188+ }
189+ cmd.Stdin, cmd.Stdout, cmd.Stderr = slave, slave, slave
190+ // Ctty is the child descriptor number after Start remaps slave to stdin.
191+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
192+ if err := cmd.Start(); err != nil {
193+ _ = output.Close()
194+ _ = master.Close()
195+ _ = slave.Close()
196+ if p.cleanupRuntime != nil {
197+ _ = p.cleanupRuntime()
198+ }
199+ return nil, err
200+ }
201+ _ = slave.Close()
202+ p.pty, p.output, p.chunks, p.done = master, output, make(chan []byte, 32), make(chan struct{})
203+ go p.copyTerminal()
204+ } else {
205+ // Every launched role owns a dedicated process group, so the bounded
206+ // cleanup path never signals the harness/test runner's group.
207+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
208+ devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
209+ if err != nil {
210+ return nil, err
211+ }
212+ cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull
213+ if err := cmd.Start(); err != nil {
214+ _ = devNull.Close()
215+ if p.cleanupRuntime != nil {
216+ _ = p.cleanupRuntime()
217+ }
218+ return nil, err
219+ }
220+ _ = devNull.Close()
221+ }
222+ if m.Role == "daemon" {
223+ // Stop the foreground daemon through its public CLI before its bounded
224+ // process-group fallback. This lets blocked carriage operations close and
225+ // serialize their failed end marks instead of being cut off by SIGKILL.
226+ p.shutdown = func() error {
227+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
228+ defer cancel()
229+ shutdown := exec.CommandContext(ctx, l.bin, "kill", "--daemon")
230+ shutdown.Env = withoutEnv(cmd.Env, "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN")
231+ return shutdown.Run()
232+ }
233+ }
234+ go func() { p.waitErr <- cmd.Wait() }()
235+ return p, nil
236+}
237+
238+// runtimeDir returns one short, private runtime directory shared by the roles
239+// of one run. Keeping the Unix socket outside deeply nested evidence output
240+// avoids the platform socket-path limit without weakening run isolation.
241+func (l *cliLauncher) runtimeDir(runDir string) (string, error) {
242+ l.mu.Lock()
243+ defer l.mu.Unlock()
244+ if path := l.runtimes[runDir]; path != "" {
245+ return path, nil
246+ }
247+ path, err := os.MkdirTemp("", "vev-harness-runtime-")
248+ if err != nil {
249+ return "", err
250+ }
251+ if err := safedir.EnsurePrivate(path); err != nil {
252+ _ = os.RemoveAll(path)
253+ return "", err
254+ }
255+ if l.runtimes == nil {
256+ l.runtimes = make(map[string]string)
257+ }
258+ l.runtimes[runDir] = path
259+ return path, nil
260+}
261+
262+func (l *cliLauncher) releaseRuntime(runDir string) error {
263+ l.mu.Lock()
264+ path := l.runtimes[runDir]
265+ delete(l.runtimes, runDir)
266+ l.mu.Unlock()
267+ if path == "" {
268+ return nil
269+ }
270+ return os.RemoveAll(path)
271+}
272+
273+// preparePeer installs a per-run ssh command seam. The ordinary public client
274+// still executes its documented remote attach command; its ssh child invokes
275+// only the parsed public _stdio or _udp-proxy command. This makes the peer
276+// carrying client traffic the declared, exclusively traced role rather than a
277+// separately launched but unused process.
278+func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedProcess, error) {
279+ runDir := filepath.Dir(m.TracePath)
280+ l.mu.Lock()
281+ runtimeDir := l.runtimes[runDir]
282+ l.mu.Unlock()
283+ peerOwnsRuntime := runtimeDir == ""
284+ if peerOwnsRuntime {
285+ var err error
286+ runtimeDir, err = l.runtimeDir(runDir)
287+ if err != nil {
288+ return nil, err
289+ }
290+ }
291+ if err := safedir.EnsurePrivate(runDir); err != nil {
292+ return nil, err
293+ }
294+ if err := safedir.EnsurePrivate(runtimeDir); err != nil {
295+ return nil, err
296+ }
297+ if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
298+ return nil, err
299+ }
300+ if len(role.Args) != 2 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") {
301+ return nil, fmt.Errorf("unsupported public peer command %q", role.Args)
302+ }
303+ route := peerRoute{mapping: m, command: role}
304+ if m.Role == "udp_peer" {
305+ route.pidPath = filepath.Join(runDir, "udp-peer.pid")
306+ factory := l.netemFactory
307+ if factory == nil {
308+ factory = newUDPNetem
309+ }
310+ netem, err := factory(udpNetemConfig{
311+ RTT: time.Duration(role.Transport.RTTMS) * time.Millisecond,
312+ LossPercent: role.Transport.LossPercent,
313+ TargetPath: filepath.Join(runDir, "udp-peer.target"),
314+ })
315+ if err != nil {
316+ return nil, fmt.Errorf("start harness UDP netem: %w", err)
317+ }
318+ route.netem = netem
319+ }
320+ shim := filepath.Join(runDir, "ssh")
321+ ready := filepath.Join(runDir, "udp-peer.ready")
322+ target := filepath.Join(runDir, "udp-peer.target")
323+ netemPort := 0
324+ if route.netem != nil {
325+ netemPort = route.netem.Port()
326+ }
327+ stderr := filepath.Join(runDir, "udp-peer.stderr")
328+ // The bootstrap protocol requires the SSH command to exit after forwarding
329+ // the readiness line. The long-lived proxy is owned by pidPath and receives
330+ // the role's unique trace identity, never the bootstrap/client identity.
331+ body := fmt.Sprintf(`#!/bin/sh
332+set -eu
333+case "$*" in
334+ *"_stdio"*)
335+ exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[12]q VEV_PERF_RUN=%[13]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _stdio %[6]q
336+ ;;
337+ *"_udp-bootstrap"*)
338+ rm -f %[7]q %[8]q %[9]q
339+ env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[12]q VEV_PERF_RUN=%[13]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _udp-proxy %[6]q >%[7]q 2>%[8]q &
340+ peer=$!
341+ printf '%%s\n' "$peer" > %[11]q
342+ i=0
343+ while [ ! -s %[7]q ] && [ "$i" -lt 1000 ]; do sleep 0.01; i=$((i+1)); done
344+ test -s %[7]q
345+ line=$(head -n 1 %[7]q)
346+ printf '%%s\n' "$line" > %[9]q
347+ set -- $line
348+ test "$1" = VEV-UDP
349+ test -n "$3"
350+ printf 'VEV-UDP %%s %%s\n' %[10]d "$3"
351+ exit 0
352+ ;;
353+ *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;;
354+esac
355+`, m.TracePath, m.ProcessID, runtimeDir, filepath.Join(runDir, "state"), l.bin, role.Args[1], ready, stderr, target, netemPort, route.pidPath, m.Scenario, m.Run)
356+ if err := os.WriteFile(shim, []byte(body), 0o700); err != nil {
357+ if route.netem != nil {
358+ _ = route.netem.Close()
359+ }
360+ return nil, err
361+ }
362+ l.mu.Lock()
363+ if l.peers == nil {
364+ l.peers = make(map[string]peerRoute)
365+ }
366+ if _, exists := l.peers[runDir]; exists {
367+ l.mu.Unlock()
368+ if route.netem != nil {
369+ _ = route.netem.Close()
370+ }
371+ return nil, fmt.Errorf("duplicate transport peer for %s", runDir)
372+ }
373+ l.peers[runDir] = route
374+ l.mu.Unlock()
375+ peer := preparedPeer{route: route}
376+ if peerOwnsRuntime {
377+ peer.cleanupRuntime = func() error { return l.releaseRuntime(runDir) }
378+ }
379+ return peer, nil
380+}
381+
382+func remoteMode(t transport) (string, error) {
383+ switch t.Kind {
384+ case "ssh_stdio":
385+ return "stdio", nil
386+ case "udp":
387+ return "udp", nil
388+ default:
389+ return "", fmt.Errorf("transport %q cannot route through a remote peer", t.ID)
390+ }
391+}
392+
393+// traceEnvironment is the complete process-local identity supplied by the
394+// persisted harness processMapping. App turns Scenario/Run into production
395+// mark correlation; sequence/request/epoch remain process-local operation IDs.
396+func traceEnvironment(m processMapping) []string {
397+ return []string{
398+ "VEV_PERF_TRACE=" + m.TracePath,
399+ "VEV_PERF_PROCESS_ID=" + m.ProcessID,
400+ "VEV_PERF_SCENARIO=" + m.Scenario,
401+ "VEV_PERF_RUN=" + strconv.Itoa(m.Run),
402+ }
403+}
404+
405+func withoutEnv(env []string, names ...string) []string {
406+ remove := make(map[string]bool, len(names))
407+ for _, name := range names {
408+ remove[name] = true
409+ }
410+ out := make([]string, 0, len(env))
411+ for _, entry := range env {
412+ name, _, _ := strings.Cut(entry, "=")
413+ if !remove[name] {
414+ out = append(out, entry)
415+ }
416+ }
417+ return out
418+}
419+
420+func (p *cliProcess) copyTerminal() {
421+ defer close(p.chunks)
422+ buf := make([]byte, 4096)
423+ for {
424+ n, err := p.pty.Read(buf)
425+ if n > 0 {
426+ chunk := append([]byte(nil), buf[:n]...)
427+ if _, writeErr := p.output.Write(chunk); writeErr != nil {
428+ return
429+ }
430+ select {
431+ case p.chunks <- chunk:
432+ case <-p.done:
433+ return
434+ }
435+ }
436+ if err != nil {
437+ return
438+ }
439+ }
440+}
441+
442+// WaitReady confirms the explicitly launched public daemon owns its unique
443+// socket before a client is launched. Without this barrier, the normal client
444+// auto-spawn behavior can create an unmanifested daemon process during startup.
445+func (p *cliProcess) WaitReady() error {
446+ if p.readyPath == "" {
447+ return errors.New("daemon readiness path is empty")
448+ }
449+ ticker := time.NewTicker(10 * time.Millisecond)
450+ defer ticker.Stop()
451+ timer := time.NewTimer(10 * time.Second)
452+ defer timer.Stop()
453+ for {
454+ if info, err := os.Stat(p.readyPath); err == nil {
455+ if info.Mode()&os.ModeSocket == 0 {
456+ return fmt.Errorf("daemon readiness path %s is not a socket", p.readyPath)
457+ }
458+ return nil
459+ }
460+ select {
461+ case err := <-p.waitErr:
462+ p.waitErr <- err
463+ if err == nil {
464+ return errors.New("daemon exited before readiness")
465+ }
466+ return fmt.Errorf("daemon exited before readiness: %w", err)
467+ case <-ticker.C:
468+ case <-timer.C:
469+ return fmt.Errorf("timed out waiting for daemon socket %s", p.readyPath)
470+ }
471+ }
472+}
473+
474+func (p *cliProcess) Warmup(input []byte) error {
475+ if p.pty == nil || len(input) == 0 {
476+ return nil
477+ }
478+ // A client cannot write application output until it has received Welcome and
479+ // entered raw mode. Require that observable output before submitting any
480+ // warmup input, so the PTY's line discipline cannot echo it locally.
481+ if !p.terminalReady {
482+ if err := p.waitForTerminalReady(); err != nil {
483+ return err
484+ }
485+ p.terminalReady = true
486+ }
487+ if _, err := p.pty.Write(input); err != nil {
488+ return err
489+ }
490+ // Do not begin measured input until the public client has rendered a real
491+ // shell response to warmup input. This is intentionally not a measurement.
492+ return p.waitForTerminalMarker(inputMarker(input), input)
493+}
494+
495+// Measure records a boundary only after the harness-owned terminal output file
496+// has accepted application output and Sync has reported a successful flush.
497+// The marker is injected through the real client PTY, never fabricated by the
498+// harness. PTY local echo is discarded and cannot satisfy this boundary.
499+func (p *cliProcess) Measure(input []byte, injected, flushed func() error) error {
500+ if p.pty == nil {
501+ return errors.New("measured process has no terminal PTY")
502+ }
503+ if _, err := p.pty.Write(input); err != nil {
504+ return err
505+ }
506+ // The injected callback runs only after the owned PTY accepts the bytes.
507+ if err := injected(); err != nil {
508+ return err
509+ }
510+ if err := p.waitForTerminalMarker(inputMarker(input), input); err != nil {
511+ return err
512+ }
513+ if err := p.output.Sync(); err != nil {
514+ return fmt.Errorf("flush terminal output: %w", err)
515+ }
516+ return flushed()
517+}
518+
519+// waitForTerminalReady waits for public client output produced before any
520+// harness input. The client enters raw mode before its output loop can render
521+// this initial prompt/state, so this is an observable readiness boundary.
522+func (p *cliProcess) waitForTerminalReady() error {
523+ timer := time.NewTimer(10 * time.Second)
524+ defer timer.Stop()
525+ for {
526+ select {
527+ case chunk, ok := <-p.chunks:
528+ if !ok {
529+ return p.terminalClosedError("readiness output")
530+ }
531+ if len(chunk) == 0 {
532+ continue
533+ }
534+ if err := p.output.Sync(); err != nil {
535+ return fmt.Errorf("flush terminal readiness output: %w", err)
536+ }
537+ return nil
538+ case err := <-p.waitErr:
539+ // Keep the exit result available to Close, which owns reaping.
540+ p.waitErr <- err
541+ if err == nil {
542+ return errors.New("client exited before readiness output")
543+ }
544+ return fmt.Errorf("client exited before readiness output: %w", err)
545+ case <-timer.C:
546+ return errors.New("timed out waiting for client readiness output")
547+ }
548+ }
549+}
550+
551+func (p *cliProcess) terminalClosedError(boundary string) error {
552+ if p.waitErr != nil {
553+ select {
554+ case err := <-p.waitErr:
555+ // Close owns reaping and may need the result after a failed boundary.
556+ p.waitErr <- err
557+ if err != nil {
558+ return fmt.Errorf("client terminal closed before %s: %w", boundary, err)
559+ }
560+ case <-time.After(10 * time.Millisecond):
561+ }
562+ }
563+ return fmt.Errorf("client terminal closed before %s", boundary)
564+}
565+
566+func (p *cliProcess) waitForTerminalMarker(marker, input []byte) error {
567+ var received []byte
568+ echo := newPTYLocalEcho(input)
569+ timer := time.NewTimer(10 * time.Second)
570+ defer timer.Stop()
571+ for !bytes.Contains(received, marker) {
572+ select {
573+ case chunk, ok := <-p.chunks:
574+ if !ok {
575+ return p.terminalClosedError("application output")
576+ }
577+ received = append(received, echo.applicationOutput(chunk)...)
578+ case err := <-p.waitErr:
579+ // Keep the exit result available to Close, which owns reaping.
580+ p.waitErr <- err
581+ if err == nil {
582+ return errors.New("client exited before application output")
583+ }
584+ return fmt.Errorf("client exited before application output: %w", err)
585+ case <-timer.C:
586+ return errors.New("timed out waiting for application terminal output")
587+ }
588+ }
589+ return nil
590+}
591+
592+// ptyLocalEcho removes the one ordered input echo that a not-yet-raw slave PTY
593+// can produce. It normalizes NL to the conventional ONLCR echo spelling; any
594+// divergence is retained as application output. The line discipline emits the
595+// echo before the child can read and respond to the same write.
596+type ptyLocalEcho struct {
597+ expected []byte
598+ matched int
599+ done bool
600+}
601+
602+func (e *ptyLocalEcho) applicationOutput(chunk []byte) []byte {
603+ if e.done || len(e.expected) == 0 {
604+ return chunk
605+ }
606+ for i, b := range chunk {
607+ if b != e.expected[e.matched] {
608+ e.done = true
609+ return append([]byte(nil), chunk[i:]...)
610+ }
611+ e.matched++
612+ if e.matched == len(e.expected) {
613+ e.done = true
614+ return append([]byte(nil), chunk[i+1:]...)
615+ }
616+ }
617+ return nil
618+}
619+
620+func (p *cliProcess) Close() error {
621+ var result error
622+ p.closed.Do(func() {
623+ if p.done != nil {
624+ close(p.done)
625+ }
626+ // A public terminal client owns a shell session. Ask that shell to exit
627+ // before closing the PTY so its transport can finish its receive span;
628+ // this avoids turning ordinary harness teardown into an aborted trace.
629+ exited := false
630+ if p.shutdown != nil && p.waitErr != nil {
631+ select {
632+ case <-p.waitErr:
633+ exited = true
634+ default:
635+ // The client may have just ended the last session and raced this
636+ // best-effort public shutdown. The normal bounded reaping path below
637+ // remains authoritative in that case.
638+ _ = p.shutdown()
639+ }
640+ }
641+ if p.pty != nil && p.waitErr != nil {
642+ _, _ = p.pty.Write([]byte("exit\n"))
643+ select {
644+ case <-p.waitErr:
645+ exited = true
646+ case <-p.closeDeadline():
647+ }
648+ }
649+ if p.pty != nil {
650+ _ = p.pty.Close()
651+ }
652+ if p.waitErr != nil && !exited {
653+ select {
654+ case <-p.waitErr:
655+ case <-p.closeDeadline():
656+ // Teardown is deliberately bounded: a stuck public CLI or child
657+ // must not hold a local harness run indefinitely.
658+ p.forceProcessGroupCleanup()
659+ select {
660+ case <-p.waitErr:
661+ case <-p.closeDeadline():
662+ p.forceProcessGroupKill()
663+ select {
664+ case <-p.waitErr:
665+ case <-p.closeDeadline():
666+ result = errors.New("timed out reaping public CLI process")
667+ }
668+ }
669+ }
670+ }
671+ if p.output != nil {
672+ if err := p.output.Close(); result == nil {
673+ result = err
674+ }
675+ }
676+ if p.cleanupRuntime != nil {
677+ if err := p.cleanupRuntime(); result == nil {
678+ result = err
679+ }
680+ }
681+ })
682+ return result
683+}
684+
685+func (p *cliProcess) closeDeadline() <-chan time.Time {
686+ if p.waitTimeout != nil {
687+ return p.waitTimeout()
688+ }
689+ if p.shutdown != nil {
690+ // A public kill asks the daemon to finish bounded detached notices and
691+ // close blocked carriage before it exits; do not turn that graceful
692+ // protocol into a SIGKILL at the two-second client deadline.
693+ return time.After(5 * time.Second)
694+ }
695+ return time.After(2 * time.Second)
696+}
697+
698+func (p *cliProcess) forceProcessGroupCleanup() {
699+ if p.forceCleanup != nil {
700+ p.forceCleanup()
701+ return
702+ }
703+ if p.cmd != nil && p.cmd.Process != nil {
704+ // The client is a session/process-group leader; forced cleanup must
705+ // include its ssh seam/_stdio descendants.
706+ _ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGTERM)
707+ }
708+}
709+
710+func (p *cliProcess) forceProcessGroupKill() {
711+ if p.forceKill != nil {
712+ p.forceKill()
713+ return
714+ }
715+ if p.cmd != nil && p.cmd.Process != nil {
716+ _ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL)
717+ }
718+}
719+
720+// openPTY is the small Linux PTY boundary needed to drive the public terminal
721+// client. No daemon or adapter implementation is imported by this command.
722+func openPTY() (*os.File, *os.File, error) {
723+ masterFD, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0)
724+ if err != nil {
725+ return nil, nil, err
726+ }
727+ closeMaster := func(err error) (*os.File, *os.File, error) { _ = unix.Close(masterFD); return nil, nil, err }
728+ number, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN)
729+ if err != nil {
730+ return closeMaster(err)
731+ }
732+ if err := unix.IoctlSetPointerInt(masterFD, unix.TIOCSPTLCK, 0); err != nil {
733+ return closeMaster(err)
734+ }
735+ slaveFD, err := unix.Open(fmt.Sprintf("/dev/pts/%d", number), unix.O_RDWR|unix.O_NOCTTY, 0)
736+ if err != nil {
737+ return closeMaster(err)
738+ }
739+ return os.NewFile(uintptr(masterFD), "vev-client-master"), os.NewFile(uintptr(slaveFD), "vev-client-slave"), nil
740+}
741+
742+func inputMarker(input []byte) []byte {
743+ start := bytes.Index(input, []byte("__VEV_HARNESS_"))
744+ if start < 0 {
745+ return input
746+ }
747+ end := bytes.Index(input[start:], []byte(`\n`))
748+ if end < 0 {
749+ return input[start:]
750+ }
751+ return input[start : start+end]
752+}
A cmd/vev-perf-harness/launcher_test.go +296 -0
@@ -0,0 +1,296 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "net"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+ "time"
13+)
14+
15+func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) {
16+ deadline := make(chan time.Time)
17+ close(deadline)
18+ var term, kill int
19+ p := &cliProcess{
20+ waitErr: make(chan error),
21+ waitTimeout: func() <-chan time.Time { return deadline },
22+ forceCleanup: func() { term++ },
23+ forceKill: func() { kill++ },
24+ }
25+ if err := p.Close(); err == nil {
26+ t.Fatal("stuck public role close succeeded")
27+ }
28+ if term != 1 || kill != 1 {
29+ t.Fatalf("TERM/KILL calls = %d/%d, want 1/1", term, kill)
30+ }
31+}
32+
33+func TestCLIProcessWaitReadyRequiresDaemonSocket(t *testing.T) {
34+ path := filepath.Join(t.TempDir(), "daemon.sock")
35+ listener, err := net.Listen("unix", path)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ defer listener.Close()
40+ if err := (&cliProcess{readyPath: path, waitErr: make(chan error, 1)}).WaitReady(); err != nil {
41+ t.Fatal(err)
42+ }
43+}
44+
45+func TestCLILauncherIsolatesRoleXDGEnvironmentAcrossRepetitions(t *testing.T) {
46+ captureDir := t.TempDir()
47+ t.Setenv("CAPTURE_DIR", captureDir)
48+ t.Setenv("XDG_RUNTIME_DIR", filepath.Join(t.TempDir(), "inherited-runtime"))
49+ t.Setenv("XDG_STATE_HOME", filepath.Join(t.TempDir(), "inherited-state"))
50+
51+ bin := filepath.Join(t.TempDir(), "capture-env")
52+ if err := os.WriteFile(bin, []byte("#!/bin/sh\nenv > \"$CAPTURE_DIR/$VEV_PERF_PROCESS_ID\"\n"), 0o700); err != nil {
53+ t.Fatal(err)
54+ }
55+ launcher := &cliLauncher{bin: bin}
56+ for run := 1; run <= 3; run++ {
57+ runDir := filepath.Join(t.TempDir(), fmt.Sprintf("run-%d", run))
58+ if err := os.MkdirAll(runDir, 0o700); err != nil {
59+ t.Fatal(err)
60+ }
61+ roles := []string{"daemon", "client"}
62+ for _, role := range roles {
63+ id := fmt.Sprintf("run-%d-%s", run, role)
64+ p, err := launcher.Launch(processMapping{
65+ Role: role, TracePath: filepath.Join(runDir, role+".jsonl"), ProcessID: id,
66+ }, roleCommand{Args: []string{"test"}})
67+ if err != nil {
68+ t.Fatalf("run %d %s launch: %v", run, role, err)
69+ }
70+ runtimeDir := launcher.runtimes[runDir]
71+ if err := p.Close(); err != nil {
72+ t.Fatalf("run %d %s close: %v", run, role, err)
73+ }
74+
75+ env, err := os.ReadFile(filepath.Join(captureDir, id))
76+ if err != nil {
77+ t.Fatalf("run %d %s capture: %v", run, role, err)
78+ }
79+ for _, want := range []string{
80+ "XDG_RUNTIME_DIR=" + runtimeDir,
81+ "XDG_STATE_HOME=" + filepath.Join(runDir, "state"),
82+ } {
83+ name := strings.SplitN(want, "=", 2)[0]
84+ if count := bytes.Count(env, []byte(name+"=")); count != 1 {
85+ t.Fatalf("run %d %s %s entries = %d, want exactly one; env:\n%s", run, role, name, count, env)
86+ }
87+ if !bytes.Contains(env, []byte(want+"\n")) {
88+ t.Fatalf("run %d %s missing %q; env:\n%s", run, role, want, env)
89+ }
90+ }
91+ }
92+ if err := launcher.releaseRuntime(runDir); err != nil {
93+ t.Fatalf("run %d release runtime: %v", run, err)
94+ }
95+ }
96+}
97+
98+func TestCLIProcessCloseUsesPublicDaemonShutdown(t *testing.T) {
99+ wait := make(chan error, 1)
100+ wait <- nil
101+ var shutdown int
102+ p := &cliProcess{waitErr: wait, shutdown: func() error { shutdown++; return nil }}
103+ if err := p.Close(); err != nil {
104+ t.Fatal(err)
105+ }
106+ if shutdown != 0 {
107+ t.Fatalf("shutdown calls = %d, want 0 for an exited daemon", shutdown)
108+ }
109+
110+ wait = make(chan error, 1)
111+ p = &cliProcess{waitErr: wait, shutdown: func() error { shutdown++; wait <- nil; return nil }}
112+ if err := p.Close(); err != nil {
113+ t.Fatal(err)
114+ }
115+ if shutdown != 1 {
116+ t.Fatalf("shutdown calls = %d, want 1", shutdown)
117+ }
118+}
119+
120+func TestHarnessCanonicalLocalSmokeRealRoles(t *testing.T) {
121+ if testing.Short() {
122+ t.Skip("public CLI smoke")
123+ }
124+ root, err := filepath.Abs(filepath.Join("..", ".."))
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ bin := filepath.Join(t.TempDir(), "vev")
129+ build := exec.Command("go", "build", "-o", bin, "./")
130+ build.Dir = root
131+ if output, err := build.CombinedOutput(); err != nil {
132+ t.Fatalf("build public CLI: %v\n%s", err, output)
133+ }
134+ m, err := readManifest(filepath.Join(root, "testdata", "perf", "manifest.json"))
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ if err := validateManifest(m); err != nil {
139+ t.Fatal(err)
140+ }
141+ var local scenario
142+ for _, s := range m.Scenarios {
143+ if s.Topology == "1x4" && s.Workload == "idle" && s.Transport == "local" {
144+ local = s
145+ break
146+ }
147+ }
148+ if local.ID == "" {
149+ t.Fatal("canonical local scenario missing")
150+ }
151+ dir := t.TempDir()
152+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
153+ if err != nil {
154+ t.Fatal(err)
155+ }
156+ defer raw.Close()
157+ h := defaultHarness()
158+ h.clock = &fakeClock{} // injected duration keeps the public 30s contract bounded in test
159+ h.launcher = &cliLauncher{bin: bin}
160+ result, err := h.runOne(options{vevBin: bin, out: dir, duration: minimumDuration}, m, local, 1, raw)
161+ if err != nil {
162+ t.Fatal(err)
163+ }
164+ if result.Samples < minimumInIntervalEventSamples || len(result.Processes) != 2 {
165+ t.Fatalf("canonical local smoke result=%+v", result)
166+ }
167+}
168+
169+func TestTraceEnvironmentMatchesProcessMapping(t *testing.T) {
170+ mapping := processMapping{TracePath: "/tmp/trace.jsonl", ProcessID: "p-01", Scenario: "1x4-idle-local", Run: 3}
171+ got := strings.Join(traceEnvironment(mapping), "\n")
172+ for _, want := range []string{"VEV_PERF_TRACE=/tmp/trace.jsonl", "VEV_PERF_PROCESS_ID=p-01", "VEV_PERF_SCENARIO=1x4-idle-local", "VEV_PERF_RUN=3"} {
173+ if !strings.Contains(got, want) {
174+ t.Errorf("trace environment missing %q: %s", want, got)
175+ }
176+ }
177+}
178+
179+func TestCLIProcessCloseGracefulDetachCompletesSpanBeforeForcedCleanup(t *testing.T) {
180+ var order []string
181+ waitErr := make(chan error, 1)
182+ timeout := make(chan time.Time)
183+ p := &cliProcess{
184+ pty: &orderedPTY{
185+ write: func(input []byte) {
186+ if string(input) != "exit\n" {
187+ t.Fatalf("graceful exit input=%q", input)
188+ }
189+ order = append(order, "exit_written")
190+ // The graceful client exit has closed its transport and emitted
191+ // its adapter receive-end mark before cmd.Wait completes.
192+ order = append(order, "adapter_receive_end")
193+ waitErr <- nil
194+ },
195+ close: func() { order = append(order, "pty_closed") },
196+ },
197+ output: &orderedOutput{close: func() { order = append(order, "output_closed") }},
198+ waitErr: waitErr,
199+ waitTimeout: func() <-chan time.Time { return timeout },
200+ forceCleanup: func() {
201+ order = append(order, "forced_process_group_cleanup")
202+ },
203+ }
204+
205+ if err := p.Close(); err != nil {
206+ t.Fatal(err)
207+ }
208+ if !equalStrings(order, []string{"exit_written", "adapter_receive_end", "pty_closed", "output_closed"}) {
209+ t.Fatalf("cleanup order=%q", order)
210+ }
211+}
212+
213+func TestCLIProcessWarmupWaitsForDelayedTerminalReadiness(t *testing.T) {
214+ allowWrite := make(chan struct{})
215+ pty, output := &stagedPTY{allow: allowWrite, writes: make(chan []byte, 1)}, &fakeOutput{}
216+ p := &cliProcess{pty: pty, output: output, chunks: make(chan []byte)}
217+ input := workloadInput(scenario{ID: "s"}, 1, "warmup")
218+ done := make(chan error, 1)
219+ go func() { done <- p.Warmup(input) }()
220+
221+ // This is public client output from the initial application prompt/state,
222+ // emitted only after the client has entered raw mode. A premature PTY write
223+ // blocks on allowWrite and therefore cannot consume this delayed readiness.
224+ go func() {
225+ p.chunks <- []byte("shell prompt$ ")
226+ close(allowWrite)
227+ }()
228+ select {
229+ case got := <-pty.writes:
230+ if !bytes.Equal(got, input) {
231+ t.Fatalf("warmup write=%q want %q", got, input)
232+ }
233+ case <-time.After(time.Second):
234+ t.Fatal("warmup did not wait for and then proceed from client readiness")
235+ }
236+ go func() { p.chunks <- append([]byte("application "), inputMarker(input)...); close(p.chunks) }()
237+ if err := <-done; err != nil {
238+ t.Fatal(err)
239+ }
240+ if output.syncs != 1 {
241+ t.Fatalf("readiness output was not flushed: syncs=%d", output.syncs)
242+ }
243+}
244+
245+func TestCLIProcessRejectsPTYLocalEchoAsFlushEvidence(t *testing.T) {
246+ pty, output := &fakePTY{}, &fakeOutput{}
247+ p := &cliProcess{pty: pty, output: output, chunks: make(chan []byte, 1)}
248+ input := workloadInput(scenario{ID: "s"}, 1, "measured-1")
249+ p.chunks <- newPTYLocalEcho(input).expected
250+ close(p.chunks)
251+ flushed := false
252+ if err := p.Measure(input, func() error { return nil }, func() error { flushed = true; return nil }); err == nil {
253+ t.Fatal("local PTY echo satisfied terminal flush boundary")
254+ }
255+ if flushed || output.syncs != 0 {
256+ t.Fatalf("local echo stamped a flush: flushed=%t syncs=%d", flushed, output.syncs)
257+ }
258+}
259+
260+func TestCLIProcessPairsApplicationOutputWithSuccessfulFlush(t *testing.T) {
261+ pty, output := &fakePTY{}, &fakeOutput{}
262+ p := &cliProcess{pty: pty, output: output, chunks: make(chan []byte, 1)}
263+ input := workloadInput(scenario{ID: "s"}, 1, "measured-1")
264+ echo := newPTYLocalEcho(input).expected
265+ p.chunks <- append(append([]byte(nil), echo...), append([]byte("application "), inputMarker(input)...)...)
266+ order := []string{}
267+ if err := p.Measure(input, func() error { order = append(order, "injected"); return nil }, func() error { order = append(order, "flushed"); return nil }); err != nil {
268+ t.Fatal(err)
269+ }
270+ if len(pty.writes) != 1 || output.syncs != 1 || !equalStrings(order, []string{"injected", "flushed"}) {
271+ t.Fatalf("writes=%q syncs=%d boundary order=%q", pty.writes, output.syncs, order)
272+ }
273+}
274+
275+func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) {
276+ for _, tc := range []struct {
277+ role string
278+ want []string
279+ }{
280+ {"daemon", []string{"--daemon"}},
281+ {"client", []string{"new", "perf-s-001"}},
282+ {"ssh_stdio_peer", []string{"_stdio", "perf-s-001"}},
283+ {"udp_peer", []string{"_udp-proxy", "perf-s-001"}},
284+ } {
285+ t.Run(tc.role, func(t *testing.T) {
286+ got := roleArgs(scenario{ID: "s", Transport: "local"}, processMapping{Role: tc.role, Run: 1})
287+ if !equalStrings(got.Args, tc.want) {
288+ t.Fatalf("args=%q want %q", got.Args, tc.want)
289+ }
290+ })
291+ }
292+ input := string(workloadInput(scenario{ID: "s", Workload: "interactive_flood"}, 1, "measured-1"))
293+ 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") {
294+ t.Fatalf("workload is not real PTY shell input with an observable marker: %q", input)
295+ }
296+}
A cmd/vev-perf-harness/main.go +20 -0
@@ -0,0 +1,20 @@
1+package main
2+
3+import (
4+ "fmt"
5+ "os"
6+ "time"
7+
8+ "github.com/bnema/vev/pkg/safedir"
9+)
10+
11+func main() {
12+ if err := run(os.Args[1:], defaultHarness()); err != nil {
13+ fmt.Fprintln(os.Stderr, "vev-perf-harness:", err)
14+ os.Exit(2)
15+ }
16+}
17+
18+func defaultHarness() *harness {
19+ return &harness{clock: systemClock{time.Now()}, mkdir: safedir.EnsurePrivate, createRunDir: createExclusiveRunDir, create: func(p string) (*os.File, error) { return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) }, removeAll: os.RemoveAll, gitSHA: recordedGitSHA}
20+}
A cmd/vev-perf-harness/model.go +132 -0
@@ -0,0 +1,132 @@
1+package main
2+
3+import "time"
4+
5+const (
6+ minimumDuration = 30 * time.Second
7+ minimumRepetitions = 10
8+ minimumInIntervalEventSamples = 10
9+ measuredEventCadence = time.Second
10+)
11+
12+type options struct {
13+ vevBin, manifest, out string
14+ scenario string
15+ warmup, duration time.Duration
16+ repetitions int
17+}
18+
19+type manifest struct {
20+ Schema uint16 `json:"schema"`
21+ Topologies []topology `json:"topologies"`
22+ Workloads []string `json:"workloads"`
23+ Transports []transport `json:"transports"`
24+ Scenarios []scenario `json:"scenarios"`
25+}
26+
27+type topology struct {
28+ ID string `json:"id"`
29+ Geometry string `json:"geometry"`
30+ RowsPerPane int `json:"rows_per_pane"`
31+}
32+
33+type transport struct {
34+ ID string `json:"id"`
35+ Kind string `json:"kind"`
36+ RTTMS int `json:"rtt_ms,omitempty"`
37+ LossPercent int `json:"loss_percent,omitempty"`
38+}
39+
40+type scenario struct {
41+ ID string `json:"id"`
42+ Topology string `json:"topology"`
43+ Workload string `json:"workload"`
44+ Transport string `json:"transport"`
45+ Roles []string `json:"roles"`
46+ InapplicableReason string `json:"inapplicable_reason,omitempty"`
47+}
48+
49+type processMapping struct {
50+ ProcessID string `json:"process_id"`
51+ ClockDomain string `json:"clock_domain"`
52+ TracePath string `json:"trace_path"`
53+ Role string `json:"role"`
54+ Scenario string `json:"scenario"`
55+ Run int `json:"run"`
56+ Identity string `json:"identity"`
57+}
58+
59+type runManifest struct {
60+ Scenario string `json:"scenario"`
61+ Run int `json:"run"`
62+ Processes []processMapping `json:"processes"`
63+}
64+
65+// measuredInterval belongs solely to the harness clock domain. A paired event
66+// is eligible only when both owned boundaries fall inside this interval.
67+type measuredInterval struct {
68+ Start int64
69+ End int64
70+}
71+
72+type eventSample struct {
73+ Sequence uint64 `json:"sequence"`
74+ Injected int64 `json:"injected_tick"`
75+ Flushed int64 `json:"flushed_tick"`
76+ Latency int64 `json:"latency_nanos"`
77+}
78+
79+type span struct {
80+ Component, Name string
81+ Samples []int64
82+}
83+
84+type runResult struct {
85+ Spans []span `json:"process_local_spans"`
86+ Scenario string `json:"scenario"`
87+ Run int `json:"run"`
88+ Samples int `json:"samples"`
89+ EndToEnd []int64 `json:"end_to_end_nanos"`
90+ Event distribution `json:"event_end_to_end"`
91+ Cadence distribution `json:"event_cadence_nanos"`
92+ CadenceSamples []int64 `json:"-"`
93+ MaxGap int64 `json:"event_max_gap_nanos"`
94+ Processes []processMapping `json:"processes"`
95+}
96+
97+type summary struct {
98+ Schema uint16 `json:"schema"`
99+ GitSHA string `json:"git_sha"`
100+ Warmup string `json:"warmup"`
101+ Duration string `json:"duration"`
102+ Repetitions int `json:"repetitions"`
103+ EndToEnd distribution `json:"harness_end_to_end"`
104+ Cadence distribution `json:"harness_event_cadence_nanos"`
105+ MaxGap int64 `json:"harness_event_max_gap_nanos"`
106+ RunP50Dispersion distribution `json:"run_p50_dispersion"`
107+ Spans []spanSummary `json:"process_local_spans"`
108+ Runs int `json:"runs"`
109+}
110+
111+type distribution struct {
112+ Count int `json:"count"`
113+ P50 int64 `json:"p50"`
114+ P95 int64 `json:"p95"`
115+ P99 int64 `json:"p99"`
116+ Max int64 `json:"max"`
117+}
118+
119+type spanSummary struct {
120+ Component string `json:"component"`
121+ Name string `json:"name"`
122+ Distribution distribution `json:"distribution"`
123+}
124+
125+type clock interface {
126+ Now() int64
127+ Sleep(time.Duration)
128+}
129+
130+type systemClock struct{ start time.Time }
131+
132+var spanPairs = []spanPair{{"capture_start", "capture_end", "capture_duration"}, {"compose_start", "compose_end", "compose_duration"}, {"diff_start", "diff_end", "diff_duration"}, {"queue_enqueued", "queue_dequeued", "queue_wait"}, {"ack_blocked_start", "ack_blocked_end", "ack_blocked_interval"}, {"emit_start", "emit_end", "emit_duration"}, {"adapter_send_start", "adapter_send_end", "adapter_send_duration"}, {"adapter_receive_start", "adapter_receive_end", "adapter_receive_duration"}}
A cmd/vev-perf-harness/options_manifest.go +221 -0
@@ -0,0 +1,221 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "flag"
7+ "fmt"
8+ "io"
9+ "os"
10+ "path/filepath"
11+)
12+
13+func parseOptions(args []string) (options, error) {
14+ var o options
15+ fs := flag.NewFlagSet("vev-perf-harness", flag.ContinueOnError)
16+ fs.SetOutput(io.Discard)
17+ fs.StringVar(&o.vevBin, "vev-bin", "", "public vev binary")
18+ fs.StringVar(&o.manifest, "manifest", "", "manifest")
19+ fs.StringVar(&o.out, "out", "", "output directory")
20+ fs.StringVar(&o.scenario, "scenario", "", "one canonical scenario ID (after full manifest validation)")
21+ fs.DurationVar(&o.warmup, "warmup", 0, "warmup")
22+ fs.DurationVar(&o.duration, "duration", 0, "measurement duration")
23+ fs.IntVar(&o.repetitions, "repetitions", 0, "repetitions")
24+ if err := fs.Parse(args); err != nil {
25+ return o, err
26+ }
27+ seen := map[string]bool{}
28+ fs.Visit(func(f *flag.Flag) { seen[f.Name] = true })
29+ for _, name := range []string{"vev-bin", "manifest", "out", "warmup", "duration", "repetitions"} {
30+ if !seen[name] {
31+ return o, fmt.Errorf("--%s is required", name)
32+ }
33+ }
34+ if o.vevBin == "" || o.manifest == "" || o.out == "" {
35+ return o, errors.New("--vev-bin, --manifest, and --out are required")
36+ }
37+ if o.warmup < 0 {
38+ return o, errors.New("--warmup must not be negative")
39+ }
40+ if o.duration < minimumDuration {
41+ return o, fmt.Errorf("--duration must be at least %s", minimumDuration)
42+ }
43+ if o.repetitions < minimumRepetitions {
44+ return o, fmt.Errorf("--repetitions must be at least %d", minimumRepetitions)
45+ }
46+ return o, nil
47+}
48+
49+// resolvePathOptions captures the harness cwd once, before role launchers
50+// assign their isolated working directories. All downstream filesystem access,
51+// manifests, and process environments consequently refer to the same paths.
52+func resolvePathOptions(o options) (options, error) {
53+ cwd, err := os.Getwd()
54+ if err != nil {
55+ return o, fmt.Errorf("resolve harness working directory: %w", err)
56+ }
57+ absolute := func(path string) string {
58+ if filepath.IsAbs(path) {
59+ return filepath.Clean(path)
60+ }
61+ return filepath.Clean(filepath.Join(cwd, path))
62+ }
63+ o.vevBin = absolute(o.vevBin)
64+ o.manifest = absolute(o.manifest)
65+ o.out = absolute(o.out)
66+ return o, nil
67+}
68+
69+func readManifest(path string) (manifest, error) {
70+ f, err := os.Open(path)
71+ if err != nil {
72+ return manifest{}, err
73+ }
74+ var m manifest
75+ if err := json.NewDecoder(f).Decode(&m); err != nil {
76+ return manifest{}, closeFile(f, err)
77+ }
78+ if err := f.Close(); err != nil {
79+ return manifest{}, err
80+ }
81+ return m, nil
82+}
83+
84+func validateManifest(m manifest) error {
85+ if m.Schema != 1 {
86+ return errors.New("unsupported manifest schema")
87+ }
88+ tops := map[string]bool{}
89+ for _, t := range m.Topologies {
90+ if t.ID == "" || t.Geometry != "120x40" || t.RowsPerPane != 10000 {
91+ return fmt.Errorf("invalid topology %q", t.ID)
92+ }
93+ tops[t.ID] = true
94+ }
95+ wantT := []string{"1x4", "4x1", "4x4", "8x1"}
96+ for _, v := range wantT {
97+ if !tops[v] {
98+ return fmt.Errorf("missing canonical topology %s", v)
99+ }
100+ }
101+ works := set(m.Workloads)
102+ for _, v := range []string{"idle", "active_output", "all_output", "inactive_output", "interactive_flood", "copy_search", "resize_sweep", "snapshot_output_resize", "attach_restore_tab_switch"} {
103+ if !works[v] {
104+ return fmt.Errorf("missing canonical workload %s", v)
105+ }
106+ }
107+ trans := map[string]bool{}
108+ for _, t := range m.Transports {
109+ if t.ID == "" {
110+ return errors.New("transport missing id")
111+ }
112+ if err := validateTransportFixture(t); err != nil {
113+ return err
114+ }
115+ trans[t.ID] = true
116+ }
117+ for _, v := range []string{"local", "ssh_stdio", "udp_baseline", "udp_25ms", "udp_100ms", "udp_loss_0pct", "udp_loss_1pct"} {
118+ if !trans[v] {
119+ return fmt.Errorf("missing canonical transport %s", v)
120+ }
121+ }
122+ seen := map[string]bool{}
123+ covered := map[string]bool{}
124+ for _, s := range m.Scenarios {
125+ if s.ID == "" || seen[s.ID] {
126+ return fmt.Errorf("duplicate or empty scenario id %q", s.ID)
127+ }
128+ seen[s.ID] = true
129+ if !tops[s.Topology] || !works[s.Workload] || !trans[s.Transport] {
130+ return fmt.Errorf("scenario %s references unknown matrix entry", s.ID)
131+ }
132+ if s.InapplicableReason == "" && len(s.Roles) == 0 {
133+ return fmt.Errorf("scenario %s has no process roles", s.ID)
134+ }
135+ if s.InapplicableReason != "" && len(s.Roles) != 0 {
136+ return fmt.Errorf("inapplicable scenario %s must not launch roles", s.ID)
137+ }
138+ if s.InapplicableReason == "" && !equalRoleSet(s.Roles, requiredRoles(s.Transport)) {
139+ return fmt.Errorf("scenario %s roles do not route transport %s", s.ID, s.Transport)
140+ }
141+ covered[s.Topology+"\x00"+s.Workload+"\x00"+s.Transport] = true
142+ }
143+ for topologyID := range tops {
144+ for workload := range works {
145+ for transportID := range trans {
146+ key := topologyID + "\x00" + workload + "\x00" + transportID
147+ if !covered[key] {
148+ return fmt.Errorf("missing scenario or inapplicable reason for %s/%s/%s", topologyID, workload, transportID)
149+ }
150+ }
151+ }
152+ }
153+ return nil
154+}
155+
156+// selectScenario is intentionally called only after validateManifest. A bounded
157+// local evidence run therefore cannot mask an incomplete canonical matrix.
158+func selectScenario(m manifest, id string) ([]scenario, error) {
159+ for _, s := range m.Scenarios {
160+ if s.ID != id {
161+ continue
162+ }
163+ if s.InapplicableReason != "" {
164+ return nil, fmt.Errorf("scenario %s is inapplicable: %s", id, s.InapplicableReason)
165+ }
166+ return []scenario{s}, nil
167+ }
168+ return nil, fmt.Errorf("unknown canonical scenario %s", id)
169+}
170+
171+func requiredRoles(transportID string) []string {
172+ switch transportID {
173+ case "local":
174+ return []string{"daemon", "client"}
175+ case "ssh_stdio":
176+ return []string{"daemon", "client", "ssh_stdio_peer"}
177+ default:
178+ return []string{"daemon", "client", "udp_peer"}
179+ }
180+}
181+
182+func equalRoleSet(got, want []string) bool {
183+ if len(got) != len(want) {
184+ return false
185+ }
186+ seen := make(map[string]bool, len(got))
187+ for _, role := range got {
188+ seen[role] = true
189+ }
190+ for _, role := range want {
191+ if !seen[role] {
192+ return false
193+ }
194+ }
195+ return true
196+}
197+
198+func validateTransportFixture(t transport) error {
199+ want := map[string]transport{
200+ "local": {ID: "local", Kind: "local"},
201+ "ssh_stdio": {ID: "ssh_stdio", Kind: "ssh_stdio"},
202+ "udp_baseline": {ID: "udp_baseline", Kind: "udp"},
203+ "udp_25ms": {ID: "udp_25ms", Kind: "udp", RTTMS: 25},
204+ "udp_100ms": {ID: "udp_100ms", Kind: "udp", RTTMS: 100},
205+ "udp_loss_0pct": {ID: "udp_loss_0pct", Kind: "udp", LossPercent: 0},
206+ "udp_loss_1pct": {ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1},
207+ }
208+ w, ok := want[t.ID]
209+ if !ok || t.Kind != w.Kind || t.RTTMS != w.RTTMS || t.LossPercent != w.LossPercent {
210+ return fmt.Errorf("invalid canonical transport fixture %+v", t)
211+ }
212+ return nil
213+}
214+
215+func set(v []string) map[string]bool {
216+ r := map[string]bool{}
217+ for _, x := range v {
218+ r[x] = true
219+ }
220+ return r
221+}
A cmd/vev-perf-harness/options_manifest_test.go +201 -0
@@ -0,0 +1,201 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+func TestHarnessScenarioSelectionKeepsCanonicalManifestValidation(t *testing.T) {
14+ root, err := filepath.Abs(filepath.Join("..", ".."))
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+ manifestPath := filepath.Join(root, "testdata", "perf", "manifest.json")
19+ m, err := readManifest(manifestPath)
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ selected := m.Scenarios[0]
24+ h := defaultHarness()
25+ h.clock, h.launcher = &fakeClock{}, &fakeLauncher{}
26+ out := filepath.Join(t.TempDir(), "out")
27+ if err := run([]string{"--vev-bin", "ignored", "--manifest", manifestPath, "--out", out, "--scenario", selected.ID, "--warmup", "0s", "--duration", "30s", "--repetitions", "10"}, h); err != nil {
28+ t.Fatal(err)
29+ }
30+ var runs []runResult
31+ b, err := os.ReadFile(filepath.Join(out, "runs.json"))
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ if err := json.Unmarshal(b, &runs); err != nil {
36+ t.Fatal(err)
37+ }
38+ if len(runs) != 10 {
39+ t.Fatalf("selected canonical runs=%d, want 10", len(runs))
40+ }
41+ for _, result := range runs {
42+ if result.Scenario != selected.ID {
43+ t.Fatalf("selected scenario = %q, want %q", result.Scenario, selected.ID)
44+ }
45+ }
46+}
47+
48+func TestHarnessScenarioSelectionRejectsIncompleteCanonicalManifest(t *testing.T) {
49+ m, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json"))
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ selected := m.Scenarios[0]
54+ m.Scenarios = m.Scenarios[:len(m.Scenarios)-1]
55+ dir := t.TempDir()
56+ manifestPath := filepath.Join(dir, "manifest.json")
57+ if err := os.WriteFile(manifestPath, []byte(mustJSON(m)), 0o600); err != nil {
58+ t.Fatal(err)
59+ }
60+ launcher := &fakeLauncher{}
61+ h := defaultHarness()
62+ h.clock, h.launcher = &fakeClock{}, launcher
63+ out := filepath.Join(dir, "out")
64+ err = run([]string{"--vev-bin", "ignored", "--manifest", manifestPath, "--out", out, "--scenario", selected.ID, "--warmup", "0s", "--duration", "30s", "--repetitions", "10"}, h)
65+ if err == nil || !strings.Contains(err.Error(), "missing scenario or inapplicable reason") {
66+ t.Fatalf("incomplete manifest selection error = %v", err)
67+ }
68+ if len(launcher.mappings) != 0 {
69+ t.Fatalf("launched with incomplete canonical manifest: %+v", launcher.mappings)
70+ }
71+ if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) {
72+ t.Fatalf("output created before manifest validation: %v", statErr)
73+ }
74+}
75+
76+func TestResolvePathOptionsMakesRelativeAndAbsoluteInputsIdentical(t *testing.T) {
77+ cwd, err := os.Getwd()
78+ if err != nil {
79+ t.Fatal(err)
80+ }
81+ absolute := options{
82+ vevBin: filepath.Join(cwd, "vev"),
83+ manifest: filepath.Join(cwd, "testdata", "perf", "manifest.json"),
84+ out: filepath.Join(cwd, "testdata", "perf", "results"),
85+ }
86+ relative := options{
87+ vevBin: "vev",
88+ manifest: "testdata/perf/manifest.json",
89+ out: "testdata/perf/results",
90+ }
91+ gotRelative, err := resolvePathOptions(relative)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ gotAbsolute, err := resolvePathOptions(absolute)
96+ if err != nil {
97+ t.Fatal(err)
98+ }
99+ if gotRelative != gotAbsolute {
100+ t.Fatalf("relative paths resolved to %+v, absolute paths to %+v", gotRelative, gotAbsolute)
101+ }
102+}
103+
104+func TestHarnessFlagsRejectShortMeasurements(t *testing.T) {
105+ for _, args := range [][]string{
106+ {"--vev-bin", "vev", "--manifest", "m", "--out", "o", "--duration", "29s", "--repetitions", "10"},
107+ {"--vev-bin", "vev", "--manifest", "m", "--out", "o", "--duration", "30s", "--repetitions", "9"},
108+ {"--manifest", "m", "--out", "o", "--duration", "30s", "--repetitions", "10"},
109+ } {
110+ if _, err := parseOptions(args); err == nil {
111+ t.Fatalf("parseOptions(%v) succeeded", args)
112+ }
113+ }
114+ if _, err := parseOptions([]string{"--vev-bin", "vev", "--manifest", "m", "--out", "o", "--duration", "30s", "--repetitions", "10"}); err == nil {
115+ t.Fatal("missing --warmup accepted")
116+ }
117+ if _, err := parseOptions([]string{"--vev-bin", "vev", "--manifest", "m", "--out", "o", "--warmup", "1s", "--duration", "30s", "--repetitions", "10"}); err != nil {
118+ t.Fatal(err)
119+ }
120+}
121+
122+func TestHarnessManifestCoversCanonicalMatrix(t *testing.T) {
123+ m, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json"))
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ if err := validateManifest(m); err != nil {
128+ t.Fatal(err)
129+ }
130+ if len(m.Scenarios) != 4*9*7 {
131+ t.Fatalf("scenarios=%d", len(m.Scenarios))
132+ }
133+ m.Scenarios = m.Scenarios[:len(m.Scenarios)-1]
134+ if err := validateManifest(m); err == nil {
135+ t.Fatal("missing combination accepted")
136+ }
137+}
138+
139+func TestHarnessCreatesExclusiveTraceManifestAndEvidence(t *testing.T) {
140+ dir := t.TempDir()
141+ raw, err := os.OpenFile(filepath.Join(dir, "raw-harness.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
142+ if err != nil {
143+ t.Fatal(err)
144+ }
145+ defer raw.Close()
146+ launcher := &fakeLauncher{}
147+ h := defaultHarness()
148+ h.clock = &fakeClock{}
149+ h.launcher = launcher
150+ o := options{vevBin: "ignored", out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}
151+ r, err := h.runOne(o, manifest{}, scenario{ID: "4x4-local", Roles: []string{"daemon", "client"}}, 1, raw)
152+ if err != nil {
153+ t.Fatal(err)
154+ }
155+ if !launcher.manifestPresent {
156+ t.Fatal("launched before complete run manifest")
157+ }
158+ if len(r.EndToEnd) < minimumInIntervalEventSamples || r.Samples < minimumInIntervalEventSamples {
159+ t.Fatalf("samples=%+v", r)
160+ }
161+ seen := map[string]bool{}
162+ for _, m := range r.Processes {
163+ if m.ProcessID == "" || m.ClockDomain != m.ProcessID || m.TracePath == "" || seen[m.TracePath] {
164+ t.Fatalf("bad mapping %+v", m)
165+ }
166+ seen[m.TracePath] = true
167+ if _, err := os.Stat(m.TracePath); err != nil {
168+ t.Fatal(err)
169+ }
170+ }
171+ b, err := os.ReadFile(filepath.Join(dir, "4x4-local-run-001", "manifest.json"))
172+ if err != nil {
173+ t.Fatal(err)
174+ }
175+ var got runManifest
176+ if err := json.Unmarshal(b, &got); err != nil {
177+ t.Fatal(err)
178+ }
179+ if len(got.Processes) != 2 {
180+ t.Fatalf("manifest=%+v", got)
181+ }
182+ 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) < minimumInIntervalEventSamples {
183+ t.Fatalf("process lifecycle not recorded: %+v", launcher.process)
184+ }
185+}
186+
187+func TestHarnessRejectsMissingManifestRoleTrace(t *testing.T) {
188+ dir := t.TempDir()
189+ missing := filepath.Join(dir, "daemon.jsonl")
190+ client := filepath.Join(dir, "client.jsonl")
191+ if err := os.WriteFile(client, nil, 0o600); err != nil {
192+ t.Fatal(err)
193+ }
194+ _, err := mergeProcessTraces([]processMapping{
195+ {ProcessID: "daemon", ClockDomain: "daemon", TracePath: missing, Role: "daemon", Scenario: "s", Run: 1},
196+ {ProcessID: "client", ClockDomain: "client", TracePath: client, Role: "client", Scenario: "s", Run: 1},
197+ })
198+ if !errors.Is(err, os.ErrNotExist) || !strings.Contains(err.Error(), "daemon role") {
199+ t.Fatalf("missing daemon trace error = %v, want daemon role not-exist error", err)
200+ }
201+}
A cmd/vev-perf-harness/runner.go +439 -0
@@ -0,0 +1,439 @@
1+package main
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "runtime/debug"
13+ "sort"
14+ "strings"
15+ "time"
16+
17+ "github.com/bnema/vev/pkg/safedir"
18+)
19+
20+type harnessMark struct {
21+ Scenario string `json:"scenario"`
22+ Run int `json:"run"`
23+ Sequence uint64 `json:"sequence"`
24+ Kind string `json:"kind"`
25+ Tick int64 `json:"tick"`
26+ Valid bool `json:"valid"`
27+}
28+
29+func (c systemClock) Now() int64 { return time.Since(c.start).Nanoseconds() }
30+
31+func (systemClock) Sleep(d time.Duration) { time.Sleep(d) }
32+
33+func newPTYLocalEcho(input []byte) *ptyLocalEcho {
34+ expected := make([]byte, 0, len(input))
35+ for _, b := range input {
36+ if b == '\n' {
37+ expected = append(expected, '\r')
38+ }
39+ expected = append(expected, b)
40+ }
41+ return &ptyLocalEcho{expected: expected}
42+}
43+
44+type harness struct {
45+ clock clock
46+ launcher launcher
47+ mkdir func(string) error
48+ createRunDir func(string) error
49+ create func(string) (*os.File, error)
50+ removeAll func(string) error
51+ gitSHA func() string
52+ selectScenarios func(manifest) []scenario // test-only bounded fixture seam
53+}
54+
55+// createExclusiveRunDir prevents stale runtime, socket, state, or trace files
56+// from an earlier invocation being mistaken for the next repetition's evidence.
57+func createExclusiveRunDir(path string) error {
58+ if err := os.Mkdir(path, 0o700); err != nil {
59+ return err
60+ }
61+ if err := safedir.EnsurePrivate(path); err != nil {
62+ return errors.Join(err, os.Remove(path))
63+ }
64+ return nil
65+}
66+
67+// recordedGitSHA prefers build provenance so an installed public harness does
68+// not depend on its launch directory being a checkout. The git fallback keeps
69+// go run evidence attributable as well.
70+const gitSHACommandTimeout = time.Second
71+
72+func recordedGitSHA() string {
73+ if info, ok := debug.ReadBuildInfo(); ok {
74+ for _, setting := range info.Settings {
75+ if setting.Key == "vcs.revision" && setting.Value != "" {
76+ return setting.Value
77+ }
78+ }
79+ }
80+ ctx, cancel := context.WithTimeout(context.Background(), gitSHACommandTimeout)
81+ defer cancel()
82+ out, err := exec.CommandContext(ctx, "git", "rev-parse", "HEAD").Output()
83+ if err == nil && strings.TrimSpace(string(out)) != "" {
84+ return strings.TrimSpace(string(out))
85+ }
86+ return "unknown"
87+}
88+
89+func run(args []string, h *harness) (err error) {
90+ opt, err := parseOptions(args)
91+ if err != nil {
92+ return err
93+ }
94+ opt, err = resolvePathOptions(opt)
95+ if err != nil {
96+ return err
97+ }
98+ m, err := readManifest(opt.manifest)
99+ if err != nil {
100+ return err
101+ }
102+ if err := validateManifest(m); err != nil {
103+ return err
104+ }
105+ if err = h.mkdir(opt.out); err != nil {
106+ return err
107+ }
108+ raw, err := os.OpenFile(filepath.Join(opt.out, "raw-harness.jsonl"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
109+ if err != nil {
110+ return err
111+ }
112+ defer func() {
113+ if closeErr := raw.Close(); closeErr != nil {
114+ err = errors.Join(err, closeErr)
115+ }
116+ }()
117+ results := make([]runResult, 0, len(m.Scenarios)*opt.repetitions)
118+ var localSpans []span
119+ scenarios := m.Scenarios
120+ if opt.scenario != "" {
121+ scenarios, err = selectScenario(m, opt.scenario)
122+ if err != nil {
123+ return err
124+ }
125+ }
126+ if h.selectScenarios != nil {
127+ scenarios = h.selectScenarios(m)
128+ }
129+ for _, s := range scenarios {
130+ if s.InapplicableReason != "" {
131+ continue
132+ }
133+ for r := 1; r <= opt.repetitions; r++ {
134+ rr, err := h.runOne(opt, m, s, r, raw)
135+ if err != nil {
136+ return fmt.Errorf("scenario %s run %d: %w", s.ID, r, err)
137+ }
138+ results = append(results, rr)
139+ localSpans = append(localSpans, rr.Spans...)
140+ }
141+ }
142+ all, cadence, maxGap, runP50s := aggregateEventResults(results)
143+ if len(all) == 0 {
144+ return errors.New("no measured harness input/flush pairs")
145+ }
146+ if err := writeJSON(filepath.Join(opt.out, "runs.json"), results); err != nil {
147+ return err
148+ }
149+ return writeJSON(filepath.Join(opt.out, "summary.json"), summary{Schema: 1, GitSHA: h.gitSHA(), Warmup: opt.warmup.String(), Duration: opt.duration.String(), Repetitions: opt.repetitions, EndToEnd: percentiles(all), Cadence: percentiles(cadence), MaxGap: maxGap, RunP50Dispersion: percentiles(runP50s), Spans: summarizeSpans(localSpans), Runs: len(results)})
150+}
151+
152+// aggregateEventResults deliberately preserves raw event latencies for
153+// end-to-end percentiles. Per-run p50s are a separate dispersion series.
154+func aggregateEventResults(results []runResult) (all, cadence []int64, maxGap int64, runP50s []int64) {
155+ for _, result := range results {
156+ all = append(all, result.EndToEnd...)
157+ cadence = append(cadence, result.CadenceSamples...)
158+ runP50s = append(runP50s, result.Event.P50)
159+ if result.MaxGap > maxGap {
160+ maxGap = result.MaxGap
161+ }
162+ }
163+ return all, cadence, maxGap, runP50s
164+}
165+
166+func (h *harness) runOne(o options, mat manifest, s scenario, run int, raw io.Writer) (res runResult, err error) {
167+ dir := filepath.Join(o.out, fmt.Sprintf("%s-run-%03d", safeName(s.ID), run))
168+ createRunDir := h.createRunDir
169+ if createRunDir == nil {
170+ createRunDir = createExclusiveRunDir
171+ }
172+ if err = createRunDir(dir); err != nil {
173+ return res, fmt.Errorf("create isolated run directory: %w", err)
174+ }
175+ success := false
176+ defer func() {
177+ if !success {
178+ if cleanupErr := h.removeAll(dir); cleanupErr != nil {
179+ err = errors.Join(err, fmt.Errorf("remove failed run directory: %w", cleanupErr))
180+ }
181+ }
182+ }()
183+ maps := make([]processMapping, 0, len(s.Roles))
184+ for i, role := range s.Roles {
185+ pid := fmt.Sprintf("%s-r%03d-%s-%02d", safeName(s.ID), run, safeName(role), i+1)
186+ path := filepath.Join(dir, pid+".jsonl")
187+ f, e := h.create(path)
188+ if e != nil {
189+ return res, fmt.Errorf("allocate exclusive trace %s: %w", role, e)
190+ }
191+ if e = f.Close(); e != nil {
192+ return res, e
193+ }
194+ maps = append(maps, processMapping{ProcessID: pid, ClockDomain: pid, TracePath: path, Role: role, Scenario: s.ID, Run: run, Identity: "harness-assigned:" + pid})
195+ }
196+ // Persist the complete mapping before any process can be launched. This is
197+ // both crash evidence and the only legal source for later ID correlation.
198+ if err = writeJSON(filepath.Join(dir, "manifest.json"), runManifest{Scenario: s.ID, Run: run, Processes: maps}); err != nil {
199+ return res, err
200+ }
201+ if h.launcher == nil {
202+ h.launcher = &cliLauncher{bin: o.vevBin}
203+ }
204+ processes := make([]launchedRole, 0, len(maps))
205+ closed := false
206+ closeRoles := func() error {
207+ if closed {
208+ return nil
209+ }
210+ closed = true
211+ // Clients own SSH-stdio descendants; close them before the deferred UDP
212+ // peer and daemon so no role survives a failed or completed run.
213+ return closeLaunchedRoles(processes)
214+ }
215+ defer func() {
216+ if cleanupErr := closeRoles(); cleanupErr != nil {
217+ if err == nil {
218+ err = cleanupErr
219+ } else {
220+ // Keep the primary workload failure first while retaining every
221+ // cleanup failure in reverse launch order.
222+ err = errors.Join(err, cleanupErr)
223+ }
224+ }
225+ }()
226+ selectedTransport, e := manifestTransport(mat, s.Transport)
227+ if e != nil {
228+ return res, e
229+ }
230+ for _, pm := range launchOrder(maps) {
231+ p, e := h.launcher.Launch(pm, routeRoleArgs(s, pm, selectedTransport))
232+ if e != nil {
233+ return res, e
234+ }
235+ processes = append(processes, launchedRole{mapping: pm, process: p})
236+ if pm.Role == "daemon" {
237+ if ready, ok := p.(interface{ WaitReady() error }); ok {
238+ if err = ready.WaitReady(); err != nil {
239+ return res, err
240+ }
241+ }
242+ }
243+ }
244+ marks := []harnessMark{}
245+ recordMark := func(mark harnessMark) error {
246+ marks = append(marks, mark)
247+ if err := json.NewEncoder(raw).Encode(mark); err != nil {
248+ return fmt.Errorf("write raw harness mark: %w", err)
249+ }
250+ return nil
251+ }
252+ seq := uint64(0)
253+ for _, p := range processes {
254+ if p.mapping.Role != "client" {
255+ continue
256+ }
257+ seq++
258+ sequence := seq
259+ if err = recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "input_injected", Tick: h.clock.Now(), Valid: true}); err != nil {
260+ return res, err
261+ }
262+ warmupErr := p.process.Warmup(workloadInput(s, run, "warmup"))
263+ if markErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "terminal_flushed", Tick: h.clock.Now(), Valid: warmupErr == nil}); markErr != nil {
264+ return res, errors.Join(warmupErr, markErr)
265+ }
266+ if warmupErr != nil {
267+ return res, warmupErr
268+ }
269+ }
270+ // The harness clock, rather than a process clock, owns the explicit warmup
271+ // and measured intervals. Raw evidence retains every boundary, while only
272+ // complete valid pairs within [Start, End) contribute measurement samples.
273+ h.clock.Sleep(o.warmup)
274+ interval := measuredInterval{Start: h.clock.Now()}
275+ interval.End = interval.Start + o.duration.Nanoseconds()
276+ nextInjection := interval.Start
277+ for {
278+ now := h.clock.Now()
279+ if now < nextInjection {
280+ h.clock.Sleep(time.Duration(nextInjection - now))
281+ now = h.clock.Now()
282+ }
283+ if now >= interval.End {
284+ break
285+ }
286+ for _, p := range processes {
287+ if p.mapping.Role != "client" {
288+ continue
289+ }
290+ seq++
291+ sequence := seq
292+ injectedRecorded := false
293+ flushedRecorded := false
294+ injected := func() error {
295+ if markErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "input_injected", Tick: h.clock.Now(), Valid: true}); markErr != nil {
296+ return markErr
297+ }
298+ injectedRecorded = true
299+ return nil
300+ }
301+ flushed := func() error {
302+ if markErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "terminal_flushed", Tick: h.clock.Now(), Valid: true}); markErr != nil {
303+ return markErr
304+ }
305+ flushedRecorded = true
306+ return nil
307+ }
308+ if measureErr := p.process.Measure(workloadInput(s, run, fmt.Sprintf("measured-%d", sequence)), injected, flushed); measureErr != nil {
309+ if injectedRecorded && !flushedRecorded {
310+ diagnosticErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "terminal_flushed", Tick: h.clock.Now(), Valid: false})
311+ return res, errors.Join(measureErr, diagnosticErr)
312+ }
313+ return res, measureErr
314+ }
315+ }
316+ // Cap injection rate rather than bursting events after a slow terminal
317+ // flush. Any resulting spacing is retained as raw cadence evidence.
318+ nextInjection = h.clock.Now() + measuredEventCadence.Nanoseconds()
319+ }
320+ if now := h.clock.Now(); now < interval.End {
321+ h.clock.Sleep(time.Duration(interval.End - now))
322+ }
323+ events, e := measuredEventSamples(marks, interval)
324+ if e != nil {
325+ return res, e
326+ }
327+ if err := requireMinimumEventSamples(events); err != nil {
328+ return res, err
329+ }
330+ samples := eventLatencies(events)
331+ cadenceSamples, maxGap := eventCadenceSamples(events)
332+ // Process teardown can unblock a receive and cause its failed end mark to
333+ // be serialized. Merge only the complete, post-cleanup per-process traces.
334+ if cleanupErr := closeRoles(); cleanupErr != nil {
335+ return res, cleanupErr
336+ }
337+ spans, e := mergeProcessTraces(maps)
338+ if e != nil {
339+ return res, e
340+ }
341+ success = true
342+ return runResult{Spans: spans, Scenario: s.ID, Run: run, Samples: len(samples), EndToEnd: samples, Event: percentiles(samples), Cadence: percentiles(cadenceSamples), CadenceSamples: cadenceSamples, MaxGap: maxGap, Processes: maps}, nil
343+}
344+
345+func roleArgs(s scenario, m processMapping) roleCommand {
346+ return routeRoleArgs(s, m, scenarioTransport(s))
347+}
348+
349+func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand {
350+ session := "perf-" + safeName(s.ID) + fmt.Sprintf("-%03d", m.Run)
351+ remote := []string{"attach", "harness@127.0.0.1:" + session}
352+ switch m.Role {
353+ case "daemon":
354+ return roleCommand{Args: []string{"--daemon"}}
355+ case "client":
356+ switch s.Transport {
357+ case "local":
358+ return roleCommand{Args: []string{"new", session}}
359+ case "ssh_stdio", "udp_baseline", "udp_25ms", "udp_100ms", "udp_loss_0pct", "udp_loss_1pct":
360+ return roleCommand{Args: remote}
361+ }
362+ case "ssh_stdio_peer":
363+ return roleCommand{Args: []string{"_stdio", session}, Transport: transport{ID: s.Transport, Kind: "ssh_stdio"}}
364+ case "udp_peer":
365+ return roleCommand{Args: []string{"_udp-proxy", session}, Transport: selected}
366+ }
367+ return roleCommand{}
368+}
369+
370+func manifestTransport(m manifest, id string) (transport, error) {
371+ for _, t := range m.Transports {
372+ if t.ID == id {
373+ return t, nil
374+ }
375+ }
376+ // Focused unit tests may call runOne with a minimal manifest; production
377+ // already validates every scenario reference before this point.
378+ if m.Schema == 0 {
379+ return scenarioTransport(scenario{Transport: id}), nil
380+ }
381+ return transport{}, fmt.Errorf("scenario references missing transport %q", id)
382+}
383+
384+func scenarioTransport(s scenario) transport {
385+ switch s.Transport {
386+ case "udp_baseline":
387+ return transport{ID: s.Transport, Kind: "udp"}
388+ case "udp_25ms":
389+ return transport{ID: s.Transport, Kind: "udp", RTTMS: 25}
390+ case "udp_100ms":
391+ return transport{ID: s.Transport, Kind: "udp", RTTMS: 100}
392+ case "udp_loss_0pct":
393+ return transport{ID: s.Transport, Kind: "udp", LossPercent: 0}
394+ case "udp_loss_1pct":
395+ return transport{ID: s.Transport, Kind: "udp", LossPercent: 1}
396+ default:
397+ return transport{ID: s.Transport}
398+ }
399+}
400+
401+func launchOrder(mappings []processMapping) []processMapping {
402+ out := append([]processMapping(nil), mappings...)
403+ priority := func(role string) int {
404+ switch role {
405+ case "daemon":
406+ return 0
407+ case "ssh_stdio_peer", "udp_peer":
408+ return 1
409+ case "client":
410+ return 2
411+ default:
412+ return 3
413+ }
414+ }
415+ sort.SliceStable(out, func(i, j int) bool { return priority(out[i].Role) < priority(out[j].Role) })
416+ return out
417+}
418+
419+// workloadInput is shell input delivered through the client PTY. It avoids any
420+// private daemon API and makes every topology/workload/transport manifest row
421+// execute a real terminal workload. The marker is the output boundary awaited
422+// by cliProcess; it is not a synthetic timestamp.
423+func workloadInput(s scenario, run int, phase string) []byte {
424+ marker := fmt.Sprintf("__VEV_HARNESS_%s_r%d_%s__", safeName(s.ID), run, safeName(phase))
425+ body := "printf 'vev perf %s\\n'"
426+ switch s.Workload {
427+ case "active_output", "all_output", "inactive_output", "interactive_flood":
428+ body = "i=0; while [ $i -lt 128 ]; do printf 'vev perf output %s\\n'; i=$((i+1)); done"
429+ case "resize_sweep":
430+ body = "printf 'vev perf resize-sweep %s\\n'"
431+ case "copy_search":
432+ body = "printf 'vev perf copy-search %s\\n'"
433+ case "snapshot_output_resize":
434+ body = "printf 'vev perf snapshot-output-resize %s\\n'"
435+ case "attach_restore_tab_switch":
436+ body = "printf 'vev perf attach-restore-tab-switch %s\\n'"
437+ }
438+ return []byte(fmt.Sprintf(body+"; printf '%s\\n'\n", s.ID, marker))
439+}
A cmd/vev-perf-harness/runner_test.go +456 -0
@@ -0,0 +1,456 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+ "time"
14+)
15+
16+func TestHarnessCanonicalLocalRolesAreIsolatedAcrossRepetitions(t *testing.T) {
17+ if testing.Short() {
18+ t.Skip("public CLI repetition lifecycle")
19+ }
20+ root, err := filepath.Abs(filepath.Join("..", ".."))
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ bin := filepath.Join(t.TempDir(), "vev")
25+ build := exec.Command("go", "build", "-o", bin, "./")
26+ build.Dir = root
27+ if output, err := build.CombinedOutput(); err != nil {
28+ t.Fatalf("build public CLI: %v\n%s", err, output)
29+ }
30+ m, err := readManifest(filepath.Join(root, "testdata", "perf", "manifest.json"))
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ var local scenario
35+ for _, candidate := range m.Scenarios {
36+ if candidate.ID == "1x4-idle-local" {
37+ local = candidate
38+ break
39+ }
40+ }
41+ if local.ID == "" {
42+ t.Fatal("canonical local scenario missing")
43+ }
44+ out, err := os.MkdirTemp("", "vev-ob-")
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ removeTestTree(t, out)
49+ raw, err := os.OpenFile(filepath.Join(out, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ closeTestFile(t, raw)
54+ h := defaultHarness()
55+ h.clock = &fakeClock{}
56+ h.launcher = &cliLauncher{bin: bin}
57+ for run := 1; run <= 3; run++ {
58+ result, err := h.runOne(options{vevBin: bin, out: out, duration: minimumDuration}, m, local, run, raw)
59+ if err != nil {
60+ t.Fatalf("run %d: %v", run, err)
61+ }
62+ if len(result.Processes) != 2 {
63+ t.Fatalf("run %d process mappings = %+v", run, result.Processes)
64+ }
65+ runDir := filepath.Join(out, fmt.Sprintf("%s-run-%03d", safeName(local.ID), run))
66+ manifestBytes, err := os.ReadFile(filepath.Join(runDir, "manifest.json"))
67+ if err != nil {
68+ t.Fatalf("run %d manifest: %v", run, err)
69+ }
70+ var persisted runManifest
71+ if err := json.Unmarshal(manifestBytes, &persisted); err != nil {
72+ t.Fatalf("run %d manifest JSON: %v", run, err)
73+ }
74+ if persisted.Run != run || len(persisted.Processes) != len(result.Processes) {
75+ t.Fatalf("run %d manifest = %+v", run, persisted)
76+ }
77+ for _, process := range result.Processes {
78+ if process.Role != "daemon" && process.Role != "client" {
79+ t.Fatalf("run %d unexpected role %q", run, process.Role)
80+ }
81+ if filepath.Dir(process.TracePath) != runDir {
82+ t.Fatalf("run %d %s trace outside its isolated run directory: %q", run, process.Role, process.TracePath)
83+ }
84+ trace, err := os.ReadFile(process.TracePath)
85+ if err != nil {
86+ t.Fatalf("run %d %s trace %q: %v", run, process.Role, process.TracePath, err)
87+ }
88+ if len(bytes.TrimSpace(trace)) == 0 {
89+ t.Fatalf("run %d %s did not emit its declared trace", run, process.Role)
90+ }
91+ for _, line := range bytes.Split(bytes.TrimSpace(trace), []byte{'\n'}) {
92+ var record traceRecord
93+ if err := json.Unmarshal(line, &record); err != nil {
94+ t.Fatalf("run %d %s trace JSON: %v", run, process.Role, err)
95+ }
96+ if record.ProcessID != process.ProcessID || record.Scenario != local.ID || record.Run != uint64(run) {
97+ t.Fatalf("run %d %s trace escaped its manifest identity: %+v", run, process.Role, record)
98+ }
99+ }
100+ }
101+ if _, err := os.Stat(filepath.Join(runDir, "runtime", "vev", "daemon.sock")); !errors.Is(err, os.ErrNotExist) {
102+ t.Fatalf("run %d left daemon socket for the next repetition: %v", run, err)
103+ }
104+ }
105+}
106+
107+func TestHarnessResolvesCanonicalRelativePathsBeforeRoleCWDChanges(t *testing.T) {
108+ if testing.Short() {
109+ t.Skip("public CLI relative-path lifecycle")
110+ }
111+ root, err := filepath.Abs(filepath.Join("..", ".."))
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ // Keep the real Unix-socket path below its platform limit while testing the
116+ // exact documented relative output hierarchy.
117+ workspace, err := os.MkdirTemp("", "vev-rel-")
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ removeTestTree(t, workspace)
122+ bin := filepath.Join(workspace, "vev")
123+ build := exec.Command("go", "build", "-o", bin, "./")
124+ build.Dir = root
125+ if output, err := build.CombinedOutput(); err != nil {
126+ t.Fatalf("build public CLI: %v\n%s", err, output)
127+ }
128+ manifestPath := filepath.Join(workspace, "testdata", "perf", "manifest.json")
129+ if err := os.MkdirAll(filepath.Dir(manifestPath), 0o700); err != nil {
130+ t.Fatal(err)
131+ }
132+ contents, err := os.ReadFile(filepath.Join(root, "testdata", "perf", "manifest.json"))
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ if err := os.WriteFile(manifestPath, contents, 0o600); err != nil {
137+ t.Fatal(err)
138+ }
139+ originalWD, err := os.Getwd()
140+ if err != nil {
141+ t.Fatal(err)
142+ }
143+ if err := os.Chdir(workspace); err != nil {
144+ t.Fatal(err)
145+ }
146+ t.Cleanup(func() {
147+ if err := os.Chdir(originalWD); err != nil {
148+ t.Errorf("restore cwd: %v", err)
149+ }
150+ })
151+
152+ // These are the documented relative locations. cliLauncher changes every
153+ // role to its run directory, so a failure to resolve them before launch
154+ // would make VEV_PERF_TRACE point at a nonexistent nested directory.
155+ h := defaultHarness()
156+ h.clock = &fakeClock{}
157+ if err := run([]string{
158+ "--vev-bin", "./vev",
159+ "--manifest", "testdata/perf/manifest.json",
160+ "--out", "testdata/perf/results",
161+ "--scenario", "1x4-idle-local",
162+ "--warmup", "0s",
163+ "--duration", "30s",
164+ "--repetitions", "10",
165+ }, h); err != nil {
166+ t.Fatal(err)
167+ }
168+
169+ out := filepath.Join(workspace, "testdata", "perf", "results")
170+ for run := 1; run <= 10; run++ {
171+ runDir := filepath.Join(out, fmt.Sprintf("1x4-idle-local-run-%03d", run))
172+ manifestBytes, err := os.ReadFile(filepath.Join(runDir, "manifest.json"))
173+ if err != nil {
174+ t.Fatalf("run %d manifest: %v", run, err)
175+ }
176+ var persisted runManifest
177+ if err := json.Unmarshal(manifestBytes, &persisted); err != nil {
178+ t.Fatalf("run %d manifest JSON: %v", run, err)
179+ }
180+ if persisted.Run != run || len(persisted.Processes) != 2 {
181+ t.Fatalf("run %d manifest=%+v", run, persisted)
182+ }
183+ for _, process := range persisted.Processes {
184+ if !filepath.IsAbs(process.TracePath) || filepath.Dir(process.TracePath) != runDir {
185+ t.Fatalf("run %d %s trace path=%q, want absolute path in %q", run, process.Role, process.TracePath, runDir)
186+ }
187+ trace, err := os.ReadFile(process.TracePath)
188+ if err != nil || len(bytes.TrimSpace(trace)) == 0 {
189+ t.Fatalf("run %d %s trace=%q err=%v", run, process.Role, process.TracePath, err)
190+ }
191+ }
192+ }
193+}
194+
195+func TestHarnessRejectsExistingRunDirectoryBeforeLaunchingRoles(t *testing.T) {
196+ out := t.TempDir()
197+ raw, err := os.OpenFile(filepath.Join(out, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
198+ if err != nil {
199+ t.Fatal(err)
200+ }
201+ closeTestFile(t, raw)
202+ s := scenario{ID: "isolated", Roles: []string{"daemon", "client"}}
203+ stale := filepath.Join(out, "isolated-run-001")
204+ if err := os.Mkdir(stale, 0o700); err != nil {
205+ t.Fatal(err)
206+ }
207+ if err := os.WriteFile(filepath.Join(stale, "preserve"), []byte("stale"), 0o600); err != nil {
208+ t.Fatal(err)
209+ }
210+ launcher := &fakeLauncher{}
211+ h := defaultHarness()
212+ h.clock, h.launcher = &fakeClock{}, launcher
213+ _, err = h.runOne(options{out: out, duration: minimumDuration}, manifest{}, s, 1, raw)
214+ if err == nil || !strings.Contains(err.Error(), "create isolated run directory") {
215+ t.Fatalf("existing run directory error = %v", err)
216+ }
217+ if len(launcher.mappings) != 0 {
218+ t.Fatalf("roles launched against stale run directory: %+v", launcher.mappings)
219+ }
220+ if contents, readErr := os.ReadFile(filepath.Join(stale, "preserve")); readErr != nil || string(contents) != "stale" {
221+ t.Fatalf("stale directory changed: contents=%q err=%v", contents, readErr)
222+ }
223+}
224+
225+func TestHarnessDoesNotInheritNestedSession(t *testing.T) {
226+ env := withoutEnv([]string{"VEV=session=old", "PATH=/bin", "VEV_PERF_TRACE=old"}, "VEV")
227+ if !equalStrings(env, []string{"PATH=/bin", "VEV_PERF_TRACE=old"}) {
228+ t.Fatalf("environment=%q", env)
229+ }
230+}
231+
232+func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) {
233+ cases := []struct {
234+ name string
235+ transport transport
236+ peer string
237+ wantRTT int
238+ wantLoss int
239+ }{
240+ {"ssh", transport{ID: "ssh_stdio", Kind: "ssh_stdio"}, "ssh_stdio_peer", 0, 0},
241+ {"udp baseline", transport{ID: "udp_baseline", Kind: "udp"}, "udp_peer", 0, 0},
242+ {"udp 25ms", transport{ID: "udp_25ms", Kind: "udp", RTTMS: 25}, "udp_peer", 25, 0},
243+ {"udp 100ms", transport{ID: "udp_100ms", Kind: "udp", RTTMS: 100}, "udp_peer", 100, 0},
244+ {"udp loss zero", transport{ID: "udp_loss_0pct", Kind: "udp", LossPercent: 0}, "udp_peer", 0, 0},
245+ {"udp loss", transport{ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1}, "udp_peer", 0, 1},
246+ }
247+ for _, tc := range cases {
248+ t.Run(tc.name, func(t *testing.T) {
249+ s := scenario{ID: "route", Transport: tc.transport.ID}
250+ client := routeRoleArgs(s, processMapping{Role: "client", Run: 2}, tc.transport)
251+ peer := routeRoleArgs(s, processMapping{Role: tc.peer, Run: 2}, tc.transport)
252+ if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1:perf-route-002"}) {
253+ t.Fatalf("client did not use public remote attach: %q", client.Args)
254+ }
255+ if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio", "perf-route-002"}) {
256+ t.Fatalf("ssh peer command=%q", peer.Args)
257+ }
258+ if tc.peer == "udp_peer" && !equalStrings(peer.Args, []string{"_udp-proxy", "perf-route-002"}) {
259+ t.Fatalf("udp peer command=%q", peer.Args)
260+ }
261+ if peer.Transport.RTTMS != tc.wantRTT || peer.Transport.LossPercent != tc.wantLoss {
262+ t.Fatalf("peer lost manifest network settings: %+v", peer.Transport)
263+ }
264+ })
265+ }
266+}
267+
268+func TestHarnessFakeRunnerRoutesClientToPeerAndCleansEveryRole(t *testing.T) {
269+ for _, tc := range []struct {
270+ name, transport, peer, peerCommand string
271+ }{
272+ {"ssh", "ssh_stdio", "ssh_stdio_peer", "_stdio"},
273+ {"udp baseline", "udp_baseline", "udp_peer", "_udp-proxy"},
274+ {"udp 25ms", "udp_25ms", "udp_peer", "_udp-proxy"},
275+ {"udp 100ms", "udp_100ms", "udp_peer", "_udp-proxy"},
276+ {"udp loss zero", "udp_loss_0pct", "udp_peer", "_udp-proxy"},
277+ {"udp loss", "udp_loss_1pct", "udp_peer", "_udp-proxy"},
278+ } {
279+ t.Run(tc.name, func(t *testing.T) {
280+ dir := t.TempDir()
281+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
282+ if err != nil {
283+ t.Fatal(err)
284+ }
285+ closeTestFile(t, raw)
286+ l := &fakeLauncher{}
287+ h := defaultHarness()
288+ h.clock, h.launcher = &fakeClock{}, l
289+ s := scenario{ID: "routed", Transport: tc.transport, Roles: []string{"daemon", "client", tc.peer}}
290+ if _, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, s, 1, raw); err != nil {
291+ t.Fatal(err)
292+ }
293+ if len(l.mappings) != 3 || l.mappings[1].Role != tc.peer || l.mappings[2].Role != "client" {
294+ t.Fatalf("dependency launch order=%+v", l.mappings)
295+ }
296+ if l.commands[1].Args[0] != tc.peerCommand || l.commands[2].Args[0] != "attach" {
297+ t.Fatalf("commands do not connect client through declared peer: %+v", l.commands)
298+ }
299+ for i, p := range l.process {
300+ if !p.closed {
301+ t.Fatalf("role %s was not cleaned up", l.mappings[i].Role)
302+ }
303+ }
304+ })
305+ }
306+}
307+
308+func TestHarnessAggregatesRawEventsSeparatelyFromRunDispersion(t *testing.T) {
309+ first := append([]int64(nil), make([]int64, minimumInIntervalEventSamples+5)...)
310+ for i := range first {
311+ first[i] = 1
312+ }
313+ second := []int64{100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 1_000, 1_000}
314+ all, _, _, runP50s := aggregateEventResults([]runResult{
315+ {EndToEnd: first, Event: distribution{P50: 1}},
316+ {EndToEnd: second, Event: distribution{P50: 100}},
317+ })
318+ if got, dispersion := percentiles(all), percentiles(runP50s); got.Count != 30 || got.P99 != 1_000 || dispersion.Count != 2 || dispersion.P99 != 1 {
319+ t.Fatalf("event aggregation=%+v run dispersion=%+v", got, dispersion)
320+ }
321+}
322+
323+func TestHarnessRawRecordsContainEveryMarkWhileSamplesRemainFiltered(t *testing.T) {
324+ dir := t.TempDir()
325+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
326+ if err != nil {
327+ t.Fatal(err)
328+ }
329+ h := defaultHarness()
330+ h.clock, h.launcher = &fakeClock{}, &fakeLauncher{}
331+ result, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "raw-complete", Roles: []string{"daemon", "client"}}, 1, raw)
332+ if err != nil {
333+ t.Fatal(err)
334+ }
335+ if err := raw.Close(); err != nil {
336+ t.Fatal(err)
337+ }
338+ marks := readHarnessMarks(t, filepath.Join(dir, "raw.jsonl"))
339+ if got, want := len(marks), 2*(result.Samples+1); got != want {
340+ t.Fatalf("raw marks=%d, want warmup plus every measured boundary=%d", got, want)
341+ }
342+ for i, mark := range marks[:2] {
343+ if mark.Sequence != 1 || !mark.Valid || []string{"input_injected", "terminal_flushed"}[i] != mark.Kind {
344+ t.Fatalf("warmup mark %d=%+v", i, mark)
345+ }
346+ }
347+ for sequence := uint64(2); sequence <= uint64(result.Samples+1); sequence++ {
348+ if marks[2*(sequence-1)].Sequence != sequence || marks[2*(sequence-1)+1].Sequence != sequence {
349+ t.Fatalf("raw marks did not retain both boundaries for sequence %d: %+v", sequence, marks)
350+ }
351+ }
352+}
353+
354+func TestHarnessPersistsFailedFlushDiagnostics(t *testing.T) {
355+ dir := t.TempDir()
356+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
357+ if err != nil {
358+ t.Fatal(err)
359+ }
360+ failure := errors.New("flush failed")
361+ h := defaultHarness()
362+ h.clock, h.launcher = &fakeClock{}, failedFlushLauncher{err: failure}
363+ _, err = h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "failed-flush", Roles: []string{"daemon", "client"}}, 1, raw)
364+ if !errors.Is(err, failure) {
365+ t.Fatalf("run error=%v, want failed flush", err)
366+ }
367+ if err := raw.Close(); err != nil {
368+ t.Fatal(err)
369+ }
370+ marks := readHarnessMarks(t, filepath.Join(dir, "raw.jsonl"))
371+ if got, want := len(marks), 4; got != want {
372+ t.Fatalf("raw marks=%d, want warmup and failed measured pair=%d: %+v", got, want, marks)
373+ }
374+ failed := marks[len(marks)-1]
375+ if failed.Sequence != 2 || failed.Kind != "terminal_flushed" || failed.Valid {
376+ t.Fatalf("failed flush diagnostic=%+v", failed)
377+ }
378+}
379+
380+func TestHarnessSchedulesMeasuredEventsAcrossFullInterval(t *testing.T) {
381+ dir := t.TempDir()
382+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
383+ if err != nil {
384+ t.Fatal(err)
385+ }
386+ closeTestFile(t, raw)
387+ h := defaultHarness()
388+ h.clock, h.launcher = &fakeClock{}, &fakeLauncher{}
389+ result, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "schedule", Roles: []string{"daemon", "client"}}, 1, raw)
390+ if err != nil {
391+ t.Fatal(err)
392+ }
393+ if result.Samples < minimumInIntervalEventSamples || result.Cadence.Count != result.Samples-1 || result.MaxGap <= 0 {
394+ t.Fatalf("result did not include repeated measured events: %+v", result)
395+ }
396+ if err := raw.Close(); err != nil {
397+ t.Fatal(err)
398+ }
399+ records, err := readJSONL(filepath.Join(dir, "raw.jsonl"))
400+ if err != nil {
401+ t.Fatal(err)
402+ }
403+ if want := result.Samples*2 + 2; len(records) != want {
404+ t.Fatalf("raw records=%d, want %d warmup and measured boundaries", len(records), want)
405+ }
406+}
407+
408+func TestHarnessPreservesWorkloadAndCleanupErrors(t *testing.T) {
409+ dir := t.TempDir()
410+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
411+ if err != nil {
412+ t.Fatal(err)
413+ }
414+ closeTestFile(t, raw)
415+ primary := errors.New("primary workload failure")
416+ cleanup := errors.New("daemon cleanup failure")
417+ launcher := &fakeLauncher{
418+ measureErr: map[string]error{"client": primary},
419+ closeErr: map[string]error{"daemon": cleanup},
420+ }
421+ h := defaultHarness()
422+ h.clock, h.launcher = &fakeClock{}, launcher
423+ _, err = h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, scenario{ID: "errors", Roles: []string{"daemon", "client"}}, 1, raw)
424+ if !errors.Is(err, primary) || !errors.Is(err, cleanup) {
425+ t.Fatalf("workload and cleanup errors were not both preserved: %v", err)
426+ }
427+ if got, want := err.Error(), primary.Error()+"\nclose daemon role: "+cleanup.Error(); got != want {
428+ t.Fatalf("error ordering=%q want %q", got, want)
429+ }
430+ for i, p := range launcher.process {
431+ if !p.closed {
432+ t.Fatalf("role %s was not closed", launcher.mappings[i].Role)
433+ }
434+ }
435+}
436+
437+func TestHarnessCleansRunDirectoryOnProcessFailure(t *testing.T) {
438+ dir := t.TempDir()
439+ raw, err := os.OpenFile(filepath.Join(dir, "raw-harness.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
440+ if err != nil {
441+ t.Fatal(err)
442+ }
443+ closeTestFile(t, raw)
444+ launcher := &fakeLauncher{}
445+ h := defaultHarness()
446+ h.clock = &fakeClock{}
447+ // The client is the second role and fails after the daemon has started.
448+ h.launcher = &failingLauncher{fakeLauncher: launcher}
449+ o := options{vevBin: "ignored", out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}
450+ if _, err := h.runOne(o, manifest{}, scenario{ID: "cleanup", Roles: []string{"daemon", "client"}}, 1, raw); err == nil {
451+ t.Fatal("failed process accepted")
452+ }
453+ if _, err := os.Stat(filepath.Join(dir, "cleanup-run-001")); !os.IsNotExist(err) {
454+ t.Fatalf("failed run directory remains: %v", err)
455+ }
456+}
A cmd/vev-perf-harness/test_helpers_test.go +341 -0
@@ -0,0 +1,341 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "testing"
11+ "time"
12+)
13+
14+type fakeClock struct{ tick int64 }
15+
16+func (c *fakeClock) Now() int64 { c.tick += 10; return c.tick }
17+
18+func (c *fakeClock) Sleep(d time.Duration) {
19+ c.tick += d.Nanoseconds()
20+}
21+
22+type fakeUDPNetem struct {
23+ port int
24+ closed bool
25+}
26+
27+func (n *fakeUDPNetem) Port() int { return n.port }
28+
29+func (n *fakeUDPNetem) Close() error { n.closed = true; return nil }
30+
31+type fakeProcess struct {
32+ fail bool
33+ measureErr error
34+ closeErr error
35+ warmups [][]byte
36+ measures [][]byte
37+ closed bool
38+}
39+
40+func (p *fakeProcess) Warmup(input []byte) error {
41+ p.warmups = append(p.warmups, append([]byte(nil), input...))
42+ return nil
43+}
44+
45+func (p *fakeProcess) Measure(input []byte, injected, flush func() error) error {
46+ p.measures = append(p.measures, append([]byte(nil), input...))
47+ if p.measureErr != nil {
48+ return p.measureErr
49+ }
50+ if p.fail {
51+ return errors.New("measure failure")
52+ }
53+ if err := injected(); err != nil {
54+ return err
55+ }
56+ return flush()
57+}
58+
59+func (p *fakeProcess) Close() error { p.closed = true; return p.closeErr }
60+
61+type fakeLauncher struct {
62+ mappings []processMapping
63+ args [][]string
64+ commands []roleCommand
65+ process []*fakeProcess
66+ measureErr map[string]error
67+ closeErr map[string]error
68+ manifestPresent bool
69+}
70+
71+func (l *fakeLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) {
72+ l.mappings = append(l.mappings, m)
73+ l.args = append(l.args, append([]string(nil), command.Args...))
74+ l.commands = append(l.commands, command)
75+ _, e := os.Stat(filepath.Join(filepath.Dir(m.TracePath), "manifest.json"))
76+ l.manifestPresent = e == nil
77+ p := &fakeProcess{measureErr: l.measureErr[m.Role], closeErr: l.closeErr[m.Role]}
78+ l.process = append(l.process, p)
79+ return p, nil
80+}
81+
82+type lateFlushProcess struct{ clock *fakeClock }
83+
84+func (*lateFlushProcess) Warmup([]byte) error { return nil }
85+
86+func (p *lateFlushProcess) Measure(_ []byte, injected, flushed func() error) error {
87+ if err := injected(); err != nil {
88+ return err
89+ }
90+ p.clock.tick += minimumDuration.Nanoseconds()
91+ return flushed()
92+}
93+
94+func (*lateFlushProcess) Close() error { return nil }
95+
96+type insufficientEventLauncher struct{ clock *fakeClock }
97+
98+func (l insufficientEventLauncher) Launch(m processMapping, _ roleCommand) (launchedProcess, error) {
99+ if m.Role == "client" {
100+ return &lateFlushProcess{clock: l.clock}, nil
101+ }
102+ return &fakeProcess{}, nil
103+}
104+
105+type failedFlushProcess struct{ err error }
106+
107+func (*failedFlushProcess) Warmup([]byte) error { return nil }
108+
109+func (p *failedFlushProcess) Measure(_ []byte, injected, _ func() error) error {
110+ if err := injected(); err != nil {
111+ return err
112+ }
113+ return p.err
114+}
115+
116+func (*failedFlushProcess) Close() error { return nil }
117+
118+type failedFlushLauncher struct{ err error }
119+
120+func (l failedFlushLauncher) Launch(m processMapping, _ roleCommand) (launchedProcess, error) {
121+ if m.Role == "client" {
122+ return &failedFlushProcess{err: l.err}, nil
123+ }
124+ return &fakeProcess{}, nil
125+}
126+
127+func closeTestFile(t *testing.T, f *os.File) {
128+ t.Helper()
129+ t.Cleanup(func() {
130+ if err := f.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
131+ t.Error(err)
132+ }
133+ })
134+}
135+
136+func removeTestTree(t *testing.T, path string) {
137+ t.Helper()
138+ t.Cleanup(func() {
139+ if err := os.RemoveAll(path); err != nil {
140+ t.Error(err)
141+ }
142+ })
143+}
144+
145+func readHarnessMarks(t *testing.T, path string) []harnessMark {
146+ t.Helper()
147+ records, err := readJSONL(path)
148+ if err != nil {
149+ t.Fatal(err)
150+ }
151+ marks := make([]harnessMark, len(records))
152+ for i, record := range records {
153+ encoded, err := json.Marshal(record)
154+ if err != nil {
155+ t.Fatal(err)
156+ }
157+ if err := json.Unmarshal(encoded, &marks[i]); err != nil {
158+ t.Fatal(err)
159+ }
160+ }
161+ return marks
162+}
163+
164+type fakePTY struct{ writes [][]byte }
165+
166+func (p *fakePTY) Read([]byte) (int, error) { return 0, os.ErrClosed }
167+
168+func (p *fakePTY) Write(b []byte) (int, error) {
169+ p.writes = append(p.writes, append([]byte(nil), b...))
170+ return len(b), nil
171+}
172+
173+func (*fakePTY) Close() error { return nil }
174+
175+type stagedPTY struct {
176+ allow <-chan struct{}
177+ writes chan []byte
178+}
179+
180+func (*stagedPTY) Read([]byte) (int, error) { return 0, os.ErrClosed }
181+
182+func (p *stagedPTY) Write(b []byte) (int, error) {
183+ if p.allow != nil {
184+ <-p.allow
185+ }
186+ p.writes <- append([]byte(nil), b...)
187+ return len(b), nil
188+}
189+
190+func (*stagedPTY) Close() error { return nil }
191+
192+type fakeOutput struct {
193+ bytes.Buffer
194+ syncs int
195+}
196+
197+func (o *fakeOutput) Sync() error { o.syncs++; return nil }
198+
199+func (*fakeOutput) Close() error { return nil }
200+
201+type orderedPTY struct {
202+ write func([]byte)
203+ close func()
204+}
205+
206+func (*orderedPTY) Read([]byte) (int, error) { return 0, os.ErrClosed }
207+
208+func (p *orderedPTY) Write(b []byte) (int, error) {
209+ if p.write != nil {
210+ p.write(b)
211+ }
212+ return len(b), nil
213+}
214+
215+func (p *orderedPTY) Close() error {
216+ p.close()
217+ return nil
218+}
219+
220+type orderedOutput struct{ close func() }
221+
222+func (*orderedOutput) Write(b []byte) (int, error) { return len(b), nil }
223+
224+func (*orderedOutput) Sync() error { return nil }
225+
226+func (o *orderedOutput) Close() error {
227+ o.close()
228+ return nil
229+}
230+
231+type failingLauncher struct{ *fakeLauncher }
232+
233+func (l *failingLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) {
234+ p, err := l.fakeLauncher.Launch(m, command)
235+ if err != nil {
236+ return nil, err
237+ }
238+ if m.Role == "client" {
239+ fake, ok := p.(*fakeProcess)
240+ if !ok {
241+ return nil, fmt.Errorf("fake launcher returned %T, want *fakeProcess", p)
242+ }
243+ fake.fail = true
244+ }
245+ return p, nil
246+}
247+
248+func equalStrings(a, b []string) bool {
249+ if len(a) != len(b) {
250+ return false
251+ }
252+ for i := range a {
253+ if a[i] != b[i] {
254+ return false
255+ }
256+ }
257+ return true
258+}
259+
260+type traceLauncher struct{ fakeLauncher }
261+
262+func (l *traceLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) {
263+ p, err := l.fakeLauncher.Launch(m, command)
264+ if err != nil {
265+ return nil, err
266+ }
267+ f, err := os.OpenFile(m.TracePath, os.O_WRONLY|os.O_APPEND, 0o600)
268+ if err != nil {
269+ return nil, err
270+ }
271+ for i, pair := range spanPairs {
272+ for n, kind := range []string{pair.start, pair.end} {
273+ b, err := json.Marshal(traceRecord{ProcessID: m.ProcessID, Component: m.Role, Scenario: m.Scenario, Run: uint64(m.Run), Sequence: uint64(i + 1), RequestID: 1, Epoch: 1, Kind: kind, Tick: int64(i*20 + n*5), Valid: true})
274+ if err != nil {
275+ return nil, errors.Join(err, f.Close())
276+ }
277+ if _, err := f.Write(append(b, '\n')); err != nil {
278+ return nil, errors.Join(err, f.Close())
279+ }
280+ }
281+ }
282+ if err := f.Close(); err != nil {
283+ return nil, err
284+ }
285+ return p, nil
286+}
287+
288+type closeTraceProcess struct {
289+ *fakeProcess
290+ traceEnd func() error
291+}
292+
293+func (p *closeTraceProcess) Close() error {
294+ p.closed = true
295+ return errors.Join(p.closeErr, p.traceEnd())
296+}
297+
298+type closeTraceLauncher struct{ fakeLauncher }
299+
300+func (l *closeTraceLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) {
301+ p, err := l.fakeLauncher.Launch(m, command)
302+ if err != nil {
303+ return nil, err
304+ }
305+ start := traceRecord{ProcessID: m.ProcessID, Component: m.Role, Scenario: m.Scenario, Run: uint64(m.Run), Sequence: 1, RequestID: 1, Epoch: 1, Kind: "adapter_receive_start", Tick: 10, Valid: true}
306+ if err := appendTraceRecord(m.TracePath, start); err != nil {
307+ return nil, err
308+ }
309+ fake, ok := p.(*fakeProcess)
310+ if !ok {
311+ return nil, fmt.Errorf("fake launcher returned %T, want *fakeProcess", p)
312+ }
313+ return &closeTraceProcess{fakeProcess: fake, traceEnd: func() error {
314+ return appendTraceRecord(m.TracePath, traceRecord{ProcessID: m.ProcessID, Component: m.Role, Scenario: m.Scenario, Run: uint64(m.Run), Sequence: 1, RequestID: 1, Epoch: 1, Kind: "adapter_receive_end", Tick: 20, Valid: true})
315+ }}, nil
316+}
317+
318+func appendTraceRecord(path string, record traceRecord) error {
319+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600)
320+ if err != nil {
321+ return err
322+ }
323+ b, err := json.Marshal(record)
324+ if err != nil {
325+ return errors.Join(err, f.Close())
326+ }
327+ if _, err := f.Write(append(b, '\n')); err != nil {
328+ return errors.Join(err, f.Close())
329+ }
330+ return f.Close()
331+}
332+
333+// boundedLocalConcurrentTrace is an excerpt from the second run of the
334+// bounded local public-CLI smoke. Receive sequence 3 is in flight while the
335+// same process serializes sequence 4; shared-process records therefore cannot
336+// have a global sequence ordering requirement.
337+const boundedLocalConcurrentTrace = `{"schema":1,"process_id":"1x4-idle-local-r002-client-02","component":"ipc","scenario":"1x4-idle-local","run":2,"sequence":3,"request_id":3,"epoch":3,"kind":"adapter_receive_start","tick":1783902621495284782,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}
338+{"schema":1,"process_id":"1x4-idle-local-r002-client-02","component":"ipc","scenario":"1x4-idle-local","run":2,"sequence":4,"request_id":4,"epoch":4,"kind":"adapter_send_start","tick":1783902621495364552,"bytes":96,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}
339+{"schema":1,"process_id":"1x4-idle-local-r002-client-02","component":"ipc","scenario":"1x4-idle-local","run":2,"sequence":4,"request_id":4,"epoch":4,"kind":"adapter_send_end","tick":1783902621495386452,"bytes":96,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}
340+{"schema":1,"process_id":"1x4-idle-local-r002-client-02","component":"ipc","scenario":"1x4-idle-local","run":2,"sequence":3,"request_id":3,"epoch":3,"kind":"adapter_receive_end","tick":1783902621495463332,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}
341+`
A cmd/vev-perf-harness/udp_netem.go +271 -0
@@ -0,0 +1,271 @@
1+package main
2+
3+import (
4+ "container/heap"
5+ "errors"
6+ "fmt"
7+ "net"
8+ "os"
9+ "strconv"
10+ "strings"
11+ "sync"
12+ "sync/atomic"
13+ "time"
14+)
15+
16+// udpNetem is a harness-owned, user-space UDP relay. It sits between the
17+// public dgram client and vev's public _udp-proxy readiness address, so fixture
18+// impairment is real network carriage rather than an ignored vev option.
19+type udpNetem interface {
20+ Port() int
21+ Close() error
22+}
23+
24+type udpNetemFactory func(udpNetemConfig) (udpNetem, error)
25+
26+type udpNetemConfig struct {
27+ // RTT is split equally across both relay directions.
28+ RTT time.Duration
29+ LossPercent int
30+ TargetPath string
31+}
32+
33+// udpNetemQueueCapacity bounds both packets waiting to be scheduled and
34+// packets waiting for their delivery deadline. When it is full, the relay
35+// drops the arriving packet and records it in queueOverflowDrops.
36+const udpNetemQueueCapacity = 256
37+
38+type scheduledPacket struct {
39+ packet []byte
40+ to net.Addr
41+ due time.Time
42+}
43+
44+type scheduledPackets []scheduledPacket
45+
46+func (p scheduledPackets) Len() int { return len(p) }
47+func (p scheduledPackets) Less(i, j int) bool { return p[i].due.Before(p[j].due) }
48+func (p scheduledPackets) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
49+func (p *scheduledPackets) Push(x any) {
50+ packet, ok := x.(scheduledPacket)
51+ if !ok {
52+ panic(fmt.Sprintf("scheduled packet type %T", x))
53+ }
54+ *p = append(*p, packet)
55+}
56+func (p *scheduledPackets) Pop() any {
57+ old := *p
58+ n := len(old)
59+ item := old[n-1]
60+ *p = old[:n-1]
61+ return item
62+}
63+
64+type udpNetemRelay struct {
65+ config udpNetemConfig
66+ conn net.PacketConn
67+ done chan struct{}
68+ wg sync.WaitGroup
69+ once sync.Once
70+ closeErr error
71+
72+ // slots bounds queued and deadline-pending packets together. The reader and
73+ // the sole scheduler are the relay's only goroutines.
74+ slots chan struct{}
75+ queue chan scheduledPacket
76+ queueOverflowDrops atomic.Uint64
77+ deliveryErrors atomic.Uint64
78+
79+ mu sync.Mutex
80+ client, dst net.Addr
81+ packet uint64
82+}
83+
84+func newUDPNetem(config udpNetemConfig) (udpNetem, error) {
85+ if config.RTT < 0 || config.LossPercent < 0 || config.LossPercent > 100 || config.TargetPath == "" {
86+ return nil, fmt.Errorf("invalid UDP netem fixture: rtt=%s loss=%d target=%q", config.RTT, config.LossPercent, config.TargetPath)
87+ }
88+ conn, err := net.ListenPacket("udp", "127.0.0.1:0")
89+ if err != nil {
90+ return nil, err
91+ }
92+ r := &udpNetemRelay{
93+ config: config,
94+ conn: conn,
95+ done: make(chan struct{}),
96+ slots: make(chan struct{}, udpNetemQueueCapacity),
97+ queue: make(chan scheduledPacket, udpNetemQueueCapacity),
98+ }
99+ r.wg.Add(2)
100+ go r.run()
101+ go r.schedule()
102+ return r, nil
103+}
104+
105+func (r *udpNetemRelay) Port() int {
106+ addr, ok := r.conn.LocalAddr().(*net.UDPAddr)
107+ if !ok {
108+ return 0
109+ }
110+ return addr.Port
111+}
112+
113+func (r *udpNetemRelay) Close() error {
114+ r.once.Do(func() {
115+ close(r.done)
116+ r.closeErr = r.conn.Close()
117+ r.wg.Wait()
118+ if drops := r.queueOverflowDrops.Load(); drops != 0 {
119+ r.closeErr = errors.Join(r.closeErr, fmt.Errorf("udp netem queue overflow drops: %d", drops))
120+ }
121+ if writes := r.deliveryErrors.Load(); writes != 0 {
122+ r.closeErr = errors.Join(r.closeErr, fmt.Errorf("udp netem delivery errors: %d", writes))
123+ }
124+ })
125+ return r.closeErr
126+}
127+
128+func (r *udpNetemRelay) run() {
129+ defer r.wg.Done()
130+ buf := make([]byte, 64*1024)
131+ for {
132+ n, from, err := r.conn.ReadFrom(buf)
133+ if err != nil {
134+ return
135+ }
136+ to, ok := r.destination(from)
137+ if !ok || r.drop() {
138+ continue
139+ }
140+ // Copy because PacketConn reuses buf on its next read.
141+ r.enqueue(scheduledPacket{packet: append([]byte(nil), buf[:n]...), to: to, due: time.Now().Add(r.config.RTT / 2)})
142+ }
143+}
144+
145+// enqueue implements the explicit overflow policy: never block the reader;
146+// drop a packet if the bounded scheduler capacity has been reached.
147+func (r *udpNetemRelay) enqueue(packet scheduledPacket) bool {
148+ select {
149+ case r.slots <- struct{}{}:
150+ case <-r.done:
151+ return false
152+ default:
153+ r.queueOverflowDrops.Add(1)
154+ return false
155+ }
156+ select {
157+ case r.queue <- packet:
158+ return true
159+ case <-r.done:
160+ <-r.slots
161+ return false
162+ }
163+}
164+
165+func (r *udpNetemRelay) schedule() {
166+ defer r.wg.Done()
167+ pending := scheduledPackets{}
168+ heap.Init(&pending)
169+ timer := time.NewTimer(time.Hour)
170+ if !timer.Stop() {
171+ <-timer.C
172+ }
173+ defer timer.Stop()
174+ var timerC <-chan time.Time
175+ resetTimer := func() {
176+ if timerC != nil && !timer.Stop() {
177+ select {
178+ case <-timer.C:
179+ default:
180+ }
181+ }
182+ if pending.Len() == 0 {
183+ timerC = nil
184+ return
185+ }
186+ delay := time.Until(pending[0].due)
187+ if delay < 0 {
188+ delay = 0
189+ }
190+ timer.Reset(delay)
191+ timerC = timer.C
192+ }
193+ for {
194+ resetTimer()
195+ select {
196+ case <-r.done:
197+ return
198+ case packet := <-r.queue:
199+ heap.Push(&pending, packet)
200+ case <-timerC:
201+ now := time.Now()
202+ for pending.Len() > 0 && !pending[0].due.After(now) {
203+ packet, ok := heap.Pop(&pending).(scheduledPacket)
204+ if !ok {
205+ panic("scheduled packet heap contained unexpected type")
206+ }
207+ select {
208+ case <-r.done:
209+ return
210+ default:
211+ if _, err := r.conn.WriteTo(packet.packet, packet.to); err != nil {
212+ select {
213+ case <-r.done:
214+ default:
215+ r.deliveryErrors.Add(1)
216+ }
217+ }
218+ <-r.slots
219+ }
220+ }
221+ }
222+ }
223+}
224+
225+func (r *udpNetemRelay) destination(from net.Addr) (net.Addr, bool) {
226+ r.mu.Lock()
227+ defer r.mu.Unlock()
228+ if r.dst == nil {
229+ dst, err := udpNetemTarget(r.config.TargetPath)
230+ if err != nil {
231+ return nil, false
232+ }
233+ r.dst = dst
234+ }
235+ if sameUDPAddress(from, r.dst) {
236+ return r.client, r.client != nil
237+ }
238+ r.client = from
239+ return r.dst, true
240+}
241+
242+func (r *udpNetemRelay) drop() bool {
243+ r.mu.Lock()
244+ defer r.mu.Unlock()
245+ r.packet++
246+ // The deterministic cycle makes the configured percentage auditable: a 1%
247+ // fixture drops exactly one carriage packet in every 100 attempts.
248+ return r.config.LossPercent > 0 && int((r.packet-1)%100) < r.config.LossPercent
249+}
250+
251+func udpNetemTarget(path string) (net.Addr, error) {
252+ line, err := os.ReadFile(path)
253+ if err != nil {
254+ return nil, err
255+ }
256+ fields := strings.Fields(string(line))
257+ if len(fields) != 3 || fields[0] != "VEV-UDP" {
258+ return nil, errors.New("invalid UDP proxy readiness")
259+ }
260+ port, err := strconv.Atoi(fields[1])
261+ if err != nil || port < 1 || port > 65535 || fields[2] == "" {
262+ return nil, errors.New("invalid UDP proxy readiness")
263+ }
264+ return &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: port}, nil
265+}
266+
267+func sameUDPAddress(a, b net.Addr) bool {
268+ ua, aOK := a.(*net.UDPAddr)
269+ ub, bOK := b.(*net.UDPAddr)
270+ return aOK && bOK && ua.Port == ub.Port && ua.IP.Equal(ub.IP)
271+}
A cmd/vev-perf-harness/udp_netem_test.go +147 -0
@@ -0,0 +1,147 @@
1+package main
2+
3+import (
4+ "net"
5+ "os"
6+ "path/filepath"
7+ "strconv"
8+ "testing"
9+ "time"
10+)
11+
12+func closeUDPFixture(t *testing.T, c interface{ Close() error }) {
13+ t.Helper()
14+ t.Cleanup(func() {
15+ if err := c.Close(); err != nil {
16+ t.Error(err)
17+ }
18+ })
19+}
20+
21+func TestUDPNetemDropsWhenBoundedSchedulerIsFull(t *testing.T) {
22+ r := &udpNetemRelay{
23+ done: make(chan struct{}),
24+ slots: make(chan struct{}, udpNetemQueueCapacity),
25+ queue: make(chan scheduledPacket, udpNetemQueueCapacity),
26+ }
27+ packet := scheduledPacket{packet: []byte("bounded"), to: &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1}}
28+ for range udpNetemQueueCapacity {
29+ if !r.enqueue(packet) {
30+ t.Fatal("enqueue() dropped packet before scheduler capacity was full")
31+ }
32+ }
33+ if r.enqueue(packet) {
34+ t.Fatal("enqueue() accepted packet beyond scheduler capacity")
35+ }
36+ if got := r.queueOverflowDrops.Load(); got != 1 {
37+ t.Fatalf("queue overflow drops=%d, want 1", got)
38+ }
39+}
40+
41+func TestUDPNetemCloseRejectsQueueOverflow(t *testing.T) {
42+ netem, err := newUDPNetem(udpNetemConfig{TargetPath: filepath.Join(t.TempDir(), "target")})
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ relay, ok := netem.(*udpNetemRelay)
47+ if !ok {
48+ t.Fatalf("netem type=%T, want *udpNetemRelay", netem)
49+ }
50+ relay.queueOverflowDrops.Store(3)
51+ if err := netem.Close(); err == nil || err.Error() != "udp netem queue overflow drops: 3" {
52+ t.Fatalf("Close error=%v, want overflow rejection", err)
53+ }
54+}
55+
56+func TestUDPNetemExecutesRTTAndLossFixtures(t *testing.T) {
57+ fixtures := []struct {
58+ name string
59+ rtt time.Duration
60+ loss int
61+ sent int
62+ want int
63+ }{
64+ {"25ms RTT", 25 * time.Millisecond, 0, 1, 1},
65+ {"100ms RTT", 100 * time.Millisecond, 0, 1, 1},
66+ {"zero loss", 0, 0, 10, 10},
67+ {"one percent loss", 0, 1, 100, 99},
68+ }
69+ for _, tc := range fixtures {
70+ t.Run(tc.name, func(t *testing.T) {
71+ target, err := net.ListenPacket("udp", "127.0.0.1:0")
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ closeUDPFixture(t, target)
76+ targetAddr, ok := target.LocalAddr().(*net.UDPAddr)
77+ if !ok {
78+ t.Fatalf("target address type=%T, want *net.UDPAddr", target.LocalAddr())
79+ }
80+ path := filepath.Join(t.TempDir(), "target")
81+ if err := os.WriteFile(path, []byte("VEV-UDP "+strconv.Itoa(targetAddr.Port)+" key\\n"), 0o600); err != nil {
82+ t.Fatal(err)
83+ }
84+ netem, err := newUDPNetem(udpNetemConfig{RTT: tc.rtt, LossPercent: tc.loss, TargetPath: path})
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ closeUDPFixture(t, netem)
89+ client, err := net.ListenPacket("udp", "127.0.0.1:0")
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ closeUDPFixture(t, client)
94+ started := time.Now()
95+ for i := 0; i < tc.sent; i++ {
96+ if _, err := client.WriteTo([]byte{byte(i)}, &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: netem.Port()}); err != nil {
97+ t.Fatal(err)
98+ }
99+ }
100+ if err := target.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
101+ t.Fatal(err)
102+ }
103+ got := 0
104+ buf := make([]byte, 32)
105+ var relayAddr net.Addr
106+ for got < tc.want {
107+ if _, relayAddr, err = target.ReadFrom(buf); err != nil {
108+ t.Fatalf("received %d packets, want %d: %v", got, tc.want, err)
109+ }
110+ got++
111+ }
112+ if tc.rtt > 0 {
113+ if elapsed := time.Since(started); elapsed < tc.rtt/2 {
114+ t.Fatalf("client-to-target netem delay=%s, want at least %s", elapsed, tc.rtt/2)
115+ }
116+ }
117+ if tc.loss == 0 {
118+ // The relay learns the client from the first carriage packet. Replying
119+ // through that same relay address verifies the target-to-client half
120+ // of the configured RTT as well.
121+ started = time.Now()
122+ if _, err := target.WriteTo([]byte("reply"), relayAddr); err != nil {
123+ t.Fatal(err)
124+ }
125+ if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
126+ t.Fatal(err)
127+ }
128+ if _, _, err := client.ReadFrom(buf); err != nil {
129+ t.Fatalf("relay reply: %v", err)
130+ }
131+ if tc.rtt > 0 {
132+ if elapsed := time.Since(started); elapsed < tc.rtt/2 {
133+ t.Fatalf("target-to-client netem delay=%s, want at least %s", elapsed, tc.rtt/2)
134+ }
135+ }
136+ }
137+ // Keep the deadline short: extra carriage would prove the exact 1%%
138+ // loss cycle was not enforced.
139+ if err := target.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); err != nil {
140+ t.Fatal(err)
141+ }
142+ if _, _, err := target.ReadFrom(buf); tc.want != tc.sent && err == nil {
143+ t.Fatalf("received more than %d packets with %d%% loss", tc.want, tc.loss)
144+ }
145+ })
146+ }
147+}
M docs/performance.md +63 -91
@@ -1,107 +1,79 @@
1-# Performance methodology (M5)
2-
3-This baseline measures in-process daemon work only. It includes coordinator-enabled
4-fixture scheduling metrics, but deliberately does **not** measure chunk
5-encoding, compression, GC tuning, or real network latency. Output metrics count frames and encoded output data produced
6-by the daemon; snapshot writes and serialized bytes are local snapshot-store
7-proxies. These metrics approximate local rendering, IPC payload, and persistence
8-work, not a Unix socket, SSH, or UDP transport. Real transport impairment and
9-latency benchmarks are deferred.
10-
11-## Large-history daemon baseline
12-
13-Use a single iteration only as a smoke check; it is not suitable for drawing
14-performance conclusions:
15-
16-```sh
17-go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
18-```
19-
20-For before/after comparison, collect repeated samples for the **full matrix**
21-(all workloads and topologies), pinning CPU/governor and recording the host.
22-The benchmark harness must be committed and present in **both** measured
23-revisions: collect the baseline after the harness is introduced but before the
24-optimization, then collect the optimized revision with that same harness. The
25-fixed duration and count make the output suitable for `benchstat`:
26-
27-```sh
28-go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1s -count=5 -benchmem > /tmp/vev-history-after.txt
29-benchstat /tmp/vev-history-before.txt /tmp/vev-history-after.txt
1+# Performance measurement
2+
3+## S5a transport observability
4+
5+S5a is measurement-only. It records process-local runtime marks and harness-owned
6+end-to-end boundaries without changing rendering, pacing, transport policy, wire
7+bytes, or `ProtocolVersion`. The canonical matrix and workload/topology/
8+transport coverage are in [`testdata/perf/manifest.json`](../testdata/perf/manifest.json):
9+`1x4`, `4x1`, `4x4`, and `8x1`, all at `120x40` with 10,000 rows per pane;
10+idle, active/all/inactive output, interactive flood, copy search, resize sweep,
11+snapshot/output/resize, and attach/restore/tab switch; local, SSH stdio, UDP
12+baseline, UDP 25/100 ms, and UDP 0%/1% loss. It has 252 scenarios (4×9×7),
13+with inapplicable cases required to be explicit. The seven transport entries are
14+`local`, `ssh_stdio`, `udp_baseline`, `udp_loss_0pct`, `udp_25ms`, `udp_100ms`,
15+and `udp_loss_1pct`.
16+
17+### Trace schema and clock domains
18+
19+Every process JSONL record has exactly these fields:
20+`schema` (uint), `process_id` (string), `component` (string), `scenario`
21+(string), `run`, `sequence`, `request_id`, `epoch` (uint), `kind` (string),
22+`tick` (int64), `bytes`, `fragments`, `retransmits`, `pending` (uint),
23+`ack_rtt_nanos` (int64), and `valid` (bool). Schema 1 is represented by:
24+
25+```json
26+{"schema":1,"process_id":"p","component":"daemon","scenario":"s","run":1,"sequence":1,"request_id":1,"epoch":1,"kind":"diff_start","tick":0,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}
3027 ```
3128
32-The matrix exercises live paint, snapshot capture, copy-mode entry, copy search,
33-and resize at `120x40` client geometry. Each pane is populated with 10,000
34-full-width deterministic history rows containing a stable `needle-<tab>-<row>`
35-prefix and patterned text. The layouts are:
29+`tick` is stamped only by the concrete JSONL observer using its process-local
30+injected monotonic clock; producers supply no timestamp. The harness sets `VEV_PERF_TRACE`,
31+`VEV_PERF_PROCESS_ID`, `VEV_PERF_SCENARIO`, and `VEV_PERF_RUN` for every
32+launched role, identifying its exclusive trace path and process/scenario/run
33+mapping. Components in one OS
34+process share that observer. The harness clock is a separate monotonic domain
35+and is the sole clock for input-injection → terminal-bytes-successfully-flushed
36+end-to-end samples. Cross-process ticks are never ordered or subtracted;
37+records correlate only by scenario/run/sequence/request/epoch IDs.
3638
37-- 1 tab x 1 pane (control)
38-- 1 tab x 4 panes
39-- 4 tabs x 1 pane
40-- 4 tabs x 4 panes
41-- 8 tabs x 1 pane
39+The named process-local spans are capture, compose, diff, queue wait,
40+ACK-blocked interval, emit, adapter send, and adapter receive. Their start/end
41+marks are paired within one process and summarized by component/adapter with
42+raw records, counts, p50/p95/p99, and max. Diagnostic fields (bytes, fragments,
43+retransmits, pending, ACK RTT) remain separate from duration calculations.
4244
43-The fixture starts no PTY readers or transports. It primes the renderer shadow
44-before timing; live paint then alternates fixed writes through the coordinator.
45-The reported `outputframes/op` value counts output messages. `outputbytes/op`
46-counts their encoded terminal data and excludes the 24-byte output header;
47-`framepayloadbytes/op` counts the complete output payload including that header.
48-`snapshotwrites/op` and `snapshotbytes/op` are local snapshot-store writes and
49-serialized bytes. `coordinatorinvalidations/op`, `coordinatorwakes/op`, and
50-`coordinatorcoalesced/op` respectively count scheduling requests, delivered
51-wakes, and requests coalesced into those wakes; `coordinatorcoalescingratio`
52-is the average number of coalesced invalidations delivered per wake. They must
53-not be presented as network
54-throughput or latency.
45+### Baseline procedure and validity
5546
56-Capture a pre-change result outside the repository, including Go and kernel
57-context:
47+Use the public CLI harness (not internal packages):
5848
5949 ```sh
60-{
61- date -Is
62- go version
63- uname -a
64- git rev-parse HEAD
65- # Smoke only; do not use this result for comparisons.
66- go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
67- # Repeated full matrix for every workload/topology used in conclusions.
68- go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1s -count=5 -benchmem
69-} > /tmp/vev-history-before.txt 2>&1
50+go build -o /tmp/vev-perf ./
51+rm -rf testdata/perf/results
52+go run ./cmd/vev-perf-harness --vev-bin /tmp/vev-perf \
53+ --manifest testdata/perf/manifest.json --out testdata/perf/results \
54+ --warmup 10s --duration 30s --repetitions 10
7055 ```
7156
72-## Immutable document allocation gate
57+The minimums are 30 seconds measured and 10 independent repetitions; warmup is
58+excluded. During the measured interval the harness injects at most one event per
59+second. At least 10 complete event pairs are required: both injection and
60+successful terminal flush must fall inside the measured interval; straddling or
61+failed events are retained only as raw diagnostics and are not samples. Each run must contain `manifest.json`, `raw-harness.jsonl`, per-process
62+JSONL, `runs.json`, and `summary.json`. Reject results with missing input/flush
63+boundaries, unmatched IDs, bad correlation, nonmonotonic same-process sequence,
64+negative same-domain spans, fewer than 10 complete in-interval event pairs,
65+invalid denominators,
66+any cross-process tick arithmetic, or unmet duration/repetition minima. Raw
67+JSONL and run manifests are the evidence files; no result authorizes a policy
68+or optimization change.
7369
74-The copy-mode document retains an immutable view of existing scrollback rows and
75-clones only the visible frame. Visual-search models share that document; cloning
76-a model copies only its mutable input and match state. Check those two paths at
77-10,000 history rows with:
70+## Existing in-process smoke checks
7871
79-```sh
80-go test ./internal/usecase/copy ./internal/usecase/visualsearch -run '^$' -bench '^(BenchmarkNewSnapshot10KRows|BenchmarkVisualSearchClone10KRows)$' -benchtime=1s -count=5 -benchmem
81-```
82-
83-Treat `allocs/op` and `B/op` as allocation gates: compare the complete repeated
84-output with the accepted baseline before merging, and investigate any increase.
85-The automated scaling gates also compare equal row/match counts at narrow and
86-wide row widths; `B/op` may grow by at most 2x, a conservative relative
87-allowance that catches full cell-row copies without depending on an absolute
88-Go-version-specific budget. This scope does not yet include an
89-incremental/full-text search index: queries still scan the immutable document
90-and produce a fresh match list. Chunk encoding, compression, GC tuning, and real Unix-socket, SSH, or UDP
91-impairment/latency remain out of scope.
92-
93-## Lower-level smoke baseline
94-
95-Use this command to check renderer, VT, IPC, and daemon microbenchmarks without
96-claiming an end-to-end transport result:
72+These commands measure local daemon work only and are not end-to-end transport
73+results:
9774
9875 ```sh
76+go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
9977 go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem
10078 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeFloatingFrameCached$' -benchmem
10179 ```
102-
103-Enable daemon pprof explicitly when collecting a daemon profile:
104-
105-```sh
106-VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon
107-```
M internal/adapters/dgram/ssh_dialer.go +2 -1
@@ -102,6 +102,7 @@ type RemoteDialer struct {
102102 BootstrapTimeout time.Duration
103103 ProbeTimeout time.Duration
104104 Log *slog.Logger
105+ RuntimeObserver ports.RuntimeObserver
105106 }
106107
107108 func NewRemoteDialer(target, session string) RemoteDialer {
@@ -180,7 +181,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
180181 RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
181182 return listenUDPPacket(ctx)
182183 },
183- })
184+ }, WithRuntimeObserver(d.RuntimeObserver))
184185 if err != nil {
185186 _ = pc.Close()
186187 return nil, udpUnavailable("create UDP transport", err, &stderr)
M internal/adapters/dgram/transport.go +96 -7
@@ -45,6 +45,14 @@ var (
4545 errControlFull = errors.New("dgram: control queue full")
4646 )
4747
48+type RuntimeOption func(*Transport)
49+
50+// WithRuntimeObserver enables process-local marks without changing dgram's
51+// behavior clock or carriage policy.
52+func WithRuntimeObserver(observer ports.RuntimeObserver) RuntimeOption {
53+ return func(t *Transport) { t.runtimeObserver = observer }
54+}
55+
4856 type Options struct {
4957 MTU int
5058 ResendAfter time.Duration
@@ -168,6 +176,11 @@ type Transport struct {
168176 // mutation and before ordered event publication.
169177 afterLinkStateCommit func(ports.LinkState)
170178 observe DiagnosticObserver
179+ runtimeObserver ports.RuntimeObserver
180+ operationMu sync.Mutex
181+ operationCount int
182+ closing bool
183+ operationsDone chan struct{}
171184 diagnosticCh chan Diagnostic
172185 heartbeat time.Duration
173186 resendAfter time.Duration
@@ -311,11 +324,11 @@ func (s *writeDeadlineState) expire(now time.Time) {
311324 s.mu.Unlock()
312325 }
313326
314-func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) {
315- return NewTransportWithOptions(pc, peer, key, sendDir, recvDir, Options{})
327+func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, runtimeOpts ...RuntimeOption) (*Transport, error) {
328+ return NewTransportWithOptions(pc, peer, key, sendDir, recvDir, Options{}, runtimeOpts...)
316329 }
317330
318-func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, opts Options) (*Transport, error) {
331+func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, opts Options, runtimeOpts ...RuntimeOption) (*Transport, error) {
319332 c, err := pdgram.NewCodec(key)
320333 if err != nil {
321334 return nil, err
@@ -360,6 +373,11 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD
360373 nextRecvSeq: 1,
361374 recvBuf: make(map[uint64]ports.Frame),
362375 }
376+ for _, opt := range runtimeOpts {
377+ if opt != nil {
378+ opt(t)
379+ }
380+ }
363381 t.sendWake = make(chan struct{})
364382 t.control = make(chan controlRecord, controlQueueSize)
365383 t.ackWake = make(chan struct{}, 1)
@@ -387,9 +405,19 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD
387405 return t, nil
388406 }
389407
390-func (t *Transport) Send(f ports.Frame) error { return t.send(f, false) }
408+func (t *Transport) Send(f ports.Frame) error {
409+ end := t.beginRuntimeOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
410+ err := t.send(f, false)
411+ end(err == nil)
412+ return err
413+}
391414
392-func (t *Transport) SendAsync(f ports.Frame) error { return t.send(f, true) }
415+func (t *Transport) SendAsync(f ports.Frame) error {
416+ end := t.beginRuntimeOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
417+ err := t.send(f, true)
418+ end(err == nil)
419+ return err
420+}
393421
394422 // SendSynchronous owns queue reservation, fragment pacing, per-write deadlines,
395423 // and close cancellation. Callers must not layer a shorter preflight timer over it.
@@ -568,25 +596,35 @@ func (t *Transport) removePendingLocked(seq uint64) bool {
568596 }
569597
570598 func (t *Transport) Recv() (ports.Frame, error) {
599+ end := t.beginRuntimeOperation(ports.RuntimeAdapterReceiveStart, 0)
571600 select {
572601 case f := <-t.in:
602+ end(true)
573603 return f, nil
574604 case <-t.done:
575605 t.mu.Lock()
576606 err := t.closeErr
577607 t.mu.Unlock()
578608 if err != nil {
609+ end(false)
579610 return ports.Frame{}, err
580611 }
581- return ports.Frame{}, errors.New("dgram: closed")
612+ err = errors.New("dgram: closed")
613+ end(false)
614+ return ports.Frame{}, err
582615 }
583616 }
584617 func (t *Transport) Close() error {
618+ done := t.beginShutdown()
585619 t.closeWithError(errors.New("dgram: closed"))
586620 t.mu.Lock()
587621 pc := t.pc
588622 t.mu.Unlock()
589- return pc.Close()
623+ err := pc.Close()
624+ if done != nil {
625+ <-done
626+ }
627+ return err
590628 }
591629
592630 func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }
@@ -1585,4 +1623,55 @@ func decodeData(b []byte) (uint64, bool, ports.Frame, bool) {
15851623 }
15861624
15871625 // DatagramTransport marks Transport as a UDP-style datagram transport.
1626+func (t *Transport) beginRuntimeOperation(start ports.RuntimeMarkKind, bytes uint64) func(bool) {
1627+ if t.runtimeObserver == nil {
1628+ return func(bool) {}
1629+ }
1630+ if !t.beginObservedOperation() {
1631+ return func(bool) {}
1632+ }
1633+ correlation := ports.NewRuntimeCorrelation()
1634+ t.runtimeObserver.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("dgram", correlation, start, bytes, true))
1635+ end := ports.RuntimeAdapterSendEnd
1636+ if start == ports.RuntimeAdapterReceiveStart {
1637+ end = ports.RuntimeAdapterReceiveEnd
1638+ }
1639+ return func(valid bool) {
1640+ defer t.finishObservedOperation()
1641+ t.runtimeObserver.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("dgram", correlation, end, bytes, valid))
1642+ }
1643+}
1644+
1645+func (t *Transport) beginObservedOperation() bool {
1646+ t.operationMu.Lock()
1647+ defer t.operationMu.Unlock()
1648+ if t.closing {
1649+ return false
1650+ }
1651+ if t.operationCount == 0 {
1652+ t.operationsDone = make(chan struct{})
1653+ }
1654+ t.operationCount++
1655+ return true
1656+}
1657+
1658+func (t *Transport) finishObservedOperation() {
1659+ t.operationMu.Lock()
1660+ defer t.operationMu.Unlock()
1661+ t.operationCount--
1662+ if t.operationCount == 0 {
1663+ close(t.operationsDone)
1664+ }
1665+}
1666+
1667+func (t *Transport) beginShutdown() <-chan struct{} {
1668+ t.operationMu.Lock()
1669+ defer t.operationMu.Unlock()
1670+ t.closing = true
1671+ if t.operationCount == 0 {
1672+ return nil
1673+ }
1674+ return t.operationsDone
1675+}
1676+
15881677 func (*Transport) DatagramTransport() {}
A internal/adapters/dgram/transport_observability_test.go +202 -0
@@ -0,0 +1,202 @@
1+package dgram
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "net"
7+ "os"
8+ "reflect"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/vev/internal/adapters/observability"
15+ "github.com/bnema/vev/internal/ports"
16+)
17+
18+func TestTransportObservabilityDgramFailedCloseEndsReceiveAndSend(t *testing.T) {
19+ behaviorClock := newManualClock(time.Unix(0, 417))
20+ link := newSimulatedLink(behaviorClock, packetPolicy{})
21+ aPC, _ := newPairWithCapacity(link, 64)
22+ closeErr := errors.New("packet connection close failed")
23+ tracePath := t.TempDir() + "/dgram-close.jsonl"
24+ observer, closer, err := observability.NewJSONL(tracePath, dgramObserverClock{now: time.Unix(0, 521)}, "dgram-process")
25+ if err != nil {
26+ t.Fatalf("NewJSONL() error = %v", err)
27+ }
28+ receiveStarted := make(chan struct{})
29+ signalingObserver := &dgramStartObserver{RuntimeObserver: observer, receiveStarted: receiveStarted}
30+ transport, err := NewTransportWithOptions(&dgramCloseFailPC{fakePC: aPC, err: closeErr}, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(signalingObserver))
31+ if err != nil {
32+ t.Fatalf("NewTransportWithOptions() error = %v", err)
33+ }
34+ if err := transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("write failure")}); err == nil {
35+ t.Fatal("Send() error = nil on packet write failure")
36+ }
37+ recvDone := make(chan error, 1)
38+ go func() { _, err := transport.Recv(); recvDone <- err }()
39+ <-receiveStarted
40+ if err := transport.Close(); !errors.Is(err, closeErr) {
41+ t.Fatalf("Close() error = %v, want %v", err, closeErr)
42+ }
43+ if err := <-recvDone; err == nil {
44+ t.Fatal("Recv() error = nil after failed Close()")
45+ }
46+ if err := closer.Close(); err != nil {
47+ t.Fatalf("trace Close() error = %v", err)
48+ }
49+ assertDgramFailedSpanPairs(t, tracePath)
50+}
51+
52+type dgramStartObserver struct {
53+ ports.RuntimeObserver
54+ receiveStarted chan struct{}
55+ once sync.Once
56+}
57+
58+func (o *dgramStartObserver) ObserveRuntime(mark ports.RuntimeMark) {
59+ if mark.Kind == ports.RuntimeAdapterReceiveStart {
60+ o.once.Do(func() { close(o.receiveStarted) })
61+ }
62+ o.RuntimeObserver.ObserveRuntime(mark)
63+}
64+
65+type dgramCloseFailPC struct {
66+ *fakePC
67+ err error
68+}
69+
70+func (p *dgramCloseFailPC) WriteTo([]byte, net.Addr) (int, error) {
71+ return 0, errors.New("packet write failed")
72+}
73+
74+func (p *dgramCloseFailPC) Close() error {
75+ _ = p.fakePC.Close()
76+ return p.err
77+}
78+
79+func assertDgramFailedSpanPairs(t *testing.T, path string) {
80+ t.Helper()
81+ data, err := os.ReadFile(path)
82+ if err != nil {
83+ t.Fatalf("ReadFile(trace): %v", err)
84+ }
85+ type mark struct {
86+ ProcessID string `json:"process_id"`
87+ Scenario string `json:"scenario"`
88+ Run uint64 `json:"run"`
89+ Sequence uint64 `json:"sequence"`
90+ RequestID uint64 `json:"request_id"`
91+ Epoch uint64 `json:"epoch"`
92+ Kind string `json:"kind"`
93+ Valid bool `json:"valid"`
94+ }
95+ type spanKey struct {
96+ processID, scenario string
97+ run, sequence, requestID, epoch uint64
98+ kind string
99+ }
100+ starts := make(map[spanKey]mark)
101+ pairs := map[string]int{}
102+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
103+ var m mark
104+ if err := json.Unmarshal([]byte(line), &m); err != nil {
105+ t.Fatalf("Unmarshal(trace): %v", err)
106+ }
107+ key := spanKey{m.ProcessID, m.Scenario, m.Run, m.Sequence, m.RequestID, m.Epoch, m.Kind}
108+ switch m.Kind {
109+ case "adapter_receive_start", "adapter_send_start":
110+ if _, exists := starts[key]; exists {
111+ t.Fatalf("duplicate adapter start: %#v", m)
112+ }
113+ starts[key] = m
114+ case "adapter_receive_end", "adapter_send_end":
115+ key.kind = strings.TrimSuffix(m.Kind, "_end") + "_start"
116+ start, ok := starts[key]
117+ if !ok || m.Valid {
118+ t.Fatalf("unmatched or valid failed end: start=%#v end=%#v", start, m)
119+ }
120+ pairs[strings.TrimSuffix(m.Kind, "_end")]++
121+ delete(starts, key)
122+ }
123+ }
124+ if len(starts) != 0 {
125+ t.Fatalf("unmatched adapter starts: %#v", starts)
126+ }
127+ if pairs["adapter_send"] != 1 || pairs["adapter_receive"] != 1 || len(pairs) != 2 {
128+ t.Fatalf("failed adapter span pairs=%v, want exactly one send and one receive", pairs)
129+ }
130+}
131+
132+func TestTransportObservabilityDgramKeepsBehaviorClockSeparate(t *testing.T) {
133+ behaviorClock := newManualClock(time.Unix(0, 401))
134+ link := newSimulatedLink(behaviorClock, packetPolicy{})
135+ aPC, bPC := newPairWithCapacity(link, 64)
136+ tracePath := t.TempDir() + "/dgram.jsonl"
137+ observer, closer, err := observability.NewJSONL(tracePath, dgramObserverClock{now: time.Unix(0, 509)}, "dgram-process")
138+ if err != nil {
139+ t.Fatalf("NewJSONL() error = %v", err)
140+ }
141+
142+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(observer))
143+ if err != nil {
144+ t.Fatalf("NewTransportWithOptions(a) error = %v", err)
145+ }
146+ defer func() { _ = a.Close() }()
147+ b, err := NewTransportWithOptions(bPC, testAddr("a"), key(), 2, 1, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(observer))
148+ if err != nil {
149+ t.Fatalf("NewTransportWithOptions(b) error = %v", err)
150+ }
151+ defer func() { _ = b.Close() }()
152+
153+ want := ports.Frame{Type: ports.MsgOutput, Payload: []byte("datagram carriage remains exact")}
154+ if err := a.Send(want); err != nil {
155+ t.Fatalf("Send() error = %v", err)
156+ }
157+ gotCh := make(chan ports.Frame, 1)
158+ errCh := make(chan error, 1)
159+ go func() {
160+ frame, recvErr := b.Recv()
161+ if recvErr != nil {
162+ errCh <- recvErr
163+ return
164+ }
165+ gotCh <- frame
166+ }()
167+ select {
168+ case err := <-errCh:
169+ t.Fatalf("Recv() error = %v", err)
170+ case got := <-gotCh:
171+ if !reflect.DeepEqual(got, want) {
172+ t.Fatalf("carried frame = %#v, want %#v", got, want)
173+ }
174+ case <-time.After(time.Second):
175+ t.Fatal("Recv() did not receive deterministic simulated datagram")
176+ }
177+ if err := closer.Close(); err != nil {
178+ t.Fatalf("trace Close() error = %v", err)
179+ }
180+ trace, err := os.ReadFile(tracePath)
181+ if err != nil {
182+ t.Fatalf("ReadFile(trace): %v", err)
183+ }
184+ for _, kind := range []string{"adapter_send_start", "adapter_send_end", "adapter_receive_start", "adapter_receive_end"} {
185+ if !strings.Contains(string(trace), `"kind":"`+kind+`"`) {
186+ t.Errorf("trace missing %s", kind)
187+ }
188+ }
189+}
190+
191+type dgramObserverClock struct{ now time.Time }
192+
193+func (c dgramObserverClock) Now() time.Time { return c.now }
194+func (c dgramObserverClock) NewTimer(d time.Duration) ports.Timer {
195+ return dgramObserverTimer{timer: time.NewTimer(d)}
196+}
197+
198+type dgramObserverTimer struct{ timer *time.Timer }
199+
200+func (t dgramObserverTimer) C() <-chan time.Time { return t.timer.C }
201+func (t dgramObserverTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) }
202+func (t dgramObserverTimer) Stop() bool { return t.timer.Stop() }
M internal/adapters/ipc/dial.go +7 -5
@@ -1,6 +1,7 @@
11 package ipc
22
33 import (
4+ "context"
45 "fmt"
56 "net"
67 "path/filepath"
@@ -8,15 +9,16 @@ import (
89 "github.com/bnema/vev/internal/ports"
910 )
1011
11-// Dial connects to the daemon's socket in dir (dir/daemon.sock) and returns
12-// a Transport speaking vev's framed protocol. A dial failure (no daemon
12+// DialContext connects to the daemon's socket in dir (dir/daemon.sock) and
13+// returns a Transport speaking vev's framed protocol. A dial failure (no daemon
1314 // listening, or a stale/absent socket) is reported as a wrapped error so
1415 // callers can decide whether to spawn a daemon and retry.
15-func Dial(dir string) (ports.Transport, error) {
16+func DialContext(ctx context.Context, dir string, opts ...Option) (ports.Transport, error) {
1617 sockPath := filepath.Join(dir, socketFileName)
17- conn, err := net.Dial("unix", sockPath)
18+ var dialer net.Dialer
19+ conn, err := dialer.DialContext(ctx, "unix", sockPath)
1820 if err != nil {
1921 return nil, fmt.Errorf("ipc: dial %s: %w", sockPath, err)
2022 }
21- return NewTransport(conn), nil
23+ return NewTransport(conn, opts...), nil
2224 }
A internal/adapters/ipc/dial_test.go +79 -0
@@ -0,0 +1,79 @@
1+package ipc
2+
3+import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+type dialTestObserver struct{}
14+
15+func (dialTestObserver) ObserveRuntime(ports.RuntimeMark) {}
16+
17+func TestDialContextCanceledOrExpired(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ ctx func() (context.Context, context.CancelFunc)
21+ want error
22+ }{
23+ {
24+ name: "canceled",
25+ ctx: func() (context.Context, context.CancelFunc) {
26+ ctx, cancel := context.WithCancel(context.Background())
27+ cancel()
28+ return ctx, func() {}
29+ },
30+ want: context.Canceled,
31+ },
32+ {
33+ name: "deadline exceeded",
34+ ctx: func() (context.Context, context.CancelFunc) {
35+ return context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
36+ },
37+ want: context.DeadlineExceeded,
38+ },
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ ctx, cancel := tt.ctx()
44+ defer cancel()
45+
46+ transport, err := DialContext(ctx, t.TempDir())
47+ if transport != nil {
48+ t.Fatal("DialContext returned a transport for a completed context")
49+ }
50+ if !errors.Is(err, tt.want) {
51+ t.Fatalf("DialContext error = %v, want wrapping %v", err, tt.want)
52+ }
53+ })
54+ }
55+}
56+
57+func TestDialContextPreservesOptions(t *testing.T) {
58+ dir := filepath.Join(t.TempDir(), "vev")
59+ listener, err := Listen(dir)
60+ if err != nil {
61+ t.Fatalf("Listen: %v", err)
62+ }
63+ defer func() { _ = listener.Close() }()
64+
65+ observer := dialTestObserver{}
66+ transport, err := DialContext(context.Background(), dir, WithRuntimeObserver(observer))
67+ if err != nil {
68+ t.Fatalf("DialContext: %v", err)
69+ }
70+ defer func() { _ = transport.Close() }()
71+
72+ got, ok := transport.(*unixTransport)
73+ if !ok {
74+ t.Fatalf("DialContext transport = %T, want *unixTransport", transport)
75+ }
76+ if got.observer != observer {
77+ t.Fatal("DialContext did not apply runtime observer option")
78+ }
79+}
M internal/adapters/ipc/listener.go +4 -3
@@ -25,6 +25,7 @@ var ErrDaemonRunning = errors.New("ipc: a daemon is already listening on this so
2525 type unixListener struct {
2626 ln *net.UnixListener
2727 addr string
28+ opts []Option
2829 }
2930
3031 // Listen creates the socket directory (0700) if needed and starts
@@ -35,7 +36,7 @@ type unixListener struct {
3536 // the previous owner died without cleaning up, so the stale socket file is
3637 // unlinked and bind is retried once. A successful dial means a live daemon
3738 // owns the socket, and Listen returns ErrDaemonRunning.
38-func Listen(dir string) (ports.Listener, error) {
39+func Listen(dir string, opts ...Option) (ports.Listener, error) {
3940 if err := safedir.EnsurePrivate(dir); err != nil {
4041 return nil, fmt.Errorf("ipc: securing socket directory: %w", err)
4142 }
@@ -69,7 +70,7 @@ func Listen(dir string) (ports.Listener, error) {
6970 return nil, fmt.Errorf("ipc: chmod %s: %w", sockPath, err)
7071 }
7172
72- return &unixListener{ln: ln, addr: sockPath}, nil
73+ return &unixListener{ln: ln, addr: sockPath, opts: opts}, nil
7374 }
7475
7576 // bindUnix binds and listens on sockPath.
@@ -104,7 +105,7 @@ func (l *unixListener) Accept() (ports.Transport, error) {
104105 if err != nil {
105106 return nil, err
106107 }
107- return NewTransport(conn), nil
108+ return NewTransport(conn, l.opts...), nil
108109 }
109110
110111 // Close stops accepting connections. The underlying net.UnixListener
M internal/adapters/ipc/transport.go +91 -5
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "io"
77 "net"
8+ "sync"
89
910 "github.com/bnema/vev/internal/ports"
1011 )
@@ -35,8 +36,21 @@ var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length")
3536 // goroutine and a single writer goroutine); it is not safe for concurrent
3637 // calls to Send from multiple goroutines, nor Recv from multiple
3738 // goroutines.
39+type Option func(*unixTransport)
40+
41+// WithRuntimeObserver enables process-local transport marks. It accepts no
42+// clock: timestamp ownership belongs to the configured observer.
43+func WithRuntimeObserver(observer ports.RuntimeObserver) Option {
44+ return func(t *unixTransport) { t.observer = observer }
45+}
46+
3847 type unixTransport struct {
39- conn net.Conn
48+ conn net.Conn
49+ observer ports.RuntimeObserver
50+ operationMu sync.Mutex
51+ operationCount int
52+ closing bool
53+ operationsDone chan struct{}
4054
4155 // readBuf is reused across Recv calls (grow-once strategy): it grows
4256 // to fit the largest frame seen so far and is never shrunk. The Frame
@@ -47,16 +61,24 @@ type unixTransport struct {
4761
4862 // NewTransport wraps conn as a ports.Transport speaking vev's framed
4963 // binary protocol.
50-func NewTransport(conn net.Conn) ports.Transport {
51- return &unixTransport{conn: conn}
64+func NewTransport(conn net.Conn, opts ...Option) ports.Transport {
65+ t := &unixTransport{conn: conn}
66+ for _, opt := range opts {
67+ if opt != nil {
68+ opt(t)
69+ }
70+ }
71+ return t
5272 }
5373
5474 // Send writes f as a single frame: a 4-byte big-endian length (covering
5575 // the type byte and payload), the type byte, then the payload — assembled
5676 // into one buffer and written with a single Write call.
5777 func (t *unixTransport) Send(f ports.Frame) error {
78+ end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
5879 n := 1 + len(f.Payload) // type + payload
5980 if n > maxFrameLen {
81+ end(false)
6082 return ErrFrameTooLarge
6183 }
6284
@@ -66,6 +88,7 @@ func (t *unixTransport) Send(f ports.Frame) error {
6688 copy(buf[frameHeaderLen+1:], f.Payload)
6789
6890 _, err := t.conn.Write(buf)
91+ end(err == nil)
6992 return err
7093 }
7194
@@ -73,16 +96,20 @@ func (t *unixTransport) Send(f ports.Frame) error {
7396 // (type byte + payload). It blocks until a full frame arrives, the
7497 // connection is closed (io.EOF), or an error occurs.
7598 func (t *unixTransport) Recv() (ports.Frame, error) {
99+ end := t.beginOperation(ports.RuntimeAdapterReceiveStart, 0)
76100 var hdr [frameHeaderLen]byte
77101 if _, err := io.ReadFull(t.conn, hdr[:]); err != nil {
102+ end(false)
78103 return ports.Frame{}, err
79104 }
80105
81106 n := binary.BigEndian.Uint32(hdr[:])
82107 if n == 0 {
108+ end(false)
83109 return ports.Frame{}, ErrZeroLengthFrame
84110 }
85111 if n > maxFrameLen {
112+ end(false)
86113 return ports.Frame{}, ErrFrameTooLarge
87114 }
88115
@@ -92,6 +119,7 @@ func (t *unixTransport) Recv() (ports.Frame, error) {
92119 t.readBuf = t.readBuf[:n]
93120 }
94121 if _, err := io.ReadFull(t.conn, t.readBuf); err != nil {
122+ end(false)
95123 return ports.Frame{}, err
96124 }
97125
@@ -100,10 +128,68 @@ func (t *unixTransport) Recv() (ports.Frame, error) {
100128 payload = make([]byte, n-1)
101129 copy(payload, t.readBuf[1:])
102130 }
131+ end(true)
103132 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
104133 }
105134
106-// Close closes the underlying connection.
135+func (t *unixTransport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) func(bool) {
136+ if t.observer == nil {
137+ return func(bool) {}
138+ }
139+ if !t.beginObservedOperation() {
140+ return func(bool) {}
141+ }
142+ correlation := ports.NewRuntimeCorrelation()
143+ t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("ipc", correlation, start, bytes, true))
144+ end := ports.RuntimeAdapterSendEnd
145+ if start == ports.RuntimeAdapterReceiveStart {
146+ end = ports.RuntimeAdapterReceiveEnd
147+ }
148+ return func(valid bool) {
149+ defer t.finishObservedOperation()
150+ t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("ipc", correlation, end, bytes, valid))
151+ }
152+}
153+
154+func (t *unixTransport) beginObservedOperation() bool {
155+ t.operationMu.Lock()
156+ defer t.operationMu.Unlock()
157+ if t.closing {
158+ return false
159+ }
160+ if t.operationCount == 0 {
161+ t.operationsDone = make(chan struct{})
162+ }
163+ t.operationCount++
164+ return true
165+}
166+
167+func (t *unixTransport) finishObservedOperation() {
168+ t.operationMu.Lock()
169+ defer t.operationMu.Unlock()
170+ t.operationCount--
171+ if t.operationCount == 0 {
172+ close(t.operationsDone)
173+ }
174+}
175+
176+func (t *unixTransport) beginShutdown() <-chan struct{} {
177+ t.operationMu.Lock()
178+ defer t.operationMu.Unlock()
179+ t.closing = true
180+ if t.operationCount == 0 {
181+ return nil
182+ }
183+ return t.operationsDone
184+}
185+
186+// Close closes the underlying connection and waits for any interrupted
187+// carriage operation to write its matching failed end mark.
107188 func (t *unixTransport) Close() error {
108- return t.conn.Close()
189+ done := t.beginShutdown()
190+ err := t.conn.Close()
191+ if done != nil {
192+ <-done
193+ }
194+ return err
109195 }
A internal/adapters/ipc/transport_observability_test.go +191 -0
@@ -0,0 +1,191 @@
1+package ipc
2+
3+import (
4+ "encoding/json"
5+ "net"
6+ "os"
7+ "reflect"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/adapters/observability"
14+ "github.com/bnema/vev/internal/ports"
15+)
16+
17+func TestTransportObservabilityIPCEOFAndCloseEndFailedSpans(t *testing.T) {
18+ trace := t.TempDir() + "/ipc-shutdown.jsonl"
19+ observer, closer, err := observability.NewJSONL(trace, ipcRuntimeClock{now: time.Unix(0, 223)}, "ipc-process")
20+ if err != nil {
21+ t.Fatalf("NewJSONL() error = %v", err)
22+ }
23+
24+ // EOF from the peer and a locally closed blocked receive are distinct
25+ // shutdown paths; both must record a failed, correlated end.
26+ for _, closeLocal := range []bool{false, true} {
27+ left, right := net.Pipe()
28+ var started <-chan struct{}
29+ conn := left
30+ if closeLocal {
31+ observedConn := &ipcReadStartedConn{Conn: left, started: make(chan struct{})}
32+ conn, started = observedConn, observedConn.started
33+ }
34+ transport := NewTransport(conn, WithRuntimeObserver(observer))
35+ recvDone := make(chan error, 1)
36+ go func() { _, err := transport.Recv(); recvDone <- err }()
37+ if closeLocal {
38+ <-started
39+ if err := transport.Close(); err != nil {
40+ t.Fatalf("Close() error = %v", err)
41+ }
42+ } else if err := right.Close(); err != nil {
43+ t.Fatalf("peer Close() error = %v", err)
44+ }
45+ if err := <-recvDone; err == nil {
46+ t.Fatal("Recv() error = nil after transport shutdown")
47+ }
48+ _ = left.Close()
49+ _ = right.Close()
50+ }
51+
52+ // A peer shutdown also makes Send fail after its start mark.
53+ left, right := net.Pipe()
54+ transport := NewTransport(left, WithRuntimeObserver(observer))
55+ _ = right.Close()
56+ if err := transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("failed")}); err == nil {
57+ t.Fatal("Send() error = nil after peer close")
58+ }
59+ _ = transport.Close()
60+ if err := closer.Close(); err != nil {
61+ t.Fatalf("trace Close() error = %v", err)
62+ }
63+ assertIPCFailedSpanPairs(t, trace)
64+}
65+
66+type ipcReadStartedConn struct {
67+ net.Conn
68+ started chan struct{}
69+ once sync.Once
70+}
71+
72+func (c *ipcReadStartedConn) Read(p []byte) (int, error) {
73+ c.once.Do(func() { close(c.started) })
74+ return c.Conn.Read(p)
75+}
76+
77+func assertIPCFailedSpanPairs(t *testing.T, path string) {
78+ t.Helper()
79+ data, err := os.ReadFile(path)
80+ if err != nil {
81+ t.Fatalf("ReadFile(trace): %v", err)
82+ }
83+ type mark struct {
84+ ProcessID string `json:"process_id"`
85+ Component string `json:"component"`
86+ Scenario string `json:"scenario"`
87+ Run uint64 `json:"run"`
88+ Sequence uint64 `json:"sequence"`
89+ RequestID uint64 `json:"request_id"`
90+ Epoch uint64 `json:"epoch"`
91+ Kind string `json:"kind"`
92+ Valid bool `json:"valid"`
93+ }
94+ type spanKey struct {
95+ processID, scenario string
96+ run, sequence, requestID, epoch uint64
97+ }
98+ starts := make(map[spanKey]mark)
99+ startCounts := map[string]int{}
100+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
101+ var m mark
102+ if err := json.Unmarshal([]byte(line), &m); err != nil {
103+ t.Fatalf("Unmarshal(trace): %v", err)
104+ }
105+ key := spanKey{m.ProcessID, m.Scenario, m.Run, m.Sequence, m.RequestID, m.Epoch}
106+ switch m.Kind {
107+ case "adapter_receive_start", "adapter_send_start":
108+ if m.ProcessID != "ipc-process" || m.Component != "ipc" || m.Scenario != "runtime" || m.Run != 1 || m.Sequence == 0 || m.RequestID != m.Sequence || m.Epoch != m.Sequence || !m.Valid {
109+ t.Fatalf("adapter start has invalid identity: %#v", m)
110+ }
111+ if _, exists := starts[key]; exists {
112+ t.Fatalf("duplicate adapter start for %#v", key)
113+ }
114+ starts[key] = m
115+ startCounts[m.Kind]++
116+ case "adapter_receive_end", "adapter_send_end":
117+ start, ok := starts[key]
118+ if !ok || strings.TrimSuffix(start.Kind, "_start") != strings.TrimSuffix(m.Kind, "_end") {
119+ t.Fatalf("unmatched adapter end %#v", m)
120+ }
121+ if m.Valid {
122+ t.Fatalf("failed adapter end is valid: %#v", m)
123+ }
124+ delete(starts, key)
125+ }
126+ }
127+ if len(starts) != 0 {
128+ t.Fatalf("unmatched adapter starts: %#v", starts)
129+ }
130+ if got := startCounts["adapter_receive_start"]; got != 2 {
131+ t.Errorf("adapter receive starts = %d, want 2", got)
132+ }
133+ if got := startCounts["adapter_send_start"]; got != 1 {
134+ t.Errorf("adapter send starts = %d, want 1", got)
135+ }
136+}
137+
138+func TestTransportObservabilityIPCMarksCarriageWithoutChangingBytes(t *testing.T) {
139+ trace := t.TempDir() + "/ipc.jsonl"
140+ observer, closer, err := observability.NewJSONL(trace, ipcRuntimeClock{now: time.Unix(0, 211)}, "ipc-process")
141+ if err != nil {
142+ t.Fatalf("NewJSONL() error = %v", err)
143+ }
144+
145+ left, right := net.Pipe()
146+ defer func() { _ = left.Close() }()
147+ defer func() { _ = right.Close() }()
148+ client := NewTransport(left, WithRuntimeObserver(observer))
149+ server := NewTransport(right, WithRuntimeObserver(observer))
150+ want := ports.Frame{Type: ports.MsgOutput, Payload: []byte("wire bytes stay exact")}
151+
152+ var wg sync.WaitGroup
153+ wg.Go(func() {
154+ if err := client.Send(want); err != nil {
155+ t.Errorf("Send() error = %v", err)
156+ }
157+ })
158+ got, err := server.Recv()
159+ wg.Wait()
160+ if err != nil {
161+ t.Fatalf("Recv() error = %v", err)
162+ }
163+ if !reflect.DeepEqual(got, want) {
164+ t.Fatalf("carried frame = %#v, want %#v", got, want)
165+ }
166+ if err := closer.Close(); err != nil {
167+ t.Fatalf("trace Close() error = %v", err)
168+ }
169+ traceBytes, err := os.ReadFile(trace)
170+ if err != nil {
171+ t.Fatalf("ReadFile(trace): %v", err)
172+ }
173+ for _, kind := range []string{"adapter_send_start", "adapter_send_end", "adapter_receive_start", "adapter_receive_end"} {
174+ if !strings.Contains(string(traceBytes), `"kind":"`+kind+`"`) {
175+ t.Errorf("trace missing %s", kind)
176+ }
177+ }
178+}
179+
180+type ipcRuntimeClock struct{ now time.Time }
181+
182+func (c ipcRuntimeClock) Now() time.Time { return c.now }
183+func (c ipcRuntimeClock) NewTimer(d time.Duration) ports.Timer {
184+ return ipcRuntimeTimer{timer: time.NewTimer(d)}
185+}
186+
187+type ipcRuntimeTimer struct{ timer *time.Timer }
188+
189+func (t ipcRuntimeTimer) C() <-chan time.Time { return t.timer.C }
190+func (t ipcRuntimeTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) }
191+func (t ipcRuntimeTimer) Stop() bool { return t.timer.Stop() }
A internal/adapters/observability/jsonl.go +100 -0
@@ -0,0 +1,100 @@
1+// Package observability contains concrete process-local trace sinks.
2+package observability
3+
4+import (
5+ "encoding/json"
6+ "io"
7+ "os"
8+ "sync"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+// NewJSONL opens path as the sole timestamp owner for one OS process. Marks
14+// carrying a caller timestamp are rejected rather than rewritten, preventing
15+// accidental arithmetic across clock domains.
16+func NewJSONL(path string, clock ports.Clock, processID string) (ports.RuntimeObserver, io.Closer, error) {
17+ if path == "" || clock == nil || processID == "" {
18+ return nil, nil, os.ErrInvalid
19+ }
20+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
21+ if err != nil {
22+ return nil, nil, err
23+ }
24+ j := &jsonl{file: f, clock: clock, processID: processID}
25+ return j, j, nil
26+}
27+
28+type jsonl struct {
29+ mu sync.Mutex
30+ file *os.File
31+ clock ports.Clock
32+ processID string
33+ closed bool
34+ err error
35+}
36+
37+type record struct {
38+ Schema uint16 `json:"schema"`
39+ ProcessID string `json:"process_id"`
40+ Component string `json:"component"`
41+ Scenario string `json:"scenario"`
42+ Run uint64 `json:"run"`
43+ Sequence uint64 `json:"sequence"`
44+ RequestID uint64 `json:"request_id"`
45+ Epoch uint64 `json:"epoch"`
46+ Kind ports.RuntimeMarkKind `json:"kind"`
47+ Tick int64 `json:"tick"`
48+ Bytes uint64 `json:"bytes"`
49+ Fragments uint64 `json:"fragments"`
50+ Retransmits uint64 `json:"retransmits"`
51+ Pending uint64 `json:"pending"`
52+ AckRTTNanos int64 `json:"ack_rtt_nanos"`
53+ Valid bool `json:"valid"`
54+}
55+
56+func (j *jsonl) ObserveRuntime(mark ports.RuntimeMark) {
57+ // Tick is a write-once value owned by this adapter. Reject it before any
58+ // mutation so callers cannot smuggle a timestamp from their own clock.
59+ if j == nil || mark.Tick != 0 {
60+ return
61+ }
62+ mark.ProcessID = j.processID
63+ if !ports.RuntimeMarkValid(mark) {
64+ return
65+ }
66+ j.mu.Lock()
67+ defer j.mu.Unlock()
68+ if j.closed {
69+ return
70+ }
71+ r := record{Schema: mark.Schema, ProcessID: mark.ProcessID, Component: mark.Component, Scenario: mark.Scenario, Run: mark.Run, Sequence: mark.Sequence, RequestID: mark.RequestID, Epoch: mark.Epoch, Kind: mark.Kind, Tick: j.clock.Now().UnixNano(), Bytes: mark.Bytes, Fragments: mark.Fragments, Retransmits: mark.Retransmits, Pending: mark.Pending, AckRTTNanos: mark.AckRTTNanos, Valid: mark.Valid}
72+ encoded, err := json.Marshal(r)
73+ if err != nil {
74+ j.recordError(err)
75+ return
76+ }
77+ if _, err := j.file.Write(append(encoded, '\n')); err != nil {
78+ j.recordError(err)
79+ }
80+}
81+
82+func (j *jsonl) recordError(err error) {
83+ if err != nil && j.err == nil {
84+ j.err = err
85+ }
86+}
87+
88+func (j *jsonl) Close() error {
89+ j.mu.Lock()
90+ defer j.mu.Unlock()
91+ if j.closed {
92+ return nil
93+ }
94+ j.closed = true
95+ closeErr := j.file.Close()
96+ if j.err != nil {
97+ return j.err
98+ }
99+ return closeErr
100+}
A internal/adapters/observability/jsonl_test.go +193 -0
@@ -0,0 +1,193 @@
1+package observability
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "os"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+func TestTransportObservabilityJSONLOwnsTicksAndSchema(t *testing.T) {
15+ path := t.TempDir() + "/daemon.jsonl"
16+ observer, closer, err := NewJSONL(path, fixedRuntimeClock{now: time.Unix(0, 41)}, "daemon-process")
17+ if err != nil {
18+ t.Fatalf("NewJSONL() error = %v", err)
19+ }
20+
21+ observer.ObserveRuntime(validRuntimeMark(ports.RuntimeComposeStart))
22+ // A producer's clock domain must never enter a JSONL record. Rejection is
23+ // observable as no second record, rather than a silently rewritten mark.
24+ producerTicked := validRuntimeMark(ports.RuntimeComposeEnd)
25+ producerTicked.Tick = 99
26+ observer.ObserveRuntime(producerTicked)
27+ if err := closer.Close(); err != nil {
28+ t.Fatalf("Close() error = %v", err)
29+ }
30+
31+ records := readJSONLRecords(t, path)
32+ if len(records) != 1 {
33+ t.Fatalf("JSONL records = %d, want 1 (caller Tick must be rejected)", len(records))
34+ }
35+ wantKeys := map[string]bool{
36+ "schema": true, "process_id": true, "component": true, "scenario": true,
37+ "run": true, "sequence": true, "request_id": true, "epoch": true,
38+ "kind": true, "tick": true, "bytes": true, "fragments": true,
39+ "retransmits": true, "pending": true, "ack_rtt_nanos": true, "valid": true,
40+ }
41+ if len(records[0]) != len(wantKeys) {
42+ t.Fatalf("JSONL fields = %#v, want exactly canonical schema", records[0])
43+ }
44+ for key := range wantKeys {
45+ if _, ok := records[0][key]; !ok {
46+ t.Errorf("JSONL missing %q", key)
47+ }
48+ }
49+ if got := records[0]["tick"]; got != float64(41) {
50+ t.Errorf("JSONL tick = %v, want observer clock tick 41", got)
51+ }
52+ if got := records[0]["process_id"]; got != "daemon-process" {
53+ t.Errorf("JSONL process_id = %v, want observer process", got)
54+ }
55+}
56+
57+func TestTransportObservabilityJSONLRejectsInvalidRequiredFields(t *testing.T) {
58+ for _, mark := range []ports.RuntimeMark{
59+ {},
60+ {Schema: 1, ProcessID: "p", Component: "daemon", Scenario: "s", Run: 1, Sequence: 1, Kind: ports.RuntimeEmitStart},
61+ {Schema: 2, ProcessID: "p", Component: "daemon", Scenario: "s", Run: 1, Sequence: 1, Kind: ports.RuntimeEmitStart, Valid: true},
62+ } {
63+ path := t.TempDir() + "/invalid.jsonl"
64+ observer, closer, err := NewJSONL(path, fixedRuntimeClock{now: time.Unix(0, 5)}, "p")
65+ if err != nil {
66+ t.Fatalf("NewJSONL() error = %v", err)
67+ }
68+ observer.ObserveRuntime(mark)
69+ if err := closer.Close(); err != nil {
70+ t.Fatalf("Close() error = %v", err)
71+ }
72+ if records := readJSONLRecords(t, path); len(records) != 0 {
73+ t.Errorf("invalid mark %#v wrote %#v", mark, records)
74+ }
75+ }
76+}
77+
78+func TestTransportObservabilityJSONLWriteErrorSurfacesOnClose(t *testing.T) {
79+ // /dev/full accepts Open but rejects every write, exercising the concrete
80+ // observer lifecycle without changing the one-way RuntimeObserver port.
81+ observer, closer, err := NewJSONL("/dev/full", fixedRuntimeClock{now: time.Unix(0, 1)}, "p")
82+ if err != nil {
83+ t.Skipf("/dev/full unavailable: %v", err)
84+ }
85+ observer.ObserveRuntime(validRuntimeMark(ports.RuntimeEmitStart))
86+ if err := closer.Close(); err == nil {
87+ t.Fatal("Close() error = nil, want surfaced JSONL write error")
88+ }
89+}
90+
91+func TestTransportObservabilitySameProcessSpanClockDomains(t *testing.T) {
92+ for _, tc := range []struct {
93+ name string
94+ start ports.RuntimeMarkKind
95+ end ports.RuntimeMarkKind
96+ field string
97+ }{
98+ {"diff", ports.RuntimeDiffStart, ports.RuntimeDiffEnd, "diff_duration"},
99+ {"queue", ports.RuntimeQueueEnqueued, ports.RuntimeQueueDequeued, "queue_wait"},
100+ {"ack", ports.RuntimeACKBlockedStart, ports.RuntimeACKBlockedEnd, "ack_blocked_interval"},
101+ {"send", ports.RuntimeAdapterSendStart, ports.RuntimeAdapterSendEnd, "adapter_send_duration"},
102+ {"receive", ports.RuntimeAdapterReceiveStart, ports.RuntimeAdapterReceiveEnd, "adapter_receive_duration"},
103+ } {
104+ t.Run(tc.name, func(t *testing.T) {
105+ start := validRuntimeMark(tc.start)
106+ start.Tick = 11
107+ end := start
108+ end.Kind, end.Tick = tc.end, 19
109+ if got, err := sameProcessDuration(start, end); err != nil || got != 8 {
110+ t.Fatalf("%s = %d, %v; want 8, nil", tc.field, got, err)
111+ }
112+
113+ end.ProcessID = "other-process"
114+ if _, err := sameProcessDuration(start, end); err == nil {
115+ t.Fatalf("%s accepted cross-process ticks", tc.field)
116+ }
117+ })
118+ }
119+}
120+
121+func TestTransportObservabilityJSONLSerializesConcurrentMarks(t *testing.T) {
122+ path := t.TempDir() + "/concurrent.jsonl"
123+ observer, closer, err := NewJSONL(path, fixedRuntimeClock{now: time.Unix(0, 73)}, "ipc-process")
124+ if err != nil {
125+ t.Fatalf("NewJSONL() error = %v", err)
126+ }
127+ var wg sync.WaitGroup
128+ for i := uint64(0); i < 32; i++ {
129+ wg.Add(1)
130+ go func(sequence uint64) {
131+ defer wg.Done()
132+ mark := validRuntimeMark(ports.RuntimeAdapterSendEnd)
133+ mark.Sequence = sequence + 1
134+ observer.ObserveRuntime(mark)
135+ }(i)
136+ }
137+ wg.Wait()
138+ if err := closer.Close(); err != nil {
139+ t.Fatalf("Close() error = %v", err)
140+ }
141+ if records := readJSONLRecords(t, path); len(records) != 32 {
142+ t.Fatalf("concurrently serialized records = %d, want 32", len(records))
143+ }
144+}
145+
146+func sameProcessDuration(start, end ports.RuntimeMark) (int64, error) {
147+ if start.ProcessID == "" || start.ProcessID != end.ProcessID {
148+ return 0, os.ErrInvalid
149+ }
150+ if start.Scenario != end.Scenario || start.Run != end.Run || start.Sequence != end.Sequence || start.RequestID != end.RequestID || start.Epoch != end.Epoch || end.Tick < start.Tick {
151+ return 0, os.ErrInvalid
152+ }
153+ return end.Tick - start.Tick, nil
154+}
155+
156+func validRuntimeMark(kind ports.RuntimeMarkKind) ports.RuntimeMark {
157+ return ports.RuntimeMark{Schema: 1, ProcessID: "daemon-process", Component: "daemon", Scenario: "resize", Run: 7, Sequence: 9, RequestID: 11, Epoch: 13, Kind: kind, Valid: true}
158+}
159+
160+func readJSONLRecords(t *testing.T, path string) []map[string]any {
161+ t.Helper()
162+ f, err := os.Open(path)
163+ if err != nil {
164+ t.Fatalf("Open(%q): %v", path, err)
165+ }
166+ defer func() { _ = f.Close() }()
167+ var records []map[string]any
168+ scanner := bufio.NewScanner(f)
169+ for scanner.Scan() {
170+ var record map[string]any
171+ if err := json.Unmarshal(scanner.Bytes(), &record); err != nil {
172+ t.Fatalf("invalid JSONL record %q: %v", scanner.Text(), err)
173+ }
174+ records = append(records, record)
175+ }
176+ if err := scanner.Err(); err != nil {
177+ t.Fatalf("reading JSONL: %v", err)
178+ }
179+ return records
180+}
181+
182+type fixedRuntimeClock struct{ now time.Time }
183+
184+func (c fixedRuntimeClock) Now() time.Time { return c.now }
185+func (c fixedRuntimeClock) NewTimer(d time.Duration) ports.Timer {
186+ return fixedRuntimeTimer{timer: time.NewTimer(d)}
187+}
188+
189+type fixedRuntimeTimer struct{ timer *time.Timer }
190+
191+func (t fixedRuntimeTimer) C() <-chan time.Time { return t.timer.C }
192+func (t fixedRuntimeTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) }
193+func (t fixedRuntimeTimer) Stop() bool { return t.timer.Stop() }
M internal/adapters/remote/remote_factory.go +17 -8
@@ -11,35 +11,44 @@ import (
1111 )
1212
1313 // DialerFactory selects the requested remote transport adapter.
14-type DialerFactory struct{}
14+type DialerFactory struct{ observer ports.RuntimeObserver }
1515
1616 var _ ports.RemoteDialerFactory = DialerFactory{}
1717
1818 func NewDialerFactory() DialerFactory { return DialerFactory{} }
1919
20-func (DialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
20+// NewDialerFactoryWithRuntimeObserver wires the one process-local sink into
21+// whichever concrete remote carriage adapter is selected.
22+func NewDialerFactoryWithRuntimeObserver(observer ports.RuntimeObserver) DialerFactory {
23+ return DialerFactory{observer: observer}
24+}
25+
26+func (f DialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
2127 switch mode {
2228 case ports.RemoteTransportUDP:
2329 if log != nil {
2430 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
2531 }
26- return dgram.NewRemoteDialerWithLogger(target, session, log), nil
32+ dialer := dgram.NewRemoteDialerWithLogger(target, session, log)
33+ dialer.RuntimeObserver = f.observer
34+ return dialer, nil
2735 case ports.RemoteTransportStdio:
2836 if log != nil {
2937 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
3038 }
31- return stdioDialer{target: target, session: session, log: log}, nil
39+ return stdioDialer{target: target, session: session, log: log, observer: f.observer}, nil
3240 default:
3341 return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)
3442 }
3543 }
3644
3745 type stdioDialer struct {
38- target string
39- session string
40- log *slog.Logger
46+ target string
47+ session string
48+ log *slog.Logger
49+ observer ports.RuntimeObserver
4150 }
4251
4352 func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) {
44- return sshstdio.DialContext(ctx, d.target, d.session, d.log)
53+ return sshstdio.DialContextWithRuntimeObserver(ctx, d.target, d.session, d.log, sshstdio.WithRuntimeObserver(d.observer))
4554 }
M internal/adapters/sshstdio/transport.go +127 -14
@@ -34,9 +34,23 @@ var (
3434 type closeFunc func() error
3535 type eofErrFunc func() error
3636
37+type Option func(*transport)
38+
39+// WithRuntimeObserver enables process-local transport marks; it deliberately
40+// takes only an observer so a carriage adapter never owns trace time.
41+func WithRuntimeObserver(observer ports.RuntimeObserver) Option {
42+ return func(t *transport) { t.observer = observer }
43+}
44+
3745 // NewTransport wraps separate reader/writer streams as a framed Transport.
38-func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc) ports.Transport {
39- return newTransport(r, w, closeFn, nil)
46+func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc, opts ...Option) ports.Transport {
47+ t := newTransport(r, w, closeFn, nil).(*transport)
48+ for _, opt := range opts {
49+ if opt != nil {
50+ opt(t)
51+ }
52+ }
53+ return t
4054 }
4155
4256 func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc) ports.Transport {
@@ -47,18 +61,25 @@ func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc
4761 }
4862
4963 type transport struct {
50- r io.Reader
51- w io.Writer
52- close closeFunc
53- eofErr eofErrFunc
64+ r io.Reader
65+ w io.Writer
66+ close closeFunc
67+ eofErr eofErrFunc
68+ observer ports.RuntimeObserver
69+ operationMu sync.Mutex
70+ operationCount int
71+ closing bool
72+ operationsDone chan struct{}
5473
5574 mu sync.Mutex
5675 readBuf []byte
5776 }
5877
5978 func (t *transport) Send(f ports.Frame) error {
79+ end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
6080 n := 1 + len(f.Payload)
6181 if n > maxFrameLen {
82+ end(false)
6283 return ErrFrameTooLarge
6384 }
6485
@@ -68,22 +89,28 @@ func (t *transport) Send(f ports.Frame) error {
6889 copy(buf[frameHeaderLen+1:], f.Payload)
6990
7091 t.mu.Lock()
71- defer t.mu.Unlock()
7292 _, err := t.w.Write(buf)
93+ t.mu.Unlock()
94+ end(err == nil)
7395 return err
7496 }
7597
7698 func (t *transport) Recv() (ports.Frame, error) {
99+ end := t.beginOperation(ports.RuntimeAdapterReceiveStart, 0)
77100 var hdr [frameHeaderLen]byte
78101 if _, err := io.ReadFull(t.r, hdr[:]); err != nil {
79- return ports.Frame{}, t.mapEOFError(err)
102+ err = t.mapEOFError(err)
103+ end(false)
104+ return ports.Frame{}, err
80105 }
81106
82107 n := binary.BigEndian.Uint32(hdr[:])
83108 if n == 0 {
109+ end(false)
84110 return ports.Frame{}, ErrZeroLengthFrame
85111 }
86112 if n > maxFrameLen {
113+ end(false)
87114 return ports.Frame{}, ErrFrameTooLarge
88115 }
89116
@@ -93,13 +120,35 @@ func (t *transport) Recv() (ports.Frame, error) {
93120 t.readBuf = t.readBuf[:n]
94121 }
95122 if _, err := io.ReadFull(t.r, t.readBuf); err != nil {
96- return ports.Frame{}, t.mapEOFError(err)
123+ err = t.mapEOFError(err)
124+ end(false)
125+ return ports.Frame{}, err
97126 }
98127
99128 payload := append([]byte(nil), t.readBuf[1:]...)
129+ end(true)
100130 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
101131 }
102132
133+func (t *transport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) func(bool) {
134+ if t.observer == nil {
135+ return func(bool) {}
136+ }
137+ if !t.beginObservedOperation() {
138+ return func(bool) {}
139+ }
140+ correlation := ports.NewRuntimeCorrelation()
141+ t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("sshstdio", correlation, start, bytes, true))
142+ end := ports.RuntimeAdapterSendEnd
143+ if start == ports.RuntimeAdapterReceiveStart {
144+ end = ports.RuntimeAdapterReceiveEnd
145+ }
146+ return func(valid bool) {
147+ defer t.finishObservedOperation()
148+ t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("sshstdio", correlation, end, bytes, valid))
149+ }
150+}
151+
103152 func (t *transport) mapEOFError(err error) error {
104153 if t.eofErr == nil || (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) {
105154 return err
@@ -110,7 +159,56 @@ func (t *transport) mapEOFError(err error) error {
110159 return err
111160 }
112161
113-func (t *transport) Close() error { return t.close() }
162+func (t *transport) Close() error {
163+ done := t.beginShutdown()
164+ // Closing the reader first releases a Recv blocked in io.ReadFull even if
165+ // the subprocess shutdown callback reports an error. This lets Recv emit
166+ // its failed end mark before the process observer is closed.
167+ var readErr error
168+ if r, ok := t.r.(io.Closer); ok {
169+ readErr = r.Close()
170+ }
171+ closeErr := t.close()
172+ if done != nil {
173+ <-done
174+ }
175+ if closeErr != nil {
176+ return closeErr
177+ }
178+ return readErr
179+}
180+
181+func (t *transport) beginObservedOperation() bool {
182+ t.operationMu.Lock()
183+ defer t.operationMu.Unlock()
184+ if t.closing {
185+ return false
186+ }
187+ if t.operationCount == 0 {
188+ t.operationsDone = make(chan struct{})
189+ }
190+ t.operationCount++
191+ return true
192+}
193+
194+func (t *transport) finishObservedOperation() {
195+ t.operationMu.Lock()
196+ defer t.operationMu.Unlock()
197+ t.operationCount--
198+ if t.operationCount == 0 {
199+ close(t.operationsDone)
200+ }
201+}
202+
203+func (t *transport) beginShutdown() <-chan struct{} {
204+ t.operationMu.Lock()
205+ defer t.operationMu.Unlock()
206+ t.closing = true
207+ if t.operationCount == 0 {
208+ return nil
209+ }
210+ return t.operationsDone
211+}
114212
115213 type processWaiter struct {
116214 cmd *exec.Cmd
@@ -222,18 +320,27 @@ func Dial(target, session string) (ports.Transport, error) {
222320 return DialContext(context.Background(), target, session)
223321 }
224322
323+// DialContextWithRuntimeObserver is the option-bearing production constructor.
324+func DialContextWithRuntimeObserver(ctx context.Context, target, session string, logger *slog.Logger, opts ...Option) (ports.Transport, error) {
325+ return dialContext(ctx, target, session, logger, opts...)
326+}
327+
225328 // DialContext is like Dial, but the context is propagated to ssh startup so a
226329 // canceled attach attempt interrupts the local ssh process. Callers may pass a
227330 // logger to record ssh start failures and non-clean exits without logging the
228331 // generated command line.
229332 func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) {
230- if err := ctx.Err(); err != nil {
231- return nil, err
232- }
233333 var log *slog.Logger
234334 if len(logger) > 0 {
235335 log = logger[0]
236336 }
337+ return dialContext(ctx, target, session, log)
338+}
339+
340+func dialContext(ctx context.Context, target, session string, log *slog.Logger, opts ...Option) (ports.Transport, error) {
341+ if err := ctx.Err(); err != nil {
342+ return nil, err
343+ }
237344 spec := BuildCommand(target, session)
238345 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
239346 stdin, err := cmd.StdinPipe()
@@ -254,5 +361,11 @@ func DialContext(ctx context.Context, target, session string, logger ...*slog.Lo
254361 }
255362
256363 waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)
257- return newTransport(stdout, stdin, waiter.close, waiter.eofErr), nil
364+ transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr).(*transport)
365+ for _, opt := range opts {
366+ if opt != nil {
367+ opt(transport)
368+ }
369+ }
370+ return transport, nil
258371 }
A internal/adapters/sshstdio/transport_observability_test.go +173 -0
@@ -0,0 +1,173 @@
1+package sshstdio
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/adapters/observability"
14+ "github.com/bnema/vev/internal/ports"
15+)
16+
17+func TestTransportObservabilitySSHStdioEOFEndsReceive(t *testing.T) {
18+ tracePath := t.TempDir() + "/ssh-eof.jsonl"
19+ observer, closer, err := observability.NewJSONL(tracePath, sshRuntimeClock{now: time.Unix(0, 309)}, "ssh-process")
20+ if err != nil {
21+ t.Fatalf("NewJSONL() error = %v", err)
22+ }
23+ if _, err := NewTransport(bytes.NewReader(nil), &bytes.Buffer{}, nil, WithRuntimeObserver(observer)).Recv(); err == nil {
24+ t.Fatal("Recv() error = nil at EOF")
25+ }
26+ if err := closer.Close(); err != nil {
27+ t.Fatalf("trace Close() error = %v", err)
28+ }
29+ assertSSHReceiveFailurePair(t, tracePath)
30+}
31+
32+func TestTransportObservabilitySSHStdioCloseEndsBlockedReceive(t *testing.T) {
33+ tracePath := t.TempDir() + "/ssh-close.jsonl"
34+ observer, closer, err := observability.NewJSONL(tracePath, sshRuntimeClock{now: time.Unix(0, 311)}, "ssh-process")
35+ if err != nil {
36+ t.Fatalf("NewJSONL() error = %v", err)
37+ }
38+ reader := &sshShutdownReader{entered: make(chan struct{}), done: make(chan struct{})}
39+ shutdownErr := errors.New("ssh shutdown failed")
40+ transport := NewTransport(reader, &bytes.Buffer{}, func() error { return shutdownErr }, WithRuntimeObserver(observer))
41+ recvDone := make(chan error, 1)
42+ go func() { _, err := transport.Recv(); recvDone <- err }()
43+ <-reader.entered
44+ if err := transport.Close(); !errors.Is(err, shutdownErr) {
45+ t.Fatalf("Close() error = %v, want %v", err, shutdownErr)
46+ }
47+ if err := <-recvDone; err == nil {
48+ t.Fatal("Recv() error = nil after Close()")
49+ }
50+ if err := closer.Close(); err != nil {
51+ t.Fatalf("trace Close() error = %v", err)
52+ }
53+ assertSSHReceiveFailurePair(t, tracePath)
54+}
55+
56+type sshShutdownReader struct {
57+ entered chan struct{}
58+ done chan struct{}
59+ once sync.Once
60+}
61+
62+func (r *sshShutdownReader) Read([]byte) (int, error) {
63+ r.once.Do(func() { close(r.entered) })
64+ <-r.done
65+ return 0, errors.New("reader closed")
66+}
67+
68+func (r *sshShutdownReader) Close() error {
69+ r.once.Do(func() { close(r.entered) })
70+ close(r.done)
71+ return nil
72+}
73+
74+func assertSSHReceiveFailurePair(t *testing.T, path string) {
75+ t.Helper()
76+ data, err := os.ReadFile(path)
77+ if err != nil {
78+ t.Fatalf("ReadFile(trace): %v", err)
79+ }
80+ var starts, ends []struct {
81+ ProcessID string `json:"process_id"`
82+ Scenario string `json:"scenario"`
83+ Run uint64 `json:"run"`
84+ Sequence uint64 `json:"sequence"`
85+ RequestID uint64 `json:"request_id"`
86+ Epoch uint64 `json:"epoch"`
87+ Kind string `json:"kind"`
88+ Valid bool `json:"valid"`
89+ }
90+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
91+ var mark struct {
92+ ProcessID string `json:"process_id"`
93+ Scenario string `json:"scenario"`
94+ Run uint64 `json:"run"`
95+ Sequence uint64 `json:"sequence"`
96+ RequestID uint64 `json:"request_id"`
97+ Epoch uint64 `json:"epoch"`
98+ Kind string `json:"kind"`
99+ Valid bool `json:"valid"`
100+ }
101+ if err := json.Unmarshal([]byte(line), &mark); err != nil {
102+ t.Fatalf("Unmarshal(trace): %v", err)
103+ }
104+ switch mark.Kind {
105+ case "adapter_receive_start":
106+ starts = append(starts, mark)
107+ case "adapter_receive_end":
108+ ends = append(ends, mark)
109+ }
110+ }
111+ if len(starts) != 1 || len(ends) != 1 {
112+ t.Fatalf("receive spans start=%d end=%d, want exactly one pair", len(starts), len(ends))
113+ }
114+ start, end := starts[0], ends[0]
115+ if start.ProcessID != end.ProcessID || start.Scenario != end.Scenario || start.Run != end.Run || start.Sequence != end.Sequence || start.RequestID != end.RequestID || start.Epoch != end.Epoch {
116+ t.Fatalf("mismatched receive correlation: start=%+v end=%+v", start, end)
117+ }
118+ if !start.Valid || end.Valid {
119+ t.Fatalf("receive validity start=%t end=%t, want true/false", start.Valid, end.Valid)
120+ }
121+}
122+
123+func TestTransportObservabilitySSHStdioPreservesCarriage(t *testing.T) {
124+ tracePath := t.TempDir() + "/ssh.jsonl"
125+ observer, closer, err := observability.NewJSONL(tracePath, sshRuntimeClock{now: time.Unix(0, 307)}, "ssh-process")
126+ if err != nil {
127+ t.Fatalf("NewJSONL() error = %v", err)
128+ }
129+
130+ frame := ports.Frame{Type: ports.MsgOutput, Payload: []byte("stdio bytes stay exact")}
131+ var observed, baseline bytes.Buffer
132+ if err := NewTransport(bytes.NewReader(nil), &observed, nil, WithRuntimeObserver(observer)).Send(frame); err != nil {
133+ t.Fatalf("observed Send() error = %v", err)
134+ }
135+ if err := NewTransport(bytes.NewReader(nil), &baseline, nil).Send(frame); err != nil {
136+ t.Fatalf("baseline Send() error = %v", err)
137+ }
138+ if !bytes.Equal(observed.Bytes(), baseline.Bytes()) {
139+ t.Fatalf("observer changed SSH stdio wire bytes: got %x, want %x", observed.Bytes(), baseline.Bytes())
140+ }
141+ received, err := NewTransport(bytes.NewReader(baseline.Bytes()), &bytes.Buffer{}, nil, WithRuntimeObserver(observer)).Recv()
142+ if err != nil {
143+ t.Fatalf("observed Recv() error = %v", err)
144+ }
145+ if received.Type != frame.Type || !bytes.Equal(received.Payload, frame.Payload) {
146+ t.Fatalf("observed Recv() = %#v, want %#v", received, frame)
147+ }
148+ if err := closer.Close(); err != nil {
149+ t.Fatalf("trace Close() error = %v", err)
150+ }
151+ trace, err := os.ReadFile(tracePath)
152+ if err != nil {
153+ t.Fatalf("ReadFile(trace): %v", err)
154+ }
155+ for _, kind := range []string{"adapter_send_start", "adapter_send_end", "adapter_receive_start", "adapter_receive_end"} {
156+ if !strings.Contains(string(trace), `"kind":"`+kind+`"`) {
157+ t.Errorf("trace missing %s", kind)
158+ }
159+ }
160+}
161+
162+type sshRuntimeClock struct{ now time.Time }
163+
164+func (c sshRuntimeClock) Now() time.Time { return c.now }
165+func (c sshRuntimeClock) NewTimer(d time.Duration) ports.Timer {
166+ return sshRuntimeTimer{timer: time.NewTimer(d)}
167+}
168+
169+type sshRuntimeTimer struct{ timer *time.Timer }
170+
171+func (t sshRuntimeTimer) C() <-chan time.Time { return t.timer.C }
172+func (t sshRuntimeTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) }
173+func (t sshRuntimeTimer) Stop() bool { return t.timer.Stop() }
M internal/app/daemon_integration_test.go +5 -5
@@ -74,7 +74,7 @@ func recvPump(tr ports.Transport) *pump {
7474 // attach dials, handshakes, and returns the transport plus its frame pump.
7575 func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (ports.Transport, *pump) {
7676 t.Helper()
77- tr, err := ipc.Dial(dir)
77+ tr, err := ipc.DialContext(context.Background(), dir)
7878 require.NoError(t, err)
7979 hello := ports.Hello{Version: ports.ProtocolVersion, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color"}
8080 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}))
@@ -91,7 +91,7 @@ func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size)
9191
9292 func listRemoteSessions(t *testing.T, dir string) ports.Sessions {
9393 t.Helper()
94- tr, err := ipc.Dial(dir)
94+ tr, err := ipc.DialContext(context.Background(), dir)
9595 require.NoError(t, err)
9696 defer func() { _ = tr.Close() }()
9797 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))
@@ -104,7 +104,7 @@ func listRemoteSessions(t *testing.T, dir string) ports.Sessions {
104104 }
105105
106106 func killAll(dir string) error {
107- tr, err := ipc.Dial(dir)
107+ tr, err := ipc.DialContext(context.Background(), dir)
108108 if err != nil {
109109 return err
110110 }
@@ -248,7 +248,7 @@ func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) {
248248 text = awaitScreenText(t, p, sz, " work ")
249249 require.NotContains(t, text, "work*")
250250
251- listTr, err := ipc.Dial(dir)
251+ listTr, err := ipc.DialContext(context.Background(), dir)
252252 require.NoError(t, err)
253253 defer func() { _ = listTr.Close() }()
254254 require.NoError(t, listTr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))
@@ -367,7 +367,7 @@ func TestIntegration_KillAllShutsDownDaemon(t *testing.T) {
367367 tr2, _ := attach(t, dir, ports.IntentNew, "two", sz)
368368 require.NoError(t, tr2.Close())
369369
370- killTr, err := ipc.Dial(dir)
370+ killTr, err := ipc.DialContext(context.Background(), dir)
371371 require.NoError(t, err)
372372 defer func() { _ = killTr.Close() }()
373373 require.NoError(t, killTr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}))
M internal/app/run.go +137 -19
@@ -22,6 +22,7 @@ import (
2222 "path/filepath"
2323 "strconv"
2424 "strings"
25+ "sync"
2526 "syscall"
2627 "text/tabwriter"
2728 "time"
@@ -31,6 +32,7 @@ import (
3132 "github.com/bnema/vev/internal/adapters/config"
3233 "github.com/bnema/vev/internal/adapters/dgram"
3334 "github.com/bnema/vev/internal/adapters/ipc"
35+ "github.com/bnema/vev/internal/adapters/observability"
3436 "github.com/bnema/vev/internal/adapters/pty"
3537 remoteadapter "github.com/bnema/vev/internal/adapters/remote"
3638 "github.com/bnema/vev/internal/adapters/shellcmd"
@@ -238,6 +240,72 @@ func dispatch(ctx context.Context, cmd command) error {
238240 }
239241 }
240242
243+// performanceTrace creates one serialized timestamp owner for this process.
244+// An empty trace environment leaves all production behavior and wire bytes
245+// unchanged.
246+func performanceTrace(clk ports.Clock) (ports.RuntimeObserver, io.Closer, error) {
247+ path, processID := os.Getenv("VEV_PERF_TRACE"), os.Getenv("VEV_PERF_PROCESS_ID")
248+ if path == "" {
249+ return nil, nil, nil
250+ }
251+ observer, closer, err := observability.NewJSONL(path, clk, processID)
252+ if err != nil {
253+ return nil, nil, err
254+ }
255+ // The harness supplies these manifest fields to every launched role. Keep a
256+ // valid standalone trace for operators that set only the original trace
257+ // variables, while ensuring harness traces match their process mapping.
258+ inputs := ports.RuntimeCorrelationInputs{Scenario: os.Getenv("VEV_PERF_SCENARIO"), Run: 1}
259+ if inputs.Scenario == "" {
260+ inputs.Scenario = "runtime"
261+ }
262+ if rawRun := os.Getenv("VEV_PERF_RUN"); rawRun != "" {
263+ inputs.Run, err = strconv.ParseUint(rawRun, 10, 64)
264+ if err != nil || inputs.Run == 0 {
265+ _ = closer.Close()
266+ return nil, nil, fmt.Errorf("invalid VEV_PERF_RUN %q", rawRun)
267+ }
268+ }
269+ observer, err = ports.NewRuntimeCorrelationObserver(observer, inputs)
270+ if err != nil {
271+ _ = closer.Close()
272+ return nil, nil, err
273+ }
274+ reporter := ports.NewSerializedRuntimeObserver(observer, runtimeTraceQueueDepth)
275+ return reporter, &runtimeTraceCloser{reporter: reporter, closer: closer}, nil
276+}
277+
278+// runtimeTraceQueueDepth bounds all process-local trace producer handoffs.
279+// A full queue emits the serialized diagnostic gap rather than delaying a
280+// terminal, transport, or ACK progress path.
281+const runtimeTraceQueueDepth = 256
282+
283+// runtimeTraceCloser owns the two-stage trace shutdown: drain the reporter
284+// before closing the concrete timestamp/file owner. sync.Once makes every
285+// deferred and explicit close path safe without closing shared workers twice.
286+type runtimeTraceCloser struct {
287+ reporter ports.SerializedRuntimeObserver
288+ closer io.Closer
289+ once sync.Once
290+ err error
291+}
292+
293+func (c *runtimeTraceCloser) Close() error {
294+ if c == nil {
295+ return nil
296+ }
297+ c.once.Do(func() {
298+ if c.reporter != nil {
299+ c.reporter.Flush()
300+ c.reporter.Close()
301+ }
302+ if c.closer != nil {
303+ c.err = c.closer.Close()
304+ }
305+ })
306+ return c.err
307+}
308+
241309 func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.Logger, io.Closer, error) {
242310 return logging.Setup(logging.Config{
243311 Dir: platform.StateDir(),
@@ -285,7 +353,16 @@ func runDaemon() error {
285353 }
286354 defer func() { _ = logCloser.Close() }()
287355
288- ln, err := ipc.Listen(ipc.SocketDir())
356+ clk := clock.New()
357+ observer, observerCloser, err := performanceTrace(clk)
358+ if err != nil {
359+ return fmt.Errorf("vev: performance trace: %w", err)
360+ }
361+ if observerCloser != nil {
362+ defer func() { _ = observerCloser.Close() }()
363+ }
364+ // Every accepted local connection shares this process's serialized sink.
365+ ln, err := ipc.Listen(ipc.SocketDir(), ipc.WithRuntimeObserver(observer))
289366 if err != nil {
290367 log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err)
291368 return fmt.Errorf("vev: daemon listen: %w", err)
@@ -309,6 +386,9 @@ func runDaemon() error {
309386
310387 log.Info("daemon starting", "socket", ln.Addr())
311388 daemonOpts := []daemon.Option(nil)
389+ if observer != nil {
390+ daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer))
391+ }
312392 configPath := platform.ConfigPath()
313393 cfg, warnings, err := config.Load(configPath)
314394 if err != nil {
@@ -327,7 +407,6 @@ func runDaemon() error {
327407 log.Info("session persistence enabled", "path", storePath)
328408 daemonOpts = append(daemonOpts, daemon.WithStore(store))
329409 }
330- clk := clock.New()
331410 d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...)
332411 watchCtx, stopWatch := context.WithCancel(ctx)
333412 defer stopWatch()
@@ -357,13 +436,25 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
357436 }
358437 defer func() { _ = logCloser.Close() }()
359438
439+ clk := clock.New()
440+ observer, observerCloser, err := performanceTrace(clk)
441+ if err != nil {
442+ return fmt.Errorf("vev: performance trace: %w", err)
443+ }
444+ if observerCloser != nil {
445+ defer func() { _ = observerCloser.Close() }()
446+ }
360447 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{
361- localDialer: defaultLocalDialer,
362- remoteDialerFactory: defaultRemoteDialerFactory(),
448+ localDialer: func() ports.Dialer {
449+ return localDaemonDialer{dir: ipc.SocketDir(), observer: observer}
450+ },
451+ remoteDialerFactory: remoteadapter.NewDialerFactoryWithRuntimeObserver(observer),
363452 selectedRemoteTransport: os.Getenv(envRemoteTransport),
364- runClient: client.Run,
365- createDetached: createDetachedLocalSession,
366- clipboard: clipboard.New(),
453+ runClient: func(ctx context.Context, dialer ports.Dialer, terminal ports.Terminal, behaviorClock ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) error {
454+ return client.RunWithRuntimeObserver(ctx, dialer, terminal, behaviorClock, intent, name, remote, clipboard, log, observer)
455+ },
456+ createDetached: createDetachedLocalSession,
457+ clipboard: clipboard.New(),
367458 })
368459 }
369460
@@ -499,10 +590,19 @@ func isDaemonVersionDrift(err error) bool {
499590 return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal
500591 }
501592
502-type localDaemonDialer struct{ dir string }
593+type localDaemonDialer struct {
594+ dir string
595+ observer ports.RuntimeObserver
596+}
503597
504598 func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
505- return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff)
599+ dial := realDial
600+ if d.observer != nil {
601+ dial = func(ctx context.Context, dir string) (ports.Transport, error) {
602+ return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(d.observer))
603+ }
604+ }
605+ return ensureDaemon(ctx, d.dir, dial, realSpawn, defaultBackoff)
506606 }
507607
508608 func detachedLocalHello(name, cwd string) ports.Hello {
@@ -584,7 +684,7 @@ func requestDaemonStop(ctx context.Context) error {
584684
585685 done := make(chan error, 1)
586686 go func() {
587- transport, err := realDial(ipc.SocketDir())
687+ transport, err := realDial(ctx, ipc.SocketDir())
588688 if err != nil {
589689 done <- fmt.Errorf("vev: no daemon running")
590690 return
@@ -629,13 +729,22 @@ func runStdio(ctx context.Context) error {
629729 defer func() { _ = logCloser.Close() }()
630730 log.Debug("stdio proxy starting")
631731
632- transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
732+ clk := clock.New()
733+ observer, observerCloser, err := performanceTrace(clk)
734+ if err != nil {
735+ return fmt.Errorf("vev: performance trace: %w", err)
736+ }
737+ if observerCloser != nil {
738+ defer func() { _ = observerCloser.Close() }()
739+ }
740+ transport, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
741+ return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
742+ }, realSpawn, defaultBackoff)
633743 if err != nil {
634744 return err
635745 }
636746 defer func() { _ = transport.Close() }()
637-
638- stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil)
747+ stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil, sshstdio.WithRuntimeObserver(observer))
639748 return proxyTransports(ctx, stdio, transport, log)
640749 }
641750
@@ -727,6 +836,13 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
727836 defer func() { _ = logCloser.Close() }()
728837 log.Debug("udp proxy starting", "session", session)
729838
839+ observer, observerCloser, err := performanceTrace(clock.New())
840+ if err != nil {
841+ return fmt.Errorf("vev: performance trace: %w", err)
842+ }
843+ if observerCloser != nil {
844+ defer func() { _ = observerCloser.Close() }()
845+ }
730846 portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange))
731847 if err != nil {
732848 return err
@@ -740,14 +856,16 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
740856 if _, err := rand.Read(key); err != nil {
741857 return err
742858 }
743- daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
859+ daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
860+ return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
861+ }, realSpawn, defaultBackoff)
744862 if err != nil {
745863 return err
746864 }
747865 defer func() { _ = daemonTr.Close() }()
748866 udpOptions := udpProxyClientTransportOptions
749867 udpOptions.Observe = dgram.DiagnosticLogObserver(log)
750- dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions)
868+ dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer))
751869 if err != nil {
752870 return err
753871 }
@@ -890,8 +1008,8 @@ func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger
8901008
8911009 // runList prints the daemon's session listing. With no daemon running, it
8921010 // falls back to the persisted stopped-session records.
893-func runList(_ context.Context) error {
894- transport, err := realDial(ipc.SocketDir())
1011+func runList(ctx context.Context) error {
1012+ transport, err := realDial(ctx, ipc.SocketDir())
8951013 if err != nil {
8961014 records, loadErr := persist.LoadReadOnly(platform.StateDir())
8971015 if loadErr != nil {
@@ -955,8 +1073,8 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
9551073 }
9561074
9571075 // runKill asks the daemon to terminate a named session, every session, or the daemon.
958-func runKill(_ context.Context, name string, all, daemon bool) error {
959- transport, err := realDial(ipc.SocketDir())
1076+func runKill(ctx context.Context, name string, all, daemon bool) error {
1077+ transport, err := realDial(ctx, ipc.SocketDir())
9601078 if err != nil {
9611079 if name != "" && !all && !daemon {
9621080 storePath := persist.StorePath(platform.StateDir())
M internal/app/spawn.go +14 -5
@@ -32,7 +32,7 @@ const staleLockAge = 10 * time.Second
3232
3333 // dialFunc dials the daemon socket in dir. Injected so tests can drive the
3434 // spawn/backoff logic without a real socket.
35-type dialFunc func(dir string) (ports.Transport, error)
35+type dialFunc func(ctx context.Context, dir string) (ports.Transport, error)
3636
3737 // spawnFunc launches a detached daemon process. Injected so tests never
3838 // re-exec a real binary.
@@ -58,7 +58,10 @@ var defaultBackoff = backoffConfig{
5858 // spawner via an mkdir lock (losers simply wait), then retry-dials with
5959 // backoff until the socket is live or the budget expires.
6060 func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {
61- if t, err := dial(dir); err == nil {
61+ if err := ctx.Err(); err != nil {
62+ return nil, err
63+ }
64+ if t, err := dial(ctx, dir); err == nil {
6265 slog.Debug("daemon already reachable", "socket_dir", dir)
6366 return t, nil
6467 }
@@ -73,6 +76,9 @@ func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFun
7376 // rather than spawning a second daemon.
7477 defer release()
7578 slog.Info("spawning daemon", "socket_dir", dir)
79+ if err := ctx.Err(); err != nil {
80+ return nil, err
81+ }
7682 if err := spawn(); err != nil {
7783 slog.Error("daemon spawn failed", "err", err)
7884 return nil, fmt.Errorf("vev: spawning daemon: %w", err)
@@ -120,7 +126,10 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig
120126 deadline := time.Now().Add(cfg.total)
121127 backoff := cfg.initial
122128 for {
123- if t, err := dial(dir); err == nil {
129+ if err := ctx.Err(); err != nil {
130+ return nil, err
131+ }
132+ if t, err := dial(ctx, dir); err == nil {
124133 return t, nil
125134 }
126135 if !time.Now().Before(deadline) {
@@ -139,8 +148,8 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig
139148 }
140149
141150 // realDial is the production dialer.
142-func realDial(dir string) (ports.Transport, error) {
143- return ipc.Dial(dir)
151+func realDial(ctx context.Context, dir string) (ports.Transport, error) {
152+ return ipc.DialContext(ctx, dir)
144153 }
145154
146155 // realSpawn re-execs this binary as a detached daemon: a new session
M internal/app/spawn_test.go +53 -6
@@ -91,7 +91,7 @@ func TestAcquireSpawnLockTakesOverStaleLock(t *testing.T) {
9191 func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {
9292 want := portsmocks.NewMockTransport(t)
9393 var calls atomic.Int32
94- dial := func(string) (ports.Transport, error) {
94+ dial := func(context.Context, string) (ports.Transport, error) {
9595 if calls.Add(1) <= 3 {
9696 return nil, errDialFailed
9797 }
@@ -111,7 +111,7 @@ func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {
111111 }
112112
113113 func TestRetryDialPermanentFailure(t *testing.T) {
114- dial := func(string) (ports.Transport, error) { return nil, errDialFailed }
114+ dial := func(context.Context, string) (ports.Transport, error) { return nil, errDialFailed }
115115 _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff)
116116 if !errors.Is(err, ErrDaemonUnreachable) {
117117 t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err)
@@ -121,18 +121,65 @@ func TestRetryDialPermanentFailure(t *testing.T) {
121121 func TestRetryDialContextCancelled(t *testing.T) {
122122 ctx, cancel := context.WithCancel(context.Background())
123123 cancel()
124- dial := func(string) (ports.Transport, error) { return nil, errDialFailed }
124+ var calls atomic.Int32
125+ dial := func(context.Context, string) (ports.Transport, error) {
126+ calls.Add(1)
127+ return nil, errDialFailed
128+ }
125129 _, err := retryDial(ctx, t.TempDir(), dial, fastBackoff)
126130 if !errors.Is(err, context.Canceled) {
127131 t.Fatalf("retryDial error = %v, want context.Canceled", err)
128132 }
133+ if calls.Load() != 0 {
134+ t.Fatalf("dial called %d times for canceled context, want 0", calls.Load())
135+ }
136+}
137+
138+func TestEnsureDaemonContextCancelledDoesNotDialOrSpawn(t *testing.T) {
139+ ctx, cancel := context.WithCancel(context.Background())
140+ cancel()
141+ dial := func(context.Context, string) (ports.Transport, error) {
142+ t.Fatal("ensureDaemon dialed with a canceled context")
143+ return nil, nil
144+ }
145+ spawn := func() error {
146+ t.Fatal("ensureDaemon spawned with a canceled context")
147+ return nil
148+ }
149+
150+ _, err := ensureDaemon(ctx, t.TempDir(), dial, spawn, fastBackoff)
151+ if !errors.Is(err, context.Canceled) {
152+ t.Fatalf("ensureDaemon error = %v, want context.Canceled", err)
153+ }
154+}
155+
156+func TestEnsureDaemonDoesNotSpawnWhenContextCancelsAfterFailedDial(t *testing.T) {
157+ ctx, cancel := context.WithCancel(context.Background())
158+ defer cancel()
159+ var spawnCalls atomic.Int32
160+ dial := func(context.Context, string) (ports.Transport, error) {
161+ cancel()
162+ return nil, errDialFailed
163+ }
164+ spawn := func() error {
165+ spawnCalls.Add(1)
166+ return nil
167+ }
168+
169+ _, err := ensureDaemon(ctx, filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff)
170+ if !errors.Is(err, context.Canceled) {
171+ t.Fatalf("ensureDaemon error = %v, want context.Canceled", err)
172+ }
173+ if got := spawnCalls.Load(); got != 0 {
174+ t.Fatalf("spawn called %d times after canceled failed dial, want 0", got)
175+ }
129176 }
130177
131178 func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
132179 dir := filepath.Join(t.TempDir(), "vev")
133180 want := portsmocks.NewMockTransport(t)
134181 var dialCalls, spawnCalls atomic.Int32
135- dial := func(string) (ports.Transport, error) {
182+ dial := func(context.Context, string) (ports.Transport, error) {
136183 // First (pre-spawn) dial fails; the post-spawn retry succeeds.
137184 if dialCalls.Add(1) == 1 {
138185 return nil, errDialFailed
@@ -163,7 +210,7 @@ func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
163210 func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {
164211 want := portsmocks.NewMockTransport(t)
165212 var spawnCalls atomic.Int32
166- dial := func(string) (ports.Transport, error) { return want, nil }
213+ dial := func(context.Context, string) (ports.Transport, error) { return want, nil }
167214 spawn := func() error { spawnCalls.Add(1); return nil }
168215
169216 got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff)
@@ -180,7 +227,7 @@ func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {
180227
181228 func TestEnsureDaemonSpawnFailure(t *testing.T) {
182229 spawnErr := errors.New("exec boom")
183- dial := func(string) (ports.Transport, error) { return nil, errDialFailed }
230+ dial := func(context.Context, string) (ports.Transport, error) { return nil, errDialFailed }
184231 spawn := func() error { return spawnErr }
185232
186233 _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff)
A internal/app/transport_observability_test.go +82 -0
@@ -0,0 +1,82 @@
1+package app
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/ports"
13+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+)
15+
16+func TestPerformanceTraceUsesHarnessProcessMapping(t *testing.T) {
17+ path := filepath.Join(t.TempDir(), "process.jsonl")
18+ t.Setenv("VEV_PERF_TRACE", path)
19+ t.Setenv("VEV_PERF_PROCESS_ID", "idle-local-r001-daemon-01")
20+ t.Setenv("VEV_PERF_SCENARIO", "1x4-idle-local")
21+ t.Setenv("VEV_PERF_RUN", "1")
22+
23+ clock := portsmocks.NewMockClock(t)
24+ clock.EXPECT().Now().Return(time.Unix(0, 1)).Once()
25+ observer, closer, err := performanceTrace(clock)
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ if observer == nil || closer == nil {
30+ t.Fatal("performance trace was not configured")
31+ }
32+ if _, ok := observer.(ports.SerializedRuntimeObserver); !ok {
33+ t.Fatalf("performance trace observer = %T, want serialized owner", observer)
34+ }
35+ observer.ObserveRuntime(ports.RuntimeMark{Schema: ports.RuntimeMarkSchema, Component: "daemon", Scenario: "runtime", Run: 1, Kind: ports.RuntimeEmitEnd, Valid: true})
36+ if err := closer.Close(); err != nil {
37+ t.Fatal(err)
38+ }
39+ if err := closer.Close(); err != nil {
40+ t.Fatal(err)
41+ }
42+ f, err := os.Open(path)
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ defer f.Close()
47+ var mark struct {
48+ ProcessID string `json:"process_id"`
49+ Scenario string `json:"scenario"`
50+ Run uint64 `json:"run"`
51+ Sequence uint64 `json:"sequence"`
52+ RequestID uint64 `json:"request_id"`
53+ Epoch uint64 `json:"epoch"`
54+ }
55+ if err := json.NewDecoder(bufio.NewReader(f)).Decode(&mark); err != nil {
56+ t.Fatal(err)
57+ }
58+ if mark.ProcessID != "idle-local-r001-daemon-01" || mark.Scenario != "1x4-idle-local" || mark.Run != 1 || mark.Sequence == 0 || mark.RequestID == 0 || mark.Epoch == 0 {
59+ t.Fatalf("mark does not match harness mapping: %+v", mark)
60+ }
61+}
62+
63+func TestPerformanceTraceAppWiresOneObserverPerProcess(t *testing.T) {
64+ source, err := os.ReadFile("run.go")
65+ if err != nil {
66+ t.Fatalf("ReadFile(run.go): %v", err)
67+ }
68+ text := string(source)
69+ for _, required := range []string{
70+ "VEV_PERF_TRACE",
71+ "VEV_PERF_PROCESS_ID",
72+ "observability.NewJSONL",
73+ "WithRuntimeObserver",
74+ } {
75+ if !strings.Contains(text, required) {
76+ t.Errorf("runtime trace wiring missing %q", required)
77+ }
78+ }
79+ if strings.Contains(text, "WithRuntimeObserver(clock") || strings.Contains(text, "WithRuntimeObserver(clk") {
80+ t.Error("consumer observer configuration must not accept a clock")
81+ }
82+}
M internal/ports/mocks/mocks.go +67 -0
@@ -3129,3 +3129,70 @@ func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(ta
31293129 _c.Call.Return(run)
31303130 return _c
31313131 }
3132+
3133+// NewMockRuntimeObserver creates a new instance of MockRuntimeObserver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
3134+// The first argument is typically a *testing.T value.
3135+func NewMockRuntimeObserver(t interface {
3136+ mock.TestingT
3137+ Cleanup(func())
3138+}) *MockRuntimeObserver {
3139+ mock := &MockRuntimeObserver{}
3140+ mock.Mock.Test(t)
3141+
3142+ t.Cleanup(func() { mock.AssertExpectations(t) })
3143+
3144+ return mock
3145+}
3146+
3147+// MockRuntimeObserver is an autogenerated mock type for the RuntimeObserver type
3148+type MockRuntimeObserver struct {
3149+ mock.Mock
3150+}
3151+
3152+type MockRuntimeObserver_Expecter struct {
3153+ mock *mock.Mock
3154+}
3155+
3156+func (_m *MockRuntimeObserver) EXPECT() *MockRuntimeObserver_Expecter {
3157+ return &MockRuntimeObserver_Expecter{mock: &_m.Mock}
3158+}
3159+
3160+// ObserveRuntime provides a mock function for the type MockRuntimeObserver
3161+func (_mock *MockRuntimeObserver) ObserveRuntime(runtimeMark ports.RuntimeMark) {
3162+ _mock.Called(runtimeMark)
3163+ return
3164+}
3165+
3166+// MockRuntimeObserver_ObserveRuntime_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ObserveRuntime'
3167+type MockRuntimeObserver_ObserveRuntime_Call struct {
3168+ *mock.Call
3169+}
3170+
3171+// ObserveRuntime is a helper method to define mock.On call
3172+// - runtimeMark ports.RuntimeMark
3173+func (_e *MockRuntimeObserver_Expecter) ObserveRuntime(runtimeMark any) *MockRuntimeObserver_ObserveRuntime_Call {
3174+ return &MockRuntimeObserver_ObserveRuntime_Call{Call: _e.mock.On("ObserveRuntime", runtimeMark)}
3175+}
3176+
3177+func (_c *MockRuntimeObserver_ObserveRuntime_Call) Run(run func(runtimeMark ports.RuntimeMark)) *MockRuntimeObserver_ObserveRuntime_Call {
3178+ _c.Call.Run(func(args mock.Arguments) {
3179+ var arg0 ports.RuntimeMark
3180+ if args[0] != nil {
3181+ arg0 = args[0].(ports.RuntimeMark)
3182+ }
3183+ run(
3184+ arg0,
3185+ )
3186+ })
3187+ return _c
3188+}
3189+
3190+func (_c *MockRuntimeObserver_ObserveRuntime_Call) Return() *MockRuntimeObserver_ObserveRuntime_Call {
3191+ _c.Call.Return()
3192+ return _c
3193+}
3194+
3195+func (_c *MockRuntimeObserver_ObserveRuntime_Call) RunAndReturn(run func(runtimeMark ports.RuntimeMark)) *MockRuntimeObserver_ObserveRuntime_Call {
3196+ _c.Run(run)
3197+ return _c
3198+}
A internal/ports/runtime_observer.go +297 -0
@@ -0,0 +1,297 @@
1+package ports
2+
3+import (
4+ "errors"
5+ "sync"
6+ "sync/atomic"
7+)
8+
9+// RuntimeMarkSchema is the version of the process-local performance trace.
10+const RuntimeMarkSchema uint16 = 1
11+
12+// RuntimeMarkKind identifies a process-local runtime boundary. It deliberately
13+// contains no protocol payload, cells, or rendering policy.
14+type RuntimeMarkKind string
15+
16+const (
17+ RuntimeInputInjected RuntimeMarkKind = "input_injected"
18+ RuntimeResizeRequested RuntimeMarkKind = "resize_requested"
19+ RuntimeResizeCommitted RuntimeMarkKind = "resize_committed"
20+ RuntimeCaptureStart RuntimeMarkKind = "capture_start"
21+ RuntimeCaptureEnd RuntimeMarkKind = "capture_end"
22+ RuntimeComposeStart RuntimeMarkKind = "compose_start"
23+ RuntimeComposeEnd RuntimeMarkKind = "compose_end"
24+ RuntimeDiffStart RuntimeMarkKind = "diff_start"
25+ RuntimeDiffEnd RuntimeMarkKind = "diff_end"
26+ RuntimeQueueEnqueued RuntimeMarkKind = "queue_enqueued"
27+ RuntimeQueueDequeued RuntimeMarkKind = "queue_dequeued"
28+ RuntimeACKBlockedStart RuntimeMarkKind = "ack_blocked_start"
29+ RuntimeACKBlockedEnd RuntimeMarkKind = "ack_blocked_end"
30+ RuntimeEmitStart RuntimeMarkKind = "emit_start"
31+ RuntimeEmitEnd RuntimeMarkKind = "emit_end"
32+ RuntimeAdapterSendStart RuntimeMarkKind = "adapter_send_start"
33+ RuntimeAdapterSendEnd RuntimeMarkKind = "adapter_send_end"
34+ RuntimeAdapterReceiveStart RuntimeMarkKind = "adapter_receive_start"
35+ RuntimeAdapterReceiveEnd RuntimeMarkKind = "adapter_receive_end"
36+ RuntimeTerminalFlushed RuntimeMarkKind = "terminal_flushed"
37+ RuntimeTransportDiagnostic RuntimeMarkKind = "transport_diagnostic"
38+)
39+
40+// RuntimeMark is a process-local observation record. Tick is intentionally
41+// left zero by producers: the concrete process observer owns timestamping.
42+type RuntimeMark struct {
43+ Schema uint16
44+ ProcessID string
45+ Component string
46+ Scenario string
47+ Run uint64
48+ Sequence uint64
49+ RequestID uint64
50+ Epoch uint64
51+ Kind RuntimeMarkKind
52+ Tick int64
53+ Bytes uint64
54+ Fragments uint64
55+ Retransmits uint64
56+ Pending uint64
57+ AckRTTNanos int64
58+ Valid bool
59+}
60+
61+// RuntimeObserver receives process-local marks. Implementations must not let
62+// a producer provide a timestamp in another clock domain.
63+type RuntimeObserver interface{ ObserveRuntime(RuntimeMark) }
64+
65+// SerializedRuntimeObserver accepts marks without waiting for observer I/O.
66+// Flush and Close wait until every accepted mark has reached Observer. Close
67+// is idempotent and rejects later marks.
68+type SerializedRuntimeObserver interface {
69+ RuntimeObserver
70+ Flush()
71+ Close()
72+}
73+
74+// NewSerializedRuntimeObserver isolates producers from a potentially slow
75+// observer with one ordered worker. capacity bounds queued marks. If the queue
76+// fills, dropped marks are represented by one ordered diagnostic mark before
77+// reporting resumes, so a trace can never silently claim completeness.
78+func NewSerializedRuntimeObserver(observer RuntimeObserver, capacity int) SerializedRuntimeObserver {
79+ if observer == nil {
80+ return nil
81+ }
82+ if capacity < 1 {
83+ capacity = 1
84+ }
85+ o := &serializedRuntimeObserver{observer: observer, capacity: capacity, done: make(chan struct{})}
86+ o.cond = sync.NewCond(&o.mu)
87+ go o.run()
88+ return o
89+}
90+
91+// EnsureSerializedRuntimeObserver returns observer's existing serialized
92+// owner when it has one. Otherwise it creates an owned worker. Callers close
93+// the result only when owned is true, preventing nested workers and duplicate
94+// lifecycle ownership when application wiring already serialized the process
95+// observer.
96+func EnsureSerializedRuntimeObserver(observer RuntimeObserver, capacity int) (serialized SerializedRuntimeObserver, owned bool) {
97+ if observer == nil {
98+ return nil, false
99+ }
100+ if serialized, ok := observer.(SerializedRuntimeObserver); ok {
101+ return serialized, false
102+ }
103+ return NewSerializedRuntimeObserver(observer, capacity), true
104+}
105+
106+type serializedRuntimeObserver struct {
107+ observer RuntimeObserver
108+ capacity int
109+
110+ mu sync.Mutex
111+ cond *sync.Cond
112+ marks []RuntimeMark
113+ dropped uint64
114+ closed bool
115+ active bool
116+ done chan struct{}
117+}
118+
119+func (o *serializedRuntimeObserver) ObserveRuntime(mark RuntimeMark) {
120+ if o == nil {
121+ return
122+ }
123+ o.mu.Lock()
124+ defer o.mu.Unlock()
125+ if o.closed {
126+ return
127+ }
128+ if o.dropped != 0 {
129+ // Reserve space for both the gap marker and the first post-gap mark.
130+ // This preserves the producer order around a bounded-queue loss.
131+ if len(o.marks)+2 > o.capacity {
132+ o.dropped++
133+ return
134+ }
135+ o.marks = append(o.marks, runtimeObserverGapMark(o.dropped), mark)
136+ o.dropped = 0
137+ o.cond.Signal()
138+ return
139+ }
140+ if len(o.marks) == o.capacity {
141+ o.dropped = 1
142+ return
143+ }
144+ o.marks = append(o.marks, mark)
145+ o.cond.Signal()
146+}
147+
148+func runtimeObserverGapMark(dropped uint64) RuntimeMark {
149+ return NewRuntimeMark("observer", RuntimeTransportDiagnostic, dropped, false)
150+}
151+
152+func (o *serializedRuntimeObserver) Flush() {
153+ if o == nil {
154+ return
155+ }
156+ o.mu.Lock()
157+ for len(o.marks) != 0 || o.active || o.dropped != 0 {
158+ o.cond.Signal()
159+ o.cond.Wait()
160+ }
161+ closed := o.closed
162+ o.mu.Unlock()
163+ if closed {
164+ <-o.done
165+ }
166+}
167+
168+func (o *serializedRuntimeObserver) Close() {
169+ if o == nil {
170+ return
171+ }
172+ o.mu.Lock()
173+ if !o.closed {
174+ o.closed = true
175+ o.cond.Broadcast()
176+ }
177+ o.mu.Unlock()
178+ <-o.done
179+}
180+
181+func (o *serializedRuntimeObserver) run() {
182+ defer close(o.done)
183+ for {
184+ o.mu.Lock()
185+ for len(o.marks) == 0 && o.dropped == 0 && !o.closed {
186+ o.cond.Wait()
187+ }
188+ if len(o.marks) == 0 && o.dropped != 0 {
189+ o.marks = append(o.marks, runtimeObserverGapMark(o.dropped))
190+ o.dropped = 0
191+ }
192+ if len(o.marks) == 0 && o.closed {
193+ o.cond.Broadcast()
194+ o.mu.Unlock()
195+ return
196+ }
197+ mark := o.marks[0]
198+ o.marks = o.marks[1:]
199+ o.active = true
200+ o.mu.Unlock()
201+
202+ o.observer.ObserveRuntime(mark)
203+
204+ o.mu.Lock()
205+ o.active = false
206+ o.cond.Broadcast()
207+ o.mu.Unlock()
208+ }
209+}
210+
211+// RuntimeMarkValid reports whether a mark has all correlation fields required
212+// by the JSONL schema. Tick is checked separately by the timestamp owner.
213+func RuntimeMarkValid(mark RuntimeMark) bool {
214+ if mark.Schema != RuntimeMarkSchema || mark.Component == "" || mark.Scenario == "" || mark.Run == 0 || mark.Sequence == 0 || mark.RequestID == 0 || mark.Epoch == 0 {
215+ return false
216+ }
217+ switch mark.Kind {
218+ case RuntimeInputInjected, RuntimeResizeRequested, RuntimeResizeCommitted,
219+ RuntimeCaptureStart, RuntimeCaptureEnd, RuntimeComposeStart, RuntimeComposeEnd,
220+ RuntimeDiffStart, RuntimeDiffEnd, RuntimeQueueEnqueued, RuntimeQueueDequeued,
221+ RuntimeACKBlockedStart, RuntimeACKBlockedEnd, RuntimeEmitStart, RuntimeEmitEnd,
222+ RuntimeAdapterSendStart, RuntimeAdapterSendEnd, RuntimeAdapterReceiveStart,
223+ RuntimeAdapterReceiveEnd, RuntimeTerminalFlushed, RuntimeTransportDiagnostic:
224+ return true
225+ default:
226+ return false
227+ }
228+}
229+
230+// RuntimeCorrelation identifies one attempted operation. It is deliberately
231+// copied to both endpoints of a span; process ID remains owned by the sink.
232+type RuntimeCorrelation struct {
233+ Scenario string
234+ Run, Sequence, RequestID, Epoch uint64
235+}
236+
237+// RuntimeCorrelationInputs are supplied once by process orchestration. They
238+// are deliberately data-only: consumer APIs continue to receive only their
239+// one RuntimeObserver argument and never a clock or tick.
240+type RuntimeCorrelationInputs struct {
241+ Scenario string
242+ Run uint64
243+}
244+
245+// NewRuntimeCorrelationObserver binds every mark from one OS process to its
246+// harness manifest scenario/run. Operation IDs remain producer-owned when
247+// supplied; missing IDs are assigned from the process-wide allocator.
248+func NewRuntimeCorrelationObserver(observer RuntimeObserver, inputs RuntimeCorrelationInputs) (RuntimeObserver, error) {
249+ if observer == nil || inputs.Scenario == "" || inputs.Run == 0 {
250+ return nil, errors.New("invalid runtime correlation inputs")
251+ }
252+ return &runtimeCorrelationObserver{observer: observer, inputs: inputs}, nil
253+}
254+
255+type runtimeCorrelationObserver struct {
256+ observer RuntimeObserver
257+ inputs RuntimeCorrelationInputs
258+}
259+
260+func (o *runtimeCorrelationObserver) ObserveRuntime(mark RuntimeMark) {
261+ if o == nil || o.observer == nil {
262+ return
263+ }
264+ mark.Scenario, mark.Run = o.inputs.Scenario, o.inputs.Run
265+ if mark.Sequence == 0 {
266+ mark.Sequence = runtimeMarkSequence.Add(1)
267+ }
268+ if mark.RequestID == 0 {
269+ mark.RequestID = mark.Sequence
270+ }
271+ if mark.Epoch == 0 {
272+ mark.Epoch = mark.RequestID
273+ }
274+ o.observer.ObserveRuntime(mark)
275+}
276+
277+var runtimeMarkSequence atomic.Uint64
278+
279+// NewRuntimeCorrelation makes a unique, process-local operation identity.
280+// Components with a domain request/epoch should use those values directly via
281+// NewRuntimeMarkWithCorrelation instead of inventing a second identity.
282+func NewRuntimeCorrelation() RuntimeCorrelation {
283+ sequence := runtimeMarkSequence.Add(1)
284+ return RuntimeCorrelation{Scenario: "runtime", Run: 1, Sequence: sequence, RequestID: sequence, Epoch: sequence}
285+}
286+
287+// NewRuntimeMarkWithCorrelation returns a timestamp-free mark carrying the
288+// supplied operation identity. Start/end callers must reuse correlation.
289+func NewRuntimeMarkWithCorrelation(component string, correlation RuntimeCorrelation, kind RuntimeMarkKind, bytes uint64, valid bool) RuntimeMark {
290+ return RuntimeMark{Schema: RuntimeMarkSchema, Component: component, Scenario: correlation.Scenario, Run: correlation.Run, Sequence: correlation.Sequence, RequestID: correlation.RequestID, Epoch: correlation.Epoch, Kind: kind, Bytes: bytes, Valid: valid}
291+}
292+
293+// NewRuntimeMark is retained for one-shot marks. Unlike the old helper it
294+// never returns constant correlation IDs.
295+func NewRuntimeMark(component string, kind RuntimeMarkKind, bytes uint64, valid bool) RuntimeMark {
296+ return NewRuntimeMarkWithCorrelation(component, NewRuntimeCorrelation(), kind, bytes, valid)
297+}
A internal/ports/runtime_observer_test.go +204 -0
@@ -0,0 +1,204 @@
1+package ports
2+
3+import (
4+ "reflect"
5+ "sync"
6+ "testing"
7+ "time"
8+)
9+
10+func TestRuntimeObserverContract(t *testing.T) {
11+ wantFields := []struct {
12+ name string
13+ typ reflect.Type
14+ }{
15+ {"Schema", reflect.TypeFor[uint16]()},
16+ {"ProcessID", reflect.TypeFor[string]()},
17+ {"Component", reflect.TypeFor[string]()},
18+ {"Scenario", reflect.TypeFor[string]()},
19+ {"Run", reflect.TypeFor[uint64]()},
20+ {"Sequence", reflect.TypeFor[uint64]()},
21+ {"RequestID", reflect.TypeFor[uint64]()},
22+ {"Epoch", reflect.TypeFor[uint64]()},
23+ {"Kind", reflect.TypeFor[RuntimeMarkKind]()},
24+ {"Tick", reflect.TypeFor[int64]()},
25+ {"Bytes", reflect.TypeFor[uint64]()},
26+ {"Fragments", reflect.TypeFor[uint64]()},
27+ {"Retransmits", reflect.TypeFor[uint64]()},
28+ {"Pending", reflect.TypeFor[uint64]()},
29+ {"AckRTTNanos", reflect.TypeFor[int64]()},
30+ {"Valid", reflect.TypeFor[bool]()},
31+ }
32+
33+ typ := reflect.TypeFor[RuntimeMark]()
34+ if typ.NumField() != len(wantFields) {
35+ t.Fatalf("RuntimeMark has %d fields, want exactly %d", typ.NumField(), len(wantFields))
36+ }
37+ for i, want := range wantFields {
38+ field := typ.Field(i)
39+ if field.Name != want.name || field.Type != want.typ {
40+ t.Fatalf("RuntimeMark field %d = %s %v, want %s %v", i, field.Name, field.Type, want.name, want.typ)
41+ }
42+ }
43+
44+ var _ RuntimeObserver = runtimeObserverFunc(func(RuntimeMark) {})
45+}
46+
47+func TestSerializedRuntimeObserverFlushesInOrderAndCloses(t *testing.T) {
48+ var mu sync.Mutex
49+ var got []RuntimeMark
50+ observer := NewSerializedRuntimeObserver(runtimeObserverFunc(func(mark RuntimeMark) {
51+ mu.Lock()
52+ got = append(got, mark)
53+ mu.Unlock()
54+ }), 2)
55+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitStart, 1, true))
56+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitEnd, 1, true))
57+ observer.Flush()
58+ observer.Close()
59+ observer.Close()
60+
61+ mu.Lock()
62+ defer mu.Unlock()
63+ if len(got) != 2 || got[0].Kind != RuntimeEmitStart || got[1].Kind != RuntimeEmitEnd {
64+ t.Fatalf("serialized marks = %#v", got)
65+ }
66+}
67+
68+func TestEnsureSerializedRuntimeObserverReusesExistingOwner(t *testing.T) {
69+ existing := NewSerializedRuntimeObserver(runtimeObserverFunc(func(RuntimeMark) {}), 1)
70+ reporter, owned := EnsureSerializedRuntimeObserver(existing, 1)
71+ if owned || reporter != existing {
72+ t.Fatalf("EnsureSerializedRuntimeObserver() = (%T, %t), want existing unowned reporter", reporter, owned)
73+ }
74+ existing.Close()
75+}
76+
77+func TestSerializedRuntimeObserverReportsBoundedQueueLoss(t *testing.T) {
78+ entered := make(chan struct{})
79+ release := make(chan struct{})
80+ var mu sync.Mutex
81+ var got []RuntimeMark
82+ observer := NewSerializedRuntimeObserver(runtimeObserverFunc(func(mark RuntimeMark) {
83+ if mark.Kind == RuntimeEmitStart {
84+ close(entered)
85+ <-release
86+ }
87+ mu.Lock()
88+ got = append(got, mark)
89+ mu.Unlock()
90+ }), 2)
91+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitStart, 1, true))
92+ select {
93+ case <-entered:
94+ case <-time.After(time.Second):
95+ t.Fatal("observer did not start")
96+ }
97+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeComposeStart, 1, true))
98+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeComposeEnd, 1, true))
99+ observer.ObserveRuntime(NewRuntimeMark("client", RuntimeEmitEnd, 1, true)) // bounded loss
100+ close(release)
101+ observer.Flush()
102+ observer.Close()
103+
104+ mu.Lock()
105+ defer mu.Unlock()
106+ if len(got) != 4 || got[0].Kind != RuntimeEmitStart || got[1].Kind != RuntimeComposeStart || got[2].Kind != RuntimeComposeEnd || got[3].Kind != RuntimeTransportDiagnostic || got[3].Bytes != 1 || got[3].Valid {
107+ t.Fatalf("bounded-loss marks = %#v", got)
108+ }
109+}
110+
111+func TestRuntimeObserverRequiredSpanKinds(t *testing.T) {
112+ pairs := []struct {
113+ start RuntimeMarkKind
114+ end RuntimeMarkKind
115+ }{
116+ {RuntimeDiffStart, RuntimeDiffEnd},
117+ {RuntimeQueueEnqueued, RuntimeQueueDequeued},
118+ {RuntimeACKBlockedStart, RuntimeACKBlockedEnd},
119+ {RuntimeAdapterSendStart, RuntimeAdapterSendEnd},
120+ {RuntimeAdapterReceiveStart, RuntimeAdapterReceiveEnd},
121+ }
122+ for _, pair := range pairs {
123+ if pair.start == "" || pair.end == "" || pair.start == pair.end {
124+ t.Fatalf("invalid required span mapping %#v", pair)
125+ }
126+ }
127+}
128+
129+func TestRuntimeCorrelationObserverUsesHarnessProcessInputs(t *testing.T) {
130+ var got []RuntimeMark
131+ observer, err := NewRuntimeCorrelationObserver(runtimeObserverFunc(func(mark RuntimeMark) {
132+ got = append(got, mark)
133+ }), RuntimeCorrelationInputs{Scenario: "1x4-idle-local", Run: 7})
134+ if err != nil {
135+ t.Fatal(err)
136+ }
137+
138+ observer.ObserveRuntime(RuntimeMark{Schema: RuntimeMarkSchema, Component: "daemon", Scenario: "runtime", Run: 1, Kind: RuntimeEmitEnd, Valid: true})
139+ observer.ObserveRuntime(RuntimeMark{Schema: RuntimeMarkSchema, Component: "daemon", Scenario: "runtime", Run: 1, Sequence: 9, RequestID: 10, Epoch: 11, Kind: RuntimeEmitStart, Valid: true})
140+
141+ if len(got) != 2 {
142+ t.Fatalf("marks=%+v", got)
143+ }
144+ for _, mark := range got {
145+ if mark.Scenario != "1x4-idle-local" || mark.Run != 7 {
146+ t.Fatalf("harness correlation was not applied: %+v", mark)
147+ }
148+ if mark.Sequence == 0 || mark.RequestID == 0 || mark.Epoch == 0 {
149+ t.Fatalf("missing deterministic operation identity: %+v", mark)
150+ }
151+ }
152+ if got[1].Sequence != 9 || got[1].RequestID != 10 || got[1].Epoch != 11 {
153+ t.Fatalf("explicit operation identity changed: %+v", got[1])
154+ }
155+}
156+
157+func TestRuntimeCorrelationObserverAllocatesMissingIDsProcessWide(t *testing.T) {
158+ var got []RuntimeMark
159+ collect := runtimeObserverFunc(func(mark RuntimeMark) { got = append(got, mark) })
160+ first, err := NewRuntimeCorrelationObserver(collect, RuntimeCorrelationInputs{Scenario: "scenario", Run: 1})
161+ if err != nil {
162+ t.Fatal(err)
163+ }
164+ second, err := NewRuntimeCorrelationObserver(collect, RuntimeCorrelationInputs{Scenario: "scenario", Run: 1})
165+ if err != nil {
166+ t.Fatal(err)
167+ }
168+
169+ first.ObserveRuntime(RuntimeMark{Schema: RuntimeMarkSchema, Component: "first", Kind: RuntimeEmitStart, Valid: true})
170+ second.ObserveRuntime(RuntimeMark{Schema: RuntimeMarkSchema, Component: "second", Kind: RuntimeEmitEnd, Valid: true})
171+
172+ if len(got) != 2 {
173+ t.Fatalf("marks=%+v", got)
174+ }
175+ if got[0].Sequence == got[1].Sequence {
176+ t.Fatalf("wrapper-local sequence collision: %+v", got)
177+ }
178+ for _, mark := range got {
179+ if mark.RequestID != mark.Sequence || mark.Epoch != mark.RequestID {
180+ t.Fatalf("missing-ID fallback changed: %+v", mark)
181+ }
182+ }
183+}
184+
185+func TestRuntimeCorrelationObserverRejectsInvalidHarnessInputs(t *testing.T) {
186+ for _, in := range []RuntimeCorrelationInputs{{}, {Scenario: "scenario"}} {
187+ if _, err := NewRuntimeCorrelationObserver(runtimeObserverFunc(func(RuntimeMark) {}), in); err == nil {
188+ t.Fatalf("invalid inputs accepted: %+v", in)
189+ }
190+ }
191+}
192+
193+func TestRuntimeMarkHasNoRenderingPayloadOrPolicyFields(t *testing.T) {
194+ typ := reflect.TypeFor[RuntimeMark]()
195+ for _, forbidden := range []string{"Cell", "Cells", "Payload", "Frame", "Renderer", "Policy", "Transport"} {
196+ if _, ok := typ.FieldByName(forbidden); ok {
197+ t.Fatalf("RuntimeMark must not carry %s", forbidden)
198+ }
199+ }
200+}
201+
202+type runtimeObserverFunc func(RuntimeMark)
203+
204+func (f runtimeObserverFunc) ObserveRuntime(mark RuntimeMark) { f(mark) }
M internal/usecase/client/client.go +52 -5
@@ -102,6 +102,10 @@ const stdinBufSize = 4096
102102 // goroutine, decoupling the input/resize pumps from transport back-pressure.
103103 const sendQueueDepth = 64
104104
105+// runtimeObserverQueueDepth bounds marks awaiting the one reporting worker.
106+// A full queue records an ordered diagnostic gap rather than blocking ACKs.
107+const runtimeObserverQueueDepth = 64
108+
105109 var reconnectSleep = sleepReconnect
106110 var reconnectSleepWithResize = sleepReconnectWithResizeEvents
107111
@@ -120,7 +124,36 @@ const (
120124 // ClipboardReader configured (clipboard non-nil) — local attaches forward
121125 // 0x16 untouched so the locally running agent's own clipboard handling
122126 // applies.
123-func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) (retErr error) {
127+// Option configures opt-in client runtime observation.
128+type Option func(*runtimeOptions)
129+
130+type runtimeOptions struct{ observer ports.RuntimeObserver }
131+
132+// WithRuntimeObserver enables process-local marks. It intentionally accepts no
133+// clock; timestamps are assigned by the concrete observer.
134+func WithRuntimeObserver(observer ports.RuntimeObserver) Option {
135+ return func(opts *runtimeOptions) { opts.observer = observer }
136+}
137+
138+func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) error {
139+ return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, nil)
140+}
141+
142+// RunWithRuntimeObserver is the application wiring entry point.
143+func RunWithRuntimeObserver(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.RuntimeObserver) error {
144+ return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, observer)
145+}
146+
147+func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.RuntimeObserver) (retErr error) {
148+ if reporter, owned := ports.EnsureSerializedRuntimeObserver(observer, runtimeObserverQueueDepth); reporter != nil {
149+ if owned {
150+ defer func() {
151+ reporter.Flush()
152+ reporter.Close()
153+ }()
154+ }
155+ observer = reporter
156+ }
124157 if log == nil {
125158 log = slog.Default()
126159 }
@@ -244,7 +277,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
244277 if reporter, ok := transport.(ports.LinkStateReporter); ok {
245278 linkEvents = reporter.LinkEvents()
246279 }
247- result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log)
280+ result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log, observer)
248281 if result.welcomed {
249282 backoff = defaultReconnectBackoff.initial
250283 }
@@ -311,8 +344,14 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
311344 // disappears, or the context is cancelled.
312345 //
313346 // It is kept as a compatibility wrapper for callers that still own dialing.
314-func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {
315- return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default())
347+func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, options ...Option) error {
348+ var opts runtimeOptions
349+ for _, option := range options {
350+ if option != nil {
351+ option(&opts)
352+ }
353+ }
354+ return run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default(), opts.observer)
316355 }
317356
318357 type singleTransportDialer struct{ transport ports.Transport }
@@ -346,7 +385,11 @@ func requestedOutputWindow(transport ports.Transport) uint8 {
346385 return 8
347386 }
348387
349-func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), drawStatusStage func(reconnectStage), linkEvents <-chan ports.LinkEvent, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) attachResult {
388+func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), drawStatusStage func(reconnectStage), linkEvents <-chan ports.LinkEvent, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observers ...ports.RuntimeObserver) attachResult {
389+ var observer ports.RuntimeObserver
390+ if len(observers) != 0 {
391+ observer = observers[0]
392+ }
350393 // 1. Handshake: send Hello with our size and TERM.
351394 size, err := term.Size()
352395 if err != nil {
@@ -501,6 +544,10 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
501544 if ferr := term.Flush(); ferr != nil {
502545 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
503546 }
547+ // The terminal boundary is after a successful flush and before ACK.
548+ if observer != nil {
549+ observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true))
550+ }
504551 if o.NewStateNum != 0 {
505552 ackQueue.offer(o.NewStateNum)
506553 }
A internal/usecase/client/transport_observability_test.go +140 -0
@@ -0,0 +1,140 @@
1+package client_test
2+
3+import (
4+ "bytes"
5+ "context"
6+ "sync"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+ "github.com/bnema/vev/internal/usecase/client"
17+)
18+
19+func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
20+ var out bytes.Buffer
21+ var restores atomic.Int32
22+ resizeCh := make(chan domain.Size)
23+ flushed := make(chan struct{})
24+ term := portsmocks.NewMockTerminal(t)
25+ term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
26+ term.EXPECT().EnterRaw().Return(func() error { restores.Add(1); return nil }, nil).Once()
27+ in := newBlockingReader()
28+ defer in.unblock()
29+ term.EXPECT().In().Return(in).Maybe()
30+ term.EXPECT().Out().Return(&out).Maybe()
31+ term.EXPECT().Flush().Run(func() { close(flushed) }).Return(nil).Once()
32+ term.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
33+
34+ observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})}
35+ acked := make(chan struct{})
36+ transport := portsmocks.NewMockTransport(t)
37+ transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
38+ transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once()
39+ unblock := scriptRecv(transport,
40+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
41+ recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("flush-before-observe"), NewStateNum: 3}))},
42+ )
43+ defer unblock()
44+ transport.EXPECT().Close().Return(nil).Once()
45+
46+ ctx, cancel := context.WithCancel(context.Background())
47+ result := make(chan error, 1)
48+ go func() {
49+ result <- client.Attach(ctx, transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(observer))
50+ }()
51+ awaitClientRuntime(t, observer.entered, "blocking observer")
52+ awaitClientRuntime(t, flushed, "terminal flush")
53+ awaitClientRuntime(t, acked, "ACK")
54+ cancel()
55+ close(observer.release)
56+ select {
57+ case err := <-result:
58+ require.NoError(t, err)
59+ case <-time.After(time.Second):
60+ t.Fatal("Attach did not complete after observer release")
61+ }
62+ require.Equal(t, "flush-before-observe", out.String())
63+ require.Equal(t, int32(1), restores.Load())
64+}
65+
66+func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
67+ var out bytes.Buffer
68+ var restores atomic.Int32
69+ resizeCh := make(chan domain.Size)
70+ term, in := newHappyTerminal(t, &out, &restores, resizeCh)
71+ defer in.unblock()
72+
73+ observer := &clientRuntimeObserver{}
74+ transport := portsmocks.NewMockTransport(t)
75+ transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
76+ unblock := scriptRecv(transport,
77+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
78+ recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("unchanged-by-observer"), NewStateNum: 3}))},
79+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
80+ )
81+ defer unblock()
82+ transport.EXPECT().Send(isType(ports.MsgAck)).Return(nil).Maybe()
83+ transport.EXPECT().Close().Return(nil).Once()
84+
85+ err := client.Attach(context.Background(), transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(observer))
86+ require.NoError(t, err)
87+ require.Equal(t, "unchanged-by-observer", out.String(), "observer must not alter terminal bytes")
88+ require.Equal(t, int32(1), restores.Load())
89+ // Carriage spans belong only to concrete adapters. The client owns the
90+ // post-successful-flush terminal boundary, before its ACK is queued.
91+ observer.requireOrdered(t, ports.RuntimeTerminalFlushed)
92+}
93+
94+type blockingClientRuntimeObserver struct {
95+ entered chan struct{}
96+ release chan struct{}
97+ once sync.Once
98+}
99+
100+func (o *blockingClientRuntimeObserver) ObserveRuntime(ports.RuntimeMark) {
101+ o.once.Do(func() {
102+ close(o.entered)
103+ <-o.release
104+ })
105+}
106+
107+func awaitClientRuntime(t *testing.T, ch <-chan struct{}, what string) {
108+ t.Helper()
109+ select {
110+ case <-ch:
111+ case <-time.After(time.Second):
112+ t.Fatalf("timed out waiting for %s", what)
113+ }
114+}
115+
116+type clientRuntimeObserver struct {
117+ mu sync.Mutex
118+ marks []ports.RuntimeMark
119+}
120+
121+func (o *clientRuntimeObserver) ObserveRuntime(mark ports.RuntimeMark) {
122+ o.mu.Lock()
123+ defer o.mu.Unlock()
124+ o.marks = append(o.marks, mark)
125+}
126+
127+func (o *clientRuntimeObserver) requireOrdered(t *testing.T, kinds ...ports.RuntimeMarkKind) {
128+ t.Helper()
129+ o.mu.Lock()
130+ defer o.mu.Unlock()
131+ next := 0
132+ for _, mark := range o.marks {
133+ if next < len(kinds) && mark.Kind == kinds[next] {
134+ next++
135+ }
136+ }
137+ if next != len(kinds) {
138+ t.Fatalf("runtime marks %#v do not contain ordered %#v", o.marks, kinds)
139+ }
140+}
M internal/usecase/daemon/client.go +2 -1
@@ -424,7 +424,8 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient,
424424 rc := sess.renderCoordinator()
425425 if rc == nil {
426426 rc = newRenderCoordinator(renderCoordinatorOptions{
427- clock: d.clock,
427+ clock: d.clock,
428+ observer: d.runtimeObserver,
428429 wake: func(w renderWake) {
429430 // Never reread sess.client here: a wake is bound to both its
430431 // attachment and its coordinator incarnation. The second check in
M internal/usecase/daemon/daemon.go +59 -21
@@ -22,8 +22,9 @@ import (
2222 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
2323 // sustained floods step toward the maximum to reduce frame/syscall pressure.
2424 const (
25- minDebounceInterval = 2 * time.Millisecond
26- maxSyncUpdateDuration = 500 * time.Millisecond
25+ minDebounceInterval = 2 * time.Millisecond
26+ maxSyncUpdateDuration = 500 * time.Millisecond
27+ runtimeObserverQueueDepth = 64
2728 )
2829
2930 const debounceInterval = minDebounceInterval
@@ -92,25 +93,28 @@ type Daemon struct {
9293 // interval between JRS validation and its committed hand-off.
9394 beforeRecentSessionHandoff func()
9495
95- ptys ports.PTYFactory
96- clock ports.Clock
97- log *slog.Logger
98- baseEnv []string
99- shell string
100- shellArgs []string
101- persist *persist.Persister
102- persistEnabled bool
103- snaps ports.SnapshotStore
104- snapsEnabled bool
105- snapshotMarshal func(snapcodec.Session) ([]byte, error)
106- snapshotJobs chan *snapshotCapture
107- snapshotWorkerMu sync.Mutex
108- snapshotWorkerID uint64
109- snapshotWorkerCtx context.Context
110- snapshotWorkerCancel context.CancelFunc
111- snapshotWorkerDone chan struct{}
112- snapshotWorkerFlush chan struct{}
113- snapshotWorkerFinalWake chan struct{}
96+ ptys ports.PTYFactory
97+ clock ports.Clock
98+ log *slog.Logger
99+ runtimeObserver ports.RuntimeObserver
100+ runtimeObserverCloser ports.SerializedRuntimeObserver
101+ runtimeObserverCloseOnce sync.Once
102+ baseEnv []string
103+ shell string
104+ shellArgs []string
105+ persist *persist.Persister
106+ persistEnabled bool
107+ snaps ports.SnapshotStore
108+ snapsEnabled bool
109+ snapshotMarshal func(snapcodec.Session) ([]byte, error)
110+ snapshotJobs chan *snapshotCapture
111+ snapshotWorkerMu sync.Mutex
112+ snapshotWorkerID uint64
113+ snapshotWorkerCtx context.Context
114+ snapshotWorkerCancel context.CancelFunc
115+ snapshotWorkerDone chan struct{}
116+ snapshotWorkerFlush chan struct{}
117+ snapshotWorkerFinalWake chan struct{}
114118 // snapshotFinalJobs retains immutable terminal captures rejected by the
115119 // bounded regular queue. It is only populated during session teardown and
116120 // drained by the same worker, so persistence remains outside daemon locks.
@@ -183,6 +187,39 @@ func (s stoppedSession) same(other stoppedSession) bool {
183187
184188 type Option func(*Daemon)
185189
190+// WithRuntimeObserver enables opt-in process-local marks. It takes no clock:
191+// the concrete observer is the sole timestamp owner.
192+func WithRuntimeObserver(observer ports.RuntimeObserver) Option {
193+ return func(d *Daemon) {
194+ // Options are applied before the daemon starts. Replacing an owned
195+ // observer here cannot race producers and must retire its worker.
196+ if d.runtimeObserverCloser != nil {
197+ d.runtimeObserverCloser.Close()
198+ d.runtimeObserverCloser = nil
199+ }
200+ reporter, owned := ports.EnsureSerializedRuntimeObserver(observer, runtimeObserverQueueDepth)
201+ d.runtimeObserver = reporter
202+ if owned {
203+ d.runtimeObserverCloser = reporter
204+ }
205+ }
206+}
207+
208+// closeRuntimeObserver drains the daemon-owned reporter before teardown
209+// completes. Application-owned process reporters are closed by their app
210+// lifecycle instead, so shared observers never receive a duplicate close.
211+func (d *Daemon) closeRuntimeObserver() {
212+ if d == nil {
213+ return
214+ }
215+ d.runtimeObserverCloseOnce.Do(func() {
216+ if d.runtimeObserverCloser != nil {
217+ d.runtimeObserverCloser.Flush()
218+ d.runtimeObserverCloser.Close()
219+ }
220+ })
221+}
222+
186223 // WithShell overrides the command (and its args) each session spawns. The
187224 // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a
188225 // deterministic program.
@@ -356,6 +393,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
356393 // returns when the last session is removed or ctx is cancelled; on the latter
357394 // path attached clients are detached with ReasonServerShutdown.
358395 func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
396+ defer d.closeRuntimeObserver()
359397 d.serveCtx, d.serveCancel = context.WithCancel(ctx)
360398 defer d.serveCancel()
361399 d.hardCtx, d.hardCancel = context.WithCancel(context.Background())
M internal/usecase/daemon/render.go +56 -2
@@ -5,6 +5,7 @@ import (
55 "bytes"
66
77 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
89 "github.com/bnema/vev/internal/usecase/layout"
910 themeui "github.com/bnema/vev/internal/usecase/theme"
1011 "github.com/bnema/vev/pkg/renderer"
@@ -193,7 +194,50 @@ func (d *Daemon) flushPTYEffects(sess *session, tb *tab, p *pane) {
193194 // paint draws the composed client frame (active tab plus status bar) and
194195 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
195196 // such as switch/create/close/rename/resize so the repaint is complete.
197+func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid bool) {
198+ if d != nil && d.runtimeObserver != nil {
199+ d.runtimeObserver.ObserveRuntime(ports.NewRuntimeMark("daemon", kind, bytes, valid))
200+ }
201+}
202+
203+// runtimeMarkBatch captures immutable runtime marks during a render transaction.
204+// paint owns attachment sendMu while it captures, composes, and emits; JSONL
205+// observer I/O must therefore happen only after that ownership is released.
206+type runtimeMarkBatch struct {
207+ observer ports.RuntimeObserver
208+ marks []ports.RuntimeMark
209+}
210+
211+func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
212+ if d == nil {
213+ return runtimeMarkBatch{}
214+ }
215+ return runtimeMarkBatch{observer: d.runtimeObserver}
216+}
217+
218+func (b *runtimeMarkBatch) span(start, end ports.RuntimeMarkKind, bytes uint64) func(uint64, bool) {
219+ if b == nil || b.observer == nil {
220+ return func(uint64, bool) {}
221+ }
222+ correlation := ports.NewRuntimeCorrelation()
223+ b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, start, bytes, true))
224+ return func(endBytes uint64, valid bool) {
225+ b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, end, endBytes, valid))
226+ }
227+}
228+
229+func (b *runtimeMarkBatch) flush() {
230+ if b == nil || b.observer == nil {
231+ return
232+ }
233+ for _, mark := range b.marks {
234+ b.observer.ObserveRuntime(mark)
235+ }
236+}
237+
196238 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, epochs ...uint64) {
239+ marks := d.newRuntimeMarkBatch()
240+
197241 attachmentEpoch := uint64(0)
198242 if len(epochs) != 0 {
199243 attachmentEpoch = epochs[0]
@@ -218,6 +262,8 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, epochs ...
218262 // the normal gate, but this ownership check also protects direct resize and
219263 // test paint paths from consuming damage that cannot be emitted.
220264 if ac.output != nil && ac.output.atCapacity() {
265+ // The coordinator owns the blocked interval; this guard only protects
266+ // direct test/resize paint calls from destructive capture.
221267 ac.sendMu.Unlock()
222268 return
223269 }
@@ -228,11 +274,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, epochs ...
228274 overlays := ac.overlays.SnapshotForRender()
229275 repaintAttachedClients := false
230276 defer func() {
277+ overlays.Unlock()
278+ // Every return below has released sendMu before reaching this defer.
279+ // Emit the captured sequence before any follow-up repaint can introduce
280+ // another boundary, while no attachment, session, tab, or pane lock is held.
281+ marks.flush()
231282 if repaintAttachedClients {
232283 d.repaintAllAttachedClients()
233284 }
234285 }()
235- defer overlays.Unlock()
236286 preview := snapshotPickerPreview(nil)
237287 if overlays.previewTab != tb {
238288 preview = snapshotPickerPreview(overlays.previewTab)
@@ -270,7 +320,9 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, epochs ...
270320 pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
271321 copyFeedback: overlays.copyFeedback,
272322 }
323+ endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
273324 state, ok := captureRenderState(sess, ac, bars, capturedOverlays, preview, floatingCfg, reset, damageCaptureConsume)
325+ endCapture(0, ok)
274326 if !ok {
275327 ac.sendMu.Unlock()
276328 return
@@ -285,11 +337,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, epochs ...
285337 state.preview = pickerPreviewFromCapturedRender(previewState)
286338 }
287339 captureOverlayLayers(state, overlays, paletteCfg)
340+ endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0)
288341 composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
342+ endCompose(0, true)
289343 if ac.renderStages.compose != nil {
290344 ac.renderStages.compose()
291345 }
292- d.emitFrame(sess, ac, state, composed)
346+ d.emitFrame(sess, ac, state, composed, &marks)
293347 }
294348
295349 type themeStyles struct {
M internal/usecase/daemon/render_coordinator.go +131 -1
@@ -78,7 +78,8 @@ type resizeRequestMetadata struct {
7878
7979 // renderCoordinatorOptions wires one coordinator instance.
8080 type renderCoordinatorOptions struct {
81- clock ports.Clock
81+ clock ports.Clock
82+ observer ports.RuntimeObserver
8283 // wake is the transitional composition target.
8384 wake func(renderWake)
8485 // ackReady reports whether the attachment may compose another output
@@ -107,6 +108,56 @@ type renderCoordinatorBurstMetrics struct {
107108 coalesced atomic.Uint64
108109 }
109110
111+// ackBlockedSpan hands an ACK-blocked endpoint pair between coordinator
112+// transitions without holding c.mu while the observer runs. A consuming ACK
113+// may arrive while Start is blocked in observer I/O; finish records that
114+// closure and publishStart emits it only after Start has returned.
115+type ackBlockedSpan struct {
116+ observer ports.RuntimeObserver
117+ correlation ports.RuntimeCorrelation
118+
119+ mu sync.Mutex
120+ startPublished bool
121+ finished bool
122+ endPending bool
123+ endValid bool
124+}
125+
126+func newACKBlockedSpan(observer ports.RuntimeObserver) *ackBlockedSpan {
127+ return &ackBlockedSpan{observer: observer, correlation: ports.NewRuntimeCorrelation()}
128+}
129+
130+func (s *ackBlockedSpan) publishStart() {
131+ s.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", s.correlation, ports.RuntimeACKBlockedStart, 0, true))
132+
133+ s.mu.Lock()
134+ s.startPublished = true
135+ endPending, endValid := s.endPending, s.endValid
136+ s.endPending = false
137+ s.mu.Unlock()
138+ if endPending {
139+ s.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", s.correlation, ports.RuntimeACKBlockedEnd, 0, endValid))
140+ }
141+}
142+
143+// finish closes a span at most once. It never invokes the observer before the
144+// blocked start has completed publication.
145+func (s *ackBlockedSpan) finish(valid bool) {
146+ s.mu.Lock()
147+ if s.finished {
148+ s.mu.Unlock()
149+ return
150+ }
151+ s.finished = true
152+ if !s.startPublished {
153+ s.endPending, s.endValid = true, valid
154+ s.mu.Unlock()
155+ return
156+ }
157+ s.mu.Unlock()
158+ s.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", s.correlation, ports.RuntimeACKBlockedEnd, 0, valid))
159+}
160+
110161 // renderCoordinatorBurstMetricsSnapshot is an immutable, internal view for
111162 // benchmark reporting. Scheduling only writes the atomic counters above.
112163 type renderCoordinatorBurstMetricsSnapshot struct {
@@ -124,6 +175,10 @@ type renderCoordinator struct {
124175 pending bool
125176 pendingReset bool
126177 pendingUrgent bool
178+ // queueMarked spans the coordinator's actual pending interval, from first
179+ // enqueue until fire consumes the coalesced work.
180+ queueMarked bool
181+ queueCorrelation ports.RuntimeCorrelation
127182 // pendingPreview is accounted separately from the target primary wake:
128183 // viewers may consume a coalesced target snapshot while that target is
129184 // still ACK-blocked.
@@ -131,6 +186,9 @@ type renderCoordinator struct {
131186 // ackDeferred records that an expired deadline or watchdog was blocked by
132187 // output-window capacity. ACK notifications may flush only this state.
133188 ackDeferred bool
189+ // ackBlocked retains the first rejected ACK-capacity probe until the
190+ // matching consume or lifecycle closure releases that exact interval.
191+ ackBlocked *ackBlockedSpan
134192 // deadlineDue closes the handoff between a timer worker receiving its tick
135193 // and fire observing unavailable ACK capacity.
136194 deadlineDue bool
@@ -287,9 +345,17 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache
287345 onInvalidate := c.opts.onInvalidate
288346 c.metrics.invalidations.Add(1)
289347 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview
348+ queueStart := false
349+ var queueCorrelation ports.RuntimeCorrelation
290350 if !wasPending {
291351 c.ackDeferred = false
292352 c.deadlineDue = false
353+ if c.opts.observer != nil {
354+ c.queueMarked = true
355+ c.queueCorrelation = ports.NewRuntimeCorrelation()
356+ queueCorrelation = c.queueCorrelation
357+ queueStart = true
358+ }
293359 }
294360 c.pending = true
295361 c.pendingReset = c.pendingReset || inv.reset
@@ -312,7 +378,11 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache
312378 }
313379 clock := c.opts.clock
314380 c.armed = c.armed || arm
381+ observer := c.opts.observer
315382 c.mu.Unlock()
383+ if queueStart && observer != nil {
384+ observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))
385+ }
316386 stopTimer(old)
317387 if !arm || clock == nil {
318388 if onInvalidate != nil {
@@ -377,7 +447,12 @@ func (c *renderCoordinator) notifyAck() {
377447 return
378448 }
379449 gen := c.generation
450+ blocked := c.ackBlocked
451+ c.ackBlocked = nil
380452 c.mu.Unlock()
453+ if blocked != nil {
454+ blocked.finish(true)
455+ }
381456 c.fire(gen, false, false)
382457 }
383458
@@ -635,6 +710,10 @@ func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
635710 func (c *renderCoordinator) noteDetach(ac *attachedClient) {
636711 c.mu.Lock()
637712 var timer, resizeTimer, retryTimer ports.Timer
713+ var observer ports.RuntimeObserver
714+ var queueCorrelation ports.RuntimeCorrelation
715+ var queueMarked bool
716+ var ackBlocked *ackBlockedSpan
638717 if c.attachment == ac {
639718 c.advanceAttachmentEpochLocked(ac)
640719 c.attachment = nil
@@ -660,11 +739,20 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
660739 }
661740 retryTimer, c.retryTimer = c.retryTimer, nil
662741 c.retryGen++
742+ observer, queueCorrelation, queueMarked = c.opts.observer, c.queueCorrelation, c.queueMarked
743+ ackBlocked = c.ackBlocked
744+ c.queueMarked, c.ackBlocked = false, nil
663745 }
664746 c.mu.Unlock()
665747 stopTimer(timer)
666748 stopTimer(resizeTimer)
667749 stopTimer(retryTimer)
750+ if queueMarked && observer != nil {
751+ observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, false))
752+ }
753+ if ackBlocked != nil {
754+ ackBlocked.finish(false)
755+ }
668756 }
669757
670758 // noteReplace hands the coordinator from old to replacement; callbacks
@@ -675,6 +763,10 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
675763 ready = readiness[0]
676764 }
677765 c.mu.Lock()
766+ var observer ports.RuntimeObserver
767+ var queueCorrelation ports.RuntimeCorrelation
768+ var queueMarked bool
769+ var ackBlocked *ackBlockedSpan
678770 // A coordinator may be installed while replacing a legacy attachment
679771 // which predated coordinator ownership. In that case nil has no pending
680772 // identity to invalidate, and the replacement becomes the first bound
@@ -708,11 +800,20 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
708800 }
709801 retryTimer, c.retryTimer = c.retryTimer, nil
710802 c.retryGen++
803+ observer, queueCorrelation, queueMarked = c.opts.observer, c.queueCorrelation, c.queueMarked
804+ ackBlocked = c.ackBlocked
805+ c.queueMarked, c.ackBlocked = false, nil
711806 }
712807 c.mu.Unlock()
713808 stopTimer(timer)
714809 stopTimer(resizeTimer)
715810 stopTimer(retryTimer)
811+ if queueMarked && observer != nil {
812+ observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, false))
813+ }
814+ if ackBlocked != nil {
815+ ackBlocked.finish(false)
816+ }
716817 }
717818
718819 // notePark invalidates pending wakes when the attachment parks for resume.
@@ -747,7 +848,16 @@ func (c *renderCoordinator) noteSessionTeardown() {
747848 c.armed = false
748849 timer := c.detachNormalTimerLocked()
749850 timers := c.detachSyncTimersLocked()
851+ observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked
852+ ackBlocked := c.ackBlocked
853+ c.queueMarked, c.ackBlocked = false, nil
750854 c.mu.Unlock()
855+ if queueMarked && observer != nil {
856+ observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, false))
857+ }
858+ if ackBlocked != nil {
859+ ackBlocked.finish(false)
860+ }
751861 stopTimer(timer)
752862 stopTimer(resizeTimer)
753863 stopTimer(retryTimer)
@@ -1052,11 +1162,22 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
10521162 // notification rather than accumulating ackDeferred forever.
10531163 headlessPreviewOnly := c.detached && c.attachment == nil
10541164 if !headlessPreviewOnly && (!ready || (c.attachment != nil && !c.attachmentReady)) {
1165+ var ackStart *ackBlockedSpan
10551166 if !ready && deadline {
10561167 c.ackDeferred = true
1168+ if c.ackBlocked == nil && c.opts.observer != nil {
1169+ // Install the span while coordinator state is owned. Its Start
1170+ // publication runs unlocked, and its own handoff defers End until
1171+ // that call has returned.
1172+ ackStart = newACKBlockedSpan(c.opts.observer)
1173+ c.ackBlocked = ackStart
1174+ }
10571175 }
10581176 preview, previews := c.takePendingPreviewsLocked()
10591177 c.mu.Unlock()
1178+ if ackStart != nil {
1179+ ackStart.publishStart()
1180+ }
10601181 c.notifyPreviews(w, preview, previews)
10611182 return
10621183 }
@@ -1075,9 +1196,18 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
10751196 }
10761197 timer := c.detachNormalTimerLocked()
10771198 c.pending, c.pendingReset, c.pendingUrgent, c.ackDeferred, c.deadlineDue, c.coalesced, c.armed = false, false, false, false, false, 0, false
1199+ observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked
1200+ ackBlocked := c.ackBlocked
1201+ c.queueMarked, c.ackBlocked = false, nil
10781202 wake := c.opts.wake
10791203 c.mu.Unlock()
10801204 stopTimer(timer)
1205+ if queueMarked && observer != nil {
1206+ observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true))
1207+ }
1208+ if ackBlocked != nil {
1209+ ackBlocked.finish(true)
1210+ }
10811211 if wake != nil && !headlessPreviewOnly {
10821212 wake(w)
10831213 }
M internal/usecase/daemon/render_pipeline.go +16 -1
@@ -286,7 +286,18 @@ func commitDamageReceipts(receipts []damageReceipt) {
286286
287287 // emitFrame is the sole side-effecting half of the pipeline. The caller holds
288288 // sendMu for the complete capture/compose/emit transaction.
289-func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame) bool {
289+func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {
290+ var ownedMarks runtimeMarkBatch
291+ var marks *runtimeMarkBatch
292+ if len(batches) != 0 {
293+ marks = batches[0]
294+ } else {
295+ ownedMarks = d.newRuntimeMarkBatch()
296+ marks = &ownedMarks
297+ // Direct test callers retain the same guarantee as paint: observer I/O
298+ // follows emitFrame's release of attachment ownership.
299+ defer marks.flush()
300+ }
290301 if sess == nil || ac == nil || state == nil {
291302 if ac != nil {
292303 ac.sendMu.Unlock()
@@ -306,7 +317,9 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
306317 ac.sendMu.Unlock()
307318 return false
308319 }
320+ endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
309321 prepared, err := ac.output.prepare(composed.frame, composed.damage, composed.reset)
322+ endDiff(0, err == nil)
310323 if err != nil {
311324 ac.sess.mu.Unlock()
312325 ac.sendMu.Unlock()
@@ -319,6 +332,7 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
319332 var sendErr error
320333 if len(data) > 0 {
321334 sendTr = ac.transport()
335+ endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
322336 if sendTr == nil {
323337 sendErr = errors.New("client transport is nil")
324338 } else {
@@ -328,6 +342,7 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
328342 }
329343 sendErr = prepared.send(data, ac.echoAck.Load(), send)
330344 }
345+ endEmit(uint64(len(data)), sendErr == nil)
331346 }
332347 if sendErr == nil {
333348 // Publish only after output preparation and transport emission both
M internal/usecase/daemon/transactional_resize.go +5 -1
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
56 "github.com/bnema/vev/internal/usecase/layout"
67 )
78
@@ -161,6 +162,7 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan
161162 ac.sendMu.Unlock()
162163 }
163164 markSnapshotDirty(sess)
165+ d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
164166 }
165167
166168 func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) bool {
@@ -249,7 +251,9 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin
249251 // attached requests, and coordinator schedule acceptance for async requests.
250252 // Headless requests have no reset invalidation and report geometry completion.
251253 func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {
252- if sess == nil || !size.Valid() {
254+ valid := sess != nil && size.Valid()
255+ d.observeRuntime(ports.RuntimeResizeRequested, 0, valid)
256+ if !valid {
253257 return false
254258 }
255259 if ac == nil {
A internal/usecase/daemon/transport_observability_test.go +388 -0
@@ -0,0 +1,388 @@
1+package daemon
2+
3+import (
4+ "runtime"
5+ "sync"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// This acceptance boundary exercises the attached resize, render, and ACK
15+// path rather than merely listing the marks the implementation should emit.
16+func TestTransportObservabilityDaemonBoundaries(t *testing.T) {
17+ observer := &daemonRuntimeObserver{}
18+ d, sess, ac, sends := newManualSessionWithPTYs(t, &quietPTY{})
19+ d.runtimeObserver = observer
20+ // One in-flight state makes the second public resize take the ACK-blocked
21+ // path. The real coordinator owns both renders and the ACK wake.
22+ ac.output = newOutputStateStream(1)
23+ rc := d.attachCoordinator(sess, nil, ac, true)
24+
25+ if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) {
26+ t.Fatal("first resize was not accepted")
27+ }
28+ first := <-sends
29+ if first.Type != ports.MsgOutput {
30+ t.Fatalf("first frame = %v, want output", first.Type)
31+ }
32+ output, err := ports.UnmarshalOutput(first.Payload)
33+ if err != nil {
34+ t.Fatalf("decode first output: %v", err)
35+ }
36+ if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 100, Rows: 26}) {
37+ t.Fatal("second resize was not accepted")
38+ }
39+ ac.ackOutputState(output.NewStateNum)
40+ rc.notifyAck()
41+ second := <-sends
42+ if second.Type != ports.MsgOutput {
43+ t.Fatalf("second frame = %v, want output", second.Type)
44+ }
45+
46+ want := []ports.RuntimeMarkKind{
47+ ports.RuntimeResizeRequested, ports.RuntimeResizeCommitted,
48+ ports.RuntimeQueueEnqueued, ports.RuntimeQueueDequeued,
49+ ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd,
50+ ports.RuntimeComposeStart, ports.RuntimeComposeEnd,
51+ ports.RuntimeDiffStart, ports.RuntimeDiffEnd,
52+ ports.RuntimeEmitStart, ports.RuntimeEmitEnd,
53+ ports.RuntimeResizeRequested, ports.RuntimeResizeCommitted,
54+ ports.RuntimeQueueEnqueued, ports.RuntimeACKBlockedStart,
55+ ports.RuntimeACKBlockedEnd, ports.RuntimeQueueDequeued,
56+ ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd,
57+ ports.RuntimeComposeStart, ports.RuntimeComposeEnd,
58+ ports.RuntimeDiffStart, ports.RuntimeDiffEnd,
59+ ports.RuntimeEmitStart, ports.RuntimeEmitEnd,
60+ }
61+ if len(observer.marks) != len(want) {
62+ t.Fatalf("runtime marks = %#v, want kinds %#v", observer.marks, want)
63+ }
64+ for i, kind := range want {
65+ if observer.marks[i].Kind != kind {
66+ t.Fatalf("runtime mark %d = %q, want %q; all=%#v", i, observer.marks[i].Kind, kind, observer.marks)
67+ }
68+ }
69+}
70+
71+type daemonRuntimeObserver struct{ marks []ports.RuntimeMark }
72+
73+func (o *daemonRuntimeObserver) ObserveRuntime(mark ports.RuntimeMark) {
74+ o.marks = append(o.marks, mark)
75+}
76+
77+type blockingDaemonRuntimeObserver struct {
78+ mu sync.Mutex
79+ entered chan struct{}
80+ release chan struct{}
81+ blocked bool
82+}
83+
84+func newBlockingDaemonRuntimeObserver() *blockingDaemonRuntimeObserver {
85+ return &blockingDaemonRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})}
86+}
87+
88+func (o *blockingDaemonRuntimeObserver) ObserveRuntime(ports.RuntimeMark) {
89+ o.mu.Lock()
90+ defer o.mu.Unlock()
91+ if !o.blocked {
92+ o.blocked = true
93+ close(o.entered)
94+ <-o.release
95+ }
96+}
97+
98+func awaitDaemonObserver(t *testing.T, ch <-chan struct{}, what string) {
99+ t.Helper()
100+ select {
101+ case <-ch:
102+ case <-time.After(time.Second):
103+ t.Fatalf("timed out waiting for %s", what)
104+ }
105+}
106+
107+// A runtime observer may synchronously write JSONL. Its I/O must run only
108+// after the render transaction has released attachment, session, and pane
109+// ownership, so a blocked trace cannot stall live output processing.
110+func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t *testing.T) {
111+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
112+ observer := newBlockingDaemonRuntimeObserver()
113+ d.runtimeObserver = observer
114+
115+ paintDone := make(chan struct{})
116+ go func() {
117+ d.paint(sess, ac, true)
118+ close(paintDone)
119+ }()
120+ awaitDaemonObserver(t, observer.entered, "blocked render observer")
121+ frame := <-sends // The frame was emitted before the observer began its I/O.
122+ output, err := ports.UnmarshalOutput(frame.Payload)
123+ if err != nil {
124+ t.Fatalf("decode emitted output: %v", err)
125+ }
126+ ac.ackOutputState(output.NewStateNum)
127+
128+ pane := sess.activeTab().focusedPane()
129+ for _, lock := range []struct {
130+ name string
131+ fn func()
132+ }{
133+ {"attachment", func() { ac.sendMu.Lock(); runtime.Gosched(); ac.sendMu.Unlock() }},
134+ {"session", func() { sess.mu.Lock(); runtime.Gosched(); sess.mu.Unlock() }},
135+ {"pane", func() { pane.mu.Lock(); runtime.Gosched(); pane.mu.Unlock() }},
136+ } {
137+ done := make(chan struct{})
138+ go func(fn func()) { fn(); close(done) }(lock.fn)
139+ awaitDaemonObserver(t, done, lock.name+" lock")
140+ }
141+
142+ livePTY := make(chan struct{})
143+ go func() {
144+ d.processPTYData(sess, sess.activeTab(), pane, []byte("live"), false)
145+ close(livePTY)
146+ }()
147+ awaitDaemonObserver(t, livePTY, "live PTY processing")
148+
149+ // A second render can complete its actual pipeline while the first trace
150+ // write is blocked; it is not serialized behind the first attachment lock.
151+ secondPaint := make(chan struct{})
152+ go func() {
153+ d.paint(sess, ac, false)
154+ close(secondPaint)
155+ }()
156+ <-sends // The second frame is live even though serialized observer I/O waits.
157+
158+ close(observer.release)
159+ awaitDaemonObserver(t, paintDone, "first render completion")
160+ awaitDaemonObserver(t, secondPaint, "unrelated render completion")
161+}
162+
163+// ACK-blocked start is captured while coordinator state changes, then emitted
164+// after c.mu is unlocked. This closes the remaining coordinator-observer path.
165+func TestTransportObservabilityACKBlockedSpansEndExactlyOnce(t *testing.T) {
166+ cases := []struct {
167+ name string
168+ finish func(*renderCoordinator, *bool)
169+ }{
170+ {
171+ name: "notify ACK",
172+ finish: func(rc *renderCoordinator, ready *bool) {
173+ *ready = true
174+ rc.notifyAck()
175+ },
176+ },
177+ {
178+ name: "successful consume",
179+ finish: func(rc *renderCoordinator, ready *bool) {
180+ *ready = true
181+ rc.fire(rc.generation, false, false)
182+ },
183+ },
184+ {
185+ name: "teardown",
186+ finish: func(rc *renderCoordinator, ready *bool) {
187+ rc.noteSessionTeardown()
188+ },
189+ },
190+ }
191+ for _, tt := range cases {
192+ t.Run(tt.name, func(t *testing.T) {
193+ observer := &daemonRuntimeObserver{}
194+ ready := false
195+ rc := newRenderCoordinator(renderCoordinatorOptions{
196+ observer: observer,
197+ ackReady: func() bool { return ready },
198+ })
199+ rc.attach(&attachedClient{})
200+ rc.invalidate(renderInvalidation{class: invalidateOutput})
201+ rc.fire(rc.generation, false, true)
202+ tt.finish(rc, &ready)
203+ // A late notification after consuming or tearing down must not emit a
204+ // second end for the already-cleared blocked interval.
205+ rc.notifyAck()
206+
207+ var starts, ends []ports.RuntimeMark
208+ for _, mark := range observer.marks {
209+ switch mark.Kind {
210+ case ports.RuntimeACKBlockedStart:
211+ starts = append(starts, mark)
212+ case ports.RuntimeACKBlockedEnd:
213+ ends = append(ends, mark)
214+ }
215+ }
216+ if len(starts) != 1 || len(ends) != 1 {
217+ t.Fatalf("ACK-blocked span marks: starts=%+v ends=%+v all=%+v", starts, ends, observer.marks)
218+ }
219+ if starts[0].Sequence != ends[0].Sequence || starts[0].RequestID != ends[0].RequestID || starts[0].Epoch != ends[0].Epoch {
220+ t.Fatalf("ACK-blocked span correlation changed: start=%+v end=%+v", starts[0], ends[0])
221+ }
222+ })
223+ }
224+}
225+
226+// A raw observer supplied through daemon configuration is serialized by the
227+// daemon. Once Start has been published, a blocked End must not delay the ACK
228+// that consumes deferred work and delivers its wake.
229+func TestTransportObservabilityBlockedACKEndDoesNotDelayNotifyAck(t *testing.T) {
230+ observer := &blockingACKEndObserver{
231+ startObserved: make(chan struct{}),
232+ endEntered: make(chan struct{}),
233+ releaseEnd: make(chan struct{}),
234+ }
235+ d := New(nil, nil, nil, WithRuntimeObserver(observer))
236+ wakes := make(chan renderWake, 1)
237+ var ready atomic.Bool
238+ rc := newRenderCoordinator(renderCoordinatorOptions{
239+ observer: d.runtimeObserver,
240+ ackReady: ready.Load,
241+ wake: func(w renderWake) { wakes <- w },
242+ })
243+ rc.attach(&attachedClient{})
244+ rc.invalidate(renderInvalidation{class: invalidateOutput})
245+
246+ // Enter the ACK-blocked state and wait until Start has reached the injected
247+ // observer. End is the only deliberately blocked call in this test.
248+ rc.fire(rc.generation, false, true)
249+ awaitDaemonObserver(t, observer.startObserved, "ACK-blocked start observer")
250+
251+ ready.Store(true)
252+ ackDone := make(chan struct{})
253+ go func() {
254+ rc.notifyAck()
255+ close(ackDone)
256+ }()
257+ awaitDaemonObserver(t, observer.endEntered, "blocked ACK-blocked end observer")
258+ awaitDaemonObserver(t, ackDone, "nonblocking ACK progress")
259+ awaitWake(t, wakes)
260+ rc.mu.Lock()
261+ pending := rc.pending
262+ rc.mu.Unlock()
263+ if pending {
264+ t.Fatal("notifyAck left consumed ACK-deferred work pending")
265+ }
266+
267+ close(observer.releaseEnd)
268+ d.closeRuntimeObserver()
269+}
270+
271+type blockingACKEndObserver struct {
272+ startObserved chan struct{}
273+ endEntered chan struct{}
274+ releaseEnd chan struct{}
275+}
276+
277+func (o *blockingACKEndObserver) ObserveRuntime(mark ports.RuntimeMark) {
278+ switch mark.Kind {
279+ case ports.RuntimeACKBlockedStart:
280+ close(o.startObserved)
281+ case ports.RuntimeACKBlockedEnd:
282+ close(o.endEntered)
283+ <-o.releaseEnd
284+ }
285+}
286+
287+func TestTransportObservabilityBlockedCoordinatorObserverReleasesLock(t *testing.T) {
288+ observer := newBlockingDaemonRuntimeObserver()
289+ rc := newRenderCoordinator(renderCoordinatorOptions{
290+ observer: observer,
291+ ackReady: func() bool { return false },
292+ })
293+ rc.attach(&attachedClient{})
294+ rc.mu.Lock()
295+ rc.pending = true
296+ gen := rc.generation
297+ rc.mu.Unlock()
298+
299+ fired := make(chan struct{})
300+ go func() {
301+ rc.fire(gen, false, true)
302+ close(fired)
303+ }()
304+ awaitDaemonObserver(t, observer.entered, "blocked ACK observer")
305+
306+ acquired := make(chan struct{})
307+ go func() {
308+ rc.mu.Lock()
309+ runtime.Gosched()
310+ rc.mu.Unlock()
311+ close(acquired)
312+ }()
313+ awaitDaemonObserver(t, acquired, "coordinator lock")
314+
315+ close(observer.release)
316+ awaitDaemonObserver(t, fired, "ACK fire completion")
317+}
318+
319+type ackSpanInterleavingObserver struct {
320+ mu sync.Mutex
321+ marks []ports.RuntimeMark
322+ startEntered chan struct{}
323+ releaseStart chan struct{}
324+}
325+
326+func newACKSpanInterleavingObserver() *ackSpanInterleavingObserver {
327+ return &ackSpanInterleavingObserver{startEntered: make(chan struct{}), releaseStart: make(chan struct{})}
328+}
329+
330+func (o *ackSpanInterleavingObserver) ObserveRuntime(mark ports.RuntimeMark) {
331+ if mark.Kind == ports.RuntimeACKBlockedStart {
332+ close(o.startEntered)
333+ <-o.releaseStart
334+ }
335+ o.mu.Lock()
336+ o.marks = append(o.marks, mark)
337+ o.mu.Unlock()
338+}
339+
340+func (o *ackSpanInterleavingObserver) snapshot() []ports.RuntimeMark {
341+ o.mu.Lock()
342+ defer o.mu.Unlock()
343+ return append([]ports.RuntimeMark(nil), o.marks...)
344+}
345+
346+// An ACK can consume deferred work while the start mark is still waiting on
347+// observer I/O. The End must wait for that Start publication, but the ACK
348+// itself must not wait for either observer call.
349+func TestTransportObservabilityACKBlockedStartPublishesBeforeEnd(t *testing.T) {
350+ observer := newACKSpanInterleavingObserver()
351+ ready := false
352+ rc := newRenderCoordinator(renderCoordinatorOptions{
353+ observer: observer,
354+ ackReady: func() bool { return ready },
355+ })
356+ rc.mu.Lock()
357+ rc.pending = true
358+ gen := rc.generation
359+ rc.mu.Unlock()
360+
361+ fireDone := make(chan struct{})
362+ go func() {
363+ rc.fire(gen, false, true)
364+ close(fireDone)
365+ }()
366+ awaitDaemonObserver(t, observer.startEntered, "ACK-blocked start observer")
367+
368+ ready = true
369+ ackDone := make(chan struct{})
370+ go func() {
371+ rc.notifyAck()
372+ close(ackDone)
373+ }()
374+ awaitDaemonObserver(t, ackDone, "nonblocking ACK progress")
375+ if marks := observer.snapshot(); len(marks) != 0 {
376+ t.Fatalf("ACK-blocked end published before its blocked start: %#v", marks)
377+ }
378+
379+ close(observer.releaseStart)
380+ awaitDaemonObserver(t, fireDone, "ACK-blocked start completion")
381+ marks := observer.snapshot()
382+ if len(marks) != 2 || marks[0].Kind != ports.RuntimeACKBlockedStart || marks[1].Kind != ports.RuntimeACKBlockedEnd {
383+ t.Fatalf("ACK-blocked marks = %#v, want one ordered start/end pair", marks)
384+ }
385+ if marks[0].Sequence != marks[1].Sequence || marks[0].RequestID != marks[1].RequestID || marks[0].Epoch != marks[1].Epoch {
386+ t.Fatalf("ACK-blocked span correlation changed: start=%+v end=%+v", marks[0], marks[1])
387+ }
388+}
A testdata/perf/README.md +69 -0
@@ -0,0 +1,69 @@
1+# S5a transport-observability evidence
2+
3+`manifest.json` is the complete, measurement-only public-CLI matrix: four
4+120x40 topologies (`1x4`, `4x1`, `4x4`, `8x1`), 10,000 history rows per pane,
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;
7+there are no omitted combinations. A genuinely unavailable combination must
8+remain listed with `inapplicable_reason` and no launched roles.
9+
10+Build and run the harness with at least a 10-second warmup, 30-second measured
11+interval, and 10 independent repetitions:
12+
13+```sh
14+go build -o /tmp/vev-perf ./
15+rm -rf testdata/perf/results
16+go run ./cmd/vev-perf-harness --vev-bin /tmp/vev-perf \
17+ --manifest testdata/perf/manifest.json --out testdata/perf/results \
18+ --warmup 10s --duration 30s --repetitions 10
19+```
20+
21+Each run writes `manifest.json`, `raw-harness.jsonl`, one JSONL trace per
22+launched process, `runs.json`, and `summary.json`. The process manifest records
23+process ID, role, scenario/run, clock domain, exact exclusive trace path, and
24+identity. Every launched role receives `VEV_PERF_TRACE`, `VEV_PERF_PROCESS_ID`,
25+`VEV_PERF_SCENARIO`, and `VEV_PERF_RUN`; the first is exclusive to that process
26+and the remaining values correlate it to the manifest. The harness owns only the end-to-end boundaries: input injection to
27+terminal bytes successfully flushed. It never subtracts ticks from daemon,
28+client, or adapter processes.
29+
30+A process trace is JSONL with exactly these keys and types:
31+
32+```json
33+{"schema":1,"process_id":"p","component":"daemon","scenario":"s","run":1,"sequence":1,"request_id":1,"epoch":1,"kind":"diff_start","tick":0,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}
34+```
35+
36+`tick` is owned and stamped by that process's injected monotonic clock. Producers
37+must leave it zero; one observer/clock is shared by components in a process.
38+Ticks from different `process_id` or clock domains are never ordered or
39+subtracted. Correlation uses scenario, run, sequence, request ID, and epoch.
40+
41+Named process-local spans are: capture, compose, diff (`diff_start/end`), queue
42+wait (`queue_enqueued/dequeued`), ACK-blocked interval, emit, adapter send,
43+and adapter receive. Adapter spans are reported by component/transport; bytes,
44+fragments, retransmits, pending, and ACK RTT remain diagnostics. Reports retain
45+raw records and counts plus p50/p95/p99 (and max where applicable). End-to-end
46+reports are harness-clock distributions only.
47+
48+The harness injects at most one measured event per second and requires at least
49+10 complete in-interval event pairs. Both the input-injection and successful
50+terminal-flush boundaries must be within the measured interval; warmup and
51+straddling pairs are excluded from percentile samples (but retained as raw
52+evidence). Reject a result for missing or unmatched boundaries, missing flush,
53+invalid correlation, nonmonotonic same-process sequence, negative same-domain
54+duration, fewer than 10 complete in-interval pairs, invalid denominators,
55+cross-process tick math, less than 30 seconds measured, or fewer than 10
56+repetitions. Warmup is excluded from measured samples. Raw traces are evidence, not a transport-policy or
57+optimization gate; wire bytes, protocol version, rendering policy, pacing,
58+compression, and loss behavior are unchanged.
59+
60+## Bounded manifest and harness checks
61+
62+Before a measurement, validate the manifest shape and run the harness tests without launching the full matrix:
63+
64+```sh
65+jq -e '(.topologies|length)==4 and (.workloads|length)==9 and (.transports|length)==7 and (.scenarios|length)==252' testdata/perf/manifest.json
66+go test ./cmd/vev-perf-harness
67+```
68+
69+The full command above is the measurement procedure; its 10-second warmup, 30-second measured interval, and 10 repetitions are the minimum accepted settings. For a bounded local evidence run, add `--scenario 1x4-idle-local`; the harness validates the complete canonical manifest before it applies that selection, so a single-scenario run cannot hide missing matrix coverage.
A testdata/perf/manifest.json +2808 -0 large diff (2808 lines changed): collapsed

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