detached 0adb096

Merge pull request #84 from bnema/next

chore(release): promote next to main
commit
0adb0962e4428b5f5538dc9ec4955e36f9ad5355
author
bnema · · 2026-07-14 09:03 UTC
committer
GitHub · · 2026-07-14 09:03 UTC
merge of
ca3be6d + 93b77ca · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
148 files · +24676 -3896
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 +758 -0
@@ -0,0 +1,758 @@
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+ fail := func(err error) error {
285+ if peerOwnsRuntime {
286+ return errors.Join(err, l.releaseRuntime(runDir))
287+ }
288+ return err
289+ }
290+ if peerOwnsRuntime {
291+ var err error
292+ runtimeDir, err = l.runtimeDir(runDir)
293+ if err != nil {
294+ return nil, fail(err)
295+ }
296+ }
297+ if err := safedir.EnsurePrivate(runDir); err != nil {
298+ return nil, fail(err)
299+ }
300+ if err := safedir.EnsurePrivate(runtimeDir); err != nil {
301+ return nil, fail(err)
302+ }
303+ if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
304+ return nil, fail(err)
305+ }
306+ if len(role.Args) != 2 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") {
307+ return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args))
308+ }
309+ route := peerRoute{mapping: m, command: role}
310+ closeNetem := func(err error) error {
311+ if route.netem != nil {
312+ err = errors.Join(err, route.netem.Close())
313+ }
314+ return fail(err)
315+ }
316+ if m.Role == "udp_peer" {
317+ route.pidPath = filepath.Join(runDir, "udp-peer.pid")
318+ factory := l.netemFactory
319+ if factory == nil {
320+ factory = newUDPNetem
321+ }
322+ netem, err := factory(udpNetemConfig{
323+ RTT: time.Duration(role.Transport.RTTMS) * time.Millisecond,
324+ LossPercent: role.Transport.LossPercent,
325+ TargetPath: filepath.Join(runDir, "udp-peer.target"),
326+ })
327+ if err != nil {
328+ return nil, fail(fmt.Errorf("start harness UDP netem: %w", err))
329+ }
330+ route.netem = netem
331+ }
332+ shim := filepath.Join(runDir, "ssh")
333+ ready := filepath.Join(runDir, "udp-peer.ready")
334+ target := filepath.Join(runDir, "udp-peer.target")
335+ netemPort := 0
336+ if route.netem != nil {
337+ netemPort = route.netem.Port()
338+ }
339+ stderr := filepath.Join(runDir, "udp-peer.stderr")
340+ // The bootstrap protocol requires the SSH command to exit after forwarding
341+ // the readiness line. The long-lived proxy is owned by pidPath and receives
342+ // the role's unique trace identity, never the bootstrap/client identity.
343+ body := fmt.Sprintf(`#!/bin/sh
344+set -eu
345+case "$*" in
346+ *"_stdio"*)
347+ 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
348+ ;;
349+ *"_udp-bootstrap"*)
350+ rm -f %[7]q %[8]q %[9]q
351+ 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 &
352+ peer=$!
353+ printf '%%s\n' "$peer" > %[11]q
354+ i=0
355+ while [ ! -s %[7]q ] && [ "$i" -lt 1000 ]; do sleep 0.01; i=$((i+1)); done
356+ test -s %[7]q
357+ line=$(head -n 1 %[7]q)
358+ printf '%%s\n' "$line" > %[9]q
359+ set -- $line
360+ test "$1" = VEV-UDP
361+ test -n "$3"
362+ printf 'VEV-UDP %%s %%s\n' %[10]d "$3"
363+ exit 0
364+ ;;
365+ *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;;
366+esac
367+`, m.TracePath, m.ProcessID, runtimeDir, filepath.Join(runDir, "state"), l.bin, role.Args[1], ready, stderr, target, netemPort, route.pidPath, m.Scenario, m.Run)
368+ if err := os.WriteFile(shim, []byte(body), 0o700); err != nil {
369+ return nil, closeNetem(err)
370+ }
371+ l.mu.Lock()
372+ if l.peers == nil {
373+ l.peers = make(map[string]peerRoute)
374+ }
375+ if _, exists := l.peers[runDir]; exists {
376+ l.mu.Unlock()
377+ return nil, closeNetem(fmt.Errorf("duplicate transport peer for %s", runDir))
378+ }
379+ l.peers[runDir] = route
380+ l.mu.Unlock()
381+ peer := preparedPeer{route: route}
382+ if peerOwnsRuntime {
383+ peer.cleanupRuntime = func() error { return l.releaseRuntime(runDir) }
384+ }
385+ return peer, nil
386+}
387+
388+func remoteMode(t transport) (string, error) {
389+ switch t.Kind {
390+ case "ssh_stdio":
391+ return "stdio", nil
392+ case "udp":
393+ return "udp", nil
394+ default:
395+ return "", fmt.Errorf("transport %q cannot route through a remote peer", t.ID)
396+ }
397+}
398+
399+// traceEnvironment is the complete process-local identity supplied by the
400+// persisted harness processMapping. App turns Scenario/Run into production
401+// mark correlation; sequence/request/epoch remain process-local operation IDs.
402+func traceEnvironment(m processMapping) []string {
403+ return []string{
404+ "VEV_PERF_TRACE=" + m.TracePath,
405+ "VEV_PERF_PROCESS_ID=" + m.ProcessID,
406+ "VEV_PERF_SCENARIO=" + m.Scenario,
407+ "VEV_PERF_RUN=" + strconv.Itoa(m.Run),
408+ }
409+}
410+
411+func withoutEnv(env []string, names ...string) []string {
412+ remove := make(map[string]bool, len(names))
413+ for _, name := range names {
414+ remove[name] = true
415+ }
416+ out := make([]string, 0, len(env))
417+ for _, entry := range env {
418+ name, _, _ := strings.Cut(entry, "=")
419+ if !remove[name] {
420+ out = append(out, entry)
421+ }
422+ }
423+ return out
424+}
425+
426+func (p *cliProcess) copyTerminal() {
427+ defer close(p.chunks)
428+ buf := make([]byte, 4096)
429+ for {
430+ n, err := p.pty.Read(buf)
431+ if n > 0 {
432+ chunk := append([]byte(nil), buf[:n]...)
433+ if _, writeErr := p.output.Write(chunk); writeErr != nil {
434+ return
435+ }
436+ select {
437+ case p.chunks <- chunk:
438+ case <-p.done:
439+ return
440+ }
441+ }
442+ if err != nil {
443+ return
444+ }
445+ }
446+}
447+
448+// WaitReady confirms the explicitly launched public daemon owns its unique
449+// socket before a client is launched. Without this barrier, the normal client
450+// auto-spawn behavior can create an unmanifested daemon process during startup.
451+func (p *cliProcess) WaitReady() error {
452+ if p.readyPath == "" {
453+ return errors.New("daemon readiness path is empty")
454+ }
455+ ticker := time.NewTicker(10 * time.Millisecond)
456+ defer ticker.Stop()
457+ timer := time.NewTimer(10 * time.Second)
458+ defer timer.Stop()
459+ for {
460+ if info, err := os.Stat(p.readyPath); err == nil {
461+ if info.Mode()&os.ModeSocket == 0 {
462+ return fmt.Errorf("daemon readiness path %s is not a socket", p.readyPath)
463+ }
464+ return nil
465+ }
466+ select {
467+ case err := <-p.waitErr:
468+ p.waitErr <- err
469+ if err == nil {
470+ return errors.New("daemon exited before readiness")
471+ }
472+ return fmt.Errorf("daemon exited before readiness: %w", err)
473+ case <-ticker.C:
474+ case <-timer.C:
475+ return fmt.Errorf("timed out waiting for daemon socket %s", p.readyPath)
476+ }
477+ }
478+}
479+
480+func (p *cliProcess) Warmup(input []byte) error {
481+ if p.pty == nil || len(input) == 0 {
482+ return nil
483+ }
484+ // A client cannot write application output until it has received Welcome and
485+ // entered raw mode. Require that observable output before submitting any
486+ // warmup input, so the PTY's line discipline cannot echo it locally.
487+ if !p.terminalReady {
488+ if err := p.waitForTerminalReady(); err != nil {
489+ return err
490+ }
491+ p.terminalReady = true
492+ }
493+ if _, err := p.pty.Write(input); err != nil {
494+ return err
495+ }
496+ // Do not begin measured input until the public client has rendered a real
497+ // shell response to warmup input. This is intentionally not a measurement.
498+ return p.waitForTerminalMarker(inputMarker(input), input)
499+}
500+
501+// Measure records a boundary only after the harness-owned terminal output file
502+// has accepted application output and Sync has reported a successful flush.
503+// The marker is injected through the real client PTY, never fabricated by the
504+// harness. PTY local echo is discarded and cannot satisfy this boundary.
505+func (p *cliProcess) Measure(input []byte, injected, flushed func() error) error {
506+ if p.pty == nil {
507+ return errors.New("measured process has no terminal PTY")
508+ }
509+ if _, err := p.pty.Write(input); err != nil {
510+ return err
511+ }
512+ // The injected callback runs only after the owned PTY accepts the bytes.
513+ if err := injected(); err != nil {
514+ return err
515+ }
516+ if err := p.waitForTerminalMarker(inputMarker(input), input); err != nil {
517+ return err
518+ }
519+ if err := p.output.Sync(); err != nil {
520+ return fmt.Errorf("flush terminal output: %w", err)
521+ }
522+ return flushed()
523+}
524+
525+// waitForTerminalReady waits for public client output produced before any
526+// harness input. The client enters raw mode before its output loop can render
527+// this initial prompt/state, so this is an observable readiness boundary.
528+func (p *cliProcess) waitForTerminalReady() error {
529+ timer := time.NewTimer(10 * time.Second)
530+ defer timer.Stop()
531+ for {
532+ select {
533+ case chunk, ok := <-p.chunks:
534+ if !ok {
535+ return p.terminalClosedError("readiness output")
536+ }
537+ if len(chunk) == 0 {
538+ continue
539+ }
540+ if err := p.output.Sync(); err != nil {
541+ return fmt.Errorf("flush terminal readiness output: %w", err)
542+ }
543+ return nil
544+ case err := <-p.waitErr:
545+ // Keep the exit result available to Close, which owns reaping.
546+ p.waitErr <- err
547+ if err == nil {
548+ return errors.New("client exited before readiness output")
549+ }
550+ return fmt.Errorf("client exited before readiness output: %w", err)
551+ case <-timer.C:
552+ return errors.New("timed out waiting for client readiness output")
553+ }
554+ }
555+}
556+
557+func (p *cliProcess) terminalClosedError(boundary string) error {
558+ if p.waitErr != nil {
559+ select {
560+ case err := <-p.waitErr:
561+ // Close owns reaping and may need the result after a failed boundary.
562+ p.waitErr <- err
563+ if err != nil {
564+ return fmt.Errorf("client terminal closed before %s: %w", boundary, err)
565+ }
566+ case <-time.After(10 * time.Millisecond):
567+ }
568+ }
569+ return fmt.Errorf("client terminal closed before %s", boundary)
570+}
571+
572+func (p *cliProcess) waitForTerminalMarker(marker, input []byte) error {
573+ var received []byte
574+ echo := newPTYLocalEcho(input)
575+ timer := time.NewTimer(10 * time.Second)
576+ defer timer.Stop()
577+ for !bytes.Contains(received, marker) {
578+ select {
579+ case chunk, ok := <-p.chunks:
580+ if !ok {
581+ return p.terminalClosedError("application output")
582+ }
583+ received = append(received, echo.applicationOutput(chunk)...)
584+ case err := <-p.waitErr:
585+ // Keep the exit result available to Close, which owns reaping.
586+ p.waitErr <- err
587+ if err == nil {
588+ return errors.New("client exited before application output")
589+ }
590+ return fmt.Errorf("client exited before application output: %w", err)
591+ case <-timer.C:
592+ return errors.New("timed out waiting for application terminal output")
593+ }
594+ }
595+ return nil
596+}
597+
598+// ptyLocalEcho removes the one ordered input echo that a not-yet-raw slave PTY
599+// can produce. It normalizes NL to the conventional ONLCR echo spelling; any
600+// divergence is retained as application output. The line discipline emits the
601+// echo before the child can read and respond to the same write.
602+type ptyLocalEcho struct {
603+ expected []byte
604+ matched int
605+ done bool
606+}
607+
608+func (e *ptyLocalEcho) applicationOutput(chunk []byte) []byte {
609+ if e.done || len(e.expected) == 0 {
610+ return chunk
611+ }
612+ for i, b := range chunk {
613+ if b != e.expected[e.matched] {
614+ e.done = true
615+ return append([]byte(nil), chunk[i:]...)
616+ }
617+ e.matched++
618+ if e.matched == len(e.expected) {
619+ e.done = true
620+ return append([]byte(nil), chunk[i+1:]...)
621+ }
622+ }
623+ return nil
624+}
625+
626+func (p *cliProcess) Close() error {
627+ var result error
628+ p.closed.Do(func() {
629+ if p.done != nil {
630+ close(p.done)
631+ }
632+ // A public terminal client owns a shell session. Ask that shell to exit
633+ // before closing the PTY so its transport can finish its receive span;
634+ // this avoids turning ordinary harness teardown into an aborted trace.
635+ exited := false
636+ if p.shutdown != nil && p.waitErr != nil {
637+ select {
638+ case <-p.waitErr:
639+ exited = true
640+ default:
641+ // The client may have just ended the last session and raced this
642+ // best-effort public shutdown. The normal bounded reaping path below
643+ // remains authoritative in that case.
644+ _ = p.shutdown()
645+ }
646+ }
647+ if p.pty != nil && p.waitErr != nil {
648+ _, _ = p.pty.Write([]byte("exit\n"))
649+ select {
650+ case <-p.waitErr:
651+ exited = true
652+ case <-p.closeDeadline():
653+ }
654+ }
655+ if p.pty != nil {
656+ _ = p.pty.Close()
657+ }
658+ if p.waitErr != nil && !exited {
659+ select {
660+ case <-p.waitErr:
661+ case <-p.closeDeadline():
662+ // Teardown is deliberately bounded: a stuck public CLI or child
663+ // must not hold a local harness run indefinitely.
664+ p.forceProcessGroupCleanup()
665+ select {
666+ case <-p.waitErr:
667+ case <-p.closeDeadline():
668+ p.forceProcessGroupKill()
669+ select {
670+ case <-p.waitErr:
671+ case <-p.closeDeadline():
672+ result = errors.New("timed out reaping public CLI process")
673+ }
674+ }
675+ }
676+ }
677+ if p.output != nil {
678+ if err := p.output.Close(); result == nil {
679+ result = err
680+ }
681+ }
682+ if p.cleanupRuntime != nil {
683+ if err := p.cleanupRuntime(); result == nil {
684+ result = err
685+ }
686+ }
687+ })
688+ return result
689+}
690+
691+func (p *cliProcess) closeDeadline() <-chan time.Time {
692+ if p.waitTimeout != nil {
693+ return p.waitTimeout()
694+ }
695+ if p.shutdown != nil {
696+ // A public kill asks the daemon to finish bounded detached notices and
697+ // close blocked carriage before it exits; do not turn that graceful
698+ // protocol into a SIGKILL at the two-second client deadline.
699+ return time.After(5 * time.Second)
700+ }
701+ return time.After(2 * time.Second)
702+}
703+
704+func (p *cliProcess) forceProcessGroupCleanup() {
705+ if p.forceCleanup != nil {
706+ p.forceCleanup()
707+ return
708+ }
709+ if p.cmd != nil && p.cmd.Process != nil {
710+ // The client is a session/process-group leader; forced cleanup must
711+ // include its ssh seam/_stdio descendants.
712+ _ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGTERM)
713+ }
714+}
715+
716+func (p *cliProcess) forceProcessGroupKill() {
717+ if p.forceKill != nil {
718+ p.forceKill()
719+ return
720+ }
721+ if p.cmd != nil && p.cmd.Process != nil {
722+ _ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL)
723+ }
724+}
725+
726+// openPTY is the small Linux PTY boundary needed to drive the public terminal
727+// client. No daemon or adapter implementation is imported by this command.
728+func openPTY() (*os.File, *os.File, error) {
729+ masterFD, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0)
730+ if err != nil {
731+ return nil, nil, err
732+ }
733+ closeMaster := func(err error) (*os.File, *os.File, error) { _ = unix.Close(masterFD); return nil, nil, err }
734+ number, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN)
735+ if err != nil {
736+ return closeMaster(err)
737+ }
738+ if err := unix.IoctlSetPointerInt(masterFD, unix.TIOCSPTLCK, 0); err != nil {
739+ return closeMaster(err)
740+ }
741+ slaveFD, err := unix.Open(fmt.Sprintf("/dev/pts/%d", number), unix.O_RDWR|unix.O_NOCTTY, 0)
742+ if err != nil {
743+ return closeMaster(err)
744+ }
745+ return os.NewFile(uintptr(masterFD), "vev-client-master"), os.NewFile(uintptr(slaveFD), "vev-client-slave"), nil
746+}
747+
748+func inputMarker(input []byte) []byte {
749+ start := bytes.Index(input, []byte("__VEV_HARNESS_"))
750+ if start < 0 {
751+ return input
752+ }
753+ end := bytes.Index(input[start:], []byte(`\n`))
754+ if end < 0 {
755+ return input[start:]
756+ }
757+ return input[start : start+end]
758+}
A cmd/vev-perf-harness/launcher_test.go +326 -0
@@ -0,0 +1,326 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "net"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/bnema/vev/pkg/safedir"
16+)
17+
18+func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) {
19+ deadline := make(chan time.Time)
20+ close(deadline)
21+ var term, kill int
22+ p := &cliProcess{
23+ waitErr: make(chan error),
24+ waitTimeout: func() <-chan time.Time { return deadline },
25+ forceCleanup: func() { term++ },
26+ forceKill: func() { kill++ },
27+ }
28+ if err := p.Close(); err == nil {
29+ t.Fatal("stuck public role close succeeded")
30+ }
31+ if term != 1 || kill != 1 {
32+ t.Fatalf("TERM/KILL calls = %d/%d, want 1/1", term, kill)
33+ }
34+}
35+
36+func TestCLIProcessWaitReadyRequiresDaemonSocket(t *testing.T) {
37+ path := filepath.Join(t.TempDir(), "daemon.sock")
38+ var lc net.ListenConfig
39+ listener, err := lc.Listen(context.Background(), "unix", path)
40+ if err != nil {
41+ t.Fatal(err)
42+ }
43+ t.Cleanup(func() {
44+ if err := listener.Close(); err != nil {
45+ t.Error(err)
46+ }
47+ })
48+ if err := (&cliProcess{readyPath: path, waitErr: make(chan error, 1)}).WaitReady(); err != nil {
49+ t.Fatal(err)
50+ }
51+}
52+
53+func TestCLILauncherIsolatesRoleXDGEnvironmentAcrossRepetitions(t *testing.T) {
54+ captureDir := t.TempDir()
55+ t.Setenv("CAPTURE_DIR", captureDir)
56+ t.Setenv("XDG_RUNTIME_DIR", filepath.Join(t.TempDir(), "inherited-runtime"))
57+ t.Setenv("XDG_STATE_HOME", filepath.Join(t.TempDir(), "inherited-state"))
58+
59+ bin := filepath.Join(t.TempDir(), "capture-env")
60+ if err := os.WriteFile(bin, []byte("#!/bin/sh\nenv > \"$CAPTURE_DIR/$VEV_PERF_PROCESS_ID\"\n"), 0o700); err != nil {
61+ t.Fatal(err)
62+ }
63+ launcher := &cliLauncher{bin: bin}
64+ for run := 1; run <= 3; run++ {
65+ runDir := filepath.Join(t.TempDir(), fmt.Sprintf("run-%d", run))
66+ if err := safedir.EnsurePrivate(runDir); err != nil {
67+ t.Fatal(err)
68+ }
69+ roles := []string{"daemon", "client"}
70+ for _, role := range roles {
71+ id := fmt.Sprintf("run-%d-%s", run, role)
72+ p, err := launcher.Launch(processMapping{
73+ Role: role, TracePath: filepath.Join(runDir, role+".jsonl"), ProcessID: id,
74+ }, roleCommand{Args: []string{"test"}})
75+ if err != nil {
76+ t.Fatalf("run %d %s launch: %v", run, role, err)
77+ }
78+ runtimeDir := launcher.runtimes[runDir]
79+ if err := p.Close(); err != nil {
80+ t.Fatalf("run %d %s close: %v", run, role, err)
81+ }
82+
83+ env, err := os.ReadFile(filepath.Join(captureDir, id))
84+ if err != nil {
85+ t.Fatalf("run %d %s capture: %v", run, role, err)
86+ }
87+ for _, want := range []string{
88+ "XDG_RUNTIME_DIR=" + runtimeDir,
89+ "XDG_STATE_HOME=" + filepath.Join(runDir, "state"),
90+ } {
91+ name := strings.SplitN(want, "=", 2)[0]
92+ if count := bytes.Count(env, []byte(name+"=")); count != 1 {
93+ t.Fatalf("run %d %s %s entries = %d, want exactly one; env:\n%s", run, role, name, count, env)
94+ }
95+ if !bytes.Contains(env, []byte(want+"\n")) {
96+ t.Fatalf("run %d %s missing %q; env:\n%s", run, role, want, env)
97+ }
98+ }
99+ }
100+ if err := launcher.releaseRuntime(runDir); err != nil {
101+ t.Fatalf("run %d release runtime: %v", run, err)
102+ }
103+ }
104+}
105+
106+func TestPreparePeerReleasesOwnedRuntimeOnPreparationFailure(t *testing.T) {
107+ t.Setenv("TMPDIR", t.TempDir())
108+ runDir := filepath.Join(t.TempDir(), "run")
109+ if err := safedir.EnsurePrivate(runDir); err != nil {
110+ t.Fatal(err)
111+ }
112+ launcher := &cliLauncher{}
113+ _, err := launcher.preparePeer(processMapping{
114+ Role: "ssh_stdio_peer",
115+ TracePath: filepath.Join(runDir, "peer.jsonl"),
116+ }, roleCommand{Args: []string{"invalid"}})
117+ if err == nil || !strings.Contains(err.Error(), "unsupported public peer command") {
118+ t.Fatalf("preparePeer error = %v", err)
119+ }
120+ if runtimeDir := launcher.runtimes[runDir]; runtimeDir != "" {
121+ t.Fatalf("failed peer preparation leaked runtime directory %q", runtimeDir)
122+ }
123+ if paths, globErr := filepath.Glob(filepath.Join(os.TempDir(), "vev-harness-runtime-*")); globErr != nil || len(paths) != 0 {
124+ t.Fatalf("failed peer preparation left runtime directories %q: %v", paths, globErr)
125+ }
126+}
127+
128+func TestCLIProcessCloseUsesPublicDaemonShutdown(t *testing.T) {
129+ wait := make(chan error, 1)
130+ wait <- nil
131+ var shutdown int
132+ p := &cliProcess{waitErr: wait, shutdown: func() error { shutdown++; return nil }}
133+ if err := p.Close(); err != nil {
134+ t.Fatal(err)
135+ }
136+ if shutdown != 0 {
137+ t.Fatalf("shutdown calls = %d, want 0 for an exited daemon", shutdown)
138+ }
139+
140+ wait = make(chan error, 1)
141+ p = &cliProcess{waitErr: wait, shutdown: func() error { shutdown++; wait <- nil; return nil }}
142+ if err := p.Close(); err != nil {
143+ t.Fatal(err)
144+ }
145+ if shutdown != 1 {
146+ t.Fatalf("shutdown calls = %d, want 1", shutdown)
147+ }
148+}
149+
150+func TestHarnessCanonicalLocalSmokeRealRoles(t *testing.T) {
151+ if testing.Short() {
152+ t.Skip("public CLI smoke")
153+ }
154+ root, err := filepath.Abs(filepath.Join("..", ".."))
155+ if err != nil {
156+ t.Fatal(err)
157+ }
158+ bin := filepath.Join(t.TempDir(), "vev")
159+ build := exec.Command("go", "build", "-o", bin, "./")
160+ build.Dir = root
161+ if output, err := build.CombinedOutput(); err != nil {
162+ t.Fatalf("build public CLI: %v\n%s", err, output)
163+ }
164+ m, err := readManifest(filepath.Join(root, "testdata", "perf", "manifest.json"))
165+ if err != nil {
166+ t.Fatal(err)
167+ }
168+ if err := validateManifest(m); err != nil {
169+ t.Fatal(err)
170+ }
171+ var local scenario
172+ for _, s := range m.Scenarios {
173+ if s.Topology == "1x4" && s.Workload == "idle" && s.Transport == "local" {
174+ local = s
175+ break
176+ }
177+ }
178+ if local.ID == "" {
179+ t.Fatal("canonical local scenario missing")
180+ }
181+ dir := t.TempDir()
182+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
183+ if err != nil {
184+ t.Fatal(err)
185+ }
186+ closeTestFile(t, raw)
187+ h := defaultHarness()
188+ h.clock = &fakeClock{} // injected duration keeps the public 30s contract bounded in test
189+ h.launcher = &cliLauncher{bin: bin}
190+ result, err := h.runOne(options{vevBin: bin, out: dir, duration: minimumDuration}, m, local, 1, raw)
191+ if err != nil {
192+ t.Fatal(err)
193+ }
194+ if result.Samples < minimumInIntervalEventSamples || len(result.Processes) != 2 {
195+ t.Fatalf("canonical local smoke result=%+v", result)
196+ }
197+}
198+
199+func TestTraceEnvironmentMatchesProcessMapping(t *testing.T) {
200+ mapping := processMapping{TracePath: "/tmp/trace.jsonl", ProcessID: "p-01", Scenario: "1x4-idle-local", Run: 3}
201+ got := strings.Join(traceEnvironment(mapping), "\n")
202+ 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"} {
203+ if !strings.Contains(got, want) {
204+ t.Errorf("trace environment missing %q: %s", want, got)
205+ }
206+ }
207+}
208+
209+func TestCLIProcessCloseGracefulDetachCompletesSpanBeforeForcedCleanup(t *testing.T) {
210+ var order []string
211+ waitErr := make(chan error, 1)
212+ timeout := make(chan time.Time)
213+ p := &cliProcess{
214+ pty: &orderedPTY{
215+ write: func(input []byte) {
216+ if string(input) != "exit\n" {
217+ t.Fatalf("graceful exit input=%q", input)
218+ }
219+ order = append(order, "exit_written")
220+ // The graceful client exit has closed its transport and emitted
221+ // its adapter receive-end mark before cmd.Wait completes.
222+ order = append(order, "adapter_receive_end")
223+ waitErr <- nil
224+ },
225+ close: func() { order = append(order, "pty_closed") },
226+ },
227+ output: &orderedOutput{close: func() { order = append(order, "output_closed") }},
228+ waitErr: waitErr,
229+ waitTimeout: func() <-chan time.Time { return timeout },
230+ forceCleanup: func() {
231+ order = append(order, "forced_process_group_cleanup")
232+ },
233+ }
234+
235+ if err := p.Close(); err != nil {
236+ t.Fatal(err)
237+ }
238+ if !equalStrings(order, []string{"exit_written", "adapter_receive_end", "pty_closed", "output_closed"}) {
239+ t.Fatalf("cleanup order=%q", order)
240+ }
241+}
242+
243+func TestCLIProcessWarmupWaitsForDelayedTerminalReadiness(t *testing.T) {
244+ allowWrite := make(chan struct{})
245+ pty, output := &stagedPTY{allow: allowWrite, writes: make(chan []byte, 1)}, &fakeOutput{}
246+ p := &cliProcess{pty: pty, output: output, chunks: make(chan []byte)}
247+ input := workloadInput(scenario{ID: "s"}, 1, "warmup")
248+ done := make(chan error, 1)
249+ go func() { done <- p.Warmup(input) }()
250+
251+ // This is public client output from the initial application prompt/state,
252+ // emitted only after the client has entered raw mode. A premature PTY write
253+ // blocks on allowWrite and therefore cannot consume this delayed readiness.
254+ go func() {
255+ p.chunks <- []byte("shell prompt$ ")
256+ close(allowWrite)
257+ }()
258+ select {
259+ case got := <-pty.writes:
260+ if !bytes.Equal(got, input) {
261+ t.Fatalf("warmup write=%q want %q", got, input)
262+ }
263+ case <-time.After(time.Second):
264+ t.Fatal("warmup did not wait for and then proceed from client readiness")
265+ }
266+ go func() { p.chunks <- append([]byte("application "), inputMarker(input)...); close(p.chunks) }()
267+ if err := <-done; err != nil {
268+ t.Fatal(err)
269+ }
270+ if output.syncs != 1 {
271+ t.Fatalf("readiness output was not flushed: syncs=%d", output.syncs)
272+ }
273+}
274+
275+func TestCLIProcessRejectsPTYLocalEchoAsFlushEvidence(t *testing.T) {
276+ pty, output := &fakePTY{}, &fakeOutput{}
277+ p := &cliProcess{pty: pty, output: output, chunks: make(chan []byte, 1)}
278+ input := workloadInput(scenario{ID: "s"}, 1, "measured-1")
279+ p.chunks <- newPTYLocalEcho(input).expected
280+ close(p.chunks)
281+ flushed := false
282+ if err := p.Measure(input, func() error { return nil }, func() error { flushed = true; return nil }); err == nil {
283+ t.Fatal("local PTY echo satisfied terminal flush boundary")
284+ }
285+ if flushed || output.syncs != 0 {
286+ t.Fatalf("local echo stamped a flush: flushed=%t syncs=%d", flushed, output.syncs)
287+ }
288+}
289+
290+func TestCLIProcessPairsApplicationOutputWithSuccessfulFlush(t *testing.T) {
291+ pty, output := &fakePTY{}, &fakeOutput{}
292+ p := &cliProcess{pty: pty, output: output, chunks: make(chan []byte, 1)}
293+ input := workloadInput(scenario{ID: "s"}, 1, "measured-1")
294+ echo := newPTYLocalEcho(input).expected
295+ p.chunks <- append(append([]byte(nil), echo...), append([]byte("application "), inputMarker(input)...)...)
296+ order := []string{}
297+ if err := p.Measure(input, func() error { order = append(order, "injected"); return nil }, func() error { order = append(order, "flushed"); return nil }); err != nil {
298+ t.Fatal(err)
299+ }
300+ if len(pty.writes) != 1 || output.syncs != 1 || !equalStrings(order, []string{"injected", "flushed"}) {
301+ t.Fatalf("writes=%q syncs=%d boundary order=%q", pty.writes, output.syncs, order)
302+ }
303+}
304+
305+func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) {
306+ for _, tc := range []struct {
307+ role string
308+ want []string
309+ }{
310+ {"daemon", []string{"--daemon"}},
311+ {"client", []string{"new", "perf-s-001"}},
312+ {"ssh_stdio_peer", []string{"_stdio", "perf-s-001"}},
313+ {"udp_peer", []string{"_udp-proxy", "perf-s-001"}},
314+ } {
315+ t.Run(tc.role, func(t *testing.T) {
316+ got := roleArgs(scenario{ID: "s", Transport: "local"}, processMapping{Role: tc.role, Run: 1})
317+ if !equalStrings(got.Args, tc.want) {
318+ t.Fatalf("args=%q want %q", got.Args, tc.want)
319+ }
320+ })
321+ }
322+ input := string(workloadInput(scenario{ID: "s", Workload: "interactive_flood"}, 1, "measured-1"))
323+ 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") {
324+ t.Fatalf("workload is not real PTY shell input with an observable marker: %q", input)
325+ }
326+}
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 +254 -0
@@ -0,0 +1,254 @@
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+var (
14+ canonicalTopologyIDs = []string{"1x4", "4x1", "4x4", "8x1"}
15+ canonicalTopologySet = stringSet(canonicalTopologyIDs)
16+ canonicalWorkloadIDs = []string{
17+ "idle", "active_output", "all_output", "inactive_output", "interactive_flood",
18+ "copy_search", "resize_sweep", "snapshot_output_resize", "attach_restore_tab_switch",
19+ }
20+ canonicalWorkloadSet = stringSet(canonicalWorkloadIDs)
21+)
22+
23+func stringSet(ids []string) map[string]bool {
24+ set := make(map[string]bool, len(ids))
25+ for _, id := range ids {
26+ set[id] = true
27+ }
28+ return set
29+}
30+
31+func parseOptions(args []string) (options, error) {
32+ var o options
33+ fs := flag.NewFlagSet("vev-perf-harness", flag.ContinueOnError)
34+ fs.SetOutput(io.Discard)
35+ fs.StringVar(&o.vevBin, "vev-bin", "", "public vev binary")
36+ fs.StringVar(&o.manifest, "manifest", "", "manifest")
37+ fs.StringVar(&o.out, "out", "", "output directory")
38+ fs.StringVar(&o.scenario, "scenario", "", "one canonical scenario ID (after full manifest validation)")
39+ fs.DurationVar(&o.warmup, "warmup", 0, "warmup")
40+ fs.DurationVar(&o.duration, "duration", 0, "measurement duration")
41+ fs.IntVar(&o.repetitions, "repetitions", 0, "repetitions")
42+ if err := fs.Parse(args); err != nil {
43+ return o, err
44+ }
45+ seen := map[string]bool{}
46+ fs.Visit(func(f *flag.Flag) { seen[f.Name] = true })
47+ for _, name := range []string{"vev-bin", "manifest", "out", "warmup", "duration", "repetitions"} {
48+ if !seen[name] {
49+ return o, fmt.Errorf("--%s is required", name)
50+ }
51+ }
52+ if o.vevBin == "" || o.manifest == "" || o.out == "" {
53+ return o, errors.New("--vev-bin, --manifest, and --out are required")
54+ }
55+ if o.warmup < 0 {
56+ return o, errors.New("--warmup must not be negative")
57+ }
58+ if o.duration < minimumDuration {
59+ return o, fmt.Errorf("--duration must be at least %s", minimumDuration)
60+ }
61+ if o.repetitions < minimumRepetitions {
62+ return o, fmt.Errorf("--repetitions must be at least %d", minimumRepetitions)
63+ }
64+ return o, nil
65+}
66+
67+// resolvePathOptions captures the harness cwd once, before role launchers
68+// assign their isolated working directories. All downstream filesystem access,
69+// manifests, and process environments consequently refer to the same paths.
70+func resolvePathOptions(o options) (options, error) {
71+ cwd, err := os.Getwd()
72+ if err != nil {
73+ return o, fmt.Errorf("resolve harness working directory: %w", err)
74+ }
75+ absolute := func(path string) string {
76+ if filepath.IsAbs(path) {
77+ return filepath.Clean(path)
78+ }
79+ return filepath.Clean(filepath.Join(cwd, path))
80+ }
81+ o.vevBin = absolute(o.vevBin)
82+ o.manifest = absolute(o.manifest)
83+ o.out = absolute(o.out)
84+ return o, nil
85+}
86+
87+func readManifest(path string) (manifest, error) {
88+ f, err := os.Open(path)
89+ if err != nil {
90+ return manifest{}, err
91+ }
92+ var m manifest
93+ if err := json.NewDecoder(f).Decode(&m); err != nil {
94+ return manifest{}, closeFile(f, err)
95+ }
96+ if err := f.Close(); err != nil {
97+ return manifest{}, err
98+ }
99+ return m, nil
100+}
101+
102+func validateManifest(m manifest) error {
103+ if m.Schema != 1 {
104+ return errors.New("unsupported manifest schema")
105+ }
106+ tops := map[string]bool{}
107+ for _, t := range m.Topologies {
108+ if t.ID == "" {
109+ return errors.New("empty topology id")
110+ }
111+ if tops[t.ID] {
112+ return fmt.Errorf("duplicate topology id %q", t.ID)
113+ }
114+ if !canonicalTopologySet[t.ID] {
115+ return fmt.Errorf("unknown topology id %q", t.ID)
116+ }
117+ if t.Geometry != "120x40" || t.RowsPerPane != 10000 {
118+ return fmt.Errorf("invalid topology %q", t.ID)
119+ }
120+ tops[t.ID] = true
121+ }
122+ for _, v := range canonicalTopologyIDs {
123+ if !tops[v] {
124+ return fmt.Errorf("missing canonical topology %s", v)
125+ }
126+ }
127+ works := map[string]bool{}
128+ for _, workload := range m.Workloads {
129+ if workload == "" {
130+ return errors.New("empty workload id")
131+ }
132+ if works[workload] {
133+ return fmt.Errorf("duplicate workload id %q", workload)
134+ }
135+ if !canonicalWorkloadSet[workload] {
136+ return fmt.Errorf("unknown workload id %q", workload)
137+ }
138+ works[workload] = true
139+ }
140+ for _, v := range canonicalWorkloadIDs {
141+ if !works[v] {
142+ return fmt.Errorf("missing canonical workload %s", v)
143+ }
144+ }
145+ trans := map[string]bool{}
146+ for _, t := range m.Transports {
147+ if t.ID == "" {
148+ return errors.New("empty transport id")
149+ }
150+ if trans[t.ID] {
151+ return fmt.Errorf("duplicate transport id %q", t.ID)
152+ }
153+ if err := validateTransportFixture(t); err != nil {
154+ return err
155+ }
156+ trans[t.ID] = true
157+ }
158+ for _, v := range []string{"local", "ssh_stdio", "udp_baseline", "udp_25ms", "udp_100ms", "udp_loss_0pct", "udp_loss_1pct"} {
159+ if !trans[v] {
160+ return fmt.Errorf("missing canonical transport %s", v)
161+ }
162+ }
163+ seen := map[string]bool{}
164+ covered := map[string]bool{}
165+ for _, s := range m.Scenarios {
166+ if s.ID == "" || seen[s.ID] {
167+ return fmt.Errorf("duplicate or empty scenario id %q", s.ID)
168+ }
169+ seen[s.ID] = true
170+ if !tops[s.Topology] || !works[s.Workload] || !trans[s.Transport] {
171+ return fmt.Errorf("scenario %s references unknown matrix entry", s.ID)
172+ }
173+ if s.InapplicableReason == "" && len(s.Roles) == 0 {
174+ return fmt.Errorf("scenario %s has no process roles", s.ID)
175+ }
176+ if s.InapplicableReason != "" && len(s.Roles) != 0 {
177+ return fmt.Errorf("inapplicable scenario %s must not launch roles", s.ID)
178+ }
179+ if s.InapplicableReason == "" && !equalRoleSet(s.Roles, requiredRoles(s.Transport)) {
180+ return fmt.Errorf("scenario %s roles do not route transport %s", s.ID, s.Transport)
181+ }
182+ covered[s.Topology+"\x00"+s.Workload+"\x00"+s.Transport] = true
183+ }
184+ for topologyID := range tops {
185+ for workload := range works {
186+ for transportID := range trans {
187+ key := topologyID + "\x00" + workload + "\x00" + transportID
188+ if !covered[key] {
189+ return fmt.Errorf("missing scenario or inapplicable reason for %s/%s/%s", topologyID, workload, transportID)
190+ }
191+ }
192+ }
193+ }
194+ return nil
195+}
196+
197+// selectScenario is intentionally called only after validateManifest. A bounded
198+// local evidence run therefore cannot mask an incomplete canonical matrix.
199+func selectScenario(m manifest, id string) ([]scenario, error) {
200+ for _, s := range m.Scenarios {
201+ if s.ID != id {
202+ continue
203+ }
204+ if s.InapplicableReason != "" {
205+ return nil, fmt.Errorf("scenario %s is inapplicable: %s", id, s.InapplicableReason)
206+ }
207+ return []scenario{s}, nil
208+ }
209+ return nil, fmt.Errorf("unknown canonical scenario %s", id)
210+}
211+
212+func requiredRoles(transportID string) []string {
213+ switch transportID {
214+ case "local":
215+ return []string{"daemon", "client"}
216+ case "ssh_stdio":
217+ return []string{"daemon", "client", "ssh_stdio_peer"}
218+ default:
219+ return []string{"daemon", "client", "udp_peer"}
220+ }
221+}
222+
223+func equalRoleSet(got, want []string) bool {
224+ if len(got) != len(want) {
225+ return false
226+ }
227+ seen := make(map[string]bool, len(got))
228+ for _, role := range got {
229+ seen[role] = true
230+ }
231+ for _, role := range want {
232+ if !seen[role] {
233+ return false
234+ }
235+ }
236+ return true
237+}
238+
239+func validateTransportFixture(t transport) error {
240+ want := map[string]transport{
241+ "local": {ID: "local", Kind: "local"},
242+ "ssh_stdio": {ID: "ssh_stdio", Kind: "ssh_stdio"},
243+ "udp_baseline": {ID: "udp_baseline", Kind: "udp"},
244+ "udp_25ms": {ID: "udp_25ms", Kind: "udp", RTTMS: 25},
245+ "udp_100ms": {ID: "udp_100ms", Kind: "udp", RTTMS: 100},
246+ "udp_loss_0pct": {ID: "udp_loss_0pct", Kind: "udp", LossPercent: 0},
247+ "udp_loss_1pct": {ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1},
248+ }
249+ w, ok := want[t.ID]
250+ if !ok || t.Kind != w.Kind || t.RTTMS != w.RTTMS || t.LossPercent != w.LossPercent {
251+ return fmt.Errorf("invalid canonical transport fixture %+v", t)
252+ }
253+ return nil
254+}
A cmd/vev-perf-harness/options_manifest_test.go +279 -0
@@ -0,0 +1,279 @@
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 TestValidateManifestRejectsEmptyAndDuplicateMatrixIDs(t *testing.T) {
123+ canonical, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json"))
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ for _, tc := range []struct {
128+ name string
129+ mutate func(*manifest)
130+ want string
131+ }{
132+ {"empty topology", func(m *manifest) { m.Topologies[0].ID = "" }, "empty topology id"},
133+ {"duplicate topology", func(m *manifest) { m.Topologies[1].ID = m.Topologies[0].ID }, "duplicate topology id"},
134+ {"empty workload", func(m *manifest) { m.Workloads[0] = "" }, "empty workload id"},
135+ {"duplicate workload", func(m *manifest) { m.Workloads[1] = m.Workloads[0] }, "duplicate workload id"},
136+ {"empty transport", func(m *manifest) { m.Transports[0].ID = "" }, "empty transport id"},
137+ {"duplicate transport", func(m *manifest) { m.Transports[1].ID = m.Transports[0].ID }, "duplicate transport id"},
138+ } {
139+ t.Run(tc.name, func(t *testing.T) {
140+ m := canonical
141+ m.Topologies = append([]topology(nil), canonical.Topologies...)
142+ m.Workloads = append([]string(nil), canonical.Workloads...)
143+ m.Transports = append([]transport(nil), canonical.Transports...)
144+ tc.mutate(&m)
145+ if err := validateManifest(m); err == nil || !strings.Contains(err.Error(), tc.want) {
146+ t.Fatalf("validateManifest() error = %v, want %q", err, tc.want)
147+ }
148+ })
149+ }
150+}
151+
152+func TestValidateManifestRejectsFullyCoveredUnknownMatrixIDs(t *testing.T) {
153+ canonical, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json"))
154+ if err != nil {
155+ t.Fatal(err)
156+ }
157+ for _, tc := range []struct {
158+ name string
159+ mutate func(*manifest)
160+ want string
161+ }{
162+ {
163+ name: "topology",
164+ mutate: func(m *manifest) {
165+ m.Topologies = append(m.Topologies, topology{ID: "9x9", Geometry: "120x40", RowsPerPane: 10000})
166+ for _, s := range canonical.Scenarios {
167+ s.ID = "9x9-" + s.ID
168+ s.Topology = "9x9"
169+ m.Scenarios = append(m.Scenarios, s)
170+ }
171+ },
172+ want: "unknown topology id \"9x9\"",
173+ },
174+ {
175+ name: "workload",
176+ mutate: func(m *manifest) {
177+ m.Workloads = append(m.Workloads, "unknown_workload")
178+ for _, s := range canonical.Scenarios {
179+ s.ID = "unknown-workload-" + s.ID
180+ s.Workload = "unknown_workload"
181+ m.Scenarios = append(m.Scenarios, s)
182+ }
183+ },
184+ want: "unknown workload id \"unknown_workload\"",
185+ },
186+ } {
187+ t.Run(tc.name, func(t *testing.T) {
188+ m := canonical
189+ m.Topologies = append([]topology(nil), canonical.Topologies...)
190+ m.Workloads = append([]string(nil), canonical.Workloads...)
191+ m.Scenarios = append([]scenario(nil), canonical.Scenarios...)
192+ tc.mutate(&m)
193+ if err := validateManifest(m); err == nil || !strings.Contains(err.Error(), tc.want) {
194+ t.Fatalf("validateManifest() error = %v, want %q", err, tc.want)
195+ }
196+ })
197+ }
198+}
199+
200+func TestHarnessManifestCoversCanonicalMatrix(t *testing.T) {
201+ m, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json"))
202+ if err != nil {
203+ t.Fatal(err)
204+ }
205+ if err := validateManifest(m); err != nil {
206+ t.Fatal(err)
207+ }
208+ if len(m.Scenarios) != 4*9*7 {
209+ t.Fatalf("scenarios=%d", len(m.Scenarios))
210+ }
211+ m.Scenarios = m.Scenarios[:len(m.Scenarios)-1]
212+ if err := validateManifest(m); err == nil {
213+ t.Fatal("missing combination accepted")
214+ }
215+}
216+
217+func TestHarnessCreatesExclusiveTraceManifestAndEvidence(t *testing.T) {
218+ dir := t.TempDir()
219+ raw, err := os.OpenFile(filepath.Join(dir, "raw-harness.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
220+ if err != nil {
221+ t.Fatal(err)
222+ }
223+ closeTestFile(t, raw)
224+ launcher := &fakeLauncher{}
225+ h := defaultHarness()
226+ h.clock = &fakeClock{}
227+ h.launcher = launcher
228+ o := options{vevBin: "ignored", out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}
229+ r, err := h.runOne(o, manifest{}, scenario{ID: "4x4-local", Roles: []string{"daemon", "client"}}, 1, raw)
230+ if err != nil {
231+ t.Fatal(err)
232+ }
233+ if !launcher.manifestPresent {
234+ t.Fatal("launched before complete run manifest")
235+ }
236+ if len(r.EndToEnd) < minimumInIntervalEventSamples || r.Samples < minimumInIntervalEventSamples {
237+ t.Fatalf("samples=%+v", r)
238+ }
239+ seen := map[string]bool{}
240+ for _, m := range r.Processes {
241+ if m.ProcessID == "" || m.ClockDomain != m.ProcessID || m.TracePath == "" || seen[m.TracePath] {
242+ t.Fatalf("bad mapping %+v", m)
243+ }
244+ seen[m.TracePath] = true
245+ if _, err := os.Stat(m.TracePath); err != nil {
246+ t.Fatal(err)
247+ }
248+ }
249+ b, err := os.ReadFile(filepath.Join(dir, "4x4-local-run-001", "manifest.json"))
250+ if err != nil {
251+ t.Fatal(err)
252+ }
253+ var got runManifest
254+ if err := json.Unmarshal(b, &got); err != nil {
255+ t.Fatal(err)
256+ }
257+ if len(got.Processes) != 2 {
258+ t.Fatalf("manifest=%+v", got)
259+ }
260+ 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 {
261+ t.Fatalf("process lifecycle not recorded: %+v", launcher.process)
262+ }
263+}
264+
265+func TestHarnessRejectsMissingManifestRoleTrace(t *testing.T) {
266+ dir := t.TempDir()
267+ missing := filepath.Join(dir, "daemon.jsonl")
268+ client := filepath.Join(dir, "client.jsonl")
269+ if err := os.WriteFile(client, nil, 0o600); err != nil {
270+ t.Fatal(err)
271+ }
272+ _, err := mergeProcessTraces([]processMapping{
273+ {ProcessID: "daemon", ClockDomain: "daemon", TracePath: missing, Role: "daemon", Scenario: "s", Run: 1},
274+ {ProcessID: "client", ClockDomain: "client", TracePath: client, Role: "client", Scenario: "s", Run: 1},
275+ })
276+ if !errors.Is(err, os.ErrNotExist) || !strings.Contains(err.Error(), "daemon role") {
277+ t.Fatalf("missing daemon trace error = %v, want daemon role not-exist error", err)
278+ }
279+}
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 +458 -0
@@ -0,0 +1,458 @@
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+ "github.com/bnema/vev/pkg/safedir"
16+)
17+
18+func TestHarnessCanonicalLocalRolesAreIsolatedAcrossRepetitions(t *testing.T) {
19+ if testing.Short() {
20+ t.Skip("public CLI repetition lifecycle")
21+ }
22+ root, err := filepath.Abs(filepath.Join("..", ".."))
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ bin := filepath.Join(t.TempDir(), "vev")
27+ build := exec.Command("go", "build", "-o", bin, "./")
28+ build.Dir = root
29+ if output, err := build.CombinedOutput(); err != nil {
30+ t.Fatalf("build public CLI: %v\n%s", err, output)
31+ }
32+ m, err := readManifest(filepath.Join(root, "testdata", "perf", "manifest.json"))
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ var local scenario
37+ for _, candidate := range m.Scenarios {
38+ if candidate.ID == "1x4-idle-local" {
39+ local = candidate
40+ break
41+ }
42+ }
43+ if local.ID == "" {
44+ t.Fatal("canonical local scenario missing")
45+ }
46+ out, err := os.MkdirTemp("", "vev-ob-")
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ removeTestTree(t, out)
51+ raw, err := os.OpenFile(filepath.Join(out, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ closeTestFile(t, raw)
56+ h := defaultHarness()
57+ h.clock = &fakeClock{}
58+ h.launcher = &cliLauncher{bin: bin}
59+ for run := 1; run <= 3; run++ {
60+ result, err := h.runOne(options{vevBin: bin, out: out, duration: minimumDuration}, m, local, run, raw)
61+ if err != nil {
62+ t.Fatalf("run %d: %v", run, err)
63+ }
64+ if len(result.Processes) != 2 {
65+ t.Fatalf("run %d process mappings = %+v", run, result.Processes)
66+ }
67+ runDir := filepath.Join(out, fmt.Sprintf("%s-run-%03d", safeName(local.ID), run))
68+ manifestBytes, err := os.ReadFile(filepath.Join(runDir, "manifest.json"))
69+ if err != nil {
70+ t.Fatalf("run %d manifest: %v", run, err)
71+ }
72+ var persisted runManifest
73+ if err := json.Unmarshal(manifestBytes, &persisted); err != nil {
74+ t.Fatalf("run %d manifest JSON: %v", run, err)
75+ }
76+ if persisted.Run != run || len(persisted.Processes) != len(result.Processes) {
77+ t.Fatalf("run %d manifest = %+v", run, persisted)
78+ }
79+ for _, process := range result.Processes {
80+ if process.Role != "daemon" && process.Role != "client" {
81+ t.Fatalf("run %d unexpected role %q", run, process.Role)
82+ }
83+ if filepath.Dir(process.TracePath) != runDir {
84+ t.Fatalf("run %d %s trace outside its isolated run directory: %q", run, process.Role, process.TracePath)
85+ }
86+ trace, err := os.ReadFile(process.TracePath)
87+ if err != nil {
88+ t.Fatalf("run %d %s trace %q: %v", run, process.Role, process.TracePath, err)
89+ }
90+ if len(bytes.TrimSpace(trace)) == 0 {
91+ t.Fatalf("run %d %s did not emit its declared trace", run, process.Role)
92+ }
93+ for _, line := range bytes.Split(bytes.TrimSpace(trace), []byte{'\n'}) {
94+ var record traceRecord
95+ if err := json.Unmarshal(line, &record); err != nil {
96+ t.Fatalf("run %d %s trace JSON: %v", run, process.Role, err)
97+ }
98+ if record.ProcessID != process.ProcessID || record.Scenario != local.ID || record.Run != uint64(run) {
99+ t.Fatalf("run %d %s trace escaped its manifest identity: %+v", run, process.Role, record)
100+ }
101+ }
102+ }
103+ if _, err := os.Stat(filepath.Join(runDir, "runtime", "vev", "daemon.sock")); !errors.Is(err, os.ErrNotExist) {
104+ t.Fatalf("run %d left daemon socket for the next repetition: %v", run, err)
105+ }
106+ }
107+}
108+
109+func TestHarnessResolvesCanonicalRelativePathsBeforeRoleCWDChanges(t *testing.T) {
110+ if testing.Short() {
111+ t.Skip("public CLI relative-path lifecycle")
112+ }
113+ root, err := filepath.Abs(filepath.Join("..", ".."))
114+ if err != nil {
115+ t.Fatal(err)
116+ }
117+ // Keep the real Unix-socket path below its platform limit while testing the
118+ // exact documented relative output hierarchy.
119+ workspace, err := os.MkdirTemp("", "vev-rel-")
120+ if err != nil {
121+ t.Fatal(err)
122+ }
123+ removeTestTree(t, workspace)
124+ bin := filepath.Join(workspace, "vev")
125+ build := exec.Command("go", "build", "-o", bin, "./")
126+ build.Dir = root
127+ if output, err := build.CombinedOutput(); err != nil {
128+ t.Fatalf("build public CLI: %v\n%s", err, output)
129+ }
130+ manifestPath := filepath.Join(workspace, "testdata", "perf", "manifest.json")
131+ if err := safedir.EnsurePrivate(filepath.Dir(manifestPath)); err != nil {
132+ t.Fatal(err)
133+ }
134+ contents, err := os.ReadFile(filepath.Join(root, "testdata", "perf", "manifest.json"))
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ if err := os.WriteFile(manifestPath, contents, 0o600); err != nil {
139+ t.Fatal(err)
140+ }
141+ originalWD, err := os.Getwd()
142+ if err != nil {
143+ t.Fatal(err)
144+ }
145+ if err := os.Chdir(workspace); err != nil {
146+ t.Fatal(err)
147+ }
148+ t.Cleanup(func() {
149+ if err := os.Chdir(originalWD); err != nil {
150+ t.Errorf("restore cwd: %v", err)
151+ }
152+ })
153+
154+ // These are the documented relative locations. cliLauncher changes every
155+ // role to its run directory, so a failure to resolve them before launch
156+ // would make VEV_PERF_TRACE point at a nonexistent nested directory.
157+ h := defaultHarness()
158+ h.clock = &fakeClock{}
159+ if err := run([]string{
160+ "--vev-bin", "./vev",
161+ "--manifest", "testdata/perf/manifest.json",
162+ "--out", "testdata/perf/results",
163+ "--scenario", "1x4-idle-local",
164+ "--warmup", "0s",
165+ "--duration", "30s",
166+ "--repetitions", "10",
167+ }, h); err != nil {
168+ t.Fatal(err)
169+ }
170+
171+ out := filepath.Join(workspace, "testdata", "perf", "results")
172+ for run := 1; run <= 10; run++ {
173+ runDir := filepath.Join(out, fmt.Sprintf("1x4-idle-local-run-%03d", run))
174+ manifestBytes, err := os.ReadFile(filepath.Join(runDir, "manifest.json"))
175+ if err != nil {
176+ t.Fatalf("run %d manifest: %v", run, err)
177+ }
178+ var persisted runManifest
179+ if err := json.Unmarshal(manifestBytes, &persisted); err != nil {
180+ t.Fatalf("run %d manifest JSON: %v", run, err)
181+ }
182+ if persisted.Run != run || len(persisted.Processes) != 2 {
183+ t.Fatalf("run %d manifest=%+v", run, persisted)
184+ }
185+ for _, process := range persisted.Processes {
186+ if !filepath.IsAbs(process.TracePath) || filepath.Dir(process.TracePath) != runDir {
187+ t.Fatalf("run %d %s trace path=%q, want absolute path in %q", run, process.Role, process.TracePath, runDir)
188+ }
189+ trace, err := os.ReadFile(process.TracePath)
190+ if err != nil || len(bytes.TrimSpace(trace)) == 0 {
191+ t.Fatalf("run %d %s trace=%q err=%v", run, process.Role, process.TracePath, err)
192+ }
193+ }
194+ }
195+}
196+
197+func TestHarnessRejectsExistingRunDirectoryBeforeLaunchingRoles(t *testing.T) {
198+ out := t.TempDir()
199+ raw, err := os.OpenFile(filepath.Join(out, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
200+ if err != nil {
201+ t.Fatal(err)
202+ }
203+ closeTestFile(t, raw)
204+ s := scenario{ID: "isolated", Roles: []string{"daemon", "client"}}
205+ stale := filepath.Join(out, "isolated-run-001")
206+ if err := os.Mkdir(stale, 0o700); err != nil {
207+ t.Fatal(err)
208+ }
209+ if err := os.WriteFile(filepath.Join(stale, "preserve"), []byte("stale"), 0o600); err != nil {
210+ t.Fatal(err)
211+ }
212+ launcher := &fakeLauncher{}
213+ h := defaultHarness()
214+ h.clock, h.launcher = &fakeClock{}, launcher
215+ _, err = h.runOne(options{out: out, duration: minimumDuration}, manifest{}, s, 1, raw)
216+ if err == nil || !strings.Contains(err.Error(), "create isolated run directory") {
217+ t.Fatalf("existing run directory error = %v", err)
218+ }
219+ if len(launcher.mappings) != 0 {
220+ t.Fatalf("roles launched against stale run directory: %+v", launcher.mappings)
221+ }
222+ if contents, readErr := os.ReadFile(filepath.Join(stale, "preserve")); readErr != nil || string(contents) != "stale" {
223+ t.Fatalf("stale directory changed: contents=%q err=%v", contents, readErr)
224+ }
225+}
226+
227+func TestHarnessDoesNotInheritNestedSession(t *testing.T) {
228+ env := withoutEnv([]string{"VEV=session=old", "PATH=/bin", "VEV_PERF_TRACE=old"}, "VEV")
229+ if !equalStrings(env, []string{"PATH=/bin", "VEV_PERF_TRACE=old"}) {
230+ t.Fatalf("environment=%q", env)
231+ }
232+}
233+
234+func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) {
235+ cases := []struct {
236+ name string
237+ transport transport
238+ peer string
239+ wantRTT int
240+ wantLoss int
241+ }{
242+ {"ssh", transport{ID: "ssh_stdio", Kind: "ssh_stdio"}, "ssh_stdio_peer", 0, 0},
243+ {"udp baseline", transport{ID: "udp_baseline", Kind: "udp"}, "udp_peer", 0, 0},
244+ {"udp 25ms", transport{ID: "udp_25ms", Kind: "udp", RTTMS: 25}, "udp_peer", 25, 0},
245+ {"udp 100ms", transport{ID: "udp_100ms", Kind: "udp", RTTMS: 100}, "udp_peer", 100, 0},
246+ {"udp loss zero", transport{ID: "udp_loss_0pct", Kind: "udp", LossPercent: 0}, "udp_peer", 0, 0},
247+ {"udp loss", transport{ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1}, "udp_peer", 0, 1},
248+ }
249+ for _, tc := range cases {
250+ t.Run(tc.name, func(t *testing.T) {
251+ s := scenario{ID: "route", Transport: tc.transport.ID}
252+ client := routeRoleArgs(s, processMapping{Role: "client", Run: 2}, tc.transport)
253+ peer := routeRoleArgs(s, processMapping{Role: tc.peer, Run: 2}, tc.transport)
254+ if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1:perf-route-002"}) {
255+ t.Fatalf("client did not use public remote attach: %q", client.Args)
256+ }
257+ if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio", "perf-route-002"}) {
258+ t.Fatalf("ssh peer command=%q", peer.Args)
259+ }
260+ if tc.peer == "udp_peer" && !equalStrings(peer.Args, []string{"_udp-proxy", "perf-route-002"}) {
261+ t.Fatalf("udp peer command=%q", peer.Args)
262+ }
263+ if peer.Transport.RTTMS != tc.wantRTT || peer.Transport.LossPercent != tc.wantLoss {
264+ t.Fatalf("peer lost manifest network settings: %+v", peer.Transport)
265+ }
266+ })
267+ }
268+}
269+
270+func TestHarnessFakeRunnerRoutesClientToPeerAndCleansEveryRole(t *testing.T) {
271+ for _, tc := range []struct {
272+ name, transport, peer, peerCommand string
273+ }{
274+ {"ssh", "ssh_stdio", "ssh_stdio_peer", "_stdio"},
275+ {"udp baseline", "udp_baseline", "udp_peer", "_udp-proxy"},
276+ {"udp 25ms", "udp_25ms", "udp_peer", "_udp-proxy"},
277+ {"udp 100ms", "udp_100ms", "udp_peer", "_udp-proxy"},
278+ {"udp loss zero", "udp_loss_0pct", "udp_peer", "_udp-proxy"},
279+ {"udp loss", "udp_loss_1pct", "udp_peer", "_udp-proxy"},
280+ } {
281+ t.Run(tc.name, func(t *testing.T) {
282+ dir := t.TempDir()
283+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
284+ if err != nil {
285+ t.Fatal(err)
286+ }
287+ closeTestFile(t, raw)
288+ l := &fakeLauncher{}
289+ h := defaultHarness()
290+ h.clock, h.launcher = &fakeClock{}, l
291+ s := scenario{ID: "routed", Transport: tc.transport, Roles: []string{"daemon", "client", tc.peer}}
292+ if _, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, s, 1, raw); err != nil {
293+ t.Fatal(err)
294+ }
295+ if len(l.mappings) != 3 || l.mappings[1].Role != tc.peer || l.mappings[2].Role != "client" {
296+ t.Fatalf("dependency launch order=%+v", l.mappings)
297+ }
298+ if l.commands[1].Args[0] != tc.peerCommand || l.commands[2].Args[0] != "attach" {
299+ t.Fatalf("commands do not connect client through declared peer: %+v", l.commands)
300+ }
301+ for i, p := range l.process {
302+ if !p.closed {
303+ t.Fatalf("role %s was not cleaned up", l.mappings[i].Role)
304+ }
305+ }
306+ })
307+ }
308+}
309+
310+func TestHarnessAggregatesRawEventsSeparatelyFromRunDispersion(t *testing.T) {
311+ first := append([]int64(nil), make([]int64, minimumInIntervalEventSamples+5)...)
312+ for i := range first {
313+ first[i] = 1
314+ }
315+ second := []int64{100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 1_000, 1_000}
316+ all, _, _, runP50s := aggregateEventResults([]runResult{
317+ {EndToEnd: first, Event: distribution{P50: 1}},
318+ {EndToEnd: second, Event: distribution{P50: 100}},
319+ })
320+ if got, dispersion := percentiles(all), percentiles(runP50s); got.Count != 30 || got.P99 != 1_000 || dispersion.Count != 2 || dispersion.P99 != 1 {
321+ t.Fatalf("event aggregation=%+v run dispersion=%+v", got, dispersion)
322+ }
323+}
324+
325+func TestHarnessRawRecordsContainEveryMarkWhileSamplesRemainFiltered(t *testing.T) {
326+ dir := t.TempDir()
327+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
328+ if err != nil {
329+ t.Fatal(err)
330+ }
331+ h := defaultHarness()
332+ h.clock, h.launcher = &fakeClock{}, &fakeLauncher{}
333+ result, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "raw-complete", Roles: []string{"daemon", "client"}}, 1, raw)
334+ if err != nil {
335+ t.Fatal(err)
336+ }
337+ if err := raw.Close(); err != nil {
338+ t.Fatal(err)
339+ }
340+ marks := readHarnessMarks(t, filepath.Join(dir, "raw.jsonl"))
341+ if got, want := len(marks), 2*(result.Samples+1); got != want {
342+ t.Fatalf("raw marks=%d, want warmup plus every measured boundary=%d", got, want)
343+ }
344+ for i, mark := range marks[:2] {
345+ if mark.Sequence != 1 || !mark.Valid || []string{"input_injected", "terminal_flushed"}[i] != mark.Kind {
346+ t.Fatalf("warmup mark %d=%+v", i, mark)
347+ }
348+ }
349+ for sequence := uint64(2); sequence <= uint64(result.Samples+1); sequence++ {
350+ if marks[2*(sequence-1)].Sequence != sequence || marks[2*(sequence-1)+1].Sequence != sequence {
351+ t.Fatalf("raw marks did not retain both boundaries for sequence %d: %+v", sequence, marks)
352+ }
353+ }
354+}
355+
356+func TestHarnessPersistsFailedFlushDiagnostics(t *testing.T) {
357+ dir := t.TempDir()
358+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
359+ if err != nil {
360+ t.Fatal(err)
361+ }
362+ failure := errors.New("flush failed")
363+ h := defaultHarness()
364+ h.clock, h.launcher = &fakeClock{}, failedFlushLauncher{err: failure}
365+ _, err = h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "failed-flush", Roles: []string{"daemon", "client"}}, 1, raw)
366+ if !errors.Is(err, failure) {
367+ t.Fatalf("run error=%v, want failed flush", err)
368+ }
369+ if err := raw.Close(); err != nil {
370+ t.Fatal(err)
371+ }
372+ marks := readHarnessMarks(t, filepath.Join(dir, "raw.jsonl"))
373+ if got, want := len(marks), 4; got != want {
374+ t.Fatalf("raw marks=%d, want warmup and failed measured pair=%d: %+v", got, want, marks)
375+ }
376+ failed := marks[len(marks)-1]
377+ if failed.Sequence != 2 || failed.Kind != "terminal_flushed" || failed.Valid {
378+ t.Fatalf("failed flush diagnostic=%+v", failed)
379+ }
380+}
381+
382+func TestHarnessSchedulesMeasuredEventsAcrossFullInterval(t *testing.T) {
383+ dir := t.TempDir()
384+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
385+ if err != nil {
386+ t.Fatal(err)
387+ }
388+ closeTestFile(t, raw)
389+ h := defaultHarness()
390+ h.clock, h.launcher = &fakeClock{}, &fakeLauncher{}
391+ result, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "schedule", Roles: []string{"daemon", "client"}}, 1, raw)
392+ if err != nil {
393+ t.Fatal(err)
394+ }
395+ if result.Samples < minimumInIntervalEventSamples || result.Cadence.Count != result.Samples-1 || result.MaxGap <= 0 {
396+ t.Fatalf("result did not include repeated measured events: %+v", result)
397+ }
398+ if err := raw.Close(); err != nil {
399+ t.Fatal(err)
400+ }
401+ records, err := readJSONL(filepath.Join(dir, "raw.jsonl"))
402+ if err != nil {
403+ t.Fatal(err)
404+ }
405+ if want := result.Samples*2 + 2; len(records) != want {
406+ t.Fatalf("raw records=%d, want %d warmup and measured boundaries", len(records), want)
407+ }
408+}
409+
410+func TestHarnessPreservesWorkloadAndCleanupErrors(t *testing.T) {
411+ dir := t.TempDir()
412+ raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
413+ if err != nil {
414+ t.Fatal(err)
415+ }
416+ closeTestFile(t, raw)
417+ primary := errors.New("primary workload failure")
418+ cleanup := errors.New("daemon cleanup failure")
419+ launcher := &fakeLauncher{
420+ measureErr: map[string]error{"client": primary},
421+ closeErr: map[string]error{"daemon": cleanup},
422+ }
423+ h := defaultHarness()
424+ h.clock, h.launcher = &fakeClock{}, launcher
425+ _, err = h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, scenario{ID: "errors", Roles: []string{"daemon", "client"}}, 1, raw)
426+ if !errors.Is(err, primary) || !errors.Is(err, cleanup) {
427+ t.Fatalf("workload and cleanup errors were not both preserved: %v", err)
428+ }
429+ if got, want := err.Error(), primary.Error()+"\nclose daemon role: "+cleanup.Error(); got != want {
430+ t.Fatalf("error ordering=%q want %q", got, want)
431+ }
432+ for i, p := range launcher.process {
433+ if !p.closed {
434+ t.Fatalf("role %s was not closed", launcher.mappings[i].Role)
435+ }
436+ }
437+}
438+
439+func TestHarnessCleansRunDirectoryOnProcessFailure(t *testing.T) {
440+ dir := t.TempDir()
441+ raw, err := os.OpenFile(filepath.Join(dir, "raw-harness.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
442+ if err != nil {
443+ t.Fatal(err)
444+ }
445+ closeTestFile(t, raw)
446+ launcher := &fakeLauncher{}
447+ h := defaultHarness()
448+ h.clock = &fakeClock{}
449+ // The client is the second role and fails after the daemon has started.
450+ h.launcher = &failingLauncher{fakeLauncher: launcher}
451+ o := options{vevBin: "ignored", out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}
452+ if _, err := h.runOne(o, manifest{}, scenario{ID: "cleanup", Roles: []string{"daemon", "client"}}, 1, raw); err == nil {
453+ t.Fatal("failed process accepted")
454+ }
455+ if _, err := os.Stat(filepath.Join(dir, "cleanup-run-001")); !os.IsNotExist(err) {
456+ t.Fatalf("failed run directory remains: %v", err)
457+ }
458+}
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 +273 -0
@@ -0,0 +1,273 @@
1+package main
2+
3+import (
4+ "container/heap"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "net"
9+ "os"
10+ "strconv"
11+ "strings"
12+ "sync"
13+ "sync/atomic"
14+ "time"
15+)
16+
17+// udpNetem is a harness-owned, user-space UDP relay. It sits between the
18+// public dgram client and vev's public _udp-proxy readiness address, so fixture
19+// impairment is real network carriage rather than an ignored vev option.
20+type udpNetem interface {
21+ Port() int
22+ Close() error
23+}
24+
25+type udpNetemFactory func(udpNetemConfig) (udpNetem, error)
26+
27+type udpNetemConfig struct {
28+ // RTT is split equally across both relay directions.
29+ RTT time.Duration
30+ LossPercent int
31+ TargetPath string
32+}
33+
34+// udpNetemQueueCapacity bounds both packets waiting to be scheduled and
35+// packets waiting for their delivery deadline. When it is full, the relay
36+// drops the arriving packet and records it in queueOverflowDrops.
37+const udpNetemQueueCapacity = 256
38+
39+type scheduledPacket struct {
40+ packet []byte
41+ to net.Addr
42+ due time.Time
43+}
44+
45+type scheduledPackets []scheduledPacket
46+
47+func (p scheduledPackets) Len() int { return len(p) }
48+func (p scheduledPackets) Less(i, j int) bool { return p[i].due.Before(p[j].due) }
49+func (p scheduledPackets) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
50+func (p *scheduledPackets) Push(x any) {
51+ packet, ok := x.(scheduledPacket)
52+ if !ok {
53+ panic(fmt.Sprintf("scheduled packet type %T", x))
54+ }
55+ *p = append(*p, packet)
56+}
57+func (p *scheduledPackets) Pop() any {
58+ old := *p
59+ n := len(old)
60+ item := old[n-1]
61+ *p = old[:n-1]
62+ return item
63+}
64+
65+type udpNetemRelay struct {
66+ config udpNetemConfig
67+ conn net.PacketConn
68+ done chan struct{}
69+ wg sync.WaitGroup
70+ once sync.Once
71+ closeErr error
72+
73+ // slots bounds queued and deadline-pending packets together. The reader and
74+ // the sole scheduler are the relay's only goroutines.
75+ slots chan struct{}
76+ queue chan scheduledPacket
77+ queueOverflowDrops atomic.Uint64
78+ deliveryErrors atomic.Uint64
79+
80+ mu sync.Mutex
81+ client, dst net.Addr
82+ packet uint64
83+}
84+
85+func newUDPNetem(config udpNetemConfig) (udpNetem, error) {
86+ if config.RTT < 0 || config.LossPercent < 0 || config.LossPercent > 100 || config.TargetPath == "" {
87+ return nil, fmt.Errorf("invalid UDP netem fixture: rtt=%s loss=%d target=%q", config.RTT, config.LossPercent, config.TargetPath)
88+ }
89+ var lc net.ListenConfig
90+ conn, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
91+ if err != nil {
92+ return nil, err
93+ }
94+ r := &udpNetemRelay{
95+ config: config,
96+ conn: conn,
97+ done: make(chan struct{}),
98+ slots: make(chan struct{}, udpNetemQueueCapacity),
99+ queue: make(chan scheduledPacket, udpNetemQueueCapacity),
100+ }
101+ r.wg.Add(2)
102+ go r.run()
103+ go r.schedule()
104+ return r, nil
105+}
106+
107+func (r *udpNetemRelay) Port() int {
108+ addr, ok := r.conn.LocalAddr().(*net.UDPAddr)
109+ if !ok {
110+ return 0
111+ }
112+ return addr.Port
113+}
114+
115+func (r *udpNetemRelay) Close() error {
116+ r.once.Do(func() {
117+ close(r.done)
118+ r.closeErr = r.conn.Close()
119+ r.wg.Wait()
120+ if drops := r.queueOverflowDrops.Load(); drops != 0 {
121+ r.closeErr = errors.Join(r.closeErr, fmt.Errorf("udp netem queue overflow drops: %d", drops))
122+ }
123+ if writes := r.deliveryErrors.Load(); writes != 0 {
124+ r.closeErr = errors.Join(r.closeErr, fmt.Errorf("udp netem delivery errors: %d", writes))
125+ }
126+ })
127+ return r.closeErr
128+}
129+
130+func (r *udpNetemRelay) run() {
131+ defer r.wg.Done()
132+ buf := make([]byte, 64*1024)
133+ for {
134+ n, from, err := r.conn.ReadFrom(buf)
135+ if err != nil {
136+ return
137+ }
138+ to, ok := r.destination(from)
139+ if !ok || r.drop() {
140+ continue
141+ }
142+ // Copy because PacketConn reuses buf on its next read.
143+ r.enqueue(scheduledPacket{packet: append([]byte(nil), buf[:n]...), to: to, due: time.Now().Add(r.config.RTT / 2)})
144+ }
145+}
146+
147+// enqueue implements the explicit overflow policy: never block the reader;
148+// drop a packet if the bounded scheduler capacity has been reached.
149+func (r *udpNetemRelay) enqueue(packet scheduledPacket) bool {
150+ select {
151+ case r.slots <- struct{}{}:
152+ case <-r.done:
153+ return false
154+ default:
155+ r.queueOverflowDrops.Add(1)
156+ return false
157+ }
158+ select {
159+ case r.queue <- packet:
160+ return true
161+ case <-r.done:
162+ <-r.slots
163+ return false
164+ }
165+}
166+
167+func (r *udpNetemRelay) schedule() {
168+ defer r.wg.Done()
169+ pending := scheduledPackets{}
170+ heap.Init(&pending)
171+ timer := time.NewTimer(time.Hour)
172+ if !timer.Stop() {
173+ <-timer.C
174+ }
175+ defer timer.Stop()
176+ var timerC <-chan time.Time
177+ resetTimer := func() {
178+ if timerC != nil && !timer.Stop() {
179+ select {
180+ case <-timer.C:
181+ default:
182+ }
183+ }
184+ if pending.Len() == 0 {
185+ timerC = nil
186+ return
187+ }
188+ delay := time.Until(pending[0].due)
189+ if delay < 0 {
190+ delay = 0
191+ }
192+ timer.Reset(delay)
193+ timerC = timer.C
194+ }
195+ for {
196+ resetTimer()
197+ select {
198+ case <-r.done:
199+ return
200+ case packet := <-r.queue:
201+ heap.Push(&pending, packet)
202+ case <-timerC:
203+ now := time.Now()
204+ for pending.Len() > 0 && !pending[0].due.After(now) {
205+ packet, ok := heap.Pop(&pending).(scheduledPacket)
206+ if !ok {
207+ panic("scheduled packet heap contained unexpected type")
208+ }
209+ select {
210+ case <-r.done:
211+ return
212+ default:
213+ if _, err := r.conn.WriteTo(packet.packet, packet.to); err != nil {
214+ select {
215+ case <-r.done:
216+ default:
217+ r.deliveryErrors.Add(1)
218+ }
219+ }
220+ <-r.slots
221+ }
222+ }
223+ }
224+ }
225+}
226+
227+func (r *udpNetemRelay) destination(from net.Addr) (net.Addr, bool) {
228+ r.mu.Lock()
229+ defer r.mu.Unlock()
230+ if r.dst == nil {
231+ dst, err := udpNetemTarget(r.config.TargetPath)
232+ if err != nil {
233+ return nil, false
234+ }
235+ r.dst = dst
236+ }
237+ if sameUDPAddress(from, r.dst) {
238+ return r.client, r.client != nil
239+ }
240+ r.client = from
241+ return r.dst, true
242+}
243+
244+func (r *udpNetemRelay) drop() bool {
245+ r.mu.Lock()
246+ defer r.mu.Unlock()
247+ r.packet++
248+ // The deterministic cycle makes the configured percentage auditable: a 1%
249+ // fixture drops exactly one carriage packet in every 100 attempts.
250+ return r.config.LossPercent > 0 && int((r.packet-1)%100) < r.config.LossPercent
251+}
252+
253+func udpNetemTarget(path string) (net.Addr, error) {
254+ line, err := os.ReadFile(path)
255+ if err != nil {
256+ return nil, err
257+ }
258+ fields := strings.Fields(string(line))
259+ if len(fields) != 3 || fields[0] != "VEV-UDP" {
260+ return nil, errors.New("invalid UDP proxy readiness")
261+ }
262+ port, err := strconv.Atoi(fields[1])
263+ if err != nil || port < 1 || port > 65535 || fields[2] == "" {
264+ return nil, errors.New("invalid UDP proxy readiness")
265+ }
266+ return &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: port}, nil
267+}
268+
269+func sameUDPAddress(a, b net.Addr) bool {
270+ ua, aOK := a.(*net.UDPAddr)
271+ ub, bOK := b.(*net.UDPAddr)
272+ return aOK && bOK && ua.Port == ub.Port && ua.IP.Equal(ub.IP)
273+}
A cmd/vev-perf-harness/udp_netem_test.go +149 -0
@@ -0,0 +1,149 @@
1+package main
2+
3+import (
4+ "context"
5+ "net"
6+ "os"
7+ "path/filepath"
8+ "strconv"
9+ "testing"
10+ "time"
11+)
12+
13+func closeUDPFixture(t *testing.T, c interface{ Close() error }) {
14+ t.Helper()
15+ t.Cleanup(func() {
16+ if err := c.Close(); err != nil {
17+ t.Error(err)
18+ }
19+ })
20+}
21+
22+func TestUDPNetemDropsWhenBoundedSchedulerIsFull(t *testing.T) {
23+ r := &udpNetemRelay{
24+ done: make(chan struct{}),
25+ slots: make(chan struct{}, udpNetemQueueCapacity),
26+ queue: make(chan scheduledPacket, udpNetemQueueCapacity),
27+ }
28+ packet := scheduledPacket{packet: []byte("bounded"), to: &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1}}
29+ for range udpNetemQueueCapacity {
30+ if !r.enqueue(packet) {
31+ t.Fatal("enqueue() dropped packet before scheduler capacity was full")
32+ }
33+ }
34+ if r.enqueue(packet) {
35+ t.Fatal("enqueue() accepted packet beyond scheduler capacity")
36+ }
37+ if got := r.queueOverflowDrops.Load(); got != 1 {
38+ t.Fatalf("queue overflow drops=%d, want 1", got)
39+ }
40+}
41+
42+func TestUDPNetemCloseRejectsQueueOverflow(t *testing.T) {
43+ netem, err := newUDPNetem(udpNetemConfig{TargetPath: filepath.Join(t.TempDir(), "target")})
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ relay, ok := netem.(*udpNetemRelay)
48+ if !ok {
49+ t.Fatalf("netem type=%T, want *udpNetemRelay", netem)
50+ }
51+ relay.queueOverflowDrops.Store(3)
52+ if err := netem.Close(); err == nil || err.Error() != "udp netem queue overflow drops: 3" {
53+ t.Fatalf("Close error=%v, want overflow rejection", err)
54+ }
55+}
56+
57+func TestUDPNetemExecutesRTTAndLossFixtures(t *testing.T) {
58+ fixtures := []struct {
59+ name string
60+ rtt time.Duration
61+ loss int
62+ sent int
63+ want int
64+ }{
65+ {"25ms RTT", 25 * time.Millisecond, 0, 1, 1},
66+ {"100ms RTT", 100 * time.Millisecond, 0, 1, 1},
67+ {"zero loss", 0, 0, 10, 10},
68+ {"one percent loss", 0, 1, 100, 99},
69+ }
70+ for _, tc := range fixtures {
71+ t.Run(tc.name, func(t *testing.T) {
72+ var lc net.ListenConfig
73+ target, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ closeUDPFixture(t, target)
78+ targetAddr, ok := target.LocalAddr().(*net.UDPAddr)
79+ if !ok {
80+ t.Fatalf("target address type=%T, want *net.UDPAddr", target.LocalAddr())
81+ }
82+ path := filepath.Join(t.TempDir(), "target")
83+ if err := os.WriteFile(path, []byte("VEV-UDP "+strconv.Itoa(targetAddr.Port)+" key\\n"), 0o600); err != nil {
84+ t.Fatal(err)
85+ }
86+ netem, err := newUDPNetem(udpNetemConfig{RTT: tc.rtt, LossPercent: tc.loss, TargetPath: path})
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ closeUDPFixture(t, netem)
91+ client, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ closeUDPFixture(t, client)
96+ started := time.Now()
97+ for i := 0; i < tc.sent; i++ {
98+ if _, err := client.WriteTo([]byte{byte(i)}, &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: netem.Port()}); err != nil {
99+ t.Fatal(err)
100+ }
101+ }
102+ if err := target.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
103+ t.Fatal(err)
104+ }
105+ got := 0
106+ buf := make([]byte, 32)
107+ var relayAddr net.Addr
108+ for got < tc.want {
109+ if _, relayAddr, err = target.ReadFrom(buf); err != nil {
110+ t.Fatalf("received %d packets, want %d: %v", got, tc.want, err)
111+ }
112+ got++
113+ }
114+ if tc.rtt > 0 {
115+ if elapsed := time.Since(started); elapsed < tc.rtt/2 {
116+ t.Fatalf("client-to-target netem delay=%s, want at least %s", elapsed, tc.rtt/2)
117+ }
118+ }
119+ if tc.loss == 0 {
120+ // The relay learns the client from the first carriage packet. Replying
121+ // through that same relay address verifies the target-to-client half
122+ // of the configured RTT as well.
123+ started = time.Now()
124+ if _, err := target.WriteTo([]byte("reply"), relayAddr); err != nil {
125+ t.Fatal(err)
126+ }
127+ if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
128+ t.Fatal(err)
129+ }
130+ if _, _, err := client.ReadFrom(buf); err != nil {
131+ t.Fatalf("relay reply: %v", err)
132+ }
133+ if tc.rtt > 0 {
134+ if elapsed := time.Since(started); elapsed < tc.rtt/2 {
135+ t.Fatalf("target-to-client netem delay=%s, want at least %s", elapsed, tc.rtt/2)
136+ }
137+ }
138+ }
139+ // Keep the deadline short: extra carriage would prove the exact 1%%
140+ // loss cycle was not enforced.
141+ if err := target.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); err != nil {
142+ t.Fatal(err)
143+ }
144+ if _, _, err := target.ReadFrom(buf); tc.want != tc.sent && err == nil {
145+ t.Fatalf("received more than %d packets with %d%% loss", tc.want, tc.loss)
146+ }
147+ })
148+ }
149+}
M docs/performance.md +63 -87
@@ -1,103 +1,79 @@
1-# Performance methodology (M5)
2-
3-This baseline measures in-process daemon work only. It deliberately does **not**
4-measure coordinator behavior, chunk encoding, compression, GC tuning, or real
5-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, schedulers, clocks, or transports. It primes
44-the renderer shadow before timing; live paint then alternates fixed writes. The
45-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. They must not be presented as network throughput or latency.
45+### Baseline procedure and validity
5046
51-Capture a pre-change result outside the repository, including Go and kernel
52-context:
47+Use the public CLI harness (not internal packages):
5348
5449 ```sh
55-{
56- date -Is
57- go version
58- uname -a
59- git rev-parse HEAD
60- # Smoke only; do not use this result for comparisons.
61- go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
62- # Repeated full matrix for every workload/topology used in conclusions.
63- go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1s -count=5 -benchmem
64-} > /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
6555 ```
6656
67-## 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.
6869
69-The copy-mode document retains an immutable view of existing scrollback rows and
70-clones only the visible frame. Visual-search models share that document; cloning
71-a model copies only its mutable input and match state. Check those two paths at
72-10,000 history rows with:
70+## Existing in-process smoke checks
7371
74-```sh
75-go test ./internal/usecase/copy ./internal/usecase/visualsearch -run '^$' -bench '^(BenchmarkNewSnapshot10KRows|BenchmarkVisualSearchClone10KRows)$' -benchtime=1s -count=5 -benchmem
76-```
77-
78-Treat `allocs/op` and `B/op` as allocation gates: compare the complete repeated
79-output with the accepted baseline before merging, and investigate any increase.
80-The automated scaling gates also compare equal row/match counts at narrow and
81-wide row widths; `B/op` may grow by at most 2x, a conservative relative
82-allowance that catches full cell-row copies without depending on an absolute
83-Go-version-specific budget. This scope does not yet include an
84-incremental/full-text search index: queries still scan the immutable document
85-and produce a fresh match list. Coordinator
86-behavior, chunk encoding, compression, GC tuning, and real Unix-socket, SSH, or
87-UDP impairment/latency remain out of scope.
88-
89-## Lower-level smoke baseline
90-
91-Use this command to check renderer, VT, IPC, and daemon microbenchmarks without
92-claiming an end-to-end transport result:
72+These commands measure local daemon work only and are not end-to-end transport
73+results:
9374
9475 ```sh
76+go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
9577 go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem
9678 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeFloatingFrameCached$' -benchmem
9779 ```
98-
99-Enable daemon pprof explicitly when collecting a daemon profile:
100-
101-```sh
102-VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon
103-```
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.SerializedRuntimeObserver
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.SerializedRuntimeObserver) 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.SerializedRuntimeObserver
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 +208 -0
@@ -0,0 +1,208 @@
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+ reporter := ports.NewSerializedRuntimeObserver(signalingObserver, 64)
31+ defer reporter.Close()
32+ transport, err := NewTransportWithOptions(&dgramCloseFailPC{fakePC: aPC, err: closeErr}, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))
33+ if err != nil {
34+ t.Fatalf("NewTransportWithOptions() error = %v", err)
35+ }
36+ if err := transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("write failure")}); err == nil {
37+ t.Fatal("Send() error = nil on packet write failure")
38+ }
39+ recvDone := make(chan error, 1)
40+ go func() { _, err := transport.Recv(); recvDone <- err }()
41+ <-receiveStarted
42+ if err := transport.Close(); !errors.Is(err, closeErr) {
43+ t.Fatalf("Close() error = %v, want %v", err, closeErr)
44+ }
45+ if err := <-recvDone; err == nil {
46+ t.Fatal("Recv() error = nil after failed Close()")
47+ }
48+ reporter.Close()
49+ if err := closer.Close(); err != nil {
50+ t.Fatalf("trace Close() error = %v", err)
51+ }
52+ assertDgramFailedSpanPairs(t, tracePath)
53+}
54+
55+type dgramStartObserver struct {
56+ ports.RuntimeObserver
57+ receiveStarted chan struct{}
58+ once sync.Once
59+}
60+
61+func (o *dgramStartObserver) ObserveRuntime(mark ports.RuntimeMark) {
62+ if mark.Kind == ports.RuntimeAdapterReceiveStart {
63+ o.once.Do(func() { close(o.receiveStarted) })
64+ }
65+ o.RuntimeObserver.ObserveRuntime(mark)
66+}
67+
68+type dgramCloseFailPC struct {
69+ *fakePC
70+ err error
71+}
72+
73+func (p *dgramCloseFailPC) WriteTo([]byte, net.Addr) (int, error) {
74+ return 0, errors.New("packet write failed")
75+}
76+
77+func (p *dgramCloseFailPC) Close() error {
78+ _ = p.fakePC.Close()
79+ return p.err
80+}
81+
82+func assertDgramFailedSpanPairs(t *testing.T, path string) {
83+ t.Helper()
84+ data, err := os.ReadFile(path)
85+ if err != nil {
86+ t.Fatalf("ReadFile(trace): %v", err)
87+ }
88+ type mark struct {
89+ ProcessID string `json:"process_id"`
90+ Scenario string `json:"scenario"`
91+ Run uint64 `json:"run"`
92+ Sequence uint64 `json:"sequence"`
93+ RequestID uint64 `json:"request_id"`
94+ Epoch uint64 `json:"epoch"`
95+ Kind string `json:"kind"`
96+ Valid bool `json:"valid"`
97+ }
98+ type spanKey struct {
99+ processID, scenario string
100+ run, sequence, requestID, epoch uint64
101+ kind string
102+ }
103+ starts := make(map[spanKey]mark)
104+ pairs := map[string]int{}
105+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
106+ var m mark
107+ if err := json.Unmarshal([]byte(line), &m); err != nil {
108+ t.Fatalf("Unmarshal(trace): %v", err)
109+ }
110+ key := spanKey{m.ProcessID, m.Scenario, m.Run, m.Sequence, m.RequestID, m.Epoch, m.Kind}
111+ switch m.Kind {
112+ case "adapter_receive_start", "adapter_send_start":
113+ if _, exists := starts[key]; exists {
114+ t.Fatalf("duplicate adapter start: %#v", m)
115+ }
116+ starts[key] = m
117+ case "adapter_receive_end", "adapter_send_end":
118+ key.kind = strings.TrimSuffix(m.Kind, "_end") + "_start"
119+ start, ok := starts[key]
120+ if !ok || m.Valid {
121+ t.Fatalf("unmatched or valid failed end: start=%#v end=%#v", start, m)
122+ }
123+ pairs[strings.TrimSuffix(m.Kind, "_end")]++
124+ delete(starts, key)
125+ }
126+ }
127+ if len(starts) != 0 {
128+ t.Fatalf("unmatched adapter starts: %#v", starts)
129+ }
130+ if pairs["adapter_send"] != 1 || pairs["adapter_receive"] != 1 || len(pairs) != 2 {
131+ t.Fatalf("failed adapter span pairs=%v, want exactly one send and one receive", pairs)
132+ }
133+}
134+
135+func TestTransportObservabilityDgramKeepsBehaviorClockSeparate(t *testing.T) {
136+ behaviorClock := newManualClock(time.Unix(0, 401))
137+ link := newSimulatedLink(behaviorClock, packetPolicy{})
138+ aPC, bPC := newPairWithCapacity(link, 64)
139+ tracePath := t.TempDir() + "/dgram.jsonl"
140+ observer, closer, err := observability.NewJSONL(tracePath, dgramObserverClock{now: time.Unix(0, 509)}, "dgram-process")
141+ if err != nil {
142+ t.Fatalf("NewJSONL() error = %v", err)
143+ }
144+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
145+ defer reporter.Close()
146+
147+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))
148+ if err != nil {
149+ t.Fatalf("NewTransportWithOptions(a) error = %v", err)
150+ }
151+ defer func() { _ = a.Close() }()
152+ b, err := NewTransportWithOptions(bPC, testAddr("a"), key(), 2, 1, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))
153+ if err != nil {
154+ t.Fatalf("NewTransportWithOptions(b) error = %v", err)
155+ }
156+ defer func() { _ = b.Close() }()
157+
158+ want := ports.Frame{Type: ports.MsgOutput, Payload: []byte("datagram carriage remains exact")}
159+ if err := a.Send(want); err != nil {
160+ t.Fatalf("Send() error = %v", err)
161+ }
162+ gotCh := make(chan ports.Frame, 1)
163+ errCh := make(chan error, 1)
164+ go func() {
165+ frame, recvErr := b.Recv()
166+ if recvErr != nil {
167+ errCh <- recvErr
168+ return
169+ }
170+ gotCh <- frame
171+ }()
172+ select {
173+ case err := <-errCh:
174+ t.Fatalf("Recv() error = %v", err)
175+ case got := <-gotCh:
176+ if !reflect.DeepEqual(got, want) {
177+ t.Fatalf("carried frame = %#v, want %#v", got, want)
178+ }
179+ case <-time.After(time.Second):
180+ t.Fatal("Recv() did not receive deterministic simulated datagram")
181+ }
182+ reporter.Close()
183+ if err := closer.Close(); err != nil {
184+ t.Fatalf("trace Close() error = %v", err)
185+ }
186+ trace, err := os.ReadFile(tracePath)
187+ if err != nil {
188+ t.Fatalf("ReadFile(trace): %v", err)
189+ }
190+ for _, kind := range []string{"adapter_send_start", "adapter_send_end", "adapter_receive_start", "adapter_receive_end"} {
191+ if !strings.Contains(string(trace), `"kind":"`+kind+`"`) {
192+ t.Errorf("trace missing %s", kind)
193+ }
194+ }
195+}
196+
197+type dgramObserverClock struct{ now time.Time }
198+
199+func (c dgramObserverClock) Now() time.Time { return c.now }
200+func (c dgramObserverClock) NewTimer(d time.Duration) ports.Timer {
201+ return dgramObserverTimer{timer: time.NewTimer(d)}
202+}
203+
204+type dgramObserverTimer struct{ timer *time.Timer }
205+
206+func (t dgramObserverTimer) C() <-chan time.Time { return t.timer.C }
207+func (t dgramObserverTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) }
208+func (t dgramObserverTimer) Stop() bool { return t.timer.Stop() }
A internal/adapters/dgram/transport_replay_test.go +31 -0
@@ -0,0 +1,31 @@
1+package dgram
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/internal/testutil/replaytest"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestTransportReplay(t *testing.T) {
12+ replaytest.Run(t, func(t *testing.T, frames []ports.Frame) []ports.Frame {
13+ link := newSimulatedLink(fixedClock{}, packetPolicy{})
14+ leftPC, rightPC := newPairWithCapacity(link, 32)
15+ left, err := NewTransport(leftPC, testAddr("b"), key(), 1, 2)
16+ require.NoError(t, err)
17+ right, err := NewTransport(rightPC, testAddr("a"), key(), 2, 1)
18+ require.NoError(t, err)
19+ t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
20+
21+ got := make([]ports.Frame, 0, len(frames))
22+ for _, frame := range frames {
23+ require.NoError(t, left.Send(frame))
24+ link.flush(rightPC)
25+ received, recvErr := right.Recv()
26+ require.NoError(t, recvErr)
27+ got = append(got, received)
28+ }
29+ return got
30+ })
31+}
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 +80 -0
@@ -0,0 +1,80 @@
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 := ports.NewSerializedRuntimeObserver(dialTestObserver{}, 1)
66+ defer observer.Close()
67+ transport, err := DialContext(context.Background(), dir, WithRuntimeObserver(observer))
68+ if err != nil {
69+ t.Fatalf("DialContext: %v", err)
70+ }
71+ defer func() { _ = transport.Close() }()
72+
73+ got, ok := transport.(*unixTransport)
74+ if !ok {
75+ t.Fatalf("DialContext transport = %T, want *unixTransport", transport)
76+ }
77+ if got.observer != observer {
78+ t.Fatal("DialContext did not apply runtime observer option")
79+ }
80+}
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 +276 -17
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "io"
77 "net"
8+ "sync"
89
910 "github.com/bnema/vev/internal/ports"
1011 )
@@ -26,17 +27,54 @@ var ErrZeroLengthFrame = errors.New("ipc: zero-length frame")
2627 // maxFrameLen, and by Send when a payload is too large to encode.
2728 var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length")
2829
30+// ErrBackpressure means the bounded IPC egress queue is full. Callers using
31+// SendAsync must treat it as a failed render emission rather than spawning an
32+// unbounded queue of their own.
33+var ErrBackpressure = errors.New("ipc: egress queue full")
34+
35+var errClosed = errors.New("ipc: closed")
36+
37+// sendQueueCapacity bounds frames accepted while a peer is not reading. It is
38+// deliberately small: render output can be large and must never turn a wedged
39+// peer into an unbounded memory commitment.
40+const sendQueueCapacity = 8
41+
2942 // unixTransport implements ports.Transport over a net.Conn (in practice an
3043 // AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets
3144 // tests exercise it over net.Pipe).
3245 //
33-// unixTransport is safe for one concurrent caller of Send and one
34-// concurrent caller of Recv at the same time (i.e. a single reader
35-// goroutine and a single writer goroutine); it is not safe for concurrent
36-// calls to Send from multiple goroutines, nor Recv from multiple
37-// goroutines.
46+// unixTransport has one writer goroutine which owns every conn.Write. Send
47+// waits for that owner's wire attempt; SendAsync only performs a bounded,
48+// ordered enqueue. Recv still has one caller at a time.
49+type Option func(*unixTransport)
50+
51+// WithRuntimeObserver enables process-local transport marks. It accepts no
52+// clock: timestamp ownership belongs to the configured observer.
53+func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
54+ return func(t *unixTransport) { t.observer = observer }
55+}
56+
3857 type unixTransport struct {
39- conn net.Conn
58+ conn net.Conn
59+ observer ports.SerializedRuntimeObserver
60+
61+ operationMu sync.Mutex
62+ operationCount int
63+ observedClosing bool
64+ operationsDone chan struct{}
65+
66+ // egressMu synchronizes Close with enqueue. Synchronous enqueuers are
67+ // tracked separately so the writer cannot drain the queue until each has
68+ // observed closure or finished enqueueing.
69+ egressMu sync.Mutex
70+ egressClosing bool
71+ egressSenders int
72+ egressSendersDone chan struct{}
73+ egress chan sendRequest
74+ done chan struct{}
75+ writerDone chan struct{}
76+ closeOnce sync.Once
77+ closeErr error
4078
4179 // readBuf is reused across Recv calls (grow-once strategy): it grows
4280 // to fit the largest frame seen so far and is never shrunk. The Frame
@@ -47,42 +85,197 @@ type unixTransport struct {
4785
4886 // NewTransport wraps conn as a ports.Transport speaking vev's framed
4987 // binary protocol.
50-func NewTransport(conn net.Conn) ports.Transport {
51- return &unixTransport{conn: conn}
88+func NewTransport(conn net.Conn, opts ...Option) ports.Transport {
89+ t := &unixTransport{
90+ conn: conn,
91+ egress: make(chan sendRequest, sendQueueCapacity),
92+ done: make(chan struct{}),
93+ writerDone: make(chan struct{}),
94+ }
95+ for _, opt := range opts {
96+ if opt != nil {
97+ opt(t)
98+ }
99+ }
100+ go t.writeLoop()
101+ return t
102+}
103+
104+type sendRequest struct {
105+ data []byte
106+ done chan error // nil for SendAsync
107+ end func(bool)
52108 }
53109
54-// Send writes f as a single frame: a 4-byte big-endian length (covering
55-// the type byte and payload), the type byte, then the payload — assembled
56-// into one buffer and written with a single Write call.
110+// Send queues f for the sole writer and waits for its wire attempt.
57111 func (t *unixTransport) Send(f ports.Frame) error {
112+ data, err := marshalFrame(f)
113+ if err != nil {
114+ return err
115+ }
116+ end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
117+ result := make(chan error, 1)
118+ if err := t.enqueueWait(sendRequest{data: data, done: result, end: end}); err != nil {
119+ end(false)
120+ return err
121+ }
122+ select {
123+ case err := <-result:
124+ return err
125+ case <-t.done:
126+ return errClosed
127+ }
128+}
129+
130+// SendAsync accepts f for ordered background transmission without waiting for
131+// a socket write. The queue is bounded and ownership includes a payload copy.
132+func (t *unixTransport) SendAsync(f ports.Frame) error {
133+ data, err := marshalFrame(f)
134+ if err != nil {
135+ return err
136+ }
137+ end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
138+ if err := t.enqueueAsync(sendRequest{data: data, end: end}); err != nil {
139+ end(false)
140+ return err
141+ }
142+ return nil
143+}
144+
145+func marshalFrame(f ports.Frame) ([]byte, error) {
58146 n := 1 + len(f.Payload) // type + payload
59147 if n > maxFrameLen {
60- return ErrFrameTooLarge
148+ return nil, ErrFrameTooLarge
61149 }
62-
63150 buf := make([]byte, frameHeaderLen+n)
64151 binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n))
65152 buf[frameHeaderLen] = byte(f.Type)
66153 copy(buf[frameHeaderLen+1:], f.Payload)
154+ return buf, nil
155+}
156+
157+// enqueueWait waits for space in the egress queue. Close wakes waiters and
158+// the writer waits for all in-flight enqueues before draining queued requests.
159+func (t *unixTransport) enqueueWait(req sendRequest) error {
160+ t.egressMu.Lock()
161+ if t.egressClosing {
162+ t.egressMu.Unlock()
163+ return errClosed
164+ }
165+ if t.egressSenders == 0 {
166+ t.egressSendersDone = make(chan struct{})
167+ }
168+ t.egressSenders++
169+ t.egressMu.Unlock()
170+
171+ select {
172+ case <-t.done:
173+ t.finishEgressSender()
174+ return errClosed
175+ case t.egress <- req:
176+ t.finishEgressSender()
177+ return nil
178+ }
179+}
67180
68- _, err := t.conn.Write(buf)
69- return err
181+// enqueueAsync performs a bounded, nonblocking enqueue for SendAsync.
182+func (t *unixTransport) enqueueAsync(req sendRequest) error {
183+ t.egressMu.Lock()
184+ defer t.egressMu.Unlock()
185+ if t.egressClosing {
186+ return errClosed
187+ }
188+ select {
189+ case t.egress <- req:
190+ return nil
191+ default:
192+ return ErrBackpressure
193+ }
194+}
195+
196+func (t *unixTransport) finishEgressSender() {
197+ t.egressMu.Lock()
198+ defer t.egressMu.Unlock()
199+ t.egressSenders--
200+ if t.egressSenders == 0 {
201+ close(t.egressSendersDone)
202+ }
203+}
204+
205+func (t *unixTransport) waitForEgressSenders() {
206+ t.egressMu.Lock()
207+ done := t.egressSendersDone
208+ t.egressMu.Unlock()
209+ if done != nil {
210+ <-done
211+ }
212+}
213+
214+func (t *unixTransport) writeLoop() {
215+ defer close(t.writerDone)
216+ for {
217+ select {
218+ case req := <-t.egress:
219+ t.completeWrite(req, writeAll(t.conn, req.data))
220+ case <-t.done:
221+ t.waitForEgressSenders()
222+ t.egressMu.Lock()
223+ for {
224+ select {
225+ case req := <-t.egress:
226+ t.completeWrite(req, errClosed)
227+ default:
228+ t.egressMu.Unlock()
229+ return
230+ }
231+ }
232+ }
233+ }
234+}
235+
236+func (t *unixTransport) completeWrite(req sendRequest, err error) {
237+ if req.end != nil {
238+ req.end(err == nil)
239+ }
240+ if req.done != nil {
241+ req.done <- err
242+ }
243+}
244+
245+func writeAll(w io.Writer, data []byte) error {
246+ for len(data) > 0 {
247+ n, err := w.Write(data)
248+ if n > 0 {
249+ data = data[n:]
250+ }
251+ if err != nil {
252+ return err
253+ }
254+ if n == 0 {
255+ return io.ErrShortWrite
256+ }
257+ }
258+ return nil
70259 }
71260
72261 // Recv reads one frame: a 4-byte length, then a body of that many bytes
73262 // (type byte + payload). It blocks until a full frame arrives, the
74263 // connection is closed (io.EOF), or an error occurs.
75264 func (t *unixTransport) Recv() (ports.Frame, error) {
265+ end := t.beginOperation(ports.RuntimeAdapterReceiveStart, 0)
76266 var hdr [frameHeaderLen]byte
77267 if _, err := io.ReadFull(t.conn, hdr[:]); err != nil {
268+ end(false)
78269 return ports.Frame{}, err
79270 }
80271
81272 n := binary.BigEndian.Uint32(hdr[:])
82273 if n == 0 {
274+ end(false)
83275 return ports.Frame{}, ErrZeroLengthFrame
84276 }
85277 if n > maxFrameLen {
278+ end(false)
86279 return ports.Frame{}, ErrFrameTooLarge
87280 }
88281
@@ -92,6 +285,7 @@ func (t *unixTransport) Recv() (ports.Frame, error) {
92285 t.readBuf = t.readBuf[:n]
93286 }
94287 if _, err := io.ReadFull(t.conn, t.readBuf); err != nil {
288+ end(false)
95289 return ports.Frame{}, err
96290 }
97291
@@ -100,10 +294,75 @@ func (t *unixTransport) Recv() (ports.Frame, error) {
100294 payload = make([]byte, n-1)
101295 copy(payload, t.readBuf[1:])
102296 }
297+ end(true)
103298 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
104299 }
105300
106-// Close closes the underlying connection.
301+func (t *unixTransport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) func(bool) {
302+ if t.observer == nil {
303+ return func(bool) {}
304+ }
305+ if !t.beginObservedOperation() {
306+ return func(bool) {}
307+ }
308+ correlation := ports.NewRuntimeCorrelation()
309+ t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("ipc", correlation, start, bytes, true))
310+ end := ports.RuntimeAdapterSendEnd
311+ if start == ports.RuntimeAdapterReceiveStart {
312+ end = ports.RuntimeAdapterReceiveEnd
313+ }
314+ return func(valid bool) {
315+ defer t.finishObservedOperation()
316+ t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("ipc", correlation, end, bytes, valid))
317+ }
318+}
319+
320+func (t *unixTransport) beginObservedOperation() bool {
321+ t.operationMu.Lock()
322+ defer t.operationMu.Unlock()
323+ if t.observedClosing {
324+ return false
325+ }
326+ if t.operationCount == 0 {
327+ t.operationsDone = make(chan struct{})
328+ }
329+ t.operationCount++
330+ return true
331+}
332+
333+func (t *unixTransport) finishObservedOperation() {
334+ t.operationMu.Lock()
335+ defer t.operationMu.Unlock()
336+ t.operationCount--
337+ if t.operationCount == 0 {
338+ close(t.operationsDone)
339+ }
340+}
341+
342+func (t *unixTransport) beginShutdown() <-chan struct{} {
343+ t.operationMu.Lock()
344+ defer t.operationMu.Unlock()
345+ t.observedClosing = true
346+ if t.operationCount == 0 {
347+ return nil
348+ }
349+ return t.operationsDone
350+}
351+
352+// Close interrupts Recv and the writer's current Write, fails queued frames,
353+// and waits until the sole writer and all observed operations have stopped.
107354 func (t *unixTransport) Close() error {
108- return t.conn.Close()
355+ t.closeOnce.Do(func() {
356+ operationsDone := t.beginShutdown()
357+ t.egressMu.Lock()
358+ t.egressClosing = true
359+ close(t.done)
360+ t.egressMu.Unlock()
361+ t.closeErr = t.conn.Close()
362+ <-t.writerDone
363+ if operationsDone != nil {
364+ <-operationsDone
365+ }
366+ })
367+ return t.closeErr
109368 }
A internal/adapters/ipc/transport_observability_test.go +197 -0
@@ -0,0 +1,197 @@
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+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
24+ defer reporter.Close()
25+
26+ // EOF from the peer and a locally closed blocked receive are distinct
27+ // shutdown paths; both must record a failed, correlated end.
28+ for _, closeLocal := range []bool{false, true} {
29+ left, right := net.Pipe()
30+ var started <-chan struct{}
31+ conn := left
32+ if closeLocal {
33+ observedConn := &ipcReadStartedConn{Conn: left, started: make(chan struct{})}
34+ conn, started = observedConn, observedConn.started
35+ }
36+ transport := NewTransport(conn, WithRuntimeObserver(reporter))
37+ recvDone := make(chan error, 1)
38+ go func() { _, err := transport.Recv(); recvDone <- err }()
39+ if closeLocal {
40+ <-started
41+ if err := transport.Close(); err != nil {
42+ t.Fatalf("Close() error = %v", err)
43+ }
44+ } else if err := right.Close(); err != nil {
45+ t.Fatalf("peer Close() error = %v", err)
46+ }
47+ if err := <-recvDone; err == nil {
48+ t.Fatal("Recv() error = nil after transport shutdown")
49+ }
50+ _ = left.Close()
51+ _ = right.Close()
52+ }
53+
54+ // A peer shutdown also makes Send fail after its start mark.
55+ left, right := net.Pipe()
56+ transport := NewTransport(left, WithRuntimeObserver(reporter))
57+ _ = right.Close()
58+ if err := transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("failed")}); err == nil {
59+ t.Fatal("Send() error = nil after peer close")
60+ }
61+ _ = transport.Close()
62+ reporter.Close()
63+ if err := closer.Close(); err != nil {
64+ t.Fatalf("trace Close() error = %v", err)
65+ }
66+ assertIPCFailedSpanPairs(t, trace)
67+}
68+
69+type ipcReadStartedConn struct {
70+ net.Conn
71+ started chan struct{}
72+ once sync.Once
73+}
74+
75+func (c *ipcReadStartedConn) Read(p []byte) (int, error) {
76+ c.once.Do(func() { close(c.started) })
77+ return c.Conn.Read(p)
78+}
79+
80+func assertIPCFailedSpanPairs(t *testing.T, path string) {
81+ t.Helper()
82+ data, err := os.ReadFile(path)
83+ if err != nil {
84+ t.Fatalf("ReadFile(trace): %v", err)
85+ }
86+ type mark struct {
87+ ProcessID string `json:"process_id"`
88+ Component string `json:"component"`
89+ Scenario string `json:"scenario"`
90+ Run uint64 `json:"run"`
91+ Sequence uint64 `json:"sequence"`
92+ RequestID uint64 `json:"request_id"`
93+ Epoch uint64 `json:"epoch"`
94+ Kind string `json:"kind"`
95+ Valid bool `json:"valid"`
96+ }
97+ type spanKey struct {
98+ processID, scenario string
99+ run, sequence, requestID, epoch uint64
100+ }
101+ starts := make(map[spanKey]mark)
102+ startCounts := map[string]int{}
103+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
104+ var m mark
105+ if err := json.Unmarshal([]byte(line), &m); err != nil {
106+ t.Fatalf("Unmarshal(trace): %v", err)
107+ }
108+ key := spanKey{m.ProcessID, m.Scenario, m.Run, m.Sequence, m.RequestID, m.Epoch}
109+ switch m.Kind {
110+ case "adapter_receive_start", "adapter_send_start":
111+ 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 {
112+ t.Fatalf("adapter start has invalid identity: %#v", m)
113+ }
114+ if _, exists := starts[key]; exists {
115+ t.Fatalf("duplicate adapter start for %#v", key)
116+ }
117+ starts[key] = m
118+ startCounts[m.Kind]++
119+ case "adapter_receive_end", "adapter_send_end":
120+ start, ok := starts[key]
121+ if !ok || strings.TrimSuffix(start.Kind, "_start") != strings.TrimSuffix(m.Kind, "_end") {
122+ t.Fatalf("unmatched adapter end %#v", m)
123+ }
124+ if m.Valid {
125+ t.Fatalf("failed adapter end is valid: %#v", m)
126+ }
127+ delete(starts, key)
128+ }
129+ }
130+ if len(starts) != 0 {
131+ t.Fatalf("unmatched adapter starts: %#v", starts)
132+ }
133+ if got := startCounts["adapter_receive_start"]; got != 2 {
134+ t.Errorf("adapter receive starts = %d, want 2", got)
135+ }
136+ if got := startCounts["adapter_send_start"]; got != 1 {
137+ t.Errorf("adapter send starts = %d, want 1", got)
138+ }
139+}
140+
141+func TestTransportObservabilityIPCMarksCarriageWithoutChangingBytes(t *testing.T) {
142+ trace := t.TempDir() + "/ipc.jsonl"
143+ observer, closer, err := observability.NewJSONL(trace, ipcRuntimeClock{now: time.Unix(0, 211)}, "ipc-process")
144+ if err != nil {
145+ t.Fatalf("NewJSONL() error = %v", err)
146+ }
147+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
148+ defer reporter.Close()
149+
150+ left, right := net.Pipe()
151+ defer func() { _ = left.Close() }()
152+ defer func() { _ = right.Close() }()
153+ client := NewTransport(left, WithRuntimeObserver(reporter))
154+ server := NewTransport(right, WithRuntimeObserver(reporter))
155+ want := ports.Frame{Type: ports.MsgOutput, Payload: []byte("wire bytes stay exact")}
156+
157+ var wg sync.WaitGroup
158+ wg.Go(func() {
159+ if err := client.Send(want); err != nil {
160+ t.Errorf("Send() error = %v", err)
161+ }
162+ })
163+ got, err := server.Recv()
164+ wg.Wait()
165+ if err != nil {
166+ t.Fatalf("Recv() error = %v", err)
167+ }
168+ if !reflect.DeepEqual(got, want) {
169+ t.Fatalf("carried frame = %#v, want %#v", got, want)
170+ }
171+ reporter.Close()
172+ if err := closer.Close(); err != nil {
173+ t.Fatalf("trace Close() error = %v", err)
174+ }
175+ traceBytes, err := os.ReadFile(trace)
176+ if err != nil {
177+ t.Fatalf("ReadFile(trace): %v", err)
178+ }
179+ for _, kind := range []string{"adapter_send_start", "adapter_send_end", "adapter_receive_start", "adapter_receive_end"} {
180+ if !strings.Contains(string(traceBytes), `"kind":"`+kind+`"`) {
181+ t.Errorf("trace missing %s", kind)
182+ }
183+ }
184+}
185+
186+type ipcRuntimeClock struct{ now time.Time }
187+
188+func (c ipcRuntimeClock) Now() time.Time { return c.now }
189+func (c ipcRuntimeClock) NewTimer(d time.Duration) ports.Timer {
190+ return ipcRuntimeTimer{timer: time.NewTimer(d)}
191+}
192+
193+type ipcRuntimeTimer struct{ timer *time.Timer }
194+
195+func (t ipcRuntimeTimer) C() <-chan time.Time { return t.timer.C }
196+func (t ipcRuntimeTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) }
197+func (t ipcRuntimeTimer) Stop() bool { return t.timer.Stop() }
A internal/adapters/ipc/transport_replay_test.go +40 -0
@@ -0,0 +1,40 @@
1+package ipc
2+
3+import (
4+ "net"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/testutil/replaytest"
10+)
11+
12+func TestTransportReplay(t *testing.T) {
13+ replaytest.Run(t, func(t *testing.T, frames []ports.Frame) []ports.Frame {
14+ left, right := net.Pipe()
15+ t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
16+ sender, receiver := NewTransport(left), NewTransport(right)
17+
18+ var wg sync.WaitGroup
19+ wg.Go(func() {
20+ for _, frame := range frames {
21+ if err := sender.Send(frame); err != nil {
22+ t.Errorf("Send: %v", err)
23+ return
24+ }
25+ }
26+ })
27+ got := make([]ports.Frame, 0, len(frames))
28+ for range frames {
29+ frame, err := receiver.Recv()
30+ if err != nil {
31+ t.Errorf("Recv: %v", err)
32+ _ = receiver.Close()
33+ break
34+ }
35+ got = append(got, frame)
36+ }
37+ wg.Wait()
38+ return got
39+ })
40+}
M internal/adapters/ipc/transport_test.go +285 -0
@@ -6,8 +6,10 @@ import (
66 "io"
77 "net"
88 "reflect"
9+ "runtime"
910 "sync"
1011 "testing"
12+ "time"
1113
1214 "github.com/bnema/vev/internal/domain"
1315 "github.com/bnema/vev/internal/ports"
@@ -231,3 +233,286 @@ func TestTransportSendOversizePayloadRejected(t *testing.T) {
231233 t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err)
232234 }
233235 }
236+
237+func TestTransportAsyncEgressPreservesWelcomeBeforeOutput(t *testing.T) {
238+ const timeout = time.Second
239+
240+ serverConn, clientConn := net.Pipe()
241+ defer func() { _ = serverConn.Close() }()
242+ defer func() { _ = clientConn.Close() }()
243+ if err := serverConn.SetDeadline(time.Now().Add(timeout)); err != nil {
244+ t.Fatalf("SetDeadline server connection: %v", err)
245+ }
246+ if err := clientConn.SetDeadline(time.Now().Add(timeout)); err != nil {
247+ t.Fatalf("SetDeadline client connection: %v", err)
248+ }
249+
250+ server := NewTransport(serverConn)
251+ defer closeTransport(t, server)
252+ client := NewTransport(clientConn)
253+ defer closeTransport(t, client)
254+ async, ok := server.(ports.AsyncTransport)
255+ if !ok {
256+ t.Fatal("IPC transport does not implement AsyncTransport")
257+ }
258+
259+ welcome := ports.Frame{Type: ports.MsgWelcome, Payload: []byte("welcome")}
260+ sent := make(chan error, 1)
261+ go func() { sent <- server.Send(welcome) }()
262+ got, err := client.Recv()
263+ if err != nil {
264+ t.Fatalf("Recv welcome: %v", err)
265+ }
266+ if !reflect.DeepEqual(got, welcome) {
267+ t.Fatalf("welcome = %#v, want %#v", got, welcome)
268+ }
269+ select {
270+ case err := <-sent:
271+ if err != nil {
272+ t.Fatalf("Send welcome: %v", err)
273+ }
274+ case <-time.After(timeout):
275+ t.Fatal("Send welcome did not return")
276+ }
277+
278+ payload := []byte("first")
279+ if err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil {
280+ t.Fatalf("SendAsync first output: %v", err)
281+ }
282+ payload[0] = 'X' // async ownership must not retain caller memory.
283+ if err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")}); err != nil {
284+ t.Fatalf("SendAsync second output: %v", err)
285+ }
286+ for _, want := range []ports.Frame{
287+ {Type: ports.MsgOutput, Payload: []byte("first")},
288+ {Type: ports.MsgOutput, Payload: []byte("second")},
289+ } {
290+ got, err := client.Recv()
291+ if err != nil {
292+ t.Fatalf("Recv output: %v", err)
293+ }
294+ if !reflect.DeepEqual(got, want) {
295+ t.Fatalf("output = %#v, want %#v", got, want)
296+ }
297+ }
298+}
299+
300+func TestTransportSendWaitsForEgressCapacity(t *testing.T) {
301+ const timeout = time.Second
302+
303+ serverConn, clientConn := net.Pipe()
304+ defer func() { _ = serverConn.Close() }()
305+ defer func() { _ = clientConn.Close() }()
306+ if err := clientConn.SetDeadline(time.Now().Add(timeout)); err != nil {
307+ t.Fatalf("SetDeadline client connection: %v", err)
308+ }
309+
310+ server := NewTransport(serverConn)
311+ defer closeTransport(t, server)
312+ transport, ok := server.(*unixTransport)
313+ if !ok {
314+ t.Fatal("IPC transport is not a unixTransport")
315+ }
316+ client := NewTransport(clientConn)
317+ defer closeTransport(t, client)
318+ async, ok := server.(ports.AsyncTransport)
319+ if !ok {
320+ t.Fatal("IPC transport does not implement AsyncTransport")
321+ }
322+
323+ queued := fillAsyncEgress(t, transport, async, timeout)
324+ want := ports.Frame{Type: ports.MsgOutput, Payload: []byte("synchronous")}
325+ sent := make(chan error, 1)
326+ go func() { sent <- server.Send(want) }()
327+ waitForEgressSender(t, transport, timeout)
328+
329+ select {
330+ case err := <-sent:
331+ t.Fatalf("Send returned before capacity was available: %v", err)
332+ default:
333+ }
334+
335+ for _, want := range append(queued, want) {
336+ got, err := client.Recv()
337+ if err != nil {
338+ t.Fatalf("Recv: %v", err)
339+ }
340+ if !reflect.DeepEqual(got, want) {
341+ t.Fatalf("Recv = %#v, want %#v", got, want)
342+ }
343+ }
344+ select {
345+ case err := <-sent:
346+ if err != nil {
347+ t.Fatalf("Send after draining: %v", err)
348+ }
349+ case <-time.After(timeout):
350+ t.Fatal("Send did not return after draining")
351+ }
352+}
353+
354+func TestTransportCloseInterruptsSendWaitingForEgressCapacity(t *testing.T) {
355+ const timeout = time.Second
356+
357+ serverConn, clientConn := net.Pipe()
358+ defer func() { _ = clientConn.Close() }()
359+
360+ server := NewTransport(serverConn)
361+ defer closeTransport(t, server)
362+ transport, ok := server.(*unixTransport)
363+ if !ok {
364+ t.Fatal("IPC transport is not a unixTransport")
365+ }
366+ async, ok := server.(ports.AsyncTransport)
367+ if !ok {
368+ t.Fatal("IPC transport does not implement AsyncTransport")
369+ }
370+ fillAsyncEgress(t, transport, async, timeout)
371+
372+ sent := make(chan error, 1)
373+ go func() { sent <- server.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("synchronous")}) }()
374+ waitForEgressSender(t, transport, timeout)
375+
376+ closeTransport(t, server)
377+ select {
378+ case err := <-sent:
379+ if !errors.Is(err, errClosed) {
380+ t.Fatalf("Send after Close = %v, want errClosed", err)
381+ }
382+ case <-time.After(timeout):
383+ t.Fatal("Close did not interrupt Send")
384+ }
385+}
386+
387+func waitForEgressSender(t *testing.T, transport *unixTransport, timeout time.Duration) {
388+ t.Helper()
389+
390+ timer := time.NewTimer(timeout)
391+ defer timer.Stop()
392+ for {
393+ transport.egressMu.Lock()
394+ senders := transport.egressSenders
395+ transport.egressMu.Unlock()
396+ if senders > 0 {
397+ return
398+ }
399+ select {
400+ case <-timer.C:
401+ t.Fatal("Send did not register as an egress capacity waiter")
402+ default:
403+ runtime.Gosched()
404+ }
405+ }
406+}
407+
408+func closeTransport(t *testing.T, transport ports.Transport) {
409+ t.Helper()
410+
411+ closed := make(chan error, 1)
412+ go func() { closed <- transport.Close() }()
413+ select {
414+ case err := <-closed:
415+ if err != nil {
416+ t.Fatalf("Close: %v", err)
417+ }
418+ case <-time.After(time.Second):
419+ t.Fatal("Close did not return")
420+ }
421+}
422+
423+func fillAsyncEgress(t *testing.T, transport *unixTransport, async ports.AsyncTransport, timeout time.Duration) []ports.Frame {
424+ t.Helper()
425+
426+ first := ports.Frame{Type: ports.MsgOutput, Payload: []byte{0}}
427+ if err := async.SendAsync(first); err != nil {
428+ t.Fatalf("SendAsync blocked frame: %v", err)
429+ }
430+ waitForEgressDequeue(t, transport, timeout)
431+
432+ queued := []ports.Frame{first}
433+ for i := 1; ; i++ {
434+ frame := ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}
435+ err := async.SendAsync(frame)
436+ switch {
437+ case err == nil:
438+ queued = append(queued, frame)
439+ case errors.Is(err, ErrBackpressure):
440+ return queued
441+ default:
442+ t.Fatalf("SendAsync queued frame: %v", err)
443+ }
444+ }
445+}
446+
447+func waitForEgressDequeue(t *testing.T, transport *unixTransport, timeout time.Duration) {
448+ t.Helper()
449+
450+ timer := time.NewTimer(timeout)
451+ defer timer.Stop()
452+ for {
453+ if len(transport.egress) == 0 {
454+ return
455+ }
456+ select {
457+ case <-timer.C:
458+ t.Fatal("IPC writer did not dequeue the blocked frame")
459+ default:
460+ runtime.Gosched()
461+ }
462+ }
463+}
464+
465+func TestTransportAsyncEgressIsBoundedAndCloseInterruptsWorkers(t *testing.T) {
466+ serverConn, clientConn := net.Pipe()
467+ defer func() { _ = clientConn.Close() }()
468+
469+ tr := NewTransport(serverConn)
470+ defer closeTransport(t, tr)
471+ transport, ok := tr.(*unixTransport)
472+ if !ok {
473+ t.Fatal("IPC transport is not a unixTransport")
474+ }
475+ async, ok := tr.(ports.AsyncTransport)
476+ if !ok {
477+ t.Fatal("IPC transport does not implement AsyncTransport")
478+ }
479+
480+ // The first write blocks in net.Pipe because the peer never drains it.
481+ if err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("blocked")}); err != nil {
482+ t.Fatalf("SendAsync blocked frame: %v", err)
483+ }
484+ var backpressure error
485+ for range sendQueueCapacity + 2 {
486+ err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")})
487+ if errors.Is(err, ErrBackpressure) {
488+ backpressure = err
489+ break
490+ }
491+ if err != nil {
492+ t.Fatalf("SendAsync unexpected error: %v", err)
493+ }
494+ }
495+ if !errors.Is(backpressure, ErrBackpressure) {
496+ t.Fatalf("SendAsync never reported bounded backpressure, got %v", backpressure)
497+ }
498+
499+ recvDone := make(chan error, 1)
500+ go func() {
501+ _, err := tr.Recv()
502+ recvDone <- err
503+ }()
504+ closeTransport(t, tr)
505+ select {
506+ case err := <-recvDone:
507+ if err == nil {
508+ t.Fatal("Recv succeeded after Close")
509+ }
510+ case <-time.After(time.Second):
511+ t.Fatal("Close did not interrupt Recv")
512+ }
513+ select {
514+ case <-transport.writerDone:
515+ default:
516+ t.Fatal("Close returned before the IPC writer terminated")
517+ }
518+}
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/pty/pty_linux.go +30 -6
@@ -8,6 +8,7 @@ package pty
88 // x/sys/unix plus the standard library.
99
1010 import (
11+ "context"
1112 "errors"
1213 "fmt"
1314 "io"
@@ -38,7 +39,10 @@ func NewFactory() *Factory { return &Factory{} }
3839 // verbatim (nil means inherit the current process environment); the caller
3940 // decides TERM and friends. sz sets the terminal window size before the child
4041 // starts, so the child observes the correct dimensions on its first query.
41-func (Factory) Open(command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
42+func (Factory) Open(ctx context.Context, command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
43+ if err := ctx.Err(); err != nil {
44+ return nil, err
45+ }
4246 masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0)
4347 if err != nil {
4448 return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err)
@@ -79,7 +83,7 @@ func (Factory) Open(command string, args []string, env []string, dir string, sz
7983 }
8084 }
8185
82- cmd := exec.Command(command, args...)
86+ cmd := exec.CommandContext(ctx, command, args...)
8387 cmd.Env = env
8488 cmd.Dir = platform.DirOrHome(dir)
8589 cmd.Stdin = slave
@@ -93,6 +97,20 @@ func (Factory) Open(command string, args []string, env []string, dir string, sz
9397 Setctty: true,
9498 Ctty: 0,
9599 }
100+ // CommandContext's default cancellation kills only the direct child. The
101+ // child leads a process group, so cancellation must target that group too.
102+ cmd.Cancel = func() error {
103+ if cmd.Process == nil {
104+ return os.ErrProcessDone
105+ }
106+ if err := signalProcessGroup(cmd.Process.Pid, syscall.SIGKILL); err != nil {
107+ if errors.Is(err, syscall.ESRCH) {
108+ return os.ErrProcessDone
109+ }
110+ return err
111+ }
112+ return nil
113+ }
96114
97115 if err := cmd.Start(); err != nil {
98116 return nil, fmt.Errorf("pty: start %q: %w", command, err)
@@ -190,9 +208,8 @@ func (p *linuxPTY) Close() error {
190208 p.closeOnce.Do(func() {
191209 pid := p.cmd.Process.Pid
192210
193- // Negative pid targets the whole process group (the child is the group
194- // leader thanks to Setsid). Ignore errors: the child may already be gone.
195- _ = unix.Kill(-pid, unix.SIGHUP)
211+ // Ignore errors: the child may already be gone.
212+ _ = signalProcessGroup(pid, syscall.SIGHUP)
196213
197214 // Reap the child (exit status is intentionally discarded; the port has no
198215 // Wait — "child gone" surfaces to callers as io.EOF on Read).
@@ -205,7 +222,7 @@ func (p *linuxPTY) Close() error {
205222 select {
206223 case <-reaped:
207224 case <-time.After(killGracePeriod):
208- _ = unix.Kill(-pid, unix.SIGKILL)
225+ _ = signalProcessGroup(pid, syscall.SIGKILL)
209226 <-reaped
210227 }
211228
@@ -214,6 +231,13 @@ func (p *linuxPTY) Close() error {
214231 return p.closeErr
215232 }
216233
234+// signalProcessGroup signals the process group led by pid. Open creates each
235+// child as its own session and process-group leader, so a negative pid reaches
236+// both the command and every descendant that remains in its process group.
237+func signalProcessGroup(pid int, signal syscall.Signal) error {
238+ return unix.Kill(-pid, signal)
239+}
240+
217241 // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ.
218242 func setWinsize(fd int, sz domain.Size) error {
219243 return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{
M internal/adapters/pty/pty_linux_test.go +44 -7
@@ -4,6 +4,7 @@ package pty_test
44
55 import (
66 "bytes"
7+ "context"
78 "errors"
89 "io"
910 "os"
@@ -53,7 +54,7 @@ func TestOpen_ChildOutputToEOF(t *testing.T) {
5354 t.Skip("skipping pty integration test in -short mode")
5455 }
5556 f := newFactory()
56- p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
57+ p, err := f.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
5758 require.NoError(t, err)
5859 t.Cleanup(func() { _ = p.Close() })
5960
@@ -70,7 +71,7 @@ func TestOpen_EchoRoundtrip(t *testing.T) {
7071 t.Skip("skipping pty integration test in -short mode")
7172 }
7273 f := newFactory()
73- p, err := f.Open("cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
74+ p, err := f.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
7475 require.NoError(t, err)
7576 t.Cleanup(func() { _ = p.Close() })
7677
@@ -103,7 +104,7 @@ func TestForegroundPgid(t *testing.T) {
103104 t.Skip("skipping pty integration test in -short mode")
104105 }
105106 f := newFactory()
106- p, err := f.Open("sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
107+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
107108 require.NoError(t, err)
108109 t.Cleanup(func() { _ = p.Close() })
109110
@@ -141,7 +142,7 @@ func TestResize_SttySize(t *testing.T) {
141142 for _, tt := range tests {
142143 t.Run(tt.name, func(t *testing.T) {
143144 f := newFactory()
144- p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial)
145+ p, err := f.Open(context.Background(), "sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial)
145146 require.NoError(t, err)
146147 t.Cleanup(func() { _ = p.Close() })
147148
@@ -160,7 +161,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) {
160161 t.Skip("skipping pty integration test in -short mode")
161162 }
162163 f := newFactory()
163- p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
164+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
164165 require.NoError(t, err)
165166
166167 pid := p.Pid()
@@ -191,7 +192,7 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) {
191192 t.Skip("skipping pty integration test in -short mode")
192193 }
193194 f := newFactory()
194- p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
195+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
195196 require.NoError(t, err)
196197 pid := p.Pid()
197198
@@ -204,6 +205,42 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) {
204205 }, 3*time.Second, 20*time.Millisecond)
205206 }
206207
208+func TestOpen_ContextCancellationTerminatesProcessGroup(t *testing.T) {
209+ if testing.Short() {
210+ t.Skip("skipping pty integration test in -short mode")
211+ }
212+ ctx, cancel := context.WithCancel(context.Background())
213+ defer cancel()
214+
215+ // The background sleep ignores SIGHUP so killing only the session leader
216+ // cannot accidentally pass via the terminal's hangup behavior.
217+ p, err := newFactory().Open(ctx, "sh", []string{"-c", `(trap '' HUP; exec sleep 60) & child=$!; printf '%s\n' "$child"; wait "$child"`}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
218+ require.NoError(t, err)
219+ t.Cleanup(func() { _ = p.Close() })
220+
221+ buf := make([]byte, 64)
222+ n, err := p.Read(buf)
223+ require.NoError(t, err)
224+ descendantPID, err := strconv.Atoi(strings.TrimSpace(string(buf[:n])))
225+ require.NoError(t, err)
226+
227+ cancel()
228+
229+ // The direct shell may remain as a zombie until Close reaps it; a zombie has
230+ // exited, so it and the background sleep must not remain running.
231+ require.Eventually(t, func() bool {
232+ return processTerminated(p.Pid()) && processTerminated(descendantPID)
233+ }, 3*time.Second, 20*time.Millisecond)
234+}
235+
236+func processTerminated(pid int) bool {
237+ if errors.Is(syscall.Kill(pid, 0), syscall.ESRCH) {
238+ return true
239+ }
240+ status, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status")
241+ return err != nil || strings.Contains(string(status), "State:\tZ")
242+}
243+
207244 func TestResize_DeliversSIGWINCH(t *testing.T) {
208245 if testing.Short() {
209246 t.Skip("skipping pty integration test in -short mode")
@@ -212,7 +249,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) {
212249 // deliver SIGWINCH to the foreground process group.
213250 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`
214251 f := newFactory()
215- p, err := f.Open("sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
252+ p, err := f.Open(context.Background(), "sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
216253 require.NoError(t, err)
217254 t.Cleanup(func() { _ = p.Close() })
218255
M internal/adapters/remote/remote_factory.go +19 -8
@@ -11,35 +11,46 @@ import (
1111 )
1212
1313 // DialerFactory selects the requested remote transport adapter.
14-type DialerFactory struct{}
14+type DialerFactory struct {
15+ observer ports.SerializedRuntimeObserver
16+}
1517
1618 var _ ports.RemoteDialerFactory = DialerFactory{}
1719
1820 func NewDialerFactory() DialerFactory { return DialerFactory{} }
1921
20-func (DialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
22+// NewDialerFactoryWithRuntimeObserver wires the one process-local sink into
23+// whichever concrete remote carriage adapter is selected.
24+func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserver) DialerFactory {
25+ return DialerFactory{observer: observer}
26+}
27+
28+func (f DialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
2129 switch mode {
2230 case ports.RemoteTransportUDP:
2331 if log != nil {
2432 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
2533 }
26- return dgram.NewRemoteDialerWithLogger(target, session, log), nil
34+ dialer := dgram.NewRemoteDialerWithLogger(target, session, log)
35+ dialer.RuntimeObserver = f.observer
36+ return dialer, nil
2737 case ports.RemoteTransportStdio:
2838 if log != nil {
2939 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
3040 }
31- return stdioDialer{target: target, session: session, log: log}, nil
41+ return stdioDialer{target: target, session: session, log: log, observer: f.observer}, nil
3242 default:
3343 return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)
3444 }
3545 }
3646
3747 type stdioDialer struct {
38- target string
39- session string
40- log *slog.Logger
48+ target string
49+ session string
50+ log *slog.Logger
51+ observer ports.SerializedRuntimeObserver
4152 }
4253
4354 func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) {
44- return sshstdio.DialContext(ctx, d.target, d.session, d.log)
55+ return sshstdio.DialContextWithRuntimeObserver(ctx, d.target, d.session, d.log, sshstdio.WithRuntimeObserver(d.observer))
4556 }
M internal/adapters/sshstdio/transport.go +128 -15
@@ -34,12 +34,26 @@ 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.SerializedRuntimeObserver) 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)
48+ for _, opt := range opts {
49+ if opt != nil {
50+ opt(t)
51+ }
52+ }
53+ return t
4054 }
4155
42-func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc) ports.Transport {
56+func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc) *transport {
4357 if closeFn == nil {
4458 closeFn = func() error { return nil }
4559 }
@@ -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.SerializedRuntimeObserver
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)
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 +182 -0
@@ -0,0 +1,182 @@
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+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
24+ defer reporter.Close()
25+ if _, err := NewTransport(bytes.NewReader(nil), &bytes.Buffer{}, nil, WithRuntimeObserver(reporter)).Recv(); err == nil {
26+ t.Fatal("Recv() error = nil at EOF")
27+ }
28+ reporter.Close()
29+ if err := closer.Close(); err != nil {
30+ t.Fatalf("trace Close() error = %v", err)
31+ }
32+ assertSSHReceiveFailurePair(t, tracePath)
33+}
34+
35+func TestTransportObservabilitySSHStdioCloseEndsBlockedReceive(t *testing.T) {
36+ tracePath := t.TempDir() + "/ssh-close.jsonl"
37+ observer, closer, err := observability.NewJSONL(tracePath, sshRuntimeClock{now: time.Unix(0, 311)}, "ssh-process")
38+ if err != nil {
39+ t.Fatalf("NewJSONL() error = %v", err)
40+ }
41+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
42+ defer reporter.Close()
43+ reader := &sshShutdownReader{entered: make(chan struct{}), done: make(chan struct{})}
44+ shutdownErr := errors.New("ssh shutdown failed")
45+ transport := NewTransport(reader, &bytes.Buffer{}, func() error { return shutdownErr }, WithRuntimeObserver(reporter))
46+ recvDone := make(chan error, 1)
47+ go func() { _, err := transport.Recv(); recvDone <- err }()
48+ <-reader.entered
49+ if err := transport.Close(); !errors.Is(err, shutdownErr) {
50+ t.Fatalf("Close() error = %v, want %v", err, shutdownErr)
51+ }
52+ if err := <-recvDone; err == nil {
53+ t.Fatal("Recv() error = nil after Close()")
54+ }
55+ reporter.Close()
56+ if err := closer.Close(); err != nil {
57+ t.Fatalf("trace Close() error = %v", err)
58+ }
59+ assertSSHReceiveFailurePair(t, tracePath)
60+}
61+
62+type sshShutdownReader struct {
63+ entered chan struct{}
64+ done chan struct{}
65+ once sync.Once
66+}
67+
68+func (r *sshShutdownReader) Read([]byte) (int, error) {
69+ r.once.Do(func() { close(r.entered) })
70+ <-r.done
71+ return 0, errors.New("reader closed")
72+}
73+
74+func (r *sshShutdownReader) Close() error {
75+ r.once.Do(func() { close(r.entered) })
76+ close(r.done)
77+ return nil
78+}
79+
80+func assertSSHReceiveFailurePair(t *testing.T, path string) {
81+ t.Helper()
82+ data, err := os.ReadFile(path)
83+ if err != nil {
84+ t.Fatalf("ReadFile(trace): %v", err)
85+ }
86+ var starts, ends []struct {
87+ ProcessID string `json:"process_id"`
88+ Scenario string `json:"scenario"`
89+ Run uint64 `json:"run"`
90+ Sequence uint64 `json:"sequence"`
91+ RequestID uint64 `json:"request_id"`
92+ Epoch uint64 `json:"epoch"`
93+ Kind string `json:"kind"`
94+ Valid bool `json:"valid"`
95+ }
96+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
97+ var mark struct {
98+ ProcessID string `json:"process_id"`
99+ Scenario string `json:"scenario"`
100+ Run uint64 `json:"run"`
101+ Sequence uint64 `json:"sequence"`
102+ RequestID uint64 `json:"request_id"`
103+ Epoch uint64 `json:"epoch"`
104+ Kind string `json:"kind"`
105+ Valid bool `json:"valid"`
106+ }
107+ if err := json.Unmarshal([]byte(line), &mark); err != nil {
108+ t.Fatalf("Unmarshal(trace): %v", err)
109+ }
110+ switch mark.Kind {
111+ case "adapter_receive_start":
112+ starts = append(starts, mark)
113+ case "adapter_receive_end":
114+ ends = append(ends, mark)
115+ }
116+ }
117+ if len(starts) != 1 || len(ends) != 1 {
118+ t.Fatalf("receive spans start=%d end=%d, want exactly one pair", len(starts), len(ends))
119+ }
120+ start, end := starts[0], ends[0]
121+ 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 {
122+ t.Fatalf("mismatched receive correlation: start=%+v end=%+v", start, end)
123+ }
124+ if !start.Valid || end.Valid {
125+ t.Fatalf("receive validity start=%t end=%t, want true/false", start.Valid, end.Valid)
126+ }
127+}
128+
129+func TestTransportObservabilitySSHStdioPreservesCarriage(t *testing.T) {
130+ tracePath := t.TempDir() + "/ssh.jsonl"
131+ observer, closer, err := observability.NewJSONL(tracePath, sshRuntimeClock{now: time.Unix(0, 307)}, "ssh-process")
132+ if err != nil {
133+ t.Fatalf("NewJSONL() error = %v", err)
134+ }
135+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
136+ defer reporter.Close()
137+
138+ frame := ports.Frame{Type: ports.MsgOutput, Payload: []byte("stdio bytes stay exact")}
139+ var observed, baseline bytes.Buffer
140+ if err := NewTransport(bytes.NewReader(nil), &observed, nil, WithRuntimeObserver(reporter)).Send(frame); err != nil {
141+ t.Fatalf("observed Send() error = %v", err)
142+ }
143+ if err := NewTransport(bytes.NewReader(nil), &baseline, nil).Send(frame); err != nil {
144+ t.Fatalf("baseline Send() error = %v", err)
145+ }
146+ if !bytes.Equal(observed.Bytes(), baseline.Bytes()) {
147+ t.Fatalf("observer changed SSH stdio wire bytes: got %x, want %x", observed.Bytes(), baseline.Bytes())
148+ }
149+ received, err := NewTransport(bytes.NewReader(baseline.Bytes()), &bytes.Buffer{}, nil, WithRuntimeObserver(reporter)).Recv()
150+ if err != nil {
151+ t.Fatalf("observed Recv() error = %v", err)
152+ }
153+ if received.Type != frame.Type || !bytes.Equal(received.Payload, frame.Payload) {
154+ t.Fatalf("observed Recv() = %#v, want %#v", received, frame)
155+ }
156+ reporter.Close()
157+ if err := closer.Close(); err != nil {
158+ t.Fatalf("trace Close() error = %v", err)
159+ }
160+ trace, err := os.ReadFile(tracePath)
161+ if err != nil {
162+ t.Fatalf("ReadFile(trace): %v", err)
163+ }
164+ for _, kind := range []string{"adapter_send_start", "adapter_send_end", "adapter_receive_start", "adapter_receive_end"} {
165+ if !strings.Contains(string(trace), `"kind":"`+kind+`"`) {
166+ t.Errorf("trace missing %s", kind)
167+ }
168+ }
169+}
170+
171+type sshRuntimeClock struct{ now time.Time }
172+
173+func (c sshRuntimeClock) Now() time.Time { return c.now }
174+func (c sshRuntimeClock) NewTimer(d time.Duration) ports.Timer {
175+ return sshRuntimeTimer{timer: time.NewTimer(d)}
176+}
177+
178+type sshRuntimeTimer struct{ timer *time.Timer }
179+
180+func (t sshRuntimeTimer) C() <-chan time.Time { return t.timer.C }
181+func (t sshRuntimeTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) }
182+func (t sshRuntimeTimer) Stop() bool { return t.timer.Stop() }
A internal/adapters/sshstdio/transport_replay_test.go +42 -0
@@ -0,0 +1,42 @@
1+package sshstdio
2+
3+import (
4+ "io"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/testutil/replaytest"
10+)
11+
12+func TestTransportReplay(t *testing.T) {
13+ replaytest.Run(t, func(t *testing.T, frames []ports.Frame) []ports.Frame {
14+ clientRead, serverWrite := io.Pipe()
15+ serverRead, clientWrite := io.Pipe()
16+ sender := NewTransport(clientRead, clientWrite, func() error { return clientWrite.Close() })
17+ receiver := NewTransport(serverRead, serverWrite, func() error { return serverRead.Close() })
18+ t.Cleanup(func() { _ = sender.Close(); _ = receiver.Close() })
19+
20+ var wg sync.WaitGroup
21+ wg.Go(func() {
22+ for _, frame := range frames {
23+ if err := sender.Send(frame); err != nil {
24+ t.Errorf("Send: %v", err)
25+ return
26+ }
27+ }
28+ })
29+ got := make([]ports.Frame, 0, len(frames))
30+ for range frames {
31+ frame, err := receiver.Recv()
32+ if err != nil {
33+ t.Errorf("Recv: %v", err)
34+ _ = receiver.Close()
35+ break
36+ }
37+ got = append(got, frame)
38+ }
39+ wg.Wait()
40+ return got
41+ })
42+}
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 +175 -23
@@ -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,106 @@ 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+// Composition-root factory seams keep observer propagation testable without
247+// opening real transports.
248+var (
249+ newPerformanceTrace = performanceTrace
250+ newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {
251+ return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer)
252+ }
253+ runClientWithRuntimeObserver = client.RunWithRuntimeObserver
254+)
255+
256+func performanceTrace(clk ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {
257+ return performanceTraceWithFactories(clk, observability.NewJSONL, ports.NewRuntimeCorrelationObserver)
258+}
259+
260+// performanceTraceWithFactories keeps setup rollback behavior directly
261+// testable without requiring an operating-system file close failure.
262+func performanceTraceWithFactories(
263+ clk ports.Clock,
264+ newSink func(string, ports.Clock, string) (ports.RuntimeObserver, io.Closer, error),
265+ newCorrelation func(ports.RuntimeObserver, ports.RuntimeCorrelationInputs) (ports.RuntimeObserver, error),
266+) (ports.SerializedRuntimeObserver, io.Closer, error) {
267+ path, processID := os.Getenv("VEV_PERF_TRACE"), os.Getenv("VEV_PERF_PROCESS_ID")
268+ if path == "" {
269+ return nil, nil, nil
270+ }
271+ observer, closer, err := newSink(path, clk, processID)
272+ if err != nil {
273+ return nil, nil, err
274+ }
275+ // The harness supplies these manifest fields to every launched role. Keep a
276+ // valid standalone trace for operators that set only the original trace
277+ // variables, while ensuring harness traces match their process mapping.
278+ inputs := ports.RuntimeCorrelationInputs{Scenario: os.Getenv("VEV_PERF_SCENARIO"), Run: 1}
279+ if inputs.Scenario == "" {
280+ inputs.Scenario = "runtime"
281+ }
282+ if rawRun := os.Getenv("VEV_PERF_RUN"); rawRun != "" {
283+ inputs.Run, err = strconv.ParseUint(rawRun, 10, 64)
284+ if err != nil {
285+ setupErr := fmt.Errorf("invalid VEV_PERF_RUN %q: %w", rawRun, err)
286+ return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)
287+ }
288+ if inputs.Run == 0 {
289+ setupErr := fmt.Errorf("invalid VEV_PERF_RUN %q", rawRun)
290+ return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)
291+ }
292+ }
293+ observer, err = newCorrelation(observer, inputs)
294+ if err != nil {
295+ setupErr := fmt.Errorf("configure runtime trace correlation: %w", err)
296+ return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)
297+ }
298+ reporter := ports.NewSerializedRuntimeObserver(observer, runtimeTraceQueueDepth)
299+ return reporter, &runtimeTraceCloser{reporter: reporter, closer: closer}, nil
300+}
301+
302+func closeTraceAfterSetupFailure(closer io.Closer, setupErr error) error {
303+ if closer == nil {
304+ return setupErr
305+ }
306+ if closeErr := closer.Close(); closeErr != nil {
307+ return errors.Join(setupErr, fmt.Errorf("close performance trace after setup failure: %w", closeErr))
308+ }
309+ return setupErr
310+}
311+
312+// runtimeTraceQueueDepth bounds all process-local trace producer handoffs.
313+// A full queue emits the serialized diagnostic gap rather than delaying a
314+// terminal, transport, or ACK progress path.
315+const runtimeTraceQueueDepth = 256
316+
317+// runtimeTraceCloser owns the two-stage trace shutdown: drain the reporter
318+// before closing the concrete timestamp/file owner. sync.Once makes every
319+// deferred and explicit close path safe without closing shared workers twice.
320+type runtimeTraceCloser struct {
321+ reporter ports.SerializedRuntimeObserver
322+ closer io.Closer
323+ once sync.Once
324+ err error
325+}
326+
327+func (c *runtimeTraceCloser) Close() error {
328+ if c == nil {
329+ return nil
330+ }
331+ c.once.Do(func() {
332+ if c.reporter != nil {
333+ c.reporter.Flush()
334+ c.reporter.Close()
335+ }
336+ if c.closer != nil {
337+ c.err = c.closer.Close()
338+ }
339+ })
340+ return c.err
341+}
342+
241343 func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.Logger, io.Closer, error) {
242344 return logging.Setup(logging.Config{
243345 Dir: platform.StateDir(),
@@ -278,14 +380,23 @@ func pprofAddrIsLoopback(addr string) bool {
278380 // entered by an auto-spawned child): it sets up logging, binds the socket,
279381 // constructs the daemon, and serves until the last session exits or a
280382 // termination signal arrives (graceful shutdown notifies attached clients).
281-func runDaemon() error {
383+func runDaemon() (retErr error) {
282384 log, logCloser, err := configureLogging(logging.Daemon, true)
283385 if err != nil {
284386 return err
285387 }
286388 defer func() { _ = logCloser.Close() }()
287389
288- ln, err := ipc.Listen(ipc.SocketDir())
390+ clk := clock.New()
391+ observer, observerCloser, err := newPerformanceTrace(clk)
392+ if err != nil {
393+ return fmt.Errorf("vev: performance trace: %w", err)
394+ }
395+ if observerCloser != nil {
396+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
397+ }
398+ // Every accepted local connection shares this process's serialized sink.
399+ ln, err := ipc.Listen(ipc.SocketDir(), ipc.WithRuntimeObserver(observer))
289400 if err != nil {
290401 log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err)
291402 return fmt.Errorf("vev: daemon listen: %w", err)
@@ -309,6 +420,9 @@ func runDaemon() error {
309420
310421 log.Info("daemon starting", "socket", ln.Addr())
311422 daemonOpts := []daemon.Option(nil)
423+ if observer != nil {
424+ daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer))
425+ }
312426 configPath := platform.ConfigPath()
313427 cfg, warnings, err := config.Load(configPath)
314428 if err != nil {
@@ -327,7 +441,6 @@ func runDaemon() error {
327441 log.Info("session persistence enabled", "path", storePath)
328442 daemonOpts = append(daemonOpts, daemon.WithStore(store))
329443 }
330- clk := clock.New()
331444 d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...)
332445 watchCtx, stopWatch := context.WithCancel(ctx)
333446 defer stopWatch()
@@ -350,20 +463,32 @@ func runDaemon() error {
350463 // runAttach dials (auto-spawning the daemon if needed) and runs the client
351464 // attach loop. Logging goes to the shared file: the client must never write
352465 // to the console while the terminal is raw.
353-func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error {
466+func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (retErr error) {
354467 log, logCloser, err := configureLogging(logging.Client, false)
355468 if err != nil {
356469 return err
357470 }
358471 defer func() { _ = logCloser.Close() }()
359472
473+ clk := clock.New()
474+ observer, observerCloser, err := newPerformanceTrace(clk)
475+ if err != nil {
476+ return fmt.Errorf("vev: performance trace: %w", err)
477+ }
478+ if observerCloser != nil {
479+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
480+ }
360481 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{
361- localDialer: defaultLocalDialer,
362- remoteDialerFactory: defaultRemoteDialerFactory(),
482+ localDialer: func() ports.Dialer {
483+ return localDaemonDialer{dir: ipc.SocketDir(), observer: observer}
484+ },
485+ remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer),
363486 selectedRemoteTransport: os.Getenv(envRemoteTransport),
364- runClient: client.Run,
365- createDetached: createDetachedLocalSession,
366- clipboard: clipboard.New(),
487+ 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 {
488+ return runClientWithRuntimeObserver(ctx, dialer, terminal, behaviorClock, intent, name, remote, clipboard, log, observer)
489+ },
490+ createDetached: createDetachedLocalSession,
491+ clipboard: clipboard.New(),
367492 })
368493 }
369494
@@ -499,10 +624,19 @@ func isDaemonVersionDrift(err error) bool {
499624 return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal
500625 }
501626
502-type localDaemonDialer struct{ dir string }
627+type localDaemonDialer struct {
628+ dir string
629+ observer ports.SerializedRuntimeObserver
630+}
503631
504632 func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
505- return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff)
633+ dial := realDial
634+ if d.observer != nil {
635+ dial = func(ctx context.Context, dir string) (ports.Transport, error) {
636+ return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(d.observer))
637+ }
638+ }
639+ return ensureDaemon(ctx, d.dir, dial, realSpawn, defaultBackoff)
506640 }
507641
508642 func detachedLocalHello(name, cwd string) ports.Hello {
@@ -584,7 +718,7 @@ func requestDaemonStop(ctx context.Context) error {
584718
585719 done := make(chan error, 1)
586720 go func() {
587- transport, err := realDial(ipc.SocketDir())
721+ transport, err := realDial(ctx, ipc.SocketDir())
588722 if err != nil {
589723 done <- fmt.Errorf("vev: no daemon running")
590724 return
@@ -621,7 +755,7 @@ func requestDaemonStop(ctx context.Context) error {
621755 // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it
622756 // connects to the per-user daemon (auto-spawning it if needed) and proxies the
623757 // framed protocol between process stdio and the daemon socket.
624-func runStdio(ctx context.Context) error {
758+func runStdio(ctx context.Context) (retErr error) {
625759 log, logCloser, err := configureLogging(logging.Stdio, false)
626760 if err != nil {
627761 return err
@@ -629,13 +763,22 @@ func runStdio(ctx context.Context) error {
629763 defer func() { _ = logCloser.Close() }()
630764 log.Debug("stdio proxy starting")
631765
632- transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
766+ clk := clock.New()
767+ observer, observerCloser, err := newPerformanceTrace(clk)
768+ if err != nil {
769+ return fmt.Errorf("vev: performance trace: %w", err)
770+ }
771+ if observerCloser != nil {
772+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
773+ }
774+ transport, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
775+ return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
776+ }, realSpawn, defaultBackoff)
633777 if err != nil {
634778 return err
635779 }
636780 defer func() { _ = transport.Close() }()
637-
638- stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil)
781+ stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil, sshstdio.WithRuntimeObserver(observer))
639782 return proxyTransports(ctx, stdio, transport, log)
640783 }
641784
@@ -719,7 +862,7 @@ func runUDPBootstrap(ctx context.Context, session string) error {
719862 }
720863
721864 // runUDPProxy is the detached long-lived remote-side datagram proxy.
722-func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
865+func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr error) {
723866 log, logCloser, err := configureLogging(logging.Stdio, false)
724867 if err != nil {
725868 return err
@@ -727,6 +870,13 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
727870 defer func() { _ = logCloser.Close() }()
728871 log.Debug("udp proxy starting", "session", session)
729872
873+ observer, observerCloser, err := newPerformanceTrace(clock.New())
874+ if err != nil {
875+ return fmt.Errorf("vev: performance trace: %w", err)
876+ }
877+ if observerCloser != nil {
878+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
879+ }
730880 portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange))
731881 if err != nil {
732882 return err
@@ -740,14 +890,16 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
740890 if _, err := rand.Read(key); err != nil {
741891 return err
742892 }
743- daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
893+ daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
894+ return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
895+ }, realSpawn, defaultBackoff)
744896 if err != nil {
745897 return err
746898 }
747899 defer func() { _ = daemonTr.Close() }()
748900 udpOptions := udpProxyClientTransportOptions
749901 udpOptions.Observe = dgram.DiagnosticLogObserver(log)
750- dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions)
902+ dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer))
751903 if err != nil {
752904 return err
753905 }
@@ -890,8 +1042,8 @@ func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger
8901042
8911043 // runList prints the daemon's session listing. With no daemon running, it
8921044 // falls back to the persisted stopped-session records.
893-func runList(_ context.Context) error {
894- transport, err := realDial(ipc.SocketDir())
1045+func runList(ctx context.Context) error {
1046+ transport, err := realDial(ctx, ipc.SocketDir())
8951047 if err != nil {
8961048 records, loadErr := persist.LoadReadOnly(platform.StateDir())
8971049 if loadErr != nil {
@@ -955,8 +1107,8 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
9551107 }
9561108
9571109 // 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())
1110+func runKill(ctx context.Context, name string, all, daemon bool) error {
1111+ transport, err := realDial(ctx, ipc.SocketDir())
9601112 if err != nil {
9611113 if name != "" && !all && !daemon {
9621114 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 +250 -0
@@ -0,0 +1,250 @@
1+package app
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ "log/slog"
10+ "os"
11+ "path/filepath"
12+ "strconv"
13+ "strings"
14+ "testing"
15+ "time"
16+
17+ "github.com/bnema/vev/internal/ports"
18+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
19+ "github.com/stretchr/testify/mock"
20+)
21+
22+func TestPerformanceTraceUsesHarnessProcessMapping(t *testing.T) {
23+ path := filepath.Join(t.TempDir(), "process.jsonl")
24+ t.Setenv("VEV_PERF_TRACE", path)
25+ t.Setenv("VEV_PERF_PROCESS_ID", "idle-local-r001-daemon-01")
26+ t.Setenv("VEV_PERF_SCENARIO", "1x4-idle-local")
27+ t.Setenv("VEV_PERF_RUN", "1")
28+
29+ clock := portsmocks.NewMockClock(t)
30+ clock.EXPECT().Now().Return(time.Unix(0, 1)).Once()
31+ observer, closer, err := performanceTrace(clock)
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ if observer == nil || closer == nil {
36+ t.Fatal("performance trace was not configured")
37+ }
38+ // performanceTrace's serialized return type makes a raw JSONL sink
39+ // unrepresentable to application transport wiring.
40+ observer.ObserveRuntime(ports.RuntimeMark{Schema: ports.RuntimeMarkSchema, Component: "daemon", Scenario: "runtime", Run: 1, Kind: ports.RuntimeEmitEnd, Valid: true})
41+ if err := closer.Close(); err != nil {
42+ t.Fatal(err)
43+ }
44+ if err := closer.Close(); err != nil {
45+ t.Fatal(err)
46+ }
47+ f, err := os.Open(path)
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ defer func() {
52+ if err := f.Close(); err != nil {
53+ t.Errorf("close trace file: %v", err)
54+ }
55+ }()
56+ var mark struct {
57+ ProcessID string `json:"process_id"`
58+ Scenario string `json:"scenario"`
59+ Run uint64 `json:"run"`
60+ Sequence uint64 `json:"sequence"`
61+ RequestID uint64 `json:"request_id"`
62+ Epoch uint64 `json:"epoch"`
63+ }
64+ if err := json.NewDecoder(bufio.NewReader(f)).Decode(&mark); err != nil {
65+ t.Fatal(err)
66+ }
67+ 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 {
68+ t.Fatalf("mark does not match harness mapping: %+v", mark)
69+ }
70+}
71+
72+func TestPerformanceTraceWithFactoriesCreatesOneConfiguredObserver(t *testing.T) {
73+ t.Setenv("VEV_PERF_TRACE", "trace.jsonl")
74+ t.Setenv("VEV_PERF_PROCESS_ID", "client-01")
75+ t.Setenv("VEV_PERF_SCENARIO", "lossy-remote")
76+ t.Setenv("VEV_PERF_RUN", "7")
77+
78+ clock := portsmocks.NewMockClock(t)
79+ sink := portsmocks.NewMockRuntimeObserver(t)
80+ sink.EXPECT().ObserveRuntime(mock.MatchedBy(func(mark ports.RuntimeMark) bool {
81+ return mark.Component == "client" && mark.Scenario == "lossy-remote" && mark.Run == 7 &&
82+ mark.Sequence != 0 && mark.RequestID != 0 && mark.Epoch != 0 && mark.Kind == ports.RuntimeEmitEnd
83+ })).Once()
84+
85+ var sinkCalls, correlationCalls int
86+ observer, closer, err := performanceTraceWithFactories(clock,
87+ func(path string, gotClock ports.Clock, processID string) (ports.RuntimeObserver, io.Closer, error) {
88+ sinkCalls++
89+ if path != "trace.jsonl" || gotClock != clock || processID != "client-01" {
90+ t.Fatalf("trace sink config = (%q, %v, %q)", path, gotClock, processID)
91+ }
92+ return sink, errorCloser{}, nil
93+ },
94+ func(got ports.RuntimeObserver, inputs ports.RuntimeCorrelationInputs) (ports.RuntimeObserver, error) {
95+ correlationCalls++
96+ if got != sink || inputs != (ports.RuntimeCorrelationInputs{Scenario: "lossy-remote", Run: 7}) {
97+ t.Fatalf("trace correlation config = (%v, %+v)", got, inputs)
98+ }
99+ return ports.NewRuntimeCorrelationObserver(got, inputs)
100+ },
101+ )
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ if sinkCalls != 1 || correlationCalls != 1 || observer == nil || closer == nil {
106+ t.Fatalf("trace setup = sink calls %d, correlation calls %d, observer %v, closer %v; want one configured observer", sinkCalls, correlationCalls, observer, closer)
107+ }
108+ observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeEmitEnd, 0, true))
109+ if err := closer.Close(); err != nil {
110+ t.Fatal(err)
111+ }
112+}
113+
114+func TestPerformanceTraceSetupRollbackJoinsCloseError(t *testing.T) {
115+ setupErr := errors.New("correlation setup failed")
116+ closeErr := errors.New("rollback close failed")
117+
118+ tests := []struct {
119+ name string
120+ rawRun string
121+ correlationErr error
122+ wantPrimaryText string
123+ wantPrimaryErr error
124+ wantNumError bool
125+ }{
126+ {
127+ name: "validation",
128+ rawRun: "not-a-run",
129+ wantPrimaryText: `invalid VEV_PERF_RUN "not-a-run"`,
130+ wantNumError: true,
131+ },
132+ {
133+ name: "correlation",
134+ rawRun: "1",
135+ correlationErr: setupErr,
136+ wantPrimaryText: "runtime trace correlation",
137+ wantPrimaryErr: setupErr,
138+ },
139+ }
140+
141+ for _, tc := range tests {
142+ t.Run(tc.name, func(t *testing.T) {
143+ t.Setenv("VEV_PERF_TRACE", "unused-by-test-sink")
144+ t.Setenv("VEV_PERF_PROCESS_ID", "test-process")
145+ t.Setenv("VEV_PERF_RUN", tc.rawRun)
146+
147+ newSink := func(string, ports.Clock, string) (ports.RuntimeObserver, io.Closer, error) {
148+ return runtimeObserverFunc(func(ports.RuntimeMark) {}), errorCloser{err: closeErr}, nil
149+ }
150+ newCorrelation := func(observer ports.RuntimeObserver, inputs ports.RuntimeCorrelationInputs) (ports.RuntimeObserver, error) {
151+ if tc.correlationErr != nil {
152+ return nil, tc.correlationErr
153+ }
154+ return ports.NewRuntimeCorrelationObserver(observer, inputs)
155+ }
156+
157+ observer, closer, err := performanceTraceWithFactories(nil, newSink, newCorrelation)
158+ if observer != nil || closer != nil {
159+ t.Fatalf("performanceTrace() returned observer=%v closer=%v after setup failure", observer, closer)
160+ }
161+ if !errors.Is(err, closeErr) {
162+ t.Errorf("performanceTrace() error = %v, want joined rollback error %v", err, closeErr)
163+ }
164+ if tc.wantPrimaryErr != nil && !errors.Is(err, tc.wantPrimaryErr) {
165+ t.Errorf("performanceTrace() error = %v, want primary setup error %v", err, tc.wantPrimaryErr)
166+ }
167+ if tc.wantNumError {
168+ var numErr *strconv.NumError
169+ if !errors.As(err, &numErr) {
170+ t.Errorf("performanceTrace() error = %v, want discoverable parse error", err)
171+ }
172+ }
173+ if !strings.Contains(err.Error(), tc.wantPrimaryText) {
174+ t.Errorf("performanceTrace() error = %q, want context %q", err, tc.wantPrimaryText)
175+ }
176+ })
177+ }
178+}
179+
180+func TestRunAttachJoinsTraceCloseError(t *testing.T) {
181+ traceCloseErr := errors.New("trace close failed")
182+ original := newPerformanceTrace
183+ newPerformanceTrace = func(ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {
184+ return ports.NewSerializedRuntimeObserver(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1), errorCloser{err: traceCloseErr}, nil
185+ }
186+ t.Cleanup(func() { newPerformanceTrace = original })
187+ t.Setenv("XDG_STATE_HOME", t.TempDir())
188+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
189+ t.Setenv(envRemoteTransport, "invalid")
190+
191+ err := runAttach(context.Background(), ports.IntentEphemeral, "", "host")
192+ if !errors.Is(err, traceCloseErr) {
193+ t.Fatalf("runAttach() error = %v, want joined trace close error %v", err, traceCloseErr)
194+ }
195+}
196+
197+type errorCloser struct{ err error }
198+
199+func (c errorCloser) Close() error { return c.err }
200+
201+type runtimeObserverFunc func(ports.RuntimeMark)
202+
203+func (f runtimeObserverFunc) ObserveRuntime(mark ports.RuntimeMark) { f(mark) }
204+
205+func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) {
206+ t.Setenv("XDG_STATE_HOME", t.TempDir())
207+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
208+ t.Setenv(envRemoteTransport, string(ports.RemoteTransportStdio))
209+
210+ observer := ports.NewSerializedRuntimeObserver(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1)
211+ originalTrace := newPerformanceTrace
212+ traceCalls := 0
213+ newPerformanceTrace = func(ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {
214+ traceCalls++
215+ return observer, &runtimeTraceCloser{reporter: observer}, nil
216+ }
217+ t.Cleanup(func() { newPerformanceTrace = originalTrace })
218+
219+ factory := portsmocks.NewMockRemoteDialerFactory(t)
220+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportStdio, mock.Anything).Return(namedDialer{name: "remote"}, nil)
221+ originalFactory := newRemoteDialerFactoryWithRuntimeObserver
222+ factoryCalls := 0
223+ newRemoteDialerFactoryWithRuntimeObserver = func(got ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {
224+ factoryCalls++
225+ if got != observer {
226+ t.Fatalf("remote transport observer = %v, want process observer %v", got, observer)
227+ }
228+ return factory
229+ }
230+ t.Cleanup(func() { newRemoteDialerFactoryWithRuntimeObserver = originalFactory })
231+
232+ originalRunClient := runClientWithRuntimeObserver
233+ runClientWithRuntimeObserver = func(_ context.Context, gotDialer ports.Dialer, _ ports.Terminal, _ ports.Clock, _ uint8, _ string, _ bool, _ ports.ClipboardReader, _ *slog.Logger, got ports.SerializedRuntimeObserver) error {
234+ if got != observer {
235+ t.Fatalf("client transport observer = %v, want process observer %v", got, observer)
236+ }
237+ if gotDialer != (namedDialer{name: "remote"}) {
238+ t.Fatalf("remote transport dialer = %v, want configured factory dialer", gotDialer)
239+ }
240+ return nil
241+ }
242+ t.Cleanup(func() { runClientWithRuntimeObserver = originalRunClient })
243+
244+ if err := runAttach(context.Background(), ports.IntentAttach, "work", "remote.example"); err != nil {
245+ t.Fatal(err)
246+ }
247+ if traceCalls != 1 || factoryCalls != 1 {
248+ t.Fatalf("trace/factory calls = %d/%d, want exactly one observer and one transport factory", traceCalls, factoryCalls)
249+ }
250+}
M internal/ports/mocks/mocks.go +93 -20
@@ -499,8 +499,8 @@ func (_m *MockPTYFactory) EXPECT() *MockPTYFactory_Expecter {
499499 }
500500
501501 // Open provides a mock function for the type MockPTYFactory
502-func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
503- ret := _mock.Called(cmd, args, env, dir, sz)
502+func (_mock *MockPTYFactory) Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
503+ ret := _mock.Called(ctx, cmd, args, env, dir, sz)
504504
505505 if len(ret) == 0 {
506506 panic("no return value specified for Open")
@@ -508,18 +508,18 @@ func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir s
508508
509509 var r0 ports.PTY
510510 var r1 error
511- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {
512- return returnFunc(cmd, args, env, dir, sz)
511+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {
512+ return returnFunc(ctx, cmd, args, env, dir, sz)
513513 }
514- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) ports.PTY); ok {
515- r0 = returnFunc(cmd, args, env, dir, sz)
514+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) ports.PTY); ok {
515+ r0 = returnFunc(ctx, cmd, args, env, dir, sz)
516516 } else {
517517 if ret.Get(0) != nil {
518518 r0 = ret.Get(0).(ports.PTY)
519519 }
520520 }
521- if returnFunc, ok := ret.Get(1).(func(string, []string, []string, string, domain.Size) error); ok {
522- r1 = returnFunc(cmd, args, env, dir, sz)
521+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, []string, string, domain.Size) error); ok {
522+ r1 = returnFunc(ctx, cmd, args, env, dir, sz)
523523 } else {
524524 r1 = ret.Error(1)
525525 }
@@ -532,36 +532,41 @@ type MockPTYFactory_Open_Call struct {
532532 }
533533
534534 // Open is a helper method to define mock.On call
535+// - ctx context.Context
535536 // - cmd string
536537 // - args []string
537538 // - env []string
538539 // - dir string
539540 // - sz domain.Size
540-func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {
541- return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", cmd, args, env, dir, sz)}
541+func (_e *MockPTYFactory_Expecter) Open(ctx any, cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {
542+ return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", ctx, cmd, args, env, dir, sz)}
542543 }
543544
544-func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call {
545+func (_c *MockPTYFactory_Open_Call) Run(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call {
545546 _c.Call.Run(func(args mock.Arguments) {
546- var arg0 string
547+ var arg0 context.Context
547548 if args[0] != nil {
548- arg0 = args[0].(string)
549+ arg0 = args[0].(context.Context)
549550 }
550- var arg1 []string
551+ var arg1 string
551552 if args[1] != nil {
552- arg1 = args[1].([]string)
553+ arg1 = args[1].(string)
553554 }
554555 var arg2 []string
555556 if args[2] != nil {
556557 arg2 = args[2].([]string)
557558 }
558- var arg3 string
559+ var arg3 []string
559560 if args[3] != nil {
560- arg3 = args[3].(string)
561+ arg3 = args[3].([]string)
561562 }
562- var arg4 domain.Size
563+ var arg4 string
563564 if args[4] != nil {
564- arg4 = args[4].(domain.Size)
565+ arg4 = args[4].(string)
566+ }
567+ var arg5 domain.Size
568+ if args[5] != nil {
569+ arg5 = args[5].(domain.Size)
565570 }
566571 run(
567572 arg0,
@@ -569,6 +574,7 @@ func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env
569574 arg2,
570575 arg3,
571576 arg4,
577+ arg5,
572578 )
573579 })
574580 return _c
@@ -579,7 +585,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac
579585 return _c
580586 }
581587
582-func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {
588+func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {
583589 _c.Call.Return(run)
584590 return _c
585591 }
@@ -3123,3 +3129,70 @@ func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(ta
31233129 _c.Call.Return(run)
31243130 return _c
31253131 }
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+}
M internal/ports/ports.go +6 -2
@@ -24,7 +24,9 @@ type PTY interface {
2424
2525 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.
2626 type PTYFactory interface {
27- Open(cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)
27+ // Open creates a PTY while honoring ctx cancellation. Implementations must
28+ // return promptly once ctx is cancelled and must not leave a child running.
29+ Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)
2830 }
2931
3032 // Terminal is the CLIENT-side controlling terminal.
@@ -38,7 +40,9 @@ type Terminal interface {
3840 Flush() error
3941 }
4042
41-// Transport is a framed message channel over a single connection.
43+// Transport is a framed message channel over a single connection. Close must
44+// be safe to call concurrently with Send and Recv, and must unblock active
45+// Send and Recv calls.
4246 type Transport interface {
4347 Send(Frame) error
4448 Recv() (Frame, error) // blocking; io.EOF on close
A internal/ports/runtime_observer.go +298 -0
@@ -0,0 +1,298 @@
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 is the only observer contract accepted by runtime
66+// transport and use-case hot paths. ObserveRuntime must return without waiting
67+// for sink I/O. Flush and Close wait until every accepted mark has reached the
68+// concrete observer; Close is idempotent and rejects later marks.
69+type SerializedRuntimeObserver interface {
70+ RuntimeObserver
71+ Flush()
72+ Close()
73+}
74+
75+// NewSerializedRuntimeObserver isolates producers from a potentially slow
76+// observer with one ordered worker. capacity bounds queued marks. If the queue
77+// fills, dropped marks are represented by one ordered diagnostic mark before
78+// reporting resumes, so a trace can never silently claim completeness.
79+func NewSerializedRuntimeObserver(observer RuntimeObserver, capacity int) SerializedRuntimeObserver {
80+ if observer == nil {
81+ return nil
82+ }
83+ if capacity < 1 {
84+ capacity = 1
85+ }
86+ o := &serializedRuntimeObserver{observer: observer, capacity: capacity, done: make(chan struct{})}
87+ o.cond = sync.NewCond(&o.mu)
88+ go o.run()
89+ return o
90+}
91+
92+// EnsureSerializedRuntimeObserver returns observer's existing serialized
93+// owner when it has one. Otherwise it creates an owned worker. Callers close
94+// the result only when owned is true, preventing nested workers and duplicate
95+// lifecycle ownership when application wiring already serialized the process
96+// observer.
97+func EnsureSerializedRuntimeObserver(observer RuntimeObserver, capacity int) (serialized SerializedRuntimeObserver, owned bool) {
98+ if observer == nil {
99+ return nil, false
100+ }
101+ if serialized, ok := observer.(SerializedRuntimeObserver); ok {
102+ return serialized, false
103+ }
104+ return NewSerializedRuntimeObserver(observer, capacity), true
105+}
106+
107+type serializedRuntimeObserver struct {
108+ observer RuntimeObserver
109+ capacity int
110+
111+ mu sync.Mutex
112+ cond *sync.Cond
113+ marks []RuntimeMark
114+ dropped uint64
115+ closed bool
116+ active bool
117+ done chan struct{}
118+}
119+
120+func (o *serializedRuntimeObserver) ObserveRuntime(mark RuntimeMark) {
121+ if o == nil {
122+ return
123+ }
124+ o.mu.Lock()
125+ defer o.mu.Unlock()
126+ if o.closed {
127+ return
128+ }
129+ if o.dropped != 0 {
130+ // Reserve space for both the gap marker and the first post-gap mark.
131+ // This preserves the producer order around a bounded-queue loss.
132+ if len(o.marks)+2 > o.capacity {
133+ o.dropped++
134+ return
135+ }
136+ o.marks = append(o.marks, runtimeObserverGapMark(o.dropped), mark)
137+ o.dropped = 0
138+ o.cond.Signal()
139+ return
140+ }
141+ if len(o.marks) == o.capacity {
142+ o.dropped = 1
143+ return
144+ }
145+ o.marks = append(o.marks, mark)
146+ o.cond.Signal()
147+}
148+
149+func runtimeObserverGapMark(dropped uint64) RuntimeMark {
150+ return NewRuntimeMark("observer", RuntimeTransportDiagnostic, dropped, false)
151+}
152+
153+func (o *serializedRuntimeObserver) Flush() {
154+ if o == nil {
155+ return
156+ }
157+ o.mu.Lock()
158+ for len(o.marks) != 0 || o.active || o.dropped != 0 {
159+ o.cond.Signal()
160+ o.cond.Wait()
161+ }
162+ closed := o.closed
163+ o.mu.Unlock()
164+ if closed {
165+ <-o.done
166+ }
167+}
168+
169+func (o *serializedRuntimeObserver) Close() {
170+ if o == nil {
171+ return
172+ }
173+ o.mu.Lock()
174+ if !o.closed {
175+ o.closed = true
176+ o.cond.Broadcast()
177+ }
178+ o.mu.Unlock()
179+ <-o.done
180+}
181+
182+func (o *serializedRuntimeObserver) run() {
183+ defer close(o.done)
184+ for {
185+ o.mu.Lock()
186+ for len(o.marks) == 0 && o.dropped == 0 && !o.closed {
187+ o.cond.Wait()
188+ }
189+ if len(o.marks) == 0 && o.dropped != 0 {
190+ o.marks = append(o.marks, runtimeObserverGapMark(o.dropped))
191+ o.dropped = 0
192+ }
193+ if len(o.marks) == 0 && o.closed {
194+ o.cond.Broadcast()
195+ o.mu.Unlock()
196+ return
197+ }
198+ mark := o.marks[0]
199+ o.marks = o.marks[1:]
200+ o.active = true
201+ o.mu.Unlock()
202+
203+ o.observer.ObserveRuntime(mark)
204+
205+ o.mu.Lock()
206+ o.active = false
207+ o.cond.Broadcast()
208+ o.mu.Unlock()
209+ }
210+}
211+
212+// RuntimeMarkValid reports whether a mark has all correlation fields required
213+// by the JSONL schema. Tick is checked separately by the timestamp owner.
214+func RuntimeMarkValid(mark RuntimeMark) bool {
215+ if mark.Schema != RuntimeMarkSchema || mark.Component == "" || mark.Scenario == "" || mark.Run == 0 || mark.Sequence == 0 || mark.RequestID == 0 || mark.Epoch == 0 {
216+ return false
217+ }
218+ switch mark.Kind {
219+ case RuntimeInputInjected, RuntimeResizeRequested, RuntimeResizeCommitted,
220+ RuntimeCaptureStart, RuntimeCaptureEnd, RuntimeComposeStart, RuntimeComposeEnd,
221+ RuntimeDiffStart, RuntimeDiffEnd, RuntimeQueueEnqueued, RuntimeQueueDequeued,
222+ RuntimeACKBlockedStart, RuntimeACKBlockedEnd, RuntimeEmitStart, RuntimeEmitEnd,
223+ RuntimeAdapterSendStart, RuntimeAdapterSendEnd, RuntimeAdapterReceiveStart,
224+ RuntimeAdapterReceiveEnd, RuntimeTerminalFlushed, RuntimeTransportDiagnostic:
225+ return true
226+ default:
227+ return false
228+ }
229+}
230+
231+// RuntimeCorrelation identifies one attempted operation. It is deliberately
232+// copied to both endpoints of a span; process ID remains owned by the sink.
233+type RuntimeCorrelation struct {
234+ Scenario string
235+ Run, Sequence, RequestID, Epoch uint64
236+}
237+
238+// RuntimeCorrelationInputs are supplied once by process orchestration. They
239+// are deliberately data-only: consumer APIs continue to receive only their
240+// one RuntimeObserver argument and never a clock or tick.
241+type RuntimeCorrelationInputs struct {
242+ Scenario string
243+ Run uint64
244+}
245+
246+// NewRuntimeCorrelationObserver binds every mark from one OS process to its
247+// harness manifest scenario/run. Operation IDs remain producer-owned when
248+// supplied; missing IDs are assigned from the process-wide allocator.
249+func NewRuntimeCorrelationObserver(observer RuntimeObserver, inputs RuntimeCorrelationInputs) (RuntimeObserver, error) {
250+ if observer == nil || inputs.Scenario == "" || inputs.Run == 0 {
251+ return nil, errors.New("invalid runtime correlation inputs")
252+ }
253+ return &runtimeCorrelationObserver{observer: observer, inputs: inputs}, nil
254+}
255+
256+type runtimeCorrelationObserver struct {
257+ observer RuntimeObserver
258+ inputs RuntimeCorrelationInputs
259+}
260+
261+func (o *runtimeCorrelationObserver) ObserveRuntime(mark RuntimeMark) {
262+ if o == nil || o.observer == nil {
263+ return
264+ }
265+ mark.Scenario, mark.Run = o.inputs.Scenario, o.inputs.Run
266+ if mark.Sequence == 0 {
267+ mark.Sequence = runtimeMarkSequence.Add(1)
268+ }
269+ if mark.RequestID == 0 {
270+ mark.RequestID = mark.Sequence
271+ }
272+ if mark.Epoch == 0 {
273+ mark.Epoch = mark.RequestID
274+ }
275+ o.observer.ObserveRuntime(mark)
276+}
277+
278+var runtimeMarkSequence atomic.Uint64
279+
280+// NewRuntimeCorrelation makes a unique, process-local operation identity.
281+// Components with a domain request/epoch should use those values directly via
282+// NewRuntimeMarkWithCorrelation instead of inventing a second identity.
283+func NewRuntimeCorrelation() RuntimeCorrelation {
284+ sequence := runtimeMarkSequence.Add(1)
285+ return RuntimeCorrelation{Scenario: "runtime", Run: 1, Sequence: sequence, RequestID: sequence, Epoch: sequence}
286+}
287+
288+// NewRuntimeMarkWithCorrelation returns a timestamp-free mark carrying the
289+// supplied operation identity. Start/end callers must reuse correlation.
290+func NewRuntimeMarkWithCorrelation(component string, correlation RuntimeCorrelation, kind RuntimeMarkKind, bytes uint64, valid bool) RuntimeMark {
291+ 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}
292+}
293+
294+// NewRuntimeMark is retained for one-shot marks. Unlike the old helper it
295+// never returns constant correlation IDs.
296+func NewRuntimeMark(component string, kind RuntimeMarkKind, bytes uint64, valid bool) RuntimeMark {
297+ return NewRuntimeMarkWithCorrelation(component, NewRuntimeCorrelation(), kind, bytes, valid)
298+}
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) }
A internal/testutil/replaytest/replay.go +64 -0
@@ -0,0 +1,64 @@
1+// Package replaytest owns the protocol-neutral transport replay fixture and
2+// assertions shared by transport adapter tests.
3+package replaytest
4+
5+import (
6+ "bytes"
7+ "reflect"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+var transcript = []ports.Frame{
14+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
15+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
16+}
17+
18+// Transcript returns a deep copy of the canonical replay transcript so an
19+// adapter cannot mutate the fixture observed by another adapter.
20+func Transcript() []ports.Frame {
21+ return cloneFrames(transcript)
22+}
23+
24+// Exchange carries already-composed frames through one transport adapter.
25+type Exchange func(t *testing.T, frames []ports.Frame) []ports.Frame
26+
27+// Run verifies that an adapter preserves the canonical transcript exactly.
28+func Run(t *testing.T, exchange Exchange) {
29+ t.Helper()
30+ want := Transcript()
31+ got := exchange(t, cloneFrames(want))
32+ if len(got) != len(want) {
33+ t.Fatalf("replayed frame count = %d, want %d", len(got), len(want))
34+ }
35+ for i := range want {
36+ if got[i].Type != want[i].Type {
37+ t.Errorf("frame %d type = %d, want %d", i, got[i].Type, want[i].Type)
38+ }
39+ if !bytes.Equal(got[i].Payload, want[i].Payload) {
40+ t.Errorf("frame %d payload = %x, want byte-for-byte %x", i, got[i].Payload, want[i].Payload)
41+ continue
42+ }
43+ gotOutput, err := ports.UnmarshalOutput(got[i].Payload)
44+ if err != nil {
45+ t.Errorf("frame %d output decode: %v", i, err)
46+ continue
47+ }
48+ wantOutput, err := ports.UnmarshalOutput(want[i].Payload)
49+ if err != nil {
50+ t.Fatalf("canonical frame %d output decode: %v", i, err)
51+ }
52+ if !reflect.DeepEqual(gotOutput, wantOutput) {
53+ t.Errorf("frame %d output = %#v, want %#v", i, gotOutput, wantOutput)
54+ }
55+ }
56+}
57+
58+func cloneFrames(frames []ports.Frame) []ports.Frame {
59+ cloned := make([]ports.Frame, len(frames))
60+ for i, frame := range frames {
61+ cloned[i] = ports.Frame{Type: frame.Type, Payload: append([]byte(nil), frame.Payload...)}
62+ }
63+ return cloned
64+}
M internal/usecase/client/client.go +105 -14
@@ -102,6 +102,10 @@ const stdinBufSize = 4096
102102 // goroutine, decoupling the input/resize pumps from transport back-pressure.
103103 const sendQueueDepth = 64
104104
105+// preWelcomeTimeout bounds each blocking operation before the daemon has
106+// accepted the attach. Mosh uses the same 15-second startup budget.
107+const preWelcomeTimeout = 15 * time.Second
108+
105109 var reconnectSleep = sleepReconnect
106110 var reconnectSleepWithResize = sleepReconnectWithResizeEvents
107111
@@ -120,7 +124,29 @@ 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 {
131+ observer ports.SerializedRuntimeObserver
132+}
133+
134+// WithRuntimeObserver accepts only a composition-root serialized observer, so
135+// terminal and transport progress can never wait for a raw trace sink.
136+func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
137+ return func(opts *runtimeOptions) { opts.observer = observer }
138+}
139+
140+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 {
141+ return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, nil)
142+}
143+
144+// RunWithRuntimeObserver is the application wiring entry point.
145+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.SerializedRuntimeObserver) error {
146+ return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, observer)
147+}
148+
149+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.SerializedRuntimeObserver) (retErr error) {
124150 if log == nil {
125151 log = slog.Default()
126152 }
@@ -244,11 +270,13 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
244270 if reporter, ok := transport.(ports.LinkStateReporter); ok {
245271 linkEvents = reporter.LinkEvents()
246272 }
247- result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log)
273+ result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log, observer)
248274 if result.welcomed {
249275 backoff = defaultReconnectBackoff.initial
250276 }
251- _ = transport.Close()
277+ if !result.transportClosed {
278+ _ = transport.Close()
279+ }
252280 if result.resumeToken != 0 {
253281 resumeToken = result.resumeToken
254282 }
@@ -311,8 +339,14 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
311339 // disappears, or the context is cancelled.
312340 //
313341 // 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())
342+func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, options ...Option) error {
343+ var opts runtimeOptions
344+ for _, option := range options {
345+ if option != nil {
346+ option(&opts)
347+ }
348+ }
349+ return run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default(), opts.observer)
316350 }
317351
318352 type singleTransportDialer struct{ transport ports.Transport }
@@ -322,10 +356,52 @@ func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) {
322356 }
323357
324358 type attachResult struct {
325- resumeToken uint64
326- sessionName string
327- welcomed bool
328- err error
359+ resumeToken uint64
360+ sessionName string
361+ welcomed bool
362+ transportClosed bool
363+ err error
364+}
365+
366+// boundedPreWelcome runs one blocking handshake operation. Transport methods
367+// do not accept a context, so cancellation and expiry close this attempt's
368+// transport to interrupt the call, then wait for its goroutine to exit.
369+func boundedPreWelcome(ctx context.Context, clk ports.Clock, transport ports.Transport, operation func() error) (bool, error) {
370+ if err := ctx.Err(); err != nil {
371+ _ = transport.Close()
372+ return true, err
373+ }
374+
375+ timer := clk.NewTimer(preWelcomeTimeout)
376+ defer timer.Stop()
377+ completed := make(chan error, 1)
378+ go func() { completed <- operation() }()
379+
380+ select {
381+ case err := <-completed:
382+ // Prefer a cancellation/expiry observed concurrently with completion.
383+ // That event owns the transport lifetime even though the operation has
384+ // already returned by the time we observe it here.
385+ if ctx.Err() != nil {
386+ _ = transport.Close()
387+ return true, ctx.Err()
388+ }
389+ select {
390+ case <-timer.C():
391+ _ = transport.Close()
392+ return true, context.DeadlineExceeded
393+ default:
394+ return false, err
395+ }
396+ case <-ctx.Done():
397+ _ = transport.Close()
398+ <-completed
399+ return true, ctx.Err()
400+ case <-timer.C():
401+ _ = transport.Close()
402+ <-completed
403+ return true, context.DeadlineExceeded
404+ }
329405 }
330406
331407 // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
@@ -346,7 +422,11 @@ func requestedOutputWindow(transport ports.Transport) uint8 {
346422 return 8
347423 }
348424
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 {
425+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 {
426+ var observer ports.RuntimeObserver
427+ if len(observers) != 0 {
428+ observer = observers[0]
429+ }
350430 // 1. Handshake: send Hello with our size and TERM.
351431 size, err := term.Size()
352432 if err != nil {
@@ -371,15 +451,22 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
371451 TrueColor: trueColor,
372452 MaxOutputInFlight: requestedOutputWindow(transport),
373453 }
374- if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
375- return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}
454+ if closed, err := boundedPreWelcome(ctx, clk, transport, func() error {
455+ return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
456+ }); err != nil {
457+ return attachResult{transportClosed: closed, err: fmt.Errorf("vev: sending hello: %w", err)}
376458 }
377459 ms.helloSent = true
378460
379461 // 2. Await Welcome or a typed rejection.
380- reply, err := transport.Recv()
462+ var reply ports.Frame
463+ closed, err := boundedPreWelcome(ctx, clk, transport, func() error {
464+ var recvErr error
465+ reply, recvErr = transport.Recv()
466+ return recvErr
467+ })
381468 if err != nil {
382- return attachResult{err: fmt.Errorf("vev: awaiting welcome: %w", err)}
469+ return attachResult{transportClosed: closed, err: fmt.Errorf("vev: awaiting welcome: %w", err)}
383470 }
384471 result := func(welcomed bool, err error) attachResult {
385472 return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err}
@@ -501,6 +588,10 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
501588 if ferr := term.Flush(); ferr != nil {
502589 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
503590 }
591+ // The terminal boundary is after a successful flush and before ACK.
592+ if observer != nil {
593+ observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true))
594+ }
504595 if o.NewStateNum != 0 {
505596 ackQueue.offer(o.NewStateNum)
506597 }
M internal/usecase/client/client_test.go +114 -0
@@ -155,6 +155,120 @@ func isType(typ ports.MsgType) any {
155155 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })
156156 }
157157
158+const testPreWelcomeTimeout = 15 * time.Second
159+
160+func newHandshakeClock(t *testing.T, timerCount int) (*portsmocks.MockClock, <-chan chan time.Time) {
161+ t.Helper()
162+ created := make(chan chan time.Time, timerCount)
163+ clk := portsmocks.NewMockClock(t)
164+ clk.EXPECT().NewTimer(testPreWelcomeTimeout).RunAndReturn(func(time.Duration) ports.Timer {
165+ timerC := make(chan time.Time, 1)
166+ timer := portsmocks.NewMockTimer(t)
167+ timer.EXPECT().C().Return((<-chan time.Time)(timerC)).Maybe()
168+ timer.EXPECT().Stop().Return(true).Once()
169+ created <- timerC
170+ return timer
171+ }).Times(timerCount)
172+ return clk, created
173+}
174+
175+func TestRunBoundsPreWelcomeOperations(t *testing.T) {
176+ tests := []struct {
177+ name string
178+ phase string
179+ end string
180+ want error
181+ }{
182+ {name: "cancel while sending hello", phase: "send", end: "cancel", want: context.Canceled},
183+ {name: "timeout while sending hello", phase: "send", end: "timeout", want: context.DeadlineExceeded},
184+ {name: "cancel while receiving welcome", phase: "recv", end: "cancel", want: context.Canceled},
185+ {name: "timeout while receiving welcome", phase: "recv", end: "timeout", want: context.DeadlineExceeded},
186+ }
187+
188+ for _, tt := range tests {
189+ t.Run(tt.name, func(t *testing.T) {
190+ ctx, cancel := context.WithCancel(context.Background())
191+ defer cancel()
192+ timerCount := 1
193+ if tt.phase == "recv" {
194+ timerCount = 2
195+ }
196+ clk, createdTimers := newHandshakeClock(t, timerCount)
197+
198+ term := portsmocks.NewMockTerminal(t)
199+ term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
200+ // No EnterRaw expectation: it must not run before Welcome.
201+
202+ started := make(chan struct{})
203+ closed := make(chan struct{})
204+ operationExited := make(chan struct{})
205+ tr := portsmocks.NewMockTransport(t)
206+ tr.EXPECT().Close().Run(func() { close(closed) }).Return(nil).Once()
207+
208+ block := func() error {
209+ close(started)
210+ <-closed
211+ close(operationExited)
212+ return io.ErrClosedPipe
213+ }
214+ if tt.phase == "send" {
215+ tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(ports.Frame) error {
216+ return block()
217+ }).Once()
218+ } else {
219+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
220+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
221+ return ports.Frame{}, block()
222+ }).Once()
223+ }
224+ dialer := portsmocks.NewMockDialer(t)
225+ dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
226+
227+ errCh := make(chan error, 1)
228+ go func() {
229+ errCh <- client.Run(ctx, dialer, term, clk, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
230+ }()
231+
232+ var timerC chan time.Time
233+ select {
234+ case timerC = <-createdTimers:
235+ case <-time.After(time.Second):
236+ t.Fatal("pre-Welcome timer was not created")
237+ }
238+ select {
239+ case <-started:
240+ case <-time.After(time.Second):
241+ t.Fatal("pre-Welcome operation did not start")
242+ }
243+ if tt.phase == "recv" {
244+ select {
245+ case timerC = <-createdTimers:
246+ case <-time.After(time.Second):
247+ t.Fatal("Welcome timer was not created")
248+ }
249+ }
250+
251+ if tt.end == "cancel" {
252+ cancel()
253+ } else {
254+ timerC <- time.Time{}
255+ }
256+
257+ select {
258+ case err := <-errCh:
259+ require.ErrorIs(t, err, tt.want)
260+ case <-time.After(time.Second):
261+ t.Fatal("Run did not return after pre-Welcome cancellation")
262+ }
263+ select {
264+ case <-operationExited:
265+ case <-time.After(time.Second):
266+ t.Fatal("pre-Welcome operation leaked after Run returned")
267+ }
268+ })
269+ }
270+}
271+
158272 func TestAttachHelloIncludesTrueColor(t *testing.T) {
159273 t.Setenv("TERM", "xterm-256color")
160274 t.Setenv("COLORTERM", "truecolor")
M internal/usecase/client/reconnect_toast_test.go +18 -6
@@ -179,6 +179,18 @@ func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransp
179179 return hello
180180 }
181181
182+// newReconnectHandshakeClock supplies the non-firing timer needed by the
183+// bounded pre-Welcome handshake; reconnect sleeps are stubbed by these tests.
184+func newReconnectHandshakeClock(t *testing.T) *portsmocks.MockClock {
185+ t.Helper()
186+ clk := portsmocks.NewMockClock(t)
187+ timer := portsmocks.NewMockTimer(t)
188+ timer.EXPECT().C().Return((<-chan time.Time)(make(chan time.Time))).Maybe()
189+ timer.EXPECT().Stop().Return(true).Maybe()
190+ clk.EXPECT().NewTimer(preWelcomeTimeout).Return(timer).Maybe()
191+ return clk
192+}
193+
182194 func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {
183195 term := newReconnectToastTerminalHarness(t)
184196 defer term.closeInput()
@@ -190,7 +202,7 @@ func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {
190202 resultCh := make(chan attachResult, 1)
191203 ms := milestones{}
192204 go func() {
193- resultCh <- attachOnce(context.Background(), tr, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error {
205+ resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error {
194206 _, err := term.term.EnterRaw()
195207 return err
196208 }, func() { clears <- struct{}{} }, func(stage reconnectStage) {
@@ -229,7 +241,7 @@ func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) {
229241 resultCh := make(chan attachResult, 1)
230242 ms := milestones{}
231243 go func() {
232- resultCh <- attachOnce(context.Background(), tr, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error {
244+ resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error {
233245 _, err := term.term.EnterRaw()
234246 return err
235247 }, func() {}, func(stage reconnectStage) {
@@ -400,7 +412,7 @@ func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) {
400412 }}
401413 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
402414
403- err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
415+ err := Run(ctx, dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
404416 require.ErrorIs(t, err, context.Canceled)
405417 require.True(t, out.failed)
406418 assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)))
@@ -435,7 +447,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
435447 }}
436448 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
437449
438- err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
450+ err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
439451 require.NoError(t, err)
440452 require.True(t, tr1.closed)
441453 require.True(t, tr2.closed)
@@ -479,7 +491,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
479491 }}
480492 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
481493
482- err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentEphemeral, "", true, nil, slog.New(slog.DiscardHandler))
494+ err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentEphemeral, "", true, nil, slog.New(slog.DiscardHandler))
483495 require.NoError(t, err)
484496 require.Equal(t, 2, dialer.calls)
485497
@@ -564,7 +576,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
564576 dialer := portsmocks.NewMockDialer(t)
565577 tt.configure(t, dialer)
566578
567- err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
579+ err := Run(ctx, dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
568580 tt.wantErr(t, err)
569581 out := term.out.String()
570582 require.Contains(t, out, reconnectToastMessage)
A internal/usecase/client/transport_observability_test.go +154 -0
@@ -0,0 +1,154 @@
1+package client_test
2+
3+import (
4+ "bytes"
5+ "context"
6+ "reflect"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
17+ "github.com/bnema/vev/internal/usecase/client"
18+)
19+
20+func TestRuntimeObserverOptionRequiresSerializedContract(t *testing.T) {
21+ got := reflect.TypeOf(client.WithRuntimeObserver).In(0)
22+ want := reflect.TypeFor[ports.SerializedRuntimeObserver]()
23+ if got != want {
24+ t.Fatalf("WithRuntimeObserver accepts %v, want %v; raw blocking sinks must not enter the hot path", got, want)
25+ }
26+}
27+
28+func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
29+ var out bytes.Buffer
30+ var restores atomic.Int32
31+ resizeCh := make(chan domain.Size)
32+ flushed := make(chan struct{})
33+ term := portsmocks.NewMockTerminal(t)
34+ term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
35+ term.EXPECT().EnterRaw().Return(func() error { restores.Add(1); return nil }, nil).Once()
36+ in := newBlockingReader()
37+ defer in.unblock()
38+ term.EXPECT().In().Return(in).Maybe()
39+ term.EXPECT().Out().Return(&out).Maybe()
40+ term.EXPECT().Flush().Run(func() { close(flushed) }).Return(nil).Once()
41+ term.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
42+
43+ observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})}
44+ reporter := ports.NewSerializedRuntimeObserver(observer, 1)
45+ defer reporter.Close()
46+ acked := make(chan struct{})
47+ transport := portsmocks.NewMockTransport(t)
48+ transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
49+ transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once()
50+ unblock := scriptRecv(transport,
51+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
52+ recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("flush-before-observe"), NewStateNum: 3}))},
53+ )
54+ defer unblock()
55+ transport.EXPECT().Close().Return(nil).Once()
56+
57+ ctx, cancel := context.WithCancel(context.Background())
58+ result := make(chan error, 1)
59+ go func() {
60+ result <- client.Attach(ctx, transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(reporter))
61+ }()
62+ awaitClientRuntime(t, observer.entered, "blocking observer")
63+ awaitClientRuntime(t, flushed, "terminal flush")
64+ awaitClientRuntime(t, acked, "ACK")
65+ cancel()
66+ close(observer.release)
67+ select {
68+ case err := <-result:
69+ require.NoError(t, err)
70+ case <-time.After(time.Second):
71+ t.Fatal("Attach did not complete after observer release")
72+ }
73+ require.Equal(t, "flush-before-observe", out.String())
74+ require.Equal(t, int32(1), restores.Load())
75+}
76+
77+func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
78+ var out bytes.Buffer
79+ var restores atomic.Int32
80+ resizeCh := make(chan domain.Size)
81+ term, in := newHappyTerminal(t, &out, &restores, resizeCh)
82+ defer in.unblock()
83+
84+ observer := &clientRuntimeObserver{}
85+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
86+ defer reporter.Close()
87+ transport := portsmocks.NewMockTransport(t)
88+ transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
89+ unblock := scriptRecv(transport,
90+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
91+ recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("unchanged-by-observer"), NewStateNum: 3}))},
92+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
93+ )
94+ defer unblock()
95+ transport.EXPECT().Send(isType(ports.MsgAck)).Return(nil).Maybe()
96+ transport.EXPECT().Close().Return(nil).Once()
97+
98+ err := client.Attach(context.Background(), transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(reporter))
99+ require.NoError(t, err)
100+ reporter.Flush()
101+ require.Equal(t, "unchanged-by-observer", out.String(), "observer must not alter terminal bytes")
102+ require.Equal(t, int32(1), restores.Load())
103+ // Carriage spans belong only to concrete adapters. The client owns the
104+ // post-successful-flush terminal boundary, before its ACK is queued.
105+ observer.requireOrdered(t, ports.RuntimeTerminalFlushed)
106+}
107+
108+type blockingClientRuntimeObserver struct {
109+ entered chan struct{}
110+ release chan struct{}
111+ once sync.Once
112+}
113+
114+func (o *blockingClientRuntimeObserver) ObserveRuntime(ports.RuntimeMark) {
115+ o.once.Do(func() {
116+ close(o.entered)
117+ <-o.release
118+ })
119+}
120+
121+func awaitClientRuntime(t *testing.T, ch <-chan struct{}, what string) {
122+ t.Helper()
123+ select {
124+ case <-ch:
125+ case <-time.After(time.Second):
126+ t.Fatalf("timed out waiting for %s", what)
127+ }
128+}
129+
130+type clientRuntimeObserver struct {
131+ mu sync.Mutex
132+ marks []ports.RuntimeMark
133+}
134+
135+func (o *clientRuntimeObserver) ObserveRuntime(mark ports.RuntimeMark) {
136+ o.mu.Lock()
137+ defer o.mu.Unlock()
138+ o.marks = append(o.marks, mark)
139+}
140+
141+func (o *clientRuntimeObserver) requireOrdered(t *testing.T, kinds ...ports.RuntimeMarkKind) {
142+ t.Helper()
143+ o.mu.Lock()
144+ defer o.mu.Unlock()
145+ next := 0
146+ for _, mark := range o.marks {
147+ if next < len(kinds) && mark.Kind == kinds[next] {
148+ next++
149+ }
150+ }
151+ if next != len(kinds) {
152+ t.Fatalf("runtime marks %#v do not contain ordered %#v", o.marks, kinds)
153+ }
154+}
M internal/usecase/copy/mode.go +44 -18
@@ -7,6 +7,7 @@ import (
77 "unicode"
88
99 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/bnema/vev/pkg/vt"
1011 )
1112
1213 // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits
@@ -17,45 +18,49 @@ import (
1718 const OSC52MaxPayloadBytes = 75_000
1819
1920 // Snapshot is the immutable scrollback-mode document: history followed by the
20-// visible screen. History rows are immutable by Scrollback's append contract;
21-// the visible frame is cloned once when the snapshot is constructed.
21+// visible screen. VT HistoryView retains immutable chunks; the visible frame is
22+// cloned once when the snapshot is constructed.
2223 type Snapshot struct {
23- history HistoryView
24+ history vt.HistoryView
2425 screen renderer.Frame
2526 Width int
2627 Height int
2728 }
2829
29-// NewSnapshot freezes the current scrollback view and clones the visible screen.
30-func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
31- var history HistoryView
32- if sb != nil {
33- history = sb.View()
30+// NewSnapshot seals the current VT history tail, then freezes that immutable
31+// view and clones the visible screen. Sealing prevents repeated copy entry from
32+// cloning every row in a partial tail.
33+func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {
34+ var history vt.HistoryView
35+ if historySource != nil {
36+ history = historySource.SealAndView()
3437 }
3538 return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}
3639 }
3740
3841 // NewSnapshotFromRows constructs a snapshot that owns explicit caller rows.
3942 // It is intended for callers that already have a complete document rather than
40-// a scrollback and visible frame.
43+// a scrollback and visible frame. Height is viewport geometry, not a request to
44+// append blank document rows.
4145 func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
42- owned := make([][]renderer.Cell, len(rows))
43- for i, row := range rows {
44- owned[i] = append([]renderer.Cell(nil), row...)
46+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256})
47+ for _, row := range rows {
48+ history.Append(row)
4549 }
46- return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height}
50+ return Snapshot{history: history.SealAndView(), Width: width, Height: height}
4751 }
4852
4953 // Len returns the number of document rows.
5054 func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
5155
52-// Row returns document row i, or nil when i is out of range.
56+// Row returns document row i, or nil when i is out of range. History rows
57+// borrow immutable history storage and must be treated as read-only.
5358 func (s Snapshot) Row(i int) []renderer.Cell {
5459 if i < 0 {
5560 return nil
5661 }
5762 if i < s.history.Len() {
58- return s.history.Row(i)
63+ return s.history.BorrowedRow(i)
5964 }
6065 i -= s.history.Len()
6166 if i >= s.screen.Height {
@@ -64,6 +69,27 @@ func (s Snapshot) Row(i int) []renderer.Cell {
6469 return s.screen.Row(i)
6570 }
6671
72+func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {
73+ rowIndex := 0
74+ stopped := false
75+ s.history.Range(func(row []renderer.Cell) bool {
76+ if !yield(rowIndex, row) {
77+ stopped = true
78+ return false
79+ }
80+ rowIndex++
81+ return true
82+ })
83+ if stopped {
84+ return
85+ }
86+ for y := range s.screen.Height {
87+ if !yield(rowIndex+y, s.screen.Row(y)) {
88+ return
89+ }
90+ }
91+}
92+
6793 // Mode stores per-client scrollback viewport and line-selection state.
6894 type SearchMatch struct {
6995 Row int
@@ -102,8 +128,7 @@ func FindMatches(s Snapshot, query string) []SearchMatch {
102128 }
103129 needle := lowerRunes(query)
104130 matches := make([]SearchMatch, 0)
105- for row := range s.Len() {
106- cells := s.Row(row)
131+ s.rangeRows(func(row int, cells []renderer.Cell) bool {
107132 haystack, cellIndexes := searchableCells(cells)
108133 var text string
109134 for start := 0; start+len(needle) <= len(haystack); {
@@ -122,7 +147,8 @@ func FindMatches(s Snapshot, query string) []SearchMatch {
122147 matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text})
123148 start += len(needle)
124149 }
125- }
150+ return true
151+ })
126152 return matches
127153 }
128154
M internal/usecase/copy/mode_test.go +128 -3
@@ -7,11 +7,30 @@ import (
77 "testing"
88
99 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/bnema/vev/pkg/vt"
1011 "github.com/stretchr/testify/require"
1112 )
1213
1314 var benchmarkSnapshotSink Snapshot
1415
16+func row(text string) []renderer.Cell {
17+ result := make([]renderer.Cell, 0, len([]rune(text)))
18+ for _, r := range text {
19+ result = append(result, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
20+ }
21+ return result
22+}
23+
24+func newHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }
25+
26+func rowText(cells []renderer.Cell) string {
27+ runes := make([]rune, len(cells))
28+ for i, cell := range cells {
29+ runes[i] = cell.Rune
30+ }
31+ return string(runes)
32+}
33+
1534 func snapshot(lines []string, height int) Snapshot {
1635 rows := make([][]renderer.Cell, len(lines))
1736 for i, line := range lines {
@@ -355,6 +374,24 @@ func TestFindMatchesKeepsRuneAndCellIndexesAlignedWhenLowercasing(t *testing.T)
355374 require.Equal(t, 2, matches[0].End)
356375 }
357376
377+func TestFindMatchesDoesNotCopyEveryHistoryRow(t *testing.T) {
378+ const rows = 100
379+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows, ChunkRows: 10})
380+ for range rows {
381+ history.Append(row(strings.Repeat("x", 120)))
382+ }
383+ s := NewSnapshot(history, renderer.NewFrame(120, 1))
384+
385+ allocs := testing.AllocsPerRun(10, func() {
386+ if matches := FindMatches(s, "missing"); len(matches) != 0 {
387+ t.Fatalf("unexpected matches: %d", len(matches))
388+ }
389+ })
390+ if allocs >= 250 {
391+ t.Fatalf("FindMatches allocations = %.0f, want < 250 (no row copy per history row)", allocs)
392+ }
393+}
394+
358395 func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) {
359396 row := []renderer.Cell{
360397 {Rune: '界'},
@@ -478,6 +515,94 @@ func TestOSC52FromBase64(t *testing.T) {
478515 }
479516 }
480517
518+func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) {
519+ history := newHistory(1)
520+ history.Append(row("old"))
521+ screen := renderer.NewFrame(3, 1)
522+ copy(screen.Row(0), row("one"))
523+
524+ document := NewSnapshot(history, screen)
525+ history.Append(row("new"))
526+ screen.Set(0, 0, renderer.Cell{Rune: 'x'})
527+
528+ require.Equal(t, 2, document.Len())
529+ require.Equal(t, "old", rowText(document.Row(0)))
530+ require.Equal(t, "one", rowText(document.Row(1)))
531+ require.Nil(t, document.Row(-1))
532+ require.Nil(t, document.Row(2))
533+}
534+
535+func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) {
536+ rows := [][]renderer.Cell{row("one")}
537+ document := NewSnapshotFromRows(rows, 3, 1)
538+ rows[0][0].Rune = 'x'
539+ require.Equal(t, "one", rowText(document.Row(0)))
540+}
541+
542+func TestNewSnapshotFromRowsKeepsDocumentAndViewportDimensionsIndependent(t *testing.T) {
543+ document := NewSnapshotFromRows([][]renderer.Cell{row("one"), row("two")}, 7, 5)
544+
545+ require.Equal(t, 2, document.Len())
546+ require.Equal(t, 7, document.Width)
547+ require.Equal(t, 5, document.Height)
548+ require.Equal(t, "one", rowText(document.Row(0)))
549+ require.Equal(t, "two", rowText(document.Row(1)))
550+ require.Nil(t, document.Row(2))
551+}
552+
553+// This scaling gate deliberately compares equal row counts rather than an
554+// absolute budget, which varies between Go releases. A HistoryView shares
555+// sealed chunks, so its allocation must not grow with history-row width.
556+func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) {
557+ if testing.Short() {
558+ t.Skip("skipping benchmark-driven allocation gate in short mode")
559+ }
560+ narrow := benchmarkNewSnapshotBytes(16)
561+ wide := benchmarkNewSnapshotBytes(512)
562+ const conservativeWidthTolerance = 2
563+ require.LessOrEqual(t, wide, narrow*conservativeWidthTolerance,
564+ "NewSnapshot B/op scaled with row width: narrow=%d wide=%d", narrow, wide)
565+}
566+
567+func benchmarkNewSnapshotBytes(width int) int64 {
568+ const historyRows = 256
569+ history := newHistory(historyRows)
570+ for range historyRows {
571+ history.Append(make([]renderer.Cell, width))
572+ }
573+ // Keep visible-frame work constant so the comparison isolates history rows.
574+ screen := renderer.NewFrame(8, 4)
575+ result := testing.Benchmark(func(b *testing.B) {
576+ b.ReportAllocs()
577+ b.ResetTimer()
578+ for b.Loop() {
579+ benchmarkSnapshotSink = NewSnapshot(history, screen)
580+ }
581+ })
582+ return result.AllocedBytesPerOp()
583+}
584+
585+// This regression replaces the removed Scrollback ring tests: the VT history
586+// remains bounded oldest-first and a captured copy document survives eviction.
587+func TestNewSnapshotRetainsBoundedHistoryAcrossLaterEviction(t *testing.T) {
588+ history := newHistory(2)
589+ history.Append(row("one"))
590+ history.Append(row("two"))
591+ document := NewSnapshot(history, renderer.NewFrame(3, 1))
592+ history.Append(row("three"))
593+ history.Append(row("four"))
594+
595+ // A copy document is history followed by the visible frame. Its total is
596+ // therefore the bounded retained history plus the one visible row.
597+ require.Equal(t, 3, document.Len())
598+ require.Equal(t, "one", rowText(document.Row(0)))
599+ require.Equal(t, "two", rowText(document.Row(1)))
600+ require.Equal(t, " ", rowText(document.Row(2)), "the visible frame remains part of the document")
601+ view := history.View()
602+ require.Equal(t, "three", rowText(view.Row(0)))
603+ require.Equal(t, "four", rowText(view.Row(1)))
604+}
605+
481606 func BenchmarkNewSnapshot10KRows(b *testing.B) {
482607 const (
483608 width = 120
@@ -485,9 +610,9 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) {
485610 historyRows = 10_000
486611 )
487612
488- scrollback := NewScrollback(historyRows)
613+ history := newHistory(historyRows)
489614 for range historyRows {
490- scrollback.Append(row(strings.Repeat("x", width)))
615+ history.Append(row(strings.Repeat("x", width)))
491616 }
492617 screen := renderer.NewFrame(width, height)
493618 for y := range height {
@@ -497,7 +622,7 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) {
497622 b.ReportAllocs()
498623 b.ResetTimer()
499624 for b.Loop() {
500- benchmarkSnapshotSink = NewSnapshot(scrollback, screen)
625+ benchmarkSnapshotSink = NewSnapshot(history, screen)
501626 }
502627 }
503628
D internal/usecase/copy/scrollback.go +0 -83
@@ -1,83 +0,0 @@
1-package copy
2-
3-import "github.com/bnema/vev/pkg/renderer"
4-
5-// Scrollback stores evicted terminal rows in a fixed-size ring buffer.
6-type Scrollback struct {
7- rows [][]renderer.Cell
8- head int
9- len int
10-}
11-
12-// NewScrollback constructs a scrollback ring capped at capacity rows.
13-func NewScrollback(capacity int) *Scrollback {
14- if capacity < 0 {
15- capacity = 0
16- }
17- return &Scrollback{rows: make([][]renderer.Cell, capacity)}
18-}
19-
20-// Cap returns the maximum number of rows retained.
21-func (s *Scrollback) Cap() int { return len(s.rows) }
22-
23-// Len returns the number of retained rows.
24-func (s *Scrollback) Len() int { return s.len }
25-
26-// Head returns the physical ring slot holding the oldest retained row.
27-func (s *Scrollback) Head() int { return s.head }
28-
29-// Append stores a copy of row, evicting the oldest row when the ring is full.
30-func (s *Scrollback) Append(row []renderer.Cell) {
31- if len(s.rows) == 0 {
32- return
33- }
34- cp := append([]renderer.Cell(nil), row...)
35- if s.len < len(s.rows) {
36- s.rows[(s.head+s.len)%len(s.rows)] = cp
37- s.len++
38- return
39- }
40- s.rows[s.head] = cp
41- s.head = (s.head + 1) % len(s.rows)
42-}
43-
44-// Snapshot returns retained rows oldest-first.
45-//
46-// The returned outer slice is a copy, but row slices are shared with the
47-// scrollback storage. Callers must treat the returned rows as read-only.
48-func (s *Scrollback) Snapshot() [][]renderer.Cell {
49- if s.len == 0 || len(s.rows) == 0 {
50- return nil
51- }
52- rows := make([][]renderer.Cell, s.len)
53- for i := range s.len {
54- rows[i] = s.rows[(s.head+i)%len(s.rows)]
55- }
56- return rows
57-}
58-
59-// HistoryView is a frozen oldest-first view of scrollback rows.
60-//
61-// Its rows share cell slices with the scrollback at the time View is called.
62-// Callers must treat rows returned by Row as read-only.
63-type HistoryView struct {
64- rows [][]renderer.Cell
65-}
66-
67-// View returns a frozen scrollback view. Appending to the scrollback cannot
68-// alter the view because Append replaces ring slots rather than mutating rows.
69-func (s *Scrollback) View() HistoryView {
70- return HistoryView{rows: s.Snapshot()}
71-}
72-
73-// Len returns the number of rows in the view.
74-func (v HistoryView) Len() int { return len(v.rows) }
75-
76-// Row returns the row at logical index i, oldest first, or nil when i is out
77-// of range. Returned rows are shared with the view and must be read-only.
78-func (v HistoryView) Row(i int) []renderer.Cell {
79- if i < 0 || i >= len(v.rows) {
80- return nil
81- }
82- return v.rows[i]
83-}
D internal/usecase/copy/scrollback_test.go +0 -209
@@ -1,209 +0,0 @@
1-package copy
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/pkg/renderer"
7-)
8-
9-func row(text string) []renderer.Cell {
10- runes := []rune(text)
11- cells := make([]renderer.Cell, len(runes))
12- for i, r := range runes {
13- cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
14- }
15- return cells
16-}
17-
18-func rowText(cells []renderer.Cell) string {
19- runes := make([]rune, len(cells))
20- for i, c := range cells {
21- runes[i] = c.Rune
22- }
23- return string(runes)
24-}
25-
26-func TestScrollbackRing(t *testing.T) {
27- tests := []struct {
28- name string
29- cap int
30- append []string
31- wantHead int
32- wantLen int
33- wantRows []string
34- }{
35- {
36- name: "empty ring drops rows",
37- cap: 0,
38- append: []string{"aa", "bb"},
39- wantHead: 0,
40- wantLen: 0,
41- },
42- {
43- name: "below capacity keeps head at zero",
44- cap: 3,
45- append: []string{"aa", "bb"},
46- wantHead: 0,
47- wantLen: 2,
48- wantRows: []string{"aa", "bb"},
49- },
50- {
51- name: "at capacity fills without rotating head",
52- cap: 3,
53- append: []string{"aa", "bb", "cc"},
54- wantHead: 0,
55- wantLen: 3,
56- wantRows: []string{"aa", "bb", "cc"},
57- },
58- {
59- name: "over capacity evicts oldest and rotates head",
60- cap: 3,
61- append: []string{"aa", "bb", "cc", "dd", "ee"},
62- wantHead: 2,
63- wantLen: 3,
64- wantRows: []string{"cc", "dd", "ee"},
65- },
66- }
67-
68- for _, tt := range tests {
69- t.Run(tt.name, func(t *testing.T) {
70- sb := NewScrollback(tt.cap)
71- for _, r := range tt.append {
72- sb.Append(row(r))
73- }
74-
75- if got := sb.Cap(); got != tt.cap {
76- t.Fatalf("Cap() = %d, want %d", got, tt.cap)
77- }
78- if got := sb.Head(); got != tt.wantHead {
79- t.Fatalf("Head() = %d, want %d", got, tt.wantHead)
80- }
81- if got := sb.Len(); got != tt.wantLen {
82- t.Fatalf("Len() = %d, want %d", got, tt.wantLen)
83- }
84- for i, want := range tt.wantRows {
85- if got := rowText(sb.View().Row(i)); got != want {
86- t.Fatalf("Row(%d) = %q, want %q", i, got, want)
87- }
88- }
89- })
90- }
91-}
92-
93-func TestScrollbackCopiesRows(t *testing.T) {
94- tests := []struct {
95- name string
96- }{
97- {name: "append copies caller row"},
98- }
99- for _, tt := range tests {
100- t.Run(tt.name, func(t *testing.T) {
101- sb := NewScrollback(1)
102- r := row("ab")
103- sb.Append(r)
104- r[0].Rune = 'z'
105-
106- if text := rowText(sb.View().Row(0)); text != "ab" {
107- t.Fatalf("stored row = %q, want copy unaffected by caller mutation", text)
108- }
109- })
110- }
111-}
112-
113-func TestScrollbackSnapshot(t *testing.T) {
114- tests := []struct {
115- name string
116- cap int
117- append []string
118- wantRows []string
119- }{
120- {
121- name: "empty capacity snapshots no rows",
122- cap: 0,
123- append: []string{"aa", "bb"},
124- wantRows: nil,
125- },
126- {
127- name: "oldest first after ring wrap",
128- cap: 3,
129- append: []string{"aa", "bb", "cc", "dd"},
130- wantRows: []string{"bb", "cc", "dd"},
131- },
132- }
133-
134- for _, tt := range tests {
135- t.Run(tt.name, func(t *testing.T) {
136- sb := NewScrollback(tt.cap)
137- for _, r := range tt.append {
138- sb.Append(row(r))
139- }
140-
141- got := sb.Snapshot()
142- if len(got) != len(tt.wantRows) {
143- t.Fatalf("Snapshot() len = %d, want %d", len(got), len(tt.wantRows))
144- }
145- for i, want := range tt.wantRows {
146- if text := rowText(got[i]); text != want {
147- t.Fatalf("Snapshot()[%d] = %q, want %q", i, text, want)
148- }
149- }
150- if len(got) > 0 {
151- got[0] = row("zz")
152- if text := rowText(sb.View().Row(0)); text != tt.wantRows[0] {
153- t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0])
154- }
155- }
156- })
157- }
158-}
159-
160-func TestScrollbackAppendDoesNotMutateSnapshottedRows(t *testing.T) {
161- sb := NewScrollback(2)
162- sb.Append(row("aa"))
163- sb.Append(row("bb"))
164- snapshot := sb.Snapshot()
165-
166- sb.Append(row("cc"))
167- sb.Append(row("dd"))
168-
169- if text := rowText(snapshot[0]); text != "aa" {
170- t.Fatalf("snapshot[0] after append = %q, want original row", text)
171- }
172- if text := rowText(snapshot[1]); text != "bb" {
173- t.Fatalf("snapshot[1] after append = %q, want original row", text)
174- }
175-}
176-
177-func TestScrollbackViewSurvivesCompleteRingOverwrite(t *testing.T) {
178- sb := NewScrollback(2)
179- sb.Append(row("aa"))
180- sb.Append(row("bb"))
181- view := sb.View()
182- if &view.Row(0)[0] != &sb.rows[0][0] {
183- t.Fatal("View() deep-copied cells, want shared row storage")
184- }
185-
186- sb.Append(row("cc"))
187- sb.Append(row("dd"))
188-
189- if got := view.Len(); got != 2 {
190- t.Fatalf("View().Len() = %d, want 2", got)
191- }
192- for i, want := range []string{"aa", "bb"} {
193- if got := rowText(view.Row(i)); got != want {
194- t.Fatalf("View().Row(%d) = %q, want %q", i, got, want)
195- }
196- }
197-}
198-
199-func TestHistoryViewRowBounds(t *testing.T) {
200- sb := NewScrollback(1)
201- sb.Append(row("aa"))
202- view := sb.View()
203-
204- for _, i := range []int{-1, 1} {
205- if got := view.Row(i); got != nil {
206- t.Errorf("View().Row(%d) = %v, want nil", i, got)
207- }
208- }
209-}
D internal/usecase/copy/snapshot_test.go +0 -85
@@ -1,85 +0,0 @@
1-package copy
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/pkg/renderer"
7-)
8-
9-func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) {
10- sb := NewScrollback(1)
11- sb.Append(row("old"))
12- screen := renderer.NewFrame(3, 1)
13- copy(screen.Row(0), row("one"))
14-
15- snapshot := NewSnapshot(sb, screen)
16- if &snapshot.Row(0)[0] != &sb.View().Row(0)[0] {
17- t.Fatal("NewSnapshot() deep-copied immutable history")
18- }
19- sb.Append(row("new"))
20- screen.Set(0, 0, renderer.Cell{Rune: 'x'})
21-
22- if got := snapshot.Len(); got != 2 {
23- t.Fatalf("Len() = %d, want 2", got)
24- }
25- for i, want := range []string{"old", "one"} {
26- if got := rowText(snapshot.Row(i)); got != want {
27- t.Errorf("Row(%d) = %q, want %q", i, got, want)
28- }
29- }
30- for _, i := range []int{-1, 2} {
31- if got := snapshot.Row(i); got != nil {
32- t.Errorf("Row(%d) = %v, want nil", i, got)
33- }
34- }
35-}
36-
37-func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) {
38- rows := [][]renderer.Cell{row("one")}
39- snapshot := NewSnapshotFromRows(rows, 3, 1)
40- rows[0][0].Rune = 'x'
41- if got := rowText(snapshot.Row(0)); got != "one" {
42- t.Fatalf("Row(0) = %q, want deep-owned caller row", got)
43- }
44-}
45-
46-// This scaling gate deliberately compares equal row counts rather than an
47-// absolute budget, which varies between Go releases. A full history-row clone
48-// grows with row width; the immutable HistoryView allocation does not.
49-func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) {
50- if testing.Short() {
51- t.Skip("skipping benchmark-driven allocation gate in short mode")
52- }
53- narrow := benchmarkNewSnapshotBytes(16)
54- wide := benchmarkNewSnapshotBytes(512)
55- assertWidthIndependentAllocations(t, "NewSnapshot", narrow, wide)
56-}
57-
58-func benchmarkNewSnapshotBytes(width int) int64 {
59- const historyRows = 256
60-
61- scrollback := NewScrollback(historyRows)
62- for range historyRows {
63- scrollback.Append(make([]renderer.Cell, width))
64- }
65- // Keep visible-frame work constant so the comparison isolates history rows.
66- screen := renderer.NewFrame(8, 4)
67- result := testing.Benchmark(func(b *testing.B) {
68- b.ReportAllocs()
69- b.ResetTimer()
70- for b.Loop() {
71- benchmarkSnapshotSink = NewSnapshot(scrollback, screen)
72- }
73- })
74- return result.AllocedBytesPerOp()
75-}
76-
77-func assertWidthIndependentAllocations(t *testing.T, operation string, narrow, wide int64) {
78- t.Helper()
79- // Two times permits allocator noise while decisively rejecting a cell-row
80- // copy, whose allocation grows by 32x for the widths above.
81- const conservativeWidthTolerance = 2
82- if wide > narrow*conservativeWidthTolerance {
83- t.Fatalf("%s B/op scaled with row width: narrow=%d wide=%d (limit=%d)", operation, narrow, wide, narrow*conservativeWidthTolerance)
84- }
85-}
M internal/usecase/daemon/attention.go +2 -2
@@ -49,7 +49,7 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
4949 if idx, ok := oldestAttentionTab(sess); ok {
5050 if sess.switchTab(idx) {
5151 d.activateTab(sess, sess.activeTab())
52- d.paint(sess, ac, true)
52+ d.invalidateRender(sess, ac, true, "attention.go")
5353 }
5454 return
5555 }
@@ -119,7 +119,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) {
119119 ac := sess.client
120120 sess.mu.Unlock()
121121 if ac != nil {
122- d.paint(sess, ac, false)
122+ d.invalidateRender(sess, ac, false, "attention.go")
123123 }
124124 }
125125
M internal/usecase/daemon/attention_test.go +9 -9
@@ -53,7 +53,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
5353 d.setAttentionFrame(0)
5454 d.noteAttention(sess, sess.tabs[0])
5555
56- d.paint(sess, ac, true)
56+ d.paint(sess, ac, true, nil)
5757 data := mustOutputData(t, sends)
5858 require.NotContains(t, string(data), string(ui.AttentionGlyph))
5959
@@ -62,7 +62,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
6262 sess.mu.Unlock()
6363
6464 d.setAttentionFrame(1)
65- d.paint(sess, ac, true)
65+ d.paint(sess, ac, true, nil)
6666 data = mustOutputData(t, sends)
6767 require.Contains(t, string(data), string(ui.AttentionGlyph))
6868
@@ -97,7 +97,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.
9797 return sess.tabs[0].attention && !sess.tabs[0].attentionAt.IsZero()
9898 }, 2*time.Second, 5*time.Millisecond)
9999
100- d.paint(sess, ac, true)
100+ d.paint(sess, ac, true, nil)
101101 data := mustOutputData(t, sends)
102102 require.Contains(t, string(data), string(ui.AttentionGlyph))
103103
@@ -242,7 +242,7 @@ func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
242242 sess.tabs[1].attentionAt = time.Unix(2, 0)
243243 sess.mu.Unlock()
244244
245- d.paint(sess, ac, true)
245+ d.paint(sess, ac, true, nil)
246246 _ = mustOutputData(t, sends)
247247 // The deferred repaint (from ackAttention) targets all attached clients,
248248 // including this one, but the bars it would draw are already exactly what
@@ -272,7 +272,7 @@ func TestPaintPreservesBackgroundAttention(t *testing.T) {
272272 sess.mu.Unlock()
273273 d.setAttentionFrame(1)
274274
275- d.paint(sess, ac, true)
275+ d.paint(sess, ac, true, nil)
276276 data := mustOutputData(t, sends)
277277
278278 require.Contains(t, string(data), "")
@@ -292,7 +292,7 @@ func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) {
292292 sess.mu.Unlock()
293293
294294 require.True(t, sess.switchTab(1))
295- d.paint(sess, ac, true)
295+ d.paint(sess, ac, true, nil)
296296 data := mustOutputData(t, sends)
297297 // As above: the deferred all-clients repaint has nothing new to say about
298298 // this client's bars, so no second frame follows.
@@ -321,7 +321,7 @@ func TestCloseRingingTabClearsClientBar(t *testing.T) {
321321 sess.mu.Unlock()
322322 d.setAttentionFrame(1)
323323
324- d.paint(sess, ac, true)
324+ d.paint(sess, ac, true, nil)
325325 before := mustOutputData(t, sends)
326326 require.Contains(t, string(before), "")
327327
@@ -346,7 +346,7 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
346346 sess.tabs[1].attentionAt = time.Unix(1, 0)
347347 sess.mu.Unlock()
348348
349- d.paint(sess, ac, true)
349+ d.paint(sess, ac, true, nil)
350350 full := mustOutputData(t, sends)
351351 require.Contains(t, string(full), "MIDSCREENMARKER")
352352
@@ -428,7 +428,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
428428 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
429429 d.sessions[sessB.id] = sessB
430430
431- d.paint(sessB, acB, true)
431+ d.paint(sessB, acB, true, nil)
432432 before := mustOutputData(t, sendsB)
433433 require.Contains(t, string(before), string(ui.AttentionGlyph))
434434
M internal/usecase/daemon/bar_refresh.go +7 -8
@@ -306,12 +306,11 @@ func (d *Daemon) pokeSessionRender(sess *session) {
306306 if sess == nil {
307307 return
308308 }
309- if tb := sess.activeTab(); tb != nil {
310- tb.mu.Lock()
311- p := tb.focusedPane()
312- tb.mu.Unlock()
313- if p != nil {
314- signal(p.dirty)
315- }
316- }
309+ sess.mu.Lock()
310+ ac := sess.client
311+ sess.mu.Unlock()
312+ // A headless session can still supply a picker preview to an attachment
313+ // owned by another session. Its coordinator fans this wake out to those
314+ // preview subscribers even though no primary attachment is bound.
315+ d.invalidateRender(sess, ac, false, "bar_refresh.go")
317316 }
M internal/usecase/daemon/bar_refresh_test.go +12 -0
@@ -277,6 +277,18 @@ func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
277277 require.Empty(t, state.bottomRight)
278278 }
279279
280+func TestPokeSessionRenderWakesHeadlessPreviewCoordinator(t *testing.T) {
281+ d := newBarRefreshTestDaemon(nil, time.Second)
282+ sess := newBarRefreshTestSession()
283+ var invalidations int
284+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(renderInvalidation) { invalidations++ }})
285+ sess.installRenderCoordinator(rc)
286+
287+ d.pokeSessionRender(sess)
288+
289+ require.Equal(t, 1, invalidations)
290+}
291+
280292 func TestBarScriptRefreshIsPerSession(t *testing.T) {
281293 r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}
282294 d := newBarRefreshTestDaemon(r, time.Second)
M internal/usecase/daemon/client.go +273 -177
@@ -1,27 +1,8 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
1+// Package daemon holds vev's server-side session multiplexer use case.
42 //
5-// Concurrency model (sessions own one or more PTY-backed tabs):
6-//
7-// - Serve runs the accept loop. Each accepted connection is handled by its
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
19-//
20-// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
3+// Lock ordering: never acquire sendMu or session/daemon locks while holding a
4+// pane lock. Coordinator callbacks and timer methods run without coordinator.mu;
5+// see the lock-specific comments on attachedClient and renderCoordinator.
256 package daemon
267
278 import (
@@ -37,28 +18,40 @@ import (
3718 themeui "github.com/bnema/vev/internal/usecase/theme"
3819 )
3920
21+type transportSnapshot struct {
22+ transport ports.Transport
23+ incarnation uint64
24+}
25+
4026 type attachedClient struct {
41- tr ports.Transport
42- output *outputStateStream
43- overlays *overlayRuntime
44- clientID [16]byte
45- resumeCapable bool
46- resumeToken uint64
47- parked bool
48- echoAck atomic.Uint64
49- bars barCache // only touched while sendMu is held
50- composed composedFrameCache // only touched while sendMu is held
51- resizePaint pendingByteTimer // guarded by sendMu
52- resizePaintGeneration uint64 // guarded by sendMu
53- resizePaintPending bool // guarded by sendMu
54- size domain.Size
55- keys *keys.Router
56- sess Guarded[*session]
57- mouseScan mouse.Scanner
58- themeMu sync.Mutex
59- theme themeui.Theme
60- clientTheme themeui.Theme
61- lastCursor cursorOut
27+ tr ports.Transport
28+ transportIncarnation uint64
29+ output *outputStateStream
30+ overlays *overlayRuntime
31+ overlayOnce sync.Once
32+ clientID [16]byte
33+ resumeCapable bool
34+ resumeToken uint64
35+ parked bool
36+ echoAck atomic.Uint64
37+ // pipelineCache is the last successfully emitted composition. pipelineScratch
38+ // is its attachment-owned alternate buffer; both are only touched under
39+ // sendMu and must never share mutable backing storage.
40+ pipelineCache composeCacheInput
41+ pipelineScratch composeCacheInput
42+ renderScratch renderCaptureScratch // only touched while sendMu is held
43+ // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
44+ // snapshots cannot leak when an attachment switches tabs or sessions.
45+ captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
46+ size domain.Size
47+ keys *keys.Router
48+ sess Guarded[*session]
49+ mouseScan mouse.Scanner
50+ themeMu sync.Mutex
51+ theme themeui.Theme
52+ clientTheme themeui.Theme
53+ lastCursor cursorOut
54+ renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
6255 // previousSession is guarded independently. It is retained through temporary
6356 // setSession(nil) hand-offs and cleared only on terminal teardown.
6457 previousSession Guarded[*session]
@@ -75,6 +68,8 @@ type cursorOut struct {
7568 hasStyle bool
7669 }
7770
71+// pendingByteTimer is retained for overlay input timers. Resize timing belongs
72+// exclusively to renderCoordinator.
7873 type pendingByteTimer struct {
7974 timer ports.Timer
8075 done chan struct{}
@@ -104,20 +99,8 @@ func (p *pendingByteTimer) stop() {
10499 }
105100 }
106101
107-// cancelResizePaint invalidates callbacks before detaching or replacing an
108-// attachment. It deliberately takes sendMu before any session/daemon lock.
109-func (ac *attachedClient) cancelResizePaint() {
110- ac.sendMu.Lock()
111- ac.resizePaintGeneration++
112- ac.resizePaintPending = false
113- ac.resizePaint.stop()
114- ac.sendMu.Unlock()
115-}
116-
117102 func (ac *attachedClient) initOverlays() {
118- if ac.overlays == nil {
119- ac.overlays = newOverlayRuntime(ac)
120- }
103+ ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
121104 }
122105
123106 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
@@ -130,6 +113,30 @@ func (ac *attachedClient) clearPreviousSession() {
130113 }
131114 }
132115
116+// pruneCaptureFrames releases snapshots for panes that have left their owner.
117+// Callers must not hold daemon, session, tab, or pane locks.
118+func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) {
119+ if ac == nil || len(panes) == 0 {
120+ return
121+ }
122+ ac.sendMu.Lock()
123+ for _, p := range panes {
124+ delete(ac.captureFrames, p)
125+ }
126+ ac.sendMu.Unlock()
127+}
128+
129+// clearCaptureFrames releases all pane snapshots when the attachment no longer
130+// owns a session.
131+func (ac *attachedClient) clearCaptureFrames() {
132+ if ac == nil {
133+ return
134+ }
135+ ac.sendMu.Lock()
136+ ac.captureFrames = nil
137+ ac.sendMu.Unlock()
138+}
139+
133140 func (ac *attachedClient) getTheme() themeui.Theme {
134141 ac.themeMu.Lock()
135142 defer ac.themeMu.Unlock()
@@ -163,9 +170,41 @@ func (ac *attachedClient) transport() ports.Transport {
163170 func (ac *attachedClient) replaceTransport(tr ports.Transport) {
164171 ac.linkMu.Lock()
165172 ac.tr = tr
173+ ac.transportIncarnation++
166174 ac.linkMu.Unlock()
167175 }
168176
177+// revokeTransport removes tr only if it is still this attachment's current
178+// link. It returns the revoked transport, so asynchronous retirement can
179+// close exactly that link without touching a later resume/replacement.
180+func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport {
181+ if tr == nil {
182+ return nil
183+ }
184+ ac.linkMu.Lock()
185+ defer ac.linkMu.Unlock()
186+ if ac.tr != tr {
187+ return nil
188+ }
189+ ac.tr = nil
190+ ac.transportIncarnation++
191+ return tr
192+}
193+
194+// transportSnapshot binds a send to one concrete link lifetime. Callers must
195+// revalidate it after acquiring sendMu before writing to that link.
196+func (ac *attachedClient) transportSnapshot() transportSnapshot {
197+ ac.linkMu.Lock()
198+ defer ac.linkMu.Unlock()
199+ return transportSnapshot{transport: ac.tr, incarnation: ac.transportIncarnation}
200+}
201+
202+func (ac *attachedClient) transportSnapshotCurrent(expected transportSnapshot) bool {
203+ ac.linkMu.Lock()
204+ defer ac.linkMu.Unlock()
205+ return ac.tr == expected.transport && ac.transportIncarnation == expected.incarnation
206+}
207+
169208 func (ac *attachedClient) closeTransport() error {
170209 tr := ac.transport()
171210 if tr == nil {
@@ -197,23 +236,6 @@ func (ac *attachedClient) ackOutputState(state uint64) {
197236 ac.output.ack(state)
198237 }
199238
200-// takeDeferredPaint reports whether a deferred diff paint is now flushable
201-// because acks have caught up, clearing the flag when it is. The caller must
202-// then paint outside sendMu (paint re-acquires it).
203-func (ac *attachedClient) takeDeferredPaint() (reset bool, ok bool) {
204- ac.sendMu.Lock()
205- defer ac.sendMu.Unlock()
206- return ac.output.takeDeferred()
207-}
208-
209-// clearPaintDeferred drops any pending deferred-paint flag. Used on park so a
210-// resumed client does not inherit a stale deferral.
211-func (ac *attachedClient) clearPaintDeferred() {
212- ac.sendMu.Lock()
213- ac.output.clearDeferred()
214- ac.sendMu.Unlock()
215-}
216-
217239 // send serialises a frame onto the client's transport.
218240 func (ac *attachedClient) send(f ports.Frame) error {
219241 _, err := ac.sendTransport(f)
@@ -230,6 +252,22 @@ func (ac *attachedClient) sendTransport(f ports.Frame) (ports.Transport, error)
230252 return tr, tr.Send(f)
231253 }
232254
255+var errTransportReplaced = errors.New("client transport was replaced")
256+
257+// sendExpectedTransport writes only when expected is still the attachment's
258+// current transport incarnation. It preserves sendMu -> linkMu lock ordering.
259+func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f ports.Frame) error {
260+ ac.sendMu.Lock()
261+ defer ac.sendMu.Unlock()
262+ if !ac.transportSnapshotCurrent(expected) {
263+ return errTransportReplaced
264+ }
265+ if expected.transport == nil {
266+ return errors.New("client transport is nil")
267+ }
268+ return expected.transport.Send(f)
269+}
270+
233271 // boundedSend sends f to ac with a deadline watchdog: if the send (including
234272 // waiting on sendMu behind a wedged paint) does not complete within
235273 // detachNotifyTimeout, the transport is force-closed, failing the in-flight
@@ -240,10 +278,12 @@ func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
240278 }
241279
242280 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
243- tr, err := d.boundedSendWith(func(capture func(ports.Transport)) error {
244- tr, err := ac.sendTransport(f)
245- capture(tr)
246- return err
281+ expected := ac.transportSnapshot()
282+ if expected.transport == nil {
283+ return errors.New("client transport is nil")
284+ }
285+ tr, err := d.boundedSendWith(expected.transport, func() error {
286+ return ac.sendExpectedTransport(expected, f)
247287 })
248288 if err != nil && errors.Is(err, errSendTimedOut) {
249289 _ = ac.closeCapturedTransport(tr)
@@ -258,68 +298,50 @@ func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
258298
259299 func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {
260300 frame := ac.output.sideEffect(b, ac.echoAck.Load())
301+ expected := ac.transportSnapshot()
302+ if expected.transport == nil {
303+ return nil, errors.New("client transport is nil")
304+ }
261305 ac.sendMu.Lock()
262- tr := ac.transport()
263- if tr == nil {
306+ if !ac.transportSnapshotCurrent(expected) {
264307 ac.sendMu.Unlock()
265- return nil, errors.New("client transport is nil")
308+ return expected.transport, errTransportReplaced
266309 }
267- if owned, ok := tr.(ports.OwnedSynchronousTransport); ok {
310+ if owned, ok := expected.transport.(ports.OwnedSynchronousTransport); ok {
268311 err := owned.SendSynchronous(frame)
269312 ac.sendMu.Unlock()
270- return tr, err
313+ return expected.transport, err
271314 }
272315 ac.sendMu.Unlock()
273- return d.boundedSendWith(func(capture func(ports.Transport)) error {
274- ac.sendMu.Lock()
275- defer ac.sendMu.Unlock()
276- tr := ac.transport()
277- capture(tr)
278- if tr == nil {
279- return errors.New("client transport is nil")
280- }
281- return tr.Send(frame)
316+ return d.boundedSendWith(expected.transport, func() error {
317+ return ac.sendExpectedTransport(expected, frame)
282318 })
283319 }
284320
285321 var errSendTimedOut = errors.New("send timed out")
286322
287-func (d *Daemon) boundedSendWith(send func(capture func(ports.Transport)) error) (ports.Transport, error) {
288- return d.boundedSendWithTimeout(detachNotifyTimeout, send)
323+func (d *Daemon) boundedSendWith(tr ports.Transport, send func() error) (ports.Transport, error) {
324+ return d.boundedSendWithTimeout(detachNotifyTimeout, tr, send)
289325 }
290326
291-func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, send func(capture func(ports.Transport)) error) (ports.Transport, error) {
327+func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, tr ports.Transport, send func() error) (ports.Transport, error) {
292328 timer := d.clock.NewTimer(timeout)
293329 result := make(chan error, 1)
294- var (
295- capturedMu sync.Mutex
296- captured ports.Transport
297- )
298- capture := func(tr ports.Transport) {
299- capturedMu.Lock()
300- captured = tr
301- capturedMu.Unlock()
302- }
303- capturedTransport := func() ports.Transport {
304- capturedMu.Lock()
305- defer capturedMu.Unlock()
306- return captured
307- }
308330 go func() {
309- result <- send(capture)
331+ result <- send()
310332 }()
311333 select {
312334 case err := <-result:
313335 timer.Stop()
314- return capturedTransport(), err
336+ return tr, err
315337 case <-timer.C():
316338 select {
317339 case err := <-result:
318- return capturedTransport(), err
340+ return tr, err
319341 default:
320342 }
321343 d.log.Warn("bounded send timed out; force closing client transport")
322- return capturedTransport(), errSendTimedOut
344+ return tr, errSendTimedOut
323345 }
324346 }
325347
@@ -376,6 +398,12 @@ type attachClientOptions struct {
376398 }
377399
378400 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
401+ ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, opts)
402+ cleanup.finish()
403+ return ac, old
404+}
405+
406+func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, renderLifecycleCleanup) {
379407 resumeToken := uint64(0)
380408 if opts.resumeCapable {
381409 resumeToken = d.nextResumeTokenLocked()
@@ -391,15 +419,96 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
391419 ac.initOverlays()
392420 ac.setSession(sess)
393421 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
422+ // Daemon attachment callers hold d.mu, serialising this preparation with
423+ // other publications. Bind coordinator ownership before sess.client becomes
424+ // visible: an old deadline can then never target this new output chain.
394425 sess.mu.Lock()
395426 old := sess.client
396- sess.client = ac
397427 name := sess.name
398428 sess.mu.Unlock()
429+ _, cleanup := d.attachCoordinatorDeferred(sess, old, ac, false)
430+ sess.mu.Lock()
431+ sess.client = ac
432+ sess.mu.Unlock()
399433 d.touchMRU(sess)
400434 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
401435 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
402- return ac, old
436+ return ac, old, cleanup
437+}
438+
439+// handoffCoordinator prepares a cross-session ownership transfer before the
440+// destination publishes sess.client. It never takes daemon/session locks while
441+// taking sendMu, avoiding a d.mu/sendMu cycle with transport callbacks.
442+func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) []renderLifecycleCleanup {
443+ cleanups := make([]renderLifecycleCleanup, 0, 2)
444+ if rc := from.renderCoordinator(); rc != nil {
445+ cleanups = append(cleanups, rc.beginDetach(current))
446+ }
447+ current.sendMu.Lock()
448+ current.output.rebase()
449+ // Capture snapshots are attachment-owned. Discard them while the attachment
450+ // is exclusively held so the transfer releases panes no longer reachable
451+ // from the target session.
452+ current.captureFrames = nil
453+ current.sendMu.Unlock()
454+ _, cleanup := d.attachCoordinatorDeferred(target, old, current, true)
455+ return append(cleanups, cleanup)
456+}
457+
458+func finishRenderLifecycleCleanups(cleanups []renderLifecycleCleanup) {
459+ for _, cleanup := range cleanups {
460+ cleanup.finish()
461+ }
462+}
463+
464+// attachCoordinator is the sole direct attachment handoff. It creates at
465+// most one coordinator for sess and changes its identity before any caller
466+// can publish resize or render state for the new client.
467+func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {
468+ rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)
469+ cleanup.finish()
470+ return rc
471+}
472+
473+func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
474+ sess.mu.Lock()
475+ rc := sess.renderCoordinator()
476+ if rc == nil {
477+ rc = newRenderCoordinator(renderCoordinatorOptions{
478+ clock: d.clock,
479+ observer: d.runtimeObserver,
480+ wake: func(w renderWake) {
481+ // Never reread sess.client here: a wake is bound to both its
482+ // attachment and its coordinator incarnation. The second check in
483+ // paint occurs under sendMu, closing a park/resume race after this
484+ // unlocked coordinator validation.
485+ if w.lease != nil && rc.wakeCurrent(w) {
486+ d.paint(sess, w.lease.attachment, w.reset, w.lease)
487+ }
488+ },
489+ ackReady: func() bool {
490+ sess.mu.Lock()
491+ attached := sess.client
492+ sess.mu.Unlock()
493+ if attached == nil {
494+ return false
495+ }
496+ attached.sendMu.Lock()
497+ ready := !attached.output.atCapacity()
498+ attached.sendMu.Unlock()
499+ return ready
500+ },
501+ })
502+ sess.installRenderCoordinator(rc)
503+ }
504+ sess.mu.Unlock()
505+ var cleanup renderLifecycleCleanup
506+ if old != nil {
507+ cleanup = rc.beginReplace(old, current, ready)
508+ } else if current != nil {
509+ rc.attachWithReadiness(current, ready)
510+ }
511+ return rc, cleanup
403512 }
404513
405514 // resetScreenDefaultColors clears the known default foreground/background
@@ -417,19 +526,36 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) {
417526 d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true)
418527 }
419528
420-func (d *Daemon) detachReplacedClient(old *attachedClient) {
529+// retireReplacedClient owns old-link retirement and obsolete render-worker
530+// joins outside the replacement handshake. If a render already owns sendMu,
531+// close immediately interrupts it; no Detached frame can safely overtake that
532+// render. Otherwise the old link gets its bounded protocol notice first.
533+func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecycleCleanup) {
421534 if old == nil {
535+ d.attachmentCleanupWg.Go(cleanup.finish)
422536 return
423537 }
424- old.cancelResizePaint()
538+ oldTransport := old.transportSnapshot().transport
539+ blockedRender := !old.sendMu.TryLock()
540+ if !blockedRender {
541+ old.sendMu.Unlock()
542+ }
425543
426- d.unregisterPreview(old)
427- old.clearPreviousSession()
428- old.setSession(nil)
429- d.log.Info("client displaced")
430- // Async + bounded: a dead or wedged old client must not stall the new
431- // client's handshake.
432- d.notifyDetachedAsync(old, ports.ReasonDetach)
544+ d.attachmentCleanupWg.Go(func() {
545+ if !blockedRender {
546+ // A healthy idle client observes the required replacement notice. Its
547+ // watchdog still bounds a peer that stops draining after this check.
548+ d.boundedSend(old, frameDetached(ports.ReasonDetach))
549+ }
550+ _ = old.closeCapturedTransport(old.revokeTransport(oldTransport))
551+ d.unregisterPreview(old)
552+ old.clearPreviousSession()
553+ old.setSession(nil)
554+ d.log.Info("client displaced")
555+ })
556+ // Join independently: the close above releases a blocked render while the
557+ // new attachment is already fully published.
558+ d.attachmentCleanupWg.Go(cleanup.finish)
433559 }
434560
435561 // firstPaint guarantees the freshly attached client sees the full screen: if
@@ -445,12 +571,18 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
445571 wsz := tb.size
446572 tb.mu.Unlock()
447573
574+ outerResizeAccepted := false
448575 if clientSize.Valid() && wsz != tabSize(clientSize) {
449- d.resizeForFirstPaint(sess, ac, clientSize)
576+ outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)
450577 }
451578 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
452- d.paint(sess, ac, true)
453- d.activateTab(sess, tb)
579+ // Activation can synchronously resize a retained floating pane. An accepted
580+ // synchronous outer request already includes that pane and emits the reset,
581+ // but activation still performs its warmup work.
582+ activationResized := d.activateTabAfterResize(sess, tb, outerResizeAccepted)
583+ if !outerResizeAccepted && !activationResized {
584+ d.invalidateRenderNow(sess, ac, true, "client.go")
585+ }
454586 }
455587
456588 // runConnLoop is the per-connection input router: it pumps client messages
@@ -503,8 +635,8 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
503635 case ports.MsgAck:
504636 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
505637 ac.ackOutputState(ack.AckedStateNum)
506- if reset, ok := ac.takeDeferredPaint(); ok {
507- d.paint(sess, ac, reset)
638+ if rc := sess.renderCoordinator(); rc != nil {
639+ rc.notifyAck()
508640 }
509641 }
510642 case ports.MsgPing:
@@ -525,7 +657,9 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
525657 if !sess.detachIfCurrent(ac) {
526658 return // already displaced by a newer client; nothing to do
527659 }
528- ac.cancelResizePaint()
660+ if rc := sess.renderCoordinator(); rc != nil {
661+ rc.noteDetach(ac)
662+ }
529663 d.unregisterPreview(ac)
530664 sess.mu.Lock()
531665 ephemeral := sess.ephemeral
@@ -576,57 +710,17 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
576710 if !d.applyHostTheme(sess, ac, t, false) {
577711 return
578712 }
579- d.paint(sess, ac, true)
713+ d.invalidateRender(sess, ac, true, "client.go")
580714 }
581715
582716 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
583- d.resizeWithPaint(sess, ac, sz, true)
717+ d.requestTransactionalResize(sess, ac, sz, false)
584718 }
585719
586-// resizeForFirstPaint applies attach geometry without installing an idle timer:
587-// firstPaint immediately emits the required full frame itself.
588-func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) {
589- d.resizeWithPaint(sess, ac, sz, false)
590-}
591-
592-func (d *Daemon) resizeWithPaint(sess *session, ac *attachedClient, sz domain.Size, schedulePaint bool) {
593- if !sz.Valid() {
594- return
595- }
596- d.exitCopyMode(ac)
597- if ac != nil {
598- // Suspend emission until every pane has adopted this geometry and the
599- // bounded resize paint is armed. PTY readers remain independent.
600- ac.sendMu.Lock()
601- defer ac.sendMu.Unlock()
602- if ac.currentSession() != sess {
603- return
604- }
605- ac.size = sz
606- }
607- tbSize := tabSize(sz)
608-
609- sess.mu.Lock()
610- tabs := append([]*tab(nil), sess.tabs...)
611- sess.mu.Unlock()
612- if len(tabs) == 0 {
613- return
614- }
615-
616- for _, tb := range tabs {
617- tb.mu.Lock()
618- tb.size = tbSize
619- d.applyLayoutLocked(tb)
620- tb.mu.Unlock()
621- }
622- // Only the shown tab's floating terminal tracks the client size. This is
623- // outside tab.mu so its PTY resize cannot block tab state.
624- d.resizeActiveFloating(sess.activeTab())
625- markSnapshotDirty(sess)
626- if ac != nil && schedulePaint {
627- d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
628- d.scheduleResizePaintLocked(sess, ac)
629- }
720+// resizeForFirstPaint retains attach's synchronous geometry guarantee. The
721+// returned value reports whether the synchronous request was accepted.
722+func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {
723+ return d.requestTransactionalResize(sess, ac, sz, true)
630724 }
631725
632726 // detachOnSendError drops a client whose transport failed, leaving the session
@@ -636,7 +730,9 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
636730 return
637731 }
638732 if sess.detachIfCurrent(ac) {
639- ac.cancelResizePaint()
733+ if rc := sess.renderCoordinator(); rc != nil {
734+ rc.noteDetach(ac)
735+ }
640736 d.unregisterPreview(ac)
641737 if d.parkAttachment(sess, ac) {
642738 _ = ac.closeCapturedTransport(failed)
M internal/usecase/daemon/clipboard.go +1 -1
@@ -1,6 +1,6 @@
11 // Package daemon holds vev's server-side session multiplexer use case: the
22 // accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
3+// and VT screen, and the per-client debounced render coordinator.
44 package daemon
55
66 import (
A internal/usecase/daemon/copy_enter_allocation_budget_race_test.go +8 -0
@@ -0,0 +1,8 @@
1+//go:build race
2+
3+package daemon
4+
5+// Race instrumentation allocations are not part of the parent benchmark's
6+// allocation baseline. TestCopyEnterAllocationBudget still exercises copy
7+// entry and validates its result under -race.
8+const copyEnterAllocationBudgetEnabled = false
A internal/usecase/daemon/copy_enter_allocation_budget_test.go +7 -0
@@ -0,0 +1,7 @@
1+//go:build !race
2+
3+package daemon
4+
5+// copyEnterAllocationBudgetEnabled keeps the allocation gate aligned with the
6+// non-race parent benchmark, whose allocation baseline defines the budget.
7+const copyEnterAllocationBudgetEnabled = true
M internal/usecase/daemon/copymode.go +10 -38
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
4-//
5-// Concurrency model (sessions own one or more PTY-backed tabs):
6-//
7-// - Serve runs the accept loop. Each accepted connection is handled by its
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
19-//
20-// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
1+// Package daemon holds vev's server-side session multiplexer use case.
252 package daemon
263
274 import (
@@ -62,7 +39,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
6239 if delta > 0 && rt.copyMode.AtBottom(*document) {
6340 rt.clearCopyModeLocked()
6441 rt.copyMu.Unlock()
65- d.paint(sess, ac, true)
42+ d.invalidateRender(sess, ac, true, "copymode.go")
6643 return
6744 }
6845 rt.copyMode.Move(*document, delta)
@@ -72,7 +49,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
7249 }
7350 rt.copyMu.Unlock()
7451
75- d.paint(sess, ac, true)
52+ d.invalidateRender(sess, ac, true, "copymode.go")
7653 }
7754
7855 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
@@ -89,12 +66,12 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
8966 // Capture the live pane under pane.mu; all subsequent copy interaction uses
9067 // the immutable document while holding only copyMu.
9168 p.mu.Lock()
92- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
69+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
9370 p.mu.Unlock()
9471 if !d.publishCopyMode(sess, ac, tb, p, document, nil) {
9572 return
9673 }
97- d.paint(sess, ac, true)
74+ d.invalidateRender(sess, ac, true, "copymode.go")
9875 }
9976
10077 // publishCopyMode installs a non-renderable candidate, validates its captured
@@ -190,7 +167,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
190167 rt.copyMu.Unlock()
191168
192169 if changed {
193- d.paint(sess, ac, true)
170+ d.invalidateRender(sess, ac, true, "copymode.go")
194171 }
195172 }
196173
@@ -220,7 +197,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
220197 changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)
221198 rt.copyMu.Unlock()
222199 if changed || closeSearch || accepted {
223- d.paint(sess, ac, true)
200+ d.invalidateRender(sess, ac, true, "copymode.go")
224201 }
225202 return
226203 }
@@ -309,11 +286,11 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
309286 rt.copyMu.Unlock()
310287 }
311288 if exit {
312- d.paint(sess, ac, true)
289+ d.invalidateRender(sess, ac, true, "copymode.go")
313290 return
314291 }
315292 if changed {
316- d.paint(sess, ac, true)
293+ d.invalidateRender(sess, ac, true, "copymode.go")
317294 }
318295 }
319296
@@ -386,7 +363,7 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
386363 rt.copyMu.Unlock()
387364
388365 if sess := ac.currentSession(); sess != nil {
389- d.paint(sess, ac, true)
366+ d.invalidateRender(sess, ac, true, "copymode.go")
390367 }
391368 })
392369 }
@@ -425,11 +402,6 @@ func isCopyEscapePrefix(data []byte) bool {
425402 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
426403 }
427404
428-func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
429- styleSet := resolveThemeStyles(styles)
430- return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
431-}
432-
433405 func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
434406 if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
435407 return frame, nil
M internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
@@ -54,7 +54,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
5454 sess.client = ac
5555 ac.setSession(sess)
5656 closing.mu.Lock()
57- document := scopy.NewSnapshot(closing.scrollback, closing.screen.Frame)
57+ document := scopy.NewSnapshot(closing.history, closing.screen.Frame)
5858 closing.mu.Unlock()
5959 ac.overlays.copyMu.Lock()
6060 ac.overlays.copyPane = closing
@@ -94,7 +94,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
9494 tb := sess.activeTab()
9595 p := tb.focusedPane()
9696 p.mu.Lock()
97- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
97+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
9898 p.mu.Unlock()
9999
100100 // Hold session validation after publication and make the captured target
@@ -157,7 +157,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
157157 tb := sess.activeTab()
158158 p := tb.focusedPane()
159159 p.mu.Lock()
160- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
160+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
161161 p.mu.Unlock()
162162 tc.invalidate(sess, tb, p)
163163
M internal/usecase/daemon/copymode_test.go +43 -28
@@ -112,7 +112,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
112112 tb := sess.activeTab()
113113 pane := tb.focusedPane()
114114 pane.mu.Lock()
115- snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame)
115+ snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)
116116 pane.mu.Unlock()
117117 mode := scopy.NewMode(snap)
118118 bars := barState{status: sess.statusSegments(true)}
@@ -147,7 +147,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
147147 tb := sess.activeTab()
148148 tb.focusedPane().screen = vt.NewScreen(12, 3)
149149 tb.focusedPane().screen.Write([]byte("live"))
150- snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)
150+ snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame)
151151 mode := scopy.NewMode(snap)
152152
153153 bars := barState{status: sess.statusSegments(true)}
@@ -166,7 +166,7 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
166166 writes := make(chan []byte, 1)
167167 p, _ := newBlockingPTYWithWrites(t, writes)
168168 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
169- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
169+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
170170 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
171171
172172 d.handleInput(sess, ac, []byte("\x1b "))
@@ -293,8 +293,8 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
293293 p, _ := newBlockingPTY(t)
294294 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
295295 pane := sess.tabs[0].focusedPane()
296- pane.scrollback = scopy.NewScrollback(4)
297- pane.scrollback.Append(testRow("old alpha"))
296+ installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})
297+ pane.history.Append(testRow("old alpha"))
298298 copy(pane.screen.Frame.Row(0), testRow("live alpha"))
299299
300300 d.enterCopyMode(sess, ac)
@@ -322,9 +322,9 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
322322 writes := make(chan []byte, 1)
323323 p, _ := newBlockingPTYWithWrites(t, writes)
324324 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
325- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
326- sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
327- sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
325+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
326+ sess.tabs[0].focusedPane().history.Append(testRow("old1 "))
327+ sess.tabs[0].focusedPane().history.Append(testRow("old2 "))
328328 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
329329
330330 d.enterCopyMode(sess, ac)
@@ -358,7 +358,7 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
358358 t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))
359359 }
360360
361- d.paint(sess, ac, true)
361+ d.paint(sess, ac, true, nil)
362362 followup := awaitFrame(t, sends, ports.MsgOutput)
363363 followupMsg, err := ports.UnmarshalOutput(followup.Payload)
364364 require.NoError(t, err)
@@ -385,11 +385,26 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
385385 p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()
386386 p.EXPECT().Pid().Return(4242).Maybe()
387387
388- d := newTestDaemon(t, newFactory(t, p), stubClock{})
388+ // Rendering is coordinator-driven: advance a controllable clock through the
389+ // retained resize timer and its resulting coordinator wake rather than
390+ // relying on wall-clock debounce delivery.
391+ clk := newCoordinatorMockClock(t, 16)
392+ d := newTestDaemon(t, newFactory(t, p), clk.clock)
389393 tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5}))
394+ advanceRender := func() {
395+ deadline := time.NewTimer(2 * time.Second)
396+ defer deadline.Stop()
397+ select {
398+ case timer := <-clk.timers:
399+ timer.ch <- time.Time{}
400+ case <-deadline.C:
401+ t.Fatal("coordinator did not arm a controllable render timer")
402+ }
403+ }
390404 var hg sync.WaitGroup
391405 hg.Go(func() { d.handleConn(tr) })
392406 awaitFrame(t, sends, ports.MsgWelcome)
407+ advanceRender() // initial coordinator invalidation
393408 awaitFrame(t, sends, ports.MsgOutput)
394409
395410 for i := range 12 {
@@ -412,7 +427,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
412427 }
413428 p.mu.Lock()
414429 defer p.mu.Unlock()
415- return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 12
430+ return scopy.NewSnapshot(p.history, p.screen.Frame).Len() >= 12
416431 }, 2*time.Second, 5*time.Millisecond)
417432
418433 sess := firstSession(d)
@@ -420,9 +435,9 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
420435 ac := sess.client
421436 require.NotNil(t, ac)
422437 d.handleInput(sess, ac, []byte("\x1b "))
423- awaitFrame(t, sends, ports.MsgOutput)
438+ awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame")
424439 d.handleInput(sess, ac, []byte("VIS\r"))
425- awaitFrame(t, sends, ports.MsgOutput)
440+ awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame")
426441 d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})
427442
428443 var payload string
@@ -448,7 +463,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
448463 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
449464 p, _ := newBlockingPTY(t)
450465 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
451- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
466+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
452467 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
453468
454469 d.enterCopyMode(sess, ac)
@@ -482,9 +497,9 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
482497 t.Run(tc.name, func(t *testing.T) {
483498 p, _ := newBlockingPTY(t)
484499 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
485- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
486- sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
487- sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
500+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
501+ sess.tabs[0].focusedPane().history.Append(testRow("old1 "))
502+ sess.tabs[0].focusedPane().history.Append(testRow("old2 "))
488503 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
489504
490505 d.enterCopyMode(sess, ac)
@@ -502,7 +517,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
502517 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
503518 p, _ := newBlockingPTY(t)
504519 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
505- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(1)
520+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 1})
506521 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
507522 sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)
508523 copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))
@@ -527,7 +542,7 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
527542 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
528543 clk := &signalClock{timers: make(chan *signalTimer, 1)}
529544 d.clock = clk
530- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
545+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
531546 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
532547
533548 d.enterCopyMode(sess, ac)
@@ -551,7 +566,7 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
551566 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
552567 clk := &signalClock{timers: make(chan *signalTimer, 1)}
553568 d.clock = clk
554- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
569+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
555570 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
556571
557572 d.enterCopyMode(sess, ac)
@@ -594,7 +609,7 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
594609 t.Run(tc.name, func(t *testing.T) {
595610 p, _ := newBlockingPTY(t)
596611 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
597- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
612+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
598613 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
599614
600615 d.enterCopyMode(sess, ac)
@@ -643,7 +658,7 @@ func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) {
643658 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
644659 p, _ := newBlockingPTY(t)
645660 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
646- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
661+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
647662 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
648663
649664 done := make(chan struct{})
@@ -665,7 +680,7 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
665680 d.handleInput(sess, ac, []byte("q"))
666681 })
667682 wg.Go(func() {
668- d.paint(sess, ac, true)
683+ d.paint(sess, ac, true, nil)
669684 })
670685 }
671686 wg.Wait()
@@ -717,11 +732,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
717732 var want *pane
718733 if tc.useFloating {
719734 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
720- want.scrollback.Append(testRow("flt-old"))
735+ want.history.Append(testRow("flt-old"))
721736 want.screen.Write([]byte("flt-live"))
722737 } else {
723738 want = main
724- want.scrollback.Append(testRow("one-old"))
739+ want.history.Append(testRow("one-old"))
725740 want.screen.Write([]byte("one-live"))
726741 }
727742
@@ -758,11 +773,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
758773 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
759774 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
760775 for i := range 30 {
761- fp.scrollback.Append(testRow(fmt.Sprintf("old-%02d", i)))
776+ fp.history.Append(testRow(fmt.Sprintf("old-%02d", i)))
762777 }
763778 fp.screen.Write([]byte("live"))
764779 fp.mu.Lock()
765- total := scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Len()
780+ total := scopy.NewSnapshot(fp.history, fp.screen.Frame).Len()
766781 fp.mu.Unlock()
767782
768783 d.enterCopyMode(sess, ac)
@@ -784,7 +799,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
784799 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
785800 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
786801 for i := range 5 {
787- fp.scrollback.Append(testRow(fmt.Sprintf("old-%d", i)))
802+ fp.history.Append(testRow(fmt.Sprintf("old-%d", i)))
788803 }
789804 fp.screen.Write([]byte("live"))
790805
M internal/usecase/daemon/daemon.go +93 -71
@@ -1,29 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
4-//
5-// Concurrency model (sessions own one or more PTY-backed tabs):
6-//
7-// - Serve runs the accept loop. Each accepted connection is handled by its
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown). Client detach does not end
19-// a session: ephemeral sessions stay in memory until explicitly killed or
20-// until the daemon exits, and named sessions can also persist on disk.
21-//
22-// Locking: a pane's screen/scrollback and per-client renderer shadow are
23-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
24-// session.mu; the registry by Daemon.mu. When more than one is held the order
25-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
26-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
1+// Package daemon holds vev's server-side session multiplexer use case.
272 package daemon
283
294 import (
@@ -41,14 +16,13 @@ import (
4116 "github.com/bnema/vev/internal/persist"
4217 "github.com/bnema/vev/internal/ports"
4318 "github.com/bnema/vev/internal/usecase/keys"
19+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
4420 )
4521
4622 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
4723 // sustained floods step toward the maximum to reduce frame/syscall pressure.
4824 const (
4925 minDebounceInterval = 2 * time.Millisecond
50- maxDebounceInterval = 16 * time.Millisecond
51- debounceStep = 2 * time.Millisecond
5226 maxSyncUpdateDuration = 500 * time.Millisecond
5327 )
5428
@@ -117,17 +91,33 @@ type Daemon struct {
11791 // beforeRecentSessionHandoff is a deterministic test seam for the narrow
11892 // interval between JRS validation and its committed hand-off.
11993 beforeRecentSessionHandoff func()
120-
121- ptys ports.PTYFactory
122- clock ports.Clock
123- log *slog.Logger
124- baseEnv []string
125- shell string
126- shellArgs []string
127- persist *persist.Persister
128- persistEnabled bool
129- snaps ports.SnapshotStore
130- snapsEnabled bool
94+ ptys ports.PTYFactory
95+ clock ports.Clock
96+ log *slog.Logger
97+ runtimeObserver ports.RuntimeObserver
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{}
114+ // snapshotFinalJobs coalesces terminal captures by session when the bounded
115+ // regular queue is full. It retains at most snapshotFinalQueueCapacity named
116+ // sessions, each with only its newest terminal state while the worker blocks.
117+ snapshotFinalJobs map[*session]*snapshotCapture
118+ snapshotFinalOrder []*session
119+ snapshotWorkerClosing bool
120+ snapshotWorkerInFlight *snapshotCapture
131121 restoreDone chan struct{}
132122 restoreOnce sync.Once
133123 procCwd func(int) (string, error)
@@ -160,8 +150,14 @@ type Daemon struct {
160150 done chan struct{}
161151 doneOnce sync.Once
162152
163- sessWg sync.WaitGroup // PTY reader + scheduler goroutines
153+ // sessWg owns attention animation, bar-script polling, CWD sampling,
154+ // snapshot save/restore, and floating-session launch goroutines.
155+ sessWg sync.WaitGroup
164156 connWg sync.WaitGroup // per-connection handler goroutines
157+ // attachmentCleanupWg owns replacement transport closes and retired render
158+ // worker joins. Only connection handlers add work; Serve waits for those
159+ // handlers before joining this group, so no Add races its terminal Wait.
160+ attachmentCleanupWg sync.WaitGroup
165161 }
166162
167163 type parkedAttachment struct {
@@ -194,6 +190,13 @@ func (s stoppedSession) same(other stoppedSession) bool {
194190
195191 type Option func(*Daemon)
196192
193+// WithRuntimeObserver accepts only a composition-root serialized observer.
194+// The application owns its lifecycle; the daemon never creates or closes a
195+// second reporting worker around it.
196+func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
197+ return func(d *Daemon) { d.runtimeObserver = observer }
198+}
199+
197200 // WithShell overrides the command (and its args) each session spawns. The
198201 // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a
199202 // deterministic program.
@@ -312,6 +315,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
312315 done: make(chan struct{}),
313316 restoreDone: make(chan struct{}),
314317 animWake: make(chan struct{}, 1),
318+ snapshotMarshal: snapcodec.Marshal,
319+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
315320 resumeParkGrace: defaultResumeParkGrace,
316321 barScripts: &barScriptState{
317322 cfg: barConfigFromDomain(domain.Defaults().Bar),
@@ -382,6 +387,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
382387 })
383388 }
384389 if d.snapsEnabled {
390+ d.startSnapshotEncodeWorker()
385391 d.sessWg.Go(func() {
386392 d.snapshotSaver(d.serveCtx)
387393 })
@@ -425,12 +431,17 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
425431 // remaining parked connections, so graceful notices are never raced by the
426432 // force-close. Finally drain the conn handlers, run one defensive sweep in
427433 // case any ordering ever leaves a session behind, and join the session
428- // goroutines (readers unblock via pty.Close, schedulers via ctx cancel).
434+ // goroutines (readers unblock via pty.Close, coordinators via ctx cancel).
429435 d.shutdownAll(ports.ReasonServerShutdown)
430436 d.waitNotifies()
431437 d.hardCancel()
432438 d.serveCancel()
439+ // Wait for handlers before flushing snapshots: a handler that already
440+ // entered killSession may still submit its terminal capture after the
441+ // registry snapshot has been removed.
433442 d.connWg.Wait()
443+ d.attachmentCleanupWg.Wait()
444+ d.stopSnapshotEncodeWorker()
434445 d.shutdownAll(ports.ReasonServerShutdown)
435446 d.sessWg.Wait()
436447 d.waitNotifies()
@@ -459,7 +470,9 @@ func (d *Daemon) shutdownAll(reason uint8) {
459470 return
460471 }
461472 for _, s := range snapshot {
462- _ = d.killSession(s, reason, false)
473+ if err := d.killSession(s, reason, false); err != nil {
474+ d.log.Error("closing session with unpersisted terminal state", "err", err)
475+ }
463476 }
464477 }
465478
@@ -620,7 +633,23 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
620633 return
621634 }
622635
623- if err := ac.send(frameWelcome(sess, ac)); err != nil {
636+ rc := sess.renderCoordinator()
637+ lease := (*attachmentLease)(nil)
638+ if rc != nil {
639+ lease = rc.attachmentLease(ac)
640+ }
641+ expected := ac.transportSnapshot()
642+ if lease == nil || expected.transport != tr {
643+ d.clientGone(sess, ac, tr, false)
644+ return
645+ }
646+ if err := ac.sendExpectedTransport(expected, frameWelcome(sess, ac)); err != nil {
647+ d.clientGone(sess, ac, tr, false)
648+ return
649+ }
650+ if !rc.markAttachmentReady(lease) {
651+ // The attachment was displaced or detached while Welcome was in flight;
652+ // never let this stale handshake emit an Output frame.
624653 d.clientGone(sess, ac, tr, false)
625654 return
626655 }
@@ -637,6 +666,23 @@ type protoErr struct {
637666
638667 func (e *protoErr) Error() string { return e.text }
639668
669+// finishAttach completes an attachment prepared while d.mu is held. It
670+// publishes the terminal state before releasing d.mu, then queues replacement
671+// teardown so obsolete worker joins never delay the new handshake.
672+func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient {
673+ ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{
674+ clientID: h.ClientID,
675+ resumeCapable: true,
676+ maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
677+ })
678+ sess.mu.Lock()
679+ sess.terminal = term
680+ sess.mu.Unlock()
681+ d.mu.Unlock()
682+ d.retireReplacedClient(old, cleanup)
683+ return ac
684+}
685+
640686 // route resolves a Hello to a session and a freshly attached client, creating
641687 // the session for ephemeral/new intents.
642688 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
@@ -687,13 +733,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
687733 }
688734 }
689735 d.purgeParkedForSessionLocked(sess)
690- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})
691- sess.mu.Lock()
692- sess.terminal = term
693- sess.mu.Unlock()
694- d.mu.Unlock()
695- d.detachReplacedClient(old)
696- return sess, ac, nil
736+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
697737
698738 case ports.IntentEphemeral:
699739 name := d.allocEphemeralNameLocked()
@@ -703,13 +743,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
703743 return nil, nil, err
704744 }
705745 d.purgeParkedForSessionLocked(sess)
706- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})
707- sess.mu.Lock()
708- sess.terminal = term
709- sess.mu.Unlock()
710- d.mu.Unlock()
711- d.detachReplacedClient(old)
712- return sess, ac, nil
746+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
713747
714748 case ports.IntentNew:
715749 if h.Name == "" {
@@ -730,13 +764,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
730764 return nil, nil, err
731765 }
732766 d.purgeParkedForSessionLocked(sess)
733- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})
734- sess.mu.Lock()
735- sess.terminal = term
736- sess.mu.Unlock()
737- d.mu.Unlock()
738- d.detachReplacedClient(old)
739- return sess, ac, nil
767+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
740768
741769 case ports.IntentAttach:
742770 sess := d.findByNameLocked(h.Name)
@@ -755,13 +783,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
755783 }
756784 }
757785 d.purgeParkedForSessionLocked(sess)
758- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})
759- sess.mu.Lock()
760- sess.terminal = term
761- sess.mu.Unlock()
762- d.mu.Unlock()
763- d.detachReplacedClient(old)
764- return sess, ac, nil
786+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
765787
766788 default:
767789 d.mu.Unlock()
M internal/usecase/daemon/daemon_test.go +99 -5
@@ -142,7 +142,7 @@ func newBlockingOpenFactory(t *testing.T, d *Daemon) *blockingOpenFactory {
142142 return f
143143 }
144144
145-func (f *blockingOpenFactory) Open(string, []string, []string, string, domain.Size) (ports.PTY, error) {
145+func (f *blockingOpenFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
146146 <-f.release
147147 return nil, io.ErrClosedPipe
148148 }
@@ -154,8 +154,8 @@ func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
154154 t.Helper()
155155 f := portsmocks.NewMockPTYFactory(t)
156156 var normal domain.Size
157- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
158- func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
157+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
158+ func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
159159 if !normal.Valid() {
160160 normal = sz
161161 }
@@ -174,8 +174,8 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
174174 var mu sync.Mutex
175175 var normal domain.Size
176176 next := 0
177- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
178- func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
177+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
178+ func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
179179 mu.Lock()
180180 defer mu.Unlock()
181181 if !normal.Valid() {
@@ -286,6 +286,54 @@ func listSessions(t *testing.T, d *Daemon) ports.Sessions {
286286 return sessions
287287 }
288288
289+// welcomeBlockingTransport holds the route-level handshake precisely between
290+// attachment publication and Welcome acceptance. It uses the generated
291+// transport mock while channels make both sides of the wire deterministic.
292+type welcomeBlockingTransport struct {
293+ tr *portsmocks.MockTransport
294+ sends chan ports.Frame
295+ welcomeEntered chan struct{}
296+ releaseWelcome chan struct{}
297+ recvDone chan struct{}
298+ releaseOnce sync.Once
299+ closeOnce sync.Once
300+}
301+
302+func newWelcomeBlockingTransport(t *testing.T) *welcomeBlockingTransport {
303+ t.Helper()
304+ b := &welcomeBlockingTransport{
305+ sends: make(chan ports.Frame, 8),
306+ welcomeEntered: make(chan struct{}),
307+ releaseWelcome: make(chan struct{}),
308+ recvDone: make(chan struct{}),
309+ }
310+ b.tr = portsmocks.NewMockTransport(t)
311+ b.tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
312+ b.sends <- f
313+ if f.Type == ports.MsgWelcome {
314+ close(b.welcomeEntered)
315+ <-b.releaseWelcome
316+ }
317+ return nil
318+ }).Maybe()
319+ b.tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
320+ <-b.recvDone
321+ return ports.Frame{}, io.EOF
322+ }).Maybe()
323+ b.tr.EXPECT().Close().Return(nil).Maybe()
324+ t.Cleanup(b.finish)
325+ return b
326+}
327+
328+func (b *welcomeBlockingTransport) release() {
329+ b.releaseOnce.Do(func() { close(b.releaseWelcome) })
330+}
331+
332+func (b *welcomeBlockingTransport) finish() {
333+ b.release()
334+ b.closeOnce.Do(func() { close(b.recvDone) })
335+}
336+
289337 func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports.Frame) {
290338 t.Helper()
291339 tr := portsmocks.NewMockTransport(t)
@@ -395,6 +443,52 @@ func (r realTimer) C() <-chan time.Time { return r.t.C }
395443 func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
396444 func (r realTimer) Stop() bool { return r.t.Stop() }
397445
446+func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) {
447+ pty, releasePTY := newBlockingPTY(t)
448+ defer releasePTY()
449+ clock := newCoordinatorMockClock(t, 4)
450+ d := newTestDaemon(t, newFactory(t, pty), clock.clock)
451+ tr := newWelcomeBlockingTransport(t)
452+ done := make(chan struct{})
453+ go func() {
454+ d.handleHello(tr.tr, mustHello(ports.IntentNew, "welcome-gate", domain.Size{Cols: 80, Rows: 24}))
455+ close(done)
456+ }()
457+
458+ awaitTestCompletion(t, tr.welcomeEntered, "timed out waiting for Welcome send")
459+ welcome := awaitFrame(t, tr.sends, ports.MsgWelcome)
460+ require.Equal(t, ports.MsgWelcome, welcome.Type)
461+ sess := firstSession(d)
462+ sess.mu.Lock()
463+ ac := sess.client
464+ sess.mu.Unlock()
465+ require.NotNil(t, ac, "route must publish the attachment before Welcome returns")
466+
467+ // Exercise the real producer fan-in after publication. The deadline worker
468+ // has completed before asserting the gate, so this is not a timing race.
469+ d.invalidateRender(sess, ac, true, "welcome-gate-test")
470+ // Attach-time theme setup may have armed an older bulk timer. The fresh
471+ // urgent producer replaces it, so advance the newest published deadline.
472+ timer := awaitLatestCoordinatorTimer(t, clock)
473+ rc := sess.renderCoordinator()
474+ rc.mu.Lock()
475+ workerDone := rc.normalLane.token.done
476+ rc.mu.Unlock()
477+ require.NotNil(t, workerDone)
478+ timer.ch <- time.Time{}
479+ awaitTestCompletion(t, workerDone, "coordinator deadline worker did not complete")
480+ requireNoCoordinatorOutputFrame(t, tr.sends)
481+
482+ tr.release()
483+ output := awaitFrame(t, tr.sends, ports.MsgOutput)
484+ first, err := ports.UnmarshalOutput(output.Payload)
485+ require.NoError(t, err)
486+ require.Zero(t, first.BaseStateNum, "the first post-Welcome frame is full")
487+ tr.finish()
488+ awaitTestCompletion(t, done, "timed out waiting for Welcome handler completion")
489+ requireNoCoordinatorOutputFrame(t, tr.sends)
490+}
491+
398492 // TestServeReturnsDespiteWedgedClientOnShutdown: killSession's teardown must
399493 // not be gated behind the Detached notice. The client transport wedges every
400494 // MsgDetached send until the transport is closed (mirroring a full kernel
M internal/usecase/daemon/floating.go +109 -19
@@ -154,9 +154,15 @@ func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
154154 // activateTab performs the work associated with making a tab the destination.
155155 // It is deliberately idempotent: every tab transition can use this single
156156 // hook, while inactive restored tabs remain cold until actually selected.
157-func (d *Daemon) activateTab(sess *session, tb *tab) {
157+func (d *Daemon) activateTab(sess *session, tb *tab) bool {
158+ return d.activateTabAfterResize(sess, tb, false)
159+}
160+
161+// activateTabAfterResize retains tab activation's warmup work while avoiding a
162+// second resize when a synchronous outer resize request was already accepted.
163+func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccepted bool) bool {
158164 if d == nil || sess == nil || tb == nil {
159- return
165+ return false
160166 }
161167 // A headless session has no actual terminal destination. Deferring warmup
162168 // until firstPaint keeps restored tabs cold and avoids launching children
@@ -166,10 +172,20 @@ func (d *Daemon) activateTab(sess *session, tb *tab) {
166172 sess.mu.Unlock()
167173 d.exitCopyMode(ac)
168174 if ac == nil {
169- return
175+ return false
170176 }
171177 d.ensureFloatingWarm(sess, tb)
172- d.resizeInstalledFloating(tb)
178+ if outerResizeAccepted {
179+ return false
180+ }
181+ tb.mu.Lock()
182+ hasFloating := tb.floating.pane != nil
183+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
184+ tb.mu.Unlock()
185+ if hasFloating {
186+ return d.requestTransactionalResize(sess, ac, size, true)
187+ }
188+ return false
173189 }
174190
175191 // toggleFloating changes only this tab's slot. Opening an uninitialized slot
@@ -184,23 +200,29 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
184200 }
185201 cfg := d.currentFloatingConfig()
186202 tb.mu.Lock()
203+ wasVisible := tb.floating.state == floatingVisible
187204 start, generation := tb.toggleFloatingLocked()
188205 // desiredVisible is meaningful only while a launch is warming. Installed
189206 // slots derive their resize and paint work from the resulting slot state.
190207 visible := tb.floating.state == floatingVisible
208+ hidden := wasVisible && tb.floating.state == floatingHidden
191209 tb.mu.Unlock()
192210 if start {
193211 d.launchFloating(sess, tb, cfg, generation, true)
194212 if ac != nil {
195- d.paint(sess, ac, true)
213+ d.invalidateRender(sess, ac, true, "floating.go")
196214 }
197215 return nil
198216 }
199- if visible {
200- d.resizeActiveFloating(tb)
217+ if hidden {
218+ d.invalidateRender(sess, ac, true, "floating.go")
219+ return nil
201220 }
202- if ac != nil {
203- d.paint(sess, ac, true)
221+ if visible {
222+ tb.mu.Lock()
223+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
224+ tb.mu.Unlock()
225+ d.requestTransactionalResize(sess, ac, size, true)
204226 }
205227 return nil
206228 }
@@ -218,6 +240,40 @@ func (d *Daemon) startFloating(sess *session, tb *tab, visible bool) {
218240
219241 // floatingLaunchSpec is an immutable snapshot of one launch. It is built
220242 // before the worker starts so config reloads cannot affect that launch.
243+// floatingLaunch owns one worker from queueing through installation or stale
244+// result cleanup. Its done channel lets session teardown reap an in-flight
245+// Open before returning.
246+type floatingLaunch struct {
247+ done chan struct{}
248+}
249+
250+func (sess *session) registerFloatingLaunch() (*floatingLaunch, bool) {
251+ launch := &floatingLaunch{done: make(chan struct{})}
252+ sess.floatingLaunchMu.Lock()
253+ defer sess.floatingLaunchMu.Unlock()
254+ if sess.floatingLaunchStopping {
255+ return nil, false
256+ }
257+ if sess.floatingLaunches == nil {
258+ sess.floatingLaunches = make(map[*floatingLaunch]struct{})
259+ }
260+ sess.floatingLaunches[launch] = struct{}{}
261+ return launch, true
262+}
263+
264+func (sess *session) finishFloatingLaunch(launch *floatingLaunch) {
265+ sess.floatingLaunchMu.Lock()
266+ delete(sess.floatingLaunches, launch)
267+ close(launch.done)
268+ sess.floatingLaunchMu.Unlock()
269+}
270+
271+func (sess *session) stopFloatingLaunches() {
272+ sess.floatingLaunchMu.Lock()
273+ sess.floatingLaunchStopping = true
274+ sess.floatingLaunchMu.Unlock()
275+}
276+
221277 type floatingLaunchSpec struct {
222278 sessionName string
223279 cwd string
@@ -233,17 +289,25 @@ type floatingLaunchSpec struct {
233289 }
234290
235291 // launchFloating snapshots launch inputs, then accounts for a worker through
236-// Open, pane initialization, publication, and reader/scheduler startup.
292+// Open, pane initialization, publication, and reader/coordinator startup.
237293 func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) {
238294 spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen)
239295 if err != nil {
240296 d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err)
241297 return
242298 }
299+ launch, ok := sess.registerFloatingLaunch()
300+ if !ok {
301+ d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, context.Canceled)
302+ return
303+ }
243304 // Count the launch before its goroutine starts. It remains counted through
244- // install, including the reader/scheduler Adds, so shutdown cannot return
305+ // install, including the reader/coordinator Adds, so shutdown cannot return
245306 // before a late Open completion has either been rejected or fully joined.
246- d.sessWg.Go(func() { d.openAndInstallFloating(sess, tb, spec, generation) })
307+ d.sessWg.Go(func() {
308+ defer sess.finishFloatingLaunch(launch)
309+ d.openAndInstallFloating(sess, tb, spec, generation)
310+ })
247311 }
248312
249313 func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {
@@ -300,7 +364,22 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
300364 // openAndInstallFloating runs entirely in the launch worker. No PTY operation
301365 // occurs under tb.mu; the pane is fully initialized before publication.
302366 func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {
303- pty, err := d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size)
367+ // Open is context-aware and is intentionally called without any daemon,
368+ // session, tab, or launch-ownership lock. Cancellation makes teardown
369+ // bounded even if the adapter is waiting to create the child.
370+ if err := spec.parentCtx.Err(); err != nil {
371+ return
372+ }
373+ openCtx, cancelOpen := context.WithCancel(spec.parentCtx)
374+ stopSessionCancel := context.AfterFunc(sess.ctx, cancelOpen)
375+ ownedByPane := false
376+ defer func() {
377+ if !ownedByPane {
378+ stopSessionCancel()
379+ cancelOpen()
380+ }
381+ }()
382+ pty, err := d.ptys.Open(openCtx, spec.command, spec.args, spec.env, spec.cwd, spec.size)
304383 if err != nil {
305384 d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)
306385 return
@@ -309,7 +388,14 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
309388 p.rect = spec.geometry.Inner
310389 p.popupGeometry = spec.geometry
311390 p.title.displayFallback = spec.fallback
312- p.ctx, p.cancel = context.WithCancel(spec.parentCtx)
391+ // CommandContext owns the child through openCtx, so retain both this context
392+ // and its cancellation until the installed pane is reaped or torn down.
393+ p.ctx = openCtx
394+ p.cancel = func() {
395+ stopSessionCancel()
396+ cancelOpen()
397+ }
398+ ownedByPane = true
313399 // The reader may run as soon as install returns; make its exit policy
314400 // immutable before publishing the pane to the slot.
315401 p.onExit = func() { d.reapFloating(sess, tb, p, generation) }
@@ -343,13 +429,13 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
343429 d.reapplyThemeSession(sess)
344430 d.startPaneGoroutines(sess, tb, p)
345431 if visible {
346- d.resizeActiveFloating(tb)
347432 sess.mu.Lock()
348433 ac := sess.client
349434 sess.mu.Unlock()
350- if ac != nil {
351- d.paint(sess, ac, true)
352- }
435+ tb.mu.Lock()
436+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
437+ tb.mu.Unlock()
438+ d.requestTransactionalResize(sess, ac, size, true)
353439 }
354440 }
355441
@@ -367,9 +453,12 @@ func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64
367453 sess.mu.Lock()
368454 ac := sess.client
369455 sess.mu.Unlock()
456+ if ac != nil {
457+ ac.pruneCaptureFrames(p)
458+ }
370459 copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
371460 if ac != nil && (visible || copyCleared) {
372- d.paint(sess, ac, true)
461+ d.invalidateRender(sess, ac, true, "floating.go")
373462 }
374463 }
375464
@@ -383,6 +472,7 @@ func (d *Daemon) teardownFloating(tb *tab, ac *attachedClient) {
383472 tb.mu.Unlock()
384473 if ac != nil {
385474 ac.overlays.clearCopyModeForPane(p)
475+ ac.pruneCaptureFrames(p)
386476 }
387477 closeFloatingPane(p)
388478 }
A internal/usecase/daemon/floating_open_test.go +299 -0
@@ -0,0 +1,299 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+)
16+
17+type contextAwareFloatingPTY struct {
18+ ctx context.Context
19+ readerStarted chan struct{}
20+ closed chan struct{}
21+ readerOnce sync.Once
22+ closeOnce sync.Once
23+ closeCount int
24+}
25+
26+func newContextAwareFloatingPTY() *contextAwareFloatingPTY {
27+ return &contextAwareFloatingPTY{
28+ readerStarted: make(chan struct{}),
29+ closed: make(chan struct{}),
30+ }
31+}
32+
33+func (p *contextAwareFloatingPTY) Read([]byte) (int, error) {
34+ p.readerOnce.Do(func() { close(p.readerStarted) })
35+ <-p.ctx.Done()
36+ return 0, p.ctx.Err()
37+}
38+
39+func (*contextAwareFloatingPTY) Write(b []byte) (int, error) { return len(b), nil }
40+func (*contextAwareFloatingPTY) Resize(domain.Size) error { return nil }
41+func (*contextAwareFloatingPTY) Pid() int { return 1 }
42+func (*contextAwareFloatingPTY) ForegroundPgid() (int, error) {
43+ return 1, nil
44+}
45+
46+func (p *contextAwareFloatingPTY) Close() error {
47+ p.closeOnce.Do(func() {
48+ p.closeCount++
49+ close(p.closed)
50+ })
51+ return nil
52+}
53+
54+type contextAwareFloatingFactory struct {
55+ pty *contextAwareFloatingPTY
56+ opened chan context.Context
57+ err error
58+ onOpen func()
59+}
60+
61+func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
62+ if f.pty != nil {
63+ f.pty.ctx = ctx
64+ }
65+ f.opened <- ctx
66+ if f.onOpen != nil {
67+ f.onOpen()
68+ }
69+ return f.pty, f.err
70+}
71+
72+func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
73+ teardowns := []struct {
74+ name string
75+ teardown func(*Daemon, *session, *tab)
76+ }{
77+ {
78+ name: "floating pane",
79+ teardown: func(d *Daemon, _ *session, tb *tab) {
80+ d.teardownFloating(tb, nil)
81+ },
82+ },
83+ {
84+ name: "tab",
85+ teardown: func(_ *Daemon, _ *session, tb *tab) {
86+ tb.closeAllPanes()
87+ },
88+ },
89+ {
90+ name: "session",
91+ teardown: func(d *Daemon, sess *session, _ *tab) {
92+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
93+ },
94+ },
95+ }
96+ for _, tt := range teardowns {
97+ t.Run(tt.name, func(t *testing.T) {
98+ pty := newContextAwareFloatingPTY()
99+ factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)}
100+ d := newTestDaemon(t, factory, stubClock{})
101+ tb := newFloatingTestTab(t)
102+ tabCtx, cancelTab := context.WithCancel(t.Context())
103+ tb.ctx, tb.cancel = tabCtx, cancelTab
104+ sessCtx, cancelSession := context.WithCancel(t.Context())
105+ sess := &session{id: "context-" + domain.SessionID(tt.name), name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession}
106+ d.mu.Lock()
107+ d.sessions[sess.id] = sess
108+ d.mu.Unlock()
109+ tb.mu.Lock()
110+ generation := tb.beginFloatingWarmLocked(true)
111+ tb.mu.Unlock()
112+
113+ d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
114+ sessionName: "work",
115+ size: domain.Size{Cols: 20, Rows: 8},
116+ geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
117+ paneStableID: "p_floating",
118+ parentCtx: tabCtx,
119+ }, generation)
120+
121+ openCtx := <-factory.opened
122+ require.NoError(t, openCtx.Err(), "the PTY context must outlive the launch worker")
123+ tb.mu.Lock()
124+ floating := tb.floating.pane
125+ tb.mu.Unlock()
126+ require.NotNil(t, floating)
127+ sess.mu.Lock()
128+ sess.client = &attachedClient{}
129+ sess.mu.Unlock()
130+ require.True(t, d.paneRenderable(sess, tb, floating), "installed visible popup must remain renderable")
131+ sess.mu.Lock()
132+ sess.client = nil
133+ sess.mu.Unlock()
134+ select {
135+ case <-pty.readerStarted:
136+ case <-time.After(time.Second):
137+ t.Fatal("floating reader did not start")
138+ }
139+
140+ tt.teardown(d, sess, tb)
141+ select {
142+ case <-openCtx.Done():
143+ case <-time.After(time.Second):
144+ t.Fatal("floating PTY context was not cancelled by teardown")
145+ }
146+ select {
147+ case <-pty.closed:
148+ case <-time.After(time.Second):
149+ t.Fatal("floating PTY was not closed by teardown")
150+ }
151+ d.sessWg.Wait()
152+ require.Equal(t, 1, pty.closeCount, "floating PTY must close exactly once")
153+ })
154+ }
155+}
156+
157+func TestFloatingFailedAndStaleOpenCancelContext(t *testing.T) {
158+ tests := []struct {
159+ name string
160+ setup func(*tab, *contextAwareFloatingFactory)
161+ }{
162+ {
163+ name: "failed Open",
164+ setup: func(_ *tab, factory *contextAwareFloatingFactory) {
165+ factory.err = context.Canceled
166+ },
167+ },
168+ {
169+ name: "stale install",
170+ setup: func(tb *tab, factory *contextAwareFloatingFactory) {
171+ factory.onOpen = func() {
172+ tb.mu.Lock()
173+ tb.takeFloatingLocked()
174+ tb.mu.Unlock()
175+ }
176+ },
177+ },
178+ }
179+ for _, tt := range tests {
180+ t.Run(tt.name, func(t *testing.T) {
181+ pty := newContextAwareFloatingPTY()
182+ factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)}
183+ d := newTestDaemon(t, factory, stubClock{})
184+ tb := newFloatingTestTab(t)
185+ sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()}
186+ tb.mu.Lock()
187+ generation := tb.beginFloatingWarmLocked(true)
188+ tb.mu.Unlock()
189+ tt.setup(tb, factory)
190+
191+ d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
192+ sessionName: "work",
193+ size: domain.Size{Cols: 20, Rows: 8},
194+ geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
195+ paneStableID: "p_floating",
196+ parentCtx: tb.ctx,
197+ }, generation)
198+
199+ openCtx := <-factory.opened
200+ select {
201+ case <-openCtx.Done():
202+ case <-time.After(time.Second):
203+ t.Fatal("failed or stale floating Open retained its context")
204+ }
205+ tb.mu.Lock()
206+ require.Nil(t, tb.floating.pane)
207+ tb.mu.Unlock()
208+ if tt.name == "stale install" {
209+ select {
210+ case <-pty.closed:
211+ case <-time.After(time.Second):
212+ t.Fatal("stale floating PTY was not closed")
213+ }
214+ require.Equal(t, 1, pty.closeCount)
215+ }
216+ })
217+ }
218+}
219+
220+func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testing.T) {
221+ factory := portsmocks.NewMockPTYFactory(t)
222+ opened := make(chan struct{})
223+ latePTY := portsmocks.NewMockPTY(t)
224+ closed := make(chan struct{})
225+ latePTY.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
226+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
227+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
228+ close(opened)
229+ <-ctx.Done()
230+ return latePTY, nil
231+ }).Once()
232+ d := newTestDaemon(t, factory, stubClock{})
233+ tb := newFloatingTestTab(t)
234+ ctx, cancel := context.WithCancel(t.Context())
235+ sess := &session{id: "blocked-open", name: "work", tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
236+ d.mu.Lock()
237+ d.sessions[sess.id] = sess
238+ d.mu.Unlock()
239+ d.startFloating(sess, tb, true)
240+ select {
241+ case <-opened:
242+ case <-time.After(time.Second):
243+ t.Fatal("floating Open did not begin")
244+ }
245+
246+ killed := make(chan error, 1)
247+ go func() { killed <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
248+ select {
249+ case err := <-killed:
250+ require.NoError(t, err)
251+ case <-time.After(time.Second):
252+ t.Fatal("session teardown waited for blocked Open")
253+ }
254+ select {
255+ case <-closed:
256+ case <-time.After(time.Second):
257+ t.Fatal("late Open PTY was not closed")
258+ }
259+ tb.mu.Lock()
260+ require.Nil(t, tb.floating.pane, "late PTY must not be installed")
261+ tb.mu.Unlock()
262+}
263+
264+func TestFloatingOpensFromUnrelatedSessionsAreConcurrent(t *testing.T) {
265+ factory := portsmocks.NewMockPTYFactory(t)
266+ entered := make(chan struct{}, 2)
267+ release := make(chan struct{})
268+ var once sync.Once
269+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
270+ func(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
271+ entered <- struct{}{}
272+ <-release
273+ return newQuietPTY(), nil
274+ }).Twice()
275+ d := newTestDaemon(t, factory, stubClock{})
276+ tabs := make([]*tab, 0, 2)
277+ cancels := make([]context.CancelFunc, 0, 2)
278+ for _, id := range []domain.SessionID{"one", "two"} {
279+ tb := newFloatingTestTab(t)
280+ ctx, cancel := context.WithCancel(t.Context())
281+ sess := &session{id: id, name: string(id), tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
282+ tabs = append(tabs, tb)
283+ cancels = append(cancels, cancel)
284+ d.startFloating(sess, tb, false)
285+ }
286+ for range 2 {
287+ select {
288+ case <-entered:
289+ case <-time.After(time.Second):
290+ t.Fatal("unrelated floating launch was serialized")
291+ }
292+ }
293+ once.Do(func() { close(release) })
294+ for i, cancel := range cancels {
295+ cancel()
296+ d.teardownFloating(tabs[i], nil)
297+ }
298+ d.sessWg.Wait()
299+}
M internal/usecase/daemon/floating_render.go +12 -113
@@ -85,60 +85,26 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
8585 }
8686 }
8787
88-// composeFloatingFrame applies the popup to a copy of the normal composed
89-// destination. In particular it never writes the VT screen frame: that frame
90-// is shared with the PTY reader and is the source for future renders.
91-func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) {
92- var frame renderer.Frame
93- if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height {
94- frame = cache.floatingFrame
95- for y := 0; y < base.Height; y++ {
96- copy(frame.Row(y), base.Row(y))
97- }
98- } else {
99- frame = base.Clone()
100- if cache != nil {
101- cache.floatingFrame = frame
102- }
103- }
104- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
105- // The PTY reader mutates both the frame and its damage under p.mu. Keep
106- // every read of them, including the blit, in the same critical section.
107- // In particular, Frame is not safe to retain as an alias after unlocking.
108- p.mu.Lock()
109- // Committed popup geometry is content-relative; translate it exactly once
110- // while holding the same lock that protects the committed state and frame.
111- geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y)
112- title := p.displayTitleLocked()
113- titleGeneration := p.title.generation
114- screenDamage := p.screen.Damage()
115- blitPaneFrame(frame, geometry.Inner, p.screen.Frame, false, theme)
88+func composeCapturedFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, floating capturedFloatingRenderState, content domain.Rect, layoutSnap capturedTabLayout, theme themeui.Theme, cache composeCacheInput, full bool) (renderer.Frame, []renderer.Damage) {
89+ frame := base.Clone()
90+ legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
91+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme)
92+ geometry := floating.geometry.translate(content.X, content.Y)
93+ blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, theme)
11694 damage := append([]renderer.Damage(nil), baseDamage...)
117- for _, d := range screenDamage {
95+ for _, d := range floating.pane.damage {
11896 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
11997 }
120- p.screen.ClearDamage()
121- p.mu.Unlock()
122-
123- // The committed geometry, rather than the requested config geometry, is
124- // what was actually rendered. Track it so movement (including a same-size
125- // resize) cannot reuse a cached popup at its former position.
126- popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry
127- titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
128- drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
129- if cache != nil {
130- cache.floating = p
131- cache.floatingGeneration = generation
132- cache.floatingGeometry = geometry
133- cache.floatingTitleGeneration = titleGeneration
134- }
98+ popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry
99+ titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration
100+ drawFloatingBorder(frame, geometry.Bounds, floating.title, newThemeStyles(theme).border)
135101 if full || popupChanged {
136- return frame, []renderer.Damage{renderer.FullRedraw()}, geometry
102+ return frame, []renderer.Damage{renderer.FullRedraw()}
137103 }
138104 if titleChanged && geometry.Bounds.Height >= 3 {
139105 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
140106 }
141- return frame, damage, geometry
107+ return frame, damage
142108 }
143109
144110 func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {
@@ -167,70 +133,3 @@ func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string,
167133 ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)
168134 }
169135 }
170-
171-// resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize
172-// is intentionally outside pane.mu; failed resizes do not publish any state.
173-func (d *Daemon) resizeFloatingPane(p *pane, geometry floatingGeometry) bool {
174- inner := geometry.Inner
175- if p == nil || !geometry.valid() {
176- return false
177- }
178- p.resizeMu.Lock()
179- defer p.resizeMu.Unlock()
180-
181- p.mu.Lock()
182- old := p.rect
183- pty := p.pty
184- p.mu.Unlock()
185- if old.Width == inner.Width && old.Height == inner.Height {
186- // Position and outer bounds can change while the terminal dimensions do
187- // not. Commit that geometry without needlessly resetting the PTY/screen.
188- p.mu.Lock()
189- p.rect = inner
190- p.popupGeometry = geometry
191- p.mu.Unlock()
192- return true
193- }
194- if pty != nil {
195- if err := pty.Resize(rectSize(inner)); err != nil {
196- d.log.Warn("floating pty resize failed", "err", err)
197- return false
198- }
199- }
200- p.mu.Lock()
201- p.screen.Resize(inner.Width, inner.Height)
202- p.rect = inner
203- p.popupGeometry = geometry
204- p.mu.Unlock()
205- return true
206-}
207-
208-// resizeInstalledFloating updates an installed floating pane, including a
209-// retained hidden pane when its tab becomes active.
210-func (d *Daemon) resizeInstalledFloating(tb *tab) bool {
211- if d == nil || tb == nil {
212- return false
213- }
214- tb.mu.Lock()
215- p := tb.floating.pane
216- content := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}
217- tb.mu.Unlock()
218- if p == nil {
219- return false
220- }
221- return d.resizeFloatingPane(p, calculateContentFloatingGeometry(content, d.currentFloatingConfig()))
222-}
223-
224-// resizeActiveFloating updates a visible floating pane during a client resize.
225-func (d *Daemon) resizeActiveFloating(tb *tab) bool {
226- if d == nil || tb == nil {
227- return false
228- }
229- tb.mu.Lock()
230- visible := tb.floating.state == floatingVisible
231- tb.mu.Unlock()
232- if !visible {
233- return false
234- }
235- return d.resizeInstalledFloating(tb)
236-}
A internal/usecase/daemon/floating_render_legacy_test.go +72 -0
@@ -0,0 +1,72 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ themeui "github.com/bnema/vev/internal/usecase/theme"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+// composeFloatingFrame applies the popup to a copy of the normal composed
10+// destination. In particular it never writes the VT screen frame: that frame
11+// is shared with the PTY reader and is the source for future renders.
12+func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) {
13+ var frame renderer.Frame
14+ if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height {
15+ frame = cache.floatingFrame
16+ for y := 0; y < base.Height; y++ {
17+ copy(frame.Row(y), base.Row(y))
18+ }
19+ } else {
20+ frame = base.Clone()
21+ if cache != nil {
22+ cache.floatingFrame = frame
23+ }
24+ }
25+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
26+ // The PTY reader mutates both the frame and its damage under p.mu. Keep
27+ // every read of them, including the blit, in the same critical section.
28+ // In particular, Frame is not safe to retain as an alias after unlocking.
29+ p.mu.Lock()
30+ // Committed popup geometry is content-relative; translate it exactly once
31+ // while holding the same lock that protects the committed state and frame.
32+ geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y)
33+ previousCapture := capturedPaneRenderState{}
34+ if cache != nil {
35+ previousCapture = cache.floatingCaptured
36+ }
37+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, previousCapture)
38+ // This retained legacy test helper models its historical synchronous
39+ // consume path; production commits receipts in emitFrame instead.
40+ p.screen.AcknowledgeDamage(captured.damageGeneration)
41+ if cache != nil {
42+ cache.floatingCaptured = captured
43+ }
44+ p.mu.Unlock()
45+ title := captured.title
46+ titleGeneration := captured.titleGeneration
47+ blitPaneFrame(frame, geometry.Inner, captured.frame, false, theme)
48+ damage := append([]renderer.Damage(nil), baseDamage...)
49+ for _, d := range captured.damage {
50+ damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
51+ }
52+
53+ // The committed geometry, rather than the requested config geometry, is
54+ // what was actually rendered. Track it so movement (including a same-size
55+ // resize) cannot reuse a cached popup at its former position.
56+ popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry
57+ titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
58+ drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
59+ if cache != nil {
60+ cache.floating = p
61+ cache.floatingGeneration = generation
62+ cache.floatingGeometry = geometry
63+ cache.floatingTitleGeneration = titleGeneration
64+ }
65+ if full || popupChanged {
66+ return frame, []renderer.Damage{renderer.FullRedraw()}, geometry
67+ }
68+ if titleChanged && geometry.Bounds.Height >= 3 {
69+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
70+ }
71+ return frame, damage, geometry
72+}
M internal/usecase/daemon/floating_render_test.go +28 -44
@@ -14,6 +14,25 @@ import (
1414 "github.com/bnema/vev/pkg/renderer"
1515 )
1616
17+// applyFloatingResizePlanForTest exercises the transactional apply primitive.
18+func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometry) bool {
19+ if p == nil || !geometry.valid() {
20+ return false
21+ }
22+ plan := resizePlan{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}}
23+ d.applyResize(&plan)
24+ member := plan.members[0]
25+ if !member.ok {
26+ return false
27+ }
28+ p.mu.Lock()
29+ p.rect = geometry.Inner
30+ p.popupGeometry = geometry
31+ p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
32+ p.mu.Unlock()
33+ return true
34+}
35+
1736 func TestFloatingGeometryTranslate(t *testing.T) {
1837 geometry := floatingGeometry{
1938 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},
@@ -200,7 +219,7 @@ func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T)
200219 p.rect, p.popupGeometry = initial.Inner, initial
201220 d := newTestDaemon(t, nil, stubClock{})
202221
203- require.True(t, d.resizeFloatingPane(p, tt.next))
222+ require.True(t, applyFloatingResizePlanForTest(d, p, tt.next))
204223 require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY")
205224 require.Equal(t, tt.next.Inner, p.rect)
206225 require.Equal(t, tt.next, p.popupGeometry)
@@ -268,41 +287,6 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
268287 }
269288 }
270289
271-func TestRenderHiddenFloatingSkipsPreviewAndActiveClientAndClearsDamage(t *testing.T) {
272- normalPTY, releaseNormal := newBlockingPTY(t)
273- defer releaseNormal()
274- d, sess, _, activeSends := newManualSessionWithPTYs(t, normalPTY)
275- previewTransport, previewSends := newCapturingTransport(t)
276- preview := &attachedClient{tr: previewTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
277- preview.initOverlays()
278- preview.setSession(sess)
279-
280- tb := sess.activeTab()
281- floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 8})
282- floating.screen.Write([]byte("hidden output"))
283- tb.mu.Lock()
284- generation := tb.beginFloatingWarmLocked(false)
285- require.True(t, tb.installFloatingLocked(floating, generation))
286- tb.previewClient = preview
287- tb.mu.Unlock()
288-
289- d.render(sess, tb, floating)
290-
291- floating.mu.Lock()
292- require.Empty(t, floating.screen.Damage())
293- floating.mu.Unlock()
294- select {
295- case frame := <-previewSends:
296- t.Fatalf("hidden floating output painted picker preview: %#v", frame)
297- default:
298- }
299- select {
300- case frame := <-activeSends:
301- t.Fatalf("hidden floating output painted active client: %#v", frame)
302- default:
303- }
304-}
305-
306290 func BenchmarkComposeFloatingFrameCached(b *testing.B) {
307291 p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
308292 p.title.displayFallback = "float"
@@ -364,14 +348,14 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
364348 d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42})
365349 require.Empty(t, pty.sizes())
366350
367- // Showing attempts the current size before paint. A failed resize leaves the
368- // old screen and rect untouched, so a later hide/show can retry.
351+ // Showing attempts the current size before paint. A failed resize retains
352+ // the old screen, but commits the new popup rect so capture clips/pads it.
369353 require.NoError(t, d.toggleFloating(sess, nil))
370354 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())
371- require.Equal(t, initial, floating.rect)
355+ require.Equal(t, current, floating.rect)
372356 require.Equal(t, initial.Width, floating.screen.Frame.Width)
373357 require.Equal(t, initial.Height, floating.screen.Frame.Height)
374- require.Equal(t, initialGeometry, floating.popupGeometry)
358+ require.Equal(t, currentGeometry, floating.popupGeometry)
375359
376360 require.NoError(t, d.toggleFloating(sess, nil)) // hide
377361 require.NoError(t, d.toggleFloating(sess, nil)) // retry show
@@ -397,7 +381,7 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T)
397381 d := newTestDaemon(t, nil, stubClock{})
398382 d.ApplyConfig(domain.Config{Floating: cfg})
399383
400- require.False(t, d.resizeFloatingPane(p, newGeometry))
384+ require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry))
401385 tb.mu.Lock()
402386 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
403387 copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y))
@@ -423,7 +407,7 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
423407 p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}
424408 d := newTestDaemon(t, nil, stubClock{})
425409 requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}}
426- require.False(t, d.resizeFloatingPane(p, requested))
410+ require.False(t, applyFloatingResizePlanForTest(d, p, requested))
427411 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
428412 require.Equal(t, floatingGeometry{}, p.popupGeometry)
429413 require.Equal(t, 5, p.screen.Frame.Width)
@@ -439,11 +423,11 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
439423 done1 := make(chan bool, 1)
440424 done2 := make(chan bool, 1)
441425 secondStarted := make(chan struct{})
442- go func() { done1 <- d.resizeFloatingPane(p, first) }()
426+ go func() { done1 <- applyFloatingResizePlanForTest(d, p, first) }()
443427 <-pty.entered
444428 go func() {
445429 close(secondStarted)
446- done2 <- d.resizeFloatingPane(p, second)
430+ done2 <- applyFloatingResizePlanForTest(d, p, second)
447431 }()
448432 <-secondStarted
449433 close(pty.release)
M internal/usecase/daemon/floating_test.go +5 -5
@@ -36,8 +36,8 @@ func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsm
3636 var releaseOnce sync.Once
3737 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }
3838 t.Cleanup(releaseOpen)
39- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
40- func(command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {
39+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
40+ func(_ context.Context, command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {
4141 opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size}
4242 <-release
4343 return result, openErr
@@ -364,7 +364,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
364364
365365 // Establish a renderer shadow first. The EOF repaint must reset it and
366366 // redraw the underlying cell rather than depending on popup damage.
367- d.paint(sess, ac, true)
367+ d.paint(sess, ac, true, nil)
368368 baseline := awaitFrame(t, sends, ports.MsgOutput)
369369 baselineOutput, err := ports.UnmarshalOutput(baseline.Payload)
370370 require.NoError(t, err)
@@ -467,8 +467,8 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
467467 t.Run(tt.name, func(t *testing.T) {
468468 factory := portsmocks.NewMockPTYFactory(t)
469469 opened := make(chan string, 1)
470- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
471- func(_ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {
470+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
471+ func(_ context.Context, _ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {
472472 opened <- cwd
473473 return nil, io.ErrUnexpectedEOF
474474 }).Once()
A internal/usecase/daemon/history_test_helper_test.go +10 -0
@@ -0,0 +1,10 @@
1+package daemon
2+
3+import "github.com/bnema/vev/pkg/vt"
4+
5+func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }
6+
7+func installTestHistory(p *pane, config vt.HistoryConfig) {
8+ p.screen = vt.NewScreenWithHistory(p.screen.Frame.Width, p.screen.Frame.Height, config)
9+ p.history = p.screen.History()
10+}
M internal/usecase/daemon/input.go +9 -32
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
4-//
5-// Concurrency model (sessions own one or more PTY-backed tabs):
6-//
7-// - Serve runs the accept loop. Each accepted connection is handled by its
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
19-//
20-// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
1+// Package daemon holds vev's server-side session multiplexer use case.
252 package daemon
263
274 import (
@@ -118,7 +95,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
11895 if target.ID != oldFocus {
11996 d.refreshPaneTitleOnFocus(sess, target.ID)
12097 }
121- d.paint(sess, ac, true)
98+ d.invalidateRender(sess, ac, true, "input.go")
12299 return
123100 }
124101 }
@@ -174,7 +151,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
174151 d.exitCopyMode(ac)
175152 d.refreshPaneTitleOnFocus(sess, pl.ID)
176153 }
177- d.paint(sess, ac, true)
154+ d.invalidateRender(sess, ac, true, "input.go")
178155 return
179156 }
180157 var p *pane
@@ -195,7 +172,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
195172 if isMouseFocusPress(ev) && pl.ID != oldFocus {
196173 d.exitCopyMode(ac)
197174 d.refreshPaneTitleOnFocus(sess, pl.ID)
198- d.paint(sess, ac, true)
175+ d.invalidateRender(sess, ac, true, "input.go")
199176 }
200177 if multi {
201178 ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
@@ -225,8 +202,8 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
225202 mouseMode, mouseSGR := p.screen.MouseMode()
226203 altScreen := p.screen.AltScreenActive()
227204 scrollbackRows := 0
228- if p.scrollback != nil {
229- scrollbackRows = p.scrollback.Len()
205+ if p.history != nil {
206+ scrollbackRows = p.history.Len()
230207 }
231208 p.mu.Unlock()
232209
@@ -272,7 +249,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
272249
273250 // The immutable snapshot is the only live-pane access needed for drag entry.
274251 p.mu.Lock()
275- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
252+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
276253 p.mu.Unlock()
277254 tb := sess.activeTab()
278255 if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) {
@@ -290,7 +267,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
290267 rt.copyDragging = true
291268 }
292269 rt.copyMu.Unlock()
293- d.paint(sess, ac, true)
270+ d.invalidateRender(sess, ac, true, "input.go")
294271 case mouse.Release:
295272 rt.copyMu.Lock()
296273 rt.normalMousePressValid = false
@@ -432,7 +409,7 @@ func (h daemonKeyHandler) Action(action keys.Action) {
432409 idx := int(action - keys.ActionSwitchTab1)
433410 if sess.switchTab(idx) {
434411 h.d.activateTab(sess, sess.activeTab())
435- h.d.paint(sess, h.ac, true)
412+ h.d.invalidateRender(sess, h.ac, true, "input.go")
436413 }
437414 }
438415 }
M internal/usecase/daemon/input_test.go +188 -13
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "context"
45 "encoding/base64"
56 "fmt"
67 "strings"
@@ -14,10 +15,10 @@ import (
1415 "github.com/bnema/vev/internal/domain"
1516 "github.com/bnema/vev/internal/ports"
1617 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17- scopy "github.com/bnema/vev/internal/usecase/copy"
1818 "github.com/bnema/vev/internal/usecase/keys"
1919 "github.com/bnema/vev/internal/usecase/layout"
2020 "github.com/bnema/vev/internal/usecase/picker"
21+ "github.com/bnema/vev/pkg/vt"
2122 )
2223
2324 // --- test doubles -----------------------------------------------------------
@@ -57,11 +58,11 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
5758 return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3
5859 }, 2*time.Second, 5*time.Millisecond)
5960 require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond)
60- require.Equal(t, []byte("A"), <-writes1)
61+ requirePTYWrite(t, writes1, []byte("A"))
6162 require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond)
62- require.Equal(t, []byte("B"), <-writes2)
63+ requirePTYWrite(t, writes2, []byte("B"))
6364 require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond)
64- require.Equal(t, []byte("C"), <-writes3)
65+ requirePTYWrite(t, writes3, []byte("C"))
6566
6667 d.mu.Lock()
6768 var sess *session
@@ -87,6 +88,77 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
8788 d.sessWg.Wait()
8889 }
8990
91+func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
92+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil)
93+ source := sess.tabs[0].focusedPane()
94+ target := sess.tabs[1].focusedPane()
95+ require.Equal(t, source.id, target.id, "tabs deliberately reuse pane-1")
96+
97+ source.mu.Lock()
98+ source.screen.Write([]byte("source"))
99+ source.mu.Unlock()
100+ d.paint(sess, ac, true, nil)
101+ first := awaitFrame(t, sends, ports.MsgOutput)
102+ firstOutput, err := ports.UnmarshalOutput(first.Payload)
103+ require.NoError(t, err)
104+ terminal := vt.NewScreen(ac.size.Cols, ac.size.Rows)
105+ terminal.Write(firstOutput.Data)
106+
107+ // A clean pane relies on the attachment capture cache for its retained
108+ // snapshot. Both tabs deliberately use the tab-local default pane ID.
109+ target.mu.Lock()
110+ target.screen.ClearDamage()
111+ target.mu.Unlock()
112+ // Use the real key action: it requests the mandatory complete repaint for
113+ // the first target-tab frame.
114+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2)
115+ require.Equal(t, 1, activeTabIndex(sess))
116+
117+ second := awaitFrame(t, sends, ports.MsgOutput)
118+ secondOutput, err := ports.UnmarshalOutput(second.Payload)
119+ require.NoError(t, err)
120+ require.Zero(t, secondOutput.BaseStateNum, "tab switch must emit the complete target frame first")
121+ terminal.Write(secondOutput.Data)
122+ require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells")
123+}
124+
125+func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) {
126+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
127+ tb := sess.tabs[0]
128+ survivor := tb.panes["pane-1"]
129+ closed := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 23})
130+ tb.mu.Lock()
131+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
132+ tb.panes[closed.id] = closed
133+ tb.mu.Unlock()
134+
135+ cachedSurvivor := capturedPaneRenderState{title: "survivor"}
136+ ac.captureFrames = map[*pane]capturedPaneRenderState{
137+ survivor: cachedSurvivor,
138+ closed: {title: "closed"},
139+ }
140+
141+ require.NoError(t, d.closePane(sess, tb, closed.id, ac, false))
142+ require.NotContains(t, ac.captureFrames, closed, "closed pane must not remain strongly retained by its attachment")
143+ require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving pane keeps its incremental capture")
144+}
145+
146+func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) {
147+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
148+ closedTab, survivorTab := sess.tabs[0], sess.tabs[1]
149+ closed := closedTab.panes["pane-1"]
150+ survivor := survivorTab.panes["pane-1"]
151+ cachedSurvivor := capturedPaneRenderState{title: "survivor"}
152+ ac.captureFrames = map[*pane]capturedPaneRenderState{
153+ closed: {title: "closed"},
154+ survivor: cachedSurvivor,
155+ }
156+
157+ d.closeTab(sess, closedTab, false)
158+ require.NotContains(t, ac.captureFrames, closed, "closed tab panes must not remain strongly retained by its attachment")
159+ require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving tab pane keeps its incremental capture")
160+}
161+
90162 func TestAltCForwardsToPTY(t *testing.T) {
91163 writes := make(chan []byte, 2)
92164 p, releasePTY := newBlockingPTYWithWrites(t, writes)
@@ -94,10 +166,74 @@ func TestAltCForwardsToPTY(t *testing.T) {
94166
95167 d.handleInput(sess, ac, []byte("\x1bc"))
96168
97- require.Equal(t, []byte("\x1bc"), <-writes)
169+ requirePTYWrite(t, writes, []byte("\x1bc"))
98170 releasePTY()
99171 }
100172
173+func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
174+ normal, releaseNormal := newBlockingPTY(t)
175+ floatingPTY, releaseFloating := newBlockingPTY(t)
176+ defer releaseNormal()
177+ defer releaseFloating()
178+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
179+ client := vt.NewScreen(80, 25)
180+ floatingCtx, cancelFloating := context.WithCancel(sess.ctx)
181+ defer cancelFloating()
182+ floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5})
183+ floating.ctx = floatingCtx
184+ floating.screen.Write([]byte("popup-content"))
185+ installTestFloating(sess.activeTab(), floating, false)
186+
187+ // Establish the client shadow while the retained popup is hidden.
188+ d.paint(sess, ac, true, nil)
189+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
190+
191+ // Route the real Alt+F binding. Showing a retained pane must paint once.
192+ d.handleInput(sess, ac, []byte("\x1bf"))
193+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
194+ require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content")
195+ require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "┌")
196+ select {
197+ case extra := <-sends:
198+ t.Fatalf("show emitted duplicate output: %#v", extra)
199+ default:
200+ }
201+
202+ // The second real key must repaint immediately, rather than waiting for
203+ // unrelated output, while retaining the existing PTY and context.
204+ d.handleInput(sess, ac, []byte("\x1bf"))
205+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
206+ require.NotContains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content")
207+ tb := sess.activeTab()
208+ tb.mu.Lock()
209+ require.Equal(t, floatingHidden, tb.floating.state)
210+ require.Same(t, floating, tb.floating.pane)
211+ generation := tb.floating.generation
212+ tb.mu.Unlock()
213+ require.Same(t, floatingPTY, floating.pty)
214+ select {
215+ case <-floating.ctx.Done():
216+ t.Fatal("hiding retained popup cancelled its context")
217+ default:
218+ }
219+
220+ // A third show uses the installed pane; it must not launch or emit a second
221+ // resize/start frame.
222+ d.handleInput(sess, ac, []byte("\x1bf"))
223+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
224+ require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content")
225+ tb.mu.Lock()
226+ require.Equal(t, floatingVisible, tb.floating.state)
227+ require.Same(t, floating, tb.floating.pane)
228+ require.Equal(t, generation, tb.floating.generation)
229+ tb.mu.Unlock()
230+ select {
231+ case extra := <-sends:
232+ t.Fatalf("retained show emitted duplicate output: %#v", extra)
233+ default:
234+ }
235+}
236+
101237 func requireFloatingInitialized(t *testing.T, tb *tab) {
102238 t.Helper()
103239 tb.mu.Lock()
@@ -260,7 +396,7 @@ func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
260396 paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~")
261397 d.handleInput(sess, ac, paste)
262398
263- require.Equal(t, paste, <-writes)
399+ requirePTYWrite(t, writes, paste)
264400 releasePTY()
265401 }
266402
@@ -342,7 +478,7 @@ func TestOverlayInputPrecedence(t *testing.T) {
342478 input: []byte("Z"),
343479 check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
344480 t.Helper()
345- require.Equal(t, []byte("Z"), <-writes)
481+ requirePTYWrite(t, writes, []byte("Z"))
346482 },
347483 },
348484 }
@@ -466,6 +602,45 @@ func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
466602 require.Same(t, updated, ac.previousSession.Get())
467603 }
468604
605+// backSession's invalid-target fallback is a render producer: it must publish
606+// exactly one coordinator invalidation instead of composing directly, while
607+// the valid-target hand-off (TestPaletteBackSessionTogglesPreviousSession) and
608+// the guarded stale clear (TestStaleBackSessionClearPreservesConcurrentTarget)
609+// keep their PR #73 behavior unchanged.
610+func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) {
611+ for _, tc := range []struct {
612+ name string
613+ prepare func(current *session, ac *attachedClient)
614+ }{
615+ {name: "no previous target", prepare: func(*session, *attachedClient) {}},
616+ {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {
617+ ac.previousSession.Set(&session{id: "gone"})
618+ }},
619+ {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {
620+ ac.previousSession.Set(current)
621+ }},
622+ } {
623+ t.Run(tc.name, func(t *testing.T) {
624+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
625+ defer releaseAll(releases)
626+ invs := make(chan renderInvalidation, 4)
627+ current.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
628+ clock: d.clock,
629+ wake: func(renderWake) {},
630+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
631+ }))
632+ tc.prepare(current, ac)
633+
634+ d.backSession(current, ac)
635+
636+ require.Same(t, current, ac.currentSession(), "an invalid target must not move the attachment")
637+ awaitInvalidation(t, invs)
638+ requireNoInvalidation(t, invs)
639+ requireNoOutputFrame(t, sends)
640+ })
641+ }
642+}
643+
469644 func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
470645 for _, tc := range []struct {
471646 name string
@@ -592,7 +767,7 @@ func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
592767 require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab")
593768 d.handleInput(sess, ac, []byte("Z"))
594769 require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)
595- require.Equal(t, []byte("Z"), <-writes)
770+ requirePTYWrite(t, writes, []byte("Z"))
596771
597772 releasePTY1()
598773 releasePTY2()
@@ -696,8 +871,8 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
696871 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
697872 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
698873
699- require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes)
700- require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes)
874+ requirePTYWrite(t, writes, []byte("\x1b[A\x1b[A\x1b[A"))
875+ requirePTYWrite(t, writes, []byte("\x1b[B\x1b[B\x1b[B"))
701876 require.Nil(t, ac.overlays.copyMode)
702877 }
703878
@@ -752,7 +927,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
752927
753928 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h"))
754929 d.handleInput(sess, ac, raw)
755- require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes)
930+ requirePTYWrite(t, writes, []byte("\x1b[<0;2;2M"))
756931
757932 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
758933 select {
@@ -892,7 +1067,7 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
8921067 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
8931068 p, _ := newBlockingPTY(t)
8941069 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
895- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(50)
1070+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 50})
8961071 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
8971072
8981073 // Press on a content row while scrollback is empty: the anchor is
@@ -902,7 +1077,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
9021077 // Simulate 5 lines evicted into scrollback between the Press and the
9031078 // first Motion (e.g. the child kept producing output).
9041079 for range 5 {
905- sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted"))
1080+ sess.tabs[0].focusedPane().history.Append(testRow("evicted"))
9061081 }
9071082
9081083 // Motion lands on wire row 3 (0-based row 2 of the *current* screen).
A internal/usecase/daemon/lease_callback_guard_test.go +67 -0
@@ -0,0 +1,67 @@
1+package daemon
2+
3+import (
4+ "runtime"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+)
12+
13+func TestResizeAndRetryCallbacksRejectSameObjectLeaseReplacement(t *testing.T) {
14+ for _, tc := range []struct {
15+ name string
16+ schedule func(*renderCoordinator, *attachedClient, *attachmentLease, func())
17+ done func(*testing.T, *renderCoordinator) <-chan struct{}
18+ }{
19+ {
20+ name: "resize",
21+ schedule: func(rc *renderCoordinator, ac *attachedClient, lease *attachmentLease, run func()) {
22+ rc.scheduleResizeForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease, func(uint64) { run() })
23+ },
24+ done: captureResizeCallbackDone,
25+ },
26+ {
27+ name: "retry",
28+ schedule: func(rc *renderCoordinator, ac *attachedClient, lease *attachmentLease, run func()) {
29+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
30+ require.NotZero(t, epoch)
31+ require.True(t, rc.resizeCurrentForLease(epoch, ac, lease, true))
32+ rc.scheduleResizeRetryForLease(epoch, ac, lease, run)
33+ },
34+ done: func(t *testing.T, rc *renderCoordinator) <-chan struct{} {
35+ t.Helper()
36+ rc.mu.Lock()
37+ defer rc.mu.Unlock()
38+ if rc.retryLane.token == nil {
39+ t.Fatal("coordinator did not publish a retry callback completion")
40+ }
41+ return rc.retryLane.token.done
42+ },
43+ },
44+ } {
45+ t.Run(tc.name, func(t *testing.T) {
46+ clock := newCoordinatorMockClock(t, 1)
47+ rc := newRenderCoordinator(renderCoordinatorOptions{clock: clock.clock})
48+ ac := &attachedClient{}
49+ rc.attach(ac)
50+ lease := rc.attachmentLease(ac)
51+ ran := false
52+ tc.schedule(rc, ac, lease, func() { ran = true })
53+ timer := awaitCoordinatorScheduledTimer(t, clock)
54+ done := tc.done(t, rc)
55+ require.NotNil(t, done)
56+
57+ rc.notePark(ac)
58+ rc.attach(ac) // resume the exact same object under a new incarnation
59+ timer.ch <- time.Time{}
60+ awaitTestCompletion(t, done, "stale callback did not complete")
61+ for range 32 {
62+ runtime.Gosched()
63+ }
64+ require.False(t, ran, "stale %s callback ran after lease replacement", tc.name)
65+ })
66+ }
67+}
M internal/usecase/daemon/mouse_panes.go +0 -4
@@ -64,10 +64,6 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
6464 return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok}
6565 }
6666
67-func (snap tabLayoutSnapshot) matchesLocked(tb *tab) bool {
68- return snap.ok && tb != nil && tb.tree != nil && tb.tree.Root == snap.root && tb.tree.Focus == snap.focus && tb.size.Cols == snap.area.Width && tb.size.Rows == snap.area.Height && snap.fingerprint == layoutFingerprint(tb.tree.Root)
69-}
70-
7167 func layoutFingerprint(root *layout.Node) string {
7268 var b strings.Builder
7369 writeLayoutFingerprint(&b, root)
M internal/usecase/daemon/nil_pty_test.go +1 -1
@@ -24,7 +24,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
2424 sess.tabs = []*tab{first, second}
2525
2626 require.NotPanics(t, func() {
27- d.resizePane(first.focusedPane(), domain.Rect{Width: 20, Height: 5})
27+ d.applyPaneResize(first.focusedPane(), domain.Rect{Width: 20, Height: 5})
2828 })
2929 require.Equal(t, 20, first.focusedPane().screen.Frame.Width)
3030 require.Equal(t, 5, first.focusedPane().screen.Frame.Height)
M internal/usecase/daemon/output_state_stream.go +0 -29
@@ -17,8 +17,6 @@ type outputStateStream struct {
1717 renderer *renderer.Renderer
1818 next uint64
1919 acked uint64
20- deferred bool
21- deferredReset bool
2220 maxOutstanding uint64
2321 }
2422
@@ -89,37 +87,10 @@ func (s *outputStateStream) ack(state uint64) {
8987 func (s *outputStateStream) rebase() {
9088 s.acked = s.next
9189 s.renderer.Reset()
92- s.clearDeferred()
9390 }
9491
9592 func (s *outputStateStream) atCapacity() bool {
9693 return s.outstanding() >= s.maxOutstanding
9794 }
9895
99-func (s *outputStateStream) deferIfAtCapacity(reset bool) bool {
100- if !s.atCapacity() {
101- if reset {
102- s.clearDeferred()
103- }
104- return false
105- }
106- s.deferred = true
107- s.deferredReset = s.deferredReset || reset
108- return true
109-}
110-
111-func (s *outputStateStream) takeDeferred() (reset bool, ok bool) {
112- if !s.deferred || s.atCapacity() {
113- return false, false
114- }
115- reset = s.deferredReset
116- s.clearDeferred()
117- return reset, true
118-}
119-
120-func (s *outputStateStream) clearDeferred() {
121- s.deferred = false
122- s.deferredReset = false
123-}
124-
12596 func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked }
M internal/usecase/daemon/output_state_stream_test.go +0 -75
@@ -68,53 +68,6 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T
6868 }
6969 }
7070
71-func TestOutputStateStreamAckProgressionReleasesDeferredPaint(t *testing.T) {
72- stream := newOutputStateStream()
73- for range maxUnackedOutputStates {
74- stream.frame([]byte("output"), false, 0)
75- }
76- require.True(t, stream.deferIfAtCapacity(false))
77- _, ok := stream.takeDeferred()
78- require.False(t, ok)
79- stream.ack(maxUnackedOutputStates - 1)
80- reset, ok := stream.takeDeferred()
81- require.True(t, ok)
82- require.False(t, reset)
83- require.Equal(t, uint64(1), stream.outstanding())
84-
85- // Stale and future ACKs do not move the cumulative acknowledgement.
86- stream.ack(maxUnackedOutputStates - 2)
87- stream.ack(maxUnackedOutputStates + 1)
88- require.Equal(t, uint64(maxUnackedOutputStates-1), stream.acked)
89-}
90-
91-func TestOutputStateStreamResetIsDependencyFreeAndClearsDeferral(t *testing.T) {
92- stream := newOutputStateStream()
93- for range maxUnackedOutputStates {
94- stream.frame([]byte("output"), false, 0)
95- }
96- require.True(t, stream.deferIfAtCapacity(false))
97- for range 32 {
98- require.True(t, stream.deferIfAtCapacity(true), "reset paints must coalesce while the stream is at capacity")
99- }
100- require.Equal(t, uint64(maxUnackedOutputStates), stream.outstanding())
101- stream.ack(maxUnackedOutputStates)
102- reset, ok := stream.takeDeferred()
103- require.True(t, ok)
104- require.True(t, reset, "a coalesced reset must flush as a dependency-free repaint")
105-
106- frame := renderer.NewFrame(2, 1)
107- fillOutputStateRows(frame, []string{"ok"})
108- outFrame, ok, err := drawOutputState(t, stream, frame, nil, true, 0)
109- require.NoError(t, err)
110- require.True(t, ok)
111- out, err := ports.UnmarshalOutput(outFrame.Payload)
112- require.NoError(t, err)
113- require.Zero(t, out.BaseStateNum)
114- _, ok = stream.takeDeferred()
115- require.False(t, ok)
116-}
117-
11871 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
11972 t.Helper()
12073 data, err := stream.render(frame, damage, reset)
@@ -124,34 +77,6 @@ func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Fra
12477 return stream.frame(data, reset, echoAck), true, nil
12578 }
12679
127-func TestOutputStateStreamWindowOneCoalescesUntilCumulativeAck(t *testing.T) {
128- stream := newOutputStateStream(1)
129- stream.frame([]byte("first"), true, 0)
130- for range 100 {
131- require.True(t, stream.deferIfAtCapacity(false))
132- }
133- require.Equal(t, uint64(1), stream.outstanding())
134-
135- // A stale ACK cannot release the coalesced paint; the cumulative ACK for
136- // the actually sent state does.
137- stream.ack(0)
138- _, ok := stream.takeDeferred()
139- require.False(t, ok)
140- stream.ack(1)
141- reset, ok := stream.takeDeferred()
142- require.True(t, ok)
143- require.False(t, reset)
144- stream.frame([]byte("latest"), reset, 0)
145- require.Equal(t, uint64(1), stream.outstanding())
146-
147- sideEffect := stream.sideEffect([]byte("reliable"), 0)
148- output, err := ports.UnmarshalOutput(sideEffect.Payload)
149- require.NoError(t, err)
150- require.Zero(t, output.BaseStateNum)
151- require.Zero(t, output.NewStateNum)
152- require.Equal(t, uint64(2), stream.next)
153-}
154-
15580 func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {
15681 require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding)
15782 require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding)
M internal/usecase/daemon/output_state_sync_test.go +52 -25
@@ -82,7 +82,7 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
8282 require.Same(t, sess, routed)
8383
8484 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
85- d.paint(sess, ac, false)
85+ d.paint(sess, ac, false, nil)
8686 first := awaitFrame(t, tr.sends, ports.MsgOutput)
8787 out, err := ports.UnmarshalOutput(first.Payload)
8888 require.NoError(t, err)
@@ -90,7 +90,7 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
9090
9191 // Before the MsgAck, the renderer has already advanced along the ordered
9292 // output dependency chain, so an unchanged repaint is a no-op.
93- d.paint(sess, ac, false)
93+ d.paint(sess, ac, false, nil)
9494 requireNoOutputFrame(t, tr.sends)
9595
9696 tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})}
@@ -99,7 +99,7 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
9999
100100 // The production MsgAck path retires retained states without moving the
101101 // renderer baseline backward.
102- d.paint(sess, ac, false)
102+ d.paint(sess, ac, false, nil)
103103 requireNoOutputFrame(t, tr.sends)
104104 }
105105
@@ -111,7 +111,7 @@ func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) {
111111 ac.replaceTransport(tr)
112112 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
113113
114- d.paint(sess, ac, false)
114+ d.paint(sess, ac, false, nil)
115115
116116 awaitFrame(t, tr.asyncSends, ports.MsgOutput)
117117 select {
@@ -144,7 +144,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
144144 }
145145 pane.screen.ClearDamage()
146146 client := vt.NewScreen(80, 25)
147- d.paint(sess, ac, true)
147+ d.paint(sess, ac, true, nil)
148148 first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))
149149 ac.ackOutputState(first.NewStateNum)
150150
@@ -164,7 +164,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
164164 pane.screen.Col = 0
165165 pane.screen.Write([]byte("\nq"))
166166 pane.screen.Frame = desired.Clone()
167- d.paint(sess, ac, false)
167+ d.paint(sess, ac, false, nil)
168168 mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))
169169 }
170170
@@ -202,11 +202,11 @@ func TestLocalAttachStillAdvancesRendererOnSend(t *testing.T) {
202202 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
203203
204204 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
205- d.paint(sess, ac, false)
205+ d.paint(sess, ac, false, nil)
206206 awaitFrame(t, sends, ports.MsgOutput)
207207
208208 // The first paint updates the renderer shadow without waiting for MsgAck.
209- d.paint(sess, ac, false)
209+ d.paint(sess, ac, false, nil)
210210 requireNoOutputFrame(t, sends)
211211 }
212212
@@ -217,17 +217,17 @@ func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) {
217217 pane := sess.tabs[0].focusedPane()
218218
219219 pane.screen.Write([]byte("A"))
220- d.paint(sess, ac, false)
220+ d.paint(sess, ac, false, nil)
221221 first := awaitFrame(t, sends, ports.MsgOutput)
222222 out, err := ports.UnmarshalOutput(first.Payload)
223223 require.NoError(t, err)
224224
225225 pane.screen.Write([]byte("\rB"))
226- d.paint(sess, ac, false)
226+ d.paint(sess, ac, false, nil)
227227 awaitFrame(t, sends, ports.MsgOutput)
228228
229229 ac.ackOutputState(out.NewStateNum)
230- d.paint(sess, ac, false)
230+ d.paint(sess, ac, false, nil)
231231 requireNoOutputFrame(t, sends)
232232 }
233233
@@ -264,7 +264,7 @@ func TestFailedPaintSendRollsBackOutputState(t *testing.T) {
264264 ac.replaceTransport(failingOutputTransport{})
265265 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
266266
267- d.paint(sess, ac, false)
267+ d.paint(sess, ac, false, nil)
268268
269269 require.Zero(t, ac.output.next)
270270 require.Zero(t, ac.output.acked)
@@ -279,30 +279,50 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
279279 {name: "stream window", window: maxUnackedOutputStates},
280280 } {
281281 t.Run(tc.name, func(t *testing.T) {
282- p, releasePTY := newBlockingPTY(t)
283- defer releasePTY()
282+ // resizeReaderPTY delivers the child's SIGWINCH redraw through the
283+ // actual ptyReader. Its Resize callback does not return until Read has
284+ // accepted the bytes, at which point resizeApplying is still true.
285+ p := newResizeReaderPTY([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))
284286 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
285287 ac.output.maxOutstanding = tc.window
286288 pane := sess.activeTab().focusedPane()
287-
289+ p.applying = func() bool {
290+ pane.mu.Lock()
291+ defer pane.mu.Unlock()
292+ return pane.resizeApplying
293+ }
288294 pane.screen.Write([]byte(strings.Repeat("A", 79)))
289- d.paint(sess, ac, true)
295+ d.paint(sess, ac, true, nil)
290296 client := vt.NewScreen(80, 24)
291297 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
292298 ac.ackOutputState(initial.NewStateNum)
293299
294- // The child can redraw the newly exposed columns while the resize
295- // output is being prepared. The first visible grown frame must contain
296- // that redraw, regardless of the negotiated output window.
300+ d.sessWg.Add(1)
301+ pane.onExit = func() {}
302+ go d.ptyReader(sess, sess.activeTab(), pane)
303+ defer func() { p.close(); d.sessWg.Wait() }()
304+
305+ // The resize deadline drives coordinator prepare/apply/commit. No test
306+ // writes the grown columns directly.
307+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
308+ d.clock = clock
297309 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
298- pane.screen.Write([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))
299- d.paint(sess, ac, false)
310+ timer := <-clock.timers
311+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
312+ timer.ch <- time.Time{}
313+ awaitTestCompletion(t, done, "resize callback did not complete")
314+ require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate")
315+ require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit")
300316
301317 client.Resize(120, 24)
302318 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
303319 require.Equal(t, 'B', client.Frame.At(100, 1).Rune,
304320 "first grown frame exposed stale pre-SIGWINCH pane content")
305- requireNoOutputFrame(t, sends)
321+ select {
322+ case extra := <-sends:
323+ t.Fatalf("resize emitted more than one frame: %#v", extra)
324+ default:
325+ }
306326 })
307327 }
308328 }
@@ -323,7 +343,9 @@ func TestResizeWithoutPTYOutputFlushesOneFullFrameAtDeadline(t *testing.T) {
323343 }
324344 requireNoOutputFrame(t, sends)
325345
346+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
326347 timer.ch <- time.Now()
348+ awaitTestCompletion(t, done, "resize callback did not complete")
327349 frame := awaitFrame(t, sends, ports.MsgOutput)
328350 client := vt.NewScreen(120, 24)
329351 out := mustApplyOutput(t, client, frame)
@@ -348,6 +370,7 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
348370 case <-time.After(time.Second):
349371 t.Fatal("first resize did not schedule a bounded paint")
350372 }
373+ firstDone := captureResizeCallbackDone(t, sess.renderCoordinator())
351374 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
352375 var latest *signalTimer
353376 select {
@@ -356,9 +379,12 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
356379 t.Fatal("latest resize did not replace the bounded paint")
357380 }
358381
382+ latestDone := captureResizeCallbackDone(t, sess.renderCoordinator())
359383 first.ch <- time.Now()
384+ awaitTestCompletion(t, firstDone, "obsolete resize callback did not complete")
360385 requireNoOutputFrame(t, sends)
361386 latest.ch <- time.Now()
387+ awaitTestCompletion(t, latestDone, "latest resize callback did not complete")
362388 awaitFrame(t, sends, ports.MsgOutput)
363389 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
364390 require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width)
@@ -378,20 +404,21 @@ func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) {
378404 MaxOutputInFlight: 1,
379405 }, tr)
380406 require.NoError(t, err)
407+ require.True(t, sess.renderCoordinator().markAttachmentReady(sess.renderCoordinator().attachmentLease(ac)))
381408
382409 pane := sess.tabs[0].focusedPane()
383410 pane.screen.Write([]byte("A"))
384- d.paint(sess, ac, false)
411+ d.paint(sess, ac, false, nil)
385412 first := awaitFrame(t, tr.sends, ports.MsgOutput)
386413 firstOutput, err := ports.UnmarshalOutput(first.Payload)
387414 require.NoError(t, err)
388415 require.Equal(t, uint64(1), firstOutput.NewStateNum)
389416 for range 100 {
390417 pane.screen.Write([]byte("x"))
391- d.paint(sess, ac, false)
418+ d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
392419 }
393420 pane.screen.Write([]byte("LATEST"))
394- d.paint(sess, ac, false)
421+ d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
395422 requireNoOutputFrame(t, tr.sends)
396423 require.Equal(t, uint64(1), ac.output.outstanding(), "only one state-bearing datagram output may be unacknowledged")
397424
M internal/usecase/daemon/overlay_runtime.go +7 -5
@@ -13,11 +13,13 @@ import (
1313 type overlayRuntime struct {
1414 ac *attachedClient
1515
16- pickerMu sync.Mutex
17- picker *picker.Model
18- pickerPreview *tab
19- pickerPending []byte
20- pickerESC pendingByteTimer
16+ pickerMu sync.Mutex
17+ picker *picker.Model
18+ pickerPreview *tab
19+ pickerPreviewSession *session
20+ pickerPreviewGeneration uint64
21+ pickerPending []byte
22+ pickerESC pendingByteTimer
2123
2224 paletteMu sync.Mutex
2325 palette *palette.Model
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
@@ -173,7 +173,7 @@ func TestOverlayRuntimeSnapshotCarriesCopyDocumentAndClearReleasesIt(t *testing.
173173 func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) {
174174 ac := &attachedClient{}
175175 ac.initOverlays()
176- history := scopy.NewScrollback(1)
176+ history := newTestHistory(1)
177177 history.Append(testRow("before"))
178178 document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1))
179179
M internal/usecase/daemon/paint_gate_test.go +0 -112
@@ -1,14 +1,10 @@
11 package daemon
22
33 import (
4- "io"
54 "testing"
65 "time"
76
87 "github.com/bnema/vev/internal/ports"
9- portsmocks "github.com/bnema/vev/internal/ports/mocks"
10- "github.com/bnema/vev/pkg/vt"
11- "github.com/stretchr/testify/require"
128 )
139
1410 func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) {
@@ -25,111 +21,3 @@ func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) {
2521 }
2622 }
2723 }
28-
29-func TestPaintDefersWhenOutputAckLagsAtCap(t *testing.T) {
30- p, _ := newBlockingPTY(t)
31- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
32- sess.tabs[0].focusedPane().screen.Write([]byte("A"))
33-
34- // Client is maxUnackedOutputStates behind (outputAck stays 0).
35- ac.output.next = maxUnackedOutputStates
36- d.paint(sess, ac, false)
37-
38- requireNoOutputFrame(t, sends)
39- ac.sendMu.Lock()
40- deferred := ac.output.deferred
41- ac.sendMu.Unlock()
42- require.True(t, deferred, "diff paint at ack cap must defer")
43-}
44-
45-func TestDeferredPaintFlushesOnceWhenAcksCatchUp(t *testing.T) {
46- p, _ := newBlockingPTY(t)
47- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
48- sess.tabs[0].focusedPane().screen.Write([]byte("A"))
49-
50- ac.output.next = maxUnackedOutputStates
51- d.paint(sess, ac, false)
52- requireNoOutputFrame(t, sends)
53-
54- // Drive a real MsgAck through the connection loop so the flush is exercised
55- // end-to-end: UnmarshalAck → advanceOutputAck → takeDeferredPaint → paint.
56- // One ack frame, then Recv blocks until the test ends.
57- recvCh := make(chan ports.Frame, 1)
58- done := make(chan struct{})
59- t.Cleanup(func() { close(done) })
60- tr, ok := ac.transport().(*portsmocks.MockTransport)
61- require.True(t, ok, "manual session transport must be the capturing mock")
62- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
63- select {
64- case f := <-recvCh:
65- return f, nil
66- case <-done:
67- return ports.Frame{}, io.EOF
68- }
69- }).Maybe()
70- recvCh <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: maxUnackedOutputStates})}
71- go d.runConnLoop(ac)
72-
73- // The cumulative flush paint must be triggered by the MsgAck handler itself
74- // (the renderer shadow only advanced on send, so the skip coalesces here).
75- awaitFrame(t, sends, ports.MsgOutput)
76- requireNoOutputFrame(t, sends)
77- ac.sendMu.Lock()
78- deferred := ac.output.deferred
79- ac.sendMu.Unlock()
80- require.False(t, deferred, "flag must clear after the flush paint")
81-}
82-
83-func TestResetPaintCoalescesAtAckGate(t *testing.T) {
84- p, _ := newBlockingPTY(t)
85- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
86- sess.tabs[0].focusedPane().screen.Write([]byte("A"))
87-
88- // At the cap, repeated reset paints coalesce instead of growing retained
89- // state and transport queues without bound.
90- ac.output.next = maxUnackedOutputStates
91- for range 32 {
92- d.paint(sess, ac, true)
93- }
94-
95- requireNoOutputFrame(t, sends)
96- ac.sendMu.Lock()
97- deferred := ac.output.deferred
98- deferredReset := ac.output.deferredReset
99- retained := ac.output.outstanding()
100- ac.sendMu.Unlock()
101- require.True(t, deferred)
102- require.True(t, deferredReset)
103- require.LessOrEqual(t, retained, uint64(maxUnackedOutputStates))
104-}
105-
106-func TestCoalescedResetFlushesLatestFrameAfterAck(t *testing.T) {
107- p, _ := newBlockingPTY(t)
108- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
109- for range maxUnackedOutputStates {
110- ac.output.frame([]byte("in flight"), false, 0)
111- }
112-
113- pane := sess.tabs[0].focusedPane()
114- var latest byte
115- for i := range 32 {
116- latest = byte('a' + i%26)
117- pane.screen.Write([]byte{'\r', latest})
118- d.paint(sess, ac, true)
119- }
120- requireNoOutputFrame(t, sends)
121- require.Equal(t, uint64(maxUnackedOutputStates), ac.output.outstanding())
122-
123- ac.ackOutputState(maxUnackedOutputStates)
124- reset, ok := ac.takeDeferredPaint()
125- require.True(t, ok)
126- require.True(t, reset)
127- d.paint(sess, ac, reset)
128-
129- client := vt.NewScreen(80, 25)
130- out := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
131- require.Zero(t, out.BaseStateNum)
132- require.Equal(t, rune(latest), client.Frame.At(0, 1).Rune)
133- require.Equal(t, uint64(1), ac.output.outstanding())
134- requireNoOutputFrame(t, sends)
135-}
M internal/usecase/daemon/palette.go +9 -9
@@ -42,7 +42,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
4242 ac.overlays.paletteHints = palette.ContextualHints{}
4343 ac.overlays.palettePending = nil
4444 ac.overlays.paletteMu.Unlock()
45- d.paint(sess, ac, true)
45+ d.invalidateRender(sess, ac, true, "palette.go")
4646 }
4747
4848 func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {
@@ -167,12 +167,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
167167 }
168168 ac.overlays.paletteMu.Unlock()
169169 if cancel {
170- d.paint(sess, ac, true)
170+ d.invalidateRender(sess, ac, true, "palette.go")
171171 return
172172 }
173173 if !execute {
174174 if changed {
175- d.paint(sess, ac, true)
175+ d.invalidateRender(sess, ac, true, "palette.go")
176176 }
177177 return
178178 }
@@ -183,7 +183,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
183183 rank, err := command.ParsePositiveDecimal(args)
184184 if err != nil {
185185 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
186- d.paint(sess, ac, true)
186+ d.invalidateRender(sess, ac, true, "palette.go")
187187 return
188188 }
189189 // The captured ID is handed off atomically. A target can disappear after
@@ -191,14 +191,14 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
191191 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}
192192 if err := exec.JumpRecentSession(rank); err != nil {
193193 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
194- d.paint(sess, ac, true)
194+ d.invalidateRender(sess, ac, true, "palette.go")
195195 return
196196 }
197197 // Publication remains generation-safe; do not let stale work close a
198198 // newer palette interaction.
199199 if ac.closeExecutedPalette(generation, rawQuery) {
200200 d.recordPaletteUse(cmd.Code)
201- d.paint(ac.currentSession(), ac, true)
201+ d.invalidateRender(ac.currentSession(), ac, true, "palette.go")
202202 }
203203 return
204204 }
@@ -259,7 +259,7 @@ type paletteExec struct {
259259 }
260260
261261 func (e paletteExec) CreateTab() error {
262- defer e.d.paint(e.sess, e.ac, true)
262+ defer e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
263263 return e.d.createTab(e.sess, e.ac.size)
264264 }
265265
@@ -325,7 +325,7 @@ func (e paletteExec) NextTab() error {
325325 if e.sess.switchRelative(1) {
326326 e.d.activateTab(e.sess, e.sess.activeTab())
327327 }
328- e.d.paint(e.sess, e.ac, true)
328+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
329329 return nil
330330 }
331331
@@ -333,7 +333,7 @@ func (e paletteExec) PrevTab() error {
333333 if e.sess.switchRelative(-1) {
334334 e.d.activateTab(e.sess, e.sess.activeTab())
335335 }
336- e.d.paint(e.sess, e.ac, true)
336+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
337337 return nil
338338 }
339339
M internal/usecase/daemon/palette_test.go +2 -2
@@ -371,7 +371,7 @@ func TestPaletteCreateTabErrorRepaintsAfterClose(t *testing.T) {
371371 d, sess, ac, sends, releases := newManualTabSession(t, 1)
372372 defer releases[0]()
373373 ptys := portsmocks.NewMockPTYFactory(t)
374- ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed"))
374+ ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed"))
375375 d.ptys = ptys
376376
377377 d.handleInput(sess, ac, []byte("\x1b "))
@@ -518,7 +518,7 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {
518518 go func() {
519519 defer wg.Done()
520520 for range 200 {
521- d.paint(sess, ac, true)
521+ d.paint(sess, ac, true, nil)
522522 }
523523 }()
524524 go func() {
M internal/usecase/daemon/pane.go +26 -32
@@ -6,27 +6,32 @@ import (
66
77 "github.com/bnema/vev/internal/domain"
88 "github.com/bnema/vev/internal/ports"
9- scopy "github.com/bnema/vev/internal/usecase/copy"
109 "github.com/bnema/vev/internal/usecase/layout"
1110 "github.com/bnema/vev/pkg/vt"
1211 )
1312
14-// pane owns one PTY, its terminal screen state, and render scheduling channels.
13+// pane owns one PTY and its terminal screen state.
1514 // Lock order when multiple locks are held is:
1615 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
1716 // The PTY reader takes only pane.mu, so child output never waits on client IO.
1817 type pane struct {
19- id layout.PaneID
20- stableID string
21- pty ports.PTY
22- mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
23- resizeMu sync.Mutex // serializes PTY resizes without holding mu
24- screen *vt.Screen
25- scrollback *scopy.Scrollback
26- dirty chan struct{}
27- flush chan struct{}
28- syncGen uint64
29- rect domain.Rect
18+ id layout.PaneID
19+ stableID string
20+ pty ports.PTY
21+ mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title
22+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
23+ screen *vt.Screen
24+ history *vt.History
25+ syncGen uint64
26+ rect domain.Rect
27+ // resizeApplying gates VT parsing across PTY.Resize. The reader continues
28+ // draining into resizePending so output is replayed against the target (or
29+ // retained old) screen only after apply resolves.
30+ resizeApplying bool
31+ resizePending []byte
32+ ptyResponses []byte
33+ ptyClipboards []string
34+ ptyAttention bool
3035 popupGeometry floatingGeometry // last geometry committed after a successful floating resize
3136 title paneTitleState
3237 ctx context.Context
@@ -42,25 +47,14 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
4247 }
4348
4449 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
45- sb := scopy.NewScrollback(defaultScrollbackRows)
46- screen := vt.NewScreen(sz.Cols, sz.Rows)
47- screen.OnLineEvicted = sb.Append
50+ screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows})
4851 return &pane{
49- id: id,
50- stableID: stableID,
51- pty: pty,
52- screen: screen,
53- scrollback: sb,
54- dirty: make(chan struct{}, 1),
55- flush: make(chan struct{}, 1),
56- rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
57- title: paneTitleState{displayFallback: "sh"},
52+ id: id,
53+ stableID: stableID,
54+ pty: pty,
55+ screen: screen,
56+ history: screen.History(),
57+ rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
58+ title: paneTitleState{displayFallback: "sh"},
5859 }
5960 }
60-
61-func paneDone(p *pane) <-chan struct{} {
62- if p.ctx == nil {
63- return nil
64- }
65- return p.ctx.Done()
66-}
M internal/usecase/daemon/pane_actions.go +15 -11
@@ -23,7 +23,7 @@ func (d *Daemon) spawnPaneOp(
2323 ) error {
2424 if d.ptys == nil {
2525 if ac != nil {
26- d.paint(sess, ac, true)
26+ d.invalidateRender(sess, ac, true, "pane_actions.go")
2727 }
2828 return nil
2929 }
@@ -69,7 +69,7 @@ func (d *Daemon) spawnPaneOp(
6969 tb.mu.Unlock()
7070 return fmt.Errorf("daemon: generating pane identity: %w", err)
7171 }
72- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
72+ pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
7373 if err != nil {
7474 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
7575 tb.mu.Lock()
@@ -99,7 +99,7 @@ func (d *Daemon) spawnPaneOp(
9999 d.startPaneGoroutines(sess, tb, p)
100100 markSnapshotDirty(sess)
101101 if ac != nil {
102- d.paint(sess, ac, true)
102+ d.invalidateRender(sess, ac, true, "pane_actions.go")
103103 }
104104 return nil
105105 }
@@ -111,7 +111,7 @@ func (d *Daemon) applyLayoutLocked(tb *tab) {
111111 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
112112 if !ok {
113113 for _, p := range tb.panes {
114- d.resizePane(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
114+ d.applyPaneResize(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
115115 }
116116 return
117117 }
@@ -120,12 +120,12 @@ func (d *Daemon) applyLayoutLocked(tb *tab) {
120120 continue
121121 }
122122 if p := tb.panes[pl.ID]; p != nil {
123- d.resizePane(p, pl.Content)
123+ d.applyPaneResize(p, pl.Content)
124124 }
125125 }
126126 }
127127
128-func (d *Daemon) resizePane(p *pane, r domain.Rect) {
128+func (d *Daemon) applyPaneResize(p *pane, r domain.Rect) {
129129 if p == nil {
130130 return
131131 }
@@ -167,7 +167,7 @@ func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
167167 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
168168 if d.ptys == nil {
169169 if ac != nil {
170- d.paint(sess, ac, true)
170+ d.invalidateRender(sess, ac, true, "pane_actions.go")
171171 }
172172 return nil
173173 }
@@ -188,7 +188,7 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
188188 if err == nil {
189189 markSnapshotDirty(sess)
190190 if ac != nil {
191- d.paint(sess, ac, true)
191+ d.invalidateRender(sess, ac, true, "pane_actions.go")
192192 }
193193 }
194194 return err
@@ -197,7 +197,7 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
197197 func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
198198 if d.ptys == nil {
199199 if ac != nil {
200- d.paint(sess, ac, true)
200+ d.invalidateRender(sess, ac, true, "pane_actions.go")
201201 }
202202 return nil
203203 }
@@ -255,8 +255,12 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
255255
256256 if ac != nil {
257257 ac.overlays.clearCopyModeForPane(p)
258+ ac.pruneCaptureFrames(p)
258259 }
259260
261+ if rc := sess.renderCoordinator(); rc != nil {
262+ rc.noteSyncPaneRemoved(p)
263+ }
260264 if p.cancel != nil {
261265 p.cancel()
262266 }
@@ -267,7 +271,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
267271 markSnapshotDirty(sess)
268272 if repaint {
269273 if ac != nil {
270- d.paint(sess, ac, true)
274+ d.invalidateRender(sess, ac, true, "pane_actions.go")
271275 }
272276 }
273277 return nil
@@ -305,7 +309,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
305309 d.refreshPaneTitleOnFocus(sess, newFocus)
306310 }
307311 }
308- d.paint(sess, ac, true)
312+ d.invalidateRender(sess, ac, true, "pane_actions.go")
309313 }
310314 if errors.Is(err, layout.ErrNoPane) {
311315 return nil
M internal/usecase/daemon/pane_actions_test.go +9 -9
@@ -39,7 +39,7 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
3939 newPTY := portsmocks.NewMockPTY(t)
4040 oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once()
4141 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
42- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once()
42+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once()
4343
4444 require.NoError(t, d.splitPane(sess, nil, tt.dir))
4545
@@ -68,7 +68,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
6868 newPTY := portsmocks.NewMockPTY(t)
6969 stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
7070 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
71- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
71+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
7272
7373 require.NoError(t, d.splitPane(sess, nil, layout.Right))
7474
@@ -84,7 +84,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
8484
8585 func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
8686 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
87- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once()
87+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once()
8888
8989 tb := sess.activeTab()
9090 tb.mu.Lock()
@@ -231,7 +231,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
231231 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})
232232 newPTY := portsmocks.NewMockPTY(t)
233233 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
234- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once()
234+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once()
235235
236236 require.NoError(t, d.stackPane(sess, nil))
237237 tb := sess.activeTab()
@@ -251,7 +251,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
251251 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})
252252 newPTY := portsmocks.NewMockPTY(t)
253253 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
254- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once()
254+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once()
255255 require.NoError(t, d.stackPane(sess, nil))
256256 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once()
257257
@@ -261,7 +261,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
261261 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
262262 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)
263263 thirdPTY := portsmocks.NewMockPTY(t)
264- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe()
264+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe()
265265 require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall)
266266 require.Len(t, tb.panes, 2)
267267 }
@@ -291,7 +291,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
291291 defer releaseNew()
292292 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
293293 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
294- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
294+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
295295
296296 require.NoError(t, d.splitPane(sess, nil, layout.Right))
297297 tb := sess.activeTab()
@@ -350,8 +350,8 @@ func TestSplitPaneInheritsTerminalEnv(t *testing.T) {
350350 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
351351 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
352352 var gotEnv []string
353- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(
354- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
353+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(
354+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
355355 gotEnv = append([]string(nil), env...)
356356 return newPTY, nil
357357 },
M internal/usecase/daemon/performance_benchmark_test.go +650 -54
@@ -5,6 +5,7 @@ import (
55 "io"
66 "sync"
77 "testing"
8+ "time"
89
910 "github.com/stretchr/testify/require"
1011
@@ -49,8 +50,15 @@ func TestPerformanceFixtureCounters(t *testing.T) {
4950 fixture.paintLive()
5051 metrics := fixture.metrics()
5152 require.Equal(t, uint64(2), metrics.outputFrames)
53+ require.Equal(t, uint64(2), metrics.renderCaptures, "capture is one immutable state snapshot per live render request")
54+ require.Equal(t, uint64(2), metrics.renderCompositions, "composition is one frame built from each captured state")
55+ require.Equal(t, uint64(2), metrics.renderEmissions, "emission is one accepted output frame")
5256 require.Positive(t, metrics.outputBytes)
5357 require.Positive(t, metrics.outputPayloadBytes)
58+ require.Equal(t, uint64(2), metrics.coordinatorInvalidations)
59+ require.Equal(t, uint64(2), metrics.coordinatorWakes)
60+ require.Equal(t, uint64(2), metrics.coordinatorCoalesced)
61+ require.Positive(t, metrics.coordinatorWakes, "coordinator metric ratios require a nonzero denominator")
5462
5563 fixture.captureSnapshot()
5664 metrics = fixture.metrics()
@@ -64,14 +72,70 @@ func TestPerformanceFixtureCounters(t *testing.T) {
6472 require.True(t, fixture.resized())
6573 }
6674
67-func TestPerformanceFixtureResizeAlternatesRealDimensions(t *testing.T) {
68- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}})
75+func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) {
76+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
77+ d.paint(sess, ac, true, nil)
78+ _ = awaitFrame(t, sends, ports.MsgOutput)
79+ var captures, compositions, emissions int
80+ ac.renderStages = renderStageHooks{
81+ capture: func() { captures++ },
82+ compose: func() { compositions++ },
83+ emit: func() { emissions++ },
84+ }
85+ ac.replaceTransport(cacheFailTransport{})
86+ p := sess.tabs[0].focusedPane()
87+ p.mu.Lock()
88+ p.screen.Write([]byte("hook"))
89+ p.mu.Unlock()
90+ d.paint(sess, ac, false, nil)
91+ require.Equal(t, 1, captures, "a completed capture counts even when its emission fails")
92+ require.Equal(t, 1, compositions, "a completed composition counts even when its emission fails")
93+ require.Zero(t, emissions, "emit counts only prepare and transport success")
94+}
95+
96+func TestPerformanceFixtureResize(t *testing.T) {
97+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}})
98+ sequence := []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}, {Cols: 160, Rows: 50}, {Cols: 80, Rows: 24}}
6999
70- for _, want := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}} {
71- fixture.resize()
100+ for _, want := range sequence {
101+ fixture.resizeTo(want)
72102 require.Equal(t, want, fixture.ac.size)
73103 require.True(t, fixture.resized())
74104 }
105+ metrics := fixture.metrics()
106+ require.Equal(t, uint64(len(sequence)), metrics.resizeRequests)
107+ require.Equal(t, uint64(len(sequence)), metrics.resizeCommits)
108+ require.Equal(t, uint64(len(sequence)), metrics.outputFrames, "one full frame per accepted epoch")
109+ require.Zero(t, metrics.skippedEpochs)
110+ require.Zero(t, metrics.ptyFailures)
111+ require.Zero(t, metrics.frameGapEpochs)
112+ require.NotZero(t, metrics.resizeCommitNanos)
113+ require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 60, Rows: 38}, {Cols: 80, Rows: 48}, {Cols: 40, Rows: 22}}, fixture.pty.requested())
114+}
115+
116+func TestTransactionalResizeMetricDefinitionsAreZeroSafe(t *testing.T) {
117+ metrics := performanceMetrics{}
118+ require.Zero(t, coordinatorCoalescingRatio(metrics))
119+ require.Zero(t, metrics.skippedEpochs, "no accepted request means no skipped epoch")
120+ require.Zero(t, metrics.frameGapEpochs, "no committed epoch means no frame gap")
121+}
122+
123+func TestTransactionalResizeMetrics(t *testing.T) {
124+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, ptyErrors: []error{fmt.Errorf("scripted failure"), nil}})
125+ fixture.resizeTo(domain.Size{Cols: 100, Rows: 30})
126+ // The fixture deliberately invokes the coordinator's committed retry path:
127+ // a retry never republishes geometry and its reset produces one extra frame.
128+ fixture.retryLatest()
129+
130+ metrics := fixture.metrics()
131+ require.Equal(t, uint64(1), metrics.resizeRequests)
132+ require.Equal(t, uint64(1), metrics.resizeCommits)
133+ require.Equal(t, uint64(1), metrics.ptyFailures)
134+ require.Equal(t, uint64(1), metrics.ptyRetries)
135+ require.Equal(t, uint64(2), metrics.outputFrames, "commit plus successful full-reset retry")
136+ require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame")
137+ require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 50, Rows: 28}}, fixture.pty.requested())
138+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, fixture.ac.size)
75139 }
76140
77141 func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
@@ -81,45 +145,137 @@ func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
81145 for _, tb := range fixture.sess.tabs {
82146 require.Len(t, tb.panes, 4)
83147 for _, p := range tb.panes {
84- require.Equal(t, 10_000, p.scrollback.Len())
148+ require.Equal(t, 10_000, p.history.Len())
85149 }
86150 }
87151 }
88152
153+func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) {
154+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000})
155+
156+ first, ok := fixture.d.captureSnapshotState(fixture.sess, 1)
157+ require.True(t, ok)
158+ require.Len(t, first.tabs, 1)
159+ require.Len(t, first.tabs[0].panes, 2)
160+ require.Equal(t, 10_000, first.tabs[0].panes[0].history.Len())
161+ require.Equal(t, 40, first.tabs[0].panes[0].history.ChunkCount())
162+
163+ second, ok := fixture.d.captureSnapshotState(fixture.sess, 2)
164+ require.True(t, ok)
165+ secondPane := capturePaneByID(second, first.tabs[0].panes[0].id)
166+ if secondPane == nil || first.tabs[0].panes[0].history.Chunk(0) != secondPane.history.Chunk(0) {
167+ t.Fatal("unchanged sealed history chunk was not reused across captures")
168+ }
169+}
170+
171+func TestPerformanceFixtureCoordinatorMetricsForCanonicalTopologies(t *testing.T) {
172+ for _, topology := range daemonHistoryTopologies {
173+ t.Run(topology.name, func(t *testing.T) {
174+ fixture := newPerformanceFixture(t, performanceConfig{
175+ size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,
176+ })
177+ fixture.paintLive()
178+ metrics := fixture.metrics()
179+ require.Equal(t, uint64(1), metrics.coordinatorInvalidations)
180+ require.Equal(t, uint64(1), metrics.coordinatorWakes)
181+ require.Equal(t, uint64(1), metrics.coordinatorCoalesced)
182+ require.Positive(t, metrics.coordinatorWakes, "coalescing ratio denominator")
183+ })
184+ }
185+}
186+
187+type daemonHistoryTopology struct {
188+ name string
189+ tabs, panes int
190+}
191+
192+var daemonHistoryTopologies = []daemonHistoryTopology{
193+ {name: "1tab-1pane-control", tabs: 1, panes: 1},
194+ {name: "1tab-4panes", tabs: 1, panes: 4},
195+ {name: "4tabs-1pane", tabs: 4, panes: 1},
196+ {name: "4tabs-4panes", tabs: 4, panes: 4},
197+ {name: "8tabs-1pane", tabs: 8, panes: 1},
198+}
199+
200+// v3 bounds a decoded snapshot to 256 MiB. One 120-column 10k-row tab is
201+// representative and valid; multi-tab fixtures exceed that durable budget, so
202+// snapshot capture/encode benchmark only the valid one-tab topologies.
203+var daemonSnapshotTopologies = []daemonHistoryTopology{
204+ {name: "1tab-1pane-control", tabs: 1, panes: 1},
205+ {name: "1tab-4panes", tabs: 1, panes: 4},
206+}
207+
89208 func BenchmarkDaemonHistoryLivePaint(b *testing.B) {
90209 benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() })
91210 }
211+
212+// BenchmarkDaemonHistorySnapshotCapture measures the synchronous immutable
213+// capture boundary only. Encoding and the bounded worker queue are deliberately
214+// excluded so scheduler latency cannot affect this benchmark.
92215 func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) {
93- benchmarkDaemonLargeHistory(b, "snapshot-capture", func(f *performanceFixture) { f.captureSnapshot() })
216+ benchmarkDaemonSnapshotCapture(b)
217+}
218+
219+// BenchmarkDaemonHistorySnapshotEncode measures v3 encoding from an already
220+// immutable capture, without queueing or persistence.
221+func BenchmarkDaemonHistorySnapshotEncode(b *testing.B) {
222+ benchmarkDaemonSnapshotEncode(b)
223+}
224+
225+// BenchmarkDaemonHistorySnapshotRestore measures terminal-state restoration
226+// from a validated v3 pane manifest without spawning PTY or session workers.
227+func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) {
228+ benchmarkDaemonSnapshotRestore(b)
94229 }
230+
231+// BenchmarkDaemonHistoryCopyEnter uses the parent benchmark's repeated-entry
232+// semantics: each operation replaces the active copy document. Exit cleanup is
233+// intentionally outside this benchmark, so its allocations cannot be reported
234+// as copy-entry work.
95235 func BenchmarkDaemonHistoryCopyEnter(b *testing.B) {
96236 benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) })
97237 }
98238 func BenchmarkDaemonHistoryCopySearch(b *testing.B) {
99239 benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) })
100240 }
101-func BenchmarkDaemonHistoryResize(b *testing.B) {
102- benchmarkDaemonLargeHistory(b, "resize", func(f *performanceFixture) { f.resize() })
241+
242+// BenchmarkDaemonHistoryResizeSweep alternates deterministic grow/shrink
243+// geometry. Metrics are fixture counters; no benchmark result depends on wall
244+// time or scheduler delivery.
245+func BenchmarkDaemonHistoryResizeSweep(b *testing.B) {
246+ sequence := []domain.Size{{Cols: 80, Rows: 24}, {Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}, {Cols: 160, Rows: 50}, {Cols: 120, Rows: 40}, {Cols: 100, Rows: 30}}
247+ benchmarkDaemonLargeHistory(b, "resize-sweep", func(f *performanceFixture) {
248+ f.resizeTo(sequence[f.resizes%len(sequence)])
249+ })
250+}
251+
252+// BenchmarkDaemonHistoryResizeRetry measures one deterministic failed apply
253+// followed by its successful full-reset retry for each operation.
254+func BenchmarkDaemonHistoryResizeRetry(b *testing.B) {
255+ benchmarkDaemonLargeHistory(b, "resize-retry", func(f *performanceFixture) {
256+ f.pty.setErrors([]error{fmt.Errorf("scripted resize failure"), nil})
257+ size := domain.Size{Cols: 100, Rows: 30}
258+ if f.resizes%2 != 0 {
259+ size = domain.Size{Cols: 120, Rows: 40}
260+ }
261+ f.resizeTo(size)
262+ f.retryLatest()
263+ })
103264 }
104265
105266 func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) {
106267 b.Helper()
107- for _, topology := range []struct {
108- name string
109- tabs, panes int
110- }{
111- {name: "1tab-1pane-control", tabs: 1, panes: 1},
112- {name: "1tab-4panes", tabs: 1, panes: 4},
113- {name: "4tabs-1pane", tabs: 4, panes: 1},
114- {name: "4tabs-4panes", tabs: 4, panes: 4},
115- {name: "8tabs-1pane", tabs: 8, panes: 1},
116- } {
268+ for _, topology := range daemonHistoryTopologies {
117269 b.Run(topology.name, func(b *testing.B) {
118270 fixture := newPerformanceFixture(b, performanceConfig{
119271 size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,
120272 })
273+ if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {
274+ b.Fatal("invalid daemon history fixture")
275+ }
121276 if workload == "copy-search" {
122- fixture.d.enterCopyMode(fixture.sess, fixture.ac)
277+ benchmarkDaemonCopySearch(b, fixture, run)
278+ return
123279 }
124280 b.ReportAllocs()
125281 fixture.resetMetrics()
@@ -133,26 +289,180 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
133289 b.Fatalf("decode last output: %v", err)
134290 }
135291 }
136- if workload == "snapshot-capture" {
137- payload := fixture.snaps.lastPayload()
138- if payload == nil {
139- b.Fatal("snapshot capture retained no snapshot payload")
140- }
141- if _, err := snapcodec.Unmarshal(payload); err != nil {
142- b.Fatalf("decode last snapshot: %v", err)
143- }
144- }
145292 if workload == "live-paint" && metrics.outputFrames != uint64(b.N) {
146293 b.Fatalf("live paint emitted %d frames for %d operations", metrics.outputFrames, b.N)
147294 }
148- if workload == "snapshot-capture" && metrics.snapshotWrites != uint64(b.N) {
149- b.Fatalf("snapshot capture wrote %d snapshots for %d operations", metrics.snapshotWrites, b.N)
150- }
151295 benchmarkReportMetrics(b, metrics, b.N, 10_000)
152296 })
153297 }
154298 }
155299
300+func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) {
301+ b.Helper()
302+ fixture.d.enterCopyMode(fixture.sess, fixture.ac)
303+ fixture.ac.ackOutputState(fixture.ac.output.next)
304+ b.ReportAllocs()
305+ b.ResetTimer()
306+ for range b.N {
307+ run(fixture)
308+ fixture.ac.ackOutputState(fixture.ac.output.next)
309+ }
310+ b.StopTimer()
311+ if fixture.searchMatches() == 0 {
312+ b.Fatal("copy search produced no deterministic matches")
313+ }
314+ b.ReportMetric(1, "copyoperations/op")
315+}
316+
317+func benchmarkDaemonSnapshotCapture(b *testing.B) {
318+ for _, topology := range daemonSnapshotTopologies {
319+ b.Run(topology.name, func(b *testing.B) {
320+ fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000})
321+ if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {
322+ b.Fatal("invalid daemon snapshot capture fixture")
323+ }
324+ first, ok := fixture.d.captureSnapshotState(fixture.sess, 1)
325+ if !ok || !validBenchmarkCapture(first) {
326+ b.Fatal("invalid snapshot capture fixture")
327+ }
328+ firstPane := first.tabs[0].panes[0]
329+ firstChunk := firstPane.history.Chunk(0)
330+ captures := 0
331+ var last *snapshotCapture
332+ b.ReportAllocs()
333+ b.ResetTimer()
334+ for range b.N {
335+ last, ok = fixture.d.captureSnapshotState(fixture.sess, uint64(captures+2))
336+ if !ok {
337+ b.Fatal("snapshot capture unexpectedly rejected fixture")
338+ }
339+ captures++
340+ }
341+ b.StopTimer()
342+ lastPane := capturePaneByID(last, firstPane.id)
343+ if captures != b.N || !validBenchmarkCapture(last) || lastPane == nil || lastPane.history.Chunk(0) != firstChunk {
344+ b.Fatal("snapshot capture did not preserve immutable sealed history")
345+ }
346+ b.ReportMetric(float64(capturePaneCount(last)), "capturepanes/op")
347+ })
348+ }
349+}
350+
351+func benchmarkDaemonSnapshotEncode(b *testing.B) {
352+ for _, topology := range daemonSnapshotTopologies {
353+ b.Run(topology.name, func(b *testing.B) {
354+ fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000})
355+ if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {
356+ b.Fatal("invalid daemon snapshot encode fixture")
357+ }
358+ capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1)
359+ if !ok || !validBenchmarkCapture(capture) {
360+ b.Fatal("invalid snapshot encode fixture")
361+ }
362+ encoded, err := fixture.d.encodeSnapshotCapture(capture)
363+ if err != nil {
364+ b.Fatal(err)
365+ }
366+ if _, err := snapcodec.Unmarshal(encoded); err != nil {
367+ b.Fatalf("invalid v3 snapshot encode fixture: %v", err)
368+ }
369+ var last []byte
370+ b.ReportAllocs()
371+ b.SetBytes(int64(len(encoded)))
372+ b.ResetTimer()
373+ for range b.N {
374+ last, err = fixture.d.encodeSnapshotCapture(capture)
375+ if err != nil {
376+ b.Fatal(err)
377+ }
378+ }
379+ b.StopTimer()
380+ if _, err := snapcodec.Unmarshal(last); err != nil {
381+ b.Fatalf("snapshot encoder produced invalid v3 data: %v", err)
382+ }
383+ b.ReportMetric(float64(len(last)), "snapshotbytes/op")
384+ })
385+ }
386+}
387+
388+func benchmarkDaemonSnapshotRestore(b *testing.B) {
389+ fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})
390+ if !fixture.hasHistoryTopology(1, 1, 10_000) {
391+ b.Fatal("invalid daemon snapshot restore fixture")
392+ }
393+ capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1)
394+ if !ok || !validBenchmarkCapture(capture) {
395+ b.Fatal("invalid snapshot restore fixture")
396+ }
397+ encoded, err := fixture.d.encodeSnapshotCapture(capture)
398+ if err != nil {
399+ b.Fatal(err)
400+ }
401+ snapshot, err := snapcodec.Unmarshal(encoded)
402+ if err != nil || len(snapshot.Tabs) != 1 || len(snapshot.Tabs[0].Panes) == 0 {
403+ b.Fatalf("invalid v3 snapshot restore fixture: %v", err)
404+ }
405+ paneSnapshot := snapshot.Tabs[0].Panes[0]
406+ b.ReportAllocs()
407+ b.SetBytes(int64(len(encoded)))
408+ b.ResetTimer()
409+ for range b.N {
410+ if err := restorePaneTerminal(fixture.activePane, paneSnapshot); err != nil {
411+ b.Fatal(err)
412+ }
413+ }
414+ b.StopTimer()
415+ fixture.activePane.mu.Lock()
416+ restoredRows := fixture.activePane.history.Len()
417+ fixture.activePane.mu.Unlock()
418+ if restoredRows != 10_000 {
419+ b.Fatalf("snapshot restore retained %d history rows, want 10000", restoredRows)
420+ }
421+ b.ReportMetric(float64(restoredRows), "historyrows/restore")
422+}
423+
424+func validBenchmarkCapture(capture *snapshotCapture) bool {
425+ return capture != nil && len(capture.tabs) > 0 && len(capture.tabs[0].panes) > 0 && capture.tabs[0].panes[0].history.Len() == 10_000 && capture.tabs[0].panes[0].history.ChunkCount() > 0
426+}
427+
428+func capturePaneCount(capture *snapshotCapture) int {
429+ count := 0
430+ for _, tab := range capture.tabs {
431+ count += len(tab.panes)
432+ }
433+ return count
434+}
435+
436+func capturePaneByID(capture *snapshotCapture, id layout.PaneID) *snapshotCapturePane {
437+ if capture == nil {
438+ return nil
439+ }
440+ for tabIndex := range capture.tabs {
441+ for paneIndex := range capture.tabs[tabIndex].panes {
442+ pane := &capture.tabs[tabIndex].panes[paneIndex]
443+ if pane.id == id {
444+ return pane
445+ }
446+ }
447+ }
448+ return nil
449+}
450+
451+func TestCoordinatorCoalescingRatioReportsInvalidationsPerWake(t *testing.T) {
452+ require.Equal(t, float64(3), coordinatorCoalescingRatio(performanceMetrics{
453+ coordinatorWakes: 1,
454+ coordinatorCoalesced: 3,
455+ }))
456+ require.Zero(t, coordinatorCoalescingRatio(performanceMetrics{}))
457+}
458+
459+func coordinatorCoalescingRatio(metrics performanceMetrics) float64 {
460+ if metrics.coordinatorWakes == 0 {
461+ return 0
462+ }
463+ return float64(metrics.coordinatorCoalesced) / float64(metrics.coordinatorWakes)
464+}
465+
156466 func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations, historyRows int) {
157467 b.Helper()
158468 if operations == 0 {
@@ -164,9 +474,89 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
164474 b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op")
165475 b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op")
166476 b.ReportMetric(float64(metrics.snapshotBytes)/perOperation, "snapshotbytes/op")
477+ b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op")
478+ b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op")
479+ b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op")
480+ // Split counters have precise fixture definitions: capture is an immutable
481+ // state snapshot requested for a live paint; composition builds the frame
482+ // from that capture; emission accepts its output frame. Keep bytes/frames
483+ // above as transport observables rather than folding them into these rates.
484+ b.ReportMetric(float64(metrics.renderCaptures)/perOperation, "rendercaptures/op")
485+ b.ReportMetric(float64(metrics.renderCompositions)/perOperation, "rendercompositions/op")
486+ b.ReportMetric(float64(metrics.renderEmissions)/perOperation, "renderemissions/op")
487+ // resizecommitns is the injected-clock elapsed time from accepted request
488+ // to committed epoch. The remaining values are counter deltas: skipped is
489+ // accepted minus committed (never negative), framegap is committed epochs
490+ // lacking their required output frame (never negative), and retries/failures
491+ // are PTY Resize outcomes. All divisions are guarded above.
492+ b.ReportMetric(float64(metrics.resizeCommitNanos)/perOperation, "resizecommitns/op")
493+ b.ReportMetric(float64(metrics.skippedEpochs)/perOperation, "skippedepochs/op")
494+ b.ReportMetric(float64(metrics.ptyFailures)/perOperation, "ptyfailures/op")
495+ b.ReportMetric(float64(metrics.ptyRetries)/perOperation, "ptyretries/op")
496+ b.ReportMetric(float64(metrics.frameGapEpochs)/perOperation, "framegapepochs/op")
497+ b.ReportMetric(coordinatorCoalescingRatio(metrics), "coordinatorcoalescingratio")
167498 b.ReportMetric(float64(historyRows), "historyrows/pane")
168499 }
169500
501+// Root-cause hypothesis (verified by this guard before the fix): S2 allocates
502+// one visible VT-frame copy during capture plus a new composed frame and its
503+// base-frame clone for every live paint. Those client-sized copies, rather
504+// than history, account for the ~1 MiB/op regression in the pinned benchmark.
505+func TestLivePaintAllocationBudget(t *testing.T) {
506+ for _, tt := range []struct {
507+ name string
508+ panes int
509+ }{
510+ {name: "1tab-1pane", panes: 1},
511+ {name: "1tab-4panes", panes: 4},
512+ } {
513+ t.Run(tt.name, func(t *testing.T) {
514+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000})
515+ allocs := testing.AllocsPerRun(20, func() {
516+ fixture.paintLive()
517+ fixture.ac.ackOutputState(fixture.ac.output.next)
518+ })
519+ require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints")
520+ })
521+ }
522+}
523+
524+// TestCopyEnterAllocationBudget protects the parent baseline plus 10%. Copy
525+// rendering borrows sealed VT history rows; allocating a copy per viewport row
526+// is a production regression even though the capture itself remains immutable.
527+func TestCopyEnterAllocationBudget(t *testing.T) {
528+ for _, tt := range []struct {
529+ name string
530+ tabs, panes, max int
531+ }{
532+ {name: "1tab-1pane", tabs: 1, panes: 1, max: 38},
533+ {name: "1tab-4panes", tabs: 1, panes: 4, max: 43},
534+ {name: "4tabs-1pane", tabs: 4, panes: 1, max: 42},
535+ {name: "4tabs-4panes", tabs: 4, panes: 4, max: 48},
536+ {name: "8tabs-1pane", tabs: 8, panes: 1, max: 50},
537+ } {
538+ t.Run(tt.name, func(t *testing.T) {
539+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})
540+ run := func() {
541+ fixture.d.enterCopyMode(fixture.sess, fixture.ac)
542+ fixture.ac.ackOutputState(fixture.ac.output.next)
543+ }
544+
545+ // Always exercise and validate copy entry. The race detector adds
546+ // instrumentation allocations which are not reported by the parent
547+ // benchmark, so its allocation count cannot represent this budget.
548+ run()
549+ require.True(t, fixture.copyModeActive(), "copy entry must install a history-backed mode")
550+ if !copyEnterAllocationBudgetEnabled {
551+ return
552+ }
553+
554+ allocs := testing.AllocsPerRun(20, run)
555+ require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10%% of the parent allocation baseline")
556+ })
557+ }
558+}
559+
170560 func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {
171561 fixture := newPerformanceFixture(t, performanceConfig{})
172562
@@ -189,29 +579,119 @@ type performanceConfig struct {
189579 tabs int
190580 panes int
191581 historyRows int
582+ ptyErrors []error
192583 }
193584
194585 type performanceMetrics struct {
195- outputFrames uint64
196- outputBytes uint64
197- outputPayloadBytes uint64
198- snapshotWrites uint64
199- snapshotBytes uint64
586+ outputFrames uint64
587+ outputBytes uint64
588+ outputPayloadBytes uint64
589+ snapshotWrites uint64
590+ snapshotBytes uint64
591+ coordinatorInvalidations uint64
592+ coordinatorWakes uint64
593+ coordinatorCoalesced uint64
594+ renderCaptures uint64
595+ renderCompositions uint64
596+ renderEmissions uint64
597+ resizeRequests uint64
598+ resizeCommits uint64
599+ resizeCommitNanos uint64
600+ skippedEpochs uint64
601+ ptyFailures uint64
602+ ptyRetries uint64
603+ frameGapEpochs uint64
604+}
605+
606+// performanceClock makes transition latency a reproducible counter rather
607+// than a wall-clock observation. A nil timer channel deliberately executes
608+// coordinator work synchronously.
609+type performanceClock struct {
610+ mu sync.Mutex
611+ now time.Time
612+}
613+
614+func (c *performanceClock) Now() time.Time {
615+ c.mu.Lock()
616+ defer c.mu.Unlock()
617+ c.now = c.now.Add(time.Microsecond)
618+ return c.now
619+}
620+func (*performanceClock) NewTimer(time.Duration) ports.Timer { return performanceTimer{} }
621+
622+type performanceTimer struct{}
623+
624+func (performanceTimer) C() <-chan time.Time { return nil }
625+func (performanceTimer) Reset(time.Duration) bool { return true }
626+func (performanceTimer) Stop() bool { return true }
627+
628+// scriptedPerformancePTY records every requested terminal size and returns a
629+// caller-provided error script. It has no reads or scheduling side effects.
630+type scriptedPerformancePTY struct {
631+ mu sync.Mutex
632+ sizes []domain.Size
633+ errs []error
634+ fails uint64
635+}
636+
637+func (p *scriptedPerformancePTY) Resize(size domain.Size) error {
638+ p.mu.Lock()
639+ defer p.mu.Unlock()
640+ p.sizes = append(p.sizes, size)
641+ index := len(p.sizes) - 1
642+ if index < len(p.errs) && p.errs[index] != nil {
643+ p.fails++
644+ return p.errs[index]
645+ }
646+ return nil
647+}
648+func (*scriptedPerformancePTY) Read([]byte) (int, error) { return 0, io.EOF }
649+func (*scriptedPerformancePTY) Write(b []byte) (int, error) { return len(b), nil }
650+func (*scriptedPerformancePTY) Close() error { return nil }
651+func (*scriptedPerformancePTY) Pid() int { return 0 }
652+func (*scriptedPerformancePTY) ForegroundPgid() (int, error) { return 0, nil }
653+func (p *scriptedPerformancePTY) requested() []domain.Size {
654+ p.mu.Lock()
655+ defer p.mu.Unlock()
656+ return append([]domain.Size(nil), p.sizes...)
657+}
658+func (p *scriptedPerformancePTY) metrics() (uint64, uint64) {
659+ p.mu.Lock()
660+ defer p.mu.Unlock()
661+ return p.fails, uint64(len(p.sizes))
662+}
663+func (p *scriptedPerformancePTY) setErrors(errs []error) {
664+ p.mu.Lock()
665+ defer p.mu.Unlock()
666+ p.errs = append([]error(nil), errs...)
667+ p.sizes = nil
668+ p.fails = 0
200669 }
201670
202671 type performanceFixture struct {
203- t testing.TB
204- d *Daemon
205- sess *session
206- ac *attachedClient
207- output *countingOutputTransport
208- snaps *countingSnapshotStore
209- activePane *pane
210- liveWrites [][]byte
211- paints int
212- resizeSizes [2]domain.Size
213- resizes int
214- resizedSize domain.Size
672+ t testing.TB
673+ d *Daemon
674+ sess *session
675+ ac *attachedClient
676+ output *countingOutputTransport
677+ snaps *countingSnapshotStore
678+ activePane *pane
679+ liveWrites [][]byte
680+ paints int
681+ resizeSizes [2]domain.Size
682+ resizes int
683+ resizedSize domain.Size
684+ pty *scriptedPerformancePTY
685+ clock *performanceClock
686+ resizeRequests uint64
687+ resizeCommits uint64
688+ resizeCommitNanos uint64
689+ ptyFailures uint64
690+ ptyRetries uint64
691+ coordinatorBaseline renderCoordinatorBurstMetricsSnapshot
692+ renderCaptures uint64
693+ renderCompositions uint64
694+ renderEmissions uint64
215695 }
216696
217697 func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {
@@ -228,8 +708,12 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
228708 if config.historyRows == 0 {
229709 config.historyRows = 1
230710 }
711+ pty := &scriptedPerformancePTY{}
231712 ptys := make([]ports.PTY, config.tabs)
713+ ptys[0] = pty
232714 d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...)
715+ clock := &performanceClock{}
716+ d.clock = clock
233717 output := &countingOutputTransport{}
234718 ac.tr = output
235719 ac.size = config.size
@@ -244,18 +728,34 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
244728 ac: ac,
245729 output: output,
246730 snaps: &countingSnapshotStore{},
731+ pty: pty,
732+ clock: clock,
247733 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
248734 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
249735 }
250736 WithSnapshotStore(fixture.snaps)(d)
737+ d.startSnapshotEncodeWorker()
738+ t.Cleanup(d.stopSnapshotEncodeWorker)
251739 for tabIndex, tb := range sess.tabs {
252740 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))
253741 }
254742 fixture.activePane = fixture.findActivePane()
743+ // Fixture construction may solve the initial layout; benchmark scripts begin
744+ // after that setup work and therefore have an empty call history.
745+ pty.setErrors(config.ptyErrors)
746+
747+ // The fixture uses the production coordinator. performanceClock's nil timer
748+ // channel completes the coordinator path synchronously and deterministically.
749+ ac.renderStages = renderStageHooks{
750+ capture: func() { fixture.renderCaptures++ },
751+ compose: func() { fixture.renderCompositions++ },
752+ emit: func() { fixture.renderEmissions++ },
753+ }
754+ d.attachCoordinator(sess, nil, ac, true)
255755
256756 // Prime the real renderer shadow before measurements. Subsequent paints use
257757 // actual production diffs rather than the initial full frame.
258- d.paint(sess, ac, true)
758+ d.paint(sess, ac, true, nil)
259759 fixture.resetMetrics()
260760 return fixture
261761 }
@@ -278,7 +778,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR
278778 for _, p := range tb.panes {
279779 p.mu.Lock()
280780 width := p.screen.Frame.Width
281- for row := 0; p.scrollback.Len() < historyRows; row++ {
781+ for row := 0; p.history.Len() < historyRows; row++ {
282782 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
283783 }
284784 p.mu.Unlock()
@@ -311,13 +811,20 @@ func (f *performanceFixture) paintLive() {
311811 p.mu.Lock()
312812 p.screen.Write(f.liveWrites[f.paints%len(f.liveWrites)])
313813 p.mu.Unlock()
314- f.d.paint(f.sess, f.ac, false)
814+ if rc := f.sess.renderCoordinator(); rc != nil {
815+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "performance_benchmark_test.go"})
816+ } else {
817+ f.d.paint(f.sess, f.ac, false, nil)
818+ }
315819 f.paints++
316820 }
317821
318822 func (f *performanceFixture) captureSnapshot() {
319823 f.t.Helper()
320824 require.True(f.t, f.d.captureSession(f.sess))
825+ // Worker finalization clears snapshotPending and signals snapshotChanged on
826+ // every success and failure path after persistence has returned.
827+ awaitSnapshotIdle(f.t, f.sess)
321828 }
322829
323830 func (f *performanceFixture) enterCopySearch() {
@@ -326,6 +833,30 @@ func (f *performanceFixture) enterCopySearch() {
326833 f.d.handleInput(f.sess, f.ac, []byte("/needle\r"))
327834 }
328835
836+func (f *performanceFixture) hasHistoryTopology(tabs, panes, historyRows int) bool {
837+ if len(f.sess.tabs) != tabs {
838+ return false
839+ }
840+ for _, tb := range f.sess.tabs {
841+ tb.mu.Lock()
842+ if len(tb.panes) != panes {
843+ tb.mu.Unlock()
844+ return false
845+ }
846+ for _, p := range tb.panes {
847+ p.mu.Lock()
848+ rows := p.history.Len()
849+ p.mu.Unlock()
850+ if rows != historyRows {
851+ tb.mu.Unlock()
852+ return false
853+ }
854+ }
855+ tb.mu.Unlock()
856+ }
857+ return true
858+}
859+
329860 func (f *performanceFixture) searchMatches() int {
330861 f.ac.overlays.copyMu.Lock()
331862 defer f.ac.overlays.copyMu.Unlock()
@@ -335,10 +866,48 @@ func (f *performanceFixture) searchMatches() int {
335866 return len(f.ac.overlays.copyMode.Searches)
336867 }
337868
869+func (f *performanceFixture) copyModeActive() bool {
870+ f.ac.overlays.copyMu.Lock()
871+ defer f.ac.overlays.copyMu.Unlock()
872+ return f.ac.overlays.copyMode != nil && f.ac.overlays.copySnapshot != nil && f.ac.overlays.copySnapshot.Len() >= 10_000
873+}
874+
338875 func (f *performanceFixture) resize() {
339- f.resizedSize = f.resizeSizes[f.resizes%len(f.resizeSizes)]
876+ f.resizeTo(f.resizeSizes[f.resizes%len(f.resizeSizes)])
877+}
878+
879+func (f *performanceFixture) resizeTo(size domain.Size) {
880+ f.resizedSize = size
340881 f.resizes++
341- f.d.resize(f.sess, f.ac, f.resizedSize)
882+ before := f.sess.renderCoordinator().resizeSnapshot()
883+ start := f.clock.Now()
884+ failuresBefore, callsBefore := f.pty.metrics()
885+ f.d.resize(f.sess, f.ac, size)
886+ after := f.sess.renderCoordinator().resizeSnapshot()
887+ if after.epoch > before.epoch {
888+ f.resizeRequests += after.epoch - before.epoch
889+ }
890+ if after.committed > before.committed {
891+ f.resizeCommits += after.committed - before.committed
892+ f.resizeCommitNanos += uint64(f.clock.Now().Sub(start).Nanoseconds())
893+ }
894+ failuresAfter, callsAfter := f.pty.metrics()
895+ f.ptyFailures += failuresAfter - failuresBefore
896+ if callsAfter > callsBefore+1 {
897+ f.ptyRetries += callsAfter - callsBefore - 1
898+ }
899+}
900+
901+func (f *performanceFixture) retryLatest() {
902+ epoch := f.sess.renderCoordinator().resizeSnapshot().committed
903+ plan := f.d.prepareResize(f.sess, f.ac.size)
904+ failuresBefore, callsBefore := f.pty.metrics()
905+ f.d.retryResizeMembers(f.sess, f.ac, f.sess.renderCoordinator().attachmentLease(f.ac), epoch, plan.members)
906+ failuresAfter, callsAfter := f.pty.metrics()
907+ f.ptyFailures += failuresAfter - failuresBefore
908+ if callsAfter > callsBefore {
909+ f.ptyRetries += callsAfter - callsBefore
910+ }
342911 }
343912
344913 func (f *performanceFixture) resized() bool {
@@ -359,15 +928,42 @@ func (f *performanceFixture) resized() bool {
359928 func (f *performanceFixture) resetMetrics() {
360929 f.output.reset()
361930 f.snaps.reset()
931+ f.renderCaptures = 0
932+ f.renderCompositions = 0
933+ f.renderEmissions = 0
934+ f.resizeRequests = 0
935+ f.resizeCommits = 0
936+ f.resizeCommitNanos = 0
937+ f.ptyFailures = 0
938+ f.ptyRetries = 0
939+ if rc := f.sess.renderCoordinator(); rc != nil {
940+ f.coordinatorBaseline = rc.burstMetricsSnapshot()
941+ }
362942 }
363943
364944 func (f *performanceFixture) metrics() performanceMetrics {
365945 output := f.output.metrics()
366946 snapshots := f.snaps.metrics()
367- return performanceMetrics{
947+ metrics := performanceMetrics{
368948 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
369949 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,
950+ renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions,
951+ resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos,
952+ ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries,
953+ }
954+ if metrics.resizeRequests > metrics.resizeCommits {
955+ metrics.skippedEpochs = metrics.resizeRequests - metrics.resizeCommits
956+ }
957+ if metrics.resizeCommits > metrics.outputFrames {
958+ metrics.frameGapEpochs = metrics.resizeCommits - metrics.outputFrames
959+ }
960+ if rc := f.sess.renderCoordinator(); rc != nil {
961+ coordinator := rc.burstMetricsSnapshot()
962+ metrics.coordinatorInvalidations = coordinator.invalidations - f.coordinatorBaseline.invalidations
963+ metrics.coordinatorWakes = coordinator.wakes - f.coordinatorBaseline.wakes
964+ metrics.coordinatorCoalesced = coordinator.coalesced - f.coordinatorBaseline.coalesced
370965 }
966+ return metrics
371967 }
372968
373969 const outputPayloadHeaderBytes = 24
M internal/usecase/daemon/picker.go +173 -128
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
4-//
5-// Concurrency model (sessions own one or more PTY-backed tabs):
6-//
7-// - Serve runs the accept loop. Each accepted connection is handled by its
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
19-//
20-// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
1+// Package daemon holds vev's server-side session multiplexer use case.
252 package daemon
263
274 import (
@@ -32,7 +9,6 @@ import (
329 "github.com/bnema/vev/internal/usecase/keys"
3310 "github.com/bnema/vev/internal/usecase/layout"
3411 "github.com/bnema/vev/internal/usecase/picker"
35- themeui "github.com/bnema/vev/internal/usecase/theme"
3612 "github.com/bnema/vev/internal/usecase/ui"
3713 "github.com/bnema/vev/pkg/renderer"
3814 )
@@ -47,7 +23,7 @@ func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
4723 ac.overlays.pickerPreview = nil
4824 ac.overlays.pickerMu.Unlock()
4925 d.registerPreviewForSelection(ac)
50- d.paint(sess, ac, true)
26+ d.invalidateRender(sess, ac, true, "picker.go")
5127 }
5228
5329 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
@@ -141,7 +117,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
141117 d.killPickerTarget(target)
142118 }
143119 d.refreshPicker(ac)
144- d.paint(sess, ac, true)
120+ d.invalidateRender(sess, ac, true, "picker.go")
145121 return
146122 case 'j':
147123 ac.overlays.picker.Down()
@@ -191,7 +167,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
191167 return
192168 }
193169 if exit || changed {
194- d.paint(sess, ac, true)
170+ d.invalidateRender(sess, ac, true, "picker.go")
195171 }
196172 }
197173
@@ -207,11 +183,12 @@ func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
207183 ac.overlays.pickerESC.timer = nil
208184 ac.overlays.pickerESC.done = nil
209185 ac.overlays.picker = nil
186+ generation := ac.overlays.pickerPreviewGeneration
210187 ac.overlays.pickerMu.Unlock()
211188
212- d.unregisterPreview(ac)
189+ d.clearPreviewGeneration(ac, generation)
213190 if sess := ac.currentSession(); sess != nil {
214- d.paint(sess, ac, true)
191+ d.invalidateRender(sess, ac, true, "picker.go")
215192 }
216193 })
217194 }
@@ -239,89 +216,138 @@ func isPickerEscapePrefix(data []byte) bool {
239216 }
240217
241218 func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
219+ if ac == nil || ac.overlays == nil {
220+ return
221+ }
242222 ac.overlays.pickerMu.Lock()
243223 var target picker.Target
244224 var ok bool
245225 if ac.overlays.picker != nil {
246226 target, ok = ac.overlays.picker.Selected()
247227 }
228+ previous, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration
229+ ac.overlays.pickerPreviewGeneration++
230+ generation := ac.overlays.pickerPreviewGeneration
231+ ac.overlays.pickerPreviewSession = nil
232+ ac.overlays.pickerPreview = nil
248233 ac.overlays.pickerMu.Unlock()
249-
250- var next *tab
251- if ok {
252- next = d.tabByTarget(target)
234+ d.teardownPreviewSubscription(ac, previous, previousGeneration)
235+ if !ok {
236+ return
237+ }
238+ next := d.tabByTarget(target)
239+ targetSess := d.sessionByID(target.Session)
240+ if next == nil || targetSess == nil {
241+ return
253242 }
254-
255243 ac.overlays.pickerMu.Lock()
256- old := ac.overlays.pickerPreview
257- if ac.overlays.picker == nil {
258- next = nil
244+ // Selection may have changed while the target was resolved.
245+ selected, stillSelected := ac.overlays.picker.Selected()
246+ valid := ac.overlays.pickerPreviewGeneration == generation && stillSelected && selected == target
247+ if valid {
248+ ac.overlays.pickerPreview = next
249+ ac.overlays.pickerPreviewSession = targetSess
259250 }
260- ac.overlays.pickerPreview = next
261251 ac.overlays.pickerMu.Unlock()
262-
263- if old != nil && old != next {
264- old.mu.Lock()
265- if old.previewClient == ac {
266- old.previewClient = nil
252+ if !valid {
253+ return
254+ }
255+ // A same-session preview is already invalidated by its own coordinator.
256+ // Cross-session previews subscribe to the target's producer wake instead
257+ // of starting a direct paint/timer path.
258+ if targetSess == ac.currentSession() {
259+ return
260+ }
261+ rc := d.attachCoordinator(targetSess, nil, nil, false)
262+ rc.subscribePreviewFor(ac, generation, func(renderWake) {
263+ if pickerPreviewCurrent(ac, targetSess, next, generation) {
264+ if owner := ac.currentSession(); owner != nil {
265+ d.invalidateRender(owner, ac, false, "picker preview")
266+ }
267267 }
268- old.mu.Unlock()
268+ })
269+ // subscribePreviewFor is deliberately outside pickerMu. Revalidate after it
270+ // returns and remove only this generation if selection changed meanwhile.
271+ d.revalidatePreviewSubscription(ac, rc, targetSess, next, generation)
272+}
273+
274+func pickerPreviewCurrent(ac *attachedClient, targetSess *session, next *tab, generation uint64) bool {
275+ ac.overlays.pickerMu.Lock()
276+ defer ac.overlays.pickerMu.Unlock()
277+ return ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerPreviewSession == targetSess && ac.overlays.pickerPreview == next
278+}
279+
280+// revalidatePreviewSubscription removes a subscription that lost the picker
281+// selection race after it was installed.
282+func (d *Daemon) revalidatePreviewSubscription(ac *attachedClient, rc *renderCoordinator, targetSess *session, next *tab, generation uint64) {
283+ if !pickerPreviewCurrent(ac, targetSess, next, generation) {
284+ rc.teardownPreviewFor(ac, generation)
269285 }
270- if next != nil {
271- next.mu.Lock()
272- next.previewClient = ac
273- next.mu.Unlock()
286+}
274287
275- ac.overlays.pickerMu.Lock()
276- keep := ac.overlays.pickerPreview == next
277- ac.overlays.pickerMu.Unlock()
278- if !keep {
279- next.mu.Lock()
280- if next.previewClient == ac {
281- next.previewClient = nil
282- }
283- next.mu.Unlock()
288+func (d *Daemon) teardownPreviewSubscription(ac *attachedClient, previous *session, generation uint64) {
289+ if previous != nil {
290+ if rc := previous.renderCoordinator(); rc != nil {
291+ rc.teardownPreviewFor(ac, generation)
284292 }
285293 }
286294 }
287295
288296 func (d *Daemon) unregisterPreview(ac *attachedClient) {
297+ if ac == nil || ac.overlays == nil {
298+ return
299+ }
289300 ac.overlays.pickerMu.Lock()
290- old := ac.overlays.pickerPreview
291- ac.overlays.pickerPreview = nil
301+ generation := ac.overlays.pickerPreviewGeneration
292302 ac.overlays.pickerMu.Unlock()
303+ d.clearPreviewGeneration(ac, generation)
304+}
293305
294- if old != nil {
295- old.mu.Lock()
296- if old.previewClient == ac {
297- old.previewClient = nil
298- }
299- old.mu.Unlock()
306+// clearPreviewGeneration removes the preview only if it is still the observed
307+// selection. Teardown paths may race a new picker selection.
308+func (d *Daemon) clearPreviewGeneration(ac *attachedClient, generation uint64) bool {
309+ ac.overlays.pickerMu.Lock()
310+ if ac.overlays.pickerPreviewGeneration != generation {
311+ ac.overlays.pickerMu.Unlock()
312+ return false
300313 }
314+ previous := ac.overlays.pickerPreviewSession
315+ ac.overlays.pickerPreviewGeneration++
316+ ac.overlays.pickerPreviewSession = nil
317+ ac.overlays.pickerPreview = nil
318+ ac.overlays.pickerMu.Unlock()
319+ d.teardownPreviewSubscription(ac, previous, generation)
320+ return true
301321 }
302322
323+// clearDestroyedTabPreview clears coordinator-owned picker state for clients
324+// that were previewing a removed tab. Tabs do not route render ownership.
303325 func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
304- tb.mu.Lock()
305- previewer := tb.previewClient
306- tb.mu.Unlock()
307- if previewer == nil {
326+ if d == nil || d.sessions == nil {
308327 return
309328 }
310- cleared := false
311- previewer.overlays.pickerMu.Lock()
312- if previewer.overlays.pickerPreview == tb {
313- previewer.overlays.pickerPreview = nil
314- cleared = true
315- }
316- previewer.overlays.pickerMu.Unlock()
317- tb.mu.Lock()
318- if tb.previewClient == previewer {
319- tb.previewClient = nil
329+ d.mu.Lock()
330+ var clients []*attachedClient
331+ for _, sess := range d.sessions {
332+ sess.mu.Lock()
333+ if sess.client != nil {
334+ clients = append(clients, sess.client)
335+ }
336+ sess.mu.Unlock()
320337 }
321- tb.mu.Unlock()
322- if cleared {
323- if sess := previewer.currentSession(); sess != nil {
324- d.paint(sess, previewer, true)
338+ d.mu.Unlock()
339+ for _, ac := range clients {
340+ if ac == nil || ac.overlays == nil {
341+ continue
342+ }
343+ ac.overlays.pickerMu.Lock()
344+ generation := ac.overlays.pickerPreviewGeneration
345+ observed := ac.overlays.pickerPreview == tb
346+ ac.overlays.pickerMu.Unlock()
347+ if observed && d.clearPreviewGeneration(ac, generation) {
348+ if sess := ac.currentSession(); sess != nil {
349+ d.invalidateRender(sess, ac, true, "picker.go")
350+ }
325351 }
326352 }
327353 }
@@ -331,8 +357,9 @@ func (d *Daemon) closePicker(ac *attachedClient) {
331357 ac.overlays.picker = nil
332358 ac.overlays.pickerPending = nil
333359 d.stopPickerPendingTimerLocked(ac)
360+ generation := ac.overlays.pickerPreviewGeneration
334361 ac.overlays.pickerMu.Unlock()
335- d.unregisterPreview(ac)
362+ d.clearPreviewGeneration(ac, generation)
336363 }
337364
338365 func (d *Daemon) tabByTarget(target picker.Target) *tab {
@@ -362,19 +389,19 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
362389 }
363390 targetSess := d.sessionByID(target.Session)
364391 if targetSess == nil {
365- d.paint(sess, ac, true)
392+ d.invalidateRender(sess, ac, true, "picker.go")
366393 return false
367394 }
368395 if targetSess == sess {
369396 if sess.switchTab(target.TabIndex) {
370397 d.activateTab(sess, sess.activeTab())
371398 }
372- d.paint(sess, ac, true)
399+ d.invalidateRender(sess, ac, true, "picker.go")
373400 return true
374401 }
375402 old := d.stealClientForTarget(sess, ac, targetSess, target)
376403 if ac.currentSession() != targetSess {
377- d.paint(sess, ac, true)
404+ d.invalidateRender(sess, ac, true, "picker.go")
378405 return false
379406 }
380407 if old != nil && old != ac {
@@ -407,16 +434,22 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
407434 targetSess.mu.Lock()
408435 old := targetSess.client
409436 targetSess.terminal = term
410- targetSess.client = ac
411437 if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
412438 targetSess.active = target.TabIndex
413439 }
414440 targetSess.mu.Unlock()
415441
416- d.touchMRU(targetSess)
442+ // Do not expose targetSess.client until the target coordinator has claimed
443+ // this attachment and the output dependency chain has been rebased.
444+ cleanups := d.handoffCoordinator(from, targetSess, old, ac)
417445 ac.setSession(targetSess)
446+ targetSess.mu.Lock()
447+ targetSess.client = ac
448+ targetSess.mu.Unlock()
449+ d.touchMRU(targetSess)
418450 ac.recordPreviousSession(from)
419451 d.mu.Unlock()
452+ finishRenderLifecycleCleanups(cleanups)
420453 return old
421454 }
422455
@@ -425,7 +458,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
425458 stopped, ok := d.stopped[target.Name]
426459 if !ok || stopped.purging {
427460 d.mu.Unlock()
428- d.paint(from, ac, true)
461+ d.invalidateRender(from, ac, true, "picker.go")
429462 return false
430463 }
431464 from.mu.Lock()
@@ -441,20 +474,22 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
441474 from.mu.Unlock()
442475 d.mu.Unlock()
443476 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
444- d.paint(from, ac, true)
477+ d.invalidateRender(from, ac, true, "picker.go")
445478 return false
446479 }
447480 from.client = nil
448481 ac.setSession(nil)
449482 from.mu.Unlock()
483+
484+ cleanups := d.handoffCoordinator(from, targetSess, nil, ac)
485+ ac.setSession(targetSess)
450486 targetSess.mu.Lock()
451487 targetSess.client = ac
452488 targetSess.mu.Unlock()
453-
454489 d.touchMRU(targetSess)
455- ac.setSession(targetSess)
456490 ac.recordPreviousSession(from)
457491 d.mu.Unlock()
492+ finishRenderLifecycleCleanups(cleanups)
458493 d.firstPaint(targetSess, ac, ac.size)
459494 return true
460495 }
@@ -496,49 +531,59 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
496531 d.registerPreviewForSelection(ac)
497532 }
498533
499-func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
500- styleSet := resolveThemeStyles(styles)
501- renderStyles := picker.RenderStyles{
502- Selection: styleSet.selection,
503- SelectionName: styleSet.pickerSelectionName,
504- SelectionMuted: styleSet.pickerSelectionMuted,
505- Name: styleSet.pickerName,
506- Detail: styleSet.paletteDesc,
507- Base: renderer.DefaultStyle(),
508- }
509- return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
510- return model.Render(size, preview, renderStyles)
511- })
512-}
513-
514534 func snapshotPickerPreview(tb *tab) picker.Preview {
515535 if tb == nil {
516536 return picker.Preview{}
517537 }
518538 tb.mu.Lock()
519- defer tb.mu.Unlock()
520- return pickerPreviewFromLockedTab(tb)
521-}
522-
523-func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
524- p := tb.focusedPane()
525- if p == nil {
526- return picker.Preview{}
527- }
528- if tb.tree == nil || tb.tree.Root == nil || tb.tree.Root.Kind == layout.Leaf {
539+ layoutSnap := solveTabLayoutLocked(tb)
540+ if !layoutSnap.ok {
541+ p := tb.focusedPane()
542+ if p == nil {
543+ tb.mu.Unlock()
544+ return picker.Preview{}
545+ }
529546 p.mu.Lock()
530- defer p.mu.Unlock()
531- return pickerPreviewFromLockedPane(p)
547+ preview := pickerPreviewFromLockedPane(p)
548+ p.mu.Unlock()
549+ tb.mu.Unlock()
550+ return preview
551+ }
552+ state := capturedRenderState{layout: capturedTabLayout{
553+ root: tb.tree.Clone().Root, area: layoutSnap.area, focus: layoutSnap.focus,
554+ placements: append([]layout.Placement(nil), layoutSnap.placements...),
555+ fingerprint: layoutSnap.fingerprint, valid: true,
556+ }}
557+ for _, placement := range layoutSnap.placements {
558+ p := tb.panes[placement.ID]
559+ if p == nil {
560+ continue
561+ }
562+ visible := placement.Content
563+ if placement.Collapsed {
564+ visible = domain.Rect{}
565+ }
566+ p.mu.Lock()
567+ captured := capturePaneRenderStateLocked(p, visible)
568+ p.mu.Unlock()
569+ captured.placement = placement
570+ captured.focused = placement.ID == layoutSnap.focus
571+ state.panes = append(state.panes, captured)
532572 }
573+ tb.mu.Unlock()
574+ return pickerPreviewFromCapturedRender(state)
575+}
533576
534- area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
535- if area.Width <= 0 || area.Height <= 0 {
536- p.mu.Lock()
537- defer p.mu.Unlock()
538- return pickerPreviewFromLockedPane(p)
577+func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview {
578+ composed := composeFrame(state, composeCacheInput{}, composeCacheInput{}).frame
579+ if composed.Height < 2 {
580+ return pickerPreviewFromFrame(composed)
581+ }
582+ rows := make([][]renderer.Cell, composed.Height-2)
583+ for y := range rows {
584+ rows[y] = append([]renderer.Cell(nil), composed.Row(y+1)...)
539585 }
540- frame, _ := composeTabFrame(tb, area, themeui.Theme{})
541- return pickerPreviewFromFrame(frame)
586+ return picker.Preview{Rows: rows, Width: composed.Width, Height: len(rows)}
542587 }
543588
544589 func pickerPreviewFromLockedPane(p *pane) picker.Preview {
M internal/usecase/daemon/picker_test.go +151 -126
@@ -2,7 +2,7 @@ package daemon
22
33 import (
44 "context"
5- "sync"
5+ "strings"
66 "testing"
77 "time"
88
@@ -15,6 +15,7 @@ import (
1515 "github.com/bnema/vev/internal/usecase/keys"
1616 "github.com/bnema/vev/internal/usecase/layout"
1717 "github.com/bnema/vev/internal/usecase/picker"
18+ "github.com/bnema/vev/pkg/vt"
1819 )
1920
2021 // --- test doubles -----------------------------------------------------------
@@ -217,6 +218,47 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
217218 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
218219 }
219220
221+func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
222+ p1, releasePTY1 := newBlockingPTY(t)
223+ p2, releasePTY2 := newBlockingPTY(t)
224+ defer releasePTY1()
225+ defer releasePTY2()
226+ d, source, ac, sends := newManualSessionWithPTYs(t, p1)
227+ source.id, source.name = "source", "source"
228+ delete(d.sessions, domain.SessionID("manual"))
229+ d.sessions[source.id] = source
230+ target := &session{
231+ id: "target", name: "target", ctx: source.ctx, cancel: func() {},
232+ tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})},
233+ }
234+ d.sessions[target.id] = target
235+
236+ sourcePane := source.tabs[0].focusedPane()
237+ targetPane := target.tabs[0].focusedPane()
238+ require.Equal(t, layout.PaneID("pane-1"), sourcePane.id, "source uses reusable pane-1")
239+ require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1")
240+ sourcePane.screen.Write([]byte("SOURCE"))
241+ client := vt.NewScreen(80, 25)
242+ d.firstPaint(source, ac, ac.size)
243+ sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
244+ ac.ackOutputState(sourceOutput.NewStateNum)
245+
246+ targetPane.screen.Write([]byte("TARGET"))
247+ targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
248+ require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")
249+ ac.previousSession.Set(target)
250+
251+ // Exercise the user-facing previous-session route, which delegates to the
252+ // real switchToTarget hand-off and immediately emits its required reset.
253+ d.backSession(source, ac)
254+ firstReset := awaitFrame(t, sends, ports.MsgOutput)
255+ out := mustApplyOutput(t, client, firstReset)
256+ require.Zero(t, out.BaseStateNum, "the first target frame must be the reset, not an eventual repair")
257+ frame := strings.Join(frameRows(client.Frame), "\n")
258+ require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture")
259+ require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")
260+}
261+
220262 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
221263 p1, releasePTY1 := newBlockingPTY(t)
222264 p2, releasePTY2 := newBlockingPTY(t)
@@ -259,6 +301,40 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
259301 awaitFrame(t, sends1, ports.MsgOutput)
260302 }
261303
304+func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
305+ p1, releasePTY1 := newBlockingPTY(t)
306+ p2, releasePTY2 := newBlockingPTY(t)
307+ defer releasePTY1()
308+ defer releasePTY2()
309+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
310+ d := newTestDaemon(t, nil, clock)
311+ tr1, _ := newCapturingTransport(t)
312+ tr2, _ := newCapturingTransport(t)
313+ ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
314+ ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
315+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
316+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
317+ defer cancel1()
318+ defer cancel2()
319+ sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}
320+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
321+ ac1.setSession(sess1)
322+ ac2.setSession(sess2)
323+ d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2
324+
325+ d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24})
326+ timer := <-clock.timers
327+ done := captureResizeCallbackDone(t, sess2.renderCoordinator())
328+ before := sess2.renderCoordinator().resizeSnapshot().epoch
329+
330+ require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}))
331+ // The target coordinator invalidates its scheduled epoch during handoff;
332+ // no attachment-local timer survives the transfer.
333+ require.Equal(t, before, sess2.renderCoordinator().resizeSnapshot().epoch)
334+ timer.ch <- time.Time{}
335+ awaitTestCompletion(t, done, "superseded resize callback did not complete")
336+}
337+
262338 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
263339 p1, releasePTY1 := newBlockingPTY(t)
264340 p2, releasePTY2 := newBlockingPTY(t)
@@ -295,7 +371,7 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
295371 oldPane.screen.Write([]byte("stale-damage"))
296372 require.NotEmpty(t, oldPane.screen.Damage())
297373
298- d.paint(sess1, ac1, false)
374+ d.paint(sess1, ac1, false, nil)
299375
300376 require.Zero(t, len(sends1), "stale paint from old session sent a frame")
301377 require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage")
@@ -329,7 +405,7 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
329405 sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching"))
330406 paintDone := make(chan struct{})
331407 go func() {
332- d.paint(sess1, ac, false)
408+ d.paint(sess1, ac, false, nil)
333409 close(paintDone)
334410 }()
335411 <-enteredSend
@@ -368,8 +444,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
368444 defer releasePTY3()
369445 var openedEnv []string
370446 f := portsmocks.NewMockPTYFactory(t)
371- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
372- func(_ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
447+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
448+ func(_ context.Context, _ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
373449 if sz != (domain.Size{Cols: 80, Rows: 22}) {
374450 return newQuietPTY(), nil
375451 }
@@ -403,6 +479,72 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
403479 require.NotContains(t, openedEnv, "TERM=xterm-direct")
404480 }
405481
482+func TestPickerPreviewGenerationRejectsStaleSubscriptionReplacement(t *testing.T) {
483+ rc := newRenderCoordinator(renderCoordinatorOptions{})
484+ viewer := &attachedClient{}
485+ newer := func(renderWake) {}
486+
487+ require.True(t, rc.subscribePreviewFor(viewer, 2, newer))
488+ require.False(t, rc.subscribePreviewFor(viewer, 1, func(renderWake) {}))
489+ rc.teardownPreviewFor(viewer, 1)
490+
491+ rc.mu.Lock()
492+ subscription, ok := rc.previewWakes[viewer]
493+ rc.mu.Unlock()
494+ require.True(t, ok)
495+ require.Equal(t, uint64(2), subscription.generation)
496+ require.NotNil(t, subscription.fn)
497+}
498+
499+func TestPickerPreviewPostSubscribeRevalidationKeepsNewerSubscription(t *testing.T) {
500+ d := newTestDaemon(t, nil, stubClock{})
501+ viewer := &attachedClient{}
502+ viewer.initOverlays()
503+ target := &session{}
504+ tab := &tab{}
505+ rc := newRenderCoordinator(renderCoordinatorOptions{})
506+
507+ viewer.overlays.pickerMu.Lock()
508+ viewer.overlays.pickerPreviewGeneration = 2
509+ viewer.overlays.pickerPreviewSession = target
510+ viewer.overlays.pickerPreview = tab
511+ viewer.overlays.pickerMu.Unlock()
512+ require.True(t, rc.subscribePreviewFor(viewer, 2, func(renderWake) {}))
513+
514+ // This is the exact post-subscribe check from registerPreviewForSelection.
515+ // It represents a generation-1 subscriber resuming after generation 2 won.
516+ d.revalidatePreviewSubscription(viewer, rc, target, tab, 1)
517+
518+ rc.mu.Lock()
519+ subscription, ok := rc.previewWakes[viewer]
520+ rc.mu.Unlock()
521+ require.True(t, ok)
522+ require.Equal(t, uint64(2), subscription.generation)
523+}
524+
525+func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) {
526+ d := newTestDaemon(t, nil, stubClock{})
527+ viewer := &attachedClient{}
528+ viewer.initOverlays()
529+ newerSession := &session{}
530+ newerTab := &tab{}
531+ rc := newRenderCoordinator(renderCoordinatorOptions{})
532+
533+ viewer.overlays.pickerMu.Lock()
534+ viewer.overlays.pickerPreviewGeneration = 2
535+ viewer.overlays.pickerPreviewSession = newerSession
536+ viewer.overlays.pickerPreview = newerTab
537+ viewer.overlays.pickerMu.Unlock()
538+ require.True(t, rc.subscribePreviewFor(viewer, 2, func(renderWake) {}))
539+
540+ require.False(t, d.clearPreviewGeneration(viewer, 1))
541+ require.True(t, pickerPreviewCurrent(viewer, newerSession, newerTab, 2))
542+ rc.mu.Lock()
543+ _, subscribed := rc.previewWakes[viewer]
544+ rc.mu.Unlock()
545+ require.True(t, subscribed)
546+}
547+
406548 func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
407549 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
408550 p := tb.focusedPane()
@@ -448,123 +590,6 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
448590 require.Equal(t, 'R', preview.Rows[2][21].Rune, "expanded stacked pane content should be included")
449591 }
450592
451-func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) {
452- d, sess, ac, sends, releases := newManualTabSession(t, 2)
453- defer func() {
454- for _, release := range releases {
455- release()
456- }
457- }()
458-
459- d.enterPicker(sess, ac)
460- awaitFrame(t, sends, ports.MsgOutput)
461- d.handleInput(sess, ac, []byte("j"))
462- awaitFrame(t, sends, ports.MsgOutput)
463-
464- previewTab := sess.tabs[1]
465- previewTab.mu.Lock()
466- previewTab.focusedPane().screen.Write([]byte("inactive-preview-live"))
467- previewTab.mu.Unlock()
468- d.render(sess, previewTab, previewTab.focusedPane())
469-
470- previewOut := awaitFrame(t, sends, ports.MsgOutput)
471- previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
472- require.NoError(t, err)
473- require.Contains(t, string(previewMsg.Data), "inactive-preview-live")
474-}
475-
476-func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
477- p1, releasePTY1 := newBlockingPTY(t)
478- p2, releasePTY2 := newBlockingPTY(t)
479- defer releasePTY1()
480- defer releasePTY2()
481- d := newTestDaemon(t, nil, stubClock{})
482- tr1, sends1 := newCapturingTransport(t)
483- tr2, _ := newCapturingTransport(t)
484- ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
485- ac1.initOverlays()
486- ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
487- ac2.initOverlays()
488- sctx1, cancel1 := context.WithCancel(d.serveCtx)
489- sctx2, cancel2 := context.WithCancel(d.serveCtx)
490- defer cancel1()
491- defer cancel2()
492- sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}
493- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
494- ac1.setSession(sess1)
495- ac2.setSession(sess2)
496- ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil)
497- ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}, nil)
498- d.sessions[sess1.id] = sess1
499- d.sessions[sess2.id] = sess2
500-
501- d.enterPicker(sess1, ac1)
502- awaitFrame(t, sends1, ports.MsgOutput)
503- d.handleInput(sess1, ac1, []byte("j"))
504- awaitFrame(t, sends1, ports.MsgOutput)
505-
506- previewTab := sess2.tabs[0]
507- previewTab.mu.Lock()
508- previewTab.focusedPane().screen.Write([]byte("cross-session-preview-live"))
509- previewTab.mu.Unlock()
510- d.render(sess2, previewTab, previewTab.focusedPane())
511-
512- previewOut := awaitFrame(t, sends1, ports.MsgOutput)
513- previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
514- require.NoError(t, err)
515- require.Contains(t, string(previewMsg.Data), "cross-session-preview-live")
516-}
517-
518-func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
519- d, sess, ac, sends, releases := newManualTabSession(t, 3)
520- defer func() {
521- for _, release := range releases {
522- release()
523- }
524- }()
525-
526- done := make(chan struct{})
527- var drain sync.WaitGroup
528- drain.Go(func() {
529- for {
530- select {
531- case <-sends:
532- case <-done:
533- return
534- }
535- }
536- })
537-
538- var wg sync.WaitGroup
539- for i := range 100 {
540- wg.Go(func() {
541- d.enterPicker(sess, ac)
542- })
543- wg.Go(func() {
544- d.handlePickerInput(ac, []byte("j"))
545- d.handlePickerInput(ac, []byte("k"))
546- })
547- wg.Go(func() {
548- d.closePicker(ac)
549- })
550- tb := sess.tabs[i%len(sess.tabs)]
551- wg.Go(func() {
552- tb.mu.Lock()
553- p := tb.focusedPane()
554- tb.mu.Unlock()
555- if p != nil {
556- p.mu.Lock()
557- p.screen.Write([]byte("render-race"))
558- p.mu.Unlock()
559- }
560- d.render(sess, tb, p)
561- })
562- }
563- wg.Wait()
564- close(done)
565- drain.Wait()
566-}
567-
568593 func TestPickerModalGeometry(t *testing.T) {
569594 base := domain.Size{Cols: 100, Rows: 40}
570595
@@ -582,7 +607,7 @@ func TestPickerResizeRecomposesModal(t *testing.T) {
582607 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
583608 // Picker recomposition is the relevant event here; do not depend on a real
584609 // resize-idle timer in this synchronous rendering test.
585- d.paint(sess, ac, false)
610+ d.paint(sess, ac, false, nil)
586611
587612 out := awaitFrame(t, sends, ports.MsgOutput)
588613 msg, err := ports.UnmarshalOutput(out.Payload)
@@ -600,8 +625,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
600625 var opens [][]string
601626 f := portsmocks.NewMockPTYFactory(t)
602627 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
603- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
604- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
628+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
629+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
605630 opens = append(opens, append([]string(nil), env...))
606631 if len(opens) == 1 {
607632 return p1, nil
@@ -610,7 +635,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
610635 },
611636 ).Twice()
612637 floating := newQuietPTY()
613- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
638+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
614639 return got != normalSize && got.Valid()
615640 })).Return(floating, nil).Once()
616641 d := newTestDaemon(t, f, stubClock{})
M internal/usecase/daemon/prompt.go +5 -11
@@ -6,7 +6,6 @@ import (
66 "github.com/bnema/vev/internal/domain"
77 promptui "github.com/bnema/vev/internal/usecase/prompt"
88 "github.com/bnema/vev/internal/usecase/ui"
9- "github.com/bnema/vev/pkg/renderer"
109 )
1110
1211 func promptModalFor(title string) ui.Modal {
@@ -20,7 +19,7 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s
2019 ac.overlays.promptSubmit = submit
2120 ac.overlays.promptPending = nil
2221 ac.overlays.promptMu.Unlock()
23- d.paint(sess, ac, true)
22+ d.invalidateRender(sess, ac, true, "prompt.go")
2423 }
2524
2625 func (d *Daemon) closePrompt(ac *attachedClient) {
@@ -77,26 +76,21 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
7776 ac.overlays.prompt.SetError(err.Error())
7877 }
7978 ac.overlays.promptMu.Unlock()
80- d.paint(sess, ac, true)
79+ d.invalidateRender(sess, ac, true, "prompt.go")
8180 return
8281 }
8382 d.closePrompt(ac)
8483 if current := ac.currentSession(); current != nil {
85- d.paint(current, ac, true)
84+ d.invalidateRender(current, ac, true, "prompt.go")
8685 }
8786 return
8887 }
8988 if exit {
9089 d.closePrompt(ac)
91- d.paint(sess, ac, true)
90+ d.invalidateRender(sess, ac, true, "prompt.go")
9291 return
9392 }
9493 if changed {
95- d.paint(sess, ac, true)
94+ d.invalidateRender(sess, ac, true, "prompt.go")
9695 }
9796 }
98-
99-func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
100- styleSet := resolveThemeStyles(styles)
101- return composeModalClientFrame(base, promptModalFor(model.Title()), styleSet, styleSet.accent, model.Render)
102-}
M internal/usecase/daemon/render.go +230 -688
@@ -1,49 +1,85 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
4-//
5-// Concurrency model (sessions own one or more PTY-backed tabs):
6-//
7-// - Serve runs the accept loop. Each accepted connection is handled by its
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
19-//
20-// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
1+// Package daemon holds vev's server-side session multiplexer use case.
252 package daemon
263
274 import (
285 "bytes"
29- "errors"
30- "strconv"
31- "time"
326
337 "github.com/bnema/vev/internal/domain"
348 "github.com/bnema/vev/internal/ports"
359 "github.com/bnema/vev/internal/usecase/layout"
3610 themeui "github.com/bnema/vev/internal/usecase/theme"
37- "github.com/bnema/vev/internal/usecase/ui"
3811 "github.com/bnema/vev/pkg/renderer"
39- "github.com/bnema/vev/pkg/vt"
4012 )
4113
42-func signal(ch chan struct{}) {
43- select {
44- case ch <- struct{}{}:
45- default:
14+// completedSynchronizedUpdate reports a full DEC 2026 batch contained in one
15+// PTY read. The VT's final state is inactive in this case, so it cannot be
16+// inferred from before/after SyncUpdateActive alone.
17+func completedSynchronizedUpdate(data []byte) bool {
18+ _, tail, found := bytes.Cut(data, []byte(renderer.SyncStartCSI))
19+ return found && bytes.Contains(tail, []byte(renderer.SyncEndCSI))
20+}
21+
22+// paneRenderable reports dynamic composition visibility. Callers must not
23+// hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks.
24+func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
25+ if sess == nil || tb == nil || p == nil {
26+ return false
27+ }
28+ sess.mu.Lock()
29+ active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
30+ attached := sess.client != nil
31+ sess.mu.Unlock()
32+
33+ // The normal attached render path needs no cross-session picker lookup.
34+ // Only inactive or headless tabs can be renderable as a picker preview.
35+ if (!active || !attached) && !d.tabIsPickerPreview(tb) {
36+ return false
37+ }
38+ tb.mu.Lock()
39+ defer tb.mu.Unlock()
40+ if tb.floating.pane == p {
41+ return active && attached && tb.floating.state == floatingVisible
42+ }
43+ placements, ok := solvedPlacementsLocked(tb)
44+ if !ok {
45+ return false
46+ }
47+ for _, placement := range placements {
48+ if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 {
49+ return true
50+ }
4651 }
52+ return false
53+}
54+
55+// tabIsPickerPreview reports whether any attached client currently composes
56+// tb as a picker preview. It snapshots daemon ownership before taking overlay
57+// locks, preserving the Daemon -> session -> tab -> pane lock order.
58+func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
59+ if d == nil || tb == nil {
60+ return false
61+ }
62+ d.mu.Lock()
63+ sessions := make([]*session, 0, len(d.sessions))
64+ for _, sess := range d.sessions {
65+ sessions = append(sessions, sess)
66+ }
67+ d.mu.Unlock()
68+ for _, sess := range sessions {
69+ sess.mu.Lock()
70+ ac := sess.client
71+ sess.mu.Unlock()
72+ if ac == nil || ac.overlays == nil {
73+ continue
74+ }
75+ ac.overlays.pickerMu.Lock()
76+ preview := ac.overlays.pickerPreview == tb
77+ ac.overlays.pickerMu.Unlock()
78+ if preview {
79+ return true
80+ }
81+ }
82+ return false
4783 }
4884
4985 func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
@@ -52,61 +88,17 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
5288 return
5389 }
5490 buf := make([]byte, ptyReadBufSize)
55- var resp []byte
56- var clipboards []string
57- attentionCh := make(chan struct{}, 1)
5891 p.mu.Lock()
59- p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
60- p.screen.OnBell = func() { signal(attentionCh) }
61- p.screen.OnNotify = func(string, string) { signal(attentionCh) }
62- p.screen.OnProgress = func(bool) { signal(attentionCh) }
63- p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) }
92+ p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
93+ p.screen.OnBell = func() { p.ptyAttention = true }
94+ p.screen.OnNotify = func(string, string) { p.ptyAttention = true }
95+ p.screen.OnProgress = func(bool) { p.ptyAttention = true }
96+ p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
6497 p.mu.Unlock()
6598 for {
6699 n, err := p.pty.Read(buf)
67100 if n > 0 {
68- data := buf[:n]
69- p.mu.Lock()
70- wasSyncing := p.screen.SyncUpdateActive()
71- p.screen.Write(data)
72- p.refreshTerminalTitleLocked()
73- isSyncing := p.screen.SyncUpdateActive()
74- p.mu.Unlock()
75- markSnapshotDirty(sess)
76- select {
77- case <-attentionCh:
78- d.noteAttention(sess, tb)
79- default:
80- }
81- if len(clipboards) > 0 {
82- for _, b64 := range clipboards {
83- d.forwardClipboardAsync(sess, b64)
84- }
85- clipboards = clipboards[:0]
86- }
87- if len(resp) > 0 {
88- if _, writeErr := p.pty.Write(resp); writeErr != nil {
89- d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
90- }
91- resp = resp[:0]
92- }
93- if wasSyncing != isSyncing {
94- p.mu.Lock()
95- p.syncGen++
96- gen := p.syncGen
97- p.mu.Unlock()
98- if isSyncing {
99- go d.syncWatchdog(p, gen)
100- }
101- }
102- if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {
103- signal(p.flush)
104- continue
105- }
106- if isSyncing {
107- continue
108- }
109- signal(p.dirty)
101+ d.processPTYData(sess, tb, p, buf[:n], true)
110102 }
111103 if err != nil {
112104 if p.onExit != nil {
@@ -119,250 +111,182 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
119111 }
120112 }
121113
122-// scheduler debounces dirty signals. The first dirty opens a short tab;
123-// sustained floods progressively widen that tab, while isolated updates
124-// return to the minimum delay for interactive latency.
125-func (d *Daemon) scheduler(sess *session, tb *tab, p *pane) {
126- defer d.sessWg.Done()
127- if p == nil {
114+// processPTYData is the sole VT parsing path. While a resize apply owns the
115+// pane, reader calls append exact bytes and returns immediately; it never waits
116+// for PTY.Resize or loses a short read.
117+func (d *Daemon) processPTYData(sess *session, tb *tab, p *pane, data []byte, bufferDuringApply bool) {
118+ rc := sess.renderCoordinator()
119+ p.mu.Lock()
120+ if bufferDuringApply && p.resizeApplying {
121+ p.resizePending = append(p.resizePending, data...)
122+ p.mu.Unlock()
128123 return
129124 }
130- delay := minDebounceInterval
131- lastRender := d.clock.Now()
132-outer:
133- for {
134- select {
135- case <-sess.ctx.Done():
136- return
137- case <-paneDone(p):
138- return
139- case <-p.flush:
140- d.render(sess, tb, p)
141- lastRender = d.clock.Now()
142- continue
143- case <-p.dirty:
144- if d.clock.Now().Sub(lastRender) >= maxDebounceInterval {
145- delay = minDebounceInterval
125+ wasSyncing := p.screen.SyncUpdateActive()
126+ p.screen.Write(data)
127+ p.refreshTerminalTitleLocked()
128+ isSyncing := p.screen.SyncUpdateActive()
129+ completeSyncRead := !wasSyncing && !isSyncing && completedSynchronizedUpdate(data)
130+ var syncGen uint64
131+ syncEnded := false
132+ if wasSyncing != isSyncing {
133+ if isSyncing {
134+ syncGen = sess.syncGen.Add(1)
135+ p.syncGen = syncGen
136+ if rc != nil {
137+ rc.noteSyncBeginWithRenderability(p, syncGen, func() bool { return d.paneRenderable(sess, tb, p) }, func() {
138+ p.mu.Lock()
139+ if p.syncGen == syncGen && p.screen.SyncUpdateActive() {
140+ p.screen.ForceSyncEnd()
141+ }
142+ p.mu.Unlock()
143+ })
146144 }
145+ } else {
146+ syncGen = p.syncGen
147+ syncEnded = rc != nil && rc.removeSyncEnd(p, syncGen, true)
147148 }
148-
149- coalesced := 0
150- timer := d.clock.NewTimer(delay)
151- absorb:
152- for {
153- select {
154- case <-sess.ctx.Done():
155- timer.Stop()
156- return
157- case <-paneDone(p):
158- timer.Stop()
159- return
160- case <-p.flush:
161- if !timer.Stop() {
162- select {
163- case <-timer.C():
164- default:
165- }
149+ }
150+ p.mu.Unlock()
151+ renderable := d.paneRenderable(sess, tb, p)
152+ markSnapshotDirty(sess)
153+ d.flushPTYEffects(sess, tb, p)
154+ if rc != nil {
155+ if wasSyncing != isSyncing {
156+ if isSyncing {
157+ if renderable {
158+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
166159 }
167- d.render(sess, tb, p)
168- lastRender = d.clock.Now()
169- continue outer
170- case <-p.dirty:
171- coalesced++
172- case <-timer.C():
173- break absorb
160+ } else if syncEnded && renderable {
161+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
174162 }
175163 }
176- delay = nextDebounceDelay(delay, coalesced)
177- d.render(sess, tb, p)
178- lastRender = d.clock.Now()
179- }
180-}
181-
182-func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration {
183- if coalesced == 0 {
184- return minDebounceInterval
185- }
186- if delay >= maxDebounceInterval {
187- return maxDebounceInterval
188- }
189- delay += debounceStep
190- if delay > maxDebounceInterval {
191- return maxDebounceInterval
164+ if completeSyncRead && renderable {
165+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
166+ } else if renderable && !isSyncing && wasSyncing == isSyncing {
167+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
168+ }
192169 }
193- return delay
194170 }
195171
196-func (d *Daemon) syncWatchdog(p *pane, gen uint64) {
197- timer := d.clock.NewTimer(maxSyncUpdateDuration)
198- select {
199- case <-paneDone(p):
200- timer.Stop()
201- return
202- case <-timer.C():
203- }
204-
172+// flushPTYEffects preserves the normal response/title/attention/clipboard
173+// behavior for both direct reads and resize-buffer replay.
174+func (d *Daemon) flushPTYEffects(sess *session, tb *tab, p *pane) {
205175 p.mu.Lock()
206- if p.syncGen != gen || !p.screen.SyncUpdateActive() {
207- p.mu.Unlock()
208- return
209- }
210- p.screen.ForceSyncEnd()
176+ attention := p.ptyAttention
177+ p.ptyAttention = false
178+ responses := append([]byte(nil), p.ptyResponses...)
179+ p.ptyResponses = p.ptyResponses[:0]
180+ clipboards := append([]string(nil), p.ptyClipboards...)
181+ p.ptyClipboards = p.ptyClipboards[:0]
211182 p.mu.Unlock()
212- signal(p.flush)
213-}
214-
215-func syncUpdateEndIn(data []byte) bool {
216- return bytes.Contains(data, []byte("\x1b[?2026l"))
217-}
218-
219-// render paints the current client, or (when detached) just clears accumulated
220-// damage so it never grows unbounded while headless.
221-func (d *Daemon) render(sess *session, tb *tab, p *pane) {
222- if p == nil {
223- return
183+ if attention {
184+ d.noteAttention(sess, tb)
224185 }
225- p.mu.Lock()
226- if p.screen.SyncUpdateActive() {
227- p.mu.Unlock()
228- return
186+ for _, b64 := range clipboards {
187+ d.forwardClipboardAsync(sess, b64)
229188 }
230- p.mu.Unlock()
231-
232- // A retained floating pane continues to drain its PTY while hidden. Its
233- // scheduler must not route through picker preview (or the active client),
234- // but it must consume damage so a long-hidden command cannot retain it.
235- tb.mu.Lock()
236- hiddenFloating := tb.floating.pane == p && tb.floating.state != floatingVisible
237- previewer := tb.previewClient
238- tb.mu.Unlock()
239- if hiddenFloating {
240- p.mu.Lock()
241- p.screen.ClearDamage()
242- p.mu.Unlock()
243- return
244- }
245-
246- sess.mu.Lock()
247- ac := sess.client
248- active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
249- sess.mu.Unlock()
250-
251- if previewer != nil {
252- if previewSess := previewer.currentSession(); previewSess != nil {
253- d.paint(previewSess, previewer, false)
189+ if len(responses) > 0 {
190+ if _, err := p.pty.Write(responses); err != nil {
191+ d.log.Warn("pty response write failed", "err", err, "session", sess.name)
254192 }
255- if ac != nil && active && ac != previewer {
256- d.paint(sess, ac, false)
257- return
258- }
259- p.mu.Lock()
260- p.screen.ClearDamage()
261- p.mu.Unlock()
262- return
263- }
264-
265- if ac == nil || !active {
266- p.mu.Lock()
267- p.screen.ClearDamage()
268- p.mu.Unlock()
269- return
270193 }
271- d.refreshBarScriptsIfDue(sess, d.clock.Now(), false)
272- d.paint(sess, ac, false)
273194 }
274195
275196 // paint draws the composed client frame (active tab plus status bar) and
276197 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
277198 // such as switch/create/close/rename/resize so the repaint is complete.
278-// copyTargetRectLocked maps a captured copy source into the already-composed
279-// client frame. The caller holds tb.mu, preserving the layout/floating
280-// snapshot while the rectangle is chosen.
281-func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, floatingFrameGeometry floatingGeometry) domain.Rect {
282- if p == nil {
283- return domain.Rect{}
284- }
285- if hasFloating && p == floating {
286- return floatingFrameGeometry.Inner
287- }
288- for _, placement := range layoutSnap.placements {
289- if placement.ID == p.id && !placement.Collapsed {
290- r := placement.Content
291- r.X += contentArea.X
292- r.Y += contentArea.Y
293- return r
294- }
199+func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid bool) {
200+ if d != nil && d.runtimeObserver != nil {
201+ d.runtimeObserver.ObserveRuntime(ports.NewRuntimeMark("daemon", kind, bytes, valid))
295202 }
296- p.mu.Lock()
297- width, height := p.screen.Frame.Width, p.screen.Frame.Height
298- p.mu.Unlock()
299- return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)}
300203 }
301204
302-func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {
303- if !ok {
304- return nil
305- }
306- ids := make([]layout.PaneID, 0, len(placements))
307- for _, pl := range placements {
308- if pl.TitleBar.Height > 0 {
309- ids = append(ids, pl.ID)
310- }
205+// runtimeMarkBatch captures immutable runtime marks during a render transaction.
206+// paint owns attachment sendMu while it captures, composes, and emits; JSONL
207+// observer I/O must therefore happen only after that ownership is released.
208+type runtimeMarkBatch struct {
209+ observer ports.RuntimeObserver
210+ marks []ports.RuntimeMark
211+}
212+
213+func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
214+ if d == nil {
215+ return runtimeMarkBatch{}
311216 }
312- return ids
217+ return runtimeMarkBatch{observer: d.runtimeObserver}
313218 }
314219
315-func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) {
316- ac.resizePaint.stop()
317- ac.resizePaintGeneration++
318- generation := ac.resizePaintGeneration
319- ac.resizePaintPending = true
320- ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) {
321- d.paintForResizeGeneration(sess, ac, true, generation)
322- })
220+func (b *runtimeMarkBatch) span(start, end ports.RuntimeMarkKind, bytes uint64) func(uint64, bool) {
221+ if b == nil || b.observer == nil {
222+ return func(uint64, bool) {}
223+ }
224+ correlation := ports.NewRuntimeCorrelation()
225+ b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, start, bytes, true))
226+ return func(endBytes uint64, valid bool) {
227+ b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, end, endBytes, valid))
228+ }
323229 }
324230
325-func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
326- d.paintForResizeGeneration(sess, ac, reset, 0)
231+func (b *runtimeMarkBatch) flush() {
232+ if b == nil || b.observer == nil {
233+ return
234+ }
235+ for _, mark := range b.marks {
236+ b.observer.ObserveRuntime(mark)
237+ }
327238 }
328239
329-func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration uint64) {
240+func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *attachmentLease) {
241+ marks := d.newRuntimeMarkBatch()
330242 tb := sess.activeTab()
331243 if tb == nil {
332244 return
333245 }
334246
335- ac.initOverlays()
336247 ac.sendMu.Lock()
337- if ac.currentSession() != sess {
338- ac.sendMu.Unlock()
339- return
340- }
341- if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) {
248+ // sendMu is the attachment ownership boundary. Check the session's
249+ // published identity while holding it so a deadline captured before an
250+ // attach/replace cannot emit on either the old or new output chain.
251+ sess.mu.Lock()
252+ owned := sess.client == ac
253+ sess.mu.Unlock()
254+ if !owned || ac.currentSession() != sess {
342255 ac.sendMu.Unlock()
343256 return
344257 }
345- if ac.resizePaintPending {
346- ac.resizePaint.stop()
347- ac.resizePaintPending = false
348- reset = true
258+ if lease != nil {
259+ rc := sess.renderCoordinator()
260+ if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {
261+ ac.sendMu.Unlock()
262+ return
263+ }
349264 }
350- // Ack-gated coalescing: while the client is at least maxUnackedOutputStates
351- // behind, skip composing another diff and mark the paint deferred. The
352- // MsgOutputAck handler flushes one cumulative paint once acks catch up. A
353- // deferred reset stays sticky so the cumulative paint is dependency-free.
354- if ac.output.deferIfAtCapacity(reset) {
265+ // Capacity is checked before any destructive capture. The coordinator is
266+ // the normal gate, but this ownership check also protects direct resize and
267+ // test paint paths from consuming damage that cannot be emitted.
268+ if ac.output != nil && ac.output.atCapacity() {
269+ // The coordinator owns the blocked interval; this guard only protects
270+ // direct test/resize paint calls from destructive capture.
355271 ac.sendMu.Unlock()
356272 return
357273 }
274+ // Composition owns attachment sendMu; initialize its lazy overlay state
275+ // under that same ownership so concurrent fallback paints cannot observe a
276+ // partially published runtime.
277+ ac.initOverlays()
358278 overlays := ac.overlays.SnapshotForRender()
359279 repaintAttachedClients := false
360280 defer func() {
281+ overlays.Unlock()
282+ // Every return below has released sendMu before reaching this defer.
283+ // Emit the captured sequence before any follow-up repaint can introduce
284+ // another boundary, while no attachment, session, tab, or pane lock is held.
285+ marks.flush()
361286 if repaintAttachedClients {
362287 d.repaintAllAttachedClients()
363288 }
364289 }()
365- defer overlays.Unlock()
366290 preview := snapshotPickerPreview(nil)
367291 if overlays.previewTab != tb {
368292 preview = snapshotPickerPreview(overlays.previewTab)
@@ -375,11 +299,16 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
375299 attentionVisible := pulseVisible(bars.attentionFrame)
376300 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
377301
378- styles := newThemeStyles(ac.getTheme())
379302 paletteCfg := d.currentPaletteConfig()
303+ floatingCfg := d.currentFloatingConfig()
304+ // Title refresh may inspect process state, so it remains before the capture
305+ // boundary and outside tab/pane ownership locks.
380306 tb.mu.Lock()
381- layoutSnap := solveTabLayoutLocked(tb)
382- titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok)
307+ titleIDs := ac.renderScratch.titleIDs[:0]
308+ if tb.tree != nil {
309+ titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)
310+ }
311+ ac.renderScratch.titleIDs = titleIDs
383312 floating := tb.floating.pane
384313 hasFloating := tb.floating.state == floatingVisible && floating != nil
385314 tb.mu.Unlock()
@@ -390,189 +319,35 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
390319 d.refreshPaneDisplayTitle(sess, floating, false)
391320 }
392321
393- tb.mu.Lock()
394- if !layoutSnap.matchesLocked(tb) {
395- layoutSnap = solveTabLayoutLocked(tb)
322+ capturedOverlays := capturedOverlayRenderState{
323+ copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
324+ pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
325+ copyFeedback: overlays.copyFeedback,
396326 }
397- p := tb.focusedPane()
398- if p == nil {
399- tb.mu.Unlock()
400- ac.sendMu.Unlock()
401- return
402- }
403- // Recheck the slot after refreshing its title outside the tab lock. One
404- // paint uses one immutable floating config snapshot across composition,
405- // copy targeting, and cursor placement.
406- floating = tb.floating.pane
407- hasFloating = tb.floating.state == floatingVisible && floating != nil
408- floatingCfg := d.currentFloatingConfig()
409- overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
410- if reset || overlayActive {
411- ac.bars.Reset()
412- ac.composed.invalidate()
413- }
414- if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {
415- ac.lastCursor.valid = false
416- }
417- var frame renderer.Frame
418- var damage []renderer.Damage
419- if overlayActive {
420- frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, nil)
421- } else {
422- frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)
423- }
424- contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}
425- floatingFrameGeometry := floatingGeometry{}
426- if hasFloating {
427- desiredFloatingGeometry := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, floatingCfg)
428- frame, damage, floatingFrameGeometry = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, desiredFloatingGeometry, layoutSnap, bars.theme, &ac.composed, reset || overlayActive)
429- }
430- if overlays.copyActive {
431- copyPane := overlays.copyPane
432- if copyPane == nil {
433- copyPane = p
434- }
435- copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry)
436- frame, damage = composeCopyClientFrame(overlays.copyMode, overlays.copySnapshot, copyTarget, frame, bars)
437- }
438- // A palette above normal/copy content dims that composed content. When a
439- // floating pane is present its own backdrop already dims normal pane
440- // contents; applying the palette backdrop here would also dim the popup.
441- if overlays.paletteActive && !hasFloating {
442- (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme)
443- }
444- if overlays.copySearchModel != nil {
445- frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles)
446- }
447- if overlays.pickerActive {
448- if overlays.previewTab == tb {
449- if layoutSnap.ok && tb.tree != nil && tb.tree.Root != nil && tb.tree.Root.Kind != layout.Leaf {
450- previewFrame, _ := composeTabFrameWithLayout(tb, layoutSnap.area, themeui.Theme{}, layoutSnap)
451- preview = pickerPreviewFromFrame(previewFrame)
452- } else {
453- preview = pickerPreviewFromLockedTab(tb)
454- }
455- }
456- frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles)
457- }
458- if overlays.paletteActive {
459- guidance := ""
460- if overlays.paletteHints != nil {
461- guidance = overlays.paletteHints.Feedback
462- }
463- frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, guidance, styles)
464- }
465- if overlays.promptActive {
466- frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)
467- }
468- overlays.Unlock()
469- cursorPane := p
470- cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)
471- cursorContent.X += contentArea.X
472- cursorContent.Y += contentArea.Y
473- if hasFloating {
474- cursorPane = floating
475- cursorContent, cursorVisible = floatingFrameGeometry.Inner, floatingFrameGeometry.Inner.Width > 0 && floatingFrameGeometry.Inner.Height > 0
476- }
477- cursorPane.mu.Lock()
478- desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)
479- cursorPane.mu.Unlock()
480- ac.sess.mu.Lock()
481- if ac.sess.v != sess {
482- ac.sess.mu.Unlock()
483- tb.mu.Unlock()
327+ endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
328+ state, ok := capturePrimaryRenderState(sess, ac, bars, capturedOverlays, preview, floatingCfg, reset, lease)
329+ endCapture(0, ok)
330+ if !ok {
484331 ac.sendMu.Unlock()
485332 return
486333 }
487- prepared, err := ac.output.prepare(frame, damage, reset || overlayActive)
488- var data []byte
489- if err == nil {
490- data = prepared.data
491- }
492- var cursorTail []byte
493- if err == nil {
494- cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
495- }
496- tb.mu.Unlock()
497-
498- var serr error
499- var sendTr ports.Transport
500- if err == nil {
501- data = append(data, cursorTail...)
502- if len(data) > 0 {
503- sendTr = ac.transport()
504- if sendTr == nil {
505- serr = errors.New("client transport is nil")
506- } else {
507- send := sendTr.Send
508- if async, ok := sendTr.(ports.AsyncTransport); ok {
509- send = async.SendAsync
510- }
511- serr = prepared.send(data, ac.echoAck.Load(), send)
512- }
513- }
514- }
515- ac.sess.mu.Unlock()
516- ac.sendMu.Unlock()
517-
518- if err != nil {
519- d.log.Error("render draw failed", "err", err, "session", sess.name)
520- return
521- }
522- if serr != nil {
523- d.detachOnSendError(sess, ac, sendTr)
334+ if ac.renderStages.capture != nil {
335+ ac.renderStages.capture()
524336 }
525-}
526-
527-func focusedPaneContentRect(layoutSnap tabLayoutSnapshot, id layout.PaneID) (domain.Rect, bool) {
528- if !layoutSnap.ok {
529- return layoutSnap.area, layoutSnap.area.Width > 0 && layoutSnap.area.Height > 0
337+ if overlays.pickerActive && overlays.previewTab == tb {
338+ previewState := *state
339+ previewState.overlays = capturedOverlayRenderState{}
340+ previewState.reset = true
341+ state.preview = pickerPreviewFromCapturedRender(previewState)
530342 }
531- for _, pl := range layoutSnap.placements {
532- if pl.ID == id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {
533- return pl.Content, true
534- }
343+ captureOverlayLayers(state, overlays, paletteCfg)
344+ endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0)
345+ composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
346+ endCompose(0, true)
347+ if ac.renderStages.compose != nil {
348+ ac.renderStages.compose()
535349 }
536- return domain.Rect{}, false
537-}
538-
539-// desiredCursorOut computes the terminal cursor state that should be shown to
540-// the client for the current pane placement and overlay mode.
541-func desiredCursorOut(s *vt.Screen, content domain.Rect, hide bool) cursorOut {
542- if hide || !s.CursorVisible() {
543- return cursorOut{hidden: true}
544- }
545- style, ok := s.CursorStyle()
546- if !ok {
547- style = 1
548- }
549- return cursorOut{row: content.Y + s.CursorRow(), col: content.X + s.CursorCol(), style: style, hasStyle: true}
550-}
551-
552-func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
553- changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
554- if !changed {
555- return nil
556- }
557- prev := ac.lastCursor
558- ac.lastCursor = desired
559- ac.lastCursor.valid = true
560- if desired.hidden {
561- return []byte("\x1b[?25l")
562- }
563- var b []byte
564- b = append(b, "\x1b["...)
565- b = strconv.AppendInt(b, int64(desired.row+1), 10)
566- b = append(b, ';')
567- b = strconv.AppendInt(b, int64(desired.col+1), 10)
568- b = append(b, 'H')
569- if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
570- b = append(b, "\x1b["...)
571- b = strconv.AppendInt(b, int64(desired.style), 10)
572- b = append(b, " q"...)
573- }
574- b = append(b, "\x1b[?25h"...)
575- return b
350+ d.emitFrame(sess, ac, state, composed, &marks)
576351 }
577352
578353 type themeStyles struct {
@@ -638,211 +413,6 @@ func (c *barCache) Reset() {
638413 c.bottom = nil
639414 }
640415
641-type composedFrameCache struct {
642- frame renderer.Frame
643- valid bool
644- layoutSnap tabLayoutSnapshot
645- titleGenerations map[layout.PaneID]uint64
646- floating *pane
647- floatingFrame renderer.Frame
648- floatingGeneration uint64
649- floatingGeometry floatingGeometry
650- floatingTitleGeneration uint64
651-}
652-
653-func (c *composedFrameCache) invalidate() {
654- c.valid = false
655- c.floating = nil
656- c.floatingGeneration = 0
657- c.floatingGeometry = floatingGeometry{}
658- c.floatingTitleGeneration = 0
659-}
660-
661-func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
662- return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...)
663-}
664-
665-func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
666- return composeClientFrameWithLayout(bars, tb, full, solveTabLayoutLocked(tb), caches...)
667-}
668-
669-func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
670- var cache *barCache
671- if len(caches) > 0 {
672- cache = caches[0]
673- }
674- return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
675-}
676-
677-func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
678- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
679-}
680-
681-func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
682- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
683-}
684-
685-func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
686- p := tb.focusedPane()
687- styles := newThemeStyles(bars.theme)
688- if p == nil {
689- width, screenRows := tb.size.Cols, tb.size.Rows
690- if width <= 0 || screenRows <= 0 {
691- return renderer.NewFrame(0, 0), nil
692- }
693- return renderer.NewFrame(width, screenRows+2), nil
694- }
695- p.mu.Lock()
696- width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
697- p.mu.Unlock()
698- if tb.size.Valid() {
699- width, screenRows = tb.size.Cols, tb.size.Rows
700- }
701- cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
702- layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
703- var frame renderer.Frame
704- if cacheValid {
705- frame = composed.frame
706- } else {
707- frame = renderer.NewFrame(width, screenRows+2)
708- if composed != nil {
709- full = true
710- }
711- }
712- if !layoutSame {
713- full = true
714- }
715- contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
716- if cacheValid && !layoutSame {
717- clearFrameRect(frame, contentArea)
718- }
719- topBar := frame.Row(0)
720- drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
721- var titleGenerations map[layout.PaneID]uint64
722- if composed != nil {
723- if composed.titleGenerations == nil {
724- composed.titleGenerations = make(map[layout.PaneID]uint64)
725- }
726- titleGenerations = composed.titleGenerations
727- }
728- contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
729- bottomBar := frame.Row(screenRows + 1)
730- drawStatusBarState(bottomBar, bars, styles)
731- if composed != nil {
732- composed.frame = frame
733- composed.valid = true
734- composed.layoutSnap = layoutSnap
735- }
736- if full {
737- if cache != nil {
738- cache.capture(topBar, bottomBar)
739- }
740- return frame, []renderer.Damage{renderer.FullRedraw()}
741- }
742- damage := translateDamage(contentDamage, 0, 1)
743- if cache == nil || !sameCells(cache.top, topBar) {
744- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
745- }
746- if cache == nil || !sameCells(cache.bottom, bottomBar) {
747- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows + 1, Width: width, Height: 1})
748- }
749- if cache != nil {
750- cache.capture(topBar, bottomBar)
751- }
752- return frame, damage
753-}
754-
755-func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
756- return composeTabFrameWithLayout(tb, area, theme, tabLayoutSnapshot{})
757-}
758-
759-func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
760- frame := renderer.NewFrame(area.Width, area.Height)
761- damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
762- return frame, damage
763-}
764-
765-func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
766- return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false, nil)
767-}
768-
769-func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage {
770- contentArea := domain.Rect{Width: area.Width, Height: area.Height}
771- root := tb.tree.Root
772- placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
773- if !ok {
774- placements, ok = layout.Solve(root, contentArea)
775- }
776- var fallback *pane
777- if !ok {
778- fallback = tb.focusedPane()
779- if fallback == nil {
780- return nil
781- }
782- placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
783- }
784- // A valid cache has the same layout, so its title IDs remain valid. Reset
785- // the existing cache only when rebuilding after layout or frame churn.
786- if titleGenerations != nil && !cacheValid {
787- clear(titleGenerations)
788- }
789- if ok && !cacheValid {
790- drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
791- }
792- var damage []renderer.Damage
793- for _, pl := range placements {
794- p := tb.panes[pl.ID]
795- if p == nil && fallback != nil && pl.ID == fallback.id {
796- p = fallback
797- }
798- if p == nil {
799- continue
800- }
801- focused := tb.tree.Focus == pl.ID
802- pl = offsetPlacement(pl, area.X, area.Y)
803- if pl.TitleBar.Height > 0 {
804- generation := drawPaneTitleBar(frame, pl, p, focused, theme)
805- if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
806- titleDamage := pl.TitleBar
807- titleDamage.X -= area.X
808- titleDamage.Y -= area.Y
809- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
810- }
811- if titleGenerations != nil {
812- titleGenerations[pl.ID] = generation
813- }
814- }
815- if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
816- if consumeDamage {
817- p.mu.Lock()
818- p.screen.ClearDamage()
819- p.mu.Unlock()
820- }
821- continue
822- }
823- p.mu.Lock()
824- paneDamage := p.screen.Damage()
825- if !cacheValid || len(paneDamage) > 0 {
826- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
827- }
828- for _, d := range paneDamage {
829- localContent := pl.Content
830- localContent.X -= area.X
831- localContent.Y -= area.Y
832- damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
833- }
834- if consumeDamage {
835- p.screen.ClearDamage()
836- }
837- p.mu.Unlock()
838- }
839- return damage
840-}
841-
842-func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
843- return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
844-}
845-
846416 func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
847417 pl.Content.X += dx
848418 pl.Content.Y += dy
@@ -851,15 +421,6 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
851421 return pl
852422 }
853423
854-func clearFrameRect(frame renderer.Frame, r domain.Rect) {
855- blank := renderer.BlankCell()
856- for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
857- for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
858- frame.Set(x, y, blank)
859- }
860- }
861-}
862-
863424 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {
864425 rows := min(r.Height, src.Height)
865426 cols := min(r.Width, src.Width)
@@ -874,25 +435,6 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo
874435 }
875436 }
876437
877-func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {
878- styles := newThemeStyles(theme)
879- style := styles.border
880- if focused {
881- style = styles.statusBar
882- } else {
883- style = themeui.DimStyle(style, theme)
884- }
885- for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
886- frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
887- }
888- p.mu.Lock()
889- title := p.displayTitleLocked()
890- generation := p.title.generation
891- p.mu.Unlock()
892- ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
893- return generation
894-}
895-
896438 func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {
897439 if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 {
898440 return
A internal/usecase/daemon/render_capture.go +335 -0
@@ -0,0 +1,335 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ scopy "github.com/bnema/vev/internal/usecase/copy"
6+ "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/internal/usecase/picker"
8+ themeui "github.com/bnema/vev/internal/usecase/theme"
9+ "github.com/bnema/vev/internal/usecase/ui"
10+ "github.com/bnema/vev/pkg/renderer"
11+)
12+
13+// capturedRenderState is the immutable hand-off from authoritative daemon
14+// state to composition. Every field is a value or an owned bounded copy; the
15+// composer must not retain or consult session, tab, pane, or overlay owners.
16+// renderCaptureScratch owns bounded snapshots for one attachment. The complete
17+// capture/compose/emit transaction holds that attachment's sendMu, so these
18+// buffers cannot be observed or reused before emission completes.
19+type renderCaptureScratch struct {
20+ state capturedRenderState
21+ panes []capturedPaneRenderState
22+ placements []layout.Placement
23+ statusTabs []statusTab
24+ mru []recentSession
25+ ranked []rankedRecent
26+ titleIDs []layout.PaneID
27+ treeNodes []layout.Node
28+ treeUsed int
29+ receipts []damageReceipt
30+}
31+
32+type damageReceipt struct {
33+ pane *pane
34+ generation uint64
35+}
36+
37+type capturedRenderState struct {
38+ attachment *attachedClient // identity only; never dereferenced by composition
39+ lease *attachmentLease
40+ reset bool
41+ layout capturedTabLayout
42+ panes []capturedPaneRenderState
43+ floating capturedFloatingRenderState
44+ bars barState
45+ theme themeui.Theme
46+ overlays capturedOverlayRenderState
47+ preview picker.Preview
48+ cursor capturedCursorInputs
49+ tabGeneration uint64
50+ floatingGeneration uint64
51+ receipts []damageReceipt // private capture receipts; compose must not inspect these
52+}
53+
54+type capturedTabLayout struct {
55+ root *layout.Node
56+ area domain.Rect
57+ focus layout.PaneID
58+ placements []layout.Placement
59+ fingerprint string
60+ valid bool
61+}
62+
63+type capturedPaneRenderState struct {
64+ id layout.PaneID
65+ frame renderer.Frame
66+ rawDamage []renderer.Damage
67+ damage []renderer.Damage
68+ damageGeneration uint64
69+ title string
70+ titleGeneration uint64
71+ placement layout.Placement
72+ focused bool
73+}
74+
75+type capturedFloatingRenderState struct {
76+ visible bool
77+ pane capturedPaneRenderState
78+ geometry floatingGeometry
79+ title string
80+ generation uint64
81+ titleGeneration uint64
82+}
83+
84+type capturedOverlayRenderState struct {
85+ copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool
86+ copyMode *scopy.Mode
87+ copySnapshot *scopy.Snapshot
88+ copyPaneID layout.PaneID
89+ copyFeedback string
90+ paletteGuidance string
91+ copySearch, picker, palette, prompt capturedModal
92+}
93+
94+type capturedModal struct {
95+ modal ui.Modal
96+ inner renderer.Frame
97+}
98+
99+func (o capturedOverlayRenderState) active() bool {
100+ return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive
101+}
102+
103+type capturedCursorInputs struct {
104+ row, col, style int
105+ hasStyle, visible, renderable, hiddenByOverlay bool
106+ content domain.Rect
107+}
108+
109+// capturePaneRenderStateLocked copies only the visible rectangle required by
110+// composition. It never copies scrollback/history and never lets the mutable
111+// VT frame's Cells or row-offset slices escape pane.mu.
112+func capturePaneRenderStateLocked(p *pane, visible domain.Rect) capturedPaneRenderState {
113+ return capturePaneRenderStateLockedInto(p, visible, capturedPaneRenderState{})
114+}
115+
116+func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out capturedPaneRenderState) capturedPaneRenderState {
117+ out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation
118+ damage := p.screen.CaptureDamage()
119+ out.damageGeneration = damage.Generation
120+ out.rawDamage = append(out.rawDamage[:0], damage.Damage...)
121+
122+ width := min(max(visible.Width, 0), p.screen.Frame.Width)
123+ height := min(max(visible.Height, 0), p.screen.Frame.Height)
124+ needsFrame := len(damage.Damage) > 0
125+ frameChanged := out.frame.Width != width || out.frame.Height != height
126+ if frameChanged {
127+ out.frame = renderer.NewFrame(width, height)
128+ needsFrame = true
129+ }
130+ // A retained attachment snapshot is already an immutable copy of this
131+ // pane. With no VT damage, retain it rather than copying inactive panes.
132+ if needsFrame {
133+ for y := range height {
134+ copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width])
135+ }
136+ }
137+
138+ if frameChanged || uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
139+ // A cache entry with a new frame has no terminal-shadow equivalent.
140+ // Redraw it even when the pane has no VT damage, such as an untouched
141+ // pane first made visible after a tab or session switch.
142+ out.damage = []renderer.Damage{renderer.FullRedraw()}
143+ } else {
144+ out.damage = append(out.damage[:0], damage.Damage...)
145+ }
146+ // Consumption is transactional: capture only snapshots damage. emitFrame
147+ // acknowledges this generation after preparation and transport success.
148+ return out
149+}
150+
151+func uncertainDamage(damage []renderer.Damage, width, height int) bool {
152+ for _, d := range damage {
153+ if d.Kind == renderer.DamageFullRedraw {
154+ if d != renderer.FullRedraw() {
155+ return true
156+ }
157+ continue
158+ }
159+ if d.Kind < renderer.DamageText || d.Kind > renderer.DamageScrollUp || d.X < 0 || d.Y < 0 || d.Width <= 0 || d.Height <= 0 || d.X+d.Width > width || d.Y+d.Height > height {
160+ return true
161+ }
162+ if d.Kind == renderer.DamageScrollUp && (d.Count <= 0 || d.Count > d.Height) {
163+ return true
164+ }
165+ }
166+ return false
167+}
168+
169+// capturePrimaryRenderState is the ownership boundary for a primary render
170+// transaction. Callers hold attachment sendMu; this function then follows
171+// session -> tab -> pane lock order. ACK-blocked capture returns before touching
172+// VT damage, and every captured pane records a receipt for successful emission.
173+func capturePrimaryRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, floatingCfg domain.FloatingConfig, reset bool, lease *attachmentLease) (*capturedRenderState, bool) {
174+ if sess == nil || ac == nil || (ac.output != nil && ac.output.atCapacity()) {
175+ return nil, false
176+ }
177+ sess.mu.Lock()
178+ if sess.client != ac || sess.active < 0 || sess.active >= len(sess.tabs) {
179+ sess.mu.Unlock()
180+ return nil, false
181+ }
182+ tb := sess.tabs[sess.active]
183+ sess.mu.Unlock()
184+
185+ scratch := &ac.renderScratch
186+ scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
187+ bars.status.tabs = scratch.statusTabs
188+ scratch.mru = append(scratch.mru[:0], bars.mru...)
189+ bars.mru = scratch.mru
190+ if bars.rankedRecent != nil {
191+ scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent)
192+ bars.rankedRecent = scratch.ranked
193+ }
194+ if len(preview.Rows) > 0 {
195+ preview = clonePickerPreview(preview)
196+ } else {
197+ preview = picker.Preview{Width: preview.Width, Height: preview.Height}
198+ }
199+
200+ tb.mu.Lock()
201+ defer tb.mu.Unlock()
202+ layoutSnap := solveTabLayoutLocked(tb)
203+ scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
204+ scratch.treeUsed = 0
205+ var root *layout.Node
206+ if tb.tree != nil {
207+ root = cloneLayoutNodeIntoScratch(tb.tree.Root, scratch)
208+ }
209+ state := &scratch.state
210+ *state = capturedRenderState{
211+ attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,
212+ overlays: overlays, preview: preview,
213+ layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
214+ floatingGeneration: tb.floating.generation,
215+ receipts: scratch.receipts[:0],
216+ }
217+ state.panes = scratch.panes[:0]
218+ state.tabGeneration = uint64(len(layoutSnap.fingerprint))
219+ if ac.captureFrames == nil {
220+ ac.captureFrames = make(map[*pane]capturedPaneRenderState)
221+ }
222+ for _, placement := range layoutSnap.placements {
223+ p := tb.panes[placement.ID]
224+ if p == nil {
225+ continue
226+ }
227+ visible := placement.Content
228+ if placement.Collapsed {
229+ visible = domain.Rect{}
230+ }
231+ p.mu.Lock()
232+ captured := capturePaneRenderStateLockedInto(p, visible, ac.captureFrames[p])
233+ state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
234+ captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
235+ if captured.focused {
236+ state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)
237+ }
238+ p.mu.Unlock()
239+ translated := captured.rawDamage[:0]
240+ for _, damage := range captured.damage {
241+ translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
242+ }
243+ captured.rawDamage, captured.damage = captured.damage, translated
244+ ac.captureFrames[p] = captured
245+ state.panes = append(state.panes, captured)
246+ }
247+ scratch.panes = state.panes
248+ if tb.floating.state == floatingVisible && tb.floating.pane != nil {
249+ p := tb.floating.pane
250+ p.mu.Lock()
251+ geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
252+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, ac.captureFrames[p])
253+ seen := false
254+ for _, receipt := range state.receipts {
255+ if receipt.pane == p {
256+ seen = true
257+ break
258+ }
259+ }
260+ if !seen {
261+ state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
262+ }
263+ ac.captureFrames[p] = captured
264+ state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
265+ state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
266+ p.mu.Unlock()
267+ }
268+ scratch.receipts = state.receipts
269+ return state, true
270+}
271+
272+// copyRankedRecentInto preserves the non-nil empty slice that selects
273+// contextual recent-session mode while retaining scratch capacity for reuse.
274+func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
275+ if len(src) == 0 {
276+ if dst != nil {
277+ return dst[:0]
278+ }
279+ return []rankedRecent{}
280+ }
281+ return append(dst[:0], src...)
282+}
283+
284+func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node {
285+ if src == nil {
286+ return nil
287+ }
288+ if scratch.treeUsed == len(scratch.treeNodes) {
289+ scratch.treeNodes = append(scratch.treeNodes, layout.Node{})
290+ }
291+ index := scratch.treeUsed
292+ scratch.treeUsed++
293+ dst := &scratch.treeNodes[index]
294+ children := dst.Children[:0]
295+ *dst = *src
296+ dst.Children = children
297+ for _, child := range src.Children {
298+ dst.Children = append(dst.Children, cloneLayoutNodeIntoScratch(child, scratch))
299+ }
300+ return dst
301+}
302+
303+func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
304+ style, hasStyle := p.screen.CursorStyle()
305+ hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
306+ return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content}
307+}
308+
309+func clonePickerPreview(in picker.Preview) picker.Preview {
310+ out := picker.Preview{Width: in.Width, Height: in.Height, Rows: make([][]renderer.Cell, len(in.Rows))}
311+ for i := range in.Rows {
312+ out.Rows[i] = append([]renderer.Cell(nil), in.Rows[i]...)
313+ }
314+ return out
315+}
316+
317+// appendStackPaneIDs returns exactly the panes whose solved placement can own a
318+// stack title bar. The caller holds tab.mu, and dst is attachment scratch.
319+func appendStackPaneIDs(dst []layout.PaneID, node *layout.Node) []layout.PaneID {
320+ if node == nil {
321+ return dst
322+ }
323+ if node.Kind == layout.Stack {
324+ for _, child := range node.Children {
325+ if child != nil && child.Kind == layout.Leaf {
326+ dst = append(dst, child.Leaf)
327+ }
328+ }
329+ return dst
330+ }
331+ for _, child := range node.Children {
332+ dst = appendStackPaneIDs(dst, child)
333+ }
334+ return dst
335+}
A internal/usecase/daemon/render_coordinator.go +880 -0
@@ -0,0 +1,880 @@
1+package daemon
2+
3+import (
4+ "runtime"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+type renderCoordinator struct {
12+ mu sync.Mutex
13+ opts renderCoordinatorOptions
14+
15+ // Coalesced pending state: latest wins, reset stays sticky, and at most
16+ // one deadline wake may be armed at a time (cap-1).
17+ pending bool
18+ pendingReset bool
19+ pendingUrgent bool
20+ // queueMarked spans the coordinator's actual pending interval, from first
21+ // enqueue until fire consumes the coalesced work.
22+ queueMarked bool
23+ queueCorrelation ports.RuntimeCorrelation
24+ // pendingPreview is accounted separately from the target primary wake:
25+ // viewers may consume a coalesced target snapshot while that target is
26+ // still ACK-blocked.
27+ pendingPreview bool
28+ // ackDeferred records that an expired deadline or watchdog was blocked by
29+ // output-window capacity. ACK notifications may flush only this state.
30+ ackDeferred bool
31+ // ackBlocked retains the first rejected ACK-capacity probe until the
32+ // matching consume or lifecycle closure releases that exact interval.
33+ ackBlocked *ackBlockedSpan
34+ // deadlineDue closes the handoff between a timer worker receiving its tick
35+ // and fire observing unavailable ACK capacity.
36+ deadlineDue bool
37+ coalesced int
38+ // outputPressure carries recent bulk-burst pressure between completed
39+ // batches. It selects a bounded 8–16ms deadline without letting a busy
40+ // batch perpetually reset its own timer.
41+ outputPressure int
42+
43+ // previewWake receives the same coalesced wakes for the legacy single
44+ // subscriber. previewWakes tracks picker subscriptions by viewer, so one
45+ // inactive session cannot replace another viewer's live preview.
46+ previewWake func(renderWake)
47+ previewWakes map[*attachedClient]previewSubscription
48+
49+ // lease is the sole primary attachment lifecycle state. A revoked lease is
50+ // retained after detach so headless preview delivery remains distinguishable
51+ // from an unbound test coordinator.
52+ lease *attachmentLease
53+ torndown bool
54+
55+ resize resizeRequestMetadata
56+ // Each lane owns one complete immutable worker token at a time.
57+ resizeLane timerLane
58+ retryLane timerLane
59+ metrics renderCoordinatorBurstMetrics
60+ // normalLane owns render invalidation deadlines. The supervisor is only
61+ // waited by the session owner after terminal teardown has rejected future
62+ // registrations.
63+ normalLane timerLane
64+ armed bool
65+ supervisor timerSupervisor
66+ // syncBatches is keyed by the stable pane owner. Each pane owns an
67+ // independent watchdog; composition remains gated until every batch ends.
68+ syncBatches map[*pane]*syncBatch
69+ // syncRegistryVersion changes for every batch identity mutation. fire
70+ // snapshots it before evaluating visibility and retries if a producer
71+ // publishes or completes a batch during that unlocked evaluation.
72+ syncRegistryVersion uint64
73+}
74+
75+// newRenderCoordinator constructs a coordinator bound to opts. It starts no
76+// goroutine; deadline scheduling is armed lazily by invalidate.
77+func newRenderCoordinator(opts renderCoordinatorOptions) *renderCoordinator {
78+ return &renderCoordinator{opts: opts}
79+}
80+
81+// detachNormalTimerLocked removes the complete current token. The caller may
82+// stop it only after dropping c.mu and must never wait for its worker.
83+func (c *renderCoordinator) detachNormalTimerLocked() *timerToken {
84+ return c.normalLane.detachLocked()
85+}
86+
87+type syncBatch struct {
88+ generation uint64
89+ // renderable is evaluated outside c.mu: tab/session visibility locks may be
90+ // acquired by this predicate.
91+ renderable func() bool
92+ force func()
93+ lane timerLane
94+}
95+
96+func detachSyncBatchLocked(batch *syncBatch) *timerToken { return batch.lane.detachLocked() }
97+
98+func (c *renderCoordinator) detachSyncTimersLocked() []*timerToken {
99+ workers := make([]*timerToken, 0, len(c.syncBatches))
100+ for pane, batch := range c.syncBatches {
101+ workers = append(workers, detachSyncBatchLocked(batch))
102+ delete(c.syncBatches, pane)
103+ c.syncRegistryVersion++
104+ }
105+ return workers
106+}
107+
108+// invalidate publishes a coordinator-owned producer transition. Callers that
109+// carry an attachment identity must use invalidateForAttachment instead.
110+func (c *renderCoordinator) invalidate(inv renderInvalidation) {
111+ c.invalidateForAttachment(nil, inv)
112+}
113+
114+// invalidateForAttachment publishes only while source is still the bound
115+// attachment. This check shares c.mu with replacement, making a stale
116+// attachment's timer callback unable to enqueue work for its replacement.
117+// A nil source represents an internal pane/session invalidation.
118+func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) bool {
119+ return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv)
120+}
121+
122+// invalidateForLease publishes an attachment-owned transition only for the
123+// exact lifecycle incarnation that captured it.
124+func (c *renderCoordinator) invalidateForLease(source *attachedClient, lease *attachmentLease, inv renderInvalidation) bool {
125+ return c.invalidateForLeaseAtResizeEpoch(source, lease, 0, inv)
126+}
127+
128+// invalidateForAttachmentAtResizeEpoch additionally requires epoch to remain
129+// current when nonzero, so a completed resize cannot publish after a newer
130+// request supersedes it.
131+func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attachedClient, epoch uint64, inv renderInvalidation) bool {
132+ return c.invalidateForLeaseAtResizeEpoch(source, nil, epoch, inv)
133+}
134+
135+// invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it
136+// rejects a same-object replacement whose source pointer still matches but
137+// whose lifecycle lease has been revoked.
138+func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {
139+ c.mu.Lock()
140+ // An unbound coordinator is the manual/headless harness state; there is no
141+ // published production attachment to protect there. A parked target may
142+ // still publish its own PTY/session mutations to picker observers, but no
143+ // attachment-owned callback may revive that target's render path.
144+ detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
145+ if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||
146+ (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) ||
147+ (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {
148+ c.mu.Unlock()
149+ return false
150+ }
151+ onInvalidate := c.opts.onInvalidate
152+ c.metrics.invalidations.Add(1)
153+ wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview
154+ queueStart := false
155+ var queueCorrelation ports.RuntimeCorrelation
156+ if !wasPending {
157+ c.ackDeferred = false
158+ c.deadlineDue = false
159+ if c.opts.observer != nil {
160+ c.queueMarked = true
161+ c.queueCorrelation = ports.NewRuntimeCorrelation()
162+ queueCorrelation = c.queueCorrelation
163+ queueStart = true
164+ }
165+ }
166+ c.pending = true
167+ c.pendingReset = c.pendingReset || inv.reset
168+ c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
169+ c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0
170+ c.coalesced++
171+ // A deadline may have expired while synchronized output still gated the
172+ // pending work. Completion republishes urgently and must reserve a fresh
173+ // deadline rather than leaving that already-fired timer as the only arm.
174+ arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
175+ var old *timerToken
176+ if arm {
177+ _, old = c.normalLane.replaceLocked()
178+ }
179+ gen := c.normalLane.generation
180+ delay := minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond
181+ if c.pendingUrgent {
182+ delay = urgentRenderDeadline
183+ }
184+ clock := c.opts.clock
185+ c.armed = c.armed || arm
186+ observer := c.opts.observer
187+ c.mu.Unlock()
188+ if queueStart && observer != nil {
189+ observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))
190+ }
191+ stopDetachedTimer(old)
192+ if !arm || clock == nil {
193+ if onInvalidate != nil {
194+ onInvalidate(inv)
195+ }
196+ return true
197+ }
198+
199+ // NewTimer and C may re-enter the coordinator. Publish only if this
200+ // generation is still the reserved deadline after those external calls.
201+ timer := clock.NewTimer(delay)
202+ timerC := timer.C()
203+ c.mu.Lock()
204+ valid := !c.torndown && c.pending && c.normalLane.generation == gen
205+ var token *timerToken
206+ if valid && timerC != nil {
207+ token = c.normalLane.publishLocked(gen, timer)
208+ valid = token != nil
209+ if valid {
210+ c.supervisor.startLocked(token, timerC, func() {
211+ c.markDeadlineDue(token)
212+ c.fireFromTimer(token, false, true)
213+ })
214+ }
215+ }
216+ c.mu.Unlock()
217+ if !valid {
218+ timer.Stop()
219+ if onInvalidate != nil {
220+ onInvalidate(inv)
221+ }
222+ return true
223+ }
224+ if timerC == nil {
225+ timer.Stop()
226+ c.fire(gen, false, true)
227+ if onInvalidate != nil {
228+ onInvalidate(inv)
229+ }
230+ return true
231+ }
232+ // Test-visible observation follows deadline publication, so a producer
233+ // callback cannot observe a successful invalidation before its timer owns
234+ // a worker (and race tests cannot finish while a mock expectation remains).
235+ if onInvalidate != nil {
236+ onInvalidate(inv)
237+ }
238+ runtime.Gosched()
239+ return true
240+}
241+
242+// notifyAck reports that the client acknowledged an output state, releasing
243+// at most one deadline/watchdog-deferred wake. It never bypasses an unexpired
244+// normal or urgent deadline.
245+func (c *renderCoordinator) notifyAck() {
246+ c.mu.Lock()
247+ if (!c.ackDeferred && !c.deadlineDue) || !c.pending {
248+ c.mu.Unlock()
249+ return
250+ }
251+ gen := c.normalLane.generation
252+ blocked := c.ackBlocked
253+ c.ackBlocked = nil
254+ c.mu.Unlock()
255+ if blocked != nil {
256+ blocked.finish(true)
257+ }
258+ c.fire(gen, false, false)
259+}
260+
261+// noteSyncBegin records a synchronized-output batch for its stable pane and
262+// arms that pane's watchdog. Overlapping pane batches are independent.
263+// markDeadlineDue records a received timer tick before the worker probes ACK
264+// readiness, so a concurrent ACK cannot miss an already-expired deadline.
265+func (c *renderCoordinator) markDeadlineDue(token *timerToken) {
266+ c.mu.Lock()
267+ if c.fireValidLocked(token, token.generation, false) {
268+ c.deadlineDue = true
269+ }
270+ c.mu.Unlock()
271+}
272+
273+func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) {
274+ var renderable func() bool
275+ if c.opts.syncRenderable != nil {
276+ renderable = func() bool { return c.opts.syncRenderable(p) }
277+ }
278+ c.noteSyncBeginWithRenderability(p, gen, renderable, force...)
279+}
280+
281+// noteSyncBeginWithRenderability records lifecycle unconditionally while the
282+// supplied predicate decides dynamically whether this batch gates composition.
283+func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64, renderable func() bool, force ...func()) {
284+ c.mu.Lock()
285+ if c.torndown {
286+ c.mu.Unlock()
287+ return
288+ }
289+ if c.syncBatches == nil {
290+ c.syncBatches = make(map[*pane]*syncBatch)
291+ }
292+ var old *timerToken
293+ if previous := c.syncBatches[p]; previous != nil {
294+ old = detachSyncBatchLocked(previous)
295+ }
296+ batch := &syncBatch{generation: gen, renderable: renderable}
297+ if len(force) != 0 {
298+ batch.force = force[0]
299+ }
300+ c.syncBatches[p] = batch
301+ c.syncRegistryVersion++
302+ clock := c.opts.clock
303+ c.mu.Unlock()
304+ stopDetachedTimer(old)
305+ if clock == nil {
306+ return
307+ }
308+
309+ // Both timer operations are external. A reentrant callback may replace or
310+ // remove batch; identity validation below rejects this stale timer.
311+ timer := clock.NewTimer(maxSyncUpdateDuration)
312+ timerC := timer.C()
313+ c.mu.Lock()
314+ valid := !c.torndown && c.syncBatches[p] == batch && batch.generation == gen && batch.lane.generation == 0
315+ var token *timerToken
316+ if valid && timerC != nil {
317+ batch.lane.generation = gen
318+ token = batch.lane.publishLocked(gen, timer)
319+ valid = token != nil
320+ if valid {
321+ c.supervisor.startLocked(token, timerC, func() { c.runSyncWatchdog(p, batch, token) })
322+ }
323+ }
324+ c.mu.Unlock()
325+ if !valid || timerC == nil {
326+ stopDetachedTimer(&timerToken{timer: timer})
327+ return
328+ }
329+}
330+
331+func (c *renderCoordinator) runSyncWatchdog(p *pane, batch *syncBatch, token *timerToken) {
332+ // Keep the batch registered while force runs. A concurrent render must
333+ // continue to observe the synchronized-output gate until the VT has
334+ // authoritatively closed the batch.
335+ c.mu.Lock()
336+ current := c.syncBatches[p]
337+ valid := !c.torndown && current == batch && current.generation == token.generation && current.lane.token == token
338+ c.mu.Unlock()
339+ if !valid {
340+ return
341+ }
342+ if batch.force != nil {
343+ batch.force()
344+ }
345+
346+ // force may synchronously end this batch, replace it with a newer
347+ // generation, or tear down the coordinator. Remove only the exact
348+ // snapshot that expired, then publish one urgent completion wake.
349+ c.mu.Lock()
350+ current = c.syncBatches[p]
351+ valid = !c.torndown && current == batch && current.generation == token.generation && current.lane.token == token
352+ var stopped *timerToken
353+ if valid {
354+ delete(c.syncBatches, p)
355+ c.syncRegistryVersion++
356+ stopped = detachSyncBatchLocked(batch)
357+ if len(c.syncBatches) == 0 && c.pending {
358+ c.pendingUrgent = true
359+ }
360+ }
361+ c.mu.Unlock()
362+ if valid {
363+ stopDetachedTimer(stopped)
364+ // Reevaluate the aggregate gate only after forcing this pane.
365+ c.fireCurrent(true)
366+ }
367+}
368+
369+// noteSyncEnd records completion for exactly one pane batch. It only flushes
370+// after the aggregate gate opens.
371+func (c *renderCoordinator) noteSyncEnd(p *pane, gen uint64) {
372+ // Visibility may acquire session/tab locks, so evaluate it outside c.mu.
373+ c.mu.Lock()
374+ batch := c.syncBatches[p]
375+ if batch == nil || batch.generation != gen {
376+ c.mu.Unlock()
377+ return
378+ }
379+ renderable := batch.renderable
380+ c.mu.Unlock()
381+ wasRenderable := renderable == nil || renderable()
382+ if c.removeSyncEnd(p, gen, wasRenderable) {
383+ c.fireCurrent(false)
384+ }
385+}
386+
387+// removeSyncEnd removes a batch without invoking a predicate or callback. PTY
388+// processing calls it while pane.mu is still held, so a deadline can never
389+// compose the post-Write partial screen between mutation and publication.
390+// The caller must invoke fireCurrent only after releasing pane.mu.
391+func (c *renderCoordinator) removeSyncEnd(p *pane, gen uint64, renderable bool) bool {
392+ c.mu.Lock()
393+ batch := c.syncBatches[p]
394+ if batch == nil || batch.generation != gen {
395+ c.mu.Unlock()
396+ return false
397+ }
398+ delete(c.syncBatches, p)
399+ c.syncRegistryVersion++
400+ worker := detachSyncBatchLocked(batch)
401+ if renderable && len(c.syncBatches) == 0 && c.pending {
402+ c.pendingUrgent = true
403+ }
404+ c.mu.Unlock()
405+ stopDetachedTimer(worker)
406+ return renderable
407+}
408+
409+// noteSyncPaneRemoved releases a pane watchdog when pane lifecycle ends.
410+func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) {
411+ c.mu.Lock()
412+ var worker *timerToken
413+ if batch := c.syncBatches[p]; batch != nil {
414+ delete(c.syncBatches, p)
415+ c.syncRegistryVersion++
416+ worker = detachSyncBatchLocked(batch)
417+ }
418+ c.mu.Unlock()
419+ stopDetachedTimer(worker)
420+}
421+
422+// subscribePreview installs fn as the preview observer for coalesced wakes.
423+func (c *renderCoordinator) subscribePreview(fn func(renderWake)) {
424+ c.mu.Lock()
425+ c.previewWake = fn
426+ c.mu.Unlock()
427+}
428+
429+// teardownPreview removes the legacy preview subscription.
430+func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() }
431+
432+type previewSubscription struct {
433+ generation uint64
434+ fn func(renderWake)
435+}
436+
437+// subscribePreviewFor installs the dynamic picker observer owned by viewer.
438+// A newer picker generation wins, so a delayed subscription cannot replace it.
439+func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, generation uint64, fn func(renderWake)) bool {
440+ if viewer == nil || fn == nil {
441+ return false
442+ }
443+ c.mu.Lock()
444+ defer c.mu.Unlock()
445+ if c.torndown {
446+ return false
447+ }
448+ if current, ok := c.previewWakes[viewer]; ok && current.generation > generation {
449+ return false
450+ }
451+ if c.previewWakes == nil {
452+ c.previewWakes = make(map[*attachedClient]previewSubscription)
453+ }
454+ c.previewWakes[viewer] = previewSubscription{generation: generation, fn: fn}
455+ return true
456+}
457+
458+// teardownPreviewFor removes viewer's observer only when it still belongs to
459+// generation. A delayed teardown cannot clear a replacement subscription.
460+func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generation uint64) {
461+ c.mu.Lock()
462+ if current, ok := c.previewWakes[viewer]; ok && current.generation == generation {
463+ delete(c.previewWakes, viewer)
464+ }
465+ c.mu.Unlock()
466+}
467+
468+// primaryDetachedLocked distinguishes an explicitly detached primary from an
469+// unbound coordinator used by headless tests. Preview subscribers remain live
470+// for the former, but it can never regain a primary transport without a lease.
471+func (c *renderCoordinator) primaryDetachedLocked() bool { return c.lease != nil && !c.lease.active }
472+
473+func (c *renderCoordinator) installLeaseLocked(ac *attachedClient, ready bool) *attachmentLease {
474+ if c.lease != nil {
475+ c.lease.active = false
476+ }
477+ c.lease = &attachmentLease{attachment: ac, ready: ready, active: ac != nil}
478+ return c.lease
479+}
480+
481+// attachmentLease returns the exact currently published lease for ac. Welcome
482+// continuations capture this before their transport write and cannot later
483+// bless a same-object park/resume replacement.
484+func (c *renderCoordinator) attachmentLease(ac *attachedClient) *attachmentLease {
485+ c.mu.Lock()
486+ defer c.mu.Unlock()
487+ if c.torndown || c.lease == nil || !c.lease.active || c.lease.attachment != ac {
488+ return nil
489+ }
490+ return c.lease
491+}
492+
493+func (c *renderCoordinator) leaseCurrent(lease *attachmentLease, ready bool) bool {
494+ c.mu.Lock()
495+ defer c.mu.Unlock()
496+ return c.leaseCurrentLocked(lease, ready)
497+}
498+
499+// leaseCurrentLocked is a short, non-reentrant effect admission check. It
500+// must not be used to retain c.mu across input routing, transport operations,
501+// or other arbitrary handler work.
502+func (c *renderCoordinator) leaseCurrentLocked(lease *attachmentLease, ready bool) bool {
503+ return !c.torndown && lease != nil && c.lease == lease && lease.active && (!ready || lease.ready)
504+}
505+
506+// attach binds an already-handshaken internal attachment. Route/resume use
507+// attachWithReadiness(..., false) until their Welcome frame is accepted.
508+func (c *renderCoordinator) attach(ac *attachedClient) { c.attachWithReadiness(ac, true) }
509+
510+func (c *renderCoordinator) attachWithReadiness(ac *attachedClient, ready bool) {
511+ c.mu.Lock()
512+ if !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment != ac) {
513+ c.installLeaseLocked(ac, ready)
514+ }
515+ c.mu.Unlock()
516+}
517+
518+// markAttachmentReady completes only the captured attachment incarnation.
519+// A stale Welcome must never bless a replacement that reused its client object.
520+func (c *renderCoordinator) markAttachmentReady(lease *attachmentLease) bool {
521+ c.mu.Lock()
522+ defer c.mu.Unlock()
523+ if !c.leaseCurrentLocked(lease, false) {
524+ return false
525+ }
526+ lease.ready = true
527+ return true
528+}
529+
530+// wakeCurrent validates a dispatched wake. paint repeats this check after
531+// sendMu is acquired, so a reused attachedClient cannot revive an old wake.
532+func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
533+ c.mu.Lock()
534+ defer c.mu.Unlock()
535+ return c.leaseCurrentLocked(w.lease, true)
536+}
537+
538+type renderLifecycleCleanup struct {
539+ tokens []*timerToken
540+ observer ports.RuntimeObserver
541+ queueCorrelation ports.RuntimeCorrelation
542+ queueMarked bool
543+ ackBlocked *ackBlockedSpan
544+}
545+
546+func (cleanup renderLifecycleCleanup) finish() {
547+ for _, token := range cleanup.tokens {
548+ stopDetachedTimer(token)
549+ }
550+ if cleanup.queueMarked && cleanup.observer != nil {
551+ cleanup.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", cleanup.queueCorrelation, ports.RuntimeQueueDequeued, 0, false))
552+ }
553+ if cleanup.ackBlocked != nil {
554+ cleanup.ackBlocked.finish(false)
555+ }
556+}
557+
558+// beginDetach invalidates pending work. It never waits for detached workers.
559+func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleCleanup {
560+ c.mu.Lock()
561+ var cleanup renderLifecycleCleanup
562+ if c.lease != nil && c.lease.active && c.lease.attachment == ac {
563+ c.lease.active = false
564+ c.pending = false
565+ c.pendingReset = false
566+ c.pendingUrgent = false
567+ c.ackDeferred = false
568+ c.pendingPreview = false
569+ c.coalesced = 0
570+ _, timer := c.normalLane.replaceLocked()
571+ c.armed = false
572+ _, resizeTimer := c.resizeLane.replaceLocked()
573+ _, retryTimer := c.retryLane.replaceLocked()
574+ cleanup = renderLifecycleCleanup{
575+ tokens: []*timerToken{timer, resizeTimer, retryTimer},
576+ observer: c.opts.observer,
577+ queueCorrelation: c.queueCorrelation,
578+ queueMarked: c.queueMarked,
579+ ackBlocked: c.ackBlocked,
580+ }
581+ c.queueMarked, c.ackBlocked = false, nil
582+ }
583+ c.mu.Unlock()
584+ return cleanup
585+}
586+
587+// noteDetach is for callers that hold no outer lifecycle locks.
588+func (c *renderCoordinator) noteDetach(ac *attachedClient) {
589+ c.beginDetach(ac).finish()
590+}
591+
592+// beginReplace hands the coordinator from old to replacement without waiting
593+// for detached workers.
594+func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup {
595+ c.mu.Lock()
596+ var cleanup renderLifecycleCleanup
597+ // A coordinator may be installed while replacing a legacy attachment
598+ // which predated coordinator ownership. In that case nil has no pending
599+ // identity to invalidate, and the replacement becomes the first bound
600+ // attachment atomically with this lifecycle transition.
601+ if c.lease == nil || c.lease.attachment == old {
602+ c.installLeaseLocked(replacement, ready)
603+ c.pending = false
604+ c.pendingReset = false
605+ c.pendingUrgent = false
606+ c.ackDeferred = false
607+ c.pendingPreview = false
608+ c.coalesced = 0
609+ _, timer := c.normalLane.replaceLocked()
610+ c.armed = false
611+ _, resizeTimer := c.resizeLane.replaceLocked()
612+ _, retryTimer := c.retryLane.replaceLocked()
613+ cleanup = renderLifecycleCleanup{
614+ tokens: []*timerToken{timer, resizeTimer, retryTimer},
615+ observer: c.opts.observer,
616+ queueCorrelation: c.queueCorrelation,
617+ queueMarked: c.queueMarked,
618+ ackBlocked: c.ackBlocked,
619+ }
620+ c.queueMarked, c.ackBlocked = false, nil
621+ }
622+ c.mu.Unlock()
623+ return cleanup
624+}
625+
626+// noteReplace is for callers that hold no outer lifecycle locks.
627+func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) {
628+ ready := true
629+ if len(readiness) != 0 {
630+ ready = readiness[0]
631+ }
632+ c.beginReplace(old, replacement, ready).finish()
633+}
634+
635+// notePark invalidates pending wakes when the attachment parks for resume.
636+func (c *renderCoordinator) notePark(ac *attachedClient) { c.noteDetach(ac) }
637+
638+// beginSessionTeardown is terminal phase one. It rejects future worker
639+// registration and atomically detaches every complete lane token.
640+func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
641+ c.mu.Lock()
642+ if c.torndown {
643+ c.mu.Unlock()
644+ return renderLifecycleCleanup{}
645+ }
646+ c.torndown = true
647+ if c.lease != nil {
648+ c.lease.active = false
649+ }
650+ c.previewWake = nil
651+ c.previewWakes = nil
652+ c.pending = false
653+ _, resizeTimer := c.resizeLane.replaceLocked()
654+ _, retryTimer := c.retryLane.replaceLocked()
655+ c.ackDeferred = false
656+ c.pendingPreview = false
657+ _, timer := c.normalLane.replaceLocked()
658+ c.armed = false
659+ workers := c.detachSyncTimersLocked()
660+ observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked
661+ ackBlocked := c.ackBlocked
662+ c.queueMarked, c.ackBlocked = false, nil
663+ c.mu.Unlock()
664+ return renderLifecycleCleanup{
665+ tokens: append([]*timerToken{timer, resizeTimer, retryTimer}, workers...),
666+ observer: observer,
667+ queueCorrelation: queueCorrelation,
668+ queueMarked: queueMarked,
669+ ackBlocked: ackBlocked,
670+ }
671+}
672+
673+// waitForTimerWorkers is terminal phase two. Only the session owner invokes it,
674+// after beginSessionTeardown has made future supervisor registration impossible.
675+func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() }
676+
677+// invalidateRender is the sole producer fan-in. In tests and transitional
678+// headless paths without an attached coordinator it retains the old private
679+// compositor; attached sessions always schedule through their coordinator.
680+func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool, producer string) {
681+ if sess != nil {
682+ if rc := sess.renderCoordinator(); rc != nil {
683+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
684+ return
685+ }
686+ }
687+ if ac != nil {
688+ d.paint(sess, ac, reset, nil)
689+ }
690+}
691+
692+// invalidateRenderNow publishes through the coordinator but immediately
693+// flushes the wake when the client can accept state. Attach uses this path so
694+// the required first full frame never depends on a debounce timer.
695+func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bool, producer string) {
696+ if sess != nil {
697+ if rc := sess.renderCoordinator(); rc != nil {
698+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
699+ rc.fireCurrent(false)
700+ return
701+ }
702+ }
703+ if ac != nil {
704+ d.paint(sess, ac, reset, nil)
705+ }
706+}
707+
708+func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
709+ c.mu.Lock()
710+ if !watchdog && c.normalLane.generation != gen {
711+ c.mu.Unlock()
712+ return
713+ }
714+ token := c.normalLane.token
715+ c.mu.Unlock()
716+ c.fireWithTimerToken(token, gen, watchdog, deadline)
717+}
718+
719+// fireFromTimer consumes a normal deadline using its exact immutable token.
720+func (c *renderCoordinator) fireFromTimer(token *timerToken, watchdog, deadline bool) {
721+ c.fireWithTimerToken(token, token.generation, watchdog, deadline)
722+}
723+
724+func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, watchdog, deadline bool) {
725+ // Readiness probes enter the attachment send path, which may in turn read
726+ // coordinator metadata. Never hold c.mu across that external callback.
727+ c.mu.Lock()
728+ if !c.fireValidLocked(token, gen, watchdog) {
729+ c.mu.Unlock()
730+ return
731+ }
732+ // Welcome holds attachment.sendMu while its transport Send is in flight.
733+ // Do not probe ACK capacity through that lock before this incarnation is
734+ // ready: a deadline that expires during the handshake must complete and
735+ // leave work pending, rather than wedging behind Welcome.
736+ handshakePending := c.lease != nil && c.lease.active && !c.lease.ready
737+ ackReady := c.opts.ackReady
738+ c.mu.Unlock()
739+ ready := !handshakePending && (ackReady == nil || ackReady())
740+
741+ // The readiness callback can detach, replace, or publish a sync batch.
742+ // syncGateOpenLocked retries predicate evaluation on every registry change
743+ // and returns holding c.mu, so pending consumption has no intervening gap.
744+ c.mu.Lock()
745+ if !c.fireValidLocked(token, gen, watchdog) {
746+ c.mu.Unlock()
747+ return
748+ }
749+ if !c.syncGateOpenLocked() {
750+ c.mu.Unlock()
751+ return
752+ }
753+ if !c.fireValidLocked(token, gen, watchdog) {
754+ c.mu.Unlock()
755+ return
756+ }
757+ // A detached target has no primary transport or ACK window. Its pending
758+ // state belongs exclusively to picker previews and must be consumed after
759+ // notification rather than accumulating ackDeferred forever. Do not pass
760+ // its revoked primary lease to preview consumers.
761+ headlessPreviewOnly := c.primaryDetachedLocked()
762+ lease := c.lease
763+ if headlessPreviewOnly {
764+ lease = nil
765+ }
766+ w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, lease: lease}
767+ if !headlessPreviewOnly && (!ready || (c.lease != nil && c.lease.active && !c.lease.ready)) {
768+ var ackStart *ackBlockedSpan
769+ if !ready && deadline {
770+ c.ackDeferred = true
771+ if c.ackBlocked == nil && c.opts.observer != nil {
772+ // Install the span while coordinator state is owned. Its Start
773+ // publication runs unlocked, and its own handoff defers End until
774+ // that call has returned.
775+ ackStart = newACKBlockedSpan(c.opts.observer)
776+ c.ackBlocked = ackStart
777+ }
778+ }
779+ preview, previews := c.takePendingPreviewsLocked()
780+ c.mu.Unlock()
781+ if ackStart != nil {
782+ ackStart.publishStart()
783+ }
784+ c.notifyPreviews(w, preview, previews)
785+ return
786+ }
787+ preview, previews := c.takePendingPreviewsLocked()
788+ c.metrics.wakes.Add(1)
789+ c.metrics.coalesced.Add(uint64(w.coalesced))
790+ if !w.urgent {
791+ if w.coalesced > 1 {
792+ c.outputPressure += w.coalesced - 1
793+ if c.outputPressure > int((maxOutputRenderDeadline-minOutputRenderDeadline)/time.Millisecond) {
794+ c.outputPressure = int((maxOutputRenderDeadline - minOutputRenderDeadline) / time.Millisecond)
795+ }
796+ } else if c.outputPressure > 0 {
797+ c.outputPressure--
798+ }
799+ }
800+ worker := c.detachNormalTimerLocked()
801+ c.pending, c.pendingReset, c.pendingUrgent, c.ackDeferred, c.deadlineDue, c.coalesced, c.armed = false, false, false, false, false, 0, false
802+ observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked
803+ ackBlocked := c.ackBlocked
804+ c.queueMarked, c.ackBlocked = false, nil
805+ wake := c.opts.wake
806+ c.mu.Unlock()
807+ stopDetachedTimer(worker)
808+ if queueMarked && observer != nil {
809+ observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true))
810+ }
811+ if ackBlocked != nil {
812+ ackBlocked.finish(true)
813+ }
814+ if wake != nil && !headlessPreviewOnly {
815+ wake(w)
816+ }
817+ c.notifyPreviews(w, preview, previews)
818+}
819+
820+func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool {
821+ if c.torndown || !c.pending || watchdog {
822+ return !c.torndown && c.pending
823+ }
824+ return c.normalLane.generation == gen && (token == nil || c.normalLane.token == token)
825+}
826+
827+// syncGateOpen evaluates pane visibility without c.mu, then reacquires and
828+// validates the complete batch identity set before accepting that result.
829+// syncGateOpen evaluates visibility outside c.mu and returns with c.mu held
830+// only when the registry version still matches the snapshot. The caller must
831+// consume pending state before unlocking, eliminating the gate-to-consume gap.
832+func (c *renderCoordinator) syncGateOpenLocked() bool {
833+ for {
834+ version := c.syncRegistryVersion
835+ batches := make([]*syncBatch, 0, len(c.syncBatches))
836+ for _, batch := range c.syncBatches {
837+ batches = append(batches, batch)
838+ }
839+ c.mu.Unlock()
840+
841+ gated := false
842+ for _, batch := range batches {
843+ if batch.renderable == nil || batch.renderable() {
844+ gated = true
845+ break
846+ }
847+ }
848+ if hook := c.opts.afterSyncGateEvaluated; hook != nil {
849+ hook()
850+ }
851+
852+ c.mu.Lock()
853+ if version == c.syncRegistryVersion {
854+ return !gated
855+ }
856+ }
857+}
858+
859+// takePendingPreviewsLocked snapshots and consumes the preview delivery for
860+// this target generation. Unlike the target primary, this is never ACK-gated.
861+func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {
862+ if !c.pendingPreview {
863+ return nil, nil
864+ }
865+ c.pendingPreview = false
866+ previews := make([]func(renderWake), 0, len(c.previewWakes))
867+ for _, subscription := range c.previewWakes {
868+ previews = append(previews, subscription.fn)
869+ }
870+ return c.previewWake, previews
871+}
872+
873+func (c *renderCoordinator) notifyPreviews(w renderWake, preview func(renderWake), previews []func(renderWake)) {
874+ if preview != nil {
875+ preview(w)
876+ }
877+ for _, fn := range previews {
878+ fn(w)
879+ }
880+}
A internal/usecase/daemon/render_coordinator_handshake_test.go +70 -0
@@ -0,0 +1,70 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+// Fresh producer state may arrive after route has published an attachment but
11+// before the transport has accepted Welcome. It must remain pending until the
12+// exact attachment incarnation completes that handshake.
13+func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) {
14+ h := newCoordinatorHarness(t)
15+ ac := &attachedClient{}
16+ h.rc.attachWithReadiness(ac, false)
17+ lease := h.rc.attachmentLease(ac)
18+ require.NotNil(t, lease)
19+
20+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "pty"})
21+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
22+ h.rc.mu.Lock()
23+ workerDone := h.rc.normalLane.token.done
24+ h.rc.mu.Unlock()
25+ require.NotNil(t, workerDone)
26+ timer.ch <- time.Time{}
27+ requireWorkerExit(t, workerDone)
28+ requireNoWake(t, h.wakes)
29+
30+ require.True(t, h.rc.markAttachmentReady(lease))
31+ h.rc.fireCurrent(false)
32+ wake := awaitWake(t, h.wakes)
33+ require.True(t, wake.reset)
34+ require.Equal(t, 1, wake.coalesced)
35+ require.Same(t, ac, wake.lease.attachment)
36+ requireNoWake(t, h.wakes)
37+}
38+
39+// Parking and resuming the same client object is a new incarnation: a failed
40+// Welcome on the replacement transport must not revive readiness from before
41+// the park.
42+func TestRenderCoordinatorParkedResumeRequiresNewWelcome(t *testing.T) {
43+ h := newCoordinatorHarness(t)
44+ ac := &attachedClient{}
45+ h.rc.attachWithReadiness(ac, false)
46+ firstLease := h.rc.attachmentLease(ac)
47+ require.True(t, h.rc.markAttachmentReady(firstLease))
48+ h.rc.notePark(ac)
49+ h.rc.attachWithReadiness(ac, false)
50+ resumedLease := h.rc.attachmentLease(ac)
51+ require.NotNil(t, resumedLease)
52+ require.NotSame(t, firstLease, resumedLease)
53+ require.False(t, h.rc.markAttachmentReady(firstLease), "stale Welcome must not bless the reused attachment")
54+
55+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "session"})
56+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
57+ h.rc.mu.Lock()
58+ workerDone := h.rc.normalLane.token.done
59+ h.rc.mu.Unlock()
60+ require.NotNil(t, workerDone)
61+ timer.ch <- time.Time{}
62+ requireWorkerExit(t, workerDone)
63+ requireNoWake(t, h.wakes)
64+
65+ require.True(t, h.rc.markAttachmentReady(resumedLease))
66+ h.rc.fireCurrent(false)
67+ wake := awaitWake(t, h.wakes)
68+ require.True(t, wake.reset)
69+ require.Equal(t, 1, wake.coalesced)
70+}
A internal/usecase/daemon/render_coordinator_publication_test.go +28 -0
@@ -0,0 +1,28 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestCoordinatorRejectsStaleAttachmentInvalidationAfterReplacement(t *testing.T) {
10+ var invalidations []renderInvalidation
11+ rc := newRenderCoordinator(renderCoordinatorOptions{
12+ onInvalidate: func(inv renderInvalidation) { invalidations = append(invalidations, inv) },
13+ })
14+ old := &attachedClient{}
15+ replacement := &attachedClient{}
16+ rc.attach(old)
17+ rc.noteReplace(old, replacement)
18+
19+ // This models a stale resize callback that passed its sendMu generation
20+ // check immediately before a replacement was published.
21+ rc.invalidateForAttachment(old, renderInvalidation{producer: "stale resize"})
22+ require.Empty(t, invalidations)
23+
24+ // Pane/session producers carry no attachment and remain coordinator-owned.
25+ rc.invalidateForAttachment(nil, renderInvalidation{producer: "pane"})
26+ require.Len(t, invalidations, 1)
27+ require.Equal(t, "pane", invalidations[0].producer)
28+}
A internal/usecase/daemon/render_coordinator_support.go +27 -0
@@ -0,0 +1,27 @@
1+package daemon
2+
3+// Small coordinator accessors stay separate from scheduling and lifecycle
4+// transitions, keeping the coordinator state file focused on ownership.
5+
6+// burstMetricsSnapshot returns a consistent read-only metric view for
7+// benchmark reporting without exposing coordinator policy outside this package.
8+func (c *renderCoordinator) burstMetricsSnapshot() renderCoordinatorBurstMetricsSnapshot {
9+ return renderCoordinatorBurstMetricsSnapshot{
10+ invalidations: c.metrics.invalidations.Load(),
11+ wakes: c.metrics.wakes.Load(),
12+ coalesced: c.metrics.coalesced.Load(),
13+ }
14+}
15+
16+// renderCoordinator returns the coordinator installed for this session.
17+func (s *session) renderCoordinator() *renderCoordinator { return s.coordinator.Load() }
18+
19+// installRenderCoordinator publishes rc as the session's coordinator.
20+func (s *session) installRenderCoordinator(rc *renderCoordinator) { s.coordinator.Store(rc) }
21+
22+func (c *renderCoordinator) fireCurrent(watchdog bool) {
23+ c.mu.Lock()
24+ gen := c.normalLane.generation
25+ c.mu.Unlock()
26+ c.fire(gen, watchdog, watchdog)
27+}
A internal/usecase/daemon/render_coordinator_test.go +1614 -0
@@ -0,0 +1,1614 @@
1+package daemon
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "sync"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
17+ "github.com/bnema/vev/internal/usecase/keys"
18+ "github.com/bnema/vev/internal/usecase/layout"
19+)
20+
21+// --- coordinator harness ------------------------------------------------------
22+
23+// coordinatorMockClock uses generated mocks while retaining deterministic timer
24+// channels and recorded deadlines for coordinator scheduling assertions.
25+type coordinatorMockClock struct {
26+ clock *portsmocks.MockClock
27+ timers chan *coordinatorMockTimer
28+}
29+
30+type coordinatorMockTimer struct {
31+ mock *portsmocks.MockTimer
32+ ch chan time.Time
33+ duration time.Duration
34+}
35+
36+func newCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockClock {
37+ t.Helper()
38+ clk := &coordinatorMockClock{
39+ clock: portsmocks.NewMockClock(t),
40+ timers: make(chan *coordinatorMockTimer, capacity),
41+ }
42+ clk.clock.EXPECT().Now().Return(time.Time{}).Maybe()
43+ clk.clock.EXPECT().NewTimer(mock.MatchedBy(func(d time.Duration) bool {
44+ return d == urgentRenderDeadline ||
45+ (d >= minOutputRenderDeadline && d <= maxOutputRenderDeadline) ||
46+ d == maxSyncUpdateDuration ||
47+ d == time.Second ||
48+ d == 15*time.Minute
49+ })).RunAndReturn(func(d time.Duration) ports.Timer {
50+ timer := &coordinatorMockTimer{
51+ mock: portsmocks.NewMockTimer(t),
52+ duration: d,
53+ }
54+ timer.ch = make(chan time.Time, 1)
55+ // A normal deadline reads C both in its worker and in the inert-clock
56+ // guard; a watchdog reads it in its worker. The generated expectation
57+ // makes every channel access observable without a hand-written Timer.
58+ timer.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(timer.ch))
59+ timer.mock.EXPECT().Stop().Maybe().Return(true)
60+ clk.timers <- timer
61+ return timer.mock
62+ }).Maybe()
63+ return clk
64+}
65+
66+// TestRenderCoordinatorTimerCallbacksReenterCoordinator is deliberately a
67+// deadlock regression: every external timer method synchronously reads
68+// coordinator metadata. Before two-phase timer ownership this blocked while
69+// c.mu was held during normal arming, urgent replacement, sync arming, and
70+// detach cancellation.
71+// Detach cancels and stops a selected worker but never synchronously joins it;
72+// the stale worker must be rejected when its external readiness probe returns.
73+func TestRenderCoordinatorDetachDoesNotJoinSelectedDeadlineWorker(t *testing.T) {
74+ clock := portsmocks.NewMockClock(t)
75+ timer := portsmocks.NewMockTimer(t)
76+ timerC := make(chan time.Time, 1)
77+ stopCalled := make(chan struct{})
78+ clock.EXPECT().NewTimer(minOutputRenderDeadline).Return(timer).Once()
79+ timer.EXPECT().C().Return((<-chan time.Time)(timerC)).Once()
80+ timer.EXPECT().Stop().Run(func() { close(stopCalled) }).Return(true).Once()
81+
82+ entered := make(chan struct{})
83+ release := make(chan struct{})
84+ rc := newRenderCoordinator(renderCoordinatorOptions{
85+ clock: clock,
86+ ackReady: func() bool {
87+ close(entered)
88+ <-release
89+ return true
90+ },
91+ })
92+ ac := &attachedClient{}
93+ rc.attach(ac)
94+ rc.invalidate(renderInvalidation{class: invalidateOutput})
95+ timerC <- time.Time{}
96+ <-entered
97+
98+ detached := make(chan struct{})
99+ go func() {
100+ rc.noteDetach(ac)
101+ close(detached)
102+ }()
103+ <-stopCalled
104+ awaitTestCompletion(t, detached, "detach synchronously waited for a selected timer worker")
105+ close(release)
106+}
107+
108+func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) {
109+ clock := portsmocks.NewMockClock(t)
110+ var rc *renderCoordinator
111+ clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
112+ timer := portsmocks.NewMockTimer(t)
113+ ch := make(chan time.Time)
114+ timer.EXPECT().C().Run(func() {
115+ _ = rc.resizeSnapshot()
116+ _ = rc.burstMetricsSnapshot()
117+ }).Return((<-chan time.Time)(ch)).Once()
118+ timer.EXPECT().Stop().Run(func() {
119+ _ = rc.resizeSnapshot()
120+ _ = rc.burstMetricsSnapshot()
121+ }).Return(true).Once()
122+ return timer
123+ }).Times(3)
124+ rc = newRenderCoordinator(renderCoordinatorOptions{clock: clock})
125+ ac := &attachedClient{}
126+ rc.attach(ac)
127+
128+ rc.invalidate(renderInvalidation{class: invalidateOutput}) // normal arm
129+ rc.invalidate(renderInvalidation{class: invalidateUrgent}) // urgent promotion
130+ rc.noteSyncBegin(nil, 1) // sync arm
131+ rc.noteDetach(ac) // cancel normal deadline only
132+ rc.noteSyncPaneRemoved(nil) // pane destruction cancels sync watchdog
133+}
134+
135+// coordinatorHarness wires one coordinator to generated clock/timer mocks and
136+// recording hooks. Every assertion is channel- or counter-based; nothing sleeps.
137+type coordinatorHarness struct {
138+ clk *coordinatorMockClock
139+ wakes chan renderWake
140+ previews chan renderWake
141+ ackReady atomic.Bool
142+ syncActive atomic.Bool
143+ rc *renderCoordinator
144+}
145+
146+func newCoordinatorHarness(t *testing.T) *coordinatorHarness {
147+ t.Helper()
148+ h := &coordinatorHarness{
149+ clk: newCoordinatorMockClock(t, 16),
150+ wakes: make(chan renderWake, 16),
151+ previews: make(chan renderWake, 16),
152+ }
153+ h.ackReady.Store(true)
154+ h.rc = newRenderCoordinator(renderCoordinatorOptions{
155+ clock: h.clk.clock,
156+ wake: func(w renderWake) { h.wakes <- w },
157+ ackReady: func() bool { return h.ackReady.Load() },
158+ syncActive: func() bool { return h.syncActive.Load() },
159+ })
160+ return h
161+}
162+
163+// armedTimers drains every deadline armed so far. Arming happens
164+// synchronously inside the coordinator call, so a non-blocking drain is
165+// deterministic.
166+func (h *coordinatorHarness) armedTimers(t *testing.T) []*coordinatorMockTimer {
167+ t.Helper()
168+ var timers []*coordinatorMockTimer
169+ for {
170+ select {
171+ case tm := <-h.clk.timers:
172+ timers = append(timers, tm)
173+ default:
174+ return timers
175+ }
176+ }
177+}
178+
179+// Coordinator calls and fake-clock advancement are synchronous test steps.
180+// These helpers deliberately never wait on wall time: a missing event is a
181+// deterministic contract failure, not a slow behavior to poll for.
182+func awaitWake(t *testing.T, ch chan renderWake) renderWake {
183+ t.Helper()
184+ return awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement")
185+}
186+
187+func requireNoWake(t *testing.T, ch chan renderWake) {
188+ t.Helper()
189+ select {
190+ case w := <-ch:
191+ t.Fatalf("unexpected coordinator wake: %+v", w)
192+ default:
193+ }
194+}
195+
196+func awaitInvalidation(t *testing.T, ch chan renderInvalidation) renderInvalidation {
197+ t.Helper()
198+ return awaitTestValue(t, ch, "producer did not publish a coordinator invalidation")
199+}
200+
201+func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {
202+ t.Helper()
203+ select {
204+ case inv := <-ch:
205+ t.Fatalf("unexpected coordinator invalidation: %+v", inv)
206+ default:
207+ }
208+}
209+
210+func captureResizeCallbackDone(t *testing.T, rc *renderCoordinator) <-chan struct{} {
211+ t.Helper()
212+ rc.mu.Lock()
213+ defer rc.mu.Unlock()
214+ if rc.resizeLane.token == nil {
215+ t.Fatal("coordinator did not publish a resize callback completion")
216+ }
217+ return rc.resizeLane.token.done
218+}
219+
220+func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
221+ t.Helper()
222+ select {
223+ case tm := <-clk.timers:
224+ return tm
225+ default:
226+ t.Fatal("coordinator did not synchronously arm a fake-clock timer")
227+ return nil
228+ }
229+}
230+
231+func awaitLatestCoordinatorTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
232+ t.Helper()
233+ timer := awaitCoordinatorScheduledTimer(t, clk)
234+ for {
235+ select {
236+ case timer = <-clk.timers:
237+ default:
238+ return timer
239+ }
240+ }
241+}
242+
243+func requireNoCoordinatorOutputFrame(t *testing.T, sends chan ports.Frame) {
244+ t.Helper()
245+ select {
246+ case frame := <-sends:
247+ t.Fatalf("unexpected output frame: %+v", frame)
248+ default:
249+ }
250+}
251+
252+// --- wake coalescing and deadlines --------------------------------------------
253+
254+func TestRenderCoordinatorCoalescesWakesUnderOneDeadline(t *testing.T) {
255+ cases := []struct {
256+ name string
257+ invalidations []renderInvalidation
258+ wantTimers int
259+ wantMin time.Duration
260+ wantMax time.Duration
261+ wantWake renderWake
262+ }{
263+ {
264+ name: "urgent transition wakes within two milliseconds",
265+ invalidations: []renderInvalidation{{class: invalidateUrgent, reset: true, producer: "input.go"}},
266+ wantTimers: 1,
267+ wantMax: urgentRenderDeadline,
268+ wantWake: renderWake{reset: true, urgent: true, coalesced: 1},
269+ },
270+ {
271+ name: "output burst coalesces into one adaptive deadline",
272+ invalidations: []renderInvalidation{
273+ {class: invalidateOutput, producer: "render.go"},
274+ {class: invalidateOutput, producer: "render.go"},
275+ {class: invalidateOutput, producer: "render.go"},
276+ },
277+ wantTimers: 1,
278+ wantMin: minOutputRenderDeadline,
279+ wantMax: maxOutputRenderDeadline,
280+ wantWake: renderWake{coalesced: 3},
281+ },
282+ {
283+ name: "full redraw stays sticky across later incremental transitions",
284+ invalidations: []renderInvalidation{
285+ {class: invalidateOutput},
286+ {class: invalidateOutput, reset: true},
287+ {class: invalidateOutput},
288+ },
289+ wantTimers: 1,
290+ wantMin: minOutputRenderDeadline,
291+ wantMax: maxOutputRenderDeadline,
292+ wantWake: renderWake{reset: true, coalesced: 3},
293+ },
294+ {
295+ name: "urgent transition tightens a pending output deadline",
296+ invalidations: []renderInvalidation{
297+ {class: invalidateOutput},
298+ {class: invalidateUrgent},
299+ },
300+ wantTimers: 2,
301+ wantMax: urgentRenderDeadline,
302+ wantWake: renderWake{urgent: true, coalesced: 2},
303+ },
304+ }
305+ for _, tc := range cases {
306+ t.Run(tc.name, func(t *testing.T) {
307+ h := newCoordinatorHarness(t)
308+ for _, inv := range tc.invalidations {
309+ h.rc.invalidate(inv)
310+ }
311+ timers := h.armedTimers(t)
312+ require.Lenf(t, timers, tc.wantTimers,
313+ "cap-1 coalescing must arm exactly %d deadline timer(s) for %d invalidations",
314+ tc.wantTimers, len(tc.invalidations))
315+ last := timers[len(timers)-1]
316+ require.GreaterOrEqual(t, last.duration, tc.wantMin)
317+ require.LessOrEqual(t, last.duration, tc.wantMax)
318+
319+ last.ch <- time.Time{}
320+ require.Equal(t, tc.wantWake, awaitWake(t, h.wakes),
321+ "one wake must deliver the latest coalesced state")
322+ requireNoWake(t, h.wakes)
323+ })
324+ }
325+}
326+
327+func TestRenderCoordinatorAdaptsOutputDeadlineAfterBurstAndDecays(t *testing.T) {
328+ h := newCoordinatorHarness(t)
329+
330+ // A burst establishes pressure without extending its already-armed first
331+ // deadline; the following output deadline grows to the 16ms ceiling.
332+ for range 9 {
333+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
334+ }
335+ burst := h.armedTimers(t)
336+ require.Len(t, burst, 1)
337+ require.Equal(t, minOutputRenderDeadline, burst[0].duration)
338+ burst[0].ch <- time.Time{}
339+ awaitWake(t, h.wakes)
340+
341+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
342+ atCap := h.armedTimers(t)
343+ require.Len(t, atCap, 1)
344+ require.Equal(t, maxOutputRenderDeadline, atCap[0].duration,
345+ "burst pressure must be capped at the 16ms output deadline")
346+ atCap[0].ch <- time.Time{}
347+ awaitWake(t, h.wakes)
348+
349+ // Quiet singleton batches decay pressure toward the 8ms floor.
350+ var previous = maxOutputRenderDeadline
351+ for range 8 {
352+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
353+ timers := h.armedTimers(t)
354+ require.Len(t, timers, 1)
355+ require.LessOrEqual(t, timers[0].duration, previous)
356+ require.GreaterOrEqual(t, timers[0].duration, minOutputRenderDeadline)
357+ previous = timers[0].duration
358+ timers[0].ch <- time.Time{}
359+ awaitWake(t, h.wakes)
360+ }
361+ require.Equal(t, minOutputRenderDeadline, previous)
362+}
363+
364+func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) {
365+ h := newCoordinatorHarness(t)
366+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
367+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
368+ for range 32 {
369+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
370+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
371+ }
372+ timers := h.armedTimers(t)
373+ require.Len(t, timers, 2, "only the urgent promotion may replace the output timer")
374+ require.Equal(t, urgentRenderDeadline, timers[len(timers)-1].duration)
375+}
376+
377+// --- ACK gating ----------------------------------------------------------------
378+
379+func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T) {
380+ owner := &attachedClient{}
381+ wakes := make(chan renderWake, 1)
382+ ackEntered := make(chan struct{})
383+ resizeDone := make(chan uint64, 1)
384+ fireDone := make(chan struct{})
385+
386+ var rc *renderCoordinator
387+ rc = newRenderCoordinator(renderCoordinatorOptions{
388+ ackReady: func() bool {
389+ close(ackEntered)
390+ // The readiness probe models the output send path reading coordinator
391+ // metadata after sendMu is held. It must never run under c.mu.
392+ _ = rc.resizeSnapshot()
393+ return true
394+ },
395+ wake: func(w renderWake) { wakes <- w },
396+ })
397+ rc.attach(owner)
398+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
399+
400+ go func() {
401+ rc.fire(1, false, true)
402+ close(fireDone)
403+ }()
404+ awaitTestCompletion(t, ackEntered, "ack readiness was not evaluated")
405+
406+ go func() { resizeDone <- rc.recordResizeRequest(domain.Size{Cols: 120, Rows: 40}, owner) }()
407+ epoch := awaitTestValue(t, resizeDone, "ack readiness re-entry blocked resize metadata")
408+ require.Equal(t, uint64(1), epoch)
409+ wake := awaitWake(t, wakes)
410+ require.Same(t, owner, wake.lease.attachment)
411+ wake.lease = nil
412+ require.Equal(t, renderWake{coalesced: 1}, wake)
413+ awaitTestCompletion(t, fireDone, "fire did not return after wake")
414+}
415+
416+func TestRenderCoordinatorAckDoesNotBypassAnUnexpiredDeadline(t *testing.T) {
417+ h := newCoordinatorHarness(t)
418+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
419+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
420+ require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)
421+
422+ // An unrelated ACK must not turn a normal output deadline into an
423+ // immediate wake or cancel its timer.
424+ h.rc.notifyAck()
425+ requireNoWake(t, h.wakes)
426+ timer.mock.AssertNotCalled(t, "Stop")
427+
428+ timer.ch <- time.Time{}
429+ awaitWake(t, h.wakes)
430+ requireNoWake(t, h.wakes)
431+}
432+
433+func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) {
434+ t.Run("expired deadline flushes exactly once after readiness", func(t *testing.T) {
435+ h := newCoordinatorHarness(t)
436+ h.ackReady.Store(false)
437+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "render.go"})
438+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
439+ timer.ch <- time.Time{}
440+ requireNoWake(t, h.wakes)
441+
442+ // Repeated ACKs while capacity remains unavailable are no-ops.
443+ h.rc.notifyAck()
444+ requireNoWake(t, h.wakes)
445+ h.ackReady.Store(true)
446+ h.rc.notifyAck()
447+ w := awaitWake(t, h.wakes)
448+ require.True(t, w.reset)
449+ requireNoWake(t, h.wakes)
450+ h.rc.notifyAck()
451+ requireNoWake(t, h.wakes)
452+ })
453+
454+ t.Run("lifecycle clears deferred work and urgent explicit fires stay immediate", func(t *testing.T) {
455+ h := newCoordinatorHarness(t)
456+ h.ackReady.Store(false)
457+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
458+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
459+ timer.ch <- time.Time{}
460+ requireNoWake(t, h.wakes)
461+ h.rc.beginSessionTeardown().finish()
462+ h.ackReady.Store(true)
463+ h.rc.notifyAck()
464+ requireNoWake(t, h.wakes)
465+
466+ immediate := newCoordinatorHarness(t)
467+ immediate.rc.invalidate(renderInvalidation{class: invalidateUrgent})
468+ immediate.rc.fireCurrent(false)
469+ w := awaitWake(t, immediate.wakes)
470+ require.True(t, w.urgent)
471+ })
472+}
473+
474+func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) {
475+ h := newCoordinatorHarness(t)
476+ h.ackReady.Store(false)
477+
478+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
479+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
480+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
481+ for _, timer := range h.armedTimers(t) {
482+ timer.ch <- time.Time{}
483+ }
484+ requireNoWake(t, h.wakes)
485+
486+ h.ackReady.Store(true)
487+ h.rc.notifyAck()
488+ w := awaitWake(t, h.wakes)
489+ require.True(t, w.reset, "the deferred full redraw must stay sticky through the ack gate")
490+ require.Equal(t, 3, w.coalesced, "the post-ack wake must carry every deferred transition")
491+ requireNoWake(t, h.wakes)
492+
493+ h.rc.notifyAck()
494+ requireNoWake(t, h.wakes)
495+}
496+
497+// --- synchronized output ---------------------------------------------------------
498+
499+func TestRenderCoordinatorConcurrentSynchronizedPaneBatches(t *testing.T) {
500+ t.Run("end orders, watchdogs, and stale callbacks are isolated per pane", func(t *testing.T) {
501+ h := newCoordinatorHarness(t)
502+ firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1)
503+ firstPane, secondPane := &pane{}, &pane{}
504+
505+ // Distinct stable pane owners must remain independent.
506+ h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
507+ firstWatchdog := h.armedTimers(t)[0]
508+ h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} })
509+ secondWatchdog := h.armedTimers(t)[0]
510+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
511+
512+ // Completing either pane leaves the aggregate gate closed.
513+ h.rc.noteSyncEnd(firstPane, 11)
514+ requireNoWake(t, h.wakes)
515+ // A stale callback for the completed pane must not force it or wake.
516+ firstWatchdog.ch <- time.Time{}
517+ select {
518+ case <-firstForced:
519+ t.Fatal("stale watchdog forced a completed pane")
520+ default:
521+ }
522+ requireNoWake(t, h.wakes)
523+
524+ // The remaining pane's watchdog forces only its own batch, then the
525+ // aggregate completion produces exactly one urgent wake.
526+ secondWatchdog.ch <- time.Time{}
527+ <-secondForced
528+ w := awaitWake(t, h.wakes)
529+ require.True(t, w.watchdog)
530+ require.True(t, w.urgent, "aggregate completion must wake urgently")
531+ requireNoWake(t, h.wakes)
532+ })
533+
534+ t.Run("later pane can end first without releasing an earlier pane", func(t *testing.T) {
535+ h := newCoordinatorHarness(t)
536+ firstForced := make(chan struct{}, 1)
537+ firstPane, secondPane := &pane{}, &pane{}
538+ h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
539+ firstWatchdog := h.armedTimers(t)[0]
540+ h.rc.noteSyncBegin(secondPane, 22)
541+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
542+
543+ h.rc.noteSyncEnd(secondPane, 22)
544+ requireNoWake(t, h.wakes)
545+ firstWatchdog.ch <- time.Time{}
546+ <-firstForced
547+ w := awaitWake(t, h.wakes)
548+ require.True(t, w.watchdog)
549+ require.True(t, w.urgent, "final pane completion must wake urgently")
550+ requireNoWake(t, h.wakes)
551+ })
552+
553+ t.Run("pane removal cancels only that pane watchdog", func(t *testing.T) {
554+ h := newCoordinatorHarness(t)
555+ firstPane, secondPane := &pane{}, &pane{}
556+ firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1)
557+ h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
558+ firstWatchdog := h.armedTimers(t)[0]
559+ h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} })
560+ secondWatchdog := h.armedTimers(t)[0]
561+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
562+
563+ h.rc.noteSyncPaneRemoved(firstPane)
564+ firstWatchdog.ch <- time.Time{}
565+ select {
566+ case <-firstForced:
567+ t.Fatal("removed pane watchdog forced output")
568+ default:
569+ }
570+ requireNoWake(t, h.wakes)
571+ secondWatchdog.ch <- time.Time{}
572+ <-secondForced
573+ require.True(t, awaitWake(t, h.wakes).watchdog)
574+ })
575+}
576+
577+func TestRenderCoordinatorSyncGateFollowsDynamicRenderability(t *testing.T) {
578+ t.Run("inactive batch that becomes active gates activation output until end", func(t *testing.T) {
579+ h := newCoordinatorHarness(t)
580+ var visible atomic.Bool
581+ p := &pane{}
582+ h.rc.noteSyncBeginWithRenderability(p, 1, visible.Load)
583+ visible.Store(true)
584+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "activation"})
585+ for _, timer := range h.armedTimers(t) {
586+ timer.ch <- time.Time{}
587+ }
588+ requireNoWake(t, h.wakes)
589+
590+ h.rc.noteSyncEnd(p, 1)
591+ w := awaitWake(t, h.wakes)
592+ require.True(t, w.urgent)
593+ requireNoWake(t, h.wakes)
594+ })
595+
596+ t.Run("hidden batch does not stall newly active output or wake on late end", func(t *testing.T) {
597+ h := newCoordinatorHarness(t)
598+ var visible atomic.Bool
599+ visible.Store(true)
600+ p := &pane{}
601+ h.rc.noteSyncBeginWithRenderability(p, 1, visible.Load)
602+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "batch"})
603+ visible.Store(false)
604+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "new-active-tab"})
605+ timers := h.armedTimers(t)
606+ require.NotEmpty(t, timers)
607+ timers[len(timers)-1].ch <- time.Time{}
608+ w := awaitWake(t, h.wakes)
609+ require.False(t, w.watchdog)
610+ h.rc.noteSyncEnd(p, 1)
611+ requireNoWake(t, h.wakes)
612+ })
613+}
614+
615+func TestRenderCoordinatorSyncGateRetriesWhenBatchPublishesDuringPredicate(t *testing.T) {
616+ h := newCoordinatorHarness(t)
617+ first, second := &pane{}, &pane{}
618+ gateEvaluated := make(chan struct{})
619+ releaseGate := make(chan struct{})
620+ var hookOnce sync.Once
621+ h.rc.opts.afterSyncGateEvaluated = func() {
622+ hookOnce.Do(func() {
623+ close(gateEvaluated)
624+ <-releaseGate
625+ })
626+ }
627+ h.rc.noteSyncBeginWithRenderability(first, 1, func() bool { return false })
628+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "test"})
629+
630+ fireDone := make(chan struct{})
631+ go func() {
632+ h.rc.fireCurrent(false)
633+ close(fireDone)
634+ }()
635+ <-gateEvaluated
636+ // This publication is deliberately between an open predicate result and
637+ // pending consumption. A stale gate result must be rejected and retried.
638+ h.rc.noteSyncBeginWithRenderability(second, 2, func() bool { return true })
639+ close(releaseGate)
640+ awaitTestCompletion(t, fireDone, "fire did not finish after retrying the registry snapshot")
641+
642+ requireNoWake(t, h.wakes)
643+ h.rc.noteSyncEnd(second, 2)
644+ require.Equal(t, renderWake{urgent: true, coalesced: 1}, awaitWake(t, h.wakes))
645+}
646+
647+func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
648+ t.Run("completion flushes pending state in one wake", func(t *testing.T) {
649+ h := newCoordinatorHarness(t)
650+ h.syncActive.Store(true)
651+ h.rc.noteSyncBegin(nil, 1)
652+ watchdogs := h.armedTimers(t)
653+ require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
654+ require.Equal(t, maxSyncUpdateDuration, watchdogs[len(watchdogs)-1].duration)
655+
656+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
657+ timers := h.armedTimers(t)
658+ require.Len(t, timers, 1)
659+ h.rc.mu.Lock()
660+ normalWorkerDone := h.rc.normalLane.token.done
661+ h.rc.mu.Unlock()
662+ require.NotNil(t, normalWorkerDone)
663+ timers[0].ch <- time.Time{}
664+ requireWorkerExit(t, normalWorkerDone)
665+ requireNoWake(t, h.wakes)
666+
667+ h.syncActive.Store(false)
668+ h.rc.noteSyncEnd(nil, 1)
669+ w := awaitWake(t, h.wakes)
670+ require.True(t, w.reset)
671+ requireNoWake(t, h.wakes)
672+ })
673+
674+ t.Run("watchdog ends a wedged batch before it flushes", func(t *testing.T) {
675+ h := newCoordinatorHarness(t)
676+ h.syncActive.Store(true)
677+ forced := make(chan struct{}, 1)
678+ h.rc.noteSyncBegin(nil, 7, func() { forced <- struct{}{} })
679+ watchdogs := h.armedTimers(t)
680+ require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
681+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
682+
683+ watchdogs[0].ch <- time.Time{}
684+ <-forced
685+ w := awaitWake(t, h.wakes)
686+ require.True(t, w.watchdog, "a wedged synchronized batch must be force-flushed by the watchdog")
687+ requireNoWake(t, h.wakes)
688+ })
689+
690+ t.Run("watchdog retains gate while force closes the VT batch", func(t *testing.T) {
691+ h := newCoordinatorHarness(t)
692+ entered := make(chan struct{})
693+ release := make(chan struct{})
694+ h.rc.noteSyncBegin(nil, 1, func() {
695+ close(entered)
696+ <-release
697+ })
698+ watchdogs := h.armedTimers(t)
699+ require.NotEmpty(t, watchdogs)
700+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
701+ watchdogs[0].ch <- time.Time{}
702+ <-entered
703+
704+ // A concurrent deadline/fire sees the retained batch gate and cannot
705+ // publish the partial VT state while force is blocked.
706+ h.rc.fireCurrent(false)
707+ requireNoWake(t, h.wakes)
708+ close(release)
709+ w := awaitWake(t, h.wakes)
710+ require.True(t, w.watchdog)
711+ require.True(t, w.urgent)
712+ requireNoWake(t, h.wakes)
713+ })
714+
715+ t.Run("watchdog flushes after the snapshotted generation is replaced", func(t *testing.T) {
716+ h := newCoordinatorHarness(t)
717+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
718+ h.rc.mu.Lock()
719+ staleGeneration := h.rc.normalLane.generation
720+ h.rc.mu.Unlock()
721+
722+ // Simulate a new urgent publication after fireCurrent(true) snapshots its
723+ // generation but before fire validates it.
724+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
725+ h.rc.fire(staleGeneration, true, true)
726+
727+ w := awaitWake(t, h.wakes)
728+ require.True(t, w.watchdog)
729+ require.True(t, w.urgent)
730+ require.Equal(t, 2, w.coalesced)
731+ requireNoWake(t, h.wakes)
732+ })
733+
734+ t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) {
735+ h := newCoordinatorHarness(t)
736+ h.syncActive.Store(true)
737+ h.rc.noteSyncBegin(nil, 1)
738+ watchdogs := h.armedTimers(t)
739+ require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
740+ h.syncActive.Store(false)
741+ h.rc.noteSyncEnd(nil, 1)
742+
743+ watchdogs[0].ch <- time.Time{}
744+ requireNoWake(t, h.wakes)
745+ })
746+}
747+
748+// --- preview subscription --------------------------------------------------------
749+
750+func TestRenderCoordinatorPreviewSubscription(t *testing.T) {
751+ h := newCoordinatorHarness(t)
752+ h.rc.subscribePreview(func(w renderWake) { h.previews <- w })
753+
754+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
755+ timers := h.armedTimers(t)
756+ require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
757+ timers[len(timers)-1].ch <- time.Time{}
758+ owner := awaitWake(t, h.wakes)
759+ preview := awaitWake(t, h.previews)
760+ require.Equal(t, owner, preview, "a subscribed preview observes the same coalesced wake")
761+ requireNoWake(t, h.previews)
762+
763+ h.rc.teardownPreview()
764+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
765+ timers = h.armedTimers(t)
766+ require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
767+ timers[len(timers)-1].ch <- time.Time{}
768+ awaitWake(t, h.wakes)
769+ requireNoWake(t, h.previews)
770+}
771+
772+// --- lifecycle and stale callbacks -----------------------------------------------
773+
774+func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
775+ cases := []struct {
776+ name string
777+ attach bool
778+ }{
779+ {name: "attached cross-session target blocked on ack", attach: true},
780+ {name: "headless cross-session target blocked on ack"},
781+ }
782+ for _, tc := range cases {
783+ t.Run(tc.name, func(t *testing.T) {
784+ h := newCoordinatorHarness(t)
785+ h.ackReady.Store(false)
786+ if tc.attach {
787+ h.rc.attach(&attachedClient{})
788+ }
789+ viewer := &attachedClient{}
790+ h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
791+
792+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "target output"})
793+ timers := h.armedTimers(t)
794+ require.Len(t, timers, 1)
795+ timers[0].ch <- time.Time{}
796+
797+ preview := awaitWake(t, h.previews)
798+ if tc.attach {
799+ require.NotNil(t, preview.lease.attachment)
800+ }
801+ preview.lease = nil
802+ require.Equal(t, renderWake{coalesced: 1}, preview,
803+ "the viewer preview must receive target output without target ACK capacity")
804+ requireNoWake(t, h.wakes)
805+ requireNoWake(t, h.previews)
806+
807+ h.ackReady.Store(true)
808+ h.rc.notifyAck()
809+ wake := awaitWake(t, h.wakes)
810+ if tc.attach {
811+ require.NotNil(t, wake.lease.attachment)
812+ }
813+ wake.lease = nil
814+ require.Equal(t, renderWake{coalesced: 1}, wake,
815+ "the target primary frame remains pending for its own ACK")
816+ requireNoWake(t, h.previews)
817+ })
818+ }
819+}
820+
821+func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *testing.T) {
822+ h := newCoordinatorHarness(t)
823+ target, viewer := &attachedClient{}, &attachedClient{}
824+ h.ackReady.Store(false)
825+ h.rc.attach(target)
826+ h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
827+ h.rc.noteDetach(target)
828+
829+ // A detached target only accepts PTY/session-owned invalidations. The old
830+ // attachment remains stale and must not revive its render path.
831+ h.rc.invalidateForAttachment(target, renderInvalidation{class: invalidateOutput, producer: "stale attachment"})
832+ require.Empty(t, h.armedTimers(t))
833+
834+ for _, producer := range []string{"first PTY output", "second PTY output"} {
835+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: producer})
836+ timers := h.armedTimers(t)
837+ require.Len(t, timers, 1)
838+ timers[0].ch <- time.Time{}
839+ preview := awaitWake(t, h.previews)
840+ require.Nil(t, preview.lease, "headless preview wakes must not retain a revoked primary lease")
841+ require.Equal(t, renderWake{coalesced: 1}, preview)
842+ requireNoWake(t, h.wakes)
843+
844+ h.rc.mu.Lock()
845+ require.False(t, h.rc.pending)
846+ require.False(t, h.rc.pendingPreview)
847+ require.False(t, h.rc.ackDeferred)
848+ require.False(t, h.rc.deadlineDue)
849+ require.Zero(t, h.rc.coalesced)
850+ require.True(t, h.rc.primaryDetachedLocked())
851+ require.NotNil(t, h.rc.lease)
852+ require.False(t, h.rc.lease.active)
853+ h.rc.mu.Unlock()
854+ }
855+
856+ // A later attach retains the normal first-paint reset path.
857+ replacement := &attachedClient{}
858+ h.ackReady.Store(true)
859+ h.rc.attach(replacement)
860+ h.rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "first paint"})
861+ timers := h.armedTimers(t)
862+ require.Len(t, timers, 1)
863+ timers[0].ch <- time.Time{}
864+ wake := awaitWake(t, h.wakes)
865+ require.True(t, wake.reset)
866+ require.Same(t, replacement, wake.lease.attachment)
867+}
868+
869+func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
870+ cases := []struct {
871+ name string
872+ transition func(*renderCoordinator, *attachedClient, *attachedClient)
873+ }{
874+ {"target detach", func(rc *renderCoordinator, target, _ *attachedClient) { rc.noteDetach(target) }},
875+ {"target replacement", func(rc *renderCoordinator, target, replacement *attachedClient) { rc.noteReplace(target, replacement) }},
876+ {"target teardown", func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
877+ }
878+ for _, tc := range cases {
879+ t.Run(tc.name, func(t *testing.T) {
880+ h := newCoordinatorHarness(t)
881+ target, replacement, viewer := &attachedClient{}, &attachedClient{}, &attachedClient{}
882+ h.rc.attach(target)
883+ h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
884+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "target output"})
885+ stale := h.armedTimers(t)
886+ require.Len(t, stale, 1)
887+
888+ tc.transition(h.rc, target, replacement)
889+ stale[0].ch <- time.Time{}
890+ requireNoWake(t, h.wakes)
891+ requireNoWake(t, h.previews)
892+ })
893+ }
894+}
895+
896+func TestRenderCoordinatorPreviewSubscriptionsAreIndependent(t *testing.T) {
897+ h := newCoordinatorHarness(t)
898+ one, two := &attachedClient{}, &attachedClient{}
899+ first, second := make(chan renderWake, 1), make(chan renderWake, 1)
900+ h.rc.subscribePreviewFor(one, 1, func(w renderWake) { first <- w })
901+ h.rc.subscribePreviewFor(two, 1, func(w renderWake) { second <- w })
902+ h.rc.attach(&attachedClient{})
903+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "preview"})
904+ last := h.armedTimers(t)
905+ last[len(last)-1].ch <- time.Time{}
906+ awaitWake(t, first)
907+ awaitWake(t, second)
908+
909+ h.rc.teardownPreviewFor(one, 1)
910+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "preview"})
911+ last = h.armedTimers(t)
912+ last[len(last)-1].ch <- time.Time{}
913+ requireNoWake(t, first)
914+ awaitWake(t, second)
915+}
916+
917+func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
918+ cases := []struct {
919+ name string
920+ teardown func(rc *renderCoordinator, owner *attachedClient)
921+ }{
922+ {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
923+ {"park", func(rc *renderCoordinator, owner *attachedClient) { rc.notePark(owner) }},
924+ {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
925+ }
926+ for _, tc := range cases {
927+ t.Run(tc.name, func(t *testing.T) {
928+ h := newCoordinatorHarness(t)
929+ owner := &attachedClient{}
930+ h.rc.attach(owner)
931+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
932+ stale := h.armedTimers(t)
933+ require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
934+
935+ tc.teardown(h.rc, owner)
936+ for _, timer := range stale {
937+ timer.ch <- time.Time{}
938+ }
939+ requireNoWake(t, h.wakes)
940+
941+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
942+ for _, timer := range h.armedTimers(t) {
943+ timer.ch <- time.Time{}
944+ }
945+ requireNoWake(t, h.wakes)
946+ })
947+ }
948+
949+ t.Run("replacement attachment wakes independently", func(t *testing.T) {
950+ h := newCoordinatorHarness(t)
951+ owner := &attachedClient{}
952+ replacement := &attachedClient{}
953+ h.rc.attach(owner)
954+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
955+ stale := h.armedTimers(t)
956+ require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
957+
958+ h.rc.noteReplace(owner, replacement)
959+ h.rc.attach(replacement)
960+ for _, timer := range stale {
961+ timer.ch <- time.Time{}
962+ }
963+ requireNoWake(t, h.wakes)
964+
965+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true})
966+ fresh := h.armedTimers(t)
967+ require.NotEmpty(t, fresh, "a replacement attachment must arm its own deadline")
968+ fresh[len(fresh)-1].ch <- time.Time{}
969+ w := awaitWake(t, h.wakes)
970+ require.True(t, w.reset, "the replacement starts from an independent full state")
971+ requireNoWake(t, h.wakes)
972+ })
973+}
974+
975+// --- resize metadata ownership -----------------------------------------------------
976+
977+func TestRenderCoordinatorResizeMetadata(t *testing.T) {
978+ sz := func(cols, rows int) domain.Size { return domain.Size{Cols: cols, Rows: rows} }
979+ cases := []struct {
980+ name string
981+ run func(t *testing.T, rc *renderCoordinator, owner, replacement *attachedClient)
982+ }{
983+ {
984+ name: "latest request wins with strictly increasing epochs",
985+ run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {
986+ require.Equal(t, uint64(1), rc.recordResizeRequest(sz(100, 30), owner))
987+ require.Equal(t, uint64(2), rc.recordResizeRequest(sz(110, 32), owner))
988+ require.Equal(t, uint64(3), rc.recordResizeRequest(sz(120, 40), owner))
989+ snap := rc.resizeSnapshot()
990+ require.Equal(t, sz(120, 40), snap.size)
991+ require.Same(t, owner, snap.source)
992+ require.Equal(t, uint64(3), snap.epoch)
993+
994+ // Resize state is exclusively coordinator-owned.
995+ require.Zero(t, snap.committed)
996+ },
997+ },
998+ {
999+ name: "duplicate sizes still advance the epoch deterministically",
1000+ run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {
1001+ require.Equal(t, uint64(1), rc.recordResizeRequest(sz(120, 40), owner))
1002+ require.Equal(t, uint64(2), rc.recordResizeRequest(sz(120, 40), owner))
1003+ snap := rc.resizeSnapshot()
1004+ require.Equal(t, sz(120, 40), snap.size)
1005+ require.Equal(t, uint64(2), snap.epoch)
1006+ },
1007+ },
1008+ {
1009+ name: "stale resize callbacks preserve latest metadata through lifecycle transitions",
1010+ run: func(t *testing.T, rc *renderCoordinator, owner, replacement *attachedClient) {
1011+ type lifecycleCase struct {
1012+ name string
1013+ installNew bool
1014+ }
1015+ for _, tc := range []lifecycleCase{
1016+ {name: "detach"},
1017+ {name: "park"},
1018+ {name: "session teardown"},
1019+ {name: "replacement", installNew: true},
1020+ } {
1021+ t.Run(tc.name, func(t *testing.T) {
1022+ h := newCoordinatorHarness(t)
1023+ staleOwner := &attachedClient{}
1024+ freshOwner := &attachedClient{}
1025+ h.rc.attach(staleOwner)
1026+ require.Equal(t, uint64(1), h.rc.recordResizeRequest(sz(120, 40), staleOwner))
1027+
1028+ // This models a stale callback which captured the old
1029+ // attachment before lifecycle ownership changed.
1030+ staleCallback := func() uint64 {
1031+ return h.rc.recordResizeRequest(sz(80, 20), staleOwner)
1032+ }
1033+
1034+ switch tc.name {
1035+ case "detach":
1036+ h.rc.noteDetach(staleOwner)
1037+ case "park":
1038+ h.rc.notePark(staleOwner)
1039+ case "session teardown":
1040+ h.rc.beginSessionTeardown().finish()
1041+ case "replacement":
1042+ h.rc.noteReplace(staleOwner, freshOwner)
1043+ h.rc.attach(freshOwner)
1044+ require.Equal(t, uint64(2), h.rc.recordResizeRequest(sz(100, 50), freshOwner))
1045+ }
1046+
1047+ require.Zero(t, staleCallback(), "stale callback must not advance the resize epoch")
1048+ snap := h.rc.resizeSnapshot()
1049+ if tc.installNew {
1050+ require.Equal(t, sz(100, 50), snap.size)
1051+ require.Equal(t, uint64(2), snap.epoch)
1052+ require.Same(t, freshOwner, snap.source)
1053+ return
1054+ }
1055+ require.Equal(t, sz(120, 40), snap.size)
1056+ require.Equal(t, uint64(1), snap.epoch)
1057+ require.Same(t, staleOwner, snap.source,
1058+ "the stale callback must not replace the recorded attachment identity")
1059+ })
1060+ }
1061+ },
1062+ },
1063+ }
1064+ for _, tc := range cases {
1065+ t.Run(tc.name, func(t *testing.T) {
1066+ h := newCoordinatorHarness(t)
1067+ owner := &attachedClient{}
1068+ replacement := &attachedClient{}
1069+ h.rc.attach(owner)
1070+ tc.run(t, h.rc, owner, replacement)
1071+ })
1072+ }
1073+}
1074+
1075+// --- producer fan-in ------------------------------------------------------------
1076+
1077+// producerFiles is the current production direct-paint inventory. Every file
1078+// gets exactly one exercised state transition in TestProducerInvalidations.
1079+var producerFiles = []string{
1080+ "attention.go", "client.go", "copymode.go", "floating.go", "input.go",
1081+ "palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go",
1082+ "session.go", "session_back.go",
1083+}
1084+
1085+func TestProducerInvalidations(t *testing.T) {
1086+ cases := []struct {
1087+ file string
1088+ name string
1089+ tabs int
1090+ run func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)
1091+ }{
1092+ {
1093+ file: "attention.go",
1094+ name: "attention repaint",
1095+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1096+ d.repaintAttachedClients(sess)
1097+ },
1098+ },
1099+ {
1100+ file: "client.go",
1101+ name: "client theme application",
1102+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1103+ d.applyTheme(sess, ac, ports.Theme{TrueColor: true})
1104+ },
1105+ },
1106+ {
1107+ file: "copymode.go",
1108+ name: "copy mode entry",
1109+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1110+ d.enterCopyMode(sess, ac)
1111+ },
1112+ },
1113+ {
1114+ file: "floating.go",
1115+ name: "floating toggle to visible",
1116+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1117+ fp := newPaneWithStableID(layout.PaneID("floating"), "float-producer", newQuietPTY(), domain.Size{Cols: 40, Rows: 10})
1118+ tb := sess.activeTab()
1119+ tb.mu.Lock()
1120+ tb.floating.pane = fp
1121+ tb.floating.state = floatingHidden
1122+ tb.floating.generation = 1
1123+ tb.mu.Unlock()
1124+ require.NoError(t, d.toggleFloating(sess, ac))
1125+ },
1126+ },
1127+ {
1128+ file: "input.go",
1129+ name: "switch tab key action",
1130+ tabs: 2,
1131+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1132+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2)
1133+ require.Equal(t, 1, activeTabIndex(sess))
1134+ },
1135+ },
1136+ {
1137+ file: "palette.go",
1138+ name: "palette entry",
1139+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1140+ d.enterPalette(sess, ac)
1141+ },
1142+ },
1143+ {
1144+ file: "pane_actions.go",
1145+ name: "pane split",
1146+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1147+ require.NoError(t, d.splitPane(sess, ac, layout.Right))
1148+ },
1149+ },
1150+ {
1151+ file: "picker.go",
1152+ name: "picker entry",
1153+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1154+ d.enterPicker(sess, ac)
1155+ },
1156+ },
1157+ {
1158+ file: "prompt.go",
1159+ name: "prompt entry",
1160+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1161+ d.enterPrompt(sess, ac, "rename", "", func(string) error { return nil })
1162+ },
1163+ },
1164+ {
1165+ file: "render.go",
1166+ name: "retained resize dispatch",
1167+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1168+ clk := newCoordinatorMockClock(t, 2)
1169+ // The coordinator owns deadline time; install the deterministic
1170+ // clock on the already-attached coordinator rather than changing
1171+ // Daemon's construction-time clock afterwards.
1172+ sess.renderCoordinator().opts.clock = clk.clock
1173+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26})
1174+ timer := awaitCoordinatorScheduledTimer(t, clk)
1175+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
1176+ timer.ch <- time.Time{}
1177+ awaitTestCompletion(t, done, "resize callback did not complete")
1178+ },
1179+ },
1180+ {
1181+ file: "session.go",
1182+ name: "tab close repaint",
1183+ tabs: 2,
1184+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1185+ sess.mu.Lock()
1186+ tb := sess.tabs[1]
1187+ sess.mu.Unlock()
1188+ d.closeTab(sess, tb, true)
1189+ },
1190+ },
1191+ {
1192+ file: "session_back.go",
1193+ name: "back session fallback without a target",
1194+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1195+ d.backSession(sess, ac)
1196+ require.Same(t, sess, ac.currentSession())
1197+ },
1198+ },
1199+ }
1200+
1201+ exercised := make([]string, 0, len(cases))
1202+ for _, tc := range cases {
1203+ exercised = append(exercised, tc.file)
1204+ }
1205+ require.ElementsMatch(t, producerFiles, exercised,
1206+ "every current direct-paint producer file needs exactly one exercised transition")
1207+
1208+ for _, tc := range cases {
1209+ t.Run(tc.file+"/"+tc.name, func(t *testing.T) {
1210+ tabs := tc.tabs
1211+ if tabs == 0 {
1212+ tabs = 1
1213+ }
1214+ d, sess, ac, sends, releases := newManualTabSession(t, tabs)
1215+ defer releaseAll(releases)
1216+ invs := make(chan renderInvalidation, 8)
1217+ sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
1218+ clock: d.clock,
1219+ wake: func(renderWake) {},
1220+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
1221+ }))
1222+
1223+ tc.run(t, d, sess, ac)
1224+
1225+ awaitInvalidation(t, invs)
1226+ requireNoInvalidation(t, invs)
1227+ requireNoCoordinatorOutputFrame(t, sends)
1228+ })
1229+ }
1230+}
1231+
1232+// TestProducerInvalidationInventory is a supplementary inventory guard only:
1233+// it keeps producerFiles aligned with the daemon source layout so the
1234+// behavioral table above cannot drift silently. It is never acceptance
1235+// evidence for coordinator behavior.
1236+func TestProducerInvalidationInventory(t *testing.T) {
1237+ for _, name := range producerFiles {
1238+ _, err := os.Stat(filepath.Join(".", name))
1239+ require.NoErrorf(t, err, "producer file %s is gone; update TestProducerInvalidations", name)
1240+ }
1241+}
1242+
1243+// --- coordinator resize dispatch ----------------------------------------------
1244+
1245+func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {
1246+ p, release := newBlockingPTY(t)
1247+ defer release()
1248+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1249+ // Exercise the original seam: two fallback paints reach lazy initialization
1250+ // together before either can compose.
1251+ ac.overlays = nil
1252+ ac.overlayOnce = sync.Once{}
1253+ start := make(chan struct{})
1254+ var wg sync.WaitGroup
1255+ wg.Add(2)
1256+ for range 2 {
1257+ go func() {
1258+ defer wg.Done()
1259+ <-start
1260+ d.paint(sess, ac, true, nil)
1261+ }()
1262+ }
1263+ close(start)
1264+ wg.Wait()
1265+ require.NotNil(t, ac.overlays)
1266+}
1267+
1268+func TestStartPaneGoroutinesAccountsForOneReader(t *testing.T) {
1269+ p, release := newBlockingPTY(t)
1270+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
1271+ tb := sess.activeTab()
1272+ require.NotNil(t, tb)
1273+ d.startPaneGoroutines(sess, tb, tb.focusedPane())
1274+ release()
1275+ select {
1276+ case <-waitGroupDone(&d.sessWg):
1277+ case <-time.After(time.Second):
1278+ t.Fatal("one reader must balance exactly one WaitGroup count")
1279+ }
1280+}
1281+
1282+func requireWorkerExit(t *testing.T, done <-chan struct{}) {
1283+ t.Helper()
1284+ awaitTestCompletion(t, done, "cancelled coordinator timer worker did not exit")
1285+}
1286+
1287+func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstall(t *testing.T) {
1288+ p, releasePTY := newBlockingPTY(t)
1289+ defer releasePTY()
1290+ d, sess, owner, ownerSends := newManualSessionWithPTYs(t, p)
1291+ replacementTransport, replacementSends := newCapturingTransport(t)
1292+ replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: owner.size}
1293+ replacement.initOverlays()
1294+ replacement.setSession(sess)
1295+
1296+ clock := newCoordinatorMockClock(t, 2)
1297+ d.clock = clock.clock
1298+ rc := newRenderCoordinator(renderCoordinatorOptions{
1299+ clock: clock.clock,
1300+ wake: func(w renderWake) {
1301+ // This is the production ownership boundary: composition must use
1302+ // the coordinator's captured attachment, never sess.client.
1303+ d.paint(sess, w.lease.attachment, w.reset, w.lease)
1304+ },
1305+ ackReady: func() bool { return true },
1306+ })
1307+ rc.attach(owner)
1308+ sess.installRenderCoordinator(rc)
1309+ rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
1310+ timer := awaitCoordinatorScheduledTimer(t, clock)
1311+
1312+ // Model attachClient's publication window exactly: sess.client is new,
1313+ // but coordinator replacement has not yet invalidated the old deadline.
1314+ sess.mu.Lock()
1315+ sess.client = replacement
1316+ sess.mu.Unlock()
1317+ timer.ch <- time.Time{}
1318+ requireNoCoordinatorOutputFrame(t, replacementSends)
1319+ requireNoCoordinatorOutputFrame(t, ownerSends)
1320+}
1321+
1322+func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) {
1323+ rc := newRenderCoordinator(renderCoordinatorOptions{})
1324+ newer := &timerToken{generation: 2, timer: &portsmocks.MockTimer{}, cancel: make(chan struct{}), done: make(chan struct{})}
1325+ stale := &timerToken{generation: 1}
1326+
1327+ rc.mu.Lock()
1328+ rc.resizeLane.generation = 2
1329+ rc.resizeLane.token = newer
1330+ require.False(t, rc.resizeLane.clearLocked(stale))
1331+ require.Same(t, newer, rc.resizeLane.token)
1332+ require.True(t, rc.resizeLane.clearLocked(newer))
1333+ require.Nil(t, rc.resizeLane.token)
1334+ rc.mu.Unlock()
1335+}
1336+
1337+func TestRenderCoordinatorNilClockRetryCompletesWithoutSynchronousRun(t *testing.T) {
1338+ rc := newRenderCoordinator(renderCoordinatorOptions{})
1339+ owner := &attachedClient{}
1340+ rc.attach(owner)
1341+ lease := rc.attachmentLease(owner)
1342+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 40}, owner, lease)
1343+ require.NotZero(t, epoch)
1344+ require.True(t, rc.resizeCurrentForLease(epoch, owner, lease, true))
1345+
1346+ called := false
1347+ rc.scheduleResizeRetryForLease(epoch, owner, lease, func() { called = true })
1348+ require.False(t, called, "a nil clock is a disabled retry lane, not a synchronous retry")
1349+ rc.mu.Lock()
1350+ retryToken := rc.retryLane.token
1351+ rc.mu.Unlock()
1352+ require.Nil(t, retryToken, "a disabled retry lane must not retain stale completion ownership")
1353+}
1354+
1355+func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {
1356+ clock := portsmocks.NewMockClock(t)
1357+ timer := portsmocks.NewMockTimer(t)
1358+ clock.EXPECT().NewTimer(minOutputRenderDeadline).Return(timer).Once()
1359+ timer.EXPECT().C().Return((<-chan time.Time)(nil)).Once()
1360+ timer.EXPECT().Stop().Return(true).Once()
1361+
1362+ wakes := make(chan renderWake, 1)
1363+ rc := newRenderCoordinator(renderCoordinatorOptions{
1364+ clock: clock,
1365+ ackReady: func() bool { return true },
1366+ wake: func(w renderWake) { wakes <- w },
1367+ })
1368+ rc.invalidate(renderInvalidation{class: invalidateOutput})
1369+
1370+ require.Equal(t, renderWake{coalesced: 1}, <-wakes)
1371+ require.Equal(t, renderCoordinatorBurstMetricsSnapshot{invalidations: 1, wakes: 1, coalesced: 1}, rc.burstMetricsSnapshot())
1372+ rc.mu.Lock()
1373+ require.Nil(t, rc.normalLane.token)
1374+ rc.mu.Unlock()
1375+}
1376+
1377+func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1378+ t.Run("detach and park retain a gated headless preview until complete", func(t *testing.T) {
1379+ for _, transition := range []struct {
1380+ name string
1381+ run func(*renderCoordinator, *attachedClient)
1382+ }{
1383+ {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1384+ {"park", func(rc *renderCoordinator, ac *attachedClient) { rc.notePark(ac) }},
1385+ } {
1386+ t.Run(transition.name, func(t *testing.T) {
1387+ h := newCoordinatorHarness(t)
1388+ target, viewer, p := &attachedClient{}, &attachedClient{}, &pane{}
1389+ h.rc.attach(target)
1390+ h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
1391+ h.rc.noteSyncBegin(p, 1)
1392+ watchdog := h.armedTimers(t)[0]
1393+ transition.run(h.rc, target)
1394+
1395+ // PTY-owned output remains visible to picker observers while the
1396+ // target attachment is parked. Its deadline must retain the sync gate.
1397+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "complete preview"})
1398+ timers := h.armedTimers(t)
1399+ require.Len(t, timers, 1)
1400+ h.rc.mu.Lock()
1401+ normalWorkerDone := h.rc.normalLane.token.done
1402+ h.rc.mu.Unlock()
1403+ require.NotNil(t, normalWorkerDone)
1404+ timers[0].ch <- time.Time{}
1405+ requireWorkerExit(t, normalWorkerDone)
1406+ requireNoWake(t, h.previews)
1407+
1408+ h.rc.mu.Lock()
1409+ require.Same(t, h.rc.syncBatches[p].lane.token.timer, watchdog.mock)
1410+ h.rc.mu.Unlock()
1411+ h.rc.noteSyncEnd(p, 1)
1412+ preview := awaitWake(t, h.previews)
1413+ preview.lease = nil
1414+ require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview)
1415+ requireNoWake(t, h.previews)
1416+ requireNoWake(t, h.wakes)
1417+ })
1418+ }
1419+ })
1420+
1421+ t.Run("replacement first paint waits for active batch and rejects stale owner", func(t *testing.T) {
1422+ h := newCoordinatorHarness(t)
1423+ old, replacement, p := &attachedClient{}, &attachedClient{}, &pane{}
1424+ h.rc.attach(old)
1425+ h.rc.noteSyncBegin(p, 1)
1426+ watchdog := h.armedTimers(t)[0]
1427+ h.rc.noteReplace(old, replacement, false)
1428+ require.False(t, h.rc.markAttachmentReady(h.rc.attachmentLease(old)), "old attachment Welcome is stale")
1429+ require.True(t, h.rc.markAttachmentReady(h.rc.attachmentLease(replacement)))
1430+
1431+ h.rc.invalidateForAttachment(old, renderInvalidation{class: invalidateUrgent, producer: "stale old attachment"})
1432+ require.Empty(t, h.armedTimers(t))
1433+ h.rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"})
1434+ timers := h.armedTimers(t)
1435+ require.Len(t, timers, 1)
1436+ h.rc.mu.Lock()
1437+ normalWorkerDone := h.rc.normalLane.token.done
1438+ h.rc.mu.Unlock()
1439+ require.NotNil(t, normalWorkerDone)
1440+ timers[0].ch <- time.Time{}
1441+ requireWorkerExit(t, normalWorkerDone)
1442+ requireNoWake(t, h.wakes)
1443+
1444+ h.rc.mu.Lock()
1445+ require.Same(t, h.rc.syncBatches[p].lane.token.timer, watchdog.mock)
1446+ h.rc.mu.Unlock()
1447+ h.rc.noteSyncEnd(p, 1)
1448+ wake := awaitWake(t, h.wakes)
1449+ require.True(t, wake.reset)
1450+ require.True(t, wake.urgent)
1451+ require.Same(t, replacement, wake.lease.attachment)
1452+ requireNoWake(t, h.wakes)
1453+ })
1454+
1455+ t.Run("pane removal and session teardown cancel sync workers", func(t *testing.T) {
1456+ for _, lifecycle := range []struct {
1457+ name string
1458+ end func(*renderCoordinator, *pane)
1459+ }{
1460+ {"pane removal", func(rc *renderCoordinator, p *pane) { rc.noteSyncPaneRemoved(p) }},
1461+ {"session teardown", func(rc *renderCoordinator, _ *pane) { rc.beginSessionTeardown().finish() }},
1462+ } {
1463+ t.Run(lifecycle.name, func(t *testing.T) {
1464+ clk := newCoordinatorMockClock(t, 2)
1465+ rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock})
1466+ p := &pane{}
1467+ rc.noteSyncBegin(p, 1)
1468+ syncTimer := <-clk.timers
1469+ rc.mu.Lock()
1470+ syncDone := rc.syncBatches[p].lane.token.done
1471+ rc.mu.Unlock()
1472+ lifecycle.end(rc, p)
1473+ syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
1474+ requireWorkerExit(t, syncDone)
1475+ if lifecycle.name == "session teardown" {
1476+ rc.waitForTimerWorkers()
1477+ }
1478+ })
1479+ }
1480+ })
1481+}
1482+
1483+func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
1484+ newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, *coordinatorMockClock, chan renderInvalidation) {
1485+ t.Helper()
1486+ p, releasePTY := newBlockingPTY(t)
1487+ t.Cleanup(releasePTY)
1488+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1489+ clk := newCoordinatorMockClock(t, 4)
1490+ d.clock = clk.clock
1491+ invs := make(chan renderInvalidation, 4)
1492+ rc := newRenderCoordinator(renderCoordinatorOptions{
1493+ clock: clk.clock,
1494+ wake: func(renderWake) {},
1495+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
1496+ })
1497+ rc.attach(ac)
1498+ sess.installRenderCoordinator(rc)
1499+ return d, sess, ac, sends, clk, invs
1500+ }
1501+
1502+ t.Run("bounded timer records metadata and dispatches through the coordinator", func(t *testing.T) {
1503+ d, sess, ac, sends, clk, invs := newResizeFixture(t)
1504+
1505+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
1506+ timer := awaitCoordinatorScheduledTimer(t, clk)
1507+ require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)
1508+ require.LessOrEqual(t, timer.duration, maxOutputRenderDeadline)
1509+ snap := sess.renderCoordinator().resizeSnapshot()
1510+ require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size,
1511+ "the coordinator must record the latest requested resize before delegating")
1512+ require.Same(t, ac, snap.source)
1513+ require.Equal(t, uint64(1), snap.epoch)
1514+ requireNoCoordinatorOutputFrame(t, sends)
1515+
1516+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
1517+ timer.ch <- time.Time{}
1518+ awaitTestCompletion(t, done, "resize callback did not complete")
1519+ inv := awaitInvalidation(t, invs)
1520+ require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")
1521+ requireNoInvalidation(t, invs)
1522+ requireNoCoordinatorOutputFrame(t, sends)
1523+ })
1524+
1525+ t.Run("stale generations stay rejected", func(t *testing.T) {
1526+ d, sess, ac, sends, clk, invs := newResizeFixture(t)
1527+
1528+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 24})
1529+ first := awaitCoordinatorScheduledTimer(t, clk)
1530+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
1531+ latest := awaitCoordinatorScheduledTimer(t, clk)
1532+ require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().epoch,
1533+ "every resize request must advance the coordinator epoch")
1534+
1535+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
1536+ first.ch <- time.Time{}
1537+ requireNoInvalidation(t, invs)
1538+ requireNoCoordinatorOutputFrame(t, sends)
1539+
1540+ latest.ch <- time.Time{}
1541+ awaitTestCompletion(t, done, "latest resize callback did not complete")
1542+ inv := awaitInvalidation(t, invs)
1543+ require.True(t, inv.reset)
1544+ requireNoInvalidation(t, invs)
1545+ requireNoCoordinatorOutputFrame(t, sends)
1546+ require.Equal(t, domain.Size{Cols: 120, Rows: 24}, sess.renderCoordinator().resizeSnapshot().size)
1547+ })
1548+
1549+ t.Run("cancellation still drops the pending dispatch", func(t *testing.T) {
1550+ d, sess, ac, sends, clk, invs := newResizeFixture(t)
1551+
1552+ d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30})
1553+ timer := awaitCoordinatorScheduledTimer(t, clk)
1554+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
1555+ sess.renderCoordinator().noteDetach(ac)
1556+
1557+ timer.ch <- time.Time{}
1558+ awaitTestCompletion(t, done, "cancelled resize callback did not complete")
1559+ requireNoInvalidation(t, invs)
1560+ requireNoCoordinatorOutputFrame(t, sends)
1561+ })
1562+
1563+ t.Run("request reports immediate completion and async schedule acceptance", func(t *testing.T) {
1564+ t.Run("immediate reset invalidation", func(t *testing.T) {
1565+ d, sess, ac, _, _, invs := newResizeFixture(t)
1566+
1567+ require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true))
1568+ inv := awaitInvalidation(t, invs)
1569+ require.True(t, inv.reset)
1570+ })
1571+
1572+ t.Run("superseded immediate transaction", func(t *testing.T) {
1573+ resizeStarted := make(chan struct{})
1574+ releaseResize := make(chan struct{})
1575+ pty := &transactionalResizePTY{onResize: func() {
1576+ close(resizeStarted)
1577+ <-releaseResize
1578+ }}
1579+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
1580+ clk := newCoordinatorMockClock(t, 2)
1581+ d.clock = clk.clock
1582+ invs := make(chan renderInvalidation, 1)
1583+ rc := newRenderCoordinator(renderCoordinatorOptions{
1584+ clock: clk.clock,
1585+ wake: func(renderWake) {},
1586+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
1587+ })
1588+ rc.attach(ac)
1589+ sess.installRenderCoordinator(rc)
1590+
1591+ result := make(chan bool, 1)
1592+ go func() {
1593+ result <- d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true)
1594+ }()
1595+ <-resizeStarted
1596+ require.NotZero(t, rc.recordResizeRequest(domain.Size{Cols: 140, Rows: 30}, ac))
1597+ close(releaseResize)
1598+
1599+ require.False(t, <-result)
1600+ requireNoInvalidation(t, invs)
1601+ })
1602+
1603+ t.Run("async schedule and teardown rejection", func(t *testing.T) {
1604+ d, sess, ac, _, clk, _ := newResizeFixture(t)
1605+
1606+ require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, false))
1607+ awaitCoordinatorScheduledTimer(t, clk)
1608+
1609+ // A torn-down coordinator cannot accept a stale attachment's schedule.
1610+ sess.renderCoordinator().beginSessionTeardown().finish()
1611+ require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false))
1612+ })
1613+ })
1614+}
A internal/usecase/daemon/render_coordinator_types.go +176 -0
@@ -0,0 +1,176 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "sync/atomic"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// Coordinator contracts and support types are kept with their ownership
13+// semantics, separate from the scheduling transitions in render_coordinator.
14+
15+// One coordinator serves a session: producers mutate authoritative state and
16+// publish an invalidation; only the coordinator decides when composition runs.
17+// Producers never compose or send.
18+
19+// Coordinator deadline bounds. Urgent transitions (input echo, overlay state
20+// changes) must become visible within urgentRenderDeadline; bulk output
21+// coalesces inside the adaptive [minOutputRenderDeadline,
22+// maxOutputRenderDeadline] window.
23+const (
24+ urgentRenderDeadline = 2 * time.Millisecond
25+ minOutputRenderDeadline = 8 * time.Millisecond
26+ maxOutputRenderDeadline = 16 * time.Millisecond
27+)
28+
29+// invalidationClass ranks a producer transition's latency demand.
30+type invalidationClass uint8
31+
32+const (
33+ // invalidateOutput is bulk PTY/screen churn served by the adaptive window.
34+ invalidateOutput invalidationClass = iota
35+ // invalidateUrgent is an interactive transition served within 2ms.
36+ invalidateUrgent
37+)
38+
39+// renderInvalidation is one producer-published state transition.
40+type renderInvalidation struct {
41+ class invalidationClass
42+ // reset requests a sticky FullRedraw: once requested it survives
43+ // coalescing and ACK deferral until a wake actually delivers it.
44+ reset bool
45+ // producer identifies the publishing call-site for provenance.
46+ producer string
47+}
48+
49+// renderWake is one coalesced composition request.
50+type renderWake struct {
51+ reset bool
52+ urgent bool
53+ coalesced int
54+ // lease is snapshotted under coordinator ownership. The wake consumer must
55+ // compose only for this exact active attachment incarnation, never by
56+ // rereading session.client after an attach publication.
57+ lease *attachmentLease
58+ // watchdog marks a flush forced by the synchronized-output watchdog.
59+ watchdog bool
60+}
61+
62+// attachmentLease is the sole lifecycle identity for a primary render
63+// attachment. It is only mutated while renderCoordinator.mu is held; callbacks
64+// carry its pointer and must revalidate it before every side effect.
65+type attachmentLease struct {
66+ attachment *attachedClient
67+ ready bool
68+ active bool
69+ // A lease is an immutable callback capability. Its lifecycle bits are
70+ // changed only under renderCoordinator.mu; callbacks revalidate it at each
71+ // short effect boundary and never retain coordinator ownership while routing
72+ // arbitrary handlers.
73+}
74+
75+// resizeRequestMetadata is the coordinator-owned latest requested resize
76+// state. Resize dispatch reads it through resizeSnapshot and validates the
77+// coordinator epoch before applying it.
78+type resizeRequestMetadata struct {
79+ size domain.Size
80+ source *attachedClient
81+ // lease binds every delayed resize/retry callback to the exact attachment
82+ // incarnation that published this request.
83+ lease *attachmentLease
84+ epoch uint64 // latest requested epoch
85+ committed uint64 // latest published epoch
86+}
87+
88+// renderCoordinatorOptions wires one coordinator instance.
89+type renderCoordinatorOptions struct {
90+ clock ports.Clock
91+ observer ports.RuntimeObserver
92+ // wake composes a coalesced render request.
93+ wake func(renderWake)
94+ // ackReady reports whether the attachment may compose another output
95+ // state (the outputStateStream window has capacity).
96+ ackReady func() bool
97+ // syncRenderable reports whether a pane's synchronized batch currently
98+ // contributes to the attached composition. It must not acquire c.mu.
99+ syncRenderable func(*pane) bool
100+ // syncActive is retained for existing test harness construction; per-pane
101+ // lifecycle and syncRenderable are the coordinator gate.
102+ syncActive func() bool
103+ // onInvalidate observes every published invalidation (test-visible hook).
104+ onInvalidate func(renderInvalidation)
105+ // afterSyncGateEvaluated is a package-private deterministic test seam. It
106+ // runs unlocked after visibility predicates and before registry validation.
107+ afterSyncGateEvaluated func()
108+}
109+
110+// renderCoordinator fans in producer invalidations for one attached session.
111+// renderCoordinatorBurstMetrics is deliberately observational. It permits
112+// benchmark/pprof callers to measure producer bursts without influencing
113+// scheduling or transport policy.
114+type renderCoordinatorBurstMetrics struct {
115+ invalidations atomic.Uint64
116+ wakes atomic.Uint64
117+ coalesced atomic.Uint64
118+}
119+
120+// ackBlockedSpan hands an ACK-blocked endpoint pair between coordinator
121+// transitions without holding c.mu while the observer runs. A consuming ACK
122+// may arrive while Start is blocked in observer I/O; finish records that
123+// closure and publishStart emits it only after Start has returned.
124+type ackBlockedSpan struct {
125+ observer ports.RuntimeObserver
126+ correlation ports.RuntimeCorrelation
127+
128+ mu sync.Mutex
129+ startPublished bool
130+ finished bool
131+ endPending bool
132+ endValid bool
133+}
134+
135+func newACKBlockedSpan(observer ports.RuntimeObserver) *ackBlockedSpan {
136+ return &ackBlockedSpan{observer: observer, correlation: ports.NewRuntimeCorrelation()}
137+}
138+
139+func (s *ackBlockedSpan) publishStart() {
140+ s.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", s.correlation, ports.RuntimeACKBlockedStart, 0, true))
141+
142+ s.mu.Lock()
143+ s.startPublished = true
144+ endPending, endValid := s.endPending, s.endValid
145+ s.endPending = false
146+ s.mu.Unlock()
147+ if endPending {
148+ s.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", s.correlation, ports.RuntimeACKBlockedEnd, 0, endValid))
149+ }
150+}
151+
152+// finish closes a span at most once. It never invokes the observer before the
153+// blocked start has completed publication.
154+func (s *ackBlockedSpan) finish(valid bool) {
155+ s.mu.Lock()
156+ if s.finished {
157+ s.mu.Unlock()
158+ return
159+ }
160+ s.finished = true
161+ if !s.startPublished {
162+ s.endPending, s.endValid = true, valid
163+ s.mu.Unlock()
164+ return
165+ }
166+ s.mu.Unlock()
167+ s.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", s.correlation, ports.RuntimeACKBlockedEnd, 0, valid))
168+}
169+
170+// renderCoordinatorBurstMetricsSnapshot is an immutable, internal view for
171+// benchmark reporting. Scheduling only writes the atomic counters above.
172+type renderCoordinatorBurstMetricsSnapshot struct {
173+ invalidations uint64
174+ wakes uint64
175+ coalesced uint64
176+}
A internal/usecase/daemon/render_damage_receipt_test.go +128 -0
@@ -0,0 +1,128 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+ "github.com/bnema/vev/pkg/renderer"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
14+ _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
15+ p := sess.tabs[0].focusedPane()
16+ p.mu.Lock()
17+ p.screen.ClearDamage()
18+ p.screen.Write([]byte("preview-safe"))
19+ p.mu.Unlock()
20+
21+ preview := snapshotPickerPreview(sess.tabs[0])
22+ require.NotEmpty(t, preview.Rows)
23+ require.Empty(t, ac.renderScratch.receipts, "picker preview must not create primary damage receipts")
24+ p.mu.Lock()
25+ require.NotEmpty(t, p.screen.Damage(), "picker preview must not acknowledge VT damage")
26+ p.mu.Unlock()
27+
28+ ac.sendMu.Lock()
29+ state, ok := capturePrimaryRenderState(sess, ac, barState{}, capturedOverlayRenderState{}, pickerPreviewEmpty(), domain.FloatingConfig{}, false, nil)
30+ ac.sendMu.Unlock()
31+ require.True(t, ok)
32+ require.Len(t, state.receipts, 1)
33+ require.Same(t, p, state.receipts[0].pane, "the primary transaction owns the pane receipt")
34+ p.mu.Lock()
35+ require.NotEmpty(t, p.screen.Damage(), "primary capture remains non-destructive until emission commits")
36+ p.mu.Unlock()
37+}
38+
39+// These are deliberately end-to-end pipeline tests: the pending damage comes
40+// from a real pane VT Write, not a hand-built FullRedraw.
41+func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {
42+ for _, tt := range []struct {
43+ name string
44+ fail func(*composedRenderFrame, *attachedClient)
45+ restore func(*session, *attachedClient, ports.Transport)
46+ }{
47+ {
48+ name: "prepare failure",
49+ fail: func(c *composedRenderFrame, _ *attachedClient) {
50+ // An invalid frame makes the real output.prepare reject after capture.
51+ c.frame = renderer.Frame{Width: 1}
52+ },
53+ restore: func(_ *session, _ *attachedClient, _ ports.Transport) {},
54+ },
55+ {
56+ name: "send failure",
57+ fail: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
58+ restore: func(sess *session, ac *attachedClient, healthy ports.Transport) {
59+ sess.mu.Lock()
60+ sess.client = ac
61+ sess.mu.Unlock()
62+ ac.setSession(sess)
63+ ac.replaceTransport(healthy)
64+ },
65+ },
66+ } {
67+ t.Run(tt.name, func(t *testing.T) {
68+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
69+ healthy := ac.transport()
70+ p := sess.tabs[0].focusedPane()
71+
72+ // Prime both the renderer shadow and the committed capture cache.
73+ d.paint(sess, ac, true, nil)
74+ <-sends
75+ p.mu.Lock()
76+ p.screen.Write([]byte("\x1b[1;1Hchanged"))
77+ p.mu.Unlock()
78+
79+ state, composed := captureComposeForReceiptTest(t, sess, ac)
80+ tt.fail(&composed, ac)
81+ require.True(t, d.emitFrame(sess, ac, state, composed))
82+ p.mu.Lock()
83+ require.NotEmpty(t, p.screen.Damage(), "failed emission must retain real VT damage")
84+ p.mu.Unlock()
85+
86+ tt.restore(sess, ac, healthy)
87+ state, composed = captureComposeForReceiptTest(t, sess, ac)
88+ require.True(t, d.emitFrame(sess, ac, state, composed))
89+ frame := <-sends
90+ out, err := ports.UnmarshalOutput(frame.Payload)
91+ require.NoError(t, err)
92+ require.Contains(t, string(out.Data), "changed", "retry must emit the retained terminal bytes")
93+ p.mu.Lock()
94+ require.Empty(t, p.screen.Damage(), "only the successful retry commits its receipt")
95+ require.Equal(t, 'c', p.screen.Frame.At(0, 0).Rune, "retry preserves the changed VT shadow")
96+ p.mu.Unlock()
97+ })
98+ }
99+}
100+
101+func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
102+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
103+ d.paint(sess, ac, true, nil)
104+ <-sends
105+ p := sess.tabs[0].focusedPane()
106+ p.mu.Lock()
107+ p.screen.Write([]byte("\x1b[1;1Hfirst"))
108+ p.mu.Unlock()
109+ state, composed := captureComposeForReceiptTest(t, sess, ac)
110+ p.mu.Lock()
111+ p.screen.Write([]byte("\x1b[2;1Hsecond"))
112+ p.mu.Unlock()
113+ require.True(t, d.emitFrame(sess, ac, state, composed))
114+ <-sends
115+ p.mu.Lock()
116+ defer p.mu.Unlock()
117+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, p.screen.Damage(), "a stale receipt conservatively preserves intervening VT output")
118+}
119+
120+func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {
121+ t.Helper()
122+ ac.sendMu.Lock() // emitFrame releases the transaction lock.
123+ state, ok := capturePrimaryRenderState(sess, ac, barState{}, capturedOverlayRenderState{}, pickerPreviewEmpty(), domain.FloatingConfig{}, false, nil)
124+ require.True(t, ok)
125+ return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
126+}
127+
128+func pickerPreviewEmpty() picker.Preview { return picker.Preview{} }
A internal/usecase/daemon/render_legacy_test.go +279 -0
@@ -0,0 +1,279 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+ themeui "github.com/bnema/vev/internal/usecase/theme"
7+ "github.com/bnema/vev/internal/usecase/ui"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+// Legacy composition adapters are test-only. Production rendering is owned by
12+// capturePrimaryRenderState -> composeFrame -> emitFrame.
13+// copyTargetRectLocked maps a captured copy source into the already-composed
14+// client frame. The caller holds tb.mu, preserving the layout/floating
15+// snapshot while the rectangle is chosen.
16+func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, floatingFrameGeometry floatingGeometry) domain.Rect {
17+ if p == nil {
18+ return domain.Rect{}
19+ }
20+ if hasFloating && p == floating {
21+ return floatingFrameGeometry.Inner
22+ }
23+ for _, placement := range layoutSnap.placements {
24+ if placement.ID == p.id && !placement.Collapsed {
25+ r := placement.Content
26+ r.X += contentArea.X
27+ r.Y += contentArea.Y
28+ return r
29+ }
30+ }
31+ p.mu.Lock()
32+ width, height := p.screen.Frame.Width, p.screen.Frame.Height
33+ p.mu.Unlock()
34+ return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)}
35+}
36+
37+type composedFrameCache struct {
38+ frame renderer.Frame
39+ valid bool
40+ layoutSnap tabLayoutSnapshot
41+ titleGenerations map[layout.PaneID]uint64
42+ floating *pane
43+ floatingFrame renderer.Frame
44+ floatingGeneration uint64
45+ floatingGeometry floatingGeometry
46+ floatingTitleGeneration uint64
47+ floatingCaptured capturedPaneRenderState
48+}
49+
50+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
51+ return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...)
52+}
53+
54+func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
55+ return composeClientFrameWithLayout(bars, tb, full, solveTabLayoutLocked(tb), caches...)
56+}
57+
58+func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
59+ var cache *barCache
60+ if len(caches) > 0 {
61+ cache = caches[0]
62+ }
63+ return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
64+}
65+
66+func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
67+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
68+}
69+
70+func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
71+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
72+}
73+
74+func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
75+ p := tb.focusedPane()
76+ styles := newThemeStyles(bars.theme)
77+ if p == nil {
78+ width, screenRows := tb.size.Cols, tb.size.Rows
79+ if width <= 0 || screenRows <= 0 {
80+ return renderer.NewFrame(0, 0), nil
81+ }
82+ return renderer.NewFrame(width, screenRows+2), nil
83+ }
84+ p.mu.Lock()
85+ width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
86+ p.mu.Unlock()
87+ if tb.size.Valid() {
88+ width, screenRows = tb.size.Cols, tb.size.Rows
89+ }
90+ cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
91+ layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
92+ var frame renderer.Frame
93+ if cacheValid {
94+ frame = composed.frame
95+ } else {
96+ frame = renderer.NewFrame(width, screenRows+2)
97+ if composed != nil {
98+ full = true
99+ }
100+ }
101+ if !layoutSame {
102+ full = true
103+ }
104+ contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
105+ if cacheValid && !layoutSame {
106+ clearFrameRect(frame, contentArea)
107+ }
108+ topBar := frame.Row(0)
109+ drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
110+ var titleGenerations map[layout.PaneID]uint64
111+ if composed != nil {
112+ if composed.titleGenerations == nil {
113+ composed.titleGenerations = make(map[layout.PaneID]uint64)
114+ }
115+ titleGenerations = composed.titleGenerations
116+ }
117+ contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
118+ bottomBar := frame.Row(screenRows + 1)
119+ drawStatusBarState(bottomBar, bars, styles)
120+ if composed != nil {
121+ composed.frame = frame
122+ composed.valid = true
123+ composed.layoutSnap = layoutSnap
124+ }
125+ if full {
126+ if cache != nil {
127+ cache.capture(topBar, bottomBar)
128+ }
129+ return frame, []renderer.Damage{renderer.FullRedraw()}
130+ }
131+ damage := translateDamage(contentDamage, 0, 1)
132+ if cache == nil || !sameCells(cache.top, topBar) {
133+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
134+ }
135+ if cache == nil || !sameCells(cache.bottom, bottomBar) {
136+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows + 1, Width: width, Height: 1})
137+ }
138+ if cache != nil {
139+ cache.capture(topBar, bottomBar)
140+ }
141+ return frame, damage
142+}
143+
144+func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
145+ return composeTabFrameWithLayout(tb, area, theme, tabLayoutSnapshot{})
146+}
147+
148+func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
149+ frame := renderer.NewFrame(area.Width, area.Height)
150+ damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
151+ return frame, damage
152+}
153+
154+func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
155+ return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false, nil)
156+}
157+
158+func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage {
159+ contentArea := domain.Rect{Width: area.Width, Height: area.Height}
160+ root := tb.tree.Root
161+ placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
162+ if !ok {
163+ placements, ok = layout.Solve(root, contentArea)
164+ }
165+ var fallback *pane
166+ if !ok {
167+ fallback = tb.focusedPane()
168+ if fallback == nil {
169+ return nil
170+ }
171+ placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
172+ }
173+ // A valid cache has the same layout, so its title IDs remain valid. Reset
174+ // the existing cache only when rebuilding after layout or frame churn.
175+ if titleGenerations != nil && !cacheValid {
176+ clear(titleGenerations)
177+ }
178+ if ok && !cacheValid {
179+ drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
180+ }
181+ var damage []renderer.Damage
182+ for _, pl := range placements {
183+ p := tb.panes[pl.ID]
184+ if p == nil && fallback != nil && pl.ID == fallback.id {
185+ p = fallback
186+ }
187+ if p == nil {
188+ continue
189+ }
190+ focused := tb.tree.Focus == pl.ID
191+ pl = offsetPlacement(pl, area.X, area.Y)
192+ if pl.TitleBar.Height > 0 {
193+ generation := drawPaneTitleBar(frame, pl, p, focused, theme)
194+ if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
195+ titleDamage := pl.TitleBar
196+ titleDamage.X -= area.X
197+ titleDamage.Y -= area.Y
198+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
199+ }
200+ if titleGenerations != nil {
201+ titleGenerations[pl.ID] = generation
202+ }
203+ }
204+ if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
205+ if consumeDamage {
206+ p.mu.Lock()
207+ captured := capturePaneRenderStateLocked(p, domain.Rect{})
208+ p.screen.AcknowledgeDamage(captured.damageGeneration)
209+ p.mu.Unlock()
210+ }
211+ continue
212+ }
213+ var paneFrame renderer.Frame
214+ var paneDamage []renderer.Damage
215+ if consumeDamage {
216+ p.mu.Lock()
217+ captured := capturePaneRenderStateLocked(p, pl.Content)
218+ p.screen.AcknowledgeDamage(captured.damageGeneration)
219+ p.mu.Unlock()
220+ paneFrame, paneDamage = captured.frame, captured.damage
221+ } else {
222+ p.mu.Lock()
223+ paneDamage = p.screen.Damage()
224+ if !cacheValid || len(paneDamage) > 0 {
225+ blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
226+ }
227+ for _, d := range paneDamage {
228+ localContent := pl.Content
229+ localContent.X -= area.X
230+ localContent.Y -= area.Y
231+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
232+ }
233+ p.mu.Unlock()
234+ continue
235+ }
236+ if !cacheValid || len(paneDamage) > 0 {
237+ blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme)
238+ }
239+ for _, d := range paneDamage {
240+ localContent := pl.Content
241+ localContent.X -= area.X
242+ localContent.Y -= area.Y
243+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
244+ }
245+ }
246+ return damage
247+}
248+
249+func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
250+ return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
251+}
252+
253+func clearFrameRect(frame renderer.Frame, r domain.Rect) {
254+ blank := renderer.BlankCell()
255+ for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
256+ for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
257+ frame.Set(x, y, blank)
258+ }
259+ }
260+}
261+
262+func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {
263+ styles := newThemeStyles(theme)
264+ style := styles.border
265+ if focused {
266+ style = styles.statusBar
267+ } else {
268+ style = themeui.DimStyle(style, theme)
269+ }
270+ for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
271+ frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
272+ }
273+ p.mu.Lock()
274+ title := p.displayTitleLocked()
275+ generation := p.title.generation
276+ p.mu.Unlock()
277+ ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
278+ return generation
279+}
A internal/usecase/daemon/render_pipeline.go +376 -0
@@ -0,0 +1,376 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "strconv"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/internal/usecase/palette"
11+ "github.com/bnema/vev/internal/usecase/picker"
12+ themeui "github.com/bnema/vev/internal/usecase/theme"
13+ "github.com/bnema/vev/internal/usecase/ui"
14+ "github.com/bnema/vev/pkg/renderer"
15+)
16+
17+// composeCacheInput is an attachment-local value snapshot. composeFrame never
18+// retains or accesses the attachment that owns it.
19+// renderStageHooks are optional observability hooks. They are invoked only at
20+// completed production boundaries: capture and compose after their result is
21+// available, and emit after prepare plus transport success (including a
22+// successful no-byte emission). Failed attempts count for capture/compose but
23+// never for emit.
24+type renderStageHooks struct {
25+ capture func()
26+ compose func()
27+ emit func()
28+}
29+
30+type composeCacheInput struct {
31+ valid bool
32+ frame renderer.Frame
33+ layoutFingerprint string
34+ titleGenerations map[layout.PaneID]uint64
35+ damage []renderer.Damage
36+ floatingVisible bool
37+ floatingGeneration uint64
38+ floatingGeometry floatingGeometry
39+ floatingTitleGeneration uint64
40+ bars barCache
41+ theme themeui.Theme
42+}
43+
44+type composedRenderFrame struct {
45+ frame renderer.Frame
46+ damage []renderer.Damage
47+ cursor cursorOut
48+ cache composeCacheInput
49+ reset bool
50+}
51+
52+// composeFrame is pure with respect to daemon ownership: it consumes only the
53+// capture, the last committed cache, and an independent attachment-owned
54+// scratch cache. It returns a replacement cache without mutating committed.
55+func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
56+ scratch := composeCacheInput{}
57+ if len(scratchIn) > 0 {
58+ scratch = scratchIn[0]
59+ }
60+ width, rows := state.layout.area.Width, state.layout.area.Height
61+ if width <= 0 || rows < 0 {
62+ return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
63+ }
64+ // Compose into the alternate attachment-owned buffer. The committed frame is
65+ // copied before incremental drawing so prepare/send failures cannot change it.
66+ canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == rows+2
67+ frame := scratch.frame
68+ if !canReuseFrame {
69+ frame = renderer.NewFrame(width, rows+2)
70+ }
71+ if in.frame.Width == width && in.frame.Height == rows+2 {
72+ for y := 0; y < frame.Height; y++ {
73+ copy(frame.Row(y), in.frame.Row(y))
74+ }
75+ }
76+ styles := newThemeStyles(state.theme)
77+ drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
78+ drawStatusBarState(frame.Row(rows+1), state.bars, styles)
79+ content := domain.Rect{Y: 1, Width: width, Height: rows}
80+ if state.layout.valid && state.layout.root != nil {
81+ drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme))
82+ }
83+
84+ full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible
85+ titles := scratch.titleGenerations
86+ if titles == nil {
87+ titles = make(map[layout.PaneID]uint64, len(state.panes))
88+ }
89+ damage := scratch.damage[:0]
90+ for _, pane := range state.panes {
91+ pl := offsetPlacement(pane.placement, 0, 1)
92+ if pl.TitleBar.Height > 0 {
93+ drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, state.theme)
94+ if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
95+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})
96+ }
97+ titles[pane.id] = pane.titleGeneration
98+ }
99+ if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
100+ blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme)
101+ }
102+ for _, d := range pane.damage {
103+ if d.Kind != renderer.DamageFullRedraw {
104+ d.Y++
105+ }
106+ damage = append(damage, d)
107+ }
108+ }
109+ // Keep the committed cache unadorned: floating composition clones this
110+ // base, so closing or moving a popup cannot retain dimmed/bordered cells.
111+ baseFrame := frame
112+ if state.floating.visible {
113+ var floatingDamage []renderer.Damage
114+ frame, floatingDamage = composeCapturedFloatingFrame(baseFrame, damage, state.floating, content, state.layout, state.theme, in, full || state.overlays.active())
115+ damage = floatingDamage
116+ }
117+ if !full {
118+ if !sameCells(in.bars.top, frame.Row(0)) {
119+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
120+ }
121+ if !sameCells(in.bars.bottom, frame.Row(rows+1)) {
122+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
123+ }
124+ }
125+ if state.overlays.active() {
126+ // Without a floating frame, overlay composition would otherwise mutate
127+ // the base cache in place. Floating composition already owns a clone.
128+ if !state.floating.visible {
129+ baseFrame = frame.Clone()
130+ }
131+ frame, damage = composeCapturedOverlays(state, frame, damage, content)
132+ }
133+ if full || state.overlays.active() {
134+ damage = []renderer.Damage{renderer.FullRedraw()}
135+ }
136+ cursorInputs := state.cursor
137+ cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
138+ cursor := desiredCapturedCursor(cursorInputs)
139+ outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
140+ outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
141+ return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
142+}
143+
144+func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, theme themeui.Theme) {
145+ styles := newThemeStyles(theme)
146+ style := styles.border
147+ if focused {
148+ style = styles.statusBar
149+ } else {
150+ style = themeui.DimStyle(style, theme)
151+ }
152+ for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
153+ frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
154+ }
155+ ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
156+}
157+
158+func desiredCapturedCursor(c capturedCursorInputs) cursorOut {
159+ if !c.renderable || c.hiddenByOverlay || !c.visible {
160+ return cursorOut{hidden: true}
161+ }
162+ style := c.style
163+ if !c.hasStyle {
164+ style = 1
165+ }
166+ return cursorOut{row: c.content.Y + 1 + c.row, col: c.content.X + c.col, style: style, hasStyle: true}
167+}
168+
169+func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {
170+ if state == nil || snap == nil {
171+ return
172+ }
173+ o := &state.overlays
174+ o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive
175+ o.copyMode, o.copySnapshot = snap.copyMode, snap.copySnapshot
176+ if snap.copyPane != nil {
177+ o.copyPaneID = snap.copyPane.id
178+ }
179+ styles := newThemeStyles(state.theme)
180+ size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
181+ if snap.copySearchModel != nil {
182+ o.copySearch.modal = copySearchModal
183+ o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.selection)
184+ }
185+ if snap.pickerActive && snap.pickerModel != nil {
186+ o.picker.modal = pickerModal
187+ renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle()}
188+ o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)
189+ }
190+ if snap.paletteActive && snap.paletteModel != nil {
191+ o.palette.modal = paletteModalFor(size, paletteCfg)
192+ guidance := ""
193+ if snap.paletteHints != nil {
194+ guidance = snap.paletteHints.Feedback
195+ }
196+ o.paletteGuidance = guidance
197+ o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance})
198+ }
199+ if snap.promptActive && snap.promptModel != nil {
200+ o.prompt.modal = promptModalFor(snap.promptModel.Title())
201+ o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.accent)
202+ }
203+ state.cursor.hiddenByOverlay = o.active()
204+}
205+
206+func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {
207+ o := state.overlays
208+ if o.copyActive {
209+ target := domain.Rect{}
210+ if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) {
211+ target = state.floating.geometry.translate(content.X, content.Y).Inner
212+ }
213+ if target.Width == 0 {
214+ id := o.copyPaneID
215+ if id == "" {
216+ id = state.layout.focus
217+ }
218+ for _, p := range state.panes {
219+ if p.id == id && !p.placement.Collapsed {
220+ target = p.placement.Content
221+ target.Y += content.Y
222+ break
223+ }
224+ }
225+ }
226+ frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars)
227+ }
228+ legacy := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}
229+ if o.paletteActive && !state.floating.visible {
230+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacy, state.theme)
231+ }
232+ for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} {
233+ if modal.inner.Width == 0 && modal.inner.Height == 0 {
234+ continue
235+ }
236+ inner := modal.modal.Composite(frame, newThemeStyles(state.theme).border)
237+ for y := range min(inner.Height, modal.inner.Height) {
238+ copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)])
239+ }
240+ damage = []renderer.Damage{renderer.FullRedraw()}
241+ }
242+ return frame, damage
243+}
244+
245+func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
246+ changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
247+ if !changed {
248+ return nil
249+ }
250+ prev := ac.lastCursor
251+ ac.lastCursor = desired
252+ ac.lastCursor.valid = true
253+ if desired.hidden {
254+ return []byte("\x1b[?25l")
255+ }
256+ var b []byte
257+ b = append(b, "\x1b["...)
258+ b = strconv.AppendInt(b, int64(desired.row+1), 10)
259+ b = append(b, ';')
260+ b = strconv.AppendInt(b, int64(desired.col+1), 10)
261+ b = append(b, 'H')
262+ if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
263+ b = append(b, "\x1b["...)
264+ b = strconv.AppendInt(b, int64(desired.style), 10)
265+ b = append(b, " q"...)
266+ }
267+ b = append(b, "\x1b[?25h"...)
268+ return b
269+}
270+
271+// commitDamageReceipts acknowledges only snapshots that reached the client.
272+// sendMu serializes attachment transactions; panes are locked individually so
273+// no pane lock is held while another is acquired. A stale generation is
274+// intentionally acknowledged too: Screen conservatively leaves FullRedraw for
275+// the next transaction.
276+func commitDamageReceipts(receipts []damageReceipt) {
277+ for _, receipt := range receipts {
278+ if receipt.pane == nil {
279+ continue
280+ }
281+ receipt.pane.mu.Lock()
282+ receipt.pane.screen.AcknowledgeDamage(receipt.generation)
283+ receipt.pane.mu.Unlock()
284+ }
285+}
286+
287+// emitFrame is the sole side-effecting half of the pipeline. The caller holds
288+// sendMu for the complete capture/compose/emit transaction.
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+ }
301+ if sess == nil || ac == nil || state == nil {
302+ if ac != nil {
303+ ac.sendMu.Unlock()
304+ }
305+ return false
306+ }
307+ sess.mu.Lock()
308+ owned := sess.client == ac
309+ sess.mu.Unlock()
310+ if !owned || state.attachment != ac || ac.currentSession() != sess {
311+ ac.sendMu.Unlock()
312+ return false
313+ }
314+ if state.lease != nil {
315+ rc := sess.renderCoordinator()
316+ if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
317+ ac.sendMu.Unlock()
318+ return false
319+ }
320+ }
321+ ac.sess.mu.Lock()
322+ if ac.sess.v != sess {
323+ ac.sess.mu.Unlock()
324+ ac.sendMu.Unlock()
325+ return false
326+ }
327+ endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
328+ prepared, err := ac.output.prepare(composed.frame, composed.damage, composed.reset)
329+ endDiff(0, err == nil)
330+ if err != nil {
331+ ac.sess.mu.Unlock()
332+ ac.sendMu.Unlock()
333+ d.log.Error("render draw failed", "err", err, "session", sess.name)
334+ return true
335+ }
336+ data := append([]byte(nil), prepared.data...)
337+ data = append(data, ac.encodeCursorTail(composed.cursor, len(data) > 0)...)
338+ var sendTr ports.Transport
339+ var sendErr error
340+ if len(data) > 0 {
341+ sendTr = ac.transport()
342+ endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
343+ if sendTr == nil {
344+ sendErr = errors.New("client transport is nil")
345+ } else {
346+ send := sendTr.Send
347+ if async, ok := sendTr.(ports.AsyncTransport); ok {
348+ send = async.SendAsync
349+ }
350+ sendErr = prepared.send(data, ac.echoAck.Load(), send)
351+ }
352+ endEmit(uint64(len(data)), sendErr == nil)
353+ }
354+ if sendErr == nil {
355+ // Publish only after output preparation and transport emission both
356+ // succeed. Receipt acknowledgement follows after releasing the attachment
357+ // session guard, while sendMu still owns this transaction.
358+ ac.pipelineScratch = ac.pipelineCache
359+ ac.pipelineCache = composed.cache
360+ }
361+ ac.sess.mu.Unlock()
362+ if sendErr == nil {
363+ // A successful no-byte emission also commits: its renderer shadow still
364+ // represents the captured frame. Lock panes only under sendMu and with no
365+ // session guard held.
366+ commitDamageReceipts(state.receipts)
367+ if ac.renderStages.emit != nil {
368+ ac.renderStages.emit()
369+ }
370+ }
371+ ac.sendMu.Unlock()
372+ if sendErr != nil {
373+ d.detachOnSendError(sess, ac, sendTr)
374+ }
375+ return true
376+}
A internal/usecase/daemon/render_pipeline_cache_test.go +116 -0
@@ -0,0 +1,116 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+ "github.com/bnema/vev/pkg/renderer"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type cacheFailTransport struct{}
16+
17+func (cacheFailTransport) Send(ports.Frame) error { return errors.New("send failed") }
18+func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
19+func (cacheFailTransport) Close() error { return nil }
20+
21+func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
22+ for _, failure := range []struct {
23+ name string
24+ apply func(*composedRenderFrame, *attachedClient)
25+ }{
26+ {
27+ name: "prepare",
28+ apply: func(composed *composedRenderFrame, _ *attachedClient) {
29+ // Frame validation fails in output.prepare after composition.
30+ composed.frame = renderer.Frame{Width: 1}
31+ },
32+ },
33+ {
34+ name: "send",
35+ apply: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
36+ },
37+ } {
38+ t.Run(failure.name, func(t *testing.T) {
39+ d, sess, ac, sends := newManualSessionWithPTYs(t)
40+ healthy := ac.transport()
41+ committed := composeFrame(cacheState("committed", 1), composeCacheInput{})
42+ ac.pipelineCache = committed.cache
43+ before := cloneComposeCache(ac.pipelineCache)
44+ pending := composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
45+ failure.apply(&pending, ac)
46+
47+ state := cacheState("next", 2)
48+ state.attachment = ac
49+ ac.sendMu.Lock()
50+ require.True(t, d.emitFrame(sess, ac, &state, pending))
51+ require.Equal(t, before, cloneComposeCache(ac.pipelineCache), "failed emission must not publish any composed cache backing storage")
52+
53+ // A send error detaches the failed link. Re-own this test attachment with
54+ // its original healthy transport, then retry the pending state.
55+ if failure.name == "send" {
56+ sess.mu.Lock()
57+ sess.client = ac
58+ sess.mu.Unlock()
59+ ac.setSession(sess)
60+ ac.replaceTransport(healthy)
61+ }
62+ pending = composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
63+ state = cacheState("next", 2)
64+ state.attachment = ac
65+ ac.sendMu.Lock()
66+ require.True(t, d.emitFrame(sess, ac, &state, pending))
67+ require.Equal(t, pending.cache, ac.pipelineCache)
68+ frame := <-sends
69+ output, err := ports.UnmarshalOutput(frame.Payload)
70+ require.NoError(t, err)
71+ require.Contains(t, string(output.Data), "next", "the retry must emit state retained after the failed emission")
72+ })
73+ }
74+}
75+
76+func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) {
77+ visible := cacheState("base", 1)
78+ committed := composeFrame(visible, composeCacheInput{})
79+
80+ hidden := visible
81+ hidden.floating = capturedFloatingRenderState{}
82+ hidden.panes[0].damage = nil
83+ result := composeFrame(hidden, committed.cache, committed.cache)
84+
85+ require.Equal(t, 's', result.frame.Row(1)[2].Rune)
86+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage)
87+}
88+
89+func cacheState(title string, generation uint64) capturedRenderState {
90+ pane := renderer.NewFrame(6, 1)
91+ for x, r := range title[:min(len(title), 6)] {
92+ pane.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
93+ }
94+ floating := renderer.NewFrame(2, 1)
95+ floating.Set(0, 0, renderer.Cell{Rune: 'F', Style: renderer.DefaultStyle()})
96+ return capturedRenderState{
97+ reset: false,
98+ layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},
99+ panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
100+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation},
101+ bars: barState{topRight: title, bottomRight: title},
102+ }
103+}
104+
105+func cloneComposeCache(in composeCacheInput) composeCacheInput {
106+ out := in
107+ out.frame = in.frame.Clone()
108+ out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
109+ for id, generation := range in.titleGenerations {
110+ out.titleGenerations[id] = generation
111+ }
112+ out.damage = append([]renderer.Damage(nil), in.damage...)
113+ out.bars.top = append([]renderer.Cell(nil), in.bars.top...)
114+ out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)
115+ return out
116+}
A internal/usecase/daemon/render_pipeline_test.go +281 -0
@@ -0,0 +1,281 @@
1+package daemon
2+
3+import (
4+ "io"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/testutil/replaytest"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+ "github.com/bnema/vev/internal/usecase/ui"
15+ "github.com/bnema/vev/pkg/renderer"
16+ "github.com/bnema/vev/pkg/vt"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+// scriptedReplayTransport is intentionally a ports.Transport only: replay
21+// assertions must not depend on any adapter's framing implementation.
22+type scriptedReplayTransport struct {
23+ t *testing.T
24+ frames []ports.Frame
25+ next int
26+}
27+
28+func (s *scriptedReplayTransport) Send(got ports.Frame) error {
29+ s.t.Helper()
30+ if s.next >= len(s.frames) {
31+ s.t.Errorf("unexpected frame %#v", got)
32+ return nil
33+ }
34+ want := s.frames[s.next]
35+ s.next++
36+ require.Equal(s.t, want.Type, got.Type)
37+ require.Equal(s.t, want.Payload, got.Payload)
38+ return nil
39+}
40+func (*scriptedReplayTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
41+func (*scriptedReplayTransport) Close() error { return nil }
42+
43+func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) {
44+ frames := replaytest.Transcript()
45+ transport := &scriptedReplayTransport{t: t, frames: frames}
46+ terminal := vt.NewScreen(8, 3)
47+ for _, frame := range frames {
48+ require.NoError(t, transport.Send(frame))
49+ output, err := ports.UnmarshalOutput(frame.Payload)
50+ require.NoError(t, err)
51+ require.Equal(t, frame.Payload, ports.MarshalOutput(output), "output payload must remain byte exact")
52+ terminal.Write(output.Data)
53+ }
54+ require.Equal(t, len(frames), transport.next)
55+ require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal.Frame), "terminal replay is the final renderer shadow")
56+}
57+
58+func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing.T) {
59+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
60+ p.screen.ClearDamage()
61+ p.screen.Write([]byte("old"))
62+
63+ p.mu.Lock()
64+ captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 3, Height: 1})
65+ p.mu.Unlock()
66+
67+ require.Equal(t, 3, captured.frame.Width)
68+ require.Equal(t, 1, captured.frame.Height)
69+ require.NotEmpty(t, p.screen.Damage(), "capture is only a transactional snapshot")
70+ p.mu.Lock()
71+ p.screen.Write([]byte("new"))
72+ p.mu.Unlock()
73+ require.Equal(t, 'o', captured.frame.At(0, 0).Rune, "capture must not alias the mutable VT frame")
74+}
75+
76+func TestCapturePaneRenderStateIsAlwaysNonDestructive(t *testing.T) {
77+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
78+ p.screen.ClearDamage()
79+ p.screen.Write([]byte("preview"))
80+
81+ p.mu.Lock()
82+ _ = capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2})
83+ p.mu.Unlock()
84+
85+ require.NotEmpty(t, p.screen.Damage())
86+}
87+
88+func TestCapturePaneRenderStateMalformedDamageFallsBackToFullRedraw(t *testing.T) {
89+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
90+ p.screen.ClearDamage()
91+ p.screen.Write([]byte("x"))
92+ p.screen.Damage()[0] = renderer.Damage{Kind: renderer.DamageText, X: -1, Y: 0, Width: 4, Height: 1}
93+
94+ p.mu.Lock()
95+ captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2})
96+ p.mu.Unlock()
97+
98+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, captured.damage)
99+}
100+
101+func TestCapturePaneRenderStateCollapsedRetainsDamage(t *testing.T) {
102+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
103+ p.screen.ClearDamage()
104+ p.screen.Write([]byte("hidden"))
105+
106+ p.mu.Lock()
107+ captured := capturePaneRenderStateLocked(p, domain.Rect{})
108+ p.mu.Unlock()
109+
110+ require.Zero(t, captured.frame.Width)
111+ require.Zero(t, captured.frame.Height)
112+ require.NotEmpty(t, p.screen.Damage(), "capture keeps hidden-pane damage pending until emission commits")
113+}
114+
115+func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
116+ pty, release := newBlockingPTY(t)
117+ t.Cleanup(release)
118+ d, sess, _, sends := newManualSessionWithPTYs(t, pty)
119+ ac := sess.client
120+ ac.sendMu.Lock()
121+ ac.output.next = ac.output.maxOutstanding
122+ ac.output.acked = 0
123+ ac.sendMu.Unlock()
124+ p := sess.tabs[0].focusedPane()
125+ p.mu.Lock()
126+ p.screen.ClearDamage()
127+ p.screen.Write([]byte("blocked"))
128+ p.mu.Unlock()
129+
130+ d.paint(sess, ac, false, nil)
131+
132+ p.mu.Lock()
133+ require.NotEmpty(t, p.screen.Damage())
134+ p.mu.Unlock()
135+ select {
136+ case frame := <-sends:
137+ t.Fatalf("ACK-blocked paint sent %#v", frame)
138+ default:
139+ }
140+}
141+
142+func TestComposeFrameUsesCapturedValuesAfterOwnersMutate(t *testing.T) {
143+ state := &capturedRenderState{
144+ layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, valid: true},
145+ panes: []capturedPaneRenderState{{
146+ id: "p", frame: func() renderer.Frame {
147+ f := renderer.NewFrame(4, 1)
148+ for i, r := range "old " {
149+ f.Set(i, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
150+ }
151+ return f
152+ }(), placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}, focused: true,
153+ damage: []renderer.Damage{renderer.FullRedraw()},
154+ }},
155+ bars: barState{},
156+ cursor: capturedCursorInputs{visible: true, renderable: true, content: domain.Rect{Width: 4, Height: 1}},
157+ }
158+ cache := composeCacheInput{}
159+ out := composeFrame(*state, cache)
160+ state.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()})
161+ require.Equal(t, "old ", rowText(out.frame.Row(1)))
162+ require.False(t, out.cursor.hidden)
163+}
164+
165+func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
166+ for _, name := range []string{"render.go", "floating_render.go", "picker.go"} {
167+ data, err := os.ReadFile(filepath.Join(".", name))
168+ require.NoError(t, err)
169+ source := string(data)
170+ for _, forbidden := range []string{
171+ "func composeClientFrame", "func composeTabFrame", "func composeFloatingFrame",
172+ } {
173+ require.NotContains(t, source, forbidden, "%s retains duplicate production composition path", name)
174+ }
175+ }
176+ data, err := os.ReadFile("render_pipeline.go")
177+ require.NoError(t, err)
178+ body := string(data)
179+ start := strings.Index(body, "func composeFrame(")
180+ require.NotEqual(t, -1, start)
181+ end := strings.Index(body[start:], "\nfunc ")
182+ if end > 0 {
183+ body = body[start : start+end]
184+ } else {
185+ body = body[start:]
186+ }
187+ for _, forbidden := range []string{".Lock()", ".Unlock()", ".Send(", ".prepare("} {
188+ require.NotContains(t, body, forbidden, "composeFrame must remain ownership- and transport-free")
189+ }
190+}
191+
192+func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
193+ paneFrame := renderer.NewFrame(12, 5)
194+ for y, text := range []string{"AAAAAAAAAAAA", "BBBBBBBBBBBB", "CCCCCCCCCCCC", "DDDDDDDDDDDD", "EEEEEEEEEEEE"} {
195+ for x, r := range text {
196+ paneFrame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
197+ }
198+ }
199+ floatingFrame := renderer.NewFrame(4, 1)
200+ for x, r := range "FLOAT"[:4] {
201+ floatingFrame.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
202+ }
203+ modalInner := renderer.NewFrame(6, 1)
204+ for x, r := range "PROMPT" {
205+ modalInner.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
206+ }
207+ state := capturedRenderState{
208+ reset: true,
209+ layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}},
210+ panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
211+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},
212+ bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
213+ overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}},
214+ cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
215+ }
216+ composed := composeFrame(state, composeCacheInput{})
217+ require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame))
218+ require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
219+
220+ stream := newOutputStateStream()
221+ prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)
222+ require.NoError(t, err)
223+ terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...)
224+ require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBB┌Prompt┐BB\x1b[4;1HCC│PROMPT│CC\x1b[5;1HDD└──────┘DD\x1b[6;1HEEEEEEEEEEEE\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
225+ client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
226+ client.Write(terminalBytes)
227+ require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
228+ again, err := stream.renderer.Draw(composed.frame, nil)
229+ require.NoError(t, err)
230+ require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
231+}
232+
233+func frameRows(frame renderer.Frame) []string {
234+ rows := make([]string, frame.Height)
235+ for y := range rows {
236+ rows[y] = rowText(frame.Row(y))
237+ }
238+ return rows
239+}
240+
241+func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
242+ for _, tt := range []struct {
243+ name string
244+ placement layout.Placement
245+ initial []string
246+ scrolled []string
247+ damage []renderer.Damage
248+ wantBytes string
249+ }{
250+ {name: "safe full width", placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 3}}, initial: []string{"AAAA", "BBBB", "CCCC"}, scrolled: []string{"BBBB", "CCCC", "N "}, damage: []renderer.Damage{{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, {Kind: renderer.DamageText, X: 0, Y: 2, Width: 4, Height: 1}}, wantBytes: "\x1b[0m\x1b[2;4r\x1b[4;1H\n\x1b[r\x1b[4;1HN\x1b[K\x1b[0m"},
251+ {name: "unsafe partial width", placement: layout.Placement{ID: "p", Content: domain.Rect{X: 2, Width: 2, Height: 3}}, initial: []string{"AA", "BB", "CC"}, scrolled: []string{"BB", "CC", "N "}, damage: []renderer.Damage{{Kind: renderer.DamageText, X: 2, Y: 0, Width: 2, Height: 3}}, wantBytes: "\x1b[2;3HBB\x1b[3;3HCC\x1b[4;3HN \x1b[0m"},
252+ } {
253+ t.Run(tt.name, func(t *testing.T) {
254+ makeState := func(rows []string, damage []renderer.Damage, reset bool) capturedRenderState {
255+ f := renderer.NewFrame(tt.placement.Content.Width, tt.placement.Content.Height)
256+ for y, text := range rows {
257+ for x, r := range text {
258+ f.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
259+ }
260+ }
261+ return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 3}, valid: true, focus: "p", placements: []layout.Placement{tt.placement}}, panes: []capturedPaneRenderState{{id: "p", frame: f, placement: tt.placement, focused: true, damage: damage}}, cursor: capturedCursorInputs{visible: false}}
262+ }
263+ stream := newOutputStateStream()
264+ first := composeFrame(makeState(tt.initial, []renderer.Damage{renderer.FullRedraw()}, true), composeCacheInput{})
265+ firstBytes, err := stream.render(first.frame, first.damage, first.reset)
266+ require.NoError(t, err)
267+ client := vt.NewScreen(4, 5)
268+ client.Write(firstBytes)
269+
270+ second := composeFrame(makeState(tt.scrolled, tt.damage, false), first.cache)
271+ secondBytes, err := stream.render(second.frame, second.damage, second.reset)
272+ require.NoError(t, err)
273+ require.Equal(t, tt.wantBytes, string(secondBytes))
274+ client.Write(secondBytes)
275+ require.Equal(t, frameRows(second.frame), frameRows(client.Frame))
276+ again, err := stream.renderer.Draw(second.frame, nil)
277+ require.NoError(t, err)
278+ require.Empty(t, again, "renderer shadow must equal replayed terminal state")
279+ })
280+ }
281+}
A internal/usecase/daemon/render_resize_deadline.go +147 -0
@@ -0,0 +1,147 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// Resize deadlines and retries retain coordinator-owned attachment, epoch, and
6+// token validation at every callback effect boundary.
7+func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
8+ return c.recordResizeRequestForLease(size, source, c.attachmentLease(source))
9+}
10+
11+func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source *attachedClient, lease *attachmentLease) uint64 {
12+ c.mu.Lock()
13+ if !c.leaseCurrentLocked(lease, false) || lease.attachment != source {
14+ c.mu.Unlock()
15+ return 0
16+ }
17+ _, retry := c.retryLane.replaceLocked()
18+ c.resize.epoch++
19+ c.resize.size, c.resize.source, c.resize.lease = size, source, lease
20+ epoch := c.resize.epoch
21+ c.mu.Unlock()
22+ stopDetachedTimer(retry)
23+ return epoch
24+}
25+
26+func (c *renderCoordinator) scheduleResizeForLease(size domain.Size, source *attachedClient, lease *attachmentLease, run func(uint64)) uint64 {
27+ epoch := c.recordResizeRequestForLease(size, source, lease)
28+ if epoch == 0 {
29+ return 0
30+ }
31+ c.mu.Lock()
32+ gen, old := c.resizeLane.replaceLocked()
33+ clock := c.opts.clock
34+ c.mu.Unlock()
35+ stopDetachedTimer(old)
36+ if clock == nil {
37+ if c.resizeCurrentForLease(epoch, source, lease, false) {
38+ run(epoch)
39+ }
40+ return epoch
41+ }
42+ timer := clock.NewTimer(minOutputRenderDeadline)
43+ timerC := timer.C()
44+ if timerC == nil {
45+ timer.Stop()
46+ if c.resizeCurrentForLease(epoch, source, lease, false) {
47+ run(epoch)
48+ }
49+ return epoch
50+ }
51+ c.mu.Lock()
52+ valid := !c.torndown && c.resizeLane.generation == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.source == source
53+ var token *timerToken
54+ if valid {
55+ token = c.resizeLane.publishLocked(gen, timer)
56+ valid = token != nil
57+ if valid {
58+ c.supervisor.startLocked(token, timerC, func() {
59+ c.mu.Lock()
60+ valid := !c.torndown && c.resizeLane.token == token && c.resizeLane.generation == token.generation && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.source == source
61+ if valid {
62+ c.resizeLane.clearLocked(token)
63+ }
64+ c.mu.Unlock()
65+ if valid {
66+ run(epoch)
67+ }
68+ })
69+ }
70+ }
71+ c.mu.Unlock()
72+ if !valid {
73+ timer.Stop()
74+ }
75+ return epoch
76+}
77+
78+func (c *renderCoordinator) resizeCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease, commit bool) bool {
79+ c.mu.Lock()
80+ defer c.mu.Unlock()
81+ if !c.leaseCurrentLocked(lease, false) || lease.attachment != source || c.resize.lease != lease || c.resize.source != source || c.resize.epoch != epoch {
82+ return false
83+ }
84+ if commit {
85+ if c.resize.committed >= epoch {
86+ return false
87+ }
88+ c.resize.committed = epoch
89+ }
90+ return true
91+}
92+
93+func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) {
94+ c.mu.Lock()
95+ if !c.leaseCurrentLocked(lease, false) || lease.attachment != source || c.resize.lease != lease || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch {
96+ c.mu.Unlock()
97+ return
98+ }
99+ gen, old := c.retryLane.replaceLocked()
100+ clock := c.opts.clock
101+ c.mu.Unlock()
102+ stopDetachedTimer(old)
103+ if clock == nil {
104+ return
105+ }
106+ timer := clock.NewTimer(minOutputRenderDeadline)
107+ timerC := timer.C()
108+ if timerC == nil {
109+ timer.Stop()
110+ return
111+ }
112+ c.mu.Lock()
113+ valid := !c.torndown && c.retryLane.generation == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
114+ if valid {
115+ token := c.retryLane.publishLocked(gen, timer)
116+ valid = token != nil
117+ if valid {
118+ c.supervisor.startLocked(token, timerC, func() {
119+ c.mu.Lock()
120+ valid := !c.torndown && c.retryLane.token == token && c.retryLane.generation == token.generation && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
121+ if valid {
122+ c.retryLane.clearLocked(token)
123+ }
124+ c.mu.Unlock()
125+ if valid {
126+ run()
127+ }
128+ })
129+ }
130+ }
131+ c.mu.Unlock()
132+ if !valid {
133+ timer.Stop()
134+ }
135+}
136+
137+func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {
138+ c.mu.Lock()
139+ defer c.mu.Unlock()
140+ return c.leaseCurrentLocked(lease, false) && lease.attachment == source && c.resize.lease == lease && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
141+}
142+
143+func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
144+ c.mu.Lock()
145+ defer c.mu.Unlock()
146+ return c.resize
147+}
M internal/usecase/daemon/render_test.go +508 -419
@@ -55,6 +55,43 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
5555 require.True(t, paletteVisible, "palette must remain composed above copy mode")
5656 }
5757
58+func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
59+ for _, tc := range []struct {
60+ name string
61+ clientSize domain.Size
62+ }{
63+ {name: "matching outer size", clientSize: domain.Size{Cols: 80, Rows: 25}},
64+ {name: "different outer size", clientSize: domain.Size{Cols: 100, Rows: 40}},
65+ } {
66+ t.Run(tc.name, func(t *testing.T) {
67+ pty, releasePTY := newBlockingPTY(t)
68+ defer releasePTY()
69+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
70+ ac.sendMu.Lock()
71+ ac.size = tc.clientSize
72+ ac.sendMu.Unlock()
73+
74+ // A retained visible popup still needs activation warmup. When the
75+ // outer terminal also changes, its completed transaction must cover
76+ // that popup rather than emitting a second reset-producing frame.
77+ floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8})
78+ installTestFloating(sess.activeTab(), floating, true)
79+
80+ d.firstPaint(sess, ac, tc.clientSize)
81+
82+ frame := awaitFrame(t, sends, ports.MsgOutput)
83+ output, err := ports.UnmarshalOutput(frame.Payload)
84+ require.NoError(t, err)
85+ require.Zero(t, output.BaseStateNum, "first paint must be a mandatory reset")
86+ select {
87+ case extra := <-sends:
88+ t.Fatalf("first paint emitted duplicate frame after floating activation resize: %#v", extra)
89+ default:
90+ }
91+ })
92+ }
93+}
94+
5895 func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
5996 p, release := newBlockingPTY(t)
6097 defer release()
@@ -84,7 +121,7 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
84121 client := vt.NewScreen(80, 25)
85122 pane := sess.tabs[0].focusedPane()
86123 pane.screen.Write([]byte("X"))
87- d.paint(sess, ac, true)
124+ d.paint(sess, ac, true, nil)
88125 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
89126 undimmed := client.Frame.At(0, 1)
90127 topBar := client.Frame.At(0, 0)
@@ -110,6 +147,451 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
110147 // stubClock returns timers whose channel never fires, so a scheduler under it
111148 // blocks in its debounce loop until the session context is cancelled. Used by
112149
150+// channelPTY drives ptyReader through generated PTY mocks. A processed token is
151+// published only when ptyReader asks for its next read, which proves the
152+// preceding chunk completed its parser and coordinator path without sleeping.
153+type channelPTYStep struct {
154+ data []byte
155+ err error
156+}
157+
158+func newChannelPTY(t *testing.T) (*portsmocks.MockPTY, chan<- channelPTYStep, <-chan struct{}) {
159+ t.Helper()
160+ pty := portsmocks.NewMockPTY(t)
161+ steps := make(chan channelPTYStep)
162+ processed := make(chan struct{}, 8)
163+ read := false
164+ pty.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
165+ if read {
166+ processed <- struct{}{}
167+ }
168+ read = true
169+ step := <-steps
170+ return copy(buf, step.data), step.err
171+ }).Maybe()
172+ pty.EXPECT().Close().Return(nil).Maybe()
173+ return pty, steps, processed
174+}
175+
176+func awaitPTYReadProcessed(t *testing.T, processed <-chan struct{}) {
177+ t.Helper()
178+ <-processed
179+}
180+
181+func awaitOutputFrameWithoutSleep(t *testing.T, sends <-chan ports.Frame) ports.Frame {
182+ t.Helper()
183+ frame := <-sends
184+ require.Equal(t, ports.MsgOutput, frame.Type)
185+ return frame
186+}
187+
188+func drainCoordinatorTimers(clock *coordinatorMockClock) []*coordinatorMockTimer {
189+ var timers []*coordinatorMockTimer
190+ for {
191+ select {
192+ case timer := <-clock.timers:
193+ timers = append(timers, timer)
194+ default:
195+ return timers
196+ }
197+ }
198+}
199+
200+func fireCoordinatorTimer(t *testing.T, rc *renderCoordinator, timers []*coordinatorMockTimer, duration time.Duration) {
201+ t.Helper()
202+ for _, timer := range timers {
203+ if timer.duration != duration {
204+ continue
205+ }
206+ var done <-chan struct{}
207+ rc.mu.Lock()
208+ if rc.normalLane.token.timer == timer.mock {
209+ done = rc.normalLane.token.done
210+ }
211+ rc.mu.Unlock()
212+ timer.ch <- time.Time{}
213+ if done != nil {
214+ <-done
215+ }
216+ return
217+ }
218+ t.Fatalf("coordinator did not arm %s timer", duration)
219+}
220+
221+func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
222+ t.Run("inactive synchronized pane activates only after complete urgent frame", func(t *testing.T) {
223+ inactivePTY, inactiveSteps, inactiveProcessed := newChannelPTY(t)
224+ activePTY, _, _ := newChannelPTY(t)
225+ d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY)
226+ clock := newCoordinatorMockClock(t, 8)
227+ d.clock = clock.clock
228+ rc := d.attachCoordinator(sess, nil, ac, true)
229+
230+ d.sessWg.Add(1)
231+ go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
232+ inactiveSteps <- channelPTYStep{data: []byte("\x1b[?2026hpartial")}
233+ awaitPTYReadProcessed(t, inactiveProcessed)
234+
235+ sess.mu.Lock()
236+ sess.active = 1
237+ sess.mu.Unlock()
238+ d.invalidateRender(sess, ac, true, "sync activation")
239+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
240+ requireNoCoordinatorOutputFrame(t, sends)
241+
242+ inactiveSteps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
243+ awaitPTYReadProcessed(t, inactiveProcessed)
244+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
245+ frame := awaitOutputFrameWithoutSleep(t, sends)
246+ output, err := ports.UnmarshalOutput(frame.Payload)
247+ require.NoError(t, err)
248+ require.Contains(t, string(output.Data), "partial complete")
249+ requireNoCoordinatorOutputFrame(t, sends)
250+
251+ inactiveSteps <- channelPTYStep{err: io.EOF}
252+ d.sessWg.Wait()
253+ })
254+
255+ t.Run("hidden synchronized pane cannot stall newly active output or flush late", func(t *testing.T) {
256+ oldPTY, oldSteps, oldProcessed := newChannelPTY(t)
257+ newPTY, newSteps, newProcessed := newChannelPTY(t)
258+ parkedPTY, _, _ := newChannelPTY(t)
259+ // Keep one unopened tab so reader EOF cleanup cannot tear down the
260+ // session and introduce unrelated detach-notification timers.
261+ d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY)
262+ clock := newCoordinatorMockClock(t, 8)
263+ d.clock = clock.clock
264+ rc := d.attachCoordinator(sess, nil, ac, true)
265+
266+ d.sessWg.Add(2)
267+ go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
268+ go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
269+ oldSteps <- channelPTYStep{data: []byte("\x1b[?2026hold partial")}
270+ awaitPTYReadProcessed(t, oldProcessed)
271+
272+ sess.mu.Lock()
273+ sess.active = 1
274+ sess.mu.Unlock()
275+ newSteps <- channelPTYStep{data: []byte("newly active")}
276+ awaitPTYReadProcessed(t, newProcessed)
277+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
278+ frame := awaitOutputFrameWithoutSleep(t, sends)
279+ output, err := ports.UnmarshalOutput(frame.Payload)
280+ require.NoError(t, err)
281+ require.Contains(t, string(output.Data), "newly active")
282+ require.NotContains(t, string(output.Data), "old partial")
283+
284+ oldSteps <- channelPTYStep{data: []byte("\x1b[?2026l")}
285+ awaitPTYReadProcessed(t, oldProcessed)
286+ requireNoCoordinatorOutputFrame(t, sends)
287+
288+ oldSteps <- channelPTYStep{err: io.EOF}
289+ newSteps <- channelPTYStep{err: io.EOF}
290+ d.sessWg.Wait()
291+ })
292+}
293+
294+func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T) {
295+ for _, tc := range []struct {
296+ name string
297+ window uint64
298+ exitBeforeACK bool
299+ exitSuffix string
300+ overlay bool
301+ }{
302+ {name: "datagram output window (1): exit waits for ACK", window: 1},
303+ {name: "datagram output window (1): exit and prompt arrive before ACK", window: 1, exitBeforeACK: true, exitSuffix: "PROMPT> "},
304+ {name: "local default output window (8): exit waits for ACK", window: maxUnackedOutputStates},
305+ {name: "local default output window (8): exit and prompt arrive before ACK", window: maxUnackedOutputStates, exitBeforeACK: true, exitSuffix: "PROMPT> "},
306+ {name: "local default output window (8): palette overlay with bars", window: maxUnackedOutputStates, overlay: true},
307+ } {
308+ t.Run(tc.name, func(t *testing.T) {
309+ pty, steps, processed := newChannelPTY(t)
310+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
311+ // Datagram clients use a one-frame window; local clients retain the
312+ // default eight-frame window. Both ACK orderings stay deterministic.
313+ ac.output.maxOutstanding = tc.window
314+ clock := newCoordinatorMockClock(t, 8)
315+ d.clock = clock.clock
316+ rc := d.attachCoordinator(sess, nil, ac, true)
317+ client := vt.NewScreen(80, 25)
318+ pane := sess.tabs[0].focusedPane()
319+
320+ d.sessWg.Add(1)
321+ go d.ptyReader(sess, sess.tabs[0], pane)
322+ t.Cleanup(func() {
323+ steps <- channelPTYStep{err: io.EOF}
324+ d.sessWg.Wait()
325+ d.waitNotifies()
326+ })
327+
328+ replay := func(data []byte, deadline time.Duration) ports.Output {
329+ t.Helper()
330+ steps <- channelPTYStep{data: data}
331+ awaitPTYReadProcessed(t, processed)
332+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), deadline)
333+ return mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
334+ }
335+ acknowledge := func(out ports.Output) {
336+ ac.ackOutputState(out.NewStateNum)
337+ rc.notifyAck()
338+ }
339+
340+ if tc.overlay {
341+ d.enterPalette(sess, ac)
342+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
343+ acknowledge(mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)))
344+ }
345+ acknowledge(replay([]byte("PRIMARY"), minOutputRenderDeadline))
346+ primary := client.Frame.Clone()
347+
348+ // The reported PTY stream is deliberately split into enter, body, and
349+ // exit reads. MATRIX distinguishes alternate cells in the replayed
350+ // terminal model from all primary, chrome, and overlay cells.
351+ acknowledge(replay([]byte("\x1b[?1049h"), minOutputRenderDeadline))
352+ alternate := replay([]byte(strings.Repeat("MATRIX", 300)), minOutputRenderDeadline)
353+ pane.mu.Lock()
354+ require.Contains(t, strings.Join(frameRows(pane.screen.Frame), "\n"), "MATRIX", "fixture must make alternate cells visible to the production renderer")
355+ pane.mu.Unlock()
356+
357+ exitData := []byte("\x1b[?1049l" + tc.exitSuffix)
358+ var exit ports.Output
359+ if tc.exitBeforeACK {
360+ steps <- channelPTYStep{data: exitData}
361+ awaitPTYReadProcessed(t, processed)
362+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
363+ if tc.window == 1 {
364+ requireNoCoordinatorOutputFrame(t, sends)
365+ acknowledge(alternate)
366+ exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
367+ } else {
368+ exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
369+ require.Equal(t, alternate.NewStateNum, exit.BaseStateNum,
370+ "local exit must be rendered before the alternate-frame ACK")
371+ acknowledge(alternate)
372+ }
373+ } else {
374+ acknowledge(alternate)
375+ steps <- channelPTYStep{data: exitData}
376+ awaitPTYReadProcessed(t, processed)
377+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
378+ exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
379+ }
380+ require.NotEmpty(t, exit.Data, "the first post-exit output must actively remove alternate cells")
381+ rendered := strings.Join(frameRows(client.Frame), "\n")
382+ require.NotContains(t, rendered, "MATRIX", "the first post-exit frame must not retain alternate cells")
383+ if tc.exitSuffix == "" {
384+ require.Equal(t, frameRows(primary), frameRows(client.Frame), "the first post-exit output must restore the primary screen")
385+ } else {
386+ require.Contains(t, rendered, tc.exitSuffix, "bytes immediately after DEC 1049 exit must appear in the first post-exit frame")
387+ }
388+ })
389+ }
390+}
391+
392+func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {
393+ t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {
394+ pty, steps, processed := newChannelPTY(t)
395+ d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)
396+ clock := newCoordinatorMockClock(t, 8)
397+ d.clock = clock.clock
398+ rc := d.attachCoordinator(target, nil, targetClient, true)
399+
400+ // A viewer in a different session is previewing the headless target.
401+ viewer := &attachedClient{}
402+ viewer.initOverlays()
403+ viewer.overlays.pickerMu.Lock()
404+ viewer.overlays.pickerPreview = target.tabs[0]
405+ viewer.overlays.pickerMu.Unlock()
406+ d.sessions["viewer"] = &session{id: "viewer", client: viewer}
407+ previews := make(chan renderWake, 2)
408+ rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w })
409+
410+ d.sessWg.Add(1)
411+ go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
412+ steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
413+ awaitPTYReadProcessed(t, processed)
414+ drainCoordinatorTimers(clock) // detach must clear the pending output work.
415+
416+ target.mu.Lock()
417+ target.client = nil
418+ target.mu.Unlock()
419+ rc.noteDetach(targetClient)
420+ steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
421+ awaitPTYReadProcessed(t, processed)
422+
423+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
424+ wake := <-previews
425+ require.True(t, wake.urgent)
426+ require.Equal(t, 1, wake.coalesced, "completion starts a fresh headless preview batch after detach")
427+ select {
428+ case duplicate := <-previews:
429+ t.Fatalf("sync completion must publish exactly one preview wake: %#v", duplicate)
430+ default:
431+ }
432+
433+ steps <- channelPTYStep{err: io.EOF}
434+ d.sessWg.Wait()
435+ })
436+
437+ t.Run("replacement receives one complete reset frame without later PTY output", func(t *testing.T) {
438+ pty, steps, processed := newChannelPTY(t)
439+ d, target, oldClient, _ := newManualSessionWithPTYs(t, pty)
440+ clock := newCoordinatorMockClock(t, 8)
441+ d.clock = clock.clock
442+ rc := d.attachCoordinator(target, nil, oldClient, true)
443+
444+ wakes := make(chan renderWake, 2)
445+ rc.opts.wake = func(w renderWake) { wakes <- w }
446+ d.sessWg.Add(1)
447+ go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
448+ steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
449+ awaitPTYReadProcessed(t, processed)
450+ drainCoordinatorTimers(clock)
451+
452+ replacement := &attachedClient{output: newOutputStateStream()}
453+ rc.noteReplace(oldClient, replacement, true)
454+ target.mu.Lock()
455+ target.client = replacement
456+ target.mu.Unlock()
457+ // The replacement's initial full paint remains gated by the batch. Its
458+ // reset must survive completion and coalesce with the completion wake.
459+ rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"})
460+ replacementTimers := drainCoordinatorTimers(clock)
461+ steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
462+ awaitPTYReadProcessed(t, processed)
463+ requireNoWake(t, wakes)
464+
465+ fireCoordinatorTimer(t, rc, replacementTimers, urgentRenderDeadline)
466+ wake := <-wakes
467+ require.True(t, wake.urgent)
468+ require.True(t, wake.reset, "the replacement's cleared batch must repaint a complete frame")
469+ require.Equal(t, 2, wake.coalesced, "completion coalesces only with the replacement's fresh reset batch")
470+ require.Same(t, replacement, wake.lease.attachment)
471+ select {
472+ case duplicate := <-wakes:
473+ t.Fatalf("sync completion must publish exactly one replacement wake: %#v", duplicate)
474+ default:
475+ }
476+
477+ steps <- channelPTYStep{err: io.EOF}
478+ d.sessWg.Wait()
479+ })
480+
481+ t.Run("headless pane without a preview stays quiet on completion", func(t *testing.T) {
482+ pty, steps, processed := newChannelPTY(t)
483+ d, target, client, _ := newManualSessionWithPTYs(t, pty)
484+ clock := newCoordinatorMockClock(t, 8)
485+ d.clock = clock.clock
486+ rc := d.attachCoordinator(target, nil, client, true)
487+ wakes := make(chan renderWake, 1)
488+ rc.opts.wake = func(w renderWake) { wakes <- w }
489+ target.mu.Lock()
490+ target.client = nil
491+ target.mu.Unlock()
492+ rc.noteDetach(client)
493+
494+ d.sessWg.Add(1)
495+ go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
496+ steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
497+ awaitPTYReadProcessed(t, processed)
498+ drainCoordinatorTimers(clock)
499+ steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
500+ awaitPTYReadProcessed(t, processed)
501+ require.Empty(t, drainCoordinatorTimers(clock))
502+ requireNoWake(t, wakes)
503+
504+ steps <- channelPTYStep{err: io.EOF}
505+ d.sessWg.Wait()
506+ })
507+}
508+
509+// S2 keeps damage pending until the owning render capture consumes it. In
510+// particular, a pane becoming invisible must not let a PTY reader erase data
511+// that a later attachment or picker preview needs to render.
512+func TestPaneRenderableActiveAttachmentDoesNotScanPickerPreviews(t *testing.T) {
513+ p, release := newBlockingPTY(t)
514+ defer release()
515+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
516+ tb := sess.activeTab()
517+ pane := tb.focusedPane()
518+
519+ // Holding daemon ownership makes a picker scan block. An active attached
520+ // tab must decide renderability from its session state without that scan.
521+ d.mu.Lock()
522+ defer d.mu.Unlock()
523+ done := make(chan bool, 1)
524+ go func() { done <- d.paneRenderable(sess, tb, pane) }()
525+ require.True(t, awaitTestValue(t, done, "active attached renderability scanned picker previews"))
526+}
527+
528+func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
529+ newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) {
530+ t.Helper()
531+ p, release := newBlockingPTY(t)
532+ t.Cleanup(release)
533+ d, sess, _, sends := newManualSessionWithPTYs(t, p)
534+ tb := sess.tabs[0]
535+ return d, sess, tb, tb.focusedPane(), sends
536+ }
537+
538+ t.Run("headless, inactive, collapsed, and hidden panes retain damage without output", func(t *testing.T) {
539+ for _, tt := range []struct {
540+ name string
541+ setup func(*Daemon, *session, *tab, *pane)
542+ }{
543+ {name: "headless", setup: func(_ *Daemon, sess *session, _ *tab, _ *pane) { sess.client = nil }},
544+ {name: "inactive tab", setup: func(_ *Daemon, sess *session, tb *tab, _ *pane) {
545+ other := newTab(nil, domain.Size{Cols: 80, Rows: 23})
546+ sess.tabs = append([]*tab{other}, sess.tabs...)
547+ sess.active = 0
548+ require.NotSame(t, other, tb)
549+ }},
550+ {name: "collapsed", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) {
551+ // The expanded leaf is deliberately absent from panes: p is the
552+ // inactive stack leaf whose content is not composited.
553+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("visible"), layout.NewLeaf(p.id)}, Expanded: "visible"}, Focus: "visible"}
554+ }},
555+ {name: "hidden floating", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) {
556+ tb.floating = floatingSlot{state: floatingHidden, pane: p, generation: 1}
557+ delete(tb.panes, p.id)
558+ }},
559+ } {
560+ t.Run(tt.name, func(t *testing.T) {
561+ d, sess, tb, p, sends := newFixture(t)
562+ tt.setup(d, sess, tb, p)
563+ p.screen.Write([]byte("damage"))
564+ _ = d.paneRenderable(sess, tb, p)
565+ require.NotEmpty(t, p.screen.Damage(), "only render capture may consume VT damage")
566+ select {
567+ case frame := <-sends:
568+ t.Fatalf("non-renderable output must not compose or send: %#v", frame)
569+ default:
570+ }
571+ })
572+ }
573+ })
574+
575+ t.Run("retains active and picker-preview pane damage", func(t *testing.T) {
576+ d, sess, tb, p, _ := newFixture(t)
577+ p.screen.Write([]byte("active"))
578+ _ = d.paneRenderable(sess, tb, p)
579+ require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition")
580+ p.screen.ClearDamage()
581+
582+ sess.client = nil
583+ viewer := &attachedClient{}
584+ viewer.initOverlays()
585+ viewer.overlays.pickerMu.Lock()
586+ viewer.overlays.pickerPreview = tb
587+ viewer.overlays.pickerMu.Unlock()
588+ d.sessions["viewer"] = &session{id: "viewer", client: viewer}
589+ p.screen.Write([]byte("preview"))
590+ _ = d.paneRenderable(sess, tb, p)
591+ require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition")
592+ })
593+}
594+
113595 func TestPTYWriteErrorIsLogged(t *testing.T) {
114596 var logs bytes.Buffer
115597 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil)))
@@ -130,56 +612,6 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
130612 }
131613 }
132614
133-func TestAltXClosesNonFinalTabScheduler(t *testing.T) {
134- p1, releasePTY1 := newBlockingPTY(t)
135- p2, releasePTY2 := newBlockingPTY(t)
136- clk := &signalClock{called: make(chan struct{})}
137- d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
138- d.clock = clk
139- defer releasePTY1()
140- defer releasePTY2()
141-
142- d.sessWg.Add(1)
143- go d.scheduler(sess, sess.tabs[1], sess.tabs[1].focusedPane())
144- sess.tabs[1].focusedPane().dirty <- struct{}{}
145- <-clk.called
146-
147- d.handleInput(sess, ac, []byte("\x1b2"))
148- d.handleInput(sess, ac, []byte("\x1b "))
149- d.handleInput(sess, ac, []byte("CLT\r"))
150-
151- select {
152- case <-waitGroupDone(&d.sessWg):
153- case <-time.After(2 * time.Second):
154- t.Fatal("scheduler for removed tab did not exit")
155- }
156- require.Equal(t, 1, sessionCount(d))
157-}
158-
159-func TestPTYEOFClosesNonFinalTabScheduler(t *testing.T) {
160- p1, releasePTY1 := newBlockingPTY(t)
161- p2, releasePTY2 := newBlockingPTY(t)
162- clk := &signalClock{called: make(chan struct{})}
163- d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
164- d.clock = clk
165- defer releasePTY1()
166- defer releasePTY2()
167-
168- d.sessWg.Add(2)
169- go d.scheduler(sess, sess.tabs[1], sess.tabs[1].focusedPane())
170- go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
171- sess.tabs[1].focusedPane().dirty <- struct{}{}
172- <-clk.called
173- releasePTY2()
174-
175- select {
176- case <-waitGroupDone(&d.sessWg):
177- case <-time.After(2 * time.Second):
178- t.Fatal("scheduler for EOF-removed tab did not exit")
179- }
180- require.Equal(t, 1, sessionCount(d))
181-}
182-
183615 func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
184616 d, sess, ac, sends, releases := newManualTabSession(t, 1)
185617 defer releases[0]()
@@ -262,87 +694,6 @@ func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
262694 d.sessWg.Wait()
263695 }
264696
265-func TestSchedulerDebounceCoalesces(t *testing.T) {
266- mc := portsmocks.NewMockClock(t)
267- mc.EXPECT().Now().Return(time.Now()).Maybe()
268- mt := portsmocks.NewMockTimer(t)
269- timerCh := make(chan time.Time, 1)
270- newTimerCalled := make(chan struct{}, 4)
271-
272- mc.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
273- select {
274- case newTimerCalled <- struct{}{}:
275- default:
276- }
277- return mt
278- }).Maybe()
279- mt.EXPECT().C().Return(timerCh).Maybe()
280- mt.EXPECT().Stop().Return(true).Maybe()
281-
282- var outputs atomic.Int32
283- gotOutput := make(chan struct{}, 1)
284- tr := portsmocks.NewMockTransport(t)
285- tr.EXPECT().Close().Return(nil).Maybe()
286- tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
287- if f.Type == ports.MsgOutput {
288- outputs.Add(1)
289- select {
290- case gotOutput <- struct{}{}:
291- default:
292- }
293- }
294- return nil
295- }).Maybe()
296-
297- d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
298- sctx, cancel := context.WithCancel(context.Background())
299- win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 5})
300- ac := &attachedClient{tr: tr, output: newOutputStateStream()}
301- ac.initOverlays()
302- sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
303- ac.setSession(sess)
304-
305- win.mu.Lock()
306- win.focusedPane().screen.Write([]byte("hi"))
307- win.mu.Unlock()
308-
309- d.sessWg.Add(1)
310- go d.scheduler(sess, win, win.focusedPane())
311-
312- // First dirty opens the debounce window.
313- win.focusedPane().dirty <- struct{}{}
314- <-newTimerCalled
315-
316- // A burst of further dirties inside the window must be absorbed.
317- for range 5 {
318- select {
319- case win.focusedPane().dirty <- struct{}{}:
320- default:
321- }
322- }
323-
324- // Fire the timer once: exactly one render.
325- timerCh <- time.Now()
326- <-gotOutput
327-
328- cancel()
329- d.sessWg.Wait()
330- require.Equal(t, int32(1), outputs.Load(), "N dirties in one tab must render exactly once")
331-}
332-
333-func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) {
334- delay := nextDebounceDelay(minDebounceInterval, 3)
335- require.Greater(t, delay, minDebounceInterval, "sustained flood should widen debounce")
336- delay = nextDebounceDelay(delay, 3)
337- require.Greater(t, delay, minDebounceInterval+debounceStep, "continued flood should keep adapting")
338-}
339-
340-func TestSchedulerAdaptiveDebounceResetsAfterQuietPeriod(t *testing.T) {
341- delay := nextDebounceDelay(minDebounceInterval, 2)
342- require.Greater(t, delay, minDebounceInterval)
343- require.Equal(t, minDebounceInterval, nextDebounceDelay(delay, 0), "isolated update after quiet window should restore idle latency")
344-}
345-
346697 // --- resize ordering --------------------------------------------------------
347698
348699 func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
@@ -372,14 +723,14 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
372723 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})
373724 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))
374725 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))
375- require.Equal(t, 2, win.focusedPane().scrollback.Len())
376- require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0)))
377- require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1)))
726+ require.Equal(t, 2, win.focusedPane().history.Len())
727+ require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0)))
728+ require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1)))
378729
379730 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
380731 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))
381732 require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1))
382- require.Equal(t, 2, win.focusedPane().scrollback.Len())
733+ require.Equal(t, 2, win.focusedPane().history.Len())
383734 }
384735
385736 func frameRowString(f renderer.Frame, y int) string {
@@ -451,39 +802,6 @@ func TestTranslatePaneDamagePreservesFullWidthScrollFastPathOnly(t *testing.T) {
451802 }
452803 }
453804
454-func TestRenderClearsDamageOnEmittingPaneWhenDetached(t *testing.T) {
455- tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
456- focused := tb.focusedPane()
457- emitting := newPane("pane-2", nil, domain.Size{Cols: 10, Rows: 3})
458- tb.panes[emitting.id] = emitting
459- tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(focused.id), layout.NewLeaf(emitting.id)}}
460- tb.tree.Focus = focused.id
461- sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0}
462- d := newTestDaemon(t, nil, stubClock{})
463-
464- focused.screen.Write([]byte("a"))
465- emitting.screen.Write([]byte("b"))
466- d.render(sess, tb, emitting)
467-
468- require.NotEmpty(t, focused.screen.Damage(), "focused pane damage should be untouched by detached render for another pane")
469- require.Empty(t, emitting.screen.Damage(), "emitting pane damage should be drained")
470-}
471-
472-func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) {
473- tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
474- sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0}
475- ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 10, Rows: 4}}
476- ac.initOverlays()
477- sess.client = ac
478- ac.setSession(sess)
479- d := newTestDaemon(t, nil, stubClock{})
480-
481- tb.focusedPane().screen.Write([]byte("x"))
482- d.render(sess, tb, tb.focusedPane())
483-
484- require.Zero(t, ac.output.next)
485-}
486-
487805 func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {
488806 win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
489807 left := win.focusedPane()
@@ -735,7 +1053,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
7351053 {
7361054 name: "picker",
7371055 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterPicker(sess, ac) },
738- close: func(d *Daemon, sess *session, ac *attachedClient) { d.closePicker(ac); d.paint(sess, ac, true) },
1056+ close: func(d *Daemon, sess *session, ac *attachedClient) { d.closePicker(ac); d.paint(sess, ac, true, nil) },
7391057 visible: "Sessions",
7401058 notVisible: "Sessions",
7411059 },
@@ -744,7 +1062,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
7441062 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) },
7451063 close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) },
7461064 prepareScreen: func(sess *session) {
747- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
1065+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
7481066 },
7491067 visible: "[SCROLL]",
7501068 notVisible: "[SCROLL]",
@@ -756,10 +1074,10 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
7561074 p, release := newBlockingPTY(t)
7571075 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
7581076 defer release()
759- sess.tabs[0].focusedPane().screen.Write([]byte("live"))
7601077 if tt.prepareScreen != nil {
7611078 tt.prepareScreen(sess)
7621079 }
1080+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
7631081 if tt.wantRestored == "" {
7641082 tt.wantRestored = "live"
7651083 }
@@ -783,23 +1101,23 @@ func TestOverlayPaintBypassesComposedCache(t *testing.T) {
7831101 pane := sess.tabs[0].focusedPane()
7841102 pane.screen.Write([]byte("live"))
7851103
786- d.paint(sess, ac, true)
1104+ d.paint(sess, ac, true, nil)
7871105 _ = mustOutputData(t, sends)
788- require.True(t, ac.composed.valid)
789- require.NotContains(t, frameText(ac.composed.frame), "Rename session")
1106+ require.True(t, ac.pipelineCache.valid)
1107+ require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
7901108
7911109 d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
7921110 shown := string(mustOutputData(t, sends))
7931111 require.Contains(t, shown, "Rename session")
794- require.False(t, ac.composed.valid, "overlay paint must not store the modal-mutated frame in the composed cache")
795- require.NotContains(t, frameText(ac.composed.frame), "Rename session")
1112+ require.False(t, ac.pipelineCache.valid, "overlay paint must not store the modal-mutated frame in the composed cache")
1113+ require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
7961114
7971115 d.closePrompt(ac)
798- d.paint(sess, ac, false)
1116+ d.paint(sess, ac, false, nil)
7991117 restored := string(mustOutputData(t, sends))
8001118 require.NotContains(t, restored, "Rename session")
801- require.True(t, ac.composed.valid)
802- cached := frameText(ac.composed.frame)
1119+ require.True(t, ac.pipelineCache.valid)
1120+ cached := frameText(ac.pipelineCache.frame)
8031121 require.Contains(t, cached, "live")
8041122 require.NotContains(t, cached, "Rename session")
8051123 }
@@ -934,7 +1252,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
9341252 d.resize(sess, ac, newSize)
9351253 // This test verifies resize ordering, not the idle fallback. Consume the
9361254 // pending resize paint deterministically under the non-firing stub clock.
937- d.paint(sess, ac, false)
1255+ d.paint(sess, ac, false, nil)
9381256
9391257 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
9401258 require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty")
@@ -965,7 +1283,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
9651283 win.focusedPane().screen.Write([]byte("x"))
9661284 win.mu.Unlock()
9671285
968- d.paint(sess, ac, true)
1286+ d.paint(sess, ac, true, nil)
9691287
9701288 require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send")
9711289 sess.mu.Lock()
@@ -977,75 +1295,6 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
9771295 d.waitNotifies()
9781296 }
9791297
980-func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) {
981- mc := portsmocks.NewMockClock(t)
982- mc.EXPECT().Now().Return(time.Now()).Maybe()
983- mt := portsmocks.NewMockTimer(t)
984- timerCh := make(chan time.Time, 1)
985- mc.EXPECT().NewTimer(mock.Anything).Return(mt).Maybe()
986- mt.EXPECT().C().Return(timerCh).Maybe()
987- mt.EXPECT().Stop().Return(true).Maybe()
988-
989- var outputs atomic.Int32
990- gotOutput := make(chan struct{}, 1)
991- tr := portsmocks.NewMockTransport(t)
992- tr.EXPECT().Close().Return(nil).Maybe()
993- tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
994- if f.Type == ports.MsgOutput {
995- outputs.Add(1)
996- select {
997- case gotOutput <- struct{}{}:
998- default:
999- }
1000- }
1001- return nil
1002- }).Maybe()
1003-
1004- d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
1005- sctx, cancel := context.WithCancel(context.Background())
1006- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
1007- win.ctx, win.cancel = sctx, cancel
1008- p := win.focusedPane()
1009- p.ctx, p.cancel = sctx, cancel
1010- ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1011- ac.initOverlays()
1012- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1013- ac.setSession(sess)
1014-
1015- p.mu.Lock()
1016- p.screen.Write([]byte("before"))
1017- p.mu.Unlock()
1018-
1019- d.sessWg.Add(1)
1020- go d.scheduler(sess, win, p)
1021- p.dirty <- struct{}{}
1022-
1023- p.mu.Lock()
1024- p.screen.Write([]byte("\x1b[?2026hafter"))
1025- p.mu.Unlock()
1026- timerCh <- time.Now()
1027-
1028- select {
1029- case <-gotOutput:
1030- t.Fatal("scheduler rendered while synchronized update was active")
1031- case <-time.After(20 * time.Millisecond):
1032- }
1033- require.Equal(t, int32(0), outputs.Load())
1034-
1035- p.mu.Lock()
1036- p.screen.Write([]byte(" done\x1b[?2026l"))
1037- p.mu.Unlock()
1038- p.flush <- struct{}{}
1039- select {
1040- case <-gotOutput:
1041- case <-time.After(time.Second):
1042- t.Fatal("sync end did not flush deferred damage")
1043- }
1044-
1045- cancel()
1046- d.sessWg.Wait()
1047-}
1048-
10491298 func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
10501299 d := newTestDaemon(t, nil, stubClock{})
10511300 p := portsmocks.NewMockPTY(t)
@@ -1089,185 +1338,25 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
10891338 }
10901339 }
10911340
1092-func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testing.T) {
1093- d := newTestDaemon(t, nil, stubClock{})
1094- p := portsmocks.NewMockPTY(t)
1095- chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")}
1096- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1097- if len(chunks) == 0 {
1098- return 0, io.EOF
1099- }
1100- n := copy(buf, chunks[0])
1101- chunks = chunks[1:]
1102- return n, nil
1103- })
1104- p.EXPECT().Close().Return(nil).Maybe()
1105-
1106- sctx, cancel := context.WithCancel(context.Background())
1107- win := newTestTabWithContext(p, sctx, cancel)
1108- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
1109- d.sessions[sess.id] = sess
1110- d.sessWg.Add(1)
1111- d.ptyReader(sess, win, win.focusedPane())
1112-
1113- select {
1114- case <-win.focusedPane().dirty:
1115- t.Fatal("same-read synchronized update end should request flush, not dirty")
1116- default:
1117- }
1118- select {
1119- case <-win.focusedPane().flush:
1120- case <-time.After(time.Second):
1121- t.Fatal("same-read synchronized update end did not request immediate flush")
1122- }
1123-}
1124-
1125-func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing.T) {
1126- d := newTestDaemon(t, nil, stubClock{})
1127- p := portsmocks.NewMockPTY(t)
1128- chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
1129- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1130- if len(chunks) == 0 {
1131- return 0, io.EOF
1132- }
1133- n := copy(buf, chunks[0])
1134- chunks = chunks[1:]
1135- return n, nil
1136- })
1137- p.EXPECT().Close().Return(nil).Maybe()
1138-
1139- sctx, cancel := context.WithCancel(context.Background())
1140- win := newTestTabWithContext(p, sctx, cancel)
1141- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
1142- d.sessions[sess.id] = sess
1143- d.sessWg.Add(1)
1144- d.ptyReader(sess, win, win.focusedPane())
1145-
1146- select {
1147- case <-win.focusedPane().dirty:
1148- t.Fatal("dirty signaled while synchronized update was active")
1149- default:
1150- }
1151- select {
1152- case <-win.focusedPane().flush:
1153- case <-time.After(time.Second):
1154- t.Fatal("sync end did not request immediate flush")
1155- }
1156-}
1157-
1158-func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
1159- clk := &signalClock{timers: make(chan *signalTimer, 1)}
1160- d := newTestDaemon(t, nil, clk)
1161- p := portsmocks.NewMockPTY(t)
1162- release := make(chan struct{})
1163- read := 0
1164- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1165- if read == 0 {
1166- read++
1167- return copy(buf, []byte("\x1b[?2026habandoned")), nil
1168- }
1169- <-release
1170- return 0, io.EOF
1171- })
1172- p.EXPECT().Close().Return(nil).Maybe()
1173-
1174- tr, sends := newCapturingTransport(t)
1175- sctx, cancel := context.WithCancel(context.Background())
1176- win := newTestTabWithContext(p, sctx, cancel)
1177- ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1178- ac.initOverlays()
1179- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1180- ac.setSession(sess)
1181- d.sessions[sess.id] = sess
1182- d.sessWg.Add(2)
1183- go d.scheduler(sess, win, win.focusedPane())
1184- go d.ptyReader(sess, win, win.focusedPane())
1185-
1186- timer := <-clk.timers
1187- timer.ch <- time.Now()
1188- awaitFrame(t, sends, ports.MsgOutput)
1189-
1190- win.mu.Lock()
1191- active := win.focusedPane().screen.SyncUpdateActive()
1192- got := screenLineText(win.focusedPane().screen, 0)
1193- win.mu.Unlock()
1194- require.False(t, active)
1195- require.Contains(t, got, "abandoned")
1196-
1197- close(release)
1198- cancel()
1199- d.sessWg.Wait()
1200-}
1201-
1202-func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
1203- clk := &signalClock{timers: make(chan *signalTimer, 1)}
1204- d := newTestDaemon(t, nil, clk)
1205- p := portsmocks.NewMockPTY(t)
1206- release := make(chan struct{})
1207- chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
1208- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1209- if len(chunks) > 0 {
1210- n := copy(buf, chunks[0])
1211- chunks = chunks[1:]
1212- return n, nil
1213- }
1214- <-release
1215- return 0, io.EOF
1216- })
1217- p.EXPECT().Close().Return(nil).Maybe()
1218-
1219- tr, sends := newCapturingTransport(t)
1220- sctx, cancel := context.WithCancel(context.Background())
1221- win := newTestTabWithContext(p, sctx, cancel)
1222- ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1223- ac.initOverlays()
1224- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1225- ac.setSession(sess)
1226- d.sessions[sess.id] = sess
1227- d.sessWg.Add(2)
1228- go d.scheduler(sess, win, win.focusedPane())
1229- go d.ptyReader(sess, win, win.focusedPane())
1230-
1231- timer := <-clk.timers
1232- awaitFrame(t, sends, ports.MsgOutput)
1233- timer.ch <- time.Now()
1234-
1235- select {
1236- case f := <-sends:
1237- if f.Type == ports.MsgOutput {
1238- t.Fatal("stale synchronized update watchdog produced an extra render")
1239- }
1240- case <-time.After(50 * time.Millisecond):
1241- }
1242- win.mu.Lock()
1243- active := win.focusedPane().screen.SyncUpdateActive()
1244- win.mu.Unlock()
1245- require.False(t, active)
1246-
1247- close(release)
1248- cancel()
1249- d.sessWg.Wait()
1250-}
1251-
12521341 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
12531342 p, _ := newBlockingPTY(t)
12541343 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
12551344 win := sess.tabs[0]
12561345 win.focusedPane().screen.Write([]byte("A"))
12571346
1258- d.paint(sess, ac, true)
1347+ d.paint(sess, ac, true, nil)
12591348 data := mustOutputData(t, sends)
12601349 require.Contains(t, string(data), "\x1b[1 q")
12611350 require.Contains(t, string(data), "\x1b[?25h")
12621351
12631352 win.focusedPane().screen.Write([]byte("\x1b[2;3H"))
1264- d.paint(sess, ac, false)
1353+ d.paint(sess, ac, false, nil)
12651354 data = mustOutputData(t, sends)
12661355 require.Contains(t, string(data), "\x1b[3;3H")
12671356 require.Contains(t, string(data), "\x1b[?25h")
12681357
12691358 win.focusedPane().screen.Write([]byte("\x1b[?25l"))
1270- d.paint(sess, ac, false)
1359+ d.paint(sess, ac, false, nil)
12711360 data = mustOutputData(t, sends)
12721361 require.Contains(t, string(data), "\x1b[?25l")
12731362 }
@@ -1284,7 +1373,7 @@ func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) {
12841373 floating.popupGeometry = committed
12851374 installTestFloating(sess.activeTab(), floating, true)
12861375
1287- d.paint(sess, ac, true)
1376+ d.paint(sess, ac, true, nil)
12881377 data := mustOutputData(t, sends)
12891378 want := committed.translate(contentArea.X, contentArea.Y)
12901379 require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, 1))
@@ -1309,7 +1398,7 @@ func TestCursorTailUsesFocusedPanePlacement(t *testing.T) {
13091398 require.True(t, ok)
13101399 rightContent := placementContent(placements, right.id)
13111400
1312- d.paint(sess, ac, true)
1401+ d.paint(sess, ac, true, nil)
13131402 data := mustOutputData(t, sends)
13141403 want := cursorCSI(rightContent.Y+right.screen.CursorRow()+2, rightContent.X+right.screen.CursorCol()+1)
13151404 require.Contains(t, string(data), want)
@@ -1338,7 +1427,7 @@ func TestCursorTailUsesExpandedStackContentPlacement(t *testing.T) {
13381427 twoContent := placementContent(placements, two.id)
13391428 require.Greater(t, twoContent.Y, 0, "stack title bars should offset content")
13401429
1341- d.paint(sess, ac, true)
1430+ d.paint(sess, ac, true, nil)
13421431 data := mustOutputData(t, sends)
13431432 want := cursorCSI(twoContent.Y+two.screen.CursorRow()+2, twoContent.X+two.screen.CursorCol()+1)
13441433 require.Contains(t, string(data), want)
@@ -1357,7 +1446,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
13571446 }
13581447 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
13591448 p.screen.Write([]byte("ab\r\ncd"))
1360- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
1449+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
13611450 mode := scopy.NewMode(document)
13621451 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
13631452
@@ -1417,7 +1506,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
14171506 defer releaseFloating()
14181507 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
14191508 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1420- fp.scrollback.Append(testRow("flt-old"))
1509+ fp.history.Append(testRow("flt-old"))
14211510 fp.screen.Write([]byte("flt-live"))
14221511 installTestFloating(sess.activeTab(), fp, true)
14231512
A internal/usecase/daemon/render_timer.go +90 -0
@@ -0,0 +1,90 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+// timerToken is the complete, immutable identity of one timer worker. A lane
11+// owns either this token or nothing; cancellation and completion are never
12+// retained separately where a stale callback could reacquire them.
13+type timerToken struct {
14+ generation uint64
15+ timer ports.Timer
16+ cancel chan struct{}
17+ done chan struct{}
18+}
19+
20+// timerLane is coordinator-owned and must be accessed while renderCoordinator.mu
21+// is held. Detach removes the complete token before any external Timer call.
22+type timerLane struct {
23+ generation uint64
24+ token *timerToken
25+}
26+
27+func (l *timerLane) replaceLocked() (uint64, *timerToken) {
28+ l.generation++
29+ return l.generation, l.detachLocked()
30+}
31+
32+func (l *timerLane) detachLocked() *timerToken {
33+ token := l.token
34+ l.token = nil
35+ if token != nil {
36+ close(token.cancel)
37+ }
38+ return token
39+}
40+
41+func (l *timerLane) publishLocked(generation uint64, timer ports.Timer) *timerToken {
42+ if l.generation != generation || l.token != nil {
43+ return nil
44+ }
45+ token := &timerToken{
46+ generation: generation,
47+ timer: timer,
48+ cancel: make(chan struct{}),
49+ done: make(chan struct{}),
50+ }
51+ l.token = token
52+ return token
53+}
54+
55+func (l *timerLane) clearLocked(token *timerToken) bool {
56+ if token == nil || l.generation != token.generation || l.token != token {
57+ return false
58+ }
59+ l.token = nil
60+ return true
61+}
62+
63+// timerSupervisor owns worker accounting. Workers are registered only while
64+// coordinator ownership is held and terminal teardown rejects registrations
65+// before Wait begins, so Add cannot race the terminal Wait.
66+type timerSupervisor struct{ workers sync.WaitGroup }
67+
68+func (s *timerSupervisor) startLocked(token *timerToken, timerC <-chan time.Time, fire func()) {
69+ s.workers.Add(1)
70+ go func() {
71+ defer s.workers.Done()
72+ defer close(token.done)
73+ select {
74+ case <-timerC:
75+ fire()
76+ case <-token.cancel:
77+ }
78+ }()
79+}
80+
81+func (s *timerSupervisor) wait() { s.workers.Wait() }
82+
83+// stopDetachedTimer is intentionally non-blocking. Ordinary lifecycle and
84+// replacement transitions may cancel/stop stale workers but callback stacks
85+// never wait for a worker to finish.
86+func stopDetachedTimer(token *timerToken) {
87+ if token != nil && token.timer != nil {
88+ token.timer.Stop()
89+ }
90+}
A internal/usecase/daemon/render_timer_test.go +144 -0
@@ -0,0 +1,144 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
11+)
12+
13+func TestTimerLaneDetachRemovesCompleteToken(t *testing.T) {
14+ lane := timerLane{generation: 1}
15+ timer := portsmocks.NewMockTimer(t)
16+ token := lane.publishLocked(1, timer)
17+ require.NotNil(t, token)
18+ detached := lane.detachLocked()
19+ require.Same(t, token, detached)
20+ require.Nil(t, lane.token)
21+ select {
22+ case <-token.cancel:
23+ default:
24+ t.Fatal("detach did not cancel token")
25+ }
26+ require.Nil(t, lane.publishLocked(0, timer), "stale generation reacquired detached lane")
27+}
28+
29+func TestRenderDeadlineCallbackCanInvalidateAgainWithoutJoiningItself(t *testing.T) {
30+ clock := newCoordinatorMockClock(t, 2)
31+ ac := &attachedClient{}
32+ wakeReturned := make(chan struct{})
33+ var rc *renderCoordinator
34+ rc = newRenderCoordinator(renderCoordinatorOptions{
35+ clock: clock.clock,
36+ wake: func(renderWake) {
37+ require.True(t, rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "reentrant-wake-test"}))
38+ close(wakeReturned)
39+ },
40+ })
41+ rc.attach(ac)
42+ t.Cleanup(func() {
43+ rc.beginSessionTeardown().finish()
44+ rc.waitForTimerWorkers()
45+ })
46+ require.True(t, rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "initial-wake-test"}))
47+ awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{}
48+ awaitTestCompletion(t, wakeReturned, "timer callback deadlocked while publishing its next invalidation")
49+}
50+
51+func TestRenderTimerCallbacksCanReplaceTheirOwnLane(t *testing.T) {
52+ cases := []struct {
53+ name string
54+ run func(*testing.T, *renderCoordinator, *coordinatorMockClock, *attachedClient)
55+ }{
56+ {
57+ name: "normal wake invalidates", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, ac *attachedClient) {
58+ woke := make(chan struct{})
59+ rc.opts.wake = func(renderWake) {
60+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "table-normal"})
61+ close(woke)
62+ }
63+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "table-normal"})
64+ awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{}
65+ awaitTestCompletion(t, woke, "normal callback did not reenter invalidation")
66+ },
67+ },
68+ {
69+ name: "resize callback schedules resize", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, ac *attachedClient) {
70+ lease := rc.attachmentLease(ac)
71+ rc.scheduleResizeForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease, func(uint64) {
72+ rc.scheduleResizeForLease(domain.Size{Cols: 81, Rows: 24}, ac, lease, func(uint64) {})
73+ })
74+ timer := awaitCoordinatorScheduledTimer(t, clock)
75+ done := captureResizeCallbackDone(t, rc)
76+ timer.ch <- time.Time{}
77+ _ = awaitTestValue(t, clock.timers, "resize callback did not schedule replacement")
78+ awaitTestCompletion(t, done, "resize callback did not complete")
79+ },
80+ },
81+ {
82+ name: "retry callback schedules retry", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, ac *attachedClient) {
83+ lease := rc.attachmentLease(ac)
84+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
85+ require.True(t, rc.resizeCurrentForLease(epoch, ac, lease, true))
86+ rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { rc.scheduleResizeRetryForLease(epoch, ac, lease, func() {}) })
87+ awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{}
88+ _ = awaitTestValue(t, clock.timers, "retry callback did not schedule replacement")
89+ },
90+ },
91+ {
92+ name: "sync force transitions sync generation", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, _ *attachedClient) {
93+ p := &pane{}
94+ rc.noteSyncBegin(p, 1, func() { rc.noteSyncBegin(p, 2) })
95+ awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{}
96+ _ = awaitTestValue(t, clock.timers, "sync force did not schedule replacement")
97+ },
98+ },
99+ }
100+ for _, tc := range cases {
101+ t.Run(tc.name, func(t *testing.T) {
102+ clock := newCoordinatorMockClock(t, 4)
103+ rc, ac := newRenderCoordinator(renderCoordinatorOptions{clock: clock.clock}), &attachedClient{}
104+ rc.attach(ac)
105+ tc.run(t, rc, clock, ac)
106+ rc.beginSessionTeardown().finish()
107+ rc.waitForTimerWorkers()
108+ })
109+ }
110+}
111+
112+func TestRenderTimerScheduleRacesTerminalTeardown(t *testing.T) {
113+ for range 32 {
114+ clock := newCoordinatorMockClock(t, 2)
115+ rc, ac := newRenderCoordinator(renderCoordinatorOptions{clock: clock.clock}), &attachedClient{}
116+ rc.attach(ac)
117+ start := make(chan struct{})
118+ scheduled := make(chan struct{})
119+ go func() {
120+ <-start
121+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "race"})
122+ close(scheduled)
123+ }()
124+ close(start)
125+ cleanup := rc.beginSessionTeardown()
126+ awaitTestCompletion(t, scheduled, "schedule raced terminal teardown")
127+ cleanup.finish()
128+ rc.waitForTimerWorkers()
129+ rc.mu.Lock()
130+ require.True(t, rc.torndown)
131+ require.Nil(t, rc.normalLane.token)
132+ rc.mu.Unlock()
133+ }
134+}
135+
136+func TestTimerSupervisorCancelsNeverFiringWorker(t *testing.T) {
137+ var lane timerLane
138+ generation, _ := lane.replaceLocked()
139+ token := lane.publishLocked(generation, portsmocks.NewMockTimer(t))
140+ var supervisor timerSupervisor
141+ supervisor.startLocked(token, nil, func() { t.Fatal("cancelled worker fired") })
142+ lane.detachLocked()
143+ supervisor.wait()
144+}
M internal/usecase/daemon/resume.go +6 -5
@@ -48,11 +48,11 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
4848 if !d.prepareParkAttachment(sess, ac) {
4949 return false
5050 }
51- // A parked attachment has no valid visible destination. Retire both the
52- // deferred diff and any resize-idle callback before registering its grace
53- // timer; resume performs a fresh full paint.
54- ac.cancelResizePaint()
55- ac.clearPaintDeferred()
51+ // A parked attachment has no session owner. Release pane snapshots before
52+ // publishing it in the daemon registry so headless pane/session teardown
53+ // cannot leave closed panes retained through its capture cache. This must
54+ // remain outside d.mu: sendMu is ordered before the daemon lock.
55+ ac.clearCaptureFrames()
5656 d.mu.Lock()
5757 if d.closing {
5858 d.mu.Unlock()
@@ -188,6 +188,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
188188 sess.client = ac
189189 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
190190 sess.mu.Unlock()
191+ d.attachCoordinator(sess, nil, ac, false)
191192 d.touchMRU(sess)
192193 d.log.Info("client resumed", "session", sess.name)
193194 return sess, ac, true, nil
M internal/usecase/daemon/resume_test.go +163 -1
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "errors"
5+ "strings"
56 "sync"
67 "testing"
78 "time"
@@ -10,6 +11,8 @@ import (
1011
1112 "github.com/bnema/vev/internal/domain"
1213 "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/layout"
15+ "github.com/bnema/vev/pkg/vt"
1316 )
1417
1518 type closeTrackingTransport struct {
@@ -77,6 +80,110 @@ func TestNamedLinkLossParks(t *testing.T) {
7780 require.True(t, parked, "named resume-capable link loss is parked")
7881 }
7982
83+func TestBoundedSendTimeoutCannotTargetResumedTransport(t *testing.T) {
84+ pty, release := newBlockingPTY(t)
85+ defer release()
86+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
87+ oldTransport := &closeTrackingTransport{}
88+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "bounded-send-resume", 0), oldTransport)
89+ require.NoError(t, err)
90+ token := ac.resumeToken
91+ d.clientGone(sess, ac, oldTransport, false)
92+
93+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
94+ d.clock = clock
95+ ac.sendMu.Lock()
96+ expected := ac.transportSnapshot()
97+ orphanDone := make(chan struct{})
98+ type boundedResult struct {
99+ transport ports.Transport
100+ err error
101+ }
102+ result := make(chan boundedResult, 1)
103+ go func() {
104+ transport, sendErr := d.boundedSendWithTimeout(time.Second, expected.transport, func() error {
105+ defer close(orphanDone)
106+ return ac.sendExpectedTransport(expected, frameDetached(ports.ReasonDetach))
107+ })
108+ result <- boundedResult{transport: transport, err: sendErr}
109+ }()
110+
111+ // The worker is parked behind sendMu, so it has not observed a transport.
112+ // Expire its deadline before resuming this same attachment with a new link.
113+ timer := <-clock.timers
114+ timer.ch <- time.Time{}
115+ got := <-result
116+ require.ErrorIs(t, got.err, errSendTimedOut)
117+ require.Same(t, oldTransport, got.transport)
118+ require.NoError(t, ac.closeCapturedTransport(got.transport))
119+
120+ newTransport := &closeTrackingTransport{}
121+ d.mu.Lock()
122+ resumedSess, resumedAC, ok, err := d.resumeParkedLocked(
123+ helloResumeCapable(ports.IntentResume, sess.name, token),
124+ newTransport,
125+ domain.Size{Cols: 80, Rows: 24},
126+ )
127+ d.mu.Unlock()
128+ require.NoError(t, err)
129+ require.True(t, ok)
130+ require.Same(t, sess, resumedSess)
131+ require.Same(t, ac, resumedAC)
132+
133+ ac.sendMu.Unlock()
134+ <-orphanDone
135+ require.Empty(t, newTransport.Sends(), "orphaned send must not write to the resumed transport")
136+ require.False(t, newTransport.Closed(), "orphaned send must not close the resumed transport")
137+}
138+
139+func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
140+ pty, releasePTY := newBlockingPTY(t)
141+ defer releasePTY()
142+ clock := newCoordinatorMockClock(t, 4)
143+ d := newTestDaemon(t, newFactorySeq(t, pty), clock.clock)
144+ oldTransport := &closeTrackingTransport{}
145+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "resume-welcome-gate", 0), oldTransport)
146+ require.NoError(t, err)
147+ token := ac.resumeToken
148+ d.clientGone(sess, ac, oldTransport, false)
149+
150+ tr := newWelcomeBlockingTransport(t)
151+ done := make(chan struct{})
152+ resumeHello := helloResumeCapable(ports.IntentResume, sess.name, token)
153+ go func() {
154+ d.handleHello(tr.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(resumeHello)})
155+ close(done)
156+ }()
157+
158+ <-tr.welcomeEntered
159+ welcome := <-tr.sends
160+ require.Equal(t, ports.MsgWelcome, welcome.Type)
161+ sess.mu.Lock()
162+ resumed := sess.client
163+ sess.mu.Unlock()
164+ require.Same(t, ac, resumed)
165+
166+ d.invalidateRender(sess, resumed, true, "resume-welcome-gate-test")
167+ timer := awaitLatestCoordinatorTimer(t, clock)
168+ rc := sess.renderCoordinator()
169+ rc.mu.Lock()
170+ workerDone := rc.normalLane.token.done
171+ rc.mu.Unlock()
172+ require.NotNil(t, workerDone)
173+ timer.ch <- time.Time{}
174+ awaitTestCompletion(t, workerDone, "coordinator deadline worker did not complete")
175+ requireNoCoordinatorOutputFrame(t, tr.sends)
176+
177+ tr.release()
178+ output := awaitFrame(t, tr.sends, ports.MsgOutput)
179+ first, err := ports.UnmarshalOutput(output.Payload)
180+ require.NoError(t, err)
181+ require.Zero(t, first.BaseStateNum)
182+ tr.finish()
183+ <-done
184+ requireNoCoordinatorOutputFrame(t, tr.sends)
185+}
186+
80187 func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
81188 pty, release := newBlockingPTY(t)
82189 defer release()
@@ -228,6 +335,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
228335 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
229336 require.NoError(t, err)
230337 require.Same(t, ac, resumedAC)
338+ require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))
231339 d.firstPaint(resumedSess, resumedAC, resumedAC.size)
232340
233341 sends := newTr.Sends()
@@ -239,7 +347,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
239347 resumedAC.ackOutputState(first.NewStateNum)
240348
241349 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))
242- d.paint(resumedSess, resumedAC, false)
350+ d.paint(resumedSess, resumedAC, false, nil)
243351 sends = newTr.Sends()
244352 require.Len(t, sends, 2)
245353 second, err := ports.UnmarshalOutput(sends[1].Payload)
@@ -247,6 +355,60 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
247355 require.Equal(t, first.NewStateNum, second.BaseStateNum)
248356 }
249357
358+func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
359+ pty, release := newBlockingPTY(t)
360+ defer release()
361+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
362+ oldTransport := &closeTrackingTransport{}
363+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
364+ require.NoError(t, err)
365+
366+ tb := sess.tabs[0]
367+ survivor := tb.panes["pane-1"]
368+ closed := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 23})
369+ tb.mu.Lock()
370+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
371+ tb.panes[closed.id] = closed
372+ d.applyLayoutLocked(tb)
373+ tb.mu.Unlock()
374+ survivor.screen.Write([]byte("survivor"))
375+ closed.screen.Write([]byte("closed"))
376+ d.paint(sess, ac, true, nil)
377+
378+ ac.sendMu.Lock()
379+ require.Contains(t, ac.captureFrames, closed, "fixture must render and capture the pane before parking")
380+ ac.sendMu.Unlock()
381+ token := ac.resumeToken
382+ require.True(t, sess.detachIfCurrent(ac))
383+ require.True(t, d.parkAttachment(sess, ac))
384+
385+ // Headless close cannot find the parked attachment through sess.client.
386+ // Its capture must already have been released before the attachment parked.
387+ require.NoError(t, d.closePane(sess, tb, closed.id, nil, false))
388+ ac.sendMu.Lock()
389+ require.NotContains(t, ac.captureFrames, closed, "parked attachment must not retain a pane closed while headless")
390+ ac.sendMu.Unlock()
391+
392+ newTransport := &closeTrackingTransport{}
393+ resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, sess.name, token), newTransport)
394+ require.NoError(t, err)
395+ require.Same(t, sess, resumedSess)
396+ require.Same(t, ac, resumedAC)
397+ require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))
398+ d.firstPaint(resumedSess, resumedAC, resumedAC.size)
399+
400+ sends := newTransport.Sends()
401+ require.Len(t, sends, 1)
402+ output, err := ports.UnmarshalOutput(sends[0].Payload)
403+ require.NoError(t, err)
404+ require.Zero(t, output.BaseStateNum, "resume must start with a complete frame")
405+ terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows)
406+ terminal.Write(output.Data)
407+ contents := strings.Join(frameRows(terminal.Frame), "\n")
408+ require.Contains(t, contents, "survivor", "resume first paint must contain current headless content")
409+ require.NotContains(t, contents, "closed", "resume first paint must not contain closed pane content")
410+}
411+
250412 func TestExplicitDetachDoesNotPark(t *testing.T) {
251413 pty, release := newBlockingPTY(t)
252414 defer release()
A internal/usecase/daemon/resume_wake_incarnation_test.go +68 -0
@@ -0,0 +1,68 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// A wake can have passed coordinator scheduling before a link loss parks its
13+// attachment. Resume deliberately reuses that attachment object, so pointer
14+// identity alone must not let the old wake paint the new transport before its
15+// Welcome frame.
16+func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
17+ pty, releasePTY := newBlockingPTY(t)
18+ defer releasePTY()
19+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
20+
21+ oldTransport := &closeTrackingTransport{}
22+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
23+ require.NoError(t, err)
24+ token := ac.resumeToken
25+ rc := sess.renderCoordinator()
26+ require.NotNil(t, rc)
27+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
28+
29+ // Pause after dispatch but before the coordinator's normal wake callback
30+ // reaches paint/sendMu. This is the precise stale-wake window.
31+ originalWake := rc.opts.wake
32+ wakeDispatched := make(chan struct{})
33+ releaseWake := make(chan struct{})
34+ rc.opts.wake = func(w renderWake) {
35+ close(wakeDispatched)
36+ <-releaseWake
37+ originalWake(w)
38+ }
39+ wakeDone := make(chan struct{})
40+ go func() {
41+ d.invalidateRenderNow(sess, ac, true, "resume-wake-test")
42+ close(wakeDone)
43+ }()
44+ <-wakeDispatched
45+
46+ d.clientGone(sess, ac, oldTransport, false)
47+ newTransport := &closeTrackingTransport{}
48+ resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, sess.name, token), newTransport, domain.Size{Cols: 80, Rows: 24})
49+ require.NoError(t, err)
50+ require.True(t, ok)
51+ require.Same(t, sess, resumedSess)
52+ require.Same(t, ac, resumedAC)
53+
54+ close(releaseWake)
55+ <-wakeDone
56+ require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome")
57+ ac.sendMu.Lock()
58+ staleWakeComposed := ac.pipelineCache.valid
59+ ac.sendMu.Unlock()
60+ require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow")
61+
62+ // The new attachment incarnation accepts the required paint only after its
63+ // replacement transport has completed Welcome.
64+ rc.opts.wake = originalWake
65+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
66+ d.firstPaint(sess, ac, ac.size)
67+ require.NotEmpty(t, newTransport.Sends())
68+}
M internal/usecase/daemon/scrollback_test.go +15 -6
@@ -14,29 +14,38 @@ func TestNewTabInitializesScrollback(t *testing.T) {
1414 wantRow string
1515 }{
1616 {
17- name: "tab owns scrollback wired to vt eviction callback",
17+ name: "primary evictions enter screen owned history",
1818 write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
1919 wantLen: 1,
2020 wantRow: "AAAA",
2121 },
22+ {
23+ name: "alternate screen evictions do not enter pane history",
24+ write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"),
25+ wantLen: 1,
26+ wantRow: "AAAA",
27+ },
2228 }
2329
2430 for _, tt := range tests {
2531 t.Run(tt.name, func(t *testing.T) {
2632 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})
27- if win.focusedPane().scrollback == nil {
33+ if win.focusedPane().history == nil {
2834 t.Fatal("scrollback is nil")
2935 }
30- if win.focusedPane().scrollback.Cap() != defaultScrollbackRows {
31- t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows)
36+ if win.focusedPane().history != win.focusedPane().screen.History() {
37+ t.Fatal("pane history is not owned by its screen")
38+ }
39+ if win.focusedPane().history.Cap() != defaultScrollbackRows {
40+ t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().history.Cap(), defaultScrollbackRows)
3241 }
3342
3443 win.focusedPane().screen.Write(tt.write)
3544
36- if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {
45+ if got := win.focusedPane().history.Len(); got != tt.wantLen {
3746 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
3847 }
39- row := win.focusedPane().scrollback.View().Row(0)
48+ row := win.focusedPane().history.View().Row(0)
4049 runes := make([]rune, len(row))
4150 for i, c := range row {
4251 runes[i] = c.Rune
M internal/usecase/daemon/session.go +68 -40
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
4-//
5-// Concurrency model (sessions own one or more PTY-backed tabs):
6-//
7-// - Serve runs the accept loop. Each accepted connection is handled by its
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
19-//
20-// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
1+// Package daemon holds vev's server-side session multiplexer use case.
252 package daemon
263
274 import (
@@ -67,15 +44,35 @@ type session struct {
6744 mruAt atomic.Uint64
6845 snapDirty atomic.Bool
6946 snapEligible atomic.Bool
47+ // snapshotMu serializes the dirty generation with worker completion. It is
48+ // intentionally independent from mu: persistence never holds session state
49+ // locks while encoding or writing.
50+ snapshotMu sync.Mutex
51+ snapshotGeneration uint64
52+ snapshotPending bool
53+ snapshotPendingCaptures uint
54+ snapshotChanged chan struct{}
55+ // syncGen makes synchronized-output watchdog generations unique across all
56+ // panes in this session.
57+ syncGen atomic.Uint64
58+ // coordinator fans in this session's producer render invalidations.
59+ coordinator atomic.Pointer[renderCoordinator]
7060 // clipFiles records clipboard-image-transfer temp file paths (see
7161 // clipboard.go) written for this session, removed best-effort in
7262 // killSession.
7363 clipFiles []string
64+
65+ // floatingLaunchMu is ownership synchronization for external floating
66+ // launches. It is separate from mu so PTYFactory.Open never holds a
67+ // session, tab, or daemon architecture lock.
68+ floatingLaunchMu sync.Mutex
69+ floatingLaunchStopping bool
70+ floatingLaunches map[*floatingLaunch]struct{}
7471 }
7572
7673 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
7774 type tab struct {
78- mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, previewClient, and pane map membership
75+ mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership
7976
8077 stableID string
8178 name string
@@ -88,9 +85,6 @@ type tab struct {
8885 // floating is independent from the normal layout tree and pane map.
8986 floating floatingSlot
9087
91- // previewClient tracks the one client currently previewing this tab in the picker.
92- // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
93- previewClient *attachedClient
9488 // attention fields are guarded by the owning session.mu.
9589 attention bool
9690 attentionAt time.Time
@@ -100,7 +94,7 @@ type tab struct {
10094 // attachedClient is a client currently attached to a session's tab. rend is
10195 // its private renderer shadow (so each client gets output minimised against
10296 // what it has actually seen). sendMu serialises the two senders — the render
103-// scheduler and the connection handler — so the transport's single-writer
97+// coordinator and the connection handler — so the transport's single-writer
10498
10599 func (d *Daemon) touchMRU(sess *session) {
106100 if d == nil || sess == nil {
@@ -145,7 +139,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
145139 closeTabs(tabs)
146140 return nil, err
147141 }
148- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
142+ pty, err := d.ptys.Open(d.serveCtx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
149143 if err != nil {
150144 closeTabs(tabs)
151145 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
@@ -258,16 +252,19 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
258252 ac.setSession(nil)
259253 from.mu.Unlock()
260254
255+ // Prepare source invalidation, output rebase, and destination coordinator
256+ // identity before publishing the destination attachment.
257+ cleanups := d.handoffCoordinator(from, newSess, nil, ac)
258+ ac.setSession(newSess)
261259 newSess.mu.Lock()
262260 newSess.client = ac
263261 newSess.mu.Unlock()
264262
265263 d.touchMRU(newSess)
266- ac.setSession(newSess)
267264 ac.recordPreviousSession(from)
268265 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
269266 d.mu.Unlock()
270-
267+ finishRenderLifecycleCleanups(cleanups)
271268 d.firstPaint(newSess, ac, sz)
272269 return nil
273270 }
@@ -284,7 +281,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
284281 if err != nil {
285282 return err
286283 }
287- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
284+ pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
288285 if err != nil {
289286 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
290287 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
@@ -418,9 +415,9 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
418415 sess.mu.Unlock()
419416 d.log.Info("pane created", "session", name, "pane", p.id)
420417 }
421- d.sessWg.Add(2)
418+ // Scheduler ownership was removed; this launch creates exactly one reader.
419+ d.sessWg.Add(1)
422420 go d.ptyReader(sess, tb, p)
423- go d.scheduler(sess, tb, p)
424421 }
425422
426423 // attachClient makes ac the session's current client, displacing any prior one
@@ -637,16 +634,27 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
637634 markSnapshotDirty(sess)
638635
639636 d.clearDestroyedTabPreview(tb)
637+ tb.mu.Lock()
638+ panes := tb.panesSnapshot()
639+ tb.mu.Unlock()
640+ if ac != nil {
641+ ac.pruneCaptureFrames(panes...)
642+ }
640643 if tb.cancel != nil {
641644 tb.cancel()
642645 }
643646 d.teardownFloating(tb, ac)
647+ if rc := sess.renderCoordinator(); rc != nil {
648+ for _, p := range panes {
649+ rc.noteSyncPaneRemoved(p)
650+ }
651+ }
644652 tb.closeAllPanes()
645653 if wasActive {
646654 d.activateTab(sess, destination)
647655 }
648656 if repaint && ac != nil {
649- d.paint(sess, ac, true)
657+ d.invalidateRender(sess, ac, true, "session.go")
650658 }
651659 if ringing {
652660 d.repaintAllAttachedClients()
@@ -664,7 +672,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
664672 if !purge && !isEphemeral && d.persistEnabled {
665673 d.refreshSessionCwd(sess)
666674 }
667- var snapshotDeleteErr error
675+ var snapshotDeleteErr, terminalSnapshotErr error
668676 if d.snapsEnabled && !isEphemeral {
669677 if purge {
670678 sess.mu.Lock()
@@ -675,7 +683,16 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
675683 d.log.Warn("deleting session snapshot failed", "err", err, "session", name)
676684 }
677685 } else {
678- d.captureSession(sess)
686+ markSnapshotDirty(sess)
687+ if !d.scheduleFinalSnapshot(sess) {
688+ sess.mu.Lock()
689+ name := sess.name
690+ sess.mu.Unlock()
691+ terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable or saturated", name)
692+ if reason != ports.ReasonServerShutdown {
693+ return terminalSnapshotErr
694+ }
695+ }
679696 }
680697 }
681698 d.mu.Lock()
@@ -731,13 +748,24 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
731748 ac := sess.client
732749 sess.client = nil
733750 sess.mu.Unlock()
751+ if rc := sess.renderCoordinator(); rc != nil {
752+ // Terminal teardown has two phases: this session owner first prevents any
753+ // new worker registration and detaches all tokens, then stops and waits
754+ // outside coordinator callbacks and session locks.
755+ rc.beginSessionTeardown().finish()
756+ rc.waitForTimerWorkers()
757+ }
734758 if ac != nil {
735- ac.cancelResizePaint()
736759 d.unregisterPreview(ac)
737760 ac.clearPreviousSession()
738761 ac.setSession(nil)
762+ ac.clearCaptureFrames()
739763 }
740764
765+ // Prevent queued launches from entering Open, then cancel the parent
766+ // context. The launch worker owns any late PTY result and closes it rather
767+ // than publishing it into the torn-down tab.
768+ sess.stopFloatingLaunches()
741769 sess.cancel()
742770 sess.mu.Lock()
743771 tabs := append([]*tab(nil), sess.tabs...)
@@ -763,7 +791,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
763791 if ac != nil {
764792 d.notifyDetachedAsync(ac, reason)
765793 }
766- return purgeErr
794+ return errors.Join(purgeErr, terminalSnapshotErr)
767795 }
768796
769797 // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
M internal/usecase/daemon/session_back.go +1 -1
@@ -11,7 +11,7 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
1111 target := ac.previousSession.Get()
1212 if target == nil || target == current || d.sessionByID(target.id) != target {
1313 ac.clearPreviousSessionIf(target)
14- d.paint(current, ac, true)
14+ d.invalidateRender(current, ac, true, "session_back.go")
1515 return
1616 }
1717 d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})
M internal/usecase/daemon/session_test.go +104 -51
@@ -21,6 +21,11 @@ import (
2121 )
2222
2323 // --- test doubles -----------------------------------------------------------
24+func expectFloatingPrewarmOpen(factory *portsmocks.MockPTYFactory, normalSize domain.Size, floating ports.PTY) {
25+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
26+ return got != normalSize && got.Valid()
27+ })).Return(floating, nil).Maybe()
28+}
2429
2530 // stubClock returns timers whose channel never fires, so a scheduler under it
2631 // blocks in its debounce loop until the session context is cancelled. Used by
@@ -34,8 +39,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
3439 f := portsmocks.NewMockPTYFactory(t)
3540 var dirs []string
3641 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
37- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
38- func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
42+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
43+ func(_ context.Context, _ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
3944 dirs = append(dirs, dir)
4045 if len(dirs) == 1 {
4146 return first, nil
@@ -44,9 +49,7 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
4449 },
4550 ).Twice()
4651 floating := newQuietPTY()
47- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
48- return got != normalSize && got.Valid()
49- })).Return(floating, nil).Once()
52+ expectFloatingPrewarmOpen(f, normalSize, floating)
5053
5154 d := newTestDaemon(t, f, stubClock{})
5255 tr := portsmocks.NewMockTransport(t)
@@ -72,11 +75,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
7275 releaseFirst()
7376 releaseSecond()
7477 d.sessWg.Wait()
75- select {
76- case <-floating.done:
77- default:
78- t.Fatal("floating prewarm PTY was not closed")
79- }
78+ // Teardown may cancel a queued prewarm before it enters Open; an opened
79+ // prewarm is closed by teardownFloating.
8080 }
8181
8282 func TestHandshakeEphemeralHappy(t *testing.T) {
@@ -202,7 +202,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
202202 p1, release1 := newBlockingPTY(t)
203203 defer release1()
204204 p2 := portsmocks.NewMockPTY(t)
205- opened := make(chan struct{})
205+ openCtx := make(chan context.Context, 1)
206206 releaseOpen := make(chan struct{})
207207 closed := make(chan struct{})
208208 p2.EXPECT().Close().RunAndReturn(func() error {
@@ -213,15 +213,16 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
213213
214214 f := portsmocks.NewMockPTYFactory(t)
215215 normalSize := domain.Size{Cols: 80, Rows: 22}
216- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()
216+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()
217217 floating := newQuietPTY()
218- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
219- return got != normalSize && got.Valid()
220- })).Return(floating, nil).Once()
221- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
222- func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
223- close(opened)
224- <-releaseOpen
218+ expectFloatingPrewarmOpen(f, normalSize, floating)
219+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
220+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
221+ openCtx <- ctx
222+ select {
223+ case <-ctx.Done():
224+ case <-releaseOpen:
225+ }
225226 return p2, nil
226227 },
227228 ).Once()
@@ -237,10 +238,20 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
237238
238239 errCh := make(chan error, 1)
239240 go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()
240- <-opened
241+ ctx := <-openCtx
241242 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
243+
244+ cancelled := true
245+ select {
246+ case <-ctx.Done():
247+ case <-time.After(time.Second):
248+ cancelled = false
249+ }
242250 close(releaseOpen)
243251
252+ if !cancelled {
253+ t.Error("killSession did not cancel the context passed to PTYFactory.Open")
254+ }
244255 require.Error(t, <-errCh)
245256 select {
246257 case <-closed:
@@ -251,11 +262,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
251262 releaseConn()
252263 hg.Wait()
253264 d.sessWg.Wait()
254- select {
255- case <-floating.done:
256- default:
257- t.Fatal("floating prewarm PTY was not closed")
258- }
265+ // A queued prewarm may be cancelled before Open during teardown.
259266 }
260267
261268 // --- ephemeral numbering ----------------------------------------------------
@@ -508,24 +515,52 @@ func TestServeGracefulShutdownOnContextCancel(t *testing.T) {
508515 // --- shutdown/create interlock ------------------------------------------------
509516
510517 // TestHelloRacingShutdownIsRejected covers the interlock between the
511-// registry-empty shutdown decision and session creation: once killSession has
512-// removed the last session (making shutdown irreversible via doneOnce), a
513-// Hello landing at any later instant must be rejected cleanly — never spawn a
514-// child that no teardown pass would reap. The interleaving is deterministic:
515-// killSession runs to completion before the racing Hellos are handled, which
516-// models the worst case (insertion attempt after the shutdown snapshot).
518+// registry-empty shutdown decision and session creation. In particular, the
519+// old `.Once()` factory expectation was wrong: firstPaint legitimately queues
520+// a floating prewarm, so an Open that starts before killSession is valid work,
521+// not a leaked post-shutdown child. The factory markers classify timing at the
522+// Open boundary: this test first observes that valid prewarm, then verifies
523+// that d.done (closed synchronously by killSession) is never followed by an
524+// Open start.
517525 func TestHelloRacingShutdownIsRejected(t *testing.T) {
518526 p, releasePTY := newBlockingPTY(t)
527+ floating := newQuietPTY()
519528 f := portsmocks.NewMockPTYFactory(t)
520- // Exactly one child may ever be spawned; a second Open call (a leaked
521- // child) fails the test via mock expectations.
522- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once()
529+ normalSize := domain.Size{Cols: 80, Rows: 22}
530+ preShutdownFloatingOpen := make(chan struct{}, 1)
531+ var opensAfterShutdown atomic.Int32
523532 d := newTestDaemon(t, f, stubClock{})
533+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
534+ func(_ context.Context, _ string, _ []string, _ []string, _ string, size domain.Size) (ports.PTY, error) {
535+ select {
536+ case <-d.done:
537+ opensAfterShutdown.Add(1)
538+ default:
539+ }
540+ if size == normalSize {
541+ return p, nil
542+ }
543+ select {
544+ case preShutdownFloatingOpen <- struct{}{}:
545+ default:
546+ }
547+ return floating, nil
548+ },
549+ ).Maybe()
524550
525551 tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
526552 var hg sync.WaitGroup
527553 hg.Go(func() { d.handleConn(tr1) })
528554 awaitFrame(t, sends1, ports.MsgWelcome)
555+ // firstPaint starts the background prewarm asynchronously. It is valid for
556+ // this Open to occur before shutdown, so do not mistake it for the racing
557+ // Hello's forbidden launch.
558+ awaitFrame(t, sends1, ports.MsgOutput)
559+ select {
560+ case <-preShutdownFloatingOpen:
561+ case <-time.After(time.Second):
562+ t.Fatal("legitimate pre-shutdown floating prewarm did not start")
563+ }
529564 sess := firstSession(d)
530565 require.NotNil(t, sess)
531566
@@ -555,12 +590,14 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
555590 require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name)
556591 }
557592 require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began")
593+ require.Zero(t, opensAfterShutdown.Load(), "no PTYFactory.Open may start after d.done/shutdown completion")
558594
559595 release1()
560596 releasePTY()
561597 hg.Wait()
562598 d.sessWg.Wait()
563599 d.waitNotifies()
600+ require.Zero(t, opensAfterShutdown.Load(), "racing Hellos must not start a PTY after shutdown")
564601 }
565602
566603 // --- wedged-client teardown ---------------------------------------------------
@@ -1090,8 +1127,8 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
10901127
10911128 var gotEnv []string
10921129 f := portsmocks.NewMockPTYFactory(t)
1093- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1094- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1130+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1131+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
10951132 gotEnv = append([]string(nil), env...)
10961133 return p, nil
10971134 },
@@ -1243,8 +1280,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12431280 var opens [][]string
12441281 f := portsmocks.NewMockPTYFactory(t)
12451282 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1246- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1247- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1283+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1284+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
12481285 opens = append(opens, append([]string(nil), env...))
12491286 if len(opens) == 1 {
12501287 return p1, nil
@@ -1253,9 +1290,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12531290 },
12541291 ).Twice()
12551292 floating := newQuietPTY()
1256- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1257- return got != normalSize && got.Valid()
1258- })).Return(floating, nil).Once()
1293+ expectFloatingPrewarmOpen(f, normalSize, floating)
12591294
12601295 d := newTestDaemon(t, f, stubClock{})
12611296 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}
@@ -1288,8 +1323,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12881323 var opens [][]string
12891324 f := portsmocks.NewMockPTYFactory(t)
12901325 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1291- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1292- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1326+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1327+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
12931328 opens = append(opens, append([]string(nil), env...))
12941329 if len(opens) == 1 {
12951330 return p1, nil
@@ -1298,9 +1333,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12981333 },
12991334 ).Twice()
13001335 floating := newQuietPTY()
1301- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1302- return got != normalSize && got.Valid()
1303- })).Return(floating, nil).Once()
1336+ expectFloatingPrewarmOpen(f, normalSize, floating)
13041337
13051338 d := newTestDaemon(t, f, stubClock{})
13061339 tr1 := portsmocks.NewMockTransport(t)
@@ -1335,8 +1368,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
13351368 var opens [][]string
13361369 f := portsmocks.NewMockPTYFactory(t)
13371370 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1338- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1339- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1371+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1372+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
13401373 opens = append(opens, append([]string(nil), env...))
13411374 if len(opens) == 1 {
13421375 return p1, nil
@@ -1345,18 +1378,38 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
13451378 },
13461379 ).Twice()
13471380 floating := newQuietPTY()
1348- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1349- return got != normalSize && got.Valid()
1350- })).Return(floating, nil).Once()
1381+ expectFloatingPrewarmOpen(f, normalSize, floating)
13511382 d := newTestDaemon(t, f, stubClock{})
13521383 tr := portsmocks.NewMockTransport(t)
13531384 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
13541385 tr.EXPECT().Close().Return(nil).Maybe()
13551386 sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
13561387 require.NoError(t, err)
1388+ // The source coordinator is deliberately made pending so the switch must
1389+ // invalidate it rather than letting its stale callback render for ac.
1390+ sourceCoordinator := sess.renderCoordinator()
1391+ require.NotNil(t, sourceCoordinator)
1392+ sourceCoordinator.invalidate(renderInvalidation{class: invalidateOutput})
1393+ ac.sendMu.Lock()
1394+ ac.output.next = 3
1395+ ac.output.acked = 1
1396+ ac.sendMu.Unlock()
1397+
13571398 require.NoError(t, d.createSessionAndSwitch(sess, ac, "next"))
13581399 got := ac.sess.Get()
13591400 require.NotNil(t, got)
1401+ sourceCoordinator.mu.Lock()
1402+ require.NotNil(t, sourceCoordinator.lease)
1403+ require.False(t, sourceCoordinator.lease.active, "source callbacks must be stale after handoff")
1404+ sourceCoordinator.mu.Unlock()
1405+ destinationCoordinator := got.renderCoordinator()
1406+ require.NotNil(t, destinationCoordinator, "destination coordinator precedes first paint")
1407+ destinationCoordinator.mu.Lock()
1408+ require.Same(t, ac, destinationCoordinator.lease.attachment)
1409+ destinationCoordinator.mu.Unlock()
1410+ ac.sendMu.Lock()
1411+ require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase")
1412+ ac.sendMu.Unlock()
13601413 got.mu.Lock()
13611414 require.True(t, got.terminal.TrueColor)
13621415 got.mu.Unlock()
M internal/usecase/daemon/snapshot.go +455 -79
@@ -4,17 +4,58 @@ import (
44 "context"
55 "fmt"
66 "math"
7+ "sync"
78 "time"
89
910 "github.com/bnema/vev/internal/domain"
10- scopy "github.com/bnema/vev/internal/usecase/copy"
1111 "github.com/bnema/vev/internal/usecase/layout"
1212 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1313 "github.com/bnema/vev/pkg/renderer"
1414 "github.com/bnema/vev/pkg/vt"
1515 )
1616
17-const snapshotInterval = 30 * time.Second
17+// snapshotQueueCapacity bounds retained immutable captures. A full queue never
18+// stalls session producers; the session remains dirty for the next interval.
19+const snapshotQueueCapacity = 1
20+
21+// snapshotFinalQueueCapacity is a global bound for terminal captures retained
22+// while the worker is blocked. On saturation a new terminal capture is rejected
23+// (and left dirty) rather than silently dropping a persistence acknowledgement.
24+const snapshotFinalQueueCapacity = 32
25+
26+type snapshotCapture struct {
27+ session *session
28+ generation uint64
29+ name string
30+ createdAt uint64
31+ active uint16
32+ tabs []snapshotCaptureTab
33+ finishOnce sync.Once
34+}
35+
36+type snapshotCaptureTab struct {
37+ stableID string
38+ cols uint16
39+ rows uint16
40+ nextPaneID uint64
41+ focus layout.PaneID
42+ tree *layout.Tree
43+ panes []snapshotCapturePane
44+}
45+
46+type snapshotCapturePane struct {
47+ id layout.PaneID
48+ stableID string
49+ cwd string
50+ history vt.HistoryView
51+ visible renderer.Frame
52+ process *snapcodec.Process
53+}
54+
55+const (
56+ snapshotInterval = 30 * time.Second
57+ snapshotFinalFlushTimeout = time.Second
58+)
1859
1960 func (d *Daemon) closeRestoreDone() {
2061 d.restoreOnce.Do(func() { close(d.restoreDone) })
@@ -51,6 +92,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
5192 if err := ctx.Err(); err != nil {
5293 return err
5394 }
95+ if len(snap.Tabs) == 0 {
96+ if snap.Active != 0 {
97+ return fmt.Errorf("snapshot: active tab out of range")
98+ }
99+ } else if int(snap.Active) >= len(snap.Tabs) {
100+ return fmt.Errorf("snapshot: active tab out of range")
101+ }
54102 if snap.Name == "" {
55103 return fmt.Errorf("snapshot: empty session name")
56104 }
@@ -141,7 +189,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
141189 return fmt.Errorf("snapshot: generating pane identity: %w", err)
142190 }
143191 }
144- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
192+ pty, err := d.ptys.Open(ctx, d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
145193 if err != nil {
146194 closeOpened()
147195 return err
@@ -149,7 +197,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
149197 p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)
150198 p.ctx, p.cancel = context.WithCancel(tb.ctx)
151199 p.rect = contentRect
152- seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)
200+ if err := restorePaneTerminal(p, paneSnap); err != nil {
201+ // p is not in tb.panes yet, so closeOpened cannot reach it.
202+ p.cancel()
203+ _ = pty.Close()
204+ closeOpened()
205+ return err
206+ }
153207 if restoreCommand != "" {
154208 if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil {
155209 d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID)
@@ -164,9 +218,6 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
164218 }
165219 createdAt := int64(snap.CreatedAt)
166220 sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt}
167- if sess.active < 0 || sess.active >= len(sess.tabs) {
168- sess.active = 0
169- }
170221 sess.snapEligible.Store(true)
171222 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
172223 sess.cwd = snap.Tabs[0].Panes[0].Cwd
@@ -201,36 +252,51 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
201252 }
202253
203254 func markSnapshotDirty(sess *session) {
204- if sess == nil {
255+ if sess == nil || !sess.snapEligible.Load() {
205256 return
206257 }
207- if sess.snapEligible.Load() {
208- sess.snapDirty.Store(true)
258+ sess.snapshotMu.Lock()
259+ sess.snapshotGeneration++
260+ sess.snapDirty.Store(true)
261+ sess.signalSnapshotChangedLocked()
262+ sess.snapshotMu.Unlock()
263+}
264+
265+// snapshotChangeLocked returns a channel closed by the next snapshot state
266+// transition. snapshotMu must be held.
267+func (sess *session) snapshotChangeLocked() chan struct{} {
268+ if sess.snapshotChanged == nil {
269+ sess.snapshotChanged = make(chan struct{})
209270 }
271+ return sess.snapshotChanged
210272 }
211273
212-func seedPaneRows(p *pane, scrollback, visible [][]renderer.Cell) {
274+// signalSnapshotChangedLocked wakes waiters after a snapshot state transition.
275+// snapshotMu must be held.
276+func (sess *session) signalSnapshotChangedLocked() {
277+ close(sess.snapshotChangeLocked())
278+ sess.snapshotChanged = make(chan struct{})
279+}
280+
281+func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
282+ if len(snap.Tail) == 0 {
283+ return fmt.Errorf("snapshot history: missing tail blob")
284+ }
285+ if len(snap.Visible) == 0 {
286+ return fmt.Errorf("snapshot visible: missing visible blob")
287+ }
213288 p.mu.Lock()
214289 defer p.mu.Unlock()
215- p.scrollback = scopy.NewScrollback(defaultScrollbackRows)
216- for _, row := range scrollback {
217- p.scrollback.Append(row)
218- }
219- if len(visible) > 0 {
220- p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)
221- for y, row := range visible {
222- if y >= p.screen.Frame.Height {
223- break
224- }
225- for x, cell := range row {
226- if x >= p.screen.Frame.Width {
227- break
228- }
229- p.screen.Frame.Set(x, y, cell)
230- }
231- }
290+ screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail)
291+ if err != nil {
292+ return fmt.Errorf("snapshot history: %w", err)
293+ }
294+ if err := screen.RestorePrimaryVisible(snap.Visible); err != nil {
295+ return fmt.Errorf("snapshot visible: %w", err)
232296 }
233- p.screen.OnLineEvicted = p.scrollback.Append
297+ p.screen = screen
298+ p.history = screen.History()
299+ return nil
234300 }
235301
236302 func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
@@ -251,35 +317,135 @@ func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
251317 return domain.Size{Cols: cols, Rows: rows}
252318 }
253319
254-func (d *Daemon) captureSession(sess *session) bool {
255- if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil {
320+// scheduleSnapshot captures a session only once while an immutable capture is
321+// queued or being written. It never waits for the bounded worker queue.
322+func (d *Daemon) scheduleSnapshot(sess *session) bool {
323+ return d.scheduleSnapshotWithFinalFallback(sess, false)
324+}
325+
326+// scheduleFinalSnapshot captures the terminal state even when an older capture
327+// is pending. A removed session cannot be retried by snapshotSaver, so the
328+// worker drains its retained terminal state before it stops.
329+func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
330+ return d.scheduleSnapshotWithFinalFallback(sess, true)
331+}
332+
333+func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {
334+ if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil || !sess.snapEligible.Load() {
335+ return true
336+ }
337+ sess.snapshotMu.Lock()
338+ if !sess.snapDirty.Load() || (!final && sess.snapshotPending) {
339+ sess.snapshotMu.Unlock()
256340 return true
257341 }
342+ generation := sess.snapshotGeneration
343+ sess.snapshotPendingCaptures++
344+ sess.snapshotPending = true
345+ sess.signalSnapshotChangedLocked()
346+ sess.snapshotMu.Unlock()
258347
348+ capture, ok := d.captureSnapshotState(sess, generation)
349+ if !ok {
350+ d.finishSnapshotCapture(capture, false)
351+ return false
352+ }
353+ if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) {
354+ return true
355+ }
356+ // Coalesce under saturation or shutdown: the latest state stays dirty and
357+ // will be captured on a later tick once an active worker has room.
358+ d.finishSnapshotCapture(capture, false)
359+ return false
360+}
361+
362+// enqueueSnapshotCapture serializes worker shutdown with the non-blocking
363+// queue send. The queue is deliberately never closed: producers can race
364+// shutdown without risking a send-on-closed panic.
365+func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
366+ d.snapshotWorkerMu.Lock()
367+ defer d.snapshotWorkerMu.Unlock()
368+ if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
369+ return false
370+ }
371+ select {
372+ case d.snapshotJobs <- capture:
373+ return true
374+ default:
375+ return false
376+ }
377+}
378+
379+// enqueueFinalSnapshotCapture hands terminal captures to the existing worker
380+// without making session teardown wait behind its normal bounded queue. A
381+// blocked worker retains at most one terminal capture per session, replacing
382+// stale state with the newest immutable capture.
383+func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
384+ if capture == nil || capture.session == nil {
385+ return false
386+ }
387+ d.snapshotWorkerMu.Lock()
388+ if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
389+ d.snapshotWorkerMu.Unlock()
390+ return false
391+ }
392+ if d.snapshotFinalJobs == nil {
393+ d.snapshotFinalJobs = make(map[*session]*snapshotCapture)
394+ }
395+ replaced, exists := d.snapshotFinalJobs[capture.session]
396+ if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity {
397+ d.snapshotWorkerMu.Unlock()
398+ d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity)
399+ return false
400+ }
401+ // Finish the stale capture before publishing its replacement. Otherwise a
402+ // fast worker could persist the replacement and then have this stale failure
403+ // mark the session dirty again.
404+ d.finishSnapshotCapture(replaced, false)
405+ if !exists {
406+ d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session)
407+ }
408+ d.snapshotFinalJobs[capture.session] = capture
409+ select {
410+ case d.snapshotWorkerFinalWake <- struct{}{}:
411+ default:
412+ }
413+ d.snapshotWorkerMu.Unlock()
414+ return true
415+}
416+
417+// captureSession rotates history tails and clones visible frames while holding
418+// each pane lock. The returned capture contains only immutable state; encoding
419+// and persistence are deliberately deferred to snapshotEncodeWorker.
420+func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {
259421 sess.mu.Lock()
260- name := sess.name
422+ capture := &snapshotCapture{
423+ session: sess,
424+ generation: generation,
425+ name: sess.name,
426+ createdAt: uint64(sess.createdAt),
427+ active: uint16(max(sess.active, 0)),
428+ }
261429 ephemeral := sess.ephemeral
262- createdAt := sess.createdAt
263- active := sess.active
264430 fallbackCwd := sess.cwd
265431 tabs := append([]*tab(nil), sess.tabs...)
266432 sess.mu.Unlock()
267- if ephemeral || name == "" {
268- return true
433+ if ephemeral || capture.name == "" {
434+ return capture, false
269435 }
270436
271- snap := snapcodec.Session{Name: name, CreatedAt: uint64(createdAt), Active: uint16(max(active, 0))}
437+ capture.tabs = make([]snapshotCaptureTab, 0, len(tabs))
272438 for _, tb := range tabs {
273439 tb.mu.Lock()
274- tabSnap := snapcodec.Tab{
275- StableID: tb.stableID,
276- Cols: uint16(max(tb.size.Cols, 0)),
277- Rows: uint16(max(tb.size.Rows, 0)),
278- NextPaneID: uint64(max(tb.nextPaneID, 0)),
279- Tree: tb.tree.Clone(),
440+ tabCapture := snapshotCaptureTab{
441+ stableID: tb.stableID,
442+ cols: uint16(max(tb.size.Cols, 0)),
443+ rows: uint16(max(tb.size.Rows, 0)),
444+ nextPaneID: uint64(max(tb.nextPaneID, 0)),
445+ tree: tb.tree.Clone(),
280446 }
281447 if tb.tree != nil {
282- tabSnap.Focus = tb.tree.Focus
448+ tabCapture.focus = tb.tree.Focus
283449 }
284450 panes := make([]*pane, 0, len(tb.panes))
285451 for _, p := range tb.panes {
@@ -287,51 +453,274 @@ func (d *Daemon) captureSession(sess *session) bool {
287453 }
288454 tb.mu.Unlock()
289455
290- paneIDs := make(map[layout.PaneID]struct{}, len(panes))
291- paneSnaps := make([]snapcodec.Pane, 0, len(panes))
456+ tabCapture.panes = make([]snapshotCapturePane, 0, len(panes))
292457 for _, p := range panes {
293458 p.mu.Lock()
294- pid := 0
295459 pty := p.pty
460+ pid := 0
296461 if pty != nil {
297462 pid = pty.Pid()
298463 }
299- paneSnap := snapcodec.Pane{
300- ID: p.id,
301- StableID: p.stableID,
302- Scrollback: cloneRows(p.scrollback.Snapshot()),
303- Visible: cloneRows(p.screen.PrimaryVisibleRows()),
464+ paneCapture := snapshotCapturePane{
465+ id: p.id,
466+ stableID: p.stableID,
467+ history: p.history.SealAndView(),
468+ visible: p.screen.PrimaryVisibleFrame(),
304469 }
305470 p.mu.Unlock()
306- paneSnap.Cwd = fallbackCwd
471+ paneCapture.cwd = fallbackCwd
307472 if d.procCwd != nil && pid > 0 {
308473 if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
309- paneSnap.Cwd = cwd
474+ paneCapture.cwd = cwd
310475 }
311476 }
312- paneSnap.Process = d.capturePaneProcess(pty, pid)
313- paneIDs[paneSnap.ID] = struct{}{}
314- paneSnaps = append(paneSnaps, paneSnap)
477+ paneCapture.process = d.capturePaneProcess(pty, pid)
478+ tabCapture.panes = append(tabCapture.panes, paneCapture)
479+ }
480+ capture.tabs = append(capture.tabs, tabCapture)
481+ }
482+ return capture, true
483+}
484+
485+// captureSession remains the synchronous producer-facing trigger for callers
486+// such as teardown and benchmarks; the actual encoding and Write stay async.
487+func (d *Daemon) captureSession(sess *session) bool {
488+ markSnapshotDirty(sess)
489+ return d.scheduleSnapshot(sess)
490+}
491+
492+func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error) {
493+ snap := snapcodec.Session{Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.Tab, 0, len(capture.tabs))}
494+ for _, tabCapture := range capture.tabs {
495+ tabSnap := snapcodec.Tab{
496+ StableID: tabCapture.stableID, Cols: tabCapture.cols, Rows: tabCapture.rows,
497+ NextPaneID: tabCapture.nextPaneID, Focus: tabCapture.focus, Tree: tabCapture.tree,
498+ Panes: make([]snapcodec.Pane, 0, len(tabCapture.panes)),
499+ }
500+ paneIDs := make(map[layout.PaneID]struct{}, len(tabCapture.panes))
501+ for _, paneCapture := range tabCapture.panes {
502+ sealed, tail, historyErr := vt.MarshalSealedHistory(paneCapture.history)
503+ visible, visibleErr := vt.MarshalVisible(paneCapture.visible)
504+ if historyErr != nil || visibleErr != nil {
505+ return nil, fmt.Errorf("terminal snapshot pane %s: history=%w visible=%w", paneCapture.id, historyErr, visibleErr)
506+ }
507+ tabSnap.Panes = append(tabSnap.Panes, snapcodec.Pane{ID: paneCapture.id, StableID: paneCapture.stableID, Cwd: paneCapture.cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: paneCapture.process})
508+ paneIDs[paneCapture.id] = struct{}{}
315509 }
316510 if tabSnap.Tree != nil {
317511 pruneTreeToPanes(tabSnap.Tree.Root, paneIDs)
318512 }
319- tabSnap.Panes = paneSnaps
320513 snap.Tabs = append(snap.Tabs, tabSnap)
321514 }
515+ return d.snapshotMarshal(snap)
516+}
322517
323- data, err := snapcodec.Marshal(snap)
324- if err != nil {
325- d.log.Warn("marshaling session snapshot failed", "err", err, "session", name)
326- return false
518+func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) {
519+ if capture == nil {
520+ return
521+ }
522+ capture.finishOnce.Do(func() {
523+ capture.session.snapshotMu.Lock()
524+ if capture.session.snapshotPendingCaptures > 0 {
525+ capture.session.snapshotPendingCaptures--
526+ }
527+ capture.session.snapshotPending = capture.session.snapshotPendingCaptures > 0
528+ if succeeded && capture.session.snapshotGeneration == capture.generation {
529+ capture.session.snapDirty.Store(false)
530+ } else if !succeeded {
531+ capture.session.snapDirty.Store(true)
532+ }
533+ capture.session.signalSnapshotChangedLocked()
534+ capture.session.snapshotMu.Unlock()
535+ })
536+}
537+
538+func (d *Daemon) startSnapshotEncodeWorker() {
539+ if d == nil {
540+ return
541+ }
542+ d.snapshotWorkerMu.Lock()
543+ if d.snapshotWorkerCancel != nil {
544+ d.snapshotWorkerMu.Unlock()
545+ return
546+ }
547+ // Shutdown owns the worker lifetime so it can flush captures after Serve
548+ // cancels its parent context. The worker is always stopped explicitly.
549+ workerCtx, cancel := context.WithCancel(context.Background())
550+ d.snapshotWorkerID++
551+ workerID := d.snapshotWorkerID
552+ d.snapshotWorkerCtx = workerCtx
553+ d.snapshotWorkerCancel = cancel
554+ d.snapshotWorkerDone = make(chan struct{})
555+ d.snapshotWorkerFlush = make(chan struct{})
556+ d.snapshotWorkerFinalWake = make(chan struct{}, 1)
557+ d.snapshotFinalJobs = make(map[*session]*snapshotCapture)
558+ d.snapshotFinalOrder = nil
559+ d.snapshotWorkerClosing = false
560+ d.snapshotWorkerInFlight = nil
561+ done := d.snapshotWorkerDone
562+ flush := d.snapshotWorkerFlush
563+ finalWake := d.snapshotWorkerFinalWake
564+ d.snapshotWorkerMu.Unlock()
565+ go func() {
566+ defer close(done)
567+ write := func(capture *snapshotCapture) bool {
568+ if workerCtx.Err() != nil || !d.setSnapshotWorkerInFlight(workerID, capture) {
569+ d.finishSnapshotCapture(capture, false)
570+ return false
571+ }
572+ data, err := d.encodeSnapshotCapture(capture)
573+ if err == nil && workerCtx.Err() == nil {
574+ err = d.snaps.Write(capture.name, data)
575+ }
576+ d.clearSnapshotWorkerInFlight(workerID, capture)
577+ if err != nil && workerCtx.Err() == nil {
578+ d.log.Warn("writing session snapshot failed", "err", err, "session", capture.name)
579+ }
580+ d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil)
581+ return workerCtx.Err() == nil
582+ }
583+ drainFinal := func() bool {
584+ for {
585+ capture := d.takeFinalSnapshotCapture()
586+ if capture == nil {
587+ return true
588+ }
589+ if !write(capture) {
590+ return false
591+ }
592+ }
593+ }
594+ flushQueued := func() {
595+ for {
596+ select {
597+ case capture := <-d.snapshotJobs:
598+ if !write(capture) {
599+ return
600+ }
601+ default:
602+ _ = drainFinal()
603+ return
604+ }
605+ }
606+ }
607+ for {
608+ select {
609+ case <-workerCtx.Done():
610+ return
611+ case <-flush:
612+ flushQueued()
613+ return
614+ case <-finalWake:
615+ if !drainFinal() {
616+ return
617+ }
618+ case capture := <-d.snapshotJobs:
619+ if !write(capture) {
620+ return
621+ }
622+ }
623+ }
624+ }()
625+}
626+
627+func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture {
628+ d.snapshotWorkerMu.Lock()
629+ defer d.snapshotWorkerMu.Unlock()
630+ for len(d.snapshotFinalOrder) > 0 {
631+ sess := d.snapshotFinalOrder[0]
632+ d.snapshotFinalOrder[0] = nil
633+ d.snapshotFinalOrder = d.snapshotFinalOrder[1:]
634+ capture := d.snapshotFinalJobs[sess]
635+ delete(d.snapshotFinalJobs, sess)
636+ if capture != nil {
637+ return capture
638+ }
327639 }
328- if err := d.snaps.Write(name, data); err != nil {
329- d.log.Warn("writing session snapshot failed", "err", err, "session", name)
640+ return nil
641+}
642+
643+func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool {
644+ d.snapshotWorkerMu.Lock()
645+ defer d.snapshotWorkerMu.Unlock()
646+ if d.snapshotWorkerID != workerID || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
330647 return false
331648 }
649+ d.snapshotWorkerInFlight = capture
332650 return true
333651 }
334652
653+func (d *Daemon) clearSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) {
654+ d.snapshotWorkerMu.Lock()
655+ defer d.snapshotWorkerMu.Unlock()
656+ if d.snapshotWorkerID == workerID && d.snapshotWorkerInFlight == capture {
657+ d.snapshotWorkerInFlight = nil
658+ }
659+}
660+
661+func (d *Daemon) stopSnapshotEncodeWorker() {
662+ if d == nil {
663+ return
664+ }
665+ d.snapshotWorkerMu.Lock()
666+ cancel := d.snapshotWorkerCancel
667+ if cancel == nil || d.snapshotWorkerClosing {
668+ d.snapshotWorkerMu.Unlock()
669+ return
670+ }
671+ // Stop accepting producers, then let the single worker drain its bounded
672+ // queue. A non-context-aware store may still block indefinitely, so the
673+ // injected clock bounds how long teardown waits before detaching it.
674+ d.snapshotWorkerClosing = true
675+ flush := d.snapshotWorkerFlush
676+ done := d.snapshotWorkerDone
677+ close(flush)
678+ timer := d.clock.NewTimer(snapshotFinalFlushTimeout)
679+ d.snapshotWorkerMu.Unlock()
680+
681+ select {
682+ case <-done:
683+ timer.Stop()
684+ d.finishStoppedSnapshotWorker(false)
685+ case <-timer.C():
686+ cancel()
687+ d.finishStoppedSnapshotWorker(true)
688+ }
689+}
690+
691+func (d *Daemon) finishStoppedSnapshotWorker(abandoned bool) {
692+ d.snapshotWorkerMu.Lock()
693+ inFlight := d.snapshotWorkerInFlight
694+ d.snapshotWorkerCtx = nil
695+ d.snapshotWorkerCancel = nil
696+ d.snapshotWorkerDone = nil
697+ d.snapshotWorkerFlush = nil
698+ d.snapshotWorkerFinalWake = nil
699+ d.snapshotWorkerClosing = false
700+ d.snapshotWorkerInFlight = nil
701+ queued := make([]*snapshotCapture, 0, len(d.snapshotJobs)+len(d.snapshotFinalJobs))
702+ for _, capture := range d.snapshotFinalJobs {
703+ queued = append(queued, capture)
704+ }
705+ d.snapshotFinalJobs = nil
706+ d.snapshotFinalOrder = nil
707+ for {
708+ select {
709+ case capture := <-d.snapshotJobs:
710+ queued = append(queued, capture)
711+ default:
712+ d.snapshotWorkerMu.Unlock()
713+ if abandoned {
714+ d.finishSnapshotCapture(inFlight, false)
715+ }
716+ for _, capture := range queued {
717+ d.finishSnapshotCapture(capture, false)
718+ }
719+ return
720+ }
721+ }
722+}
723+
335724 func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
336725 if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {
337726 return nil
@@ -368,27 +757,14 @@ func (d *Daemon) snapshotSaver(ctx context.Context) {
368757 sessions := d.sessionsSnapshotLocked()
369758 d.mu.Unlock()
370759 for _, sess := range sessions {
371- if sess.snapEligible.Load() && sess.snapDirty.Swap(false) {
372- if !d.captureSession(sess) {
373- sess.snapDirty.Store(true)
374- }
760+ if sess.snapEligible.Load() && sess.snapDirty.Load() {
761+ d.scheduleSnapshot(sess)
375762 }
376763 }
377764 t.Reset(snapshotInterval)
378765 }
379766 }
380767
381-func cloneRows(rows [][]renderer.Cell) [][]renderer.Cell {
382- if len(rows) == 0 {
383- return nil
384- }
385- out := make([][]renderer.Cell, len(rows))
386- for i, row := range rows {
387- out[i] = append([]renderer.Cell(nil), row...)
388- }
389- return out
390-}
391-
392768 func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool {
393769 if n == nil {
394770 return false
A internal/usecase/daemon/snapshot_acceptance_test.go +249 -0 collapsed to keep the page light
M internal/usecase/daemon/snapshot_restore_test.go +151 -35
@@ -18,8 +18,74 @@ import (
1818 "github.com/bnema/vev/internal/usecase/layout"
1919 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2020 "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
2122 )
2223
24+func terminalPane(t *testing.T, id, stableID, cwd string, historyRows, visibleRows [][]renderer.Cell) snapcodec.Pane {
25+ t.Helper()
26+ h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})
27+ for _, row := range historyRows {
28+ h.Append(row)
29+ }
30+ sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
31+ require.NoError(t, err)
32+ frame := renderer.NewFrame(40, 24)
33+ for y, row := range visibleRows {
34+ copy(frame.Row(y), row)
35+ }
36+ visible, err := vt.MarshalVisible(frame)
37+ require.NoError(t, err)
38+ return snapcodec.Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible}
39+}
40+
41+func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T) {
42+ valid := terminalPane(t, "pane", "p", "/work", nil, nil)
43+ tests := []struct {
44+ name string
45+ mutate func(*snapcodec.Pane)
46+ }{
47+ {name: "missing tail", mutate: func(p *snapcodec.Pane) { p.Tail = nil }},
48+ {name: "missing visible", mutate: func(p *snapcodec.Pane) { p.Visible = nil }},
49+ {name: "malformed tail", mutate: func(p *snapcodec.Pane) { p.Tail = []byte("bad") }},
50+ {name: "malformed visible", mutate: func(p *snapcodec.Pane) { p.Visible = []byte("bad") }},
51+ }
52+ for _, tt := range tests {
53+ t.Run(tt.name, func(t *testing.T) {
54+ snap := valid
55+ tt.mutate(&snap)
56+ p := newPane("pane", newRestorePTY(), domain.Size{Cols: 40, Rows: 24})
57+ require.Error(t, restorePaneTerminal(p, snap))
58+ })
59+ }
60+}
61+
62+func TestRestoreSessionRejectsInvalidActiveTab(t *testing.T) {
63+ factory := &restorePTYFactory{}
64+ d := newTestDaemon(t, factory, stubClock{})
65+
66+ err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad-active", Active: 1, Tabs: []snapcodec.Tab{{
67+ Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")},
68+ }}})
69+
70+ require.Error(t, err)
71+ require.Empty(t, factory.opens)
72+}
73+
74+func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) {
75+ factory := &restorePTYFactory{}
76+ d := newTestDaemon(t, factory, stubClock{})
77+ pane := emptyTerminalPane(t, "pane-1", "/tmp")
78+ pane.Tail = []byte("malformed")
79+
80+ err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad", Tabs: []snapcodec.Tab{{
81+ Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane},
82+ }}})
83+
84+ require.Error(t, err)
85+ require.Len(t, factory.opens, 1)
86+ require.Equal(t, 1, factory.opens[0].pty.closeCount())
87+}
88+
2389 func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
2490 store := &restoreSnapshotStore{}
2591 store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
@@ -37,8 +103,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
37103 layout.NewLeaf("pane-2"),
38104 }}},
39105 Panes: []snapcodec.Pane{
40- {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},
41- {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},
106+ terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}),
107+ terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}),
42108 },
43109 }},
44110 })}}
@@ -77,10 +143,11 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
77143 p := tb.panes["pane-2"]
78144 tb.mu.Unlock()
79145 p.mu.Lock()
80- require.Equal(t, "old2", rowText(p.scrollback.View().Row(0)))
81- require.Equal(t, 1, p.scrollback.Len())
146+ require.Same(t, p.screen.History(), p.history)
147+ require.Equal(t, "old2", rowText(p.history.View().Row(0)))
148+ require.Equal(t, 1, p.history.Len())
82149 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
83- copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
150+ copySnap := scopy.NewSnapshot(p.history, p.screen.Frame)
84151 require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))
85152 require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))
86153 p.mu.Unlock()
@@ -108,8 +175,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
108175 func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) {
109176 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
110177 Name: "work", Active: 0, Tabs: []snapcodec.Tab{
111- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/one"}}},
112- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/two"}}},
178+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/one")}},
179+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}},
113180 },
114181 })}}}
115182 factory := &restorePTYFactory{}
@@ -138,17 +205,15 @@ func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testi
138205
139206 func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {
140207 t.Helper()
208+ pane := emptyTerminalPane(t, "pane-1", "/tmp")
209+ pane.Process = proc
141210 return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{
142211 Name: name,
143212 Tabs: []snapcodec.Tab{{
144- Cols: 80,
145- Rows: 24,
146- Tree: layout.NewTree("pane-1"),
147- Panes: []snapcodec.Pane{{
148- ID: "pane-1",
149- Cwd: "/tmp",
150- Process: proc,
151- }},
213+ Cols: 80,
214+ Rows: 24,
215+ Tree: layout.NewTree("pane-1"),
216+ Panes: []snapcodec.Pane{pane},
152217 }},
153218 })}}}
154219 }
@@ -185,20 +250,17 @@ func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) {
185250 Cols: 80,
186251 Rows: 24,
187252 Tree: layout.NewTree("pane-1"),
188- Panes: []snapcodec.Pane{{
189- ID: "pane-1",
190- Cwd: "/one",
191- Process: lessProc,
192- }},
253+ Panes: func() []snapcodec.Pane {
254+ pane := emptyTerminalPane(t, "pane-1", "/one")
255+ pane.Process = lessProc
256+ return []snapcodec.Pane{pane}
257+ }(),
193258 },
194259 {
195- Cols: 80,
196- Rows: 24,
197- Tree: layout.NewTree("pane-1"),
198- Panes: []snapcodec.Pane{{
199- ID: "pane-1",
200- Cwd: "/two",
201- }},
260+ Cols: 80,
261+ Rows: 24,
262+ Tree: layout.NewTree("pane-1"),
263+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")},
202264 },
203265 },
204266 })}}},
@@ -246,11 +308,26 @@ func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {
246308 d := newTestDaemon(t, factory, stubClock{})
247309 d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
248310
249- require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp", Process: proc}}}}}))
311+ pane := emptyTerminalPane(t, "pane-1", "/tmp")
312+ pane.Process = proc
313+ require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}}}}))
250314 require.Len(t, factory.opens, 1)
251315 require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
252316 }
253317
318+func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) {
319+ factory := &restorePTYFactory{}
320+ d := newTestDaemon(t, factory, stubClock{})
321+ ctx, cancel := context.WithCancel(context.Background())
322+ defer cancel()
323+
324+ require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{
325+ Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")},
326+ }}}))
327+ require.Len(t, factory.opens, 1)
328+ require.Same(t, ctx, factory.opens[0].ctx)
329+}
330+
254331 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
255332 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
256333 Name: "stacked",
@@ -260,7 +337,11 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
260337 Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{
261338 layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),
262339 }}},
263- Panes: []snapcodec.Pane{{ID: "a", Cwd: "/a"}, {ID: "b", Cwd: "/b"}, {ID: "c", Cwd: "/c"}},
340+ Panes: []snapcodec.Pane{
341+ emptyTerminalPane(t, "a", "/a"),
342+ emptyTerminalPane(t, "b", "/b"),
343+ emptyTerminalPane(t, "c", "/c"),
344+ },
264345 }},
265346 })}}}
266347 factory := &restorePTYFactory{}
@@ -290,7 +371,16 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
290371 }
291372
292373 func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
293- valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}})
374+ valid := mustSnapshotBytes(t, snapcodec.Session{
375+ Name: "live",
376+ CreatedAt: 1,
377+ Tabs: []snapcodec.Tab{{
378+ Cols: 80,
379+ Rows: 24,
380+ Tree: layout.NewTree("pane-1"),
381+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/ok")},
382+ }},
383+ })
294384 badVersion := append([]byte(nil), valid...)
295385 binary.BigEndian.PutUint16(badVersion[4:6], 1)
296386 tests := []struct {
@@ -327,7 +417,7 @@ func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) {
327417 Cols: 80,
328418 Rows: 24,
329419 Tree: layout.NewTree("pane-1"),
330- Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/bad"}},
420+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/bad")},
331421 }},
332422 })}}}
333423 factory := &restorePTYFactory{}
@@ -353,7 +443,15 @@ func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
353443 loadFn: func() ([]ports.SnapshotBlob, error) {
354444 close(loaded)
355445 <-releaseLoad
356- return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/restored"}}}}})}}, nil
446+ return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
447+ Name: "work",
448+ Tabs: []snapcodec.Tab{{
449+ Cols: 80,
450+ Rows: 24,
451+ Tree: layout.NewTree("pane-1"),
452+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/restored")},
453+ }},
454+ })}}, nil
357455 },
358456 }
359457 factory := &restorePTYFactory{}
@@ -402,6 +500,11 @@ func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) {
402500 require.Equal(t, ports.ErrServerShutdown, perr.code)
403501 }
404502
503+func emptyTerminalPane(t *testing.T, id, cwd string) snapcodec.Pane {
504+ t.Helper()
505+ return terminalPane(t, id, "", cwd, nil, nil)
506+}
507+
405508 func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
406509 t.Helper()
407510 b, err := snapcodec.Marshal(s)
@@ -438,24 +541,26 @@ type restorePTYFactory struct {
438541 }
439542
440543 type restorePTYOpen struct {
544+ ctx context.Context
441545 dir string
442546 size domain.Size
443547 env []string
444548 pty *restorePTY
445549 }
446550
447-func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
551+func (f *restorePTYFactory) Open(ctx context.Context, _ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
448552 f.mu.Lock()
449553 defer f.mu.Unlock()
450554 pty := newRestorePTY()
451555 pty.writeErr = f.writeErr
452- f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
556+ f.opens = append(f.opens, restorePTYOpen{ctx: ctx, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
453557 return pty, nil
454558 }
455559
456560 type restorePTY struct {
457561 mu sync.Mutex
458562 writes []string
563+ closes int
459564 writeErr error
460565 done chan struct{}
461566 once sync.Once
@@ -475,7 +580,18 @@ func (p *restorePTY) Write(b []byte) (int, error) {
475580 }
476581 return len(b), nil
477582 }
478-func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil }
583+func (p *restorePTY) Close() error {
584+ p.mu.Lock()
585+ p.closes++
586+ p.mu.Unlock()
587+ p.once.Do(func() { close(p.done) })
588+ return nil
589+}
590+func (p *restorePTY) closeCount() int {
591+ p.mu.Lock()
592+ defer p.mu.Unlock()
593+ return p.closes
594+}
479595 func (p *restorePTY) Resize(domain.Size) error { return nil }
480596 func (p *restorePTY) Pid() int { return 0 }
481597 func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil }
M internal/usecase/daemon/snapshot_test.go +730 -8 collapsed to keep the page light
A internal/usecase/daemon/stalled_attachment_test.go +78 -0 collapsed to keep the page light
M internal/usecase/daemon/status.go +1 -24 collapsed to keep the page light
M internal/usecase/daemon/status_test.go +72 -20 collapsed to keep the page light
A internal/usecase/daemon/test_wait_helpers_test.go +55 -0
@@ -0,0 +1,55 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+const testWaitTimeout = 2 * time.Second
11+
12+func awaitTestValue[T any](t *testing.T, ch <-chan T, failure string) T {
13+ t.Helper()
14+ timer := time.NewTimer(testWaitTimeout)
15+ defer timer.Stop()
16+ select {
17+ case value := <-ch:
18+ return value
19+ case <-timer.C:
20+ t.Fatal(failure)
21+ var zero T
22+ return zero
23+ }
24+}
25+
26+func awaitTestCompletion(t *testing.T, done <-chan struct{}, failure string) {
27+ t.Helper()
28+ awaitTestValue(t, done, failure)
29+}
30+
31+func awaitCoordinatorOutput(
32+ t *testing.T,
33+ sends <-chan ports.Frame,
34+ timers <-chan *coordinatorMockTimer,
35+ frameContext string,
36+ timeoutFailure string,
37+) ports.Frame {
38+ t.Helper()
39+ deadline := time.NewTimer(testWaitTimeout)
40+ defer deadline.Stop()
41+ for {
42+ select {
43+ case frame := <-sends:
44+ if frame.Type != ports.MsgOutput {
45+ t.Fatalf("unexpected frame type %d %s", frame.Type, frameContext)
46+ }
47+ return frame
48+ case timer := <-timers:
49+ timer.ch <- time.Time{}
50+ case <-deadline.C:
51+ t.Fatal(timeoutFailure)
52+ return ports.Frame{}
53+ }
54+ }
55+}
M internal/usecase/daemon/theme_resolve.go +0 -1
@@ -28,7 +28,6 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
2828
2929 if ac != nil {
3030 ac.setTheme(t)
31- ac.composed.invalidate()
3231 }
3332 for _, tb := range tabs {
3433 tb.mu.Lock()
A internal/usecase/daemon/transactional_resize.go +292 -0 collapsed to keep the page light
A internal/usecase/daemon/transactional_resize_test.go +403 -0 collapsed to keep the page light
A internal/usecase/daemon/transport_observability_test.go +390 -0 collapsed to keep the page light
M internal/usecase/daemon/wire.go +1 -24 collapsed to keep the page light
A internal/usecase/snapshot/benchmark_test.go +122 -0 collapsed to keep the page light
M internal/usecase/snapshot/codec.go +84 -313 collapsed to keep the page light
M internal/usecase/snapshot/codec_test.go +390 -312 collapsed to keep the page light
A internal/usecase/snapshot/preflight.go +449 -0 collapsed to keep the page light
A internal/usecase/snapshot/preflight_acceptance_test.go +45 -0 collapsed to keep the page light
M internal/usecase/snapshot/snapshot.go +10 -12 collapsed to keep the page light
M internal/usecase/visualsearch/model_test.go +2 -2
@@ -61,14 +61,14 @@ func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) {
6161 require.Equal(t, "alpha", matches[0].Text)
6262 }
6363
64-func TestVisualSearchCloneSharesDocumentAndKeepsUIStateIndependent(t *testing.T) {
64+func TestVisualSearchCloneRetainsDocumentAndKeepsUIStateIndependent(t *testing.T) {
6565 m := New(testSnapshot("alpha", "beta alpha"))
6666 for _, r := range "alpha" {
6767 m.Insert(r)
6868 }
6969 clone := m.Clone()
7070
71- require.Same(t, &m.snapshot.Row(0)[0], &clone.snapshot.Row(0)[0])
71+ require.Equal(t, m.snapshot.Row(0), clone.snapshot.Row(0))
7272 clone.Down()
7373 clone.Insert('z')
7474
M pkg/vt/benchmark_test.go +158 -1 collapsed to keep the page light
A pkg/vt/history.go +163 -0 collapsed to keep the page light
A pkg/vt/history_acceptance_test.go +108 -0 collapsed to keep the page light
A pkg/vt/history_codec.go +308 -0 collapsed to keep the page light
A pkg/vt/history_codec_test.go +301 -0 collapsed to keep the page light
A pkg/vt/history_test.go +173 -0 collapsed to keep the page light
M pkg/vt/screen.go +84 -16 collapsed to keep the page light
M pkg/vt/screen_test.go +51 -0 collapsed to keep the page light
M pkg/vt/snapshot.go +203 -1 collapsed to keep the page light
A testdata/perf/README.md +69 -0 collapsed to keep the page light
A testdata/perf/manifest.json +2808 -0 large diff (2808 lines changed): collapsed
A transport_replay_integration_test.go +58 -0 collapsed to keep the page light

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