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)1+# Performance measurement
2-2+
3-This baseline measures in-process daemon work only. It deliberately does **not**3+## S5a transport observability
4-measure coordinator behavior, chunk encoding, compression, GC tuning, or real4+
5-network latency. Output metrics count frames and encoded output data produced5+S5a is measurement-only. It records process-local runtime marks and harness-owned
6-by the daemon; snapshot writes and serialized bytes are local snapshot-store6+end-to-end boundaries without changing rendering, pacing, transport policy, wire
7-proxies. These metrics approximate local rendering, IPC payload, and persistence7+bytes, or `ProtocolVersion`. The canonical matrix and workload/topology/
8-work, not a Unix socket, SSH, or UDP transport. Real transport impairment and8+transport coverage are in [`testdata/perf/manifest.json`](../testdata/perf/manifest.json):
9-latency benchmarks are deferred.9+`1x4`, `4x1`, `4x4`, and `8x1`, all at `120x40` with 10,000 rows per pane;
10-10+idle, active/all/inactive output, interactive flood, copy search, resize sweep,
11-## Large-history daemon baseline11+snapshot/output/resize, and attach/restore/tab switch; local, SSH stdio, UDP
12-12+baseline, UDP 25/100 ms, and UDP 0%/1% loss. It has 252 scenarios (4×9×7),
13-Use a single iteration only as a smoke check; it is not suitable for drawing13+with inapplicable cases required to be explicit. The seven transport entries are
14-performance conclusions:14+`local`, `ssh_stdio`, `udp_baseline`, `udp_loss_0pct`, `udp_25ms`, `udp_100ms`,
15-15+and `udp_loss_1pct`.
16-```sh16+
17-go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem17+### Trace schema and clock domains
18-```18+
19-19+Every process JSONL record has exactly these fields:
20-For before/after comparison, collect repeated samples for the **full matrix**20+`schema` (uint), `process_id` (string), `component` (string), `scenario`
21-(all workloads and topologies), pinning CPU/governor and recording the host.21+(string), `run`, `sequence`, `request_id`, `epoch` (uint), `kind` (string),
22-The benchmark harness must be committed and present in **both** measured22+`tick` (int64), `bytes`, `fragments`, `retransmits`, `pending` (uint),
23-revisions: collect the baseline after the harness is introduced but before the23+`ack_rtt_nanos` (int64), and `valid` (bool). Schema 1 is represented by:
24-optimization, then collect the optimized revision with that same harness. The24+
25-fixed duration and count make the output suitable for `benchstat`:25+```json
26-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}
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
30 ```27 ```
31 28
32-The matrix exercises live paint, snapshot capture, copy-mode entry, copy search,29+`tick` is stamped only by the concrete JSONL observer using its process-local
33-and resize at `120x40` client geometry. Each pane is populated with 10,00030+injected monotonic clock; producers supply no timestamp. The harness sets `VEV_PERF_TRACE`,
34-full-width deterministic history rows containing a stable `needle-<tab>-<row>`31+`VEV_PERF_PROCESS_ID`, `VEV_PERF_SCENARIO`, and `VEV_PERF_RUN` for every
35-prefix and patterned text. The layouts are: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.
36 38
37-- 1 tab x 1 pane (control)39+The named process-local spans are capture, compose, diff, queue wait,
38-- 1 tab x 4 panes40+ACK-blocked interval, emit, adapter send, and adapter receive. Their start/end
39-- 4 tabs x 1 pane41+marks are paired within one process and summarized by component/adapter with
40-- 4 tabs x 4 panes42+raw records, counts, p50/p95/p99, and max. Diagnostic fields (bytes, fragments,
41-- 8 tabs x 1 pane43+retransmits, pending, ACK RTT) remain separate from duration calculations.
42 44
43-The fixture starts no PTY readers, schedulers, clocks, or transports. It primes45+### Baseline procedure and validity
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.
50 46
51-Capture a pre-change result outside the repository, including Go and kernel47+Use the public CLI harness (not internal packages):
52-context:
53 48
54 ```sh49 ```sh
55-{50+go build -o /tmp/vev-perf ./
56- date -Is51+rm -rf testdata/perf/results
57- go version52+go run ./cmd/vev-perf-harness --vev-bin /tmp/vev-perf \
58- uname -a53+ --manifest testdata/perf/manifest.json --out testdata/perf/results \
59- git rev-parse HEAD54+ --warmup 10s --duration 30s --repetitions 10
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
65 ```55 ```
66 56
67-## Immutable document allocation gate57+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.
68 69
69-The copy-mode document retains an immutable view of existing scrollback rows and70+## Existing in-process smoke checks
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:
73 71
74-```sh72+These commands measure local daemon work only and are not end-to-end transport
75-go test ./internal/usecase/copy ./internal/usecase/visualsearch -run '^$' -bench '^(BenchmarkNewSnapshot10KRows|BenchmarkVisualSearchClone10KRows)$' -benchtime=1s -count=5 -benchmem73+results:
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:
93 74
94 ```sh75 ```sh
76+go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
95 go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem77 go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem
96 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeFloatingFrameCached$' -benchmem78 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeFloatingFrameCached$' -benchmem
97 ```79 ```
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 {
102 BootstrapTimeout time.Duration102 BootstrapTimeout time.Duration
103 ProbeTimeout time.Duration103 ProbeTimeout time.Duration
104 Log *slog.Logger104 Log *slog.Logger
105+ RuntimeObserver ports.SerializedRuntimeObserver
105 }106 }
106 107
107 func NewRemoteDialer(target, session string) RemoteDialer {108 func NewRemoteDialer(target, session string) RemoteDialer {
@@ -180,7 +181,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
180 RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {181 RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
181 return listenUDPPacket(ctx)182 return listenUDPPacket(ctx)
182 },183 },
183- })184+ }, WithRuntimeObserver(d.RuntimeObserver))
184 if err != nil {185 if err != nil {
185 _ = pc.Close()186 _ = pc.Close()
186 return nil, udpUnavailable("create UDP transport", err, &stderr)187 return nil, udpUnavailable("create UDP transport", err, &stderr)
M internal/adapters/dgram/transport.go +96 -7
@@ -45,6 +45,14 @@ var (
45 errControlFull = errors.New("dgram: control queue full")45 errControlFull = errors.New("dgram: control queue full")
46 )46 )
47 47
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+
48 type Options struct {56 type Options struct {
49 MTU int57 MTU int
50 ResendAfter time.Duration58 ResendAfter time.Duration
@@ -168,6 +176,11 @@ type Transport struct {
168 // mutation and before ordered event publication.176 // mutation and before ordered event publication.
169 afterLinkStateCommit func(ports.LinkState)177 afterLinkStateCommit func(ports.LinkState)
170 observe DiagnosticObserver178 observe DiagnosticObserver
179+ runtimeObserver ports.SerializedRuntimeObserver
180+ operationMu sync.Mutex
181+ operationCount int
182+ closing bool
183+ operationsDone chan struct{}
171 diagnosticCh chan Diagnostic184 diagnosticCh chan Diagnostic
172 heartbeat time.Duration185 heartbeat time.Duration
173 resendAfter time.Duration186 resendAfter time.Duration
@@ -311,11 +324,11 @@ func (s *writeDeadlineState) expire(now time.Time) {
311 s.mu.Unlock()324 s.mu.Unlock()
312 }325 }
313 326
314-func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) {327+func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, runtimeOpts ...RuntimeOption) (*Transport, error) {
315- return NewTransportWithOptions(pc, peer, key, sendDir, recvDir, Options{})328+ return NewTransportWithOptions(pc, peer, key, sendDir, recvDir, Options{}, runtimeOpts...)
316 }329 }
317 330
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) {
319 c, err := pdgram.NewCodec(key)332 c, err := pdgram.NewCodec(key)
320 if err != nil {333 if err != nil {
321 return nil, err334 return nil, err
@@ -360,6 +373,11 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD
360 nextRecvSeq: 1,373 nextRecvSeq: 1,
361 recvBuf: make(map[uint64]ports.Frame),374 recvBuf: make(map[uint64]ports.Frame),
362 }375 }
376+ for _, opt := range runtimeOpts {
377+ if opt != nil {
378+ opt(t)
379+ }
380+ }
363 t.sendWake = make(chan struct{})381 t.sendWake = make(chan struct{})
364 t.control = make(chan controlRecord, controlQueueSize)382 t.control = make(chan controlRecord, controlQueueSize)
365 t.ackWake = make(chan struct{}, 1)383 t.ackWake = make(chan struct{}, 1)
@@ -387,9 +405,19 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD
387 return t, nil405 return t, nil
388 }406 }
389 407
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+}
391 414
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+}
393 421
394 // SendSynchronous owns queue reservation, fragment pacing, per-write deadlines,422 // SendSynchronous owns queue reservation, fragment pacing, per-write deadlines,
395 // and close cancellation. Callers must not layer a shorter preflight timer over it.423 // and close cancellation. Callers must not layer a shorter preflight timer over it.
@@ -568,25 +596,35 @@ func (t *Transport) removePendingLocked(seq uint64) bool {
568 }596 }
569 597
570 func (t *Transport) Recv() (ports.Frame, error) {598 func (t *Transport) Recv() (ports.Frame, error) {
599+ end := t.beginRuntimeOperation(ports.RuntimeAdapterReceiveStart, 0)
571 select {600 select {
572 case f := <-t.in:601 case f := <-t.in:
602+ end(true)
573 return f, nil603 return f, nil
574 case <-t.done:604 case <-t.done:
575 t.mu.Lock()605 t.mu.Lock()
576 err := t.closeErr606 err := t.closeErr
577 t.mu.Unlock()607 t.mu.Unlock()
578 if err != nil {608 if err != nil {
609+ end(false)
579 return ports.Frame{}, err610 return ports.Frame{}, err
580 }611 }
581- return ports.Frame{}, errors.New("dgram: closed")612+ err = errors.New("dgram: closed")
613+ end(false)
614+ return ports.Frame{}, err
582 }615 }
583 }616 }
584 func (t *Transport) Close() error {617 func (t *Transport) Close() error {
618+ done := t.beginShutdown()
585 t.closeWithError(errors.New("dgram: closed"))619 t.closeWithError(errors.New("dgram: closed"))
586 t.mu.Lock()620 t.mu.Lock()
587 pc := t.pc621 pc := t.pc
588 t.mu.Unlock()622 t.mu.Unlock()
589- return pc.Close()623+ err := pc.Close()
624+ if done != nil {
625+ <-done
626+ }
627+ return err
590 }628 }
591 629
592 func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }630 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) {
1585 }1623 }
1586 1624
1587 // DatagramTransport marks Transport as a UDP-style datagram transport.1625 // 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+
1588 func (*Transport) DatagramTransport() {}1677 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 @@
1 package ipc1 package ipc
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
5 "net"6 "net"
6 "path/filepath"7 "path/filepath"
@@ -8,15 +9,16 @@ import (
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
9 )10 )
10 11
11-// Dial connects to the daemon's socket in dir (dir/daemon.sock) and returns12+// DialContext connects to the daemon's socket in dir (dir/daemon.sock) and
12-// a Transport speaking vev's framed protocol. A dial failure (no daemon13+// returns a Transport speaking vev's framed protocol. A dial failure (no daemon
13 // listening, or a stale/absent socket) is reported as a wrapped error so14 // listening, or a stale/absent socket) is reported as a wrapped error so
14 // callers can decide whether to spawn a daemon and retry.15 // 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) {
16 sockPath := filepath.Join(dir, socketFileName)17 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)
18 if err != nil {20 if err != nil {
19 return nil, fmt.Errorf("ipc: dial %s: %w", sockPath, err)21 return nil, fmt.Errorf("ipc: dial %s: %w", sockPath, err)
20 }22 }
21- return NewTransport(conn), nil23+ return NewTransport(conn, opts...), nil
22 }24 }
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
25 type unixListener struct {25 type unixListener struct {
26 ln *net.UnixListener26 ln *net.UnixListener
27 addr string27 addr string
28+ opts []Option
28 }29 }
29 30
30 // Listen creates the socket directory (0700) if needed and starts31 // Listen creates the socket directory (0700) if needed and starts
@@ -35,7 +36,7 @@ type unixListener struct {
35 // the previous owner died without cleaning up, so the stale socket file is36 // the previous owner died without cleaning up, so the stale socket file is
36 // unlinked and bind is retried once. A successful dial means a live daemon37 // unlinked and bind is retried once. A successful dial means a live daemon
37 // owns the socket, and Listen returns ErrDaemonRunning.38 // 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) {
39 if err := safedir.EnsurePrivate(dir); err != nil {40 if err := safedir.EnsurePrivate(dir); err != nil {
40 return nil, fmt.Errorf("ipc: securing socket directory: %w", err)41 return nil, fmt.Errorf("ipc: securing socket directory: %w", err)
41 }42 }
@@ -69,7 +70,7 @@ func Listen(dir string) (ports.Listener, error) {
69 return nil, fmt.Errorf("ipc: chmod %s: %w", sockPath, err)70 return nil, fmt.Errorf("ipc: chmod %s: %w", sockPath, err)
70 }71 }
71 72
72- return &unixListener{ln: ln, addr: sockPath}, nil73+ return &unixListener{ln: ln, addr: sockPath, opts: opts}, nil
73 }74 }
74 75
75 // bindUnix binds and listens on sockPath.76 // bindUnix binds and listens on sockPath.
@@ -104,7 +105,7 @@ func (l *unixListener) Accept() (ports.Transport, error) {
104 if err != nil {105 if err != nil {
105 return nil, err106 return nil, err
106 }107 }
107- return NewTransport(conn), nil108+ return NewTransport(conn, l.opts...), nil
108 }109 }
109 110
110 // Close stops accepting connections. The underlying net.UnixListener111 // Close stops accepting connections. The underlying net.UnixListener
M internal/adapters/ipc/transport.go +276 -17
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "io"6 "io"
7 "net"7 "net"
8+ "sync"
8 9
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 )11 )
@@ -26,17 +27,54 @@ var ErrZeroLengthFrame = errors.New("ipc: zero-length frame")
26 // maxFrameLen, and by Send when a payload is too large to encode.27 // maxFrameLen, and by Send when a payload is too large to encode.
27 var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length")28 var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length")
28 29
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+
29 // unixTransport implements ports.Transport over a net.Conn (in practice an42 // unixTransport implements ports.Transport over a net.Conn (in practice an
30 // AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets43 // AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets
31 // tests exercise it over net.Pipe).44 // tests exercise it over net.Pipe).
32 //45 //
33-// unixTransport is safe for one concurrent caller of Send and one46+// unixTransport has one writer goroutine which owns every conn.Write. Send
34-// concurrent caller of Recv at the same time (i.e. a single reader47+// waits for that owner's wire attempt; SendAsync only performs a bounded,
35-// goroutine and a single writer goroutine); it is not safe for concurrent48+// ordered enqueue. Recv still has one caller at a time.
36-// calls to Send from multiple goroutines, nor Recv from multiple49+type Option func(*unixTransport)
37-// goroutines.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+
38 type unixTransport struct {57 type unixTransport struct {
39- conn net.Conn58+ 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
40 78
41 // readBuf is reused across Recv calls (grow-once strategy): it grows79 // readBuf is reused across Recv calls (grow-once strategy): it grows
42 // to fit the largest frame seen so far and is never shrunk. The Frame80 // to fit the largest frame seen so far and is never shrunk. The Frame
@@ -47,42 +85,197 @@ type unixTransport struct {
47 85
48 // NewTransport wraps conn as a ports.Transport speaking vev's framed86 // NewTransport wraps conn as a ports.Transport speaking vev's framed
49 // binary protocol.87 // binary protocol.
50-func NewTransport(conn net.Conn) ports.Transport {88+func NewTransport(conn net.Conn, opts ...Option) ports.Transport {
51- return &unixTransport{conn: conn}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)
52 }108 }
53 109
54-// Send writes f as a single frame: a 4-byte big-endian length (covering110+// Send queues f for the sole writer and waits for its wire attempt.
55-// the type byte and payload), the type byte, then the payload — assembled
56-// into one buffer and written with a single Write call.
57 func (t *unixTransport) Send(f ports.Frame) error {111 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) {
58 n := 1 + len(f.Payload) // type + payload146 n := 1 + len(f.Payload) // type + payload
59 if n > maxFrameLen {147 if n > maxFrameLen {
60- return ErrFrameTooLarge148+ return nil, ErrFrameTooLarge
61 }149 }
62-
63 buf := make([]byte, frameHeaderLen+n)150 buf := make([]byte, frameHeaderLen+n)
64 binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n))151 binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n))
65 buf[frameHeaderLen] = byte(f.Type)152 buf[frameHeaderLen] = byte(f.Type)
66 copy(buf[frameHeaderLen+1:], f.Payload)153 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+}
67 180
68- _, err := t.conn.Write(buf)181+// enqueueAsync performs a bounded, nonblocking enqueue for SendAsync.
69- return err182+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
70 }259 }
71 260
72 // Recv reads one frame: a 4-byte length, then a body of that many bytes261 // Recv reads one frame: a 4-byte length, then a body of that many bytes
73 // (type byte + payload). It blocks until a full frame arrives, the262 // (type byte + payload). It blocks until a full frame arrives, the
74 // connection is closed (io.EOF), or an error occurs.263 // connection is closed (io.EOF), or an error occurs.
75 func (t *unixTransport) Recv() (ports.Frame, error) {264 func (t *unixTransport) Recv() (ports.Frame, error) {
265+ end := t.beginOperation(ports.RuntimeAdapterReceiveStart, 0)
76 var hdr [frameHeaderLen]byte266 var hdr [frameHeaderLen]byte
77 if _, err := io.ReadFull(t.conn, hdr[:]); err != nil {267 if _, err := io.ReadFull(t.conn, hdr[:]); err != nil {
268+ end(false)
78 return ports.Frame{}, err269 return ports.Frame{}, err
79 }270 }
80 271
81 n := binary.BigEndian.Uint32(hdr[:])272 n := binary.BigEndian.Uint32(hdr[:])
82 if n == 0 {273 if n == 0 {
274+ end(false)
83 return ports.Frame{}, ErrZeroLengthFrame275 return ports.Frame{}, ErrZeroLengthFrame
84 }276 }
85 if n > maxFrameLen {277 if n > maxFrameLen {
278+ end(false)
86 return ports.Frame{}, ErrFrameTooLarge279 return ports.Frame{}, ErrFrameTooLarge
87 }280 }
88 281
@@ -92,6 +285,7 @@ func (t *unixTransport) Recv() (ports.Frame, error) {
92 t.readBuf = t.readBuf[:n]285 t.readBuf = t.readBuf[:n]
93 }286 }
94 if _, err := io.ReadFull(t.conn, t.readBuf); err != nil {287 if _, err := io.ReadFull(t.conn, t.readBuf); err != nil {
288+ end(false)
95 return ports.Frame{}, err289 return ports.Frame{}, err
96 }290 }
97 291
@@ -100,10 +294,75 @@ func (t *unixTransport) Recv() (ports.Frame, error) {
100 payload = make([]byte, n-1)294 payload = make([]byte, n-1)
101 copy(payload, t.readBuf[1:])295 copy(payload, t.readBuf[1:])
102 }296 }
297+ end(true)
103 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil298 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
104 }299 }
105 300
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.
107 func (t *unixTransport) Close() error {354 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
109 }368 }
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 (
6 "io"6 "io"
7 "net"7 "net"
8 "reflect"8 "reflect"
9+ "runtime"
9 "sync"10 "sync"
10 "testing"11 "testing"
12+ "time"
11 13
12 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
@@ -231,3 +233,286 @@ func TestTransportSendOversizePayloadRejected(t *testing.T) {
231 t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err)233 t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err)
232 }234 }
233 }235 }
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
8 // x/sys/unix plus the standard library.8 // x/sys/unix plus the standard library.
9 9
10 import (10 import (
11+ "context"
11 "errors"12 "errors"
12 "fmt"13 "fmt"
13 "io"14 "io"
@@ -38,7 +39,10 @@ func NewFactory() *Factory { return &Factory{} }
38 // verbatim (nil means inherit the current process environment); the caller39 // verbatim (nil means inherit the current process environment); the caller
39 // decides TERM and friends. sz sets the terminal window size before the child40 // decides TERM and friends. sz sets the terminal window size before the child
40 // starts, so the child observes the correct dimensions on its first query.41 // 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+ }
42 masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0)46 masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0)
43 if err != nil {47 if err != nil {
44 return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err)48 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
79 }83 }
80 }84 }
81 85
82- cmd := exec.Command(command, args...)86+ cmd := exec.CommandContext(ctx, command, args...)
83 cmd.Env = env87 cmd.Env = env
84 cmd.Dir = platform.DirOrHome(dir)88 cmd.Dir = platform.DirOrHome(dir)
85 cmd.Stdin = slave89 cmd.Stdin = slave
@@ -93,6 +97,20 @@ func (Factory) Open(command string, args []string, env []string, dir string, sz
93 Setctty: true,97 Setctty: true,
94 Ctty: 0,98 Ctty: 0,
95 }99 }
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+ }
96 114
97 if err := cmd.Start(); err != nil {115 if err := cmd.Start(); err != nil {
98 return nil, fmt.Errorf("pty: start %q: %w", command, err)116 return nil, fmt.Errorf("pty: start %q: %w", command, err)
@@ -190,9 +208,8 @@ func (p *linuxPTY) Close() error {
190 p.closeOnce.Do(func() {208 p.closeOnce.Do(func() {
191 pid := p.cmd.Process.Pid209 pid := p.cmd.Process.Pid
192 210
193- // Negative pid targets the whole process group (the child is the group211+ // Ignore errors: the child may already be gone.
194- // leader thanks to Setsid). Ignore errors: the child may already be gone.212+ _ = signalProcessGroup(pid, syscall.SIGHUP)
195- _ = unix.Kill(-pid, unix.SIGHUP)
196 213
197 // Reap the child (exit status is intentionally discarded; the port has no214 // Reap the child (exit status is intentionally discarded; the port has no
198 // Wait — "child gone" surfaces to callers as io.EOF on Read).215 // Wait — "child gone" surfaces to callers as io.EOF on Read).
@@ -205,7 +222,7 @@ func (p *linuxPTY) Close() error {
205 select {222 select {
206 case <-reaped:223 case <-reaped:
207 case <-time.After(killGracePeriod):224 case <-time.After(killGracePeriod):
208- _ = unix.Kill(-pid, unix.SIGKILL)225+ _ = signalProcessGroup(pid, syscall.SIGKILL)
209 <-reaped226 <-reaped
210 }227 }
211 228
@@ -214,6 +231,13 @@ func (p *linuxPTY) Close() error {
214 return p.closeErr231 return p.closeErr
215 }232 }
216 233
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+
217 // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ.241 // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ.
218 func setWinsize(fd int, sz domain.Size) error {242 func setWinsize(fd int, sz domain.Size) error {
219 return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{243 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
4 4
5 import (5 import (
6 "bytes"6 "bytes"
7+ "context"
7 "errors"8 "errors"
8 "io"9 "io"
9 "os"10 "os"
@@ -53,7 +54,7 @@ func TestOpen_ChildOutputToEOF(t *testing.T) {
53 t.Skip("skipping pty integration test in -short mode")54 t.Skip("skipping pty integration test in -short mode")
54 }55 }
55 f := newFactory()56 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})
57 require.NoError(t, err)58 require.NoError(t, err)
58 t.Cleanup(func() { _ = p.Close() })59 t.Cleanup(func() { _ = p.Close() })
59 60
@@ -70,7 +71,7 @@ func TestOpen_EchoRoundtrip(t *testing.T) {
70 t.Skip("skipping pty integration test in -short mode")71 t.Skip("skipping pty integration test in -short mode")
71 }72 }
72 f := newFactory()73 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})
74 require.NoError(t, err)75 require.NoError(t, err)
75 t.Cleanup(func() { _ = p.Close() })76 t.Cleanup(func() { _ = p.Close() })
76 77
@@ -103,7 +104,7 @@ func TestForegroundPgid(t *testing.T) {
103 t.Skip("skipping pty integration test in -short mode")104 t.Skip("skipping pty integration test in -short mode")
104 }105 }
105 f := newFactory()106 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})
107 require.NoError(t, err)108 require.NoError(t, err)
108 t.Cleanup(func() { _ = p.Close() })109 t.Cleanup(func() { _ = p.Close() })
109 110
@@ -141,7 +142,7 @@ func TestResize_SttySize(t *testing.T) {
141 for _, tt := range tests {142 for _, tt := range tests {
142 t.Run(tt.name, func(t *testing.T) {143 t.Run(tt.name, func(t *testing.T) {
143 f := newFactory()144 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)
145 require.NoError(t, err)146 require.NoError(t, err)
146 t.Cleanup(func() { _ = p.Close() })147 t.Cleanup(func() { _ = p.Close() })
147 148
@@ -160,7 +161,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) {
160 t.Skip("skipping pty integration test in -short mode")161 t.Skip("skipping pty integration test in -short mode")
161 }162 }
162 f := newFactory()163 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})
164 require.NoError(t, err)165 require.NoError(t, err)
165 166
166 pid := p.Pid()167 pid := p.Pid()
@@ -191,7 +192,7 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) {
191 t.Skip("skipping pty integration test in -short mode")192 t.Skip("skipping pty integration test in -short mode")
192 }193 }
193 f := newFactory()194 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})
195 require.NoError(t, err)196 require.NoError(t, err)
196 pid := p.Pid()197 pid := p.Pid()
197 198
@@ -204,6 +205,42 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) {
204 }, 3*time.Second, 20*time.Millisecond)205 }, 3*time.Second, 20*time.Millisecond)
205 }206 }
206 207
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+
207 func TestResize_DeliversSIGWINCH(t *testing.T) {244 func TestResize_DeliversSIGWINCH(t *testing.T) {
208 if testing.Short() {245 if testing.Short() {
209 t.Skip("skipping pty integration test in -short mode")246 t.Skip("skipping pty integration test in -short mode")
@@ -212,7 +249,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) {
212 // deliver SIGWINCH to the foreground process group.249 // deliver SIGWINCH to the foreground process group.
213 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`250 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`
214 f := newFactory()251 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})
216 require.NoError(t, err)253 require.NoError(t, err)
217 t.Cleanup(func() { _ = p.Close() })254 t.Cleanup(func() { _ = p.Close() })
218 255
M internal/adapters/remote/remote_factory.go +19 -8
@@ -11,35 +11,46 @@ import (
11 )11 )
12 12
13 // DialerFactory selects the requested remote transport adapter.13 // DialerFactory selects the requested remote transport adapter.
14-type DialerFactory struct{}14+type DialerFactory struct {
15+ observer ports.SerializedRuntimeObserver
16+}
15 17
16 var _ ports.RemoteDialerFactory = DialerFactory{}18 var _ ports.RemoteDialerFactory = DialerFactory{}
17 19
18 func NewDialerFactory() DialerFactory { return DialerFactory{} }20 func NewDialerFactory() DialerFactory { return DialerFactory{} }
19 21
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) {
21 switch mode {29 switch mode {
22 case ports.RemoteTransportUDP:30 case ports.RemoteTransportUDP:
23 if log != nil {31 if log != nil {
24 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)32 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
25 }33 }
26- return dgram.NewRemoteDialerWithLogger(target, session, log), nil34+ dialer := dgram.NewRemoteDialerWithLogger(target, session, log)
35+ dialer.RuntimeObserver = f.observer
36+ return dialer, nil
27 case ports.RemoteTransportStdio:37 case ports.RemoteTransportStdio:
28 if log != nil {38 if log != nil {
29 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)39 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
30 }40 }
31- return stdioDialer{target: target, session: session, log: log}, nil41+ return stdioDialer{target: target, session: session, log: log, observer: f.observer}, nil
32 default:42 default:
33 return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)43 return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)
34 }44 }
35 }45 }
36 46
37 type stdioDialer struct {47 type stdioDialer struct {
38- target string48+ target string
39- session string49+ session string
40- log *slog.Logger50+ log *slog.Logger
51+ observer ports.SerializedRuntimeObserver
41 }52 }
42 53
43 func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) {54 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))
45 }56 }
M internal/adapters/sshstdio/transport.go +128 -15
@@ -34,12 +34,26 @@ var (
34 type closeFunc func() error34 type closeFunc func() error
35 type eofErrFunc func() error35 type eofErrFunc func() error
36 36
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+
37 // NewTransport wraps separate reader/writer streams as a framed Transport.45 // NewTransport wraps separate reader/writer streams as a framed Transport.
38-func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc) ports.Transport {46+func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc, opts ...Option) ports.Transport {
39- return newTransport(r, w, closeFn, nil)47+ t := newTransport(r, w, closeFn, nil)
48+ for _, opt := range opts {
49+ if opt != nil {
50+ opt(t)
51+ }
52+ }
53+ return t
40 }54 }
41 55
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 {
43 if closeFn == nil {57 if closeFn == nil {
44 closeFn = func() error { return nil }58 closeFn = func() error { return nil }
45 }59 }
@@ -47,18 +61,25 @@ func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc
47 }61 }
48 62
49 type transport struct {63 type transport struct {
50- r io.Reader64+ r io.Reader
51- w io.Writer65+ w io.Writer
52- close closeFunc66+ close closeFunc
53- eofErr eofErrFunc67+ eofErr eofErrFunc
68+ observer ports.SerializedRuntimeObserver
69+ operationMu sync.Mutex
70+ operationCount int
71+ closing bool
72+ operationsDone chan struct{}
54 73
55 mu sync.Mutex74 mu sync.Mutex
56 readBuf []byte75 readBuf []byte
57 }76 }
58 77
59 func (t *transport) Send(f ports.Frame) error {78 func (t *transport) Send(f ports.Frame) error {
79+ end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
60 n := 1 + len(f.Payload)80 n := 1 + len(f.Payload)
61 if n > maxFrameLen {81 if n > maxFrameLen {
82+ end(false)
62 return ErrFrameTooLarge83 return ErrFrameTooLarge
63 }84 }
64 85
@@ -68,22 +89,28 @@ func (t *transport) Send(f ports.Frame) error {
68 copy(buf[frameHeaderLen+1:], f.Payload)89 copy(buf[frameHeaderLen+1:], f.Payload)
69 90
70 t.mu.Lock()91 t.mu.Lock()
71- defer t.mu.Unlock()
72 _, err := t.w.Write(buf)92 _, err := t.w.Write(buf)
93+ t.mu.Unlock()
94+ end(err == nil)
73 return err95 return err
74 }96 }
75 97
76 func (t *transport) Recv() (ports.Frame, error) {98 func (t *transport) Recv() (ports.Frame, error) {
99+ end := t.beginOperation(ports.RuntimeAdapterReceiveStart, 0)
77 var hdr [frameHeaderLen]byte100 var hdr [frameHeaderLen]byte
78 if _, err := io.ReadFull(t.r, hdr[:]); err != nil {101 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
80 }105 }
81 106
82 n := binary.BigEndian.Uint32(hdr[:])107 n := binary.BigEndian.Uint32(hdr[:])
83 if n == 0 {108 if n == 0 {
109+ end(false)
84 return ports.Frame{}, ErrZeroLengthFrame110 return ports.Frame{}, ErrZeroLengthFrame
85 }111 }
86 if n > maxFrameLen {112 if n > maxFrameLen {
113+ end(false)
87 return ports.Frame{}, ErrFrameTooLarge114 return ports.Frame{}, ErrFrameTooLarge
88 }115 }
89 116
@@ -93,13 +120,35 @@ func (t *transport) Recv() (ports.Frame, error) {
93 t.readBuf = t.readBuf[:n]120 t.readBuf = t.readBuf[:n]
94 }121 }
95 if _, err := io.ReadFull(t.r, t.readBuf); err != nil {122 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
97 }126 }
98 127
99 payload := append([]byte(nil), t.readBuf[1:]...)128 payload := append([]byte(nil), t.readBuf[1:]...)
129+ end(true)
100 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil130 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
101 }131 }
102 132
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+
103 func (t *transport) mapEOFError(err error) error {152 func (t *transport) mapEOFError(err error) error {
104 if t.eofErr == nil || (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) {153 if t.eofErr == nil || (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) {
105 return err154 return err
@@ -110,7 +159,56 @@ func (t *transport) mapEOFError(err error) error {
110 return err159 return err
111 }160 }
112 161
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+}
114 212
115 type processWaiter struct {213 type processWaiter struct {
116 cmd *exec.Cmd214 cmd *exec.Cmd
@@ -222,18 +320,27 @@ func Dial(target, session string) (ports.Transport, error) {
222 return DialContext(context.Background(), target, session)320 return DialContext(context.Background(), target, session)
223 }321 }
224 322
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+
225 // DialContext is like Dial, but the context is propagated to ssh startup so a328 // DialContext is like Dial, but the context is propagated to ssh startup so a
226 // canceled attach attempt interrupts the local ssh process. Callers may pass a329 // canceled attach attempt interrupts the local ssh process. Callers may pass a
227 // logger to record ssh start failures and non-clean exits without logging the330 // logger to record ssh start failures and non-clean exits without logging the
228 // generated command line.331 // generated command line.
229 func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) {332 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- }
233 var log *slog.Logger333 var log *slog.Logger
234 if len(logger) > 0 {334 if len(logger) > 0 {
235 log = logger[0]335 log = logger[0]
236 }336 }
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+ }
237 spec := BuildCommand(target, session)344 spec := BuildCommand(target, session)
238 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)345 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
239 stdin, err := cmd.StdinPipe()346 stdin, err := cmd.StdinPipe()
@@ -254,5 +361,11 @@ func DialContext(ctx context.Context, target, session string, logger ...*slog.Lo
254 }361 }
255 362
256 waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)363 waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)
257- return newTransport(stdout, stdin, waiter.close, waiter.eofErr), nil364+ 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
258 }371 }
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 {
74 // attach dials, handshakes, and returns the transport plus its frame pump.74 // attach dials, handshakes, and returns the transport plus its frame pump.
75 func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (ports.Transport, *pump) {75 func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (ports.Transport, *pump) {
76 t.Helper()76 t.Helper()
77- tr, err := ipc.Dial(dir)77+ tr, err := ipc.DialContext(context.Background(), dir)
78 require.NoError(t, err)78 require.NoError(t, err)
79 hello := ports.Hello{Version: ports.ProtocolVersion, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color"}79 hello := ports.Hello{Version: ports.ProtocolVersion, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color"}
80 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}))80 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)
91 91
92 func listRemoteSessions(t *testing.T, dir string) ports.Sessions {92 func listRemoteSessions(t *testing.T, dir string) ports.Sessions {
93 t.Helper()93 t.Helper()
94- tr, err := ipc.Dial(dir)94+ tr, err := ipc.DialContext(context.Background(), dir)
95 require.NoError(t, err)95 require.NoError(t, err)
96 defer func() { _ = tr.Close() }()96 defer func() { _ = tr.Close() }()
97 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))97 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 {
104 }104 }
105 105
106 func killAll(dir string) error {106 func killAll(dir string) error {
107- tr, err := ipc.Dial(dir)107+ tr, err := ipc.DialContext(context.Background(), dir)
108 if err != nil {108 if err != nil {
109 return err109 return err
110 }110 }
@@ -248,7 +248,7 @@ func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) {
248 text = awaitScreenText(t, p, sz, " work ")248 text = awaitScreenText(t, p, sz, " work ")
249 require.NotContains(t, text, "work*")249 require.NotContains(t, text, "work*")
250 250
251- listTr, err := ipc.Dial(dir)251+ listTr, err := ipc.DialContext(context.Background(), dir)
252 require.NoError(t, err)252 require.NoError(t, err)
253 defer func() { _ = listTr.Close() }()253 defer func() { _ = listTr.Close() }()
254 require.NoError(t, listTr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))254 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) {
367 tr2, _ := attach(t, dir, ports.IntentNew, "two", sz)367 tr2, _ := attach(t, dir, ports.IntentNew, "two", sz)
368 require.NoError(t, tr2.Close())368 require.NoError(t, tr2.Close())
369 369
370- killTr, err := ipc.Dial(dir)370+ killTr, err := ipc.DialContext(context.Background(), dir)
371 require.NoError(t, err)371 require.NoError(t, err)
372 defer func() { _ = killTr.Close() }()372 defer func() { _ = killTr.Close() }()
373 require.NoError(t, killTr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}))373 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 (
22 "path/filepath"22 "path/filepath"
23 "strconv"23 "strconv"
24 "strings"24 "strings"
25+ "sync"
25 "syscall"26 "syscall"
26 "text/tabwriter"27 "text/tabwriter"
27 "time"28 "time"
@@ -31,6 +32,7 @@ import (
31 "github.com/bnema/vev/internal/adapters/config"32 "github.com/bnema/vev/internal/adapters/config"
32 "github.com/bnema/vev/internal/adapters/dgram"33 "github.com/bnema/vev/internal/adapters/dgram"
33 "github.com/bnema/vev/internal/adapters/ipc"34 "github.com/bnema/vev/internal/adapters/ipc"
35+ "github.com/bnema/vev/internal/adapters/observability"
34 "github.com/bnema/vev/internal/adapters/pty"36 "github.com/bnema/vev/internal/adapters/pty"
35 remoteadapter "github.com/bnema/vev/internal/adapters/remote"37 remoteadapter "github.com/bnema/vev/internal/adapters/remote"
36 "github.com/bnema/vev/internal/adapters/shellcmd"38 "github.com/bnema/vev/internal/adapters/shellcmd"
@@ -238,6 +240,106 @@ func dispatch(ctx context.Context, cmd command) error {
238 }240 }
239 }241 }
240 242
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+
241 func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.Logger, io.Closer, error) {343 func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.Logger, io.Closer, error) {
242 return logging.Setup(logging.Config{344 return logging.Setup(logging.Config{
243 Dir: platform.StateDir(),345 Dir: platform.StateDir(),
@@ -278,14 +380,23 @@ func pprofAddrIsLoopback(addr string) bool {
278 // entered by an auto-spawned child): it sets up logging, binds the socket,380 // entered by an auto-spawned child): it sets up logging, binds the socket,
279 // constructs the daemon, and serves until the last session exits or a381 // constructs the daemon, and serves until the last session exits or a
280 // termination signal arrives (graceful shutdown notifies attached clients).382 // termination signal arrives (graceful shutdown notifies attached clients).
281-func runDaemon() error {383+func runDaemon() (retErr error) {
282 log, logCloser, err := configureLogging(logging.Daemon, true)384 log, logCloser, err := configureLogging(logging.Daemon, true)
283 if err != nil {385 if err != nil {
284 return err386 return err
285 }387 }
286 defer func() { _ = logCloser.Close() }()388 defer func() { _ = logCloser.Close() }()
287 389
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))
289 if err != nil {400 if err != nil {
290 log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err)401 log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err)
291 return fmt.Errorf("vev: daemon listen: %w", err)402 return fmt.Errorf("vev: daemon listen: %w", err)
@@ -309,6 +420,9 @@ func runDaemon() error {
309 420
310 log.Info("daemon starting", "socket", ln.Addr())421 log.Info("daemon starting", "socket", ln.Addr())
311 daemonOpts := []daemon.Option(nil)422 daemonOpts := []daemon.Option(nil)
423+ if observer != nil {
424+ daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer))
425+ }
312 configPath := platform.ConfigPath()426 configPath := platform.ConfigPath()
313 cfg, warnings, err := config.Load(configPath)427 cfg, warnings, err := config.Load(configPath)
314 if err != nil {428 if err != nil {
@@ -327,7 +441,6 @@ func runDaemon() error {
327 log.Info("session persistence enabled", "path", storePath)441 log.Info("session persistence enabled", "path", storePath)
328 daemonOpts = append(daemonOpts, daemon.WithStore(store))442 daemonOpts = append(daemonOpts, daemon.WithStore(store))
329 }443 }
330- clk := clock.New()
331 d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...)444 d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...)
332 watchCtx, stopWatch := context.WithCancel(ctx)445 watchCtx, stopWatch := context.WithCancel(ctx)
333 defer stopWatch()446 defer stopWatch()
@@ -350,20 +463,32 @@ func runDaemon() error {
350 // runAttach dials (auto-spawning the daemon if needed) and runs the client463 // runAttach dials (auto-spawning the daemon if needed) and runs the client
351 // attach loop. Logging goes to the shared file: the client must never write464 // attach loop. Logging goes to the shared file: the client must never write
352 // to the console while the terminal is raw.465 // 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) {
354 log, logCloser, err := configureLogging(logging.Client, false)467 log, logCloser, err := configureLogging(logging.Client, false)
355 if err != nil {468 if err != nil {
356 return err469 return err
357 }470 }
358 defer func() { _ = logCloser.Close() }()471 defer func() { _ = logCloser.Close() }()
359 472
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+ }
360 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{481 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{
361- localDialer: defaultLocalDialer,482+ localDialer: func() ports.Dialer {
362- remoteDialerFactory: defaultRemoteDialerFactory(),483+ return localDaemonDialer{dir: ipc.SocketDir(), observer: observer}
484+ },
485+ remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer),
363 selectedRemoteTransport: os.Getenv(envRemoteTransport),486 selectedRemoteTransport: os.Getenv(envRemoteTransport),
364- runClient: client.Run,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 {
365- createDetached: createDetachedLocalSession,488+ return runClientWithRuntimeObserver(ctx, dialer, terminal, behaviorClock, intent, name, remote, clipboard, log, observer)
366- clipboard: clipboard.New(),489+ },
490+ createDetached: createDetachedLocalSession,
491+ clipboard: clipboard.New(),
367 })492 })
368 }493 }
369 494
@@ -499,10 +624,19 @@ func isDaemonVersionDrift(err error) bool {
499 return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal624 return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal
500 }625 }
501 626
502-type localDaemonDialer struct{ dir string }627+type localDaemonDialer struct {
628+ dir string
629+ observer ports.SerializedRuntimeObserver
630+}
503 631
504 func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {632 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)
506 }640 }
507 641
508 func detachedLocalHello(name, cwd string) ports.Hello {642 func detachedLocalHello(name, cwd string) ports.Hello {
@@ -584,7 +718,7 @@ func requestDaemonStop(ctx context.Context) error {
584 718
585 done := make(chan error, 1)719 done := make(chan error, 1)
586 go func() {720 go func() {
587- transport, err := realDial(ipc.SocketDir())721+ transport, err := realDial(ctx, ipc.SocketDir())
588 if err != nil {722 if err != nil {
589 done <- fmt.Errorf("vev: no daemon running")723 done <- fmt.Errorf("vev: no daemon running")
590 return724 return
@@ -621,7 +755,7 @@ func requestDaemonStop(ctx context.Context) error {
621 // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it755 // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it
622 // connects to the per-user daemon (auto-spawning it if needed) and proxies the756 // connects to the per-user daemon (auto-spawning it if needed) and proxies the
623 // framed protocol between process stdio and the daemon socket.757 // framed protocol between process stdio and the daemon socket.
624-func runStdio(ctx context.Context) error {758+func runStdio(ctx context.Context) (retErr error) {
625 log, logCloser, err := configureLogging(logging.Stdio, false)759 log, logCloser, err := configureLogging(logging.Stdio, false)
626 if err != nil {760 if err != nil {
627 return err761 return err
@@ -629,13 +763,22 @@ func runStdio(ctx context.Context) error {
629 defer func() { _ = logCloser.Close() }()763 defer func() { _ = logCloser.Close() }()
630 log.Debug("stdio proxy starting")764 log.Debug("stdio proxy starting")
631 765
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)
633 if err != nil {777 if err != nil {
634 return err778 return err
635 }779 }
636 defer func() { _ = transport.Close() }()780 defer func() { _ = transport.Close() }()
637-781+ stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil, sshstdio.WithRuntimeObserver(observer))
638- stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil)
639 return proxyTransports(ctx, stdio, transport, log)782 return proxyTransports(ctx, stdio, transport, log)
640 }783 }
641 784
@@ -719,7 +862,7 @@ func runUDPBootstrap(ctx context.Context, session string) error {
719 }862 }
720 863
721 // runUDPProxy is the detached long-lived remote-side datagram proxy.864 // 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) {
723 log, logCloser, err := configureLogging(logging.Stdio, false)866 log, logCloser, err := configureLogging(logging.Stdio, false)
724 if err != nil {867 if err != nil {
725 return err868 return err
@@ -727,6 +870,13 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
727 defer func() { _ = logCloser.Close() }()870 defer func() { _ = logCloser.Close() }()
728 log.Debug("udp proxy starting", "session", session)871 log.Debug("udp proxy starting", "session", session)
729 872
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+ }
730 portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange))880 portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange))
731 if err != nil {881 if err != nil {
732 return err882 return err
@@ -740,14 +890,16 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
740 if _, err := rand.Read(key); err != nil {890 if _, err := rand.Read(key); err != nil {
741 return err891 return err
742 }892 }
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)
744 if err != nil {896 if err != nil {
745 return err897 return err
746 }898 }
747 defer func() { _ = daemonTr.Close() }()899 defer func() { _ = daemonTr.Close() }()
748 udpOptions := udpProxyClientTransportOptions900 udpOptions := udpProxyClientTransportOptions
749 udpOptions.Observe = dgram.DiagnosticLogObserver(log)901 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))
751 if err != nil {903 if err != nil {
752 return err904 return err
753 }905 }
@@ -890,8 +1042,8 @@ func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger
890 1042
891 // runList prints the daemon's session listing. With no daemon running, it1043 // runList prints the daemon's session listing. With no daemon running, it
892 // falls back to the persisted stopped-session records.1044 // falls back to the persisted stopped-session records.
893-func runList(_ context.Context) error {1045+func runList(ctx context.Context) error {
894- transport, err := realDial(ipc.SocketDir())1046+ transport, err := realDial(ctx, ipc.SocketDir())
895 if err != nil {1047 if err != nil {
896 records, loadErr := persist.LoadReadOnly(platform.StateDir())1048 records, loadErr := persist.LoadReadOnly(platform.StateDir())
897 if loadErr != nil {1049 if loadErr != nil {
@@ -955,8 +1107,8 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
955 }1107 }
956 1108
957 // runKill asks the daemon to terminate a named session, every session, or the daemon.1109 // 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 {1110+func runKill(ctx context.Context, name string, all, daemon bool) error {
959- transport, err := realDial(ipc.SocketDir())1111+ transport, err := realDial(ctx, ipc.SocketDir())
960 if err != nil {1112 if err != nil {
961 if name != "" && !all && !daemon {1113 if name != "" && !all && !daemon {
962 storePath := persist.StorePath(platform.StateDir())1114 storePath := persist.StorePath(platform.StateDir())
M internal/app/spawn.go +14 -5
@@ -32,7 +32,7 @@ const staleLockAge = 10 * time.Second
32 32
33 // dialFunc dials the daemon socket in dir. Injected so tests can drive the33 // dialFunc dials the daemon socket in dir. Injected so tests can drive the
34 // spawn/backoff logic without a real socket.34 // 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)
36 36
37 // spawnFunc launches a detached daemon process. Injected so tests never37 // spawnFunc launches a detached daemon process. Injected so tests never
38 // re-exec a real binary.38 // re-exec a real binary.
@@ -58,7 +58,10 @@ var defaultBackoff = backoffConfig{
58 // spawner via an mkdir lock (losers simply wait), then retry-dials with58 // spawner via an mkdir lock (losers simply wait), then retry-dials with
59 // backoff until the socket is live or the budget expires.59 // backoff until the socket is live or the budget expires.
60 func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {60 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 {
62 slog.Debug("daemon already reachable", "socket_dir", dir)65 slog.Debug("daemon already reachable", "socket_dir", dir)
63 return t, nil66 return t, nil
64 }67 }
@@ -73,6 +76,9 @@ func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFun
73 // rather than spawning a second daemon.76 // rather than spawning a second daemon.
74 defer release()77 defer release()
75 slog.Info("spawning daemon", "socket_dir", dir)78 slog.Info("spawning daemon", "socket_dir", dir)
79+ if err := ctx.Err(); err != nil {
80+ return nil, err
81+ }
76 if err := spawn(); err != nil {82 if err := spawn(); err != nil {
77 slog.Error("daemon spawn failed", "err", err)83 slog.Error("daemon spawn failed", "err", err)
78 return nil, fmt.Errorf("vev: spawning daemon: %w", err)84 return nil, fmt.Errorf("vev: spawning daemon: %w", err)
@@ -120,7 +126,10 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig
120 deadline := time.Now().Add(cfg.total)126 deadline := time.Now().Add(cfg.total)
121 backoff := cfg.initial127 backoff := cfg.initial
122 for {128 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 {
124 return t, nil133 return t, nil
125 }134 }
126 if !time.Now().Before(deadline) {135 if !time.Now().Before(deadline) {
@@ -139,8 +148,8 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig
139 }148 }
140 149
141 // realDial is the production dialer.150 // realDial is the production dialer.
142-func realDial(dir string) (ports.Transport, error) {151+func realDial(ctx context.Context, dir string) (ports.Transport, error) {
143- return ipc.Dial(dir)152+ return ipc.DialContext(ctx, dir)
144 }153 }
145 154
146 // realSpawn re-execs this binary as a detached daemon: a new session155 // 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) {
91 func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {91 func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {
92 want := portsmocks.NewMockTransport(t)92 want := portsmocks.NewMockTransport(t)
93 var calls atomic.Int3293 var calls atomic.Int32
94- dial := func(string) (ports.Transport, error) {94+ dial := func(context.Context, string) (ports.Transport, error) {
95 if calls.Add(1) <= 3 {95 if calls.Add(1) <= 3 {
96 return nil, errDialFailed96 return nil, errDialFailed
97 }97 }
@@ -111,7 +111,7 @@ func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {
111 }111 }
112 112
113 func TestRetryDialPermanentFailure(t *testing.T) {113 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 }
115 _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff)115 _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff)
116 if !errors.Is(err, ErrDaemonUnreachable) {116 if !errors.Is(err, ErrDaemonUnreachable) {
117 t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err)117 t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err)
@@ -121,18 +121,65 @@ func TestRetryDialPermanentFailure(t *testing.T) {
121 func TestRetryDialContextCancelled(t *testing.T) {121 func TestRetryDialContextCancelled(t *testing.T) {
122 ctx, cancel := context.WithCancel(context.Background())122 ctx, cancel := context.WithCancel(context.Background())
123 cancel()123 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+ }
125 _, err := retryDial(ctx, t.TempDir(), dial, fastBackoff)129 _, err := retryDial(ctx, t.TempDir(), dial, fastBackoff)
126 if !errors.Is(err, context.Canceled) {130 if !errors.Is(err, context.Canceled) {
127 t.Fatalf("retryDial error = %v, want context.Canceled", err)131 t.Fatalf("retryDial error = %v, want context.Canceled", err)
128 }132 }
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+ }
129 }176 }
130 177
131 func TestEnsureDaemonSpawnsThenDials(t *testing.T) {178 func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
132 dir := filepath.Join(t.TempDir(), "vev")179 dir := filepath.Join(t.TempDir(), "vev")
133 want := portsmocks.NewMockTransport(t)180 want := portsmocks.NewMockTransport(t)
134 var dialCalls, spawnCalls atomic.Int32181 var dialCalls, spawnCalls atomic.Int32
135- dial := func(string) (ports.Transport, error) {182+ dial := func(context.Context, string) (ports.Transport, error) {
136 // First (pre-spawn) dial fails; the post-spawn retry succeeds.183 // First (pre-spawn) dial fails; the post-spawn retry succeeds.
137 if dialCalls.Add(1) == 1 {184 if dialCalls.Add(1) == 1 {
138 return nil, errDialFailed185 return nil, errDialFailed
@@ -163,7 +210,7 @@ func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
163 func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {210 func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {
164 want := portsmocks.NewMockTransport(t)211 want := portsmocks.NewMockTransport(t)
165 var spawnCalls atomic.Int32212 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 }
167 spawn := func() error { spawnCalls.Add(1); return nil }214 spawn := func() error { spawnCalls.Add(1); return nil }
168 215
169 got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff)216 got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff)
@@ -180,7 +227,7 @@ func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {
180 227
181 func TestEnsureDaemonSpawnFailure(t *testing.T) {228 func TestEnsureDaemonSpawnFailure(t *testing.T) {
182 spawnErr := errors.New("exec boom")229 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 }
184 spawn := func() error { return spawnErr }231 spawn := func() error { return spawnErr }
185 232
186 _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff)233 _, 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 {
499 }499 }
500 500
501 // Open provides a mock function for the type MockPTYFactory501 // 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) {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(cmd, args, env, dir, sz)503+ ret := _mock.Called(ctx, cmd, args, env, dir, sz)
504 504
505 if len(ret) == 0 {505 if len(ret) == 0 {
506 panic("no return value specified for Open")506 panic("no return value specified for Open")
@@ -508,18 +508,18 @@ func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir s
508 508
509 var r0 ports.PTY509 var r0 ports.PTY
510 var r1 error510 var r1 error
511- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {511+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {
512- return returnFunc(cmd, args, env, dir, sz)512+ return returnFunc(ctx, cmd, args, env, dir, sz)
513 }513 }
514- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) ports.PTY); ok {514+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) ports.PTY); ok {
515- r0 = returnFunc(cmd, args, env, dir, sz)515+ r0 = returnFunc(ctx, cmd, args, env, dir, sz)
516 } else {516 } else {
517 if ret.Get(0) != nil {517 if ret.Get(0) != nil {
518 r0 = ret.Get(0).(ports.PTY)518 r0 = ret.Get(0).(ports.PTY)
519 }519 }
520 }520 }
521- if returnFunc, ok := ret.Get(1).(func(string, []string, []string, string, domain.Size) error); ok {521+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, []string, string, domain.Size) error); ok {
522- r1 = returnFunc(cmd, args, env, dir, sz)522+ r1 = returnFunc(ctx, cmd, args, env, dir, sz)
523 } else {523 } else {
524 r1 = ret.Error(1)524 r1 = ret.Error(1)
525 }525 }
@@ -532,36 +532,41 @@ type MockPTYFactory_Open_Call struct {
532 }532 }
533 533
534 // Open is a helper method to define mock.On call534 // Open is a helper method to define mock.On call
535+// - ctx context.Context
535 // - cmd string536 // - cmd string
536 // - args []string537 // - args []string
537 // - env []string538 // - env []string
538 // - dir string539 // - dir string
539 // - sz domain.Size540 // - sz domain.Size
540-func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {541+func (_e *MockPTYFactory_Expecter) Open(ctx any, 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)}542+ return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", ctx, cmd, args, env, dir, sz)}
542 }543 }
543 544
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 {
545 _c.Call.Run(func(args mock.Arguments) {546 _c.Call.Run(func(args mock.Arguments) {
546- var arg0 string547+ var arg0 context.Context
547 if args[0] != nil {548 if args[0] != nil {
548- arg0 = args[0].(string)549+ arg0 = args[0].(context.Context)
549 }550 }
550- var arg1 []string551+ var arg1 string
551 if args[1] != nil {552 if args[1] != nil {
552- arg1 = args[1].([]string)553+ arg1 = args[1].(string)
553 }554 }
554 var arg2 []string555 var arg2 []string
555 if args[2] != nil {556 if args[2] != nil {
556 arg2 = args[2].([]string)557 arg2 = args[2].([]string)
557 }558 }
558- var arg3 string559+ var arg3 []string
559 if args[3] != nil {560 if args[3] != nil {
560- arg3 = args[3].(string)561+ arg3 = args[3].([]string)
561 }562 }
562- var arg4 domain.Size563+ var arg4 string
563 if args[4] != nil {564 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)
565 }570 }
566 run(571 run(
567 arg0,572 arg0,
@@ -569,6 +574,7 @@ func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env
569 arg2,574 arg2,
570 arg3,575 arg3,
571 arg4,576 arg4,
577+ arg5,
572 )578 )
573 })579 })
574 return _c580 return _c
@@ -579,7 +585,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac
579 return _c585 return _c
580 }586 }
581 587
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 {
583 _c.Call.Return(run)589 _c.Call.Return(run)
584 return _c590 return _c
585 }591 }
@@ -3123,3 +3129,70 @@ func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(ta
3123 _c.Call.Return(run)3129 _c.Call.Return(run)
3124 return _c3130 return _c
3125 }3131 }
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 {
24 24
25 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.25 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.
26 type PTYFactory interface {26 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)
28 }30 }
29 31
30 // Terminal is the CLIENT-side controlling terminal.32 // Terminal is the CLIENT-side controlling terminal.
@@ -38,7 +40,9 @@ type Terminal interface {
38 Flush() error40 Flush() error
39 }41 }
40 42
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.
42 type Transport interface {46 type Transport interface {
43 Send(Frame) error47 Send(Frame) error
44 Recv() (Frame, error) // blocking; io.EOF on close48 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
102 // goroutine, decoupling the input/resize pumps from transport back-pressure.102 // goroutine, decoupling the input/resize pumps from transport back-pressure.
103 const sendQueueDepth = 64103 const sendQueueDepth = 64
104 104
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+
105 var reconnectSleep = sleepReconnect109 var reconnectSleep = sleepReconnect
106 var reconnectSleepWithResize = sleepReconnectWithResizeEvents110 var reconnectSleepWithResize = sleepReconnectWithResizeEvents
107 111
@@ -120,7 +124,29 @@ const (
120 // ClipboardReader configured (clipboard non-nil) — local attaches forward124 // ClipboardReader configured (clipboard non-nil) — local attaches forward
121 // 0x16 untouched so the locally running agent's own clipboard handling125 // 0x16 untouched so the locally running agent's own clipboard handling
122 // applies.126 // 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) {
124 if log == nil {150 if log == nil {
125 log = slog.Default()151 log = slog.Default()
126 }152 }
@@ -244,11 +270,13 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
244 if reporter, ok := transport.(ports.LinkStateReporter); ok {270 if reporter, ok := transport.(ports.LinkStateReporter); ok {
245 linkEvents = reporter.LinkEvents()271 linkEvents = reporter.LinkEvents()
246 }272 }
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)
248 if result.welcomed {274 if result.welcomed {
249 backoff = defaultReconnectBackoff.initial275 backoff = defaultReconnectBackoff.initial
250 }276 }
251- _ = transport.Close()277+ if !result.transportClosed {
278+ _ = transport.Close()
279+ }
252 if result.resumeToken != 0 {280 if result.resumeToken != 0 {
253 resumeToken = result.resumeToken281 resumeToken = result.resumeToken
254 }282 }
@@ -311,8 +339,14 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
311 // disappears, or the context is cancelled.339 // disappears, or the context is cancelled.
312 //340 //
313 // It is kept as a compatibility wrapper for callers that still own dialing.341 // 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 {342+func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, options ...Option) error {
315- return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default())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)
316 }350 }
317 351
318 type singleTransportDialer struct{ transport ports.Transport }352 type singleTransportDialer struct{ transport ports.Transport }
@@ -322,10 +356,52 @@ func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) {
322 }356 }
323 357
324 type attachResult struct {358 type attachResult struct {
325- resumeToken uint64359+ resumeToken uint64
326- sessionName string360+ sessionName string
327- welcomed bool361+ welcomed bool
328- err error362+ 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+ }
329 }405 }
330 406
331 // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.407 // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
@@ -346,7 +422,11 @@ func requestedOutputWindow(transport ports.Transport) uint8 {
346 return 8422 return 8
347 }423 }
348 424
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+ }
350 // 1. Handshake: send Hello with our size and TERM.430 // 1. Handshake: send Hello with our size and TERM.
351 size, err := term.Size()431 size, err := term.Size()
352 if err != nil {432 if err != nil {
@@ -371,15 +451,22 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
371 TrueColor: trueColor,451 TrueColor: trueColor,
372 MaxOutputInFlight: requestedOutputWindow(transport),452 MaxOutputInFlight: requestedOutputWindow(transport),
373 }453 }
374- if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {454+ if closed, err := boundedPreWelcome(ctx, clk, transport, func() error {
375- return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}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)}
376 }458 }
377 ms.helloSent = true459 ms.helloSent = true
378 460
379 // 2. Await Welcome or a typed rejection.461 // 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+ })
381 if err != nil {468 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)}
383 }470 }
384 result := func(welcomed bool, err error) attachResult {471 result := func(welcomed bool, err error) attachResult {
385 return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err}472 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
501 if ferr := term.Flush(); ferr != nil {588 if ferr := term.Flush(); ferr != nil {
502 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))589 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
503 }590 }
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+ }
504 if o.NewStateNum != 0 {595 if o.NewStateNum != 0 {
505 ackQueue.offer(o.NewStateNum)596 ackQueue.offer(o.NewStateNum)
506 }597 }
M internal/usecase/client/client_test.go +114 -0
@@ -155,6 +155,120 @@ func isType(typ ports.MsgType) any {
155 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })155 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })
156 }156 }
157 157
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+
158 func TestAttachHelloIncludesTrueColor(t *testing.T) {272 func TestAttachHelloIncludesTrueColor(t *testing.T) {
159 t.Setenv("TERM", "xterm-256color")273 t.Setenv("TERM", "xterm-256color")
160 t.Setenv("COLORTERM", "truecolor")274 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
179 return hello179 return hello
180 }180 }
181 181
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+
182 func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {194 func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {
183 term := newReconnectToastTerminalHarness(t)195 term := newReconnectToastTerminalHarness(t)
184 defer term.closeInput()196 defer term.closeInput()
@@ -190,7 +202,7 @@ func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {
190 resultCh := make(chan attachResult, 1)202 resultCh := make(chan attachResult, 1)
191 ms := milestones{}203 ms := milestones{}
192 go func() {204 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 {
194 _, err := term.term.EnterRaw()206 _, err := term.term.EnterRaw()
195 return err207 return err
196 }, func() { clears <- struct{}{} }, func(stage reconnectStage) {208 }, func() { clears <- struct{}{} }, func(stage reconnectStage) {
@@ -229,7 +241,7 @@ func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) {
229 resultCh := make(chan attachResult, 1)241 resultCh := make(chan attachResult, 1)
230 ms := milestones{}242 ms := milestones{}
231 go func() {243 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 {
233 _, err := term.term.EnterRaw()245 _, err := term.term.EnterRaw()
234 return err246 return err
235 }, func() {}, func(stage reconnectStage) {247 }, func() {}, func(stage reconnectStage) {
@@ -400,7 +412,7 @@ func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) {
400 }}412 }}
401 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}413 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
402 414
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))
404 require.ErrorIs(t, err, context.Canceled)416 require.ErrorIs(t, err, context.Canceled)
405 require.True(t, out.failed)417 require.True(t, out.failed)
406 assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)))418 assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)))
@@ -435,7 +447,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
435 }}447 }}
436 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}448 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
437 449
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))
439 require.NoError(t, err)451 require.NoError(t, err)
440 require.True(t, tr1.closed)452 require.True(t, tr1.closed)
441 require.True(t, tr2.closed)453 require.True(t, tr2.closed)
@@ -479,7 +491,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
479 }}491 }}
480 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}492 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
481 493
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))
483 require.NoError(t, err)495 require.NoError(t, err)
484 require.Equal(t, 2, dialer.calls)496 require.Equal(t, 2, dialer.calls)
485 497
@@ -564,7 +576,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
564 dialer := portsmocks.NewMockDialer(t)576 dialer := portsmocks.NewMockDialer(t)
565 tt.configure(t, dialer)577 tt.configure(t, dialer)
566 578
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))
568 tt.wantErr(t, err)580 tt.wantErr(t, err)
569 out := term.out.String()581 out := term.out.String()
570 require.Contains(t, out, reconnectToastMessage)582 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 (
7 "unicode"7 "unicode"
8 8
9 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/bnema/vev/pkg/vt"
10 )11 )
11 12
12 // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits13 // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits
@@ -17,45 +18,49 @@ import (
17 const OSC52MaxPayloadBytes = 75_00018 const OSC52MaxPayloadBytes = 75_000
18 19
19 // Snapshot is the immutable scrollback-mode document: history followed by the20 // Snapshot is the immutable scrollback-mode document: history followed by the
20-// visible screen. History rows are immutable by Scrollback's append contract;21+// visible screen. VT HistoryView retains immutable chunks; the visible frame is
21-// the visible frame is cloned once when the snapshot is constructed.22+// cloned once when the snapshot is constructed.
22 type Snapshot struct {23 type Snapshot struct {
23- history HistoryView24+ history vt.HistoryView
24 screen renderer.Frame25 screen renderer.Frame
25 Width int26 Width int
26 Height int27 Height int
27 }28 }
28 29
29-// NewSnapshot freezes the current scrollback view and clones the visible screen.30+// NewSnapshot seals the current VT history tail, then freezes that immutable
30-func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {31+// view and clones the visible screen. Sealing prevents repeated copy entry from
31- var history HistoryView32+// cloning every row in a partial tail.
32- if sb != nil {33+func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {
33- history = sb.View()34+ var history vt.HistoryView
35+ if historySource != nil {
36+ history = historySource.SealAndView()
34 }37 }
35 return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}38 return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}
36 }39 }
37 40
38 // NewSnapshotFromRows constructs a snapshot that owns explicit caller rows.41 // NewSnapshotFromRows constructs a snapshot that owns explicit caller rows.
39 // It is intended for callers that already have a complete document rather than42 // 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.
41 func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {45 func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
42- owned := make([][]renderer.Cell, len(rows))46+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256})
43- for i, row := range rows {47+ for _, row := range rows {
44- owned[i] = append([]renderer.Cell(nil), row...)48+ history.Append(row)
45 }49 }
46- return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height}50+ return Snapshot{history: history.SealAndView(), Width: width, Height: height}
47 }51 }
48 52
49 // Len returns the number of document rows.53 // Len returns the number of document rows.
50 func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }54 func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
51 55
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.
53 func (s Snapshot) Row(i int) []renderer.Cell {58 func (s Snapshot) Row(i int) []renderer.Cell {
54 if i < 0 {59 if i < 0 {
55 return nil60 return nil
56 }61 }
57 if i < s.history.Len() {62 if i < s.history.Len() {
58- return s.history.Row(i)63+ return s.history.BorrowedRow(i)
59 }64 }
60 i -= s.history.Len()65 i -= s.history.Len()
61 if i >= s.screen.Height {66 if i >= s.screen.Height {
@@ -64,6 +69,27 @@ func (s Snapshot) Row(i int) []renderer.Cell {
64 return s.screen.Row(i)69 return s.screen.Row(i)
65 }70 }
66 71
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+
67 // Mode stores per-client scrollback viewport and line-selection state.93 // Mode stores per-client scrollback viewport and line-selection state.
68 type SearchMatch struct {94 type SearchMatch struct {
69 Row int95 Row int
@@ -102,8 +128,7 @@ func FindMatches(s Snapshot, query string) []SearchMatch {
102 }128 }
103 needle := lowerRunes(query)129 needle := lowerRunes(query)
104 matches := make([]SearchMatch, 0)130 matches := make([]SearchMatch, 0)
105- for row := range s.Len() {131+ s.rangeRows(func(row int, cells []renderer.Cell) bool {
106- cells := s.Row(row)
107 haystack, cellIndexes := searchableCells(cells)132 haystack, cellIndexes := searchableCells(cells)
108 var text string133 var text string
109 for start := 0; start+len(needle) <= len(haystack); {134 for start := 0; start+len(needle) <= len(haystack); {
@@ -122,7 +147,8 @@ func FindMatches(s Snapshot, query string) []SearchMatch {
122 matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text})147 matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text})
123 start += len(needle)148 start += len(needle)
124 }149 }
125- }150+ return true
151+ })
126 return matches152 return matches
127 }153 }
128 154
M internal/usecase/copy/mode_test.go +128 -3
@@ -7,11 +7,30 @@ import (
7 "testing"7 "testing"
8 8
9 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/bnema/vev/pkg/vt"
10 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
11 )12 )
12 13
13 var benchmarkSnapshotSink Snapshot14 var benchmarkSnapshotSink Snapshot
14 15
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+
15 func snapshot(lines []string, height int) Snapshot {34 func snapshot(lines []string, height int) Snapshot {
16 rows := make([][]renderer.Cell, len(lines))35 rows := make([][]renderer.Cell, len(lines))
17 for i, line := range lines {36 for i, line := range lines {
@@ -355,6 +374,24 @@ func TestFindMatchesKeepsRuneAndCellIndexesAlignedWhenLowercasing(t *testing.T)
355 require.Equal(t, 2, matches[0].End)374 require.Equal(t, 2, matches[0].End)
356 }375 }
357 376
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+
358 func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) {395 func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) {
359 row := []renderer.Cell{396 row := []renderer.Cell{
360 {Rune: '界'},397 {Rune: '界'},
@@ -478,6 +515,94 @@ func TestOSC52FromBase64(t *testing.T) {
478 }515 }
479 }516 }
480 517
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+
481 func BenchmarkNewSnapshot10KRows(b *testing.B) {606 func BenchmarkNewSnapshot10KRows(b *testing.B) {
482 const (607 const (
483 width = 120608 width = 120
@@ -485,9 +610,9 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) {
485 historyRows = 10_000610 historyRows = 10_000
486 )611 )
487 612
488- scrollback := NewScrollback(historyRows)613+ history := newHistory(historyRows)
489 for range historyRows {614 for range historyRows {
490- scrollback.Append(row(strings.Repeat("x", width)))615+ history.Append(row(strings.Repeat("x", width)))
491 }616 }
492 screen := renderer.NewFrame(width, height)617 screen := renderer.NewFrame(width, height)
493 for y := range height {618 for y := range height {
@@ -497,7 +622,7 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) {
497 b.ReportAllocs()622 b.ReportAllocs()
498 b.ResetTimer()623 b.ResetTimer()
499 for b.Loop() {624 for b.Loop() {
500- benchmarkSnapshotSink = NewSnapshot(scrollback, screen)625+ benchmarkSnapshotSink = NewSnapshot(history, screen)
501 }626 }
502 }627 }
503 628
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) {
49 if idx, ok := oldestAttentionTab(sess); ok {49 if idx, ok := oldestAttentionTab(sess); ok {
50 if sess.switchTab(idx) {50 if sess.switchTab(idx) {
51 d.activateTab(sess, sess.activeTab())51 d.activateTab(sess, sess.activeTab())
52- d.paint(sess, ac, true)52+ d.invalidateRender(sess, ac, true, "attention.go")
53 }53 }
54 return54 return
55 }55 }
@@ -119,7 +119,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) {
119 ac := sess.client119 ac := sess.client
120 sess.mu.Unlock()120 sess.mu.Unlock()
121 if ac != nil {121 if ac != nil {
122- d.paint(sess, ac, false)122+ d.invalidateRender(sess, ac, false, "attention.go")
123 }123 }
124 }124 }
125 125
M internal/usecase/daemon/attention_test.go +9 -9
@@ -53,7 +53,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
53 d.setAttentionFrame(0)53 d.setAttentionFrame(0)
54 d.noteAttention(sess, sess.tabs[0])54 d.noteAttention(sess, sess.tabs[0])
55 55
56- d.paint(sess, ac, true)56+ d.paint(sess, ac, true, nil)
57 data := mustOutputData(t, sends)57 data := mustOutputData(t, sends)
58 require.NotContains(t, string(data), string(ui.AttentionGlyph))58 require.NotContains(t, string(data), string(ui.AttentionGlyph))
59 59
@@ -62,7 +62,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
62 sess.mu.Unlock()62 sess.mu.Unlock()
63 63
64 d.setAttentionFrame(1)64 d.setAttentionFrame(1)
65- d.paint(sess, ac, true)65+ d.paint(sess, ac, true, nil)
66 data = mustOutputData(t, sends)66 data = mustOutputData(t, sends)
67 require.Contains(t, string(data), string(ui.AttentionGlyph))67 require.Contains(t, string(data), string(ui.AttentionGlyph))
68 68
@@ -97,7 +97,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.
97 return sess.tabs[0].attention && !sess.tabs[0].attentionAt.IsZero()97 return sess.tabs[0].attention && !sess.tabs[0].attentionAt.IsZero()
98 }, 2*time.Second, 5*time.Millisecond)98 }, 2*time.Second, 5*time.Millisecond)
99 99
100- d.paint(sess, ac, true)100+ d.paint(sess, ac, true, nil)
101 data := mustOutputData(t, sends)101 data := mustOutputData(t, sends)
102 require.Contains(t, string(data), string(ui.AttentionGlyph))102 require.Contains(t, string(data), string(ui.AttentionGlyph))
103 103
@@ -242,7 +242,7 @@ func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
242 sess.tabs[1].attentionAt = time.Unix(2, 0)242 sess.tabs[1].attentionAt = time.Unix(2, 0)
243 sess.mu.Unlock()243 sess.mu.Unlock()
244 244
245- d.paint(sess, ac, true)245+ d.paint(sess, ac, true, nil)
246 _ = mustOutputData(t, sends)246 _ = mustOutputData(t, sends)
247 // The deferred repaint (from ackAttention) targets all attached clients,247 // The deferred repaint (from ackAttention) targets all attached clients,
248 // including this one, but the bars it would draw are already exactly what248 // including this one, but the bars it would draw are already exactly what
@@ -272,7 +272,7 @@ func TestPaintPreservesBackgroundAttention(t *testing.T) {
272 sess.mu.Unlock()272 sess.mu.Unlock()
273 d.setAttentionFrame(1)273 d.setAttentionFrame(1)
274 274
275- d.paint(sess, ac, true)275+ d.paint(sess, ac, true, nil)
276 data := mustOutputData(t, sends)276 data := mustOutputData(t, sends)
277 277
278 require.Contains(t, string(data), "")278 require.Contains(t, string(data), "")
@@ -292,7 +292,7 @@ func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) {
292 sess.mu.Unlock()292 sess.mu.Unlock()
293 293
294 require.True(t, sess.switchTab(1))294 require.True(t, sess.switchTab(1))
295- d.paint(sess, ac, true)295+ d.paint(sess, ac, true, nil)
296 data := mustOutputData(t, sends)296 data := mustOutputData(t, sends)
297 // As above: the deferred all-clients repaint has nothing new to say about297 // As above: the deferred all-clients repaint has nothing new to say about
298 // this client's bars, so no second frame follows.298 // this client's bars, so no second frame follows.
@@ -321,7 +321,7 @@ func TestCloseRingingTabClearsClientBar(t *testing.T) {
321 sess.mu.Unlock()321 sess.mu.Unlock()
322 d.setAttentionFrame(1)322 d.setAttentionFrame(1)
323 323
324- d.paint(sess, ac, true)324+ d.paint(sess, ac, true, nil)
325 before := mustOutputData(t, sends)325 before := mustOutputData(t, sends)
326 require.Contains(t, string(before), "")326 require.Contains(t, string(before), "")
327 327
@@ -346,7 +346,7 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
346 sess.tabs[1].attentionAt = time.Unix(1, 0)346 sess.tabs[1].attentionAt = time.Unix(1, 0)
347 sess.mu.Unlock()347 sess.mu.Unlock()
348 348
349- d.paint(sess, ac, true)349+ d.paint(sess, ac, true, nil)
350 full := mustOutputData(t, sends)350 full := mustOutputData(t, sends)
351 require.Contains(t, string(full), "MIDSCREENMARKER")351 require.Contains(t, string(full), "MIDSCREENMARKER")
352 352
@@ -428,7 +428,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
428 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)428 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
429 d.sessions[sessB.id] = sessB429 d.sessions[sessB.id] = sessB
430 430
431- d.paint(sessB, acB, true)431+ d.paint(sessB, acB, true, nil)
432 before := mustOutputData(t, sendsB)432 before := mustOutputData(t, sendsB)
433 require.Contains(t, string(before), string(ui.AttentionGlyph))433 require.Contains(t, string(before), string(ui.AttentionGlyph))
434 434
M internal/usecase/daemon/bar_refresh.go +7 -8
@@ -306,12 +306,11 @@ func (d *Daemon) pokeSessionRender(sess *session) {
306 if sess == nil {306 if sess == nil {
307 return307 return
308 }308 }
309- if tb := sess.activeTab(); tb != nil {309+ sess.mu.Lock()
310- tb.mu.Lock()310+ ac := sess.client
311- p := tb.focusedPane()311+ sess.mu.Unlock()
312- tb.mu.Unlock()312+ // A headless session can still supply a picker preview to an attachment
313- if p != nil {313+ // owned by another session. Its coordinator fans this wake out to those
314- signal(p.dirty)314+ // preview subscribers even though no primary attachment is bound.
315- }315+ d.invalidateRender(sess, ac, false, "bar_refresh.go")
316- }
317 }316 }
M internal/usecase/daemon/bar_refresh_test.go +12 -0
@@ -277,6 +277,18 @@ func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
277 require.Empty(t, state.bottomRight)277 require.Empty(t, state.bottomRight)
278 }278 }
279 279
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+
280 func TestBarScriptRefreshIsPerSession(t *testing.T) {292 func TestBarScriptRefreshIsPerSession(t *testing.T) {
281 r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}293 r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}
282 d := newBarRefreshTestDaemon(r, time.Second)294 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: the1+// Package daemon holds vev's server-side session multiplexer use case.
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 //2 //
5-// Concurrency model (sessions own one or more PTY-backed tabs):3+// Lock ordering: never acquire sendMu or session/daemon locks while holding a
6-//4+// pane lock. Coordinator callbacks and timer methods run without coordinator.mu;
7-// - Serve runs the accept loop. Each accepted connection is handled by its5+// see the lock-specific comments on attachedClient and renderCoordinator.
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.
25 package daemon6 package daemon
26 7
27 import (8 import (
@@ -37,28 +18,40 @@ import (
37 themeui "github.com/bnema/vev/internal/usecase/theme"18 themeui "github.com/bnema/vev/internal/usecase/theme"
38 )19 )
39 20
21+type transportSnapshot struct {
22+ transport ports.Transport
23+ incarnation uint64
24+}
25+
40 type attachedClient struct {26 type attachedClient struct {
41- tr ports.Transport27+ tr ports.Transport
42- output *outputStateStream28+ transportIncarnation uint64
43- overlays *overlayRuntime29+ output *outputStateStream
44- clientID [16]byte30+ overlays *overlayRuntime
45- resumeCapable bool31+ overlayOnce sync.Once
46- resumeToken uint6432+ clientID [16]byte
47- parked bool33+ resumeCapable bool
48- echoAck atomic.Uint6434+ resumeToken uint64
49- bars barCache // only touched while sendMu is held35+ parked bool
50- composed composedFrameCache // only touched while sendMu is held36+ echoAck atomic.Uint64
51- resizePaint pendingByteTimer // guarded by sendMu37+ // pipelineCache is the last successfully emitted composition. pipelineScratch
52- resizePaintGeneration uint64 // guarded by sendMu38+ // is its attachment-owned alternate buffer; both are only touched under
53- resizePaintPending bool // guarded by sendMu39+ // sendMu and must never share mutable backing storage.
54- size domain.Size40+ pipelineCache composeCacheInput
55- keys *keys.Router41+ pipelineScratch composeCacheInput
56- sess Guarded[*session]42+ renderScratch renderCaptureScratch // only touched while sendMu is held
57- mouseScan mouse.Scanner43+ // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
58- themeMu sync.Mutex44+ // snapshots cannot leak when an attachment switches tabs or sessions.
59- theme themeui.Theme45+ captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
60- clientTheme themeui.Theme46+ size domain.Size
61- lastCursor cursorOut47+ 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
62 // previousSession is guarded independently. It is retained through temporary55 // previousSession is guarded independently. It is retained through temporary
63 // setSession(nil) hand-offs and cleared only on terminal teardown.56 // setSession(nil) hand-offs and cleared only on terminal teardown.
64 previousSession Guarded[*session]57 previousSession Guarded[*session]
@@ -75,6 +68,8 @@ type cursorOut struct {
75 hasStyle bool68 hasStyle bool
76 }69 }
77 70
71+// pendingByteTimer is retained for overlay input timers. Resize timing belongs
72+// exclusively to renderCoordinator.
78 type pendingByteTimer struct {73 type pendingByteTimer struct {
79 timer ports.Timer74 timer ports.Timer
80 done chan struct{}75 done chan struct{}
@@ -104,20 +99,8 @@ func (p *pendingByteTimer) stop() {
104 }99 }
105 }100 }
106 101
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-
117 func (ac *attachedClient) initOverlays() {102 func (ac *attachedClient) initOverlays() {
118- if ac.overlays == nil {103+ ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
119- ac.overlays = newOverlayRuntime(ac)
120- }
121 }104 }
122 105
123 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }106 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
@@ -130,6 +113,30 @@ func (ac *attachedClient) clearPreviousSession() {
130 }113 }
131 }114 }
132 115
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+
133 func (ac *attachedClient) getTheme() themeui.Theme {140 func (ac *attachedClient) getTheme() themeui.Theme {
134 ac.themeMu.Lock()141 ac.themeMu.Lock()
135 defer ac.themeMu.Unlock()142 defer ac.themeMu.Unlock()
@@ -163,9 +170,41 @@ func (ac *attachedClient) transport() ports.Transport {
163 func (ac *attachedClient) replaceTransport(tr ports.Transport) {170 func (ac *attachedClient) replaceTransport(tr ports.Transport) {
164 ac.linkMu.Lock()171 ac.linkMu.Lock()
165 ac.tr = tr172 ac.tr = tr
173+ ac.transportIncarnation++
166 ac.linkMu.Unlock()174 ac.linkMu.Unlock()
167 }175 }
168 176
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+
169 func (ac *attachedClient) closeTransport() error {208 func (ac *attachedClient) closeTransport() error {
170 tr := ac.transport()209 tr := ac.transport()
171 if tr == nil {210 if tr == nil {
@@ -197,23 +236,6 @@ func (ac *attachedClient) ackOutputState(state uint64) {
197 ac.output.ack(state)236 ac.output.ack(state)
198 }237 }
199 238
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-
217 // send serialises a frame onto the client's transport.239 // send serialises a frame onto the client's transport.
218 func (ac *attachedClient) send(f ports.Frame) error {240 func (ac *attachedClient) send(f ports.Frame) error {
219 _, err := ac.sendTransport(f)241 _, err := ac.sendTransport(f)
@@ -230,6 +252,22 @@ func (ac *attachedClient) sendTransport(f ports.Frame) (ports.Transport, error)
230 return tr, tr.Send(f)252 return tr, tr.Send(f)
231 }253 }
232 254
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+
233 // boundedSend sends f to ac with a deadline watchdog: if the send (including271 // boundedSend sends f to ac with a deadline watchdog: if the send (including
234 // waiting on sendMu behind a wedged paint) does not complete within272 // waiting on sendMu behind a wedged paint) does not complete within
235 // detachNotifyTimeout, the transport is force-closed, failing the in-flight273 // detachNotifyTimeout, the transport is force-closed, failing the in-flight
@@ -240,10 +278,12 @@ func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
240 }278 }
241 279
242 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {280 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
243- tr, err := d.boundedSendWith(func(capture func(ports.Transport)) error {281+ expected := ac.transportSnapshot()
244- tr, err := ac.sendTransport(f)282+ if expected.transport == nil {
245- capture(tr)283+ return errors.New("client transport is nil")
246- return err284+ }
285+ tr, err := d.boundedSendWith(expected.transport, func() error {
286+ return ac.sendExpectedTransport(expected, f)
247 })287 })
248 if err != nil && errors.Is(err, errSendTimedOut) {288 if err != nil && errors.Is(err, errSendTimedOut) {
249 _ = ac.closeCapturedTransport(tr)289 _ = ac.closeCapturedTransport(tr)
@@ -258,68 +298,50 @@ func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
258 298
259 func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {299 func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {
260 frame := ac.output.sideEffect(b, ac.echoAck.Load())300 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+ }
261 ac.sendMu.Lock()305 ac.sendMu.Lock()
262- tr := ac.transport()306+ if !ac.transportSnapshotCurrent(expected) {
263- if tr == nil {
264 ac.sendMu.Unlock()307 ac.sendMu.Unlock()
265- return nil, errors.New("client transport is nil")308+ return expected.transport, errTransportReplaced
266 }309 }
267- if owned, ok := tr.(ports.OwnedSynchronousTransport); ok {310+ if owned, ok := expected.transport.(ports.OwnedSynchronousTransport); ok {
268 err := owned.SendSynchronous(frame)311 err := owned.SendSynchronous(frame)
269 ac.sendMu.Unlock()312 ac.sendMu.Unlock()
270- return tr, err313+ return expected.transport, err
271 }314 }
272 ac.sendMu.Unlock()315 ac.sendMu.Unlock()
273- return d.boundedSendWith(func(capture func(ports.Transport)) error {316+ return d.boundedSendWith(expected.transport, func() error {
274- ac.sendMu.Lock()317+ return ac.sendExpectedTransport(expected, frame)
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)
282 })318 })
283 }319 }
284 320
285 var errSendTimedOut = errors.New("send timed out")321 var errSendTimedOut = errors.New("send timed out")
286 322
287-func (d *Daemon) boundedSendWith(send func(capture func(ports.Transport)) error) (ports.Transport, error) {323+func (d *Daemon) boundedSendWith(tr ports.Transport, send func() error) (ports.Transport, error) {
288- return d.boundedSendWithTimeout(detachNotifyTimeout, send)324+ return d.boundedSendWithTimeout(detachNotifyTimeout, tr, send)
289 }325 }
290 326
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) {
292 timer := d.clock.NewTimer(timeout)328 timer := d.clock.NewTimer(timeout)
293 result := make(chan error, 1)329 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- }
308 go func() {330 go func() {
309- result <- send(capture)331+ result <- send()
310 }()332 }()
311 select {333 select {
312 case err := <-result:334 case err := <-result:
313 timer.Stop()335 timer.Stop()
314- return capturedTransport(), err336+ return tr, err
315 case <-timer.C():337 case <-timer.C():
316 select {338 select {
317 case err := <-result:339 case err := <-result:
318- return capturedTransport(), err340+ return tr, err
319 default:341 default:
320 }342 }
321 d.log.Warn("bounded send timed out; force closing client transport")343 d.log.Warn("bounded send timed out; force closing client transport")
322- return capturedTransport(), errSendTimedOut344+ return tr, errSendTimedOut
323 }345 }
324 }346 }
325 347
@@ -376,6 +398,12 @@ type attachClientOptions struct {
376 }398 }
377 399
378 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {400 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) {
379 resumeToken := uint64(0)407 resumeToken := uint64(0)
380 if opts.resumeCapable {408 if opts.resumeCapable {
381 resumeToken = d.nextResumeTokenLocked()409 resumeToken = d.nextResumeTokenLocked()
@@ -391,15 +419,96 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
391 ac.initOverlays()419 ac.initOverlays()
392 ac.setSession(sess)420 ac.setSession(sess)
393 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)421 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.
394 sess.mu.Lock()425 sess.mu.Lock()
395 old := sess.client426 old := sess.client
396- sess.client = ac
397 name := sess.name427 name := sess.name
398 sess.mu.Unlock()428 sess.mu.Unlock()
429+ _, cleanup := d.attachCoordinatorDeferred(sess, old, ac, false)
430+ sess.mu.Lock()
431+ sess.client = ac
432+ sess.mu.Unlock()
399 d.touchMRU(sess)433 d.touchMRU(sess)
400 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)434 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
401 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)435 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
402- return ac, old436+ 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
403 }512 }
404 513
405 // resetScreenDefaultColors clears the known default foreground/background514 // resetScreenDefaultColors clears the known default foreground/background
@@ -417,19 +526,36 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) {
417 d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true)526 d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true)
418 }527 }
419 528
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) {
421 if old == nil {534 if old == nil {
535+ d.attachmentCleanupWg.Go(cleanup.finish)
422 return536 return
423 }537 }
424- old.cancelResizePaint()538+ oldTransport := old.transportSnapshot().transport
539+ blockedRender := !old.sendMu.TryLock()
540+ if !blockedRender {
541+ old.sendMu.Unlock()
542+ }
425 543
426- d.unregisterPreview(old)544+ d.attachmentCleanupWg.Go(func() {
427- old.clearPreviousSession()545+ if !blockedRender {
428- old.setSession(nil)546+ // A healthy idle client observes the required replacement notice. Its
429- d.log.Info("client displaced")547+ // watchdog still bounds a peer that stops draining after this check.
430- // Async + bounded: a dead or wedged old client must not stall the new548+ d.boundedSend(old, frameDetached(ports.ReasonDetach))
431- // client's handshake.549+ }
432- d.notifyDetachedAsync(old, ports.ReasonDetach)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)
433 }559 }
434 560
435 // firstPaint guarantees the freshly attached client sees the full screen: if561 // 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
445 wsz := tb.size571 wsz := tb.size
446 tb.mu.Unlock()572 tb.mu.Unlock()
447 573
574+ outerResizeAccepted := false
448 if clientSize.Valid() && wsz != tabSize(clientSize) {575 if clientSize.Valid() && wsz != tabSize(clientSize) {
449- d.resizeForFirstPaint(sess, ac, clientSize)576+ outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)
450 }577 }
451 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)578 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
452- d.paint(sess, ac, true)579+ // Activation can synchronously resize a retained floating pane. An accepted
453- d.activateTab(sess, tb)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+ }
454 }586 }
455 587
456 // runConnLoop is the per-connection input router: it pumps client messages588 // runConnLoop is the per-connection input router: it pumps client messages
@@ -503,8 +635,8 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
503 case ports.MsgAck:635 case ports.MsgAck:
504 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {636 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
505 ac.ackOutputState(ack.AckedStateNum)637 ac.ackOutputState(ack.AckedStateNum)
506- if reset, ok := ac.takeDeferredPaint(); ok {638+ if rc := sess.renderCoordinator(); rc != nil {
507- d.paint(sess, ac, reset)639+ rc.notifyAck()
508 }640 }
509 }641 }
510 case ports.MsgPing:642 case ports.MsgPing:
@@ -525,7 +657,9 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
525 if !sess.detachIfCurrent(ac) {657 if !sess.detachIfCurrent(ac) {
526 return // already displaced by a newer client; nothing to do658 return // already displaced by a newer client; nothing to do
527 }659 }
528- ac.cancelResizePaint()660+ if rc := sess.renderCoordinator(); rc != nil {
661+ rc.noteDetach(ac)
662+ }
529 d.unregisterPreview(ac)663 d.unregisterPreview(ac)
530 sess.mu.Lock()664 sess.mu.Lock()
531 ephemeral := sess.ephemeral665 ephemeral := sess.ephemeral
@@ -576,57 +710,17 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
576 if !d.applyHostTheme(sess, ac, t, false) {710 if !d.applyHostTheme(sess, ac, t, false) {
577 return711 return
578 }712 }
579- d.paint(sess, ac, true)713+ d.invalidateRender(sess, ac, true, "client.go")
580 }714 }
581 715
582 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {716 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)
584 }718 }
585 719
586-// resizeForFirstPaint applies attach geometry without installing an idle timer:720+// resizeForFirstPaint retains attach's synchronous geometry guarantee. The
587-// firstPaint immediately emits the required full frame itself.721+// returned value reports whether the synchronous request was accepted.
588-func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) {722+func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {
589- d.resizeWithPaint(sess, ac, sz, false)723+ return d.requestTransactionalResize(sess, ac, sz, true)
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- }
630 }724 }
631 725
632 // detachOnSendError drops a client whose transport failed, leaving the session726 // detachOnSendError drops a client whose transport failed, leaving the session
@@ -636,7 +730,9 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
636 return730 return
637 }731 }
638 if sess.detachIfCurrent(ac) {732 if sess.detachIfCurrent(ac) {
639- ac.cancelResizePaint()733+ if rc := sess.renderCoordinator(); rc != nil {
734+ rc.noteDetach(ac)
735+ }
640 d.unregisterPreview(ac)736 d.unregisterPreview(ac)
641 if d.parkAttachment(sess, ac) {737 if d.parkAttachment(sess, ac) {
642 _ = ac.closeCapturedTransport(failed)738 _ = ac.closeCapturedTransport(failed)
M internal/usecase/daemon/clipboard.go +1 -1
@@ -1,6 +1,6 @@
1 // Package daemon holds vev's server-side session multiplexer use case: the1 // Package daemon holds vev's server-side session multiplexer use case: the
2 // accept loop, the ephemeral/named session registry, the per-tab PTY reader2 // 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.
4 package daemon4 package daemon
5 5
6 import (6 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: the1+// Package daemon holds vev's server-side session multiplexer use case.
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.
25 package daemon2 package daemon
26 3
27 import (4 import (
@@ -62,7 +39,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
62 if delta > 0 && rt.copyMode.AtBottom(*document) {39 if delta > 0 && rt.copyMode.AtBottom(*document) {
63 rt.clearCopyModeLocked()40 rt.clearCopyModeLocked()
64 rt.copyMu.Unlock()41 rt.copyMu.Unlock()
65- d.paint(sess, ac, true)42+ d.invalidateRender(sess, ac, true, "copymode.go")
66 return43 return
67 }44 }
68 rt.copyMode.Move(*document, delta)45 rt.copyMode.Move(*document, delta)
@@ -72,7 +49,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
72 }49 }
73 rt.copyMu.Unlock()50 rt.copyMu.Unlock()
74 51
75- d.paint(sess, ac, true)52+ d.invalidateRender(sess, ac, true, "copymode.go")
76 }53 }
77 54
78 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {55 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
@@ -89,12 +66,12 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
89 // Capture the live pane under pane.mu; all subsequent copy interaction uses66 // Capture the live pane under pane.mu; all subsequent copy interaction uses
90 // the immutable document while holding only copyMu.67 // the immutable document while holding only copyMu.
91 p.mu.Lock()68 p.mu.Lock()
92- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)69+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
93 p.mu.Unlock()70 p.mu.Unlock()
94 if !d.publishCopyMode(sess, ac, tb, p, document, nil) {71 if !d.publishCopyMode(sess, ac, tb, p, document, nil) {
95 return72 return
96 }73 }
97- d.paint(sess, ac, true)74+ d.invalidateRender(sess, ac, true, "copymode.go")
98 }75 }
99 76
100 // publishCopyMode installs a non-renderable candidate, validates its captured77 // publishCopyMode installs a non-renderable candidate, validates its captured
@@ -190,7 +167,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
190 rt.copyMu.Unlock()167 rt.copyMu.Unlock()
191 168
192 if changed {169 if changed {
193- d.paint(sess, ac, true)170+ d.invalidateRender(sess, ac, true, "copymode.go")
194 }171 }
195 }172 }
196 173
@@ -220,7 +197,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
220 changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)197 changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)
221 rt.copyMu.Unlock()198 rt.copyMu.Unlock()
222 if changed || closeSearch || accepted {199 if changed || closeSearch || accepted {
223- d.paint(sess, ac, true)200+ d.invalidateRender(sess, ac, true, "copymode.go")
224 }201 }
225 return202 return
226 }203 }
@@ -309,11 +286,11 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
309 rt.copyMu.Unlock()286 rt.copyMu.Unlock()
310 }287 }
311 if exit {288 if exit {
312- d.paint(sess, ac, true)289+ d.invalidateRender(sess, ac, true, "copymode.go")
313 return290 return
314 }291 }
315 if changed {292 if changed {
316- d.paint(sess, ac, true)293+ d.invalidateRender(sess, ac, true, "copymode.go")
317 }294 }
318 }295 }
319 296
@@ -386,7 +363,7 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
386 rt.copyMu.Unlock()363 rt.copyMu.Unlock()
387 364
388 if sess := ac.currentSession(); sess != nil {365 if sess := ac.currentSession(); sess != nil {
389- d.paint(sess, ac, true)366+ d.invalidateRender(sess, ac, true, "copymode.go")
390 }367 }
391 })368 })
392 }369 }
@@ -425,11 +402,6 @@ func isCopyEscapePrefix(data []byte) bool {
425 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')402 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
426 }403 }
427 404
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-
433 func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {405 func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
434 if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {406 if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
435 return frame, nil407 return frame, nil
M internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
@@ -54,7 +54,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
54 sess.client = ac54 sess.client = ac
55 ac.setSession(sess)55 ac.setSession(sess)
56 closing.mu.Lock()56 closing.mu.Lock()
57- document := scopy.NewSnapshot(closing.scrollback, closing.screen.Frame)57+ document := scopy.NewSnapshot(closing.history, closing.screen.Frame)
58 closing.mu.Unlock()58 closing.mu.Unlock()
59 ac.overlays.copyMu.Lock()59 ac.overlays.copyMu.Lock()
60 ac.overlays.copyPane = closing60 ac.overlays.copyPane = closing
@@ -94,7 +94,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
94 tb := sess.activeTab()94 tb := sess.activeTab()
95 p := tb.focusedPane()95 p := tb.focusedPane()
96 p.mu.Lock()96 p.mu.Lock()
97- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)97+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
98 p.mu.Unlock()98 p.mu.Unlock()
99 99
100 // Hold session validation after publication and make the captured target100 // Hold session validation after publication and make the captured target
@@ -157,7 +157,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
157 tb := sess.activeTab()157 tb := sess.activeTab()
158 p := tb.focusedPane()158 p := tb.focusedPane()
159 p.mu.Lock()159 p.mu.Lock()
160- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)160+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
161 p.mu.Unlock()161 p.mu.Unlock()
162 tc.invalidate(sess, tb, p)162 tc.invalidate(sess, tb, p)
163 163
M internal/usecase/daemon/copymode_test.go +43 -28
@@ -112,7 +112,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
112 tb := sess.activeTab()112 tb := sess.activeTab()
113 pane := tb.focusedPane()113 pane := tb.focusedPane()
114 pane.mu.Lock()114 pane.mu.Lock()
115- snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame)115+ snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)
116 pane.mu.Unlock()116 pane.mu.Unlock()
117 mode := scopy.NewMode(snap)117 mode := scopy.NewMode(snap)
118 bars := barState{status: sess.statusSegments(true)}118 bars := barState{status: sess.statusSegments(true)}
@@ -147,7 +147,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
147 tb := sess.activeTab()147 tb := sess.activeTab()
148 tb.focusedPane().screen = vt.NewScreen(12, 3)148 tb.focusedPane().screen = vt.NewScreen(12, 3)
149 tb.focusedPane().screen.Write([]byte("live"))149 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)
151 mode := scopy.NewMode(snap)151 mode := scopy.NewMode(snap)
152 152
153 bars := barState{status: sess.statusSegments(true)}153 bars := barState{status: sess.statusSegments(true)}
@@ -166,7 +166,7 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
166 writes := make(chan []byte, 1)166 writes := make(chan []byte, 1)
167 p, _ := newBlockingPTYWithWrites(t, writes)167 p, _ := newBlockingPTYWithWrites(t, writes)
168 d, sess, ac, sends := newManualSessionWithPTYs(t, p)168 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})
170 sess.tabs[0].focusedPane().screen.Write([]byte("live"))170 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
171 171
172 d.handleInput(sess, ac, []byte("\x1b "))172 d.handleInput(sess, ac, []byte("\x1b "))
@@ -293,8 +293,8 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
293 p, _ := newBlockingPTY(t)293 p, _ := newBlockingPTY(t)
294 d, sess, ac, sends := newManualSessionWithPTYs(t, p)294 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
295 pane := sess.tabs[0].focusedPane()295 pane := sess.tabs[0].focusedPane()
296- pane.scrollback = scopy.NewScrollback(4)296+ installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})
297- pane.scrollback.Append(testRow("old alpha"))297+ pane.history.Append(testRow("old alpha"))
298 copy(pane.screen.Frame.Row(0), testRow("live alpha"))298 copy(pane.screen.Frame.Row(0), testRow("live alpha"))
299 299
300 d.enterCopyMode(sess, ac)300 d.enterCopyMode(sess, ac)
@@ -322,9 +322,9 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
322 writes := make(chan []byte, 1)322 writes := make(chan []byte, 1)
323 p, _ := newBlockingPTYWithWrites(t, writes)323 p, _ := newBlockingPTYWithWrites(t, writes)
324 d, sess, ac, sends := newManualSessionWithPTYs(t, p)324 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
325- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)325+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
326- sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))326+ sess.tabs[0].focusedPane().history.Append(testRow("old1 "))
327- sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))327+ sess.tabs[0].focusedPane().history.Append(testRow("old2 "))
328 sess.tabs[0].focusedPane().screen.Write([]byte("live"))328 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
329 329
330 d.enterCopyMode(sess, ac)330 d.enterCopyMode(sess, ac)
@@ -358,7 +358,7 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
358 t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))358 t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))
359 }359 }
360 360
361- d.paint(sess, ac, true)361+ d.paint(sess, ac, true, nil)
362 followup := awaitFrame(t, sends, ports.MsgOutput)362 followup := awaitFrame(t, sends, ports.MsgOutput)
363 followupMsg, err := ports.UnmarshalOutput(followup.Payload)363 followupMsg, err := ports.UnmarshalOutput(followup.Payload)
364 require.NoError(t, err)364 require.NoError(t, err)
@@ -385,11 +385,26 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
385 p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()385 p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()
386 p.EXPECT().Pid().Return(4242).Maybe()386 p.EXPECT().Pid().Return(4242).Maybe()
387 387
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)
389 tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5}))393 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+ }
390 var hg sync.WaitGroup404 var hg sync.WaitGroup
391 hg.Go(func() { d.handleConn(tr) })405 hg.Go(func() { d.handleConn(tr) })
392 awaitFrame(t, sends, ports.MsgWelcome)406 awaitFrame(t, sends, ports.MsgWelcome)
407+ advanceRender() // initial coordinator invalidation
393 awaitFrame(t, sends, ports.MsgOutput)408 awaitFrame(t, sends, ports.MsgOutput)
394 409
395 for i := range 12 {410 for i := range 12 {
@@ -412,7 +427,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
412 }427 }
413 p.mu.Lock()428 p.mu.Lock()
414 defer p.mu.Unlock()429 defer p.mu.Unlock()
415- return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 12430+ return scopy.NewSnapshot(p.history, p.screen.Frame).Len() >= 12
416 }, 2*time.Second, 5*time.Millisecond)431 }, 2*time.Second, 5*time.Millisecond)
417 432
418 sess := firstSession(d)433 sess := firstSession(d)
@@ -420,9 +435,9 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
420 ac := sess.client435 ac := sess.client
421 require.NotNil(t, ac)436 require.NotNil(t, ac)
422 d.handleInput(sess, ac, []byte("\x1b "))437 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")
424 d.handleInput(sess, ac, []byte("VIS\r"))439 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")
426 d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})441 d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})
427 442
428 var payload string443 var payload string
@@ -448,7 +463,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
448 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {463 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
449 p, _ := newBlockingPTY(t)464 p, _ := newBlockingPTY(t)
450 d, sess, ac, sends := newManualSessionWithPTYs(t, p)465 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})
452 sess.tabs[0].focusedPane().screen.Write([]byte("live"))467 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
453 468
454 d.enterCopyMode(sess, ac)469 d.enterCopyMode(sess, ac)
@@ -482,9 +497,9 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
482 t.Run(tc.name, func(t *testing.T) {497 t.Run(tc.name, func(t *testing.T) {
483 p, _ := newBlockingPTY(t)498 p, _ := newBlockingPTY(t)
484 d, sess, ac, sends := newManualSessionWithPTYs(t, p)499 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
485- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)500+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
486- sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))501+ sess.tabs[0].focusedPane().history.Append(testRow("old1 "))
487- sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))502+ sess.tabs[0].focusedPane().history.Append(testRow("old2 "))
488 sess.tabs[0].focusedPane().screen.Write([]byte("live"))503 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
489 504
490 d.enterCopyMode(sess, ac)505 d.enterCopyMode(sess, ac)
@@ -502,7 +517,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
502 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {517 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
503 p, _ := newBlockingPTY(t)518 p, _ := newBlockingPTY(t)
504 d, sess, ac, sends := newManualSessionWithPTYs(t, p)519 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})
506 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)521 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
507 sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)522 sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)
508 copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))523 copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))
@@ -527,7 +542,7 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
527 d, sess, ac, sends := newManualSessionWithPTYs(t, p)542 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
528 clk := &signalClock{timers: make(chan *signalTimer, 1)}543 clk := &signalClock{timers: make(chan *signalTimer, 1)}
529 d.clock = clk544 d.clock = clk
530- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)545+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
531 sess.tabs[0].focusedPane().screen.Write([]byte("live"))546 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
532 547
533 d.enterCopyMode(sess, ac)548 d.enterCopyMode(sess, ac)
@@ -551,7 +566,7 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
551 d, sess, ac, sends := newManualSessionWithPTYs(t, p)566 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
552 clk := &signalClock{timers: make(chan *signalTimer, 1)}567 clk := &signalClock{timers: make(chan *signalTimer, 1)}
553 d.clock = clk568 d.clock = clk
554- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)569+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
555 sess.tabs[0].focusedPane().screen.Write([]byte("live"))570 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
556 571
557 d.enterCopyMode(sess, ac)572 d.enterCopyMode(sess, ac)
@@ -594,7 +609,7 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
594 t.Run(tc.name, func(t *testing.T) {609 t.Run(tc.name, func(t *testing.T) {
595 p, _ := newBlockingPTY(t)610 p, _ := newBlockingPTY(t)
596 d, sess, ac, sends := newManualSessionWithPTYs(t, p)611 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})
598 sess.tabs[0].focusedPane().screen.Write([]byte("live"))613 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
599 614
600 d.enterCopyMode(sess, ac)615 d.enterCopyMode(sess, ac)
@@ -643,7 +658,7 @@ func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) {
643 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {658 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
644 p, _ := newBlockingPTY(t)659 p, _ := newBlockingPTY(t)
645 d, sess, ac, sends := newManualSessionWithPTYs(t, p)660 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})
647 sess.tabs[0].focusedPane().screen.Write([]byte("live"))662 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
648 663
649 done := make(chan struct{})664 done := make(chan struct{})
@@ -665,7 +680,7 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
665 d.handleInput(sess, ac, []byte("q"))680 d.handleInput(sess, ac, []byte("q"))
666 })681 })
667 wg.Go(func() {682 wg.Go(func() {
668- d.paint(sess, ac, true)683+ d.paint(sess, ac, true, nil)
669 })684 })
670 }685 }
671 wg.Wait()686 wg.Wait()
@@ -717,11 +732,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
717 var want *pane732 var want *pane
718 if tc.useFloating {733 if tc.useFloating {
719 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})734 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
720- want.scrollback.Append(testRow("flt-old"))735+ want.history.Append(testRow("flt-old"))
721 want.screen.Write([]byte("flt-live"))736 want.screen.Write([]byte("flt-live"))
722 } else {737 } else {
723 want = main738 want = main
724- want.scrollback.Append(testRow("one-old"))739+ want.history.Append(testRow("one-old"))
725 want.screen.Write([]byte("one-live"))740 want.screen.Write([]byte("one-live"))
726 }741 }
727 742
@@ -758,11 +773,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
758 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)773 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
759 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})774 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
760 for i := range 30 {775 for i := range 30 {
761- fp.scrollback.Append(testRow(fmt.Sprintf("old-%02d", i)))776+ fp.history.Append(testRow(fmt.Sprintf("old-%02d", i)))
762 }777 }
763 fp.screen.Write([]byte("live"))778 fp.screen.Write([]byte("live"))
764 fp.mu.Lock()779 fp.mu.Lock()
765- total := scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Len()780+ total := scopy.NewSnapshot(fp.history, fp.screen.Frame).Len()
766 fp.mu.Unlock()781 fp.mu.Unlock()
767 782
768 d.enterCopyMode(sess, ac)783 d.enterCopyMode(sess, ac)
@@ -784,7 +799,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
784 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)799 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
785 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})800 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
786 for i := range 5 {801 for i := range 5 {
787- fp.scrollback.Append(testRow(fmt.Sprintf("old-%d", i)))802+ fp.history.Append(testRow(fmt.Sprintf("old-%d", i)))
788 }803 }
789 fp.screen.Write([]byte("live"))804 fp.screen.Write([]byte("live"))
790 805
M internal/usecase/daemon/daemon.go +93 -71
@@ -1,29 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the1+// Package daemon holds vev's server-side session multiplexer use case.
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.
27 package daemon2 package daemon
28 3
29 import (4 import (
@@ -41,14 +16,13 @@ import (
41 "github.com/bnema/vev/internal/persist"16 "github.com/bnema/vev/internal/persist"
42 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
43 "github.com/bnema/vev/internal/usecase/keys"18 "github.com/bnema/vev/internal/usecase/keys"
19+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
44 )20 )
45 21
46 // Scheduler debounce bounds. Idle updates use the minimum for low latency;22 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
47 // sustained floods step toward the maximum to reduce frame/syscall pressure.23 // sustained floods step toward the maximum to reduce frame/syscall pressure.
48 const (24 const (
49 minDebounceInterval = 2 * time.Millisecond25 minDebounceInterval = 2 * time.Millisecond
50- maxDebounceInterval = 16 * time.Millisecond
51- debounceStep = 2 * time.Millisecond
52 maxSyncUpdateDuration = 500 * time.Millisecond26 maxSyncUpdateDuration = 500 * time.Millisecond
53 )27 )
54 28
@@ -117,17 +91,33 @@ type Daemon struct {
117 // beforeRecentSessionHandoff is a deterministic test seam for the narrow91 // beforeRecentSessionHandoff is a deterministic test seam for the narrow
118 // interval between JRS validation and its committed hand-off.92 // interval between JRS validation and its committed hand-off.
119 beforeRecentSessionHandoff func()93 beforeRecentSessionHandoff func()
120-94+ ptys ports.PTYFactory
121- ptys ports.PTYFactory95+ clock ports.Clock
122- clock ports.Clock96+ log *slog.Logger
123- log *slog.Logger97+ runtimeObserver ports.RuntimeObserver
124- baseEnv []string98+ baseEnv []string
125- shell string99+ shell string
126- shellArgs []string100+ shellArgs []string
127- persist *persist.Persister101+ persist *persist.Persister
128- persistEnabled bool102+ persistEnabled bool
129- snaps ports.SnapshotStore103+ snaps ports.SnapshotStore
130- snapsEnabled bool104+ 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
131 restoreDone chan struct{}121 restoreDone chan struct{}
132 restoreOnce sync.Once122 restoreOnce sync.Once
133 procCwd func(int) (string, error)123 procCwd func(int) (string, error)
@@ -160,8 +150,14 @@ type Daemon struct {
160 done chan struct{}150 done chan struct{}
161 doneOnce sync.Once151 doneOnce sync.Once
162 152
163- sessWg sync.WaitGroup // PTY reader + scheduler goroutines153+ // sessWg owns attention animation, bar-script polling, CWD sampling,
154+ // snapshot save/restore, and floating-session launch goroutines.
155+ sessWg sync.WaitGroup
164 connWg sync.WaitGroup // per-connection handler goroutines156 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
165 }161 }
166 162
167 type parkedAttachment struct {163 type parkedAttachment struct {
@@ -194,6 +190,13 @@ func (s stoppedSession) same(other stoppedSession) bool {
194 190
195 type Option func(*Daemon)191 type Option func(*Daemon)
196 192
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+
197 // WithShell overrides the command (and its args) each session spawns. The200 // WithShell overrides the command (and its args) each session spawns. The
198 // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a201 // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a
199 // deterministic program.202 // deterministic program.
@@ -312,6 +315,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
312 done: make(chan struct{}),315 done: make(chan struct{}),
313 restoreDone: make(chan struct{}),316 restoreDone: make(chan struct{}),
314 animWake: make(chan struct{}, 1),317 animWake: make(chan struct{}, 1),
318+ snapshotMarshal: snapcodec.Marshal,
319+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
315 resumeParkGrace: defaultResumeParkGrace,320 resumeParkGrace: defaultResumeParkGrace,
316 barScripts: &barScriptState{321 barScripts: &barScriptState{
317 cfg: barConfigFromDomain(domain.Defaults().Bar),322 cfg: barConfigFromDomain(domain.Defaults().Bar),
@@ -382,6 +387,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
382 })387 })
383 }388 }
384 if d.snapsEnabled {389 if d.snapsEnabled {
390+ d.startSnapshotEncodeWorker()
385 d.sessWg.Go(func() {391 d.sessWg.Go(func() {
386 d.snapshotSaver(d.serveCtx)392 d.snapshotSaver(d.serveCtx)
387 })393 })
@@ -425,12 +431,17 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
425 // remaining parked connections, so graceful notices are never raced by the431 // remaining parked connections, so graceful notices are never raced by the
426 // force-close. Finally drain the conn handlers, run one defensive sweep in432 // force-close. Finally drain the conn handlers, run one defensive sweep in
427 // case any ordering ever leaves a session behind, and join the session433 // 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).
429 d.shutdownAll(ports.ReasonServerShutdown)435 d.shutdownAll(ports.ReasonServerShutdown)
430 d.waitNotifies()436 d.waitNotifies()
431 d.hardCancel()437 d.hardCancel()
432 d.serveCancel()438 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.
433 d.connWg.Wait()442 d.connWg.Wait()
443+ d.attachmentCleanupWg.Wait()
444+ d.stopSnapshotEncodeWorker()
434 d.shutdownAll(ports.ReasonServerShutdown)445 d.shutdownAll(ports.ReasonServerShutdown)
435 d.sessWg.Wait()446 d.sessWg.Wait()
436 d.waitNotifies()447 d.waitNotifies()
@@ -459,7 +470,9 @@ func (d *Daemon) shutdownAll(reason uint8) {
459 return470 return
460 }471 }
461 for _, s := range snapshot {472 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+ }
463 }476 }
464 }477 }
465 478
@@ -620,7 +633,23 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
620 return633 return
621 }634 }
622 635
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.
624 d.clientGone(sess, ac, tr, false)653 d.clientGone(sess, ac, tr, false)
625 return654 return
626 }655 }
@@ -637,6 +666,23 @@ type protoErr struct {
637 666
638 func (e *protoErr) Error() string { return e.text }667 func (e *protoErr) Error() string { return e.text }
639 668
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+
640 // route resolves a Hello to a session and a freshly attached client, creating686 // route resolves a Hello to a session and a freshly attached client, creating
641 // the session for ephemeral/new intents.687 // the session for ephemeral/new intents.
642 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {688 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
687 }733 }
688 }734 }
689 d.purgeParkedForSessionLocked(sess)735 d.purgeParkedForSessionLocked(sess)
690- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})736+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
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
697 737
698 case ports.IntentEphemeral:738 case ports.IntentEphemeral:
699 name := d.allocEphemeralNameLocked()739 name := d.allocEphemeralNameLocked()
@@ -703,13 +743,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
703 return nil, nil, err743 return nil, nil, err
704 }744 }
705 d.purgeParkedForSessionLocked(sess)745 d.purgeParkedForSessionLocked(sess)
706- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})746+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
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
713 747
714 case ports.IntentNew:748 case ports.IntentNew:
715 if h.Name == "" {749 if h.Name == "" {
@@ -730,13 +764,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
730 return nil, nil, err764 return nil, nil, err
731 }765 }
732 d.purgeParkedForSessionLocked(sess)766 d.purgeParkedForSessionLocked(sess)
733- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})767+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
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
740 768
741 case ports.IntentAttach:769 case ports.IntentAttach:
742 sess := d.findByNameLocked(h.Name)770 sess := d.findByNameLocked(h.Name)
@@ -755,13 +783,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
755 }783 }
756 }784 }
757 d.purgeParkedForSessionLocked(sess)785 d.purgeParkedForSessionLocked(sess)
758- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})786+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
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
765 787
766 default:788 default:
767 d.mu.Unlock()789 d.mu.Unlock()
M internal/usecase/daemon/daemon_test.go +99 -5
@@ -142,7 +142,7 @@ func newBlockingOpenFactory(t *testing.T, d *Daemon) *blockingOpenFactory {
142 return f142 return f
143 }143 }
144 144
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) {
146 <-f.release146 <-f.release
147 return nil, io.ErrClosedPipe147 return nil, io.ErrClosedPipe
148 }148 }
@@ -154,8 +154,8 @@ func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
154 t.Helper()154 t.Helper()
155 f := portsmocks.NewMockPTYFactory(t)155 f := portsmocks.NewMockPTYFactory(t)
156 var normal domain.Size156 var normal domain.Size
157- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(157+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
158- func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {158+ func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
159 if !normal.Valid() {159 if !normal.Valid() {
160 normal = sz160 normal = sz
161 }161 }
@@ -174,8 +174,8 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
174 var mu sync.Mutex174 var mu sync.Mutex
175 var normal domain.Size175 var normal domain.Size
176 next := 0176 next := 0
177- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(177+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
178- func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {178+ func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
179 mu.Lock()179 mu.Lock()
180 defer mu.Unlock()180 defer mu.Unlock()
181 if !normal.Valid() {181 if !normal.Valid() {
@@ -286,6 +286,54 @@ func listSessions(t *testing.T, d *Daemon) ports.Sessions {
286 return sessions286 return sessions
287 }287 }
288 288
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+
289 func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports.Frame) {337 func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports.Frame) {
290 t.Helper()338 t.Helper()
291 tr := portsmocks.NewMockTransport(t)339 tr := portsmocks.NewMockTransport(t)
@@ -395,6 +443,52 @@ func (r realTimer) C() <-chan time.Time { return r.t.C }
395 func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }443 func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
396 func (r realTimer) Stop() bool { return r.t.Stop() }444 func (r realTimer) Stop() bool { return r.t.Stop() }
397 445
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+
398 // TestServeReturnsDespiteWedgedClientOnShutdown: killSession's teardown must492 // TestServeReturnsDespiteWedgedClientOnShutdown: killSession's teardown must
399 // not be gated behind the Detached notice. The client transport wedges every493 // not be gated behind the Detached notice. The client transport wedges every
400 // MsgDetached send until the transport is closed (mirroring a full kernel494 // 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) {
154 // activateTab performs the work associated with making a tab the destination.154 // activateTab performs the work associated with making a tab the destination.
155 // It is deliberately idempotent: every tab transition can use this single155 // It is deliberately idempotent: every tab transition can use this single
156 // hook, while inactive restored tabs remain cold until actually selected.156 // 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 {
158 if d == nil || sess == nil || tb == nil {164 if d == nil || sess == nil || tb == nil {
159- return165+ return false
160 }166 }
161 // A headless session has no actual terminal destination. Deferring warmup167 // A headless session has no actual terminal destination. Deferring warmup
162 // until firstPaint keeps restored tabs cold and avoids launching children168 // until firstPaint keeps restored tabs cold and avoids launching children
@@ -166,10 +172,20 @@ func (d *Daemon) activateTab(sess *session, tb *tab) {
166 sess.mu.Unlock()172 sess.mu.Unlock()
167 d.exitCopyMode(ac)173 d.exitCopyMode(ac)
168 if ac == nil {174 if ac == nil {
169- return175+ return false
170 }176 }
171 d.ensureFloatingWarm(sess, tb)177 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
173 }189 }
174 190
175 // toggleFloating changes only this tab's slot. Opening an uninitialized slot191 // toggleFloating changes only this tab's slot. Opening an uninitialized slot
@@ -184,23 +200,29 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
184 }200 }
185 cfg := d.currentFloatingConfig()201 cfg := d.currentFloatingConfig()
186 tb.mu.Lock()202 tb.mu.Lock()
203+ wasVisible := tb.floating.state == floatingVisible
187 start, generation := tb.toggleFloatingLocked()204 start, generation := tb.toggleFloatingLocked()
188 // desiredVisible is meaningful only while a launch is warming. Installed205 // desiredVisible is meaningful only while a launch is warming. Installed
189 // slots derive their resize and paint work from the resulting slot state.206 // slots derive their resize and paint work from the resulting slot state.
190 visible := tb.floating.state == floatingVisible207 visible := tb.floating.state == floatingVisible
208+ hidden := wasVisible && tb.floating.state == floatingHidden
191 tb.mu.Unlock()209 tb.mu.Unlock()
192 if start {210 if start {
193 d.launchFloating(sess, tb, cfg, generation, true)211 d.launchFloating(sess, tb, cfg, generation, true)
194 if ac != nil {212 if ac != nil {
195- d.paint(sess, ac, true)213+ d.invalidateRender(sess, ac, true, "floating.go")
196 }214 }
197 return nil215 return nil
198 }216 }
199- if visible {217+ if hidden {
200- d.resizeActiveFloating(tb)218+ d.invalidateRender(sess, ac, true, "floating.go")
219+ return nil
201 }220 }
202- if ac != nil {221+ if visible {
203- d.paint(sess, ac, true)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)
204 }226 }
205 return nil227 return nil
206 }228 }
@@ -218,6 +240,40 @@ func (d *Daemon) startFloating(sess *session, tb *tab, visible bool) {
218 240
219 // floatingLaunchSpec is an immutable snapshot of one launch. It is built241 // floatingLaunchSpec is an immutable snapshot of one launch. It is built
220 // before the worker starts so config reloads cannot affect that launch.242 // 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+
221 type floatingLaunchSpec struct {277 type floatingLaunchSpec struct {
222 sessionName string278 sessionName string
223 cwd string279 cwd string
@@ -233,17 +289,25 @@ type floatingLaunchSpec struct {
233 }289 }
234 290
235 // launchFloating snapshots launch inputs, then accounts for a worker through291 // 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.
237 func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) {293 func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) {
238 spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen)294 spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen)
239 if err != nil {295 if err != nil {
240 d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err)296 d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err)
241 return297 return
242 }298 }
299+ launch, ok := sess.registerFloatingLaunch()
300+ if !ok {
301+ d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, context.Canceled)
302+ return
303+ }
243 // Count the launch before its goroutine starts. It remains counted through304 // Count the launch before its goroutine starts. It remains counted through
244- // install, including the reader/scheduler Adds, so shutdown cannot return305+ // install, including the reader/coordinator Adds, so shutdown cannot return
245 // before a late Open completion has either been rejected or fully joined.306 // 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+ })
247 }311 }
248 312
249 func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {313 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
300 // openAndInstallFloating runs entirely in the launch worker. No PTY operation364 // openAndInstallFloating runs entirely in the launch worker. No PTY operation
301 // occurs under tb.mu; the pane is fully initialized before publication.365 // occurs under tb.mu; the pane is fully initialized before publication.
302 func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {366 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)
304 if err != nil {383 if err != nil {
305 d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)384 d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)
306 return385 return
@@ -309,7 +388,14 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
309 p.rect = spec.geometry.Inner388 p.rect = spec.geometry.Inner
310 p.popupGeometry = spec.geometry389 p.popupGeometry = spec.geometry
311 p.title.displayFallback = spec.fallback390 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
313 // The reader may run as soon as install returns; make its exit policy399 // The reader may run as soon as install returns; make its exit policy
314 // immutable before publishing the pane to the slot.400 // immutable before publishing the pane to the slot.
315 p.onExit = func() { d.reapFloating(sess, tb, p, generation) }401 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
343 d.reapplyThemeSession(sess)429 d.reapplyThemeSession(sess)
344 d.startPaneGoroutines(sess, tb, p)430 d.startPaneGoroutines(sess, tb, p)
345 if visible {431 if visible {
346- d.resizeActiveFloating(tb)
347 sess.mu.Lock()432 sess.mu.Lock()
348 ac := sess.client433 ac := sess.client
349 sess.mu.Unlock()434 sess.mu.Unlock()
350- if ac != nil {435+ tb.mu.Lock()
351- d.paint(sess, ac, true)436+ size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
352- }437+ tb.mu.Unlock()
438+ d.requestTransactionalResize(sess, ac, size, true)
353 }439 }
354 }440 }
355 441
@@ -367,9 +453,12 @@ func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64
367 sess.mu.Lock()453 sess.mu.Lock()
368 ac := sess.client454 ac := sess.client
369 sess.mu.Unlock()455 sess.mu.Unlock()
456+ if ac != nil {
457+ ac.pruneCaptureFrames(p)
458+ }
370 copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)459 copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
371 if ac != nil && (visible || copyCleared) {460 if ac != nil && (visible || copyCleared) {
372- d.paint(sess, ac, true)461+ d.invalidateRender(sess, ac, true, "floating.go")
373 }462 }
374 }463 }
375 464
@@ -383,6 +472,7 @@ func (d *Daemon) teardownFloating(tb *tab, ac *attachedClient) {
383 tb.mu.Unlock()472 tb.mu.Unlock()
384 if ac != nil {473 if ac != nil {
385 ac.overlays.clearCopyModeForPane(p)474 ac.overlays.clearCopyModeForPane(p)
475+ ac.pruneCaptureFrames(p)
386 }476 }
387 closeFloatingPane(p)477 closeFloatingPane(p)
388 }478 }
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
85 }85 }
86 }86 }
87 87
88-// composeFloatingFrame applies the popup to a copy of the normal composed88+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-// destination. In particular it never writes the VT screen frame: that frame89+ frame := base.Clone()
90-// is shared with the PTY reader and is the source for future renders.90+ legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
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) {91+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme)
92- var frame renderer.Frame92+ geometry := floating.geometry.translate(content.X, content.Y)
93- if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height {93+ blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, theme)
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)
116 damage := append([]renderer.Damage(nil), baseDamage...)94 damage := append([]renderer.Damage(nil), baseDamage...)
117- for _, d := range screenDamage {95+ for _, d := range floating.pane.damage {
118 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)96 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
119 }97 }
120- p.screen.ClearDamage()98+ popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry
121- p.mu.Unlock()99+ titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration
122-100+ drawFloatingBorder(frame, geometry.Bounds, floating.title, newThemeStyles(theme).border)
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- }
135 if full || popupChanged {101 if full || popupChanged {
136- return frame, []renderer.Damage{renderer.FullRedraw()}, geometry102+ return frame, []renderer.Damage{renderer.FullRedraw()}
137 }103 }
138 if titleChanged && geometry.Bounds.Height >= 3 {104 if titleChanged && geometry.Bounds.Height >= 3 {
139 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})105 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
140 }106 }
141- return frame, damage, geometry107+ return frame, damage
142 }108 }
143 109
144 func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {110 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,
167 ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)133 ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)
168 }134 }
169 }135 }
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 (
14 "github.com/bnema/vev/pkg/renderer"14 "github.com/bnema/vev/pkg/renderer"
15 )15 )
16 16
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+
17 func TestFloatingGeometryTranslate(t *testing.T) {36 func TestFloatingGeometryTranslate(t *testing.T) {
18 geometry := floatingGeometry{37 geometry := floatingGeometry{
19 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},38 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},
@@ -200,7 +219,7 @@ func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T)
200 p.rect, p.popupGeometry = initial.Inner, initial219 p.rect, p.popupGeometry = initial.Inner, initial
201 d := newTestDaemon(t, nil, stubClock{})220 d := newTestDaemon(t, nil, stubClock{})
202 221
203- require.True(t, d.resizeFloatingPane(p, tt.next))222+ require.True(t, applyFloatingResizePlanForTest(d, p, tt.next))
204 require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY")223 require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY")
205 require.Equal(t, tt.next.Inner, p.rect)224 require.Equal(t, tt.next.Inner, p.rect)
206 require.Equal(t, tt.next, p.popupGeometry)225 require.Equal(t, tt.next, p.popupGeometry)
@@ -268,41 +287,6 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
268 }287 }
269 }288 }
270 289
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-
306 func BenchmarkComposeFloatingFrameCached(b *testing.B) {290 func BenchmarkComposeFloatingFrameCached(b *testing.B) {
307 p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})291 p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
308 p.title.displayFallback = "float"292 p.title.displayFallback = "float"
@@ -364,14 +348,14 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
364 d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42})348 d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42})
365 require.Empty(t, pty.sizes())349 require.Empty(t, pty.sizes())
366 350
367- // Showing attempts the current size before paint. A failed resize leaves the351+ // Showing attempts the current size before paint. A failed resize retains
368- // old screen and rect untouched, so a later hide/show can retry.352+ // the old screen, but commits the new popup rect so capture clips/pads it.
369 require.NoError(t, d.toggleFloating(sess, nil))353 require.NoError(t, d.toggleFloating(sess, nil))
370 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())354 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())
371- require.Equal(t, initial, floating.rect)355+ require.Equal(t, current, floating.rect)
372 require.Equal(t, initial.Width, floating.screen.Frame.Width)356 require.Equal(t, initial.Width, floating.screen.Frame.Width)
373 require.Equal(t, initial.Height, floating.screen.Frame.Height)357 require.Equal(t, initial.Height, floating.screen.Frame.Height)
374- require.Equal(t, initialGeometry, floating.popupGeometry)358+ require.Equal(t, currentGeometry, floating.popupGeometry)
375 359
376 require.NoError(t, d.toggleFloating(sess, nil)) // hide360 require.NoError(t, d.toggleFloating(sess, nil)) // hide
377 require.NoError(t, d.toggleFloating(sess, nil)) // retry show361 require.NoError(t, d.toggleFloating(sess, nil)) // retry show
@@ -397,7 +381,7 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T)
397 d := newTestDaemon(t, nil, stubClock{})381 d := newTestDaemon(t, nil, stubClock{})
398 d.ApplyConfig(domain.Config{Floating: cfg})382 d.ApplyConfig(domain.Config{Floating: cfg})
399 383
400- require.False(t, d.resizeFloatingPane(p, newGeometry))384+ require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry))
401 tb.mu.Lock()385 tb.mu.Lock()
402 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)386 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
403 copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y))387 copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y))
@@ -423,7 +407,7 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
423 p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}407 p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}
424 d := newTestDaemon(t, nil, stubClock{})408 d := newTestDaemon(t, nil, stubClock{})
425 requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}}409 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))
427 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)411 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
428 require.Equal(t, floatingGeometry{}, p.popupGeometry)412 require.Equal(t, floatingGeometry{}, p.popupGeometry)
429 require.Equal(t, 5, p.screen.Frame.Width)413 require.Equal(t, 5, p.screen.Frame.Width)
@@ -439,11 +423,11 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
439 done1 := make(chan bool, 1)423 done1 := make(chan bool, 1)
440 done2 := make(chan bool, 1)424 done2 := make(chan bool, 1)
441 secondStarted := make(chan struct{})425 secondStarted := make(chan struct{})
442- go func() { done1 <- d.resizeFloatingPane(p, first) }()426+ go func() { done1 <- applyFloatingResizePlanForTest(d, p, first) }()
443 <-pty.entered427 <-pty.entered
444 go func() {428 go func() {
445 close(secondStarted)429 close(secondStarted)
446- done2 <- d.resizeFloatingPane(p, second)430+ done2 <- applyFloatingResizePlanForTest(d, p, second)
447 }()431 }()
448 <-secondStarted432 <-secondStarted
449 close(pty.release)433 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
36 var releaseOnce sync.Once36 var releaseOnce sync.Once
37 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }37 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }
38 t.Cleanup(releaseOpen)38 t.Cleanup(releaseOpen)
39- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(39+ factory.EXPECT().Open(mock.Anything, 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) {40+ func(_ context.Context, command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {
41 opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size}41 opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size}
42 <-release42 <-release
43 return result, openErr43 return result, openErr
@@ -364,7 +364,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
364 364
365 // Establish a renderer shadow first. The EOF repaint must reset it and365 // Establish a renderer shadow first. The EOF repaint must reset it and
366 // redraw the underlying cell rather than depending on popup damage.366 // redraw the underlying cell rather than depending on popup damage.
367- d.paint(sess, ac, true)367+ d.paint(sess, ac, true, nil)
368 baseline := awaitFrame(t, sends, ports.MsgOutput)368 baseline := awaitFrame(t, sends, ports.MsgOutput)
369 baselineOutput, err := ports.UnmarshalOutput(baseline.Payload)369 baselineOutput, err := ports.UnmarshalOutput(baseline.Payload)
370 require.NoError(t, err)370 require.NoError(t, err)
@@ -467,8 +467,8 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
467 t.Run(tt.name, func(t *testing.T) {467 t.Run(tt.name, func(t *testing.T) {
468 factory := portsmocks.NewMockPTYFactory(t)468 factory := portsmocks.NewMockPTYFactory(t)
469 opened := make(chan string, 1)469 opened := make(chan string, 1)
470- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(470+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
471- func(_ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {471+ func(_ context.Context, _ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {
472 opened <- cwd472 opened <- cwd
473 return nil, io.ErrUnexpectedEOF473 return nil, io.ErrUnexpectedEOF
474 }).Once()474 }).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: the1+// Package daemon holds vev's server-side session multiplexer use case.
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.
25 package daemon2 package daemon
26 3
27 import (4 import (
@@ -118,7 +95,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
118 if target.ID != oldFocus {95 if target.ID != oldFocus {
119 d.refreshPaneTitleOnFocus(sess, target.ID)96 d.refreshPaneTitleOnFocus(sess, target.ID)
120 }97 }
121- d.paint(sess, ac, true)98+ d.invalidateRender(sess, ac, true, "input.go")
122 return99 return
123 }100 }
124 }101 }
@@ -174,7 +151,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
174 d.exitCopyMode(ac)151 d.exitCopyMode(ac)
175 d.refreshPaneTitleOnFocus(sess, pl.ID)152 d.refreshPaneTitleOnFocus(sess, pl.ID)
176 }153 }
177- d.paint(sess, ac, true)154+ d.invalidateRender(sess, ac, true, "input.go")
178 return155 return
179 }156 }
180 var p *pane157 var p *pane
@@ -195,7 +172,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
195 if isMouseFocusPress(ev) && pl.ID != oldFocus {172 if isMouseFocusPress(ev) && pl.ID != oldFocus {
196 d.exitCopyMode(ac)173 d.exitCopyMode(ac)
197 d.refreshPaneTitleOnFocus(sess, pl.ID)174 d.refreshPaneTitleOnFocus(sess, pl.ID)
198- d.paint(sess, ac, true)175+ d.invalidateRender(sess, ac, true, "input.go")
199 }176 }
200 if multi {177 if multi {
201 ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)178 ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
@@ -225,8 +202,8 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
225 mouseMode, mouseSGR := p.screen.MouseMode()202 mouseMode, mouseSGR := p.screen.MouseMode()
226 altScreen := p.screen.AltScreenActive()203 altScreen := p.screen.AltScreenActive()
227 scrollbackRows := 0204 scrollbackRows := 0
228- if p.scrollback != nil {205+ if p.history != nil {
229- scrollbackRows = p.scrollback.Len()206+ scrollbackRows = p.history.Len()
230 }207 }
231 p.mu.Unlock()208 p.mu.Unlock()
232 209
@@ -272,7 +249,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
272 249
273 // The immutable snapshot is the only live-pane access needed for drag entry.250 // The immutable snapshot is the only live-pane access needed for drag entry.
274 p.mu.Lock()251 p.mu.Lock()
275- document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)252+ document := scopy.NewSnapshot(p.history, p.screen.Frame)
276 p.mu.Unlock()253 p.mu.Unlock()
277 tb := sess.activeTab()254 tb := sess.activeTab()
278 if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) {255 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,
290 rt.copyDragging = true267 rt.copyDragging = true
291 }268 }
292 rt.copyMu.Unlock()269 rt.copyMu.Unlock()
293- d.paint(sess, ac, true)270+ d.invalidateRender(sess, ac, true, "input.go")
294 case mouse.Release:271 case mouse.Release:
295 rt.copyMu.Lock()272 rt.copyMu.Lock()
296 rt.normalMousePressValid = false273 rt.normalMousePressValid = false
@@ -432,7 +409,7 @@ func (h daemonKeyHandler) Action(action keys.Action) {
432 idx := int(action - keys.ActionSwitchTab1)409 idx := int(action - keys.ActionSwitchTab1)
433 if sess.switchTab(idx) {410 if sess.switchTab(idx) {
434 h.d.activateTab(sess, sess.activeTab())411 h.d.activateTab(sess, sess.activeTab())
435- h.d.paint(sess, h.ac, true)412+ h.d.invalidateRender(sess, h.ac, true, "input.go")
436 }413 }
437 }414 }
438 }415 }
M internal/usecase/daemon/input_test.go +188 -13
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
4 "encoding/base64"5 "encoding/base64"
5 "fmt"6 "fmt"
6 "strings"7 "strings"
@@ -14,10 +15,10 @@ import (
14 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17- scopy "github.com/bnema/vev/internal/usecase/copy"
18 "github.com/bnema/vev/internal/usecase/keys"18 "github.com/bnema/vev/internal/usecase/keys"
19 "github.com/bnema/vev/internal/usecase/layout"19 "github.com/bnema/vev/internal/usecase/layout"
20 "github.com/bnema/vev/internal/usecase/picker"20 "github.com/bnema/vev/internal/usecase/picker"
21+ "github.com/bnema/vev/pkg/vt"
21 )22 )
22 23
23 // --- test doubles -----------------------------------------------------------24 // --- test doubles -----------------------------------------------------------
@@ -57,11 +58,11 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
57 return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 358 return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3
58 }, 2*time.Second, 5*time.Millisecond)59 }, 2*time.Second, 5*time.Millisecond)
59 require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond)60 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"))
61 require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond)62 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"))
63 require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond)64 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"))
65 66
66 d.mu.Lock()67 d.mu.Lock()
67 var sess *session68 var sess *session
@@ -87,6 +88,77 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
87 d.sessWg.Wait()88 d.sessWg.Wait()
88 }89 }
89 90
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+
90 func TestAltCForwardsToPTY(t *testing.T) {162 func TestAltCForwardsToPTY(t *testing.T) {
91 writes := make(chan []byte, 2)163 writes := make(chan []byte, 2)
92 p, releasePTY := newBlockingPTYWithWrites(t, writes)164 p, releasePTY := newBlockingPTYWithWrites(t, writes)
@@ -94,10 +166,74 @@ func TestAltCForwardsToPTY(t *testing.T) {
94 166
95 d.handleInput(sess, ac, []byte("\x1bc"))167 d.handleInput(sess, ac, []byte("\x1bc"))
96 168
97- require.Equal(t, []byte("\x1bc"), <-writes)169+ requirePTYWrite(t, writes, []byte("\x1bc"))
98 releasePTY()170 releasePTY()
99 }171 }
100 172
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+
101 func requireFloatingInitialized(t *testing.T, tb *tab) {237 func requireFloatingInitialized(t *testing.T, tb *tab) {
102 t.Helper()238 t.Helper()
103 tb.mu.Lock()239 tb.mu.Lock()
@@ -260,7 +396,7 @@ func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
260 paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~")396 paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~")
261 d.handleInput(sess, ac, paste)397 d.handleInput(sess, ac, paste)
262 398
263- require.Equal(t, paste, <-writes)399+ requirePTYWrite(t, writes, paste)
264 releasePTY()400 releasePTY()
265 }401 }
266 402
@@ -342,7 +478,7 @@ func TestOverlayInputPrecedence(t *testing.T) {
342 input: []byte("Z"),478 input: []byte("Z"),
343 check: func(t *testing.T, ac *attachedClient, writes chan []byte) {479 check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
344 t.Helper()480 t.Helper()
345- require.Equal(t, []byte("Z"), <-writes)481+ requirePTYWrite(t, writes, []byte("Z"))
346 },482 },
347 },483 },
348 }484 }
@@ -466,6 +602,45 @@ func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
466 require.Same(t, updated, ac.previousSession.Get())602 require.Same(t, updated, ac.previousSession.Get())
467 }603 }
468 604
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+
469 func TestSwitchSourcePreviousSessionContracts(t *testing.T) {644 func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
470 for _, tc := range []struct {645 for _, tc := range []struct {
471 name string646 name string
@@ -592,7 +767,7 @@ func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
592 require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab")767 require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab")
593 d.handleInput(sess, ac, []byte("Z"))768 d.handleInput(sess, ac, []byte("Z"))
594 require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)769 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"))
596 771
597 releasePTY1()772 releasePTY1()
598 releasePTY2()773 releasePTY2()
@@ -696,8 +871,8 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
696 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))871 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
697 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))872 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
698 873
699- require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes)874+ requirePTYWrite(t, writes, []byte("\x1b[A\x1b[A\x1b[A"))
700- require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes)875+ requirePTYWrite(t, writes, []byte("\x1b[B\x1b[B\x1b[B"))
701 require.Nil(t, ac.overlays.copyMode)876 require.Nil(t, ac.overlays.copyMode)
702 }877 }
703 878
@@ -752,7 +927,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
752 927
753 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h"))928 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h"))
754 d.handleInput(sess, ac, raw)929 d.handleInput(sess, ac, raw)
755- require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes)930+ requirePTYWrite(t, writes, []byte("\x1b[<0;2;2M"))
756 931
757 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))932 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
758 select {933 select {
@@ -892,7 +1067,7 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
892 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {1067 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
893 p, _ := newBlockingPTY(t)1068 p, _ := newBlockingPTY(t)
894 d, sess, ac, _ := newManualSessionWithPTYs(t, p)1069 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})
896 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")1071 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
897 1072
898 // Press on a content row while scrollback is empty: the anchor is1073 // Press on a content row while scrollback is empty: the anchor is
@@ -902,7 +1077,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
902 // Simulate 5 lines evicted into scrollback between the Press and the1077 // Simulate 5 lines evicted into scrollback between the Press and the
903 // first Motion (e.g. the child kept producing output).1078 // first Motion (e.g. the child kept producing output).
904 for range 5 {1079 for range 5 {
905- sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted"))1080+ sess.tabs[0].focusedPane().history.Append(testRow("evicted"))
906 }1081 }
907 1082
908 // Motion lands on wire row 3 (0-based row 2 of the *current* screen).1083 // 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 {
64 return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok}64 return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok}
65 }65 }
66 66
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-
71 func layoutFingerprint(root *layout.Node) string {67 func layoutFingerprint(root *layout.Node) string {
72 var b strings.Builder68 var b strings.Builder
73 writeLayoutFingerprint(&b, root)69 writeLayoutFingerprint(&b, root)
M internal/usecase/daemon/nil_pty_test.go +1 -1
@@ -24,7 +24,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
24 sess.tabs = []*tab{first, second}24 sess.tabs = []*tab{first, second}
25 25
26 require.NotPanics(t, func() {26 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})
28 })28 })
29 require.Equal(t, 20, first.focusedPane().screen.Frame.Width)29 require.Equal(t, 20, first.focusedPane().screen.Frame.Width)
30 require.Equal(t, 5, first.focusedPane().screen.Frame.Height)30 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 {
17 renderer *renderer.Renderer17 renderer *renderer.Renderer
18 next uint6418 next uint64
19 acked uint6419 acked uint64
20- deferred bool
21- deferredReset bool
22 maxOutstanding uint6420 maxOutstanding uint64
23 }21 }
24 22
@@ -89,37 +87,10 @@ func (s *outputStateStream) ack(state uint64) {
89 func (s *outputStateStream) rebase() {87 func (s *outputStateStream) rebase() {
90 s.acked = s.next88 s.acked = s.next
91 s.renderer.Reset()89 s.renderer.Reset()
92- s.clearDeferred()
93 }90 }
94 91
95 func (s *outputStateStream) atCapacity() bool {92 func (s *outputStateStream) atCapacity() bool {
96 return s.outstanding() >= s.maxOutstanding93 return s.outstanding() >= s.maxOutstanding
97 }94 }
98 95
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-
125 func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked }96 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
68 }68 }
69 }69 }
70 70
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-
118 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {71 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
119 t.Helper()72 t.Helper()
120 data, err := stream.render(frame, damage, reset)73 data, err := stream.render(frame, damage, reset)
@@ -124,34 +77,6 @@ func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Fra
124 return stream.frame(data, reset, echoAck), true, nil77 return stream.frame(data, reset, echoAck), true, nil
125 }78 }
126 79
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-
155 func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {80 func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {
156 require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding)81 require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding)
157 require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding)82 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) {
82 require.Same(t, sess, routed)82 require.Same(t, sess, routed)
83 83
84 sess.tabs[0].focusedPane().screen.Write([]byte("A"))84 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
85- d.paint(sess, ac, false)85+ d.paint(sess, ac, false, nil)
86 first := awaitFrame(t, tr.sends, ports.MsgOutput)86 first := awaitFrame(t, tr.sends, ports.MsgOutput)
87 out, err := ports.UnmarshalOutput(first.Payload)87 out, err := ports.UnmarshalOutput(first.Payload)
88 require.NoError(t, err)88 require.NoError(t, err)
@@ -90,7 +90,7 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
90 90
91 // Before the MsgAck, the renderer has already advanced along the ordered91 // Before the MsgAck, the renderer has already advanced along the ordered
92 // output dependency chain, so an unchanged repaint is a no-op.92 // output dependency chain, so an unchanged repaint is a no-op.
93- d.paint(sess, ac, false)93+ d.paint(sess, ac, false, nil)
94 requireNoOutputFrame(t, tr.sends)94 requireNoOutputFrame(t, tr.sends)
95 95
96 tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})}96 tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})}
@@ -99,7 +99,7 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
99 99
100 // The production MsgAck path retires retained states without moving the100 // The production MsgAck path retires retained states without moving the
101 // renderer baseline backward.101 // renderer baseline backward.
102- d.paint(sess, ac, false)102+ d.paint(sess, ac, false, nil)
103 requireNoOutputFrame(t, tr.sends)103 requireNoOutputFrame(t, tr.sends)
104 }104 }
105 105
@@ -111,7 +111,7 @@ func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) {
111 ac.replaceTransport(tr)111 ac.replaceTransport(tr)
112 sess.tabs[0].focusedPane().screen.Write([]byte("A"))112 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
113 113
114- d.paint(sess, ac, false)114+ d.paint(sess, ac, false, nil)
115 115
116 awaitFrame(t, tr.asyncSends, ports.MsgOutput)116 awaitFrame(t, tr.asyncSends, ports.MsgOutput)
117 select {117 select {
@@ -144,7 +144,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
144 }144 }
145 pane.screen.ClearDamage()145 pane.screen.ClearDamage()
146 client := vt.NewScreen(80, 25)146 client := vt.NewScreen(80, 25)
147- d.paint(sess, ac, true)147+ d.paint(sess, ac, true, nil)
148 first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))148 first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))
149 ac.ackOutputState(first.NewStateNum)149 ac.ackOutputState(first.NewStateNum)
150 150
@@ -164,7 +164,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
164 pane.screen.Col = 0164 pane.screen.Col = 0
165 pane.screen.Write([]byte("\nq"))165 pane.screen.Write([]byte("\nq"))
166 pane.screen.Frame = desired.Clone()166 pane.screen.Frame = desired.Clone()
167- d.paint(sess, ac, false)167+ d.paint(sess, ac, false, nil)
168 mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))168 mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))
169 }169 }
170 170
@@ -202,11 +202,11 @@ func TestLocalAttachStillAdvancesRendererOnSend(t *testing.T) {
202 d, sess, ac, sends := newManualSessionWithPTYs(t, p)202 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
203 203
204 sess.tabs[0].focusedPane().screen.Write([]byte("A"))204 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
205- d.paint(sess, ac, false)205+ d.paint(sess, ac, false, nil)
206 awaitFrame(t, sends, ports.MsgOutput)206 awaitFrame(t, sends, ports.MsgOutput)
207 207
208 // The first paint updates the renderer shadow without waiting for MsgAck.208 // The first paint updates the renderer shadow without waiting for MsgAck.
209- d.paint(sess, ac, false)209+ d.paint(sess, ac, false, nil)
210 requireNoOutputFrame(t, sends)210 requireNoOutputFrame(t, sends)
211 }211 }
212 212
@@ -217,17 +217,17 @@ func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) {
217 pane := sess.tabs[0].focusedPane()217 pane := sess.tabs[0].focusedPane()
218 218
219 pane.screen.Write([]byte("A"))219 pane.screen.Write([]byte("A"))
220- d.paint(sess, ac, false)220+ d.paint(sess, ac, false, nil)
221 first := awaitFrame(t, sends, ports.MsgOutput)221 first := awaitFrame(t, sends, ports.MsgOutput)
222 out, err := ports.UnmarshalOutput(first.Payload)222 out, err := ports.UnmarshalOutput(first.Payload)
223 require.NoError(t, err)223 require.NoError(t, err)
224 224
225 pane.screen.Write([]byte("\rB"))225 pane.screen.Write([]byte("\rB"))
226- d.paint(sess, ac, false)226+ d.paint(sess, ac, false, nil)
227 awaitFrame(t, sends, ports.MsgOutput)227 awaitFrame(t, sends, ports.MsgOutput)
228 228
229 ac.ackOutputState(out.NewStateNum)229 ac.ackOutputState(out.NewStateNum)
230- d.paint(sess, ac, false)230+ d.paint(sess, ac, false, nil)
231 requireNoOutputFrame(t, sends)231 requireNoOutputFrame(t, sends)
232 }232 }
233 233
@@ -264,7 +264,7 @@ func TestFailedPaintSendRollsBackOutputState(t *testing.T) {
264 ac.replaceTransport(failingOutputTransport{})264 ac.replaceTransport(failingOutputTransport{})
265 sess.tabs[0].focusedPane().screen.Write([]byte("A"))265 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
266 266
267- d.paint(sess, ac, false)267+ d.paint(sess, ac, false, nil)
268 268
269 require.Zero(t, ac.output.next)269 require.Zero(t, ac.output.next)
270 require.Zero(t, ac.output.acked)270 require.Zero(t, ac.output.acked)
@@ -279,30 +279,50 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
279 {name: "stream window", window: maxUnackedOutputStates},279 {name: "stream window", window: maxUnackedOutputStates},
280 } {280 } {
281 t.Run(tc.name, func(t *testing.T) {281 t.Run(tc.name, func(t *testing.T) {
282- p, releasePTY := newBlockingPTY(t)282+ // resizeReaderPTY delivers the child's SIGWINCH redraw through the
283- defer releasePTY()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)))
284 d, sess, ac, sends := newManualSessionWithPTYs(t, p)286 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
285 ac.output.maxOutstanding = tc.window287 ac.output.maxOutstanding = tc.window
286 pane := sess.activeTab().focusedPane()288 pane := sess.activeTab().focusedPane()
287-289+ p.applying = func() bool {
290+ pane.mu.Lock()
291+ defer pane.mu.Unlock()
292+ return pane.resizeApplying
293+ }
288 pane.screen.Write([]byte(strings.Repeat("A", 79)))294 pane.screen.Write([]byte(strings.Repeat("A", 79)))
289- d.paint(sess, ac, true)295+ d.paint(sess, ac, true, nil)
290 client := vt.NewScreen(80, 24)296 client := vt.NewScreen(80, 24)
291 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))297 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
292 ac.ackOutputState(initial.NewStateNum)298 ac.ackOutputState(initial.NewStateNum)
293 299
294- // The child can redraw the newly exposed columns while the resize300+ d.sessWg.Add(1)
295- // output is being prepared. The first visible grown frame must contain301+ pane.onExit = func() {}
296- // that redraw, regardless of the negotiated output window.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
297 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})309 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
298- pane.screen.Write([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))310+ timer := <-clock.timers
299- d.paint(sess, ac, false)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")
300 316
301 client.Resize(120, 24)317 client.Resize(120, 24)
302 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))318 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
303 require.Equal(t, 'B', client.Frame.At(100, 1).Rune,319 require.Equal(t, 'B', client.Frame.At(100, 1).Rune,
304 "first grown frame exposed stale pre-SIGWINCH pane content")320 "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+ }
306 })326 })
307 }327 }
308 }328 }
@@ -323,7 +343,9 @@ func TestResizeWithoutPTYOutputFlushesOneFullFrameAtDeadline(t *testing.T) {
323 }343 }
324 requireNoOutputFrame(t, sends)344 requireNoOutputFrame(t, sends)
325 345
346+ done := captureResizeCallbackDone(t, sess.renderCoordinator())
326 timer.ch <- time.Now()347 timer.ch <- time.Now()
348+ awaitTestCompletion(t, done, "resize callback did not complete")
327 frame := awaitFrame(t, sends, ports.MsgOutput)349 frame := awaitFrame(t, sends, ports.MsgOutput)
328 client := vt.NewScreen(120, 24)350 client := vt.NewScreen(120, 24)
329 out := mustApplyOutput(t, client, frame)351 out := mustApplyOutput(t, client, frame)
@@ -348,6 +370,7 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
348 case <-time.After(time.Second):370 case <-time.After(time.Second):
349 t.Fatal("first resize did not schedule a bounded paint")371 t.Fatal("first resize did not schedule a bounded paint")
350 }372 }
373+ firstDone := captureResizeCallbackDone(t, sess.renderCoordinator())
351 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})374 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
352 var latest *signalTimer375 var latest *signalTimer
353 select {376 select {
@@ -356,9 +379,12 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
356 t.Fatal("latest resize did not replace the bounded paint")379 t.Fatal("latest resize did not replace the bounded paint")
357 }380 }
358 381
382+ latestDone := captureResizeCallbackDone(t, sess.renderCoordinator())
359 first.ch <- time.Now()383 first.ch <- time.Now()
384+ awaitTestCompletion(t, firstDone, "obsolete resize callback did not complete")
360 requireNoOutputFrame(t, sends)385 requireNoOutputFrame(t, sends)
361 latest.ch <- time.Now()386 latest.ch <- time.Now()
387+ awaitTestCompletion(t, latestDone, "latest resize callback did not complete")
362 awaitFrame(t, sends, ports.MsgOutput)388 awaitFrame(t, sends, ports.MsgOutput)
363 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)389 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
364 require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width)390 require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width)
@@ -378,20 +404,21 @@ func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) {
378 MaxOutputInFlight: 1,404 MaxOutputInFlight: 1,
379 }, tr)405 }, tr)
380 require.NoError(t, err)406 require.NoError(t, err)
407+ require.True(t, sess.renderCoordinator().markAttachmentReady(sess.renderCoordinator().attachmentLease(ac)))
381 408
382 pane := sess.tabs[0].focusedPane()409 pane := sess.tabs[0].focusedPane()
383 pane.screen.Write([]byte("A"))410 pane.screen.Write([]byte("A"))
384- d.paint(sess, ac, false)411+ d.paint(sess, ac, false, nil)
385 first := awaitFrame(t, tr.sends, ports.MsgOutput)412 first := awaitFrame(t, tr.sends, ports.MsgOutput)
386 firstOutput, err := ports.UnmarshalOutput(first.Payload)413 firstOutput, err := ports.UnmarshalOutput(first.Payload)
387 require.NoError(t, err)414 require.NoError(t, err)
388 require.Equal(t, uint64(1), firstOutput.NewStateNum)415 require.Equal(t, uint64(1), firstOutput.NewStateNum)
389 for range 100 {416 for range 100 {
390 pane.screen.Write([]byte("x"))417 pane.screen.Write([]byte("x"))
391- d.paint(sess, ac, false)418+ d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
392 }419 }
393 pane.screen.Write([]byte("LATEST"))420 pane.screen.Write([]byte("LATEST"))
394- d.paint(sess, ac, false)421+ d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
395 requireNoOutputFrame(t, tr.sends)422 requireNoOutputFrame(t, tr.sends)
396 require.Equal(t, uint64(1), ac.output.outstanding(), "only one state-bearing datagram output may be unacknowledged")423 require.Equal(t, uint64(1), ac.output.outstanding(), "only one state-bearing datagram output may be unacknowledged")
397 424
M internal/usecase/daemon/overlay_runtime.go +7 -5
@@ -13,11 +13,13 @@ import (
13 type overlayRuntime struct {13 type overlayRuntime struct {
14 ac *attachedClient14 ac *attachedClient
15 15
16- pickerMu sync.Mutex16+ pickerMu sync.Mutex
17- picker *picker.Model17+ picker *picker.Model
18- pickerPreview *tab18+ pickerPreview *tab
19- pickerPending []byte19+ pickerPreviewSession *session
20- pickerESC pendingByteTimer20+ pickerPreviewGeneration uint64
21+ pickerPending []byte
22+ pickerESC pendingByteTimer
21 23
22 paletteMu sync.Mutex24 paletteMu sync.Mutex
23 palette *palette.Model25 palette *palette.Model
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
@@ -173,7 +173,7 @@ func TestOverlayRuntimeSnapshotCarriesCopyDocumentAndClearReleasesIt(t *testing.
173 func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) {173 func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) {
174 ac := &attachedClient{}174 ac := &attachedClient{}
175 ac.initOverlays()175 ac.initOverlays()
176- history := scopy.NewScrollback(1)176+ history := newTestHistory(1)
177 history.Append(testRow("before"))177 history.Append(testRow("before"))
178 document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1))178 document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1))
179 179
M internal/usecase/daemon/paint_gate_test.go +0 -112
@@ -1,14 +1,10 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "io"
5 "testing"4 "testing"
6 "time"5 "time"
7 6
8 "github.com/bnema/vev/internal/ports"7 "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"
12 )8 )
13 9
14 func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) {10 func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) {
@@ -25,111 +21,3 @@ func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) {
25 }21 }
26 }22 }
27 }23 }
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) {
42 ac.overlays.paletteHints = palette.ContextualHints{}42 ac.overlays.paletteHints = palette.ContextualHints{}
43 ac.overlays.palettePending = nil43 ac.overlays.palettePending = nil
44 ac.overlays.paletteMu.Unlock()44 ac.overlays.paletteMu.Unlock()
45- d.paint(sess, ac, true)45+ d.invalidateRender(sess, ac, true, "palette.go")
46 }46 }
47 47
48 func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {48 func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {
@@ -167,12 +167,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
167 }167 }
168 ac.overlays.paletteMu.Unlock()168 ac.overlays.paletteMu.Unlock()
169 if cancel {169 if cancel {
170- d.paint(sess, ac, true)170+ d.invalidateRender(sess, ac, true, "palette.go")
171 return171 return
172 }172 }
173 if !execute {173 if !execute {
174 if changed {174 if changed {
175- d.paint(sess, ac, true)175+ d.invalidateRender(sess, ac, true, "palette.go")
176 }176 }
177 return177 return
178 }178 }
@@ -183,7 +183,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
183 rank, err := command.ParsePositiveDecimal(args)183 rank, err := command.ParsePositiveDecimal(args)
184 if err != nil {184 if err != nil {
185 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")185 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
186- d.paint(sess, ac, true)186+ d.invalidateRender(sess, ac, true, "palette.go")
187 return187 return
188 }188 }
189 // The captured ID is handed off atomically. A target can disappear after189 // The captured ID is handed off atomically. A target can disappear after
@@ -191,14 +191,14 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
191 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}191 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}
192 if err := exec.JumpRecentSession(rank); err != nil {192 if err := exec.JumpRecentSession(rank); err != nil {
193 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")193 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
194- d.paint(sess, ac, true)194+ d.invalidateRender(sess, ac, true, "palette.go")
195 return195 return
196 }196 }
197 // Publication remains generation-safe; do not let stale work close a197 // Publication remains generation-safe; do not let stale work close a
198 // newer palette interaction.198 // newer palette interaction.
199 if ac.closeExecutedPalette(generation, rawQuery) {199 if ac.closeExecutedPalette(generation, rawQuery) {
200 d.recordPaletteUse(cmd.Code)200 d.recordPaletteUse(cmd.Code)
201- d.paint(ac.currentSession(), ac, true)201+ d.invalidateRender(ac.currentSession(), ac, true, "palette.go")
202 }202 }
203 return203 return
204 }204 }
@@ -259,7 +259,7 @@ type paletteExec struct {
259 }259 }
260 260
261 func (e paletteExec) CreateTab() error {261 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")
263 return e.d.createTab(e.sess, e.ac.size)263 return e.d.createTab(e.sess, e.ac.size)
264 }264 }
265 265
@@ -325,7 +325,7 @@ func (e paletteExec) NextTab() error {
325 if e.sess.switchRelative(1) {325 if e.sess.switchRelative(1) {
326 e.d.activateTab(e.sess, e.sess.activeTab())326 e.d.activateTab(e.sess, e.sess.activeTab())
327 }327 }
328- e.d.paint(e.sess, e.ac, true)328+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
329 return nil329 return nil
330 }330 }
331 331
@@ -333,7 +333,7 @@ func (e paletteExec) PrevTab() error {
333 if e.sess.switchRelative(-1) {333 if e.sess.switchRelative(-1) {
334 e.d.activateTab(e.sess, e.sess.activeTab())334 e.d.activateTab(e.sess, e.sess.activeTab())
335 }335 }
336- e.d.paint(e.sess, e.ac, true)336+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
337 return nil337 return nil
338 }338 }
339 339
M internal/usecase/daemon/palette_test.go +2 -2
@@ -371,7 +371,7 @@ func TestPaletteCreateTabErrorRepaintsAfterClose(t *testing.T) {
371 d, sess, ac, sends, releases := newManualTabSession(t, 1)371 d, sess, ac, sends, releases := newManualTabSession(t, 1)
372 defer releases[0]()372 defer releases[0]()
373 ptys := portsmocks.NewMockPTYFactory(t)373 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"))
375 d.ptys = ptys375 d.ptys = ptys
376 376
377 d.handleInput(sess, ac, []byte("\x1b "))377 d.handleInput(sess, ac, []byte("\x1b "))
@@ -518,7 +518,7 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {
518 go func() {518 go func() {
519 defer wg.Done()519 defer wg.Done()
520 for range 200 {520 for range 200 {
521- d.paint(sess, ac, true)521+ d.paint(sess, ac, true, nil)
522 }522 }
523 }()523 }()
524 go func() {524 go func() {
M internal/usecase/daemon/pane.go +26 -32
@@ -6,27 +6,32 @@ import (
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9- scopy "github.com/bnema/vev/internal/usecase/copy"
10 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
11 "github.com/bnema/vev/pkg/vt"10 "github.com/bnema/vev/pkg/vt"
12 )11 )
13 12
14-// pane owns one PTY, its terminal screen state, and render scheduling channels.13+// pane owns one PTY and its terminal screen state.
15 // Lock order when multiple locks are held is:14 // Lock order when multiple locks are held is:
16 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.15 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
17 // The PTY reader takes only pane.mu, so child output never waits on client IO.16 // The PTY reader takes only pane.mu, so child output never waits on client IO.
18 type pane struct {17 type pane struct {
19- id layout.PaneID18+ id layout.PaneID
20- stableID string19+ stableID string
21- pty ports.PTY20+ pty ports.PTY
22- mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title21+ mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title
23- resizeMu sync.Mutex // serializes PTY resizes without holding mu22+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
24- screen *vt.Screen23+ screen *vt.Screen
25- scrollback *scopy.Scrollback24+ history *vt.History
26- dirty chan struct{}25+ syncGen uint64
27- flush chan struct{}26+ rect domain.Rect
28- syncGen uint6427+ // resizeApplying gates VT parsing across PTY.Resize. The reader continues
29- rect domain.Rect28+ // 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
30 popupGeometry floatingGeometry // last geometry committed after a successful floating resize35 popupGeometry floatingGeometry // last geometry committed after a successful floating resize
31 title paneTitleState36 title paneTitleState
32 ctx context.Context37 ctx context.Context
@@ -42,25 +47,14 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
42 }47 }
43 48
44 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {49 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
45- sb := scopy.NewScrollback(defaultScrollbackRows)50+ screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows})
46- screen := vt.NewScreen(sz.Cols, sz.Rows)
47- screen.OnLineEvicted = sb.Append
48 return &pane{51 return &pane{
49- id: id,52+ id: id,
50- stableID: stableID,53+ stableID: stableID,
51- pty: pty,54+ pty: pty,
52- screen: screen,55+ screen: screen,
53- scrollback: sb,56+ history: screen.History(),
54- dirty: make(chan struct{}, 1),57+ rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
55- flush: make(chan struct{}, 1),58+ title: paneTitleState{displayFallback: "sh"},
56- rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
57- title: paneTitleState{displayFallback: "sh"},
58 }59 }
59 }60 }
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(
23 ) error {23 ) error {
24 if d.ptys == nil {24 if d.ptys == nil {
25 if ac != nil {25 if ac != nil {
26- d.paint(sess, ac, true)26+ d.invalidateRender(sess, ac, true, "pane_actions.go")
27 }27 }
28 return nil28 return nil
29 }29 }
@@ -69,7 +69,7 @@ func (d *Daemon) spawnPaneOp(
69 tb.mu.Unlock()69 tb.mu.Unlock()
70 return fmt.Errorf("daemon: generating pane identity: %w", err)70 return fmt.Errorf("daemon: generating pane identity: %w", err)
71 }71 }
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))
73 if err != nil {73 if err != nil {
74 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")74 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
75 tb.mu.Lock()75 tb.mu.Lock()
@@ -99,7 +99,7 @@ func (d *Daemon) spawnPaneOp(
99 d.startPaneGoroutines(sess, tb, p)99 d.startPaneGoroutines(sess, tb, p)
100 markSnapshotDirty(sess)100 markSnapshotDirty(sess)
101 if ac != nil {101 if ac != nil {
102- d.paint(sess, ac, true)102+ d.invalidateRender(sess, ac, true, "pane_actions.go")
103 }103 }
104 return nil104 return nil
105 }105 }
@@ -111,7 +111,7 @@ func (d *Daemon) applyLayoutLocked(tb *tab) {
111 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})111 placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
112 if !ok {112 if !ok {
113 for _, p := range tb.panes {113 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})
115 }115 }
116 return116 return
117 }117 }
@@ -120,12 +120,12 @@ func (d *Daemon) applyLayoutLocked(tb *tab) {
120 continue120 continue
121 }121 }
122 if p := tb.panes[pl.ID]; p != nil {122 if p := tb.panes[pl.ID]; p != nil {
123- d.resizePane(p, pl.Content)123+ d.applyPaneResize(p, pl.Content)
124 }124 }
125 }125 }
126 }126 }
127 127
128-func (d *Daemon) resizePane(p *pane, r domain.Rect) {128+func (d *Daemon) applyPaneResize(p *pane, r domain.Rect) {
129 if p == nil {129 if p == nil {
130 return130 return
131 }131 }
@@ -167,7 +167,7 @@ func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
167 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {167 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
168 if d.ptys == nil {168 if d.ptys == nil {
169 if ac != nil {169 if ac != nil {
170- d.paint(sess, ac, true)170+ d.invalidateRender(sess, ac, true, "pane_actions.go")
171 }171 }
172 return nil172 return nil
173 }173 }
@@ -188,7 +188,7 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
188 if err == nil {188 if err == nil {
189 markSnapshotDirty(sess)189 markSnapshotDirty(sess)
190 if ac != nil {190 if ac != nil {
191- d.paint(sess, ac, true)191+ d.invalidateRender(sess, ac, true, "pane_actions.go")
192 }192 }
193 }193 }
194 return err194 return err
@@ -197,7 +197,7 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
197 func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {197 func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
198 if d.ptys == nil {198 if d.ptys == nil {
199 if ac != nil {199 if ac != nil {
200- d.paint(sess, ac, true)200+ d.invalidateRender(sess, ac, true, "pane_actions.go")
201 }201 }
202 return nil202 return nil
203 }203 }
@@ -255,8 +255,12 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
255 255
256 if ac != nil {256 if ac != nil {
257 ac.overlays.clearCopyModeForPane(p)257 ac.overlays.clearCopyModeForPane(p)
258+ ac.pruneCaptureFrames(p)
258 }259 }
259 260
261+ if rc := sess.renderCoordinator(); rc != nil {
262+ rc.noteSyncPaneRemoved(p)
263+ }
260 if p.cancel != nil {264 if p.cancel != nil {
261 p.cancel()265 p.cancel()
262 }266 }
@@ -267,7 +271,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
267 markSnapshotDirty(sess)271 markSnapshotDirty(sess)
268 if repaint {272 if repaint {
269 if ac != nil {273 if ac != nil {
270- d.paint(sess, ac, true)274+ d.invalidateRender(sess, ac, true, "pane_actions.go")
271 }275 }
272 }276 }
273 return nil277 return nil
@@ -305,7 +309,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
305 d.refreshPaneTitleOnFocus(sess, newFocus)309 d.refreshPaneTitleOnFocus(sess, newFocus)
306 }310 }
307 }311 }
308- d.paint(sess, ac, true)312+ d.invalidateRender(sess, ac, true, "pane_actions.go")
309 }313 }
310 if errors.Is(err, layout.ErrNoPane) {314 if errors.Is(err, layout.ErrNoPane) {
311 return nil315 return nil
M internal/usecase/daemon/pane_actions_test.go +9 -9
@@ -39,7 +39,7 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
39 newPTY := portsmocks.NewMockPTY(t)39 newPTY := portsmocks.NewMockPTY(t)
40 oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once()40 oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once()
41 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()41 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()
43 43
44 require.NoError(t, d.splitPane(sess, nil, tt.dir))44 require.NoError(t, d.splitPane(sess, nil, tt.dir))
45 45
@@ -68,7 +68,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
68 newPTY := portsmocks.NewMockPTY(t)68 newPTY := portsmocks.NewMockPTY(t)
69 stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()69 stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
70 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()70 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()
72 72
73 require.NoError(t, d.splitPane(sess, nil, layout.Right))73 require.NoError(t, d.splitPane(sess, nil, layout.Right))
74 74
@@ -84,7 +84,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
84 84
85 func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {85 func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
86 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})86 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()
88 88
89 tb := sess.activeTab()89 tb := sess.activeTab()
90 tb.mu.Lock()90 tb.mu.Lock()
@@ -231,7 +231,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
231 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})231 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})
232 newPTY := portsmocks.NewMockPTY(t)232 newPTY := portsmocks.NewMockPTY(t)
233 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()233 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()
235 235
236 require.NoError(t, d.stackPane(sess, nil))236 require.NoError(t, d.stackPane(sess, nil))
237 tb := sess.activeTab()237 tb := sess.activeTab()
@@ -251,7 +251,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
251 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})251 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})
252 newPTY := portsmocks.NewMockPTY(t)252 newPTY := portsmocks.NewMockPTY(t)
253 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()253 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()
255 require.NoError(t, d.stackPane(sess, nil))255 require.NoError(t, d.stackPane(sess, nil))
256 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once()256 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once()
257 257
@@ -261,7 +261,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
261 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)261 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
262 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)262 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)
263 thirdPTY := portsmocks.NewMockPTY(t)263 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()
265 require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall)265 require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall)
266 require.Len(t, tb.panes, 2)266 require.Len(t, tb.panes, 2)
267 }267 }
@@ -291,7 +291,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
291 defer releaseNew()291 defer releaseNew()
292 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()292 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
293 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()293 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()
295 295
296 require.NoError(t, d.splitPane(sess, nil, layout.Right))296 require.NoError(t, d.splitPane(sess, nil, layout.Right))
297 tb := sess.activeTab()297 tb := sess.activeTab()
@@ -350,8 +350,8 @@ func TestSplitPaneInheritsTerminalEnv(t *testing.T) {
350 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()350 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
351 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()351 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
352 var gotEnv []string352 var gotEnv []string
353- factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(353+ factory.EXPECT().Open(mock.Anything, "/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) {354+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
355 gotEnv = append([]string(nil), env...)355 gotEnv = append([]string(nil), env...)
356 return newPTY, nil356 return newPTY, nil
357 },357 },
M internal/usecase/daemon/performance_benchmark_test.go +650 -54
@@ -5,6 +5,7 @@ import (
5 "io"5 "io"
6 "sync"6 "sync"
7 "testing"7 "testing"
8+ "time"
8 9
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 11
@@ -49,8 +50,15 @@ func TestPerformanceFixtureCounters(t *testing.T) {
49 fixture.paintLive()50 fixture.paintLive()
50 metrics := fixture.metrics()51 metrics := fixture.metrics()
51 require.Equal(t, uint64(2), metrics.outputFrames)52 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")
52 require.Positive(t, metrics.outputBytes)56 require.Positive(t, metrics.outputBytes)
53 require.Positive(t, metrics.outputPayloadBytes)57 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")
54 62
55 fixture.captureSnapshot()63 fixture.captureSnapshot()
56 metrics = fixture.metrics()64 metrics = fixture.metrics()
@@ -64,14 +72,70 @@ func TestPerformanceFixtureCounters(t *testing.T) {
64 require.True(t, fixture.resized())72 require.True(t, fixture.resized())
65 }73 }
66 74
67-func TestPerformanceFixtureResizeAlternatesRealDimensions(t *testing.T) {75+func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) {
68- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}})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}}
69 99
70- for _, want := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}} {100+ for _, want := range sequence {
71- fixture.resize()101+ fixture.resizeTo(want)
72 require.Equal(t, want, fixture.ac.size)102 require.Equal(t, want, fixture.ac.size)
73 require.True(t, fixture.resized())103 require.True(t, fixture.resized())
74 }104 }
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)
75 }139 }
76 140
77 func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {141 func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
@@ -81,45 +145,137 @@ func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
81 for _, tb := range fixture.sess.tabs {145 for _, tb := range fixture.sess.tabs {
82 require.Len(t, tb.panes, 4)146 require.Len(t, tb.panes, 4)
83 for _, p := range tb.panes {147 for _, p := range tb.panes {
84- require.Equal(t, 10_000, p.scrollback.Len())148+ require.Equal(t, 10_000, p.history.Len())
85 }149 }
86 }150 }
87 }151 }
88 152
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+
89 func BenchmarkDaemonHistoryLivePaint(b *testing.B) {208 func BenchmarkDaemonHistoryLivePaint(b *testing.B) {
90 benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() })209 benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() })
91 }210 }
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.
92 func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) {215 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)
94 }229 }
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.
95 func BenchmarkDaemonHistoryCopyEnter(b *testing.B) {235 func BenchmarkDaemonHistoryCopyEnter(b *testing.B) {
96 benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) })236 benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) })
97 }237 }
98 func BenchmarkDaemonHistoryCopySearch(b *testing.B) {238 func BenchmarkDaemonHistoryCopySearch(b *testing.B) {
99 benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) })239 benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) })
100 }240 }
101-func BenchmarkDaemonHistoryResize(b *testing.B) {241+
102- benchmarkDaemonLargeHistory(b, "resize", func(f *performanceFixture) { f.resize() })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+ })
103 }264 }
104 265
105 func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) {266 func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) {
106 b.Helper()267 b.Helper()
107- for _, topology := range []struct {268+ for _, topology := range daemonHistoryTopologies {
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- } {
117 b.Run(topology.name, func(b *testing.B) {269 b.Run(topology.name, func(b *testing.B) {
118 fixture := newPerformanceFixture(b, performanceConfig{270 fixture := newPerformanceFixture(b, performanceConfig{
119 size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,271 size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,
120 })272 })
273+ if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {
274+ b.Fatal("invalid daemon history fixture")
275+ }
121 if workload == "copy-search" {276 if workload == "copy-search" {
122- fixture.d.enterCopyMode(fixture.sess, fixture.ac)277+ benchmarkDaemonCopySearch(b, fixture, run)
278+ return
123 }279 }
124 b.ReportAllocs()280 b.ReportAllocs()
125 fixture.resetMetrics()281 fixture.resetMetrics()
@@ -133,26 +289,180 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
133 b.Fatalf("decode last output: %v", err)289 b.Fatalf("decode last output: %v", err)
134 }290 }
135 }291 }
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- }
145 if workload == "live-paint" && metrics.outputFrames != uint64(b.N) {292 if workload == "live-paint" && metrics.outputFrames != uint64(b.N) {
146 b.Fatalf("live paint emitted %d frames for %d operations", metrics.outputFrames, b.N)293 b.Fatalf("live paint emitted %d frames for %d operations", metrics.outputFrames, b.N)
147 }294 }
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- }
151 benchmarkReportMetrics(b, metrics, b.N, 10_000)295 benchmarkReportMetrics(b, metrics, b.N, 10_000)
152 })296 })
153 }297 }
154 }298 }
155 299
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+
156 func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations, historyRows int) {466 func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations, historyRows int) {
157 b.Helper()467 b.Helper()
158 if operations == 0 {468 if operations == 0 {
@@ -164,9 +474,89 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
164 b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op")474 b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op")
165 b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op")475 b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op")
166 b.ReportMetric(float64(metrics.snapshotBytes)/perOperation, "snapshotbytes/op")476 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")
167 b.ReportMetric(float64(historyRows), "historyrows/pane")498 b.ReportMetric(float64(historyRows), "historyrows/pane")
168 }499 }
169 500
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+
170 func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {560 func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {
171 fixture := newPerformanceFixture(t, performanceConfig{})561 fixture := newPerformanceFixture(t, performanceConfig{})
172 562
@@ -189,29 +579,119 @@ type performanceConfig struct {
189 tabs int579 tabs int
190 panes int580 panes int
191 historyRows int581 historyRows int
582+ ptyErrors []error
192 }583 }
193 584
194 type performanceMetrics struct {585 type performanceMetrics struct {
195- outputFrames uint64586+ outputFrames uint64
196- outputBytes uint64587+ outputBytes uint64
197- outputPayloadBytes uint64588+ outputPayloadBytes uint64
198- snapshotWrites uint64589+ snapshotWrites uint64
199- snapshotBytes uint64590+ 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
200 }669 }
201 670
202 type performanceFixture struct {671 type performanceFixture struct {
203- t testing.TB672+ t testing.TB
204- d *Daemon673+ d *Daemon
205- sess *session674+ sess *session
206- ac *attachedClient675+ ac *attachedClient
207- output *countingOutputTransport676+ output *countingOutputTransport
208- snaps *countingSnapshotStore677+ snaps *countingSnapshotStore
209- activePane *pane678+ activePane *pane
210- liveWrites [][]byte679+ liveWrites [][]byte
211- paints int680+ paints int
212- resizeSizes [2]domain.Size681+ resizeSizes [2]domain.Size
213- resizes int682+ resizes int
214- resizedSize domain.Size683+ 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
215 }695 }
216 696
217 func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {697 func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {
@@ -228,8 +708,12 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
228 if config.historyRows == 0 {708 if config.historyRows == 0 {
229 config.historyRows = 1709 config.historyRows = 1
230 }710 }
711+ pty := &scriptedPerformancePTY{}
231 ptys := make([]ports.PTY, config.tabs)712 ptys := make([]ports.PTY, config.tabs)
713+ ptys[0] = pty
232 d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...)714 d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...)
715+ clock := &performanceClock{}
716+ d.clock = clock
233 output := &countingOutputTransport{}717 output := &countingOutputTransport{}
234 ac.tr = output718 ac.tr = output
235 ac.size = config.size719 ac.size = config.size
@@ -244,18 +728,34 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
244 ac: ac,728 ac: ac,
245 output: output,729 output: output,
246 snaps: &countingSnapshotStore{},730 snaps: &countingSnapshotStore{},
731+ pty: pty,
732+ clock: clock,
247 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},733 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
248 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},734 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
249 }735 }
250 WithSnapshotStore(fixture.snaps)(d)736 WithSnapshotStore(fixture.snaps)(d)
737+ d.startSnapshotEncodeWorker()
738+ t.Cleanup(d.stopSnapshotEncodeWorker)
251 for tabIndex, tb := range sess.tabs {739 for tabIndex, tb := range sess.tabs {
252 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))740 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))
253 }741 }
254 fixture.activePane = fixture.findActivePane()742 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)
255 755
256 // Prime the real renderer shadow before measurements. Subsequent paints use756 // Prime the real renderer shadow before measurements. Subsequent paints use
257 // actual production diffs rather than the initial full frame.757 // actual production diffs rather than the initial full frame.
258- d.paint(sess, ac, true)758+ d.paint(sess, ac, true, nil)
259 fixture.resetMetrics()759 fixture.resetMetrics()
260 return fixture760 return fixture
261 }761 }
@@ -278,7 +778,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR
278 for _, p := range tb.panes {778 for _, p := range tb.panes {
279 p.mu.Lock()779 p.mu.Lock()
280 width := p.screen.Frame.Width780 width := p.screen.Frame.Width
281- for row := 0; p.scrollback.Len() < historyRows; row++ {781+ for row := 0; p.history.Len() < historyRows; row++ {
282 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))782 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
283 }783 }
284 p.mu.Unlock()784 p.mu.Unlock()
@@ -311,13 +811,20 @@ func (f *performanceFixture) paintLive() {
311 p.mu.Lock()811 p.mu.Lock()
312 p.screen.Write(f.liveWrites[f.paints%len(f.liveWrites)])812 p.screen.Write(f.liveWrites[f.paints%len(f.liveWrites)])
313 p.mu.Unlock()813 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+ }
315 f.paints++819 f.paints++
316 }820 }
317 821
318 func (f *performanceFixture) captureSnapshot() {822 func (f *performanceFixture) captureSnapshot() {
319 f.t.Helper()823 f.t.Helper()
320 require.True(f.t, f.d.captureSession(f.sess))824 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)
321 }828 }
322 829
323 func (f *performanceFixture) enterCopySearch() {830 func (f *performanceFixture) enterCopySearch() {
@@ -326,6 +833,30 @@ func (f *performanceFixture) enterCopySearch() {
326 f.d.handleInput(f.sess, f.ac, []byte("/needle\r"))833 f.d.handleInput(f.sess, f.ac, []byte("/needle\r"))
327 }834 }
328 835
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+
329 func (f *performanceFixture) searchMatches() int {860 func (f *performanceFixture) searchMatches() int {
330 f.ac.overlays.copyMu.Lock()861 f.ac.overlays.copyMu.Lock()
331 defer f.ac.overlays.copyMu.Unlock()862 defer f.ac.overlays.copyMu.Unlock()
@@ -335,10 +866,48 @@ func (f *performanceFixture) searchMatches() int {
335 return len(f.ac.overlays.copyMode.Searches)866 return len(f.ac.overlays.copyMode.Searches)
336 }867 }
337 868
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+
338 func (f *performanceFixture) resize() {875 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
340 f.resizes++881 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+ }
342 }911 }
343 912
344 func (f *performanceFixture) resized() bool {913 func (f *performanceFixture) resized() bool {
@@ -359,15 +928,42 @@ func (f *performanceFixture) resized() bool {
359 func (f *performanceFixture) resetMetrics() {928 func (f *performanceFixture) resetMetrics() {
360 f.output.reset()929 f.output.reset()
361 f.snaps.reset()930 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+ }
362 }942 }
363 943
364 func (f *performanceFixture) metrics() performanceMetrics {944 func (f *performanceFixture) metrics() performanceMetrics {
365 output := f.output.metrics()945 output := f.output.metrics()
366 snapshots := f.snaps.metrics()946 snapshots := f.snaps.metrics()
367- return performanceMetrics{947+ metrics := performanceMetrics{
368 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,948 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
369 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,949 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
370 }965 }
966+ return metrics
371 }967 }
372 968
373 const outputPayloadHeaderBytes = 24969 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: the1+// Package daemon holds vev's server-side session multiplexer use case.
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.
25 package daemon2 package daemon
26 3
27 import (4 import (
@@ -32,7 +9,6 @@ import (
32 "github.com/bnema/vev/internal/usecase/keys"9 "github.com/bnema/vev/internal/usecase/keys"
33 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
34 "github.com/bnema/vev/internal/usecase/picker"11 "github.com/bnema/vev/internal/usecase/picker"
35- themeui "github.com/bnema/vev/internal/usecase/theme"
36 "github.com/bnema/vev/internal/usecase/ui"12 "github.com/bnema/vev/internal/usecase/ui"
37 "github.com/bnema/vev/pkg/renderer"13 "github.com/bnema/vev/pkg/renderer"
38 )14 )
@@ -47,7 +23,7 @@ func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
47 ac.overlays.pickerPreview = nil23 ac.overlays.pickerPreview = nil
48 ac.overlays.pickerMu.Unlock()24 ac.overlays.pickerMu.Unlock()
49 d.registerPreviewForSelection(ac)25 d.registerPreviewForSelection(ac)
50- d.paint(sess, ac, true)26+ d.invalidateRender(sess, ac, true, "picker.go")
51 }27 }
52 28
53 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {29 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
@@ -141,7 +117,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
141 d.killPickerTarget(target)117 d.killPickerTarget(target)
142 }118 }
143 d.refreshPicker(ac)119 d.refreshPicker(ac)
144- d.paint(sess, ac, true)120+ d.invalidateRender(sess, ac, true, "picker.go")
145 return121 return
146 case 'j':122 case 'j':
147 ac.overlays.picker.Down()123 ac.overlays.picker.Down()
@@ -191,7 +167,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
191 return167 return
192 }168 }
193 if exit || changed {169 if exit || changed {
194- d.paint(sess, ac, true)170+ d.invalidateRender(sess, ac, true, "picker.go")
195 }171 }
196 }172 }
197 173
@@ -207,11 +183,12 @@ func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
207 ac.overlays.pickerESC.timer = nil183 ac.overlays.pickerESC.timer = nil
208 ac.overlays.pickerESC.done = nil184 ac.overlays.pickerESC.done = nil
209 ac.overlays.picker = nil185 ac.overlays.picker = nil
186+ generation := ac.overlays.pickerPreviewGeneration
210 ac.overlays.pickerMu.Unlock()187 ac.overlays.pickerMu.Unlock()
211 188
212- d.unregisterPreview(ac)189+ d.clearPreviewGeneration(ac, generation)
213 if sess := ac.currentSession(); sess != nil {190 if sess := ac.currentSession(); sess != nil {
214- d.paint(sess, ac, true)191+ d.invalidateRender(sess, ac, true, "picker.go")
215 }192 }
216 })193 })
217 }194 }
@@ -239,89 +216,138 @@ func isPickerEscapePrefix(data []byte) bool {
239 }216 }
240 217
241 func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {218 func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
219+ if ac == nil || ac.overlays == nil {
220+ return
221+ }
242 ac.overlays.pickerMu.Lock()222 ac.overlays.pickerMu.Lock()
243 var target picker.Target223 var target picker.Target
244 var ok bool224 var ok bool
245 if ac.overlays.picker != nil {225 if ac.overlays.picker != nil {
246 target, ok = ac.overlays.picker.Selected()226 target, ok = ac.overlays.picker.Selected()
247 }227 }
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
248 ac.overlays.pickerMu.Unlock()233 ac.overlays.pickerMu.Unlock()
249-234+ d.teardownPreviewSubscription(ac, previous, previousGeneration)
250- var next *tab235+ if !ok {
251- if ok {236+ return
252- next = d.tabByTarget(target)237+ }
238+ next := d.tabByTarget(target)
239+ targetSess := d.sessionByID(target.Session)
240+ if next == nil || targetSess == nil {
241+ return
253 }242 }
254-
255 ac.overlays.pickerMu.Lock()243 ac.overlays.pickerMu.Lock()
256- old := ac.overlays.pickerPreview244+ // Selection may have changed while the target was resolved.
257- if ac.overlays.picker == nil {245+ selected, stillSelected := ac.overlays.picker.Selected()
258- next = nil246+ valid := ac.overlays.pickerPreviewGeneration == generation && stillSelected && selected == target
247+ if valid {
248+ ac.overlays.pickerPreview = next
249+ ac.overlays.pickerPreviewSession = targetSess
259 }250 }
260- ac.overlays.pickerPreview = next
261 ac.overlays.pickerMu.Unlock()251 ac.overlays.pickerMu.Unlock()
262-252+ if !valid {
263- if old != nil && old != next {253+ return
264- old.mu.Lock()254+ }
265- if old.previewClient == ac {255+ // A same-session preview is already invalidated by its own coordinator.
266- old.previewClient = nil256+ // 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+ }
267 }267 }
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)
269 }285 }
270- if next != nil {286+}
271- next.mu.Lock()
272- next.previewClient = ac
273- next.mu.Unlock()
274 287
275- ac.overlays.pickerMu.Lock()288+func (d *Daemon) teardownPreviewSubscription(ac *attachedClient, previous *session, generation uint64) {
276- keep := ac.overlays.pickerPreview == next289+ if previous != nil {
277- ac.overlays.pickerMu.Unlock()290+ if rc := previous.renderCoordinator(); rc != nil {
278- if !keep {291+ rc.teardownPreviewFor(ac, generation)
279- next.mu.Lock()
280- if next.previewClient == ac {
281- next.previewClient = nil
282- }
283- next.mu.Unlock()
284 }292 }
285 }293 }
286 }294 }
287 295
288 func (d *Daemon) unregisterPreview(ac *attachedClient) {296 func (d *Daemon) unregisterPreview(ac *attachedClient) {
297+ if ac == nil || ac.overlays == nil {
298+ return
299+ }
289 ac.overlays.pickerMu.Lock()300 ac.overlays.pickerMu.Lock()
290- old := ac.overlays.pickerPreview301+ generation := ac.overlays.pickerPreviewGeneration
291- ac.overlays.pickerPreview = nil
292 ac.overlays.pickerMu.Unlock()302 ac.overlays.pickerMu.Unlock()
303+ d.clearPreviewGeneration(ac, generation)
304+}
293 305
294- if old != nil {306+// clearPreviewGeneration removes the preview only if it is still the observed
295- old.mu.Lock()307+// selection. Teardown paths may race a new picker selection.
296- if old.previewClient == ac {308+func (d *Daemon) clearPreviewGeneration(ac *attachedClient, generation uint64) bool {
297- old.previewClient = nil309+ ac.overlays.pickerMu.Lock()
298- }310+ if ac.overlays.pickerPreviewGeneration != generation {
299- old.mu.Unlock()311+ ac.overlays.pickerMu.Unlock()
312+ return false
300 }313 }
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
301 }321 }
302 322
323+// clearDestroyedTabPreview clears coordinator-owned picker state for clients
324+// that were previewing a removed tab. Tabs do not route render ownership.
303 func (d *Daemon) clearDestroyedTabPreview(tb *tab) {325 func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
304- tb.mu.Lock()326+ if d == nil || d.sessions == nil {
305- previewer := tb.previewClient
306- tb.mu.Unlock()
307- if previewer == nil {
308 return327 return
309 }328 }
310- cleared := false329+ d.mu.Lock()
311- previewer.overlays.pickerMu.Lock()330+ var clients []*attachedClient
312- if previewer.overlays.pickerPreview == tb {331+ for _, sess := range d.sessions {
313- previewer.overlays.pickerPreview = nil332+ sess.mu.Lock()
314- cleared = true333+ if sess.client != nil {
315- }334+ clients = append(clients, sess.client)
316- previewer.overlays.pickerMu.Unlock()335+ }
317- tb.mu.Lock()336+ sess.mu.Unlock()
318- if tb.previewClient == previewer {
319- tb.previewClient = nil
320 }337 }
321- tb.mu.Unlock()338+ d.mu.Unlock()
322- if cleared {339+ for _, ac := range clients {
323- if sess := previewer.currentSession(); sess != nil {340+ if ac == nil || ac.overlays == nil {
324- d.paint(sess, previewer, true)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+ }
325 }351 }
326 }352 }
327 }353 }
@@ -331,8 +357,9 @@ func (d *Daemon) closePicker(ac *attachedClient) {
331 ac.overlays.picker = nil357 ac.overlays.picker = nil
332 ac.overlays.pickerPending = nil358 ac.overlays.pickerPending = nil
333 d.stopPickerPendingTimerLocked(ac)359 d.stopPickerPendingTimerLocked(ac)
360+ generation := ac.overlays.pickerPreviewGeneration
334 ac.overlays.pickerMu.Unlock()361 ac.overlays.pickerMu.Unlock()
335- d.unregisterPreview(ac)362+ d.clearPreviewGeneration(ac, generation)
336 }363 }
337 364
338 func (d *Daemon) tabByTarget(target picker.Target) *tab {365 func (d *Daemon) tabByTarget(target picker.Target) *tab {
@@ -362,19 +389,19 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
362 }389 }
363 targetSess := d.sessionByID(target.Session)390 targetSess := d.sessionByID(target.Session)
364 if targetSess == nil {391 if targetSess == nil {
365- d.paint(sess, ac, true)392+ d.invalidateRender(sess, ac, true, "picker.go")
366 return false393 return false
367 }394 }
368 if targetSess == sess {395 if targetSess == sess {
369 if sess.switchTab(target.TabIndex) {396 if sess.switchTab(target.TabIndex) {
370 d.activateTab(sess, sess.activeTab())397 d.activateTab(sess, sess.activeTab())
371 }398 }
372- d.paint(sess, ac, true)399+ d.invalidateRender(sess, ac, true, "picker.go")
373 return true400 return true
374 }401 }
375 old := d.stealClientForTarget(sess, ac, targetSess, target)402 old := d.stealClientForTarget(sess, ac, targetSess, target)
376 if ac.currentSession() != targetSess {403 if ac.currentSession() != targetSess {
377- d.paint(sess, ac, true)404+ d.invalidateRender(sess, ac, true, "picker.go")
378 return false405 return false
379 }406 }
380 if old != nil && old != ac {407 if old != nil && old != ac {
@@ -407,16 +434,22 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
407 targetSess.mu.Lock()434 targetSess.mu.Lock()
408 old := targetSess.client435 old := targetSess.client
409 targetSess.terminal = term436 targetSess.terminal = term
410- targetSess.client = ac
411 if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {437 if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
412 targetSess.active = target.TabIndex438 targetSess.active = target.TabIndex
413 }439 }
414 targetSess.mu.Unlock()440 targetSess.mu.Unlock()
415 441
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)
417 ac.setSession(targetSess)445 ac.setSession(targetSess)
446+ targetSess.mu.Lock()
447+ targetSess.client = ac
448+ targetSess.mu.Unlock()
449+ d.touchMRU(targetSess)
418 ac.recordPreviousSession(from)450 ac.recordPreviousSession(from)
419 d.mu.Unlock()451 d.mu.Unlock()
452+ finishRenderLifecycleCleanups(cleanups)
420 return old453 return old
421 }454 }
422 455
@@ -425,7 +458,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
425 stopped, ok := d.stopped[target.Name]458 stopped, ok := d.stopped[target.Name]
426 if !ok || stopped.purging {459 if !ok || stopped.purging {
427 d.mu.Unlock()460 d.mu.Unlock()
428- d.paint(from, ac, true)461+ d.invalidateRender(from, ac, true, "picker.go")
429 return false462 return false
430 }463 }
431 from.mu.Lock()464 from.mu.Lock()
@@ -441,20 +474,22 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
441 from.mu.Unlock()474 from.mu.Unlock()
442 d.mu.Unlock()475 d.mu.Unlock()
443 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)476 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")
445 return false478 return false
446 }479 }
447 from.client = nil480 from.client = nil
448 ac.setSession(nil)481 ac.setSession(nil)
449 from.mu.Unlock()482 from.mu.Unlock()
483+
484+ cleanups := d.handoffCoordinator(from, targetSess, nil, ac)
485+ ac.setSession(targetSess)
450 targetSess.mu.Lock()486 targetSess.mu.Lock()
451 targetSess.client = ac487 targetSess.client = ac
452 targetSess.mu.Unlock()488 targetSess.mu.Unlock()
453-
454 d.touchMRU(targetSess)489 d.touchMRU(targetSess)
455- ac.setSession(targetSess)
456 ac.recordPreviousSession(from)490 ac.recordPreviousSession(from)
457 d.mu.Unlock()491 d.mu.Unlock()
492+ finishRenderLifecycleCleanups(cleanups)
458 d.firstPaint(targetSess, ac, ac.size)493 d.firstPaint(targetSess, ac, ac.size)
459 return true494 return true
460 }495 }
@@ -496,49 +531,59 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
496 d.registerPreviewForSelection(ac)531 d.registerPreviewForSelection(ac)
497 }532 }
498 533
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-
514 func snapshotPickerPreview(tb *tab) picker.Preview {534 func snapshotPickerPreview(tb *tab) picker.Preview {
515 if tb == nil {535 if tb == nil {
516 return picker.Preview{}536 return picker.Preview{}
517 }537 }
518 tb.mu.Lock()538 tb.mu.Lock()
519- defer tb.mu.Unlock()539+ layoutSnap := solveTabLayoutLocked(tb)
520- return pickerPreviewFromLockedTab(tb)540+ if !layoutSnap.ok {
521-}541+ p := tb.focusedPane()
522-542+ if p == nil {
523-func pickerPreviewFromLockedTab(tb *tab) picker.Preview {543+ tb.mu.Unlock()
524- p := tb.focusedPane()544+ return picker.Preview{}
525- if p == nil {545+ }
526- return picker.Preview{}
527- }
528- if tb.tree == nil || tb.tree.Root == nil || tb.tree.Root.Kind == layout.Leaf {
529 p.mu.Lock()546 p.mu.Lock()
530- defer p.mu.Unlock()547+ preview := pickerPreviewFromLockedPane(p)
531- return 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)
532 }572 }
573+ tb.mu.Unlock()
574+ return pickerPreviewFromCapturedRender(state)
575+}
533 576
534- area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}577+func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview {
535- if area.Width <= 0 || area.Height <= 0 {578+ composed := composeFrame(state, composeCacheInput{}, composeCacheInput{}).frame
536- p.mu.Lock()579+ if composed.Height < 2 {
537- defer p.mu.Unlock()580+ return pickerPreviewFromFrame(composed)
538- return pickerPreviewFromLockedPane(p)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)...)
539 }585 }
540- frame, _ := composeTabFrame(tb, area, themeui.Theme{})586+ return picker.Preview{Rows: rows, Width: composed.Width, Height: len(rows)}
541- return pickerPreviewFromFrame(frame)
542 }587 }
543 588
544 func pickerPreviewFromLockedPane(p *pane) picker.Preview {589 func pickerPreviewFromLockedPane(p *pane) picker.Preview {
M internal/usecase/daemon/picker_test.go +151 -126
@@ -2,7 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5- "sync"5+ "strings"
6 "testing"6 "testing"
7 "time"7 "time"
8 8
@@ -15,6 +15,7 @@ import (
15 "github.com/bnema/vev/internal/usecase/keys"15 "github.com/bnema/vev/internal/usecase/keys"
16 "github.com/bnema/vev/internal/usecase/layout"16 "github.com/bnema/vev/internal/usecase/layout"
17 "github.com/bnema/vev/internal/usecase/picker"17 "github.com/bnema/vev/internal/usecase/picker"
18+ "github.com/bnema/vev/pkg/vt"
18 )19 )
19 20
20 // --- test doubles -----------------------------------------------------------21 // --- test doubles -----------------------------------------------------------
@@ -217,6 +218,47 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
217 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)218 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
218 }219 }
219 220
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+
220 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {262 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
221 p1, releasePTY1 := newBlockingPTY(t)263 p1, releasePTY1 := newBlockingPTY(t)
222 p2, releasePTY2 := newBlockingPTY(t)264 p2, releasePTY2 := newBlockingPTY(t)
@@ -259,6 +301,40 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
259 awaitFrame(t, sends1, ports.MsgOutput)301 awaitFrame(t, sends1, ports.MsgOutput)
260 }302 }
261 303
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+
262 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {338 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
263 p1, releasePTY1 := newBlockingPTY(t)339 p1, releasePTY1 := newBlockingPTY(t)
264 p2, releasePTY2 := newBlockingPTY(t)340 p2, releasePTY2 := newBlockingPTY(t)
@@ -295,7 +371,7 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
295 oldPane.screen.Write([]byte("stale-damage"))371 oldPane.screen.Write([]byte("stale-damage"))
296 require.NotEmpty(t, oldPane.screen.Damage())372 require.NotEmpty(t, oldPane.screen.Damage())
297 373
298- d.paint(sess1, ac1, false)374+ d.paint(sess1, ac1, false, nil)
299 375
300 require.Zero(t, len(sends1), "stale paint from old session sent a frame")376 require.Zero(t, len(sends1), "stale paint from old session sent a frame")
301 require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage")377 require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage")
@@ -329,7 +405,7 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
329 sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching"))405 sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching"))
330 paintDone := make(chan struct{})406 paintDone := make(chan struct{})
331 go func() {407 go func() {
332- d.paint(sess1, ac, false)408+ d.paint(sess1, ac, false, nil)
333 close(paintDone)409 close(paintDone)
334 }()410 }()
335 <-enteredSend411 <-enteredSend
@@ -368,8 +444,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
368 defer releasePTY3()444 defer releasePTY3()
369 var openedEnv []string445 var openedEnv []string
370 f := portsmocks.NewMockPTYFactory(t)446 f := portsmocks.NewMockPTYFactory(t)
371- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(447+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
372- func(_ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {448+ func(_ context.Context, _ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
373 if sz != (domain.Size{Cols: 80, Rows: 22}) {449 if sz != (domain.Size{Cols: 80, Rows: 22}) {
374 return newQuietPTY(), nil450 return newQuietPTY(), nil
375 }451 }
@@ -403,6 +479,72 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
403 require.NotContains(t, openedEnv, "TERM=xterm-direct")479 require.NotContains(t, openedEnv, "TERM=xterm-direct")
404 }480 }
405 481
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+
406 func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {548 func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
407 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})549 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
408 p := tb.focusedPane()550 p := tb.focusedPane()
@@ -448,123 +590,6 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
448 require.Equal(t, 'R', preview.Rows[2][21].Rune, "expanded stacked pane content should be included")590 require.Equal(t, 'R', preview.Rows[2][21].Rune, "expanded stacked pane content should be included")
449 }591 }
450 592
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-
568 func TestPickerModalGeometry(t *testing.T) {593 func TestPickerModalGeometry(t *testing.T) {
569 base := domain.Size{Cols: 100, Rows: 40}594 base := domain.Size{Cols: 100, Rows: 40}
570 595
@@ -582,7 +607,7 @@ func TestPickerResizeRecomposesModal(t *testing.T) {
582 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})607 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
583 // Picker recomposition is the relevant event here; do not depend on a real608 // Picker recomposition is the relevant event here; do not depend on a real
584 // resize-idle timer in this synchronous rendering test.609 // resize-idle timer in this synchronous rendering test.
585- d.paint(sess, ac, false)610+ d.paint(sess, ac, false, nil)
586 611
587 out := awaitFrame(t, sends, ports.MsgOutput)612 out := awaitFrame(t, sends, ports.MsgOutput)
588 msg, err := ports.UnmarshalOutput(out.Payload)613 msg, err := ports.UnmarshalOutput(out.Payload)
@@ -600,8 +625,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
600 var opens [][]string625 var opens [][]string
601 f := portsmocks.NewMockPTYFactory(t)626 f := portsmocks.NewMockPTYFactory(t)
602 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}627 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
603- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(628+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
604- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {629+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
605 opens = append(opens, append([]string(nil), env...))630 opens = append(opens, append([]string(nil), env...))
606 if len(opens) == 1 {631 if len(opens) == 1 {
607 return p1, nil632 return p1, nil
@@ -610,7 +635,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
610 },635 },
611 ).Twice()636 ).Twice()
612 floating := newQuietPTY()637 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 {
614 return got != normalSize && got.Valid()639 return got != normalSize && got.Valid()
615 })).Return(floating, nil).Once()640 })).Return(floating, nil).Once()
616 d := newTestDaemon(t, f, stubClock{})641 d := newTestDaemon(t, f, stubClock{})
M internal/usecase/daemon/prompt.go +5 -11
@@ -6,7 +6,6 @@ import (
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7 promptui "github.com/bnema/vev/internal/usecase/prompt"7 promptui "github.com/bnema/vev/internal/usecase/prompt"
8 "github.com/bnema/vev/internal/usecase/ui"8 "github.com/bnema/vev/internal/usecase/ui"
9- "github.com/bnema/vev/pkg/renderer"
10 )9 )
11 10
12 func promptModalFor(title string) ui.Modal {11 func promptModalFor(title string) ui.Modal {
@@ -20,7 +19,7 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s
20 ac.overlays.promptSubmit = submit19 ac.overlays.promptSubmit = submit
21 ac.overlays.promptPending = nil20 ac.overlays.promptPending = nil
22 ac.overlays.promptMu.Unlock()21 ac.overlays.promptMu.Unlock()
23- d.paint(sess, ac, true)22+ d.invalidateRender(sess, ac, true, "prompt.go")
24 }23 }
25 24
26 func (d *Daemon) closePrompt(ac *attachedClient) {25 func (d *Daemon) closePrompt(ac *attachedClient) {
@@ -77,26 +76,21 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
77 ac.overlays.prompt.SetError(err.Error())76 ac.overlays.prompt.SetError(err.Error())
78 }77 }
79 ac.overlays.promptMu.Unlock()78 ac.overlays.promptMu.Unlock()
80- d.paint(sess, ac, true)79+ d.invalidateRender(sess, ac, true, "prompt.go")
81 return80 return
82 }81 }
83 d.closePrompt(ac)82 d.closePrompt(ac)
84 if current := ac.currentSession(); current != nil {83 if current := ac.currentSession(); current != nil {
85- d.paint(current, ac, true)84+ d.invalidateRender(current, ac, true, "prompt.go")
86 }85 }
87 return86 return
88 }87 }
89 if exit {88 if exit {
90 d.closePrompt(ac)89 d.closePrompt(ac)
91- d.paint(sess, ac, true)90+ d.invalidateRender(sess, ac, true, "prompt.go")
92 return91 return
93 }92 }
94 if changed {93 if changed {
95- d.paint(sess, ac, true)94+ d.invalidateRender(sess, ac, true, "prompt.go")
96 }95 }
97 }96 }
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: the1+// Package daemon holds vev's server-side session multiplexer use case.
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.
25 package daemon2 package daemon
26 3
27 import (4 import (
28 "bytes"5 "bytes"
29- "errors"
30- "strconv"
31- "time"
32 6
33 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
34 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
35 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
36 themeui "github.com/bnema/vev/internal/usecase/theme"10 themeui "github.com/bnema/vev/internal/usecase/theme"
37- "github.com/bnema/vev/internal/usecase/ui"
38 "github.com/bnema/vev/pkg/renderer"11 "github.com/bnema/vev/pkg/renderer"
39- "github.com/bnema/vev/pkg/vt"
40 )12 )
41 13
42-func signal(ch chan struct{}) {14+// completedSynchronizedUpdate reports a full DEC 2026 batch contained in one
43- select {15+// PTY read. The VT's final state is inactive in this case, so it cannot be
44- case ch <- struct{}{}:16+// inferred from before/after SyncUpdateActive alone.
45- default: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+ }
46 }51 }
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
47 }83 }
48 84
49 func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {85 func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
@@ -52,61 +88,17 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
52 return88 return
53 }89 }
54 buf := make([]byte, ptyReadBufSize)90 buf := make([]byte, ptyReadBufSize)
55- var resp []byte
56- var clipboards []string
57- attentionCh := make(chan struct{}, 1)
58 p.mu.Lock()91 p.mu.Lock()
59- p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }92+ p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
60- p.screen.OnBell = func() { signal(attentionCh) }93+ p.screen.OnBell = func() { p.ptyAttention = true }
61- p.screen.OnNotify = func(string, string) { signal(attentionCh) }94+ p.screen.OnNotify = func(string, string) { p.ptyAttention = true }
62- p.screen.OnProgress = func(bool) { signal(attentionCh) }95+ p.screen.OnProgress = func(bool) { p.ptyAttention = true }
63- p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) }96+ p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
64 p.mu.Unlock()97 p.mu.Unlock()
65 for {98 for {
66 n, err := p.pty.Read(buf)99 n, err := p.pty.Read(buf)
67 if n > 0 {100 if n > 0 {
68- data := buf[:n]101+ d.processPTYData(sess, tb, p, buf[:n], true)
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)
110 }102 }
111 if err != nil {103 if err != nil {
112 if p.onExit != nil {104 if p.onExit != nil {
@@ -119,250 +111,182 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
119 }111 }
120 }112 }
121 113
122-// scheduler debounces dirty signals. The first dirty opens a short tab;114+// processPTYData is the sole VT parsing path. While a resize apply owns the
123-// sustained floods progressively widen that tab, while isolated updates115+// pane, reader calls append exact bytes and returns immediately; it never waits
124-// return to the minimum delay for interactive latency.116+// for PTY.Resize or loses a short read.
125-func (d *Daemon) scheduler(sess *session, tb *tab, p *pane) {117+func (d *Daemon) processPTYData(sess *session, tb *tab, p *pane, data []byte, bufferDuringApply bool) {
126- defer d.sessWg.Done()118+ rc := sess.renderCoordinator()
127- if p == nil {119+ p.mu.Lock()
120+ if bufferDuringApply && p.resizeApplying {
121+ p.resizePending = append(p.resizePending, data...)
122+ p.mu.Unlock()
128 return123 return
129 }124 }
130- delay := minDebounceInterval125+ wasSyncing := p.screen.SyncUpdateActive()
131- lastRender := d.clock.Now()126+ p.screen.Write(data)
132-outer:127+ p.refreshTerminalTitleLocked()
133- for {128+ isSyncing := p.screen.SyncUpdateActive()
134- select {129+ completeSyncRead := !wasSyncing && !isSyncing && completedSynchronizedUpdate(data)
135- case <-sess.ctx.Done():130+ var syncGen uint64
136- return131+ syncEnded := false
137- case <-paneDone(p):132+ if wasSyncing != isSyncing {
138- return133+ if isSyncing {
139- case <-p.flush:134+ syncGen = sess.syncGen.Add(1)
140- d.render(sess, tb, p)135+ p.syncGen = syncGen
141- lastRender = d.clock.Now()136+ if rc != nil {
142- continue137+ rc.noteSyncBeginWithRenderability(p, syncGen, func() bool { return d.paneRenderable(sess, tb, p) }, func() {
143- case <-p.dirty:138+ p.mu.Lock()
144- if d.clock.Now().Sub(lastRender) >= maxDebounceInterval {139+ if p.syncGen == syncGen && p.screen.SyncUpdateActive() {
145- delay = minDebounceInterval140+ p.screen.ForceSyncEnd()
141+ }
142+ p.mu.Unlock()
143+ })
146 }144 }
145+ } else {
146+ syncGen = p.syncGen
147+ syncEnded = rc != nil && rc.removeSyncEnd(p, syncGen, true)
147 }148 }
148-149+ }
149- coalesced := 0150+ p.mu.Unlock()
150- timer := d.clock.NewTimer(delay)151+ renderable := d.paneRenderable(sess, tb, p)
151- absorb:152+ markSnapshotDirty(sess)
152- for {153+ d.flushPTYEffects(sess, tb, p)
153- select {154+ if rc != nil {
154- case <-sess.ctx.Done():155+ if wasSyncing != isSyncing {
155- timer.Stop()156+ if isSyncing {
156- return157+ if renderable {
157- case <-paneDone(p):158+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
158- timer.Stop()
159- return
160- case <-p.flush:
161- if !timer.Stop() {
162- select {
163- case <-timer.C():
164- default:
165- }
166 }159 }
167- d.render(sess, tb, p)160+ } else if syncEnded && renderable {
168- lastRender = d.clock.Now()161+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
169- continue outer
170- case <-p.dirty:
171- coalesced++
172- case <-timer.C():
173- break absorb
174 }162 }
175 }163 }
176- delay = nextDebounceDelay(delay, coalesced)164+ if completeSyncRead && renderable {
177- d.render(sess, tb, p)165+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
178- lastRender = d.clock.Now()166+ } else if renderable && !isSyncing && wasSyncing == isSyncing {
179- }167+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
180-}168+ }
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
192 }169 }
193- return delay
194 }170 }
195 171
196-func (d *Daemon) syncWatchdog(p *pane, gen uint64) {172+// flushPTYEffects preserves the normal response/title/attention/clipboard
197- timer := d.clock.NewTimer(maxSyncUpdateDuration)173+// behavior for both direct reads and resize-buffer replay.
198- select {174+func (d *Daemon) flushPTYEffects(sess *session, tb *tab, p *pane) {
199- case <-paneDone(p):
200- timer.Stop()
201- return
202- case <-timer.C():
203- }
204-
205 p.mu.Lock()175 p.mu.Lock()
206- if p.syncGen != gen || !p.screen.SyncUpdateActive() {176+ attention := p.ptyAttention
207- p.mu.Unlock()177+ p.ptyAttention = false
208- return178+ responses := append([]byte(nil), p.ptyResponses...)
209- }179+ p.ptyResponses = p.ptyResponses[:0]
210- p.screen.ForceSyncEnd()180+ clipboards := append([]string(nil), p.ptyClipboards...)
181+ p.ptyClipboards = p.ptyClipboards[:0]
211 p.mu.Unlock()182 p.mu.Unlock()
212- signal(p.flush)183+ if attention {
213-}184+ d.noteAttention(sess, tb)
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
224 }185 }
225- p.mu.Lock()186+ for _, b64 := range clipboards {
226- if p.screen.SyncUpdateActive() {187+ d.forwardClipboardAsync(sess, b64)
227- p.mu.Unlock()
228- return
229 }188 }
230- p.mu.Unlock()189+ if len(responses) > 0 {
231-190+ if _, err := p.pty.Write(responses); err != nil {
232- // A retained floating pane continues to drain its PTY while hidden. Its191+ d.log.Warn("pty response write failed", "err", err, "session", sess.name)
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)
254 }192 }
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
270 }193 }
271- d.refreshBarScriptsIfDue(sess, d.clock.Now(), false)
272- d.paint(sess, ac, false)
273 }194 }
274 195
275 // paint draws the composed client frame (active tab plus status bar) and196 // paint draws the composed client frame (active tab plus status bar) and
276 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations197 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
277 // such as switch/create/close/rename/resize so the repaint is complete.198 // such as switch/create/close/rename/resize so the repaint is complete.
278-// copyTargetRectLocked maps a captured copy source into the already-composed199+func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid bool) {
279-// client frame. The caller holds tb.mu, preserving the layout/floating200+ if d != nil && d.runtimeObserver != nil {
280-// snapshot while the rectangle is chosen.201+ d.runtimeObserver.ObserveRuntime(ports.NewRuntimeMark("daemon", kind, bytes, valid))
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- }
295 }202 }
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)}
300 }203 }
301 204
302-func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {205+// runtimeMarkBatch captures immutable runtime marks during a render transaction.
303- if !ok {206+// paint owns attachment sendMu while it captures, composes, and emits; JSONL
304- return nil207+// observer I/O must therefore happen only after that ownership is released.
305- }208+type runtimeMarkBatch struct {
306- ids := make([]layout.PaneID, 0, len(placements))209+ observer ports.RuntimeObserver
307- for _, pl := range placements {210+ marks []ports.RuntimeMark
308- if pl.TitleBar.Height > 0 {211+}
309- ids = append(ids, pl.ID)212+
310- }213+func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
214+ if d == nil {
215+ return runtimeMarkBatch{}
311 }216 }
312- return ids217+ return runtimeMarkBatch{observer: d.runtimeObserver}
313 }218 }
314 219
315-func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) {220+func (b *runtimeMarkBatch) span(start, end ports.RuntimeMarkKind, bytes uint64) func(uint64, bool) {
316- ac.resizePaint.stop()221+ if b == nil || b.observer == nil {
317- ac.resizePaintGeneration++222+ return func(uint64, bool) {}
318- generation := ac.resizePaintGeneration223+ }
319- ac.resizePaintPending = true224+ correlation := ports.NewRuntimeCorrelation()
320- ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) {225+ b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, start, bytes, true))
321- d.paintForResizeGeneration(sess, ac, true, generation)226+ return func(endBytes uint64, valid bool) {
322- })227+ b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, end, endBytes, valid))
228+ }
323 }229 }
324 230
325-func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {231+func (b *runtimeMarkBatch) flush() {
326- d.paintForResizeGeneration(sess, ac, reset, 0)232+ if b == nil || b.observer == nil {
233+ return
234+ }
235+ for _, mark := range b.marks {
236+ b.observer.ObserveRuntime(mark)
237+ }
327 }238 }
328 239
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()
330 tb := sess.activeTab()242 tb := sess.activeTab()
331 if tb == nil {243 if tb == nil {
332 return244 return
333 }245 }
334 246
335- ac.initOverlays()
336 ac.sendMu.Lock()247 ac.sendMu.Lock()
337- if ac.currentSession() != sess {248+ // sendMu is the attachment ownership boundary. Check the session's
338- ac.sendMu.Unlock()249+ // published identity while holding it so a deadline captured before an
339- return250+ // attach/replace cannot emit on either the old or new output chain.
340- }251+ sess.mu.Lock()
341- if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) {252+ owned := sess.client == ac
253+ sess.mu.Unlock()
254+ if !owned || ac.currentSession() != sess {
342 ac.sendMu.Unlock()255 ac.sendMu.Unlock()
343 return256 return
344 }257 }
345- if ac.resizePaintPending {258+ if lease != nil {
346- ac.resizePaint.stop()259+ rc := sess.renderCoordinator()
347- ac.resizePaintPending = false260+ if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {
348- reset = true261+ ac.sendMu.Unlock()
262+ return
263+ }
349 }264 }
350- // Ack-gated coalescing: while the client is at least maxUnackedOutputStates265+ // Capacity is checked before any destructive capture. The coordinator is
351- // behind, skip composing another diff and mark the paint deferred. The266+ // the normal gate, but this ownership check also protects direct resize and
352- // MsgOutputAck handler flushes one cumulative paint once acks catch up. A267+ // test paint paths from consuming damage that cannot be emitted.
353- // deferred reset stays sticky so the cumulative paint is dependency-free.268+ if ac.output != nil && ac.output.atCapacity() {
354- if ac.output.deferIfAtCapacity(reset) {269+ // The coordinator owns the blocked interval; this guard only protects
270+ // direct test/resize paint calls from destructive capture.
355 ac.sendMu.Unlock()271 ac.sendMu.Unlock()
356 return272 return
357 }273 }
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()
358 overlays := ac.overlays.SnapshotForRender()278 overlays := ac.overlays.SnapshotForRender()
359 repaintAttachedClients := false279 repaintAttachedClients := false
360 defer func() {280 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()
361 if repaintAttachedClients {286 if repaintAttachedClients {
362 d.repaintAllAttachedClients()287 d.repaintAllAttachedClients()
363 }288 }
364 }()289 }()
365- defer overlays.Unlock()
366 preview := snapshotPickerPreview(nil)290 preview := snapshotPickerPreview(nil)
367 if overlays.previewTab != tb {291 if overlays.previewTab != tb {
368 preview = snapshotPickerPreview(overlays.previewTab)292 preview = snapshotPickerPreview(overlays.previewTab)
@@ -375,11 +299,16 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
375 attentionVisible := pulseVisible(bars.attentionFrame)299 attentionVisible := pulseVisible(bars.attentionFrame)
376 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)300 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
377 301
378- styles := newThemeStyles(ac.getTheme())
379 paletteCfg := d.currentPaletteConfig()302 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.
380 tb.mu.Lock()306 tb.mu.Lock()
381- layoutSnap := solveTabLayoutLocked(tb)307+ titleIDs := ac.renderScratch.titleIDs[:0]
382- titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok)308+ if tb.tree != nil {
309+ titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)
310+ }
311+ ac.renderScratch.titleIDs = titleIDs
383 floating := tb.floating.pane312 floating := tb.floating.pane
384 hasFloating := tb.floating.state == floatingVisible && floating != nil313 hasFloating := tb.floating.state == floatingVisible && floating != nil
385 tb.mu.Unlock()314 tb.mu.Unlock()
@@ -390,189 +319,35 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
390 d.refreshPaneDisplayTitle(sess, floating, false)319 d.refreshPaneDisplayTitle(sess, floating, false)
391 }320 }
392 321
393- tb.mu.Lock()322+ capturedOverlays := capturedOverlayRenderState{
394- if !layoutSnap.matchesLocked(tb) {323+ copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
395- layoutSnap = solveTabLayoutLocked(tb)324+ pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
325+ copyFeedback: overlays.copyFeedback,
396 }326 }
397- p := tb.focusedPane()327+ endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
398- if p == nil {328+ state, ok := capturePrimaryRenderState(sess, ac, bars, capturedOverlays, preview, floatingCfg, reset, lease)
399- tb.mu.Unlock()329+ endCapture(0, ok)
400- ac.sendMu.Unlock()330+ if !ok {
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()
484 ac.sendMu.Unlock()331 ac.sendMu.Unlock()
485 return332 return
486 }333 }
487- prepared, err := ac.output.prepare(frame, damage, reset || overlayActive)334+ if ac.renderStages.capture != nil {
488- var data []byte335+ ac.renderStages.capture()
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)
524 }336 }
525-}337+ if overlays.pickerActive && overlays.previewTab == tb {
526-338+ previewState := *state
527-func focusedPaneContentRect(layoutSnap tabLayoutSnapshot, id layout.PaneID) (domain.Rect, bool) {339+ previewState.overlays = capturedOverlayRenderState{}
528- if !layoutSnap.ok {340+ previewState.reset = true
529- return layoutSnap.area, layoutSnap.area.Width > 0 && layoutSnap.area.Height > 0341+ state.preview = pickerPreviewFromCapturedRender(previewState)
530 }342 }
531- for _, pl := range layoutSnap.placements {343+ captureOverlayLayers(state, overlays, paletteCfg)
532- if pl.ID == id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {344+ endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0)
533- return pl.Content, true345+ composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
534- }346+ endCompose(0, true)
347+ if ac.renderStages.compose != nil {
348+ ac.renderStages.compose()
535 }349 }
536- return domain.Rect{}, false350+ d.emitFrame(sess, ac, state, composed, &marks)
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
576 }351 }
577 352
578 type themeStyles struct {353 type themeStyles struct {
@@ -638,211 +413,6 @@ func (c *barCache) Reset() {
638 c.bottom = nil413 c.bottom = nil
639 }414 }
640 415
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-
846 func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {416 func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
847 pl.Content.X += dx417 pl.Content.X += dx
848 pl.Content.Y += dy418 pl.Content.Y += dy
@@ -851,15 +421,6 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
851 return pl421 return pl
852 }422 }
853 423
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-
863 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {424 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {
864 rows := min(r.Height, src.Height)425 rows := min(r.Height, src.Height)
865 cols := min(r.Width, src.Width)426 cols := min(r.Width, src.Width)
@@ -874,25 +435,6 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo
874 }435 }
875 }436 }
876 437
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-
896 func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {438 func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {
897 if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 {439 if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 {
898 return440 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) {
55 require.True(t, paletteVisible, "palette must remain composed above copy mode")55 require.True(t, paletteVisible, "palette must remain composed above copy mode")
56 }56 }
57 57
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+
58 func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {95 func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
59 p, release := newBlockingPTY(t)96 p, release := newBlockingPTY(t)
60 defer release()97 defer release()
@@ -84,7 +121,7 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
84 client := vt.NewScreen(80, 25)121 client := vt.NewScreen(80, 25)
85 pane := sess.tabs[0].focusedPane()122 pane := sess.tabs[0].focusedPane()
86 pane.screen.Write([]byte("X"))123 pane.screen.Write([]byte("X"))
87- d.paint(sess, ac, true)124+ d.paint(sess, ac, true, nil)
88 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))125 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
89 undimmed := client.Frame.At(0, 1)126 undimmed := client.Frame.At(0, 1)
90 topBar := client.Frame.At(0, 0)127 topBar := client.Frame.At(0, 0)
@@ -110,6 +147,451 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
110 // stubClock returns timers whose channel never fires, so a scheduler under it147 // stubClock returns timers whose channel never fires, so a scheduler under it
111 // blocks in its debounce loop until the session context is cancelled. Used by148 // blocks in its debounce loop until the session context is cancelled. Used by
112 149
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+
113 func TestPTYWriteErrorIsLogged(t *testing.T) {595 func TestPTYWriteErrorIsLogged(t *testing.T) {
114 var logs bytes.Buffer596 var logs bytes.Buffer
115 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil)))597 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil)))
@@ -130,56 +612,6 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
130 }612 }
131 }613 }
132 614
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-
183 func TestAltXClosesFinalTabAndDetaches(t *testing.T) {615 func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
184 d, sess, ac, sends, releases := newManualTabSession(t, 1)616 d, sess, ac, sends, releases := newManualTabSession(t, 1)
185 defer releases[0]()617 defer releases[0]()
@@ -262,87 +694,6 @@ func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
262 d.sessWg.Wait()694 d.sessWg.Wait()
263 }695 }
264 696
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-
346 // --- resize ordering --------------------------------------------------------697 // --- resize ordering --------------------------------------------------------
347 698
348 func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {699 func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
@@ -372,14 +723,14 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
372 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})723 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})
373 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))724 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))
374 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))725 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))
375- require.Equal(t, 2, win.focusedPane().scrollback.Len())726+ require.Equal(t, 2, win.focusedPane().history.Len())
376- require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0)))727+ require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0)))
377- require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1)))728+ require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1)))
378 729
379 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})730 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
380 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))731 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))
381 require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1))732 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())
383 }734 }
384 735
385 func frameRowString(f renderer.Frame, y int) string {736 func frameRowString(f renderer.Frame, y int) string {
@@ -451,39 +802,6 @@ func TestTranslatePaneDamagePreservesFullWidthScrollFastPathOnly(t *testing.T) {
451 }802 }
452 }803 }
453 804
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-
487 func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {805 func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {
488 win := newTab(nil, domain.Size{Cols: 41, Rows: 4})806 win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
489 left := win.focusedPane()807 left := win.focusedPane()
@@ -735,7 +1053,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
735 {1053 {
736 name: "picker",1054 name: "picker",
737 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterPicker(sess, ac) },1055 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) },
739 visible: "Sessions",1057 visible: "Sessions",
740 notVisible: "Sessions",1058 notVisible: "Sessions",
741 },1059 },
@@ -744,7 +1062,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
744 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) },1062 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) },
745 close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) },1063 close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) },
746 prepareScreen: func(sess *session) {1064 prepareScreen: func(sess *session) {
747- sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)1065+ installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
748 },1066 },
749 visible: "[SCROLL]",1067 visible: "[SCROLL]",
750 notVisible: "[SCROLL]",1068 notVisible: "[SCROLL]",
@@ -756,10 +1074,10 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
756 p, release := newBlockingPTY(t)1074 p, release := newBlockingPTY(t)
757 d, sess, ac, sends := newManualSessionWithPTYs(t, p)1075 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
758 defer release()1076 defer release()
759- sess.tabs[0].focusedPane().screen.Write([]byte("live"))
760 if tt.prepareScreen != nil {1077 if tt.prepareScreen != nil {
761 tt.prepareScreen(sess)1078 tt.prepareScreen(sess)
762 }1079 }
1080+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
763 if tt.wantRestored == "" {1081 if tt.wantRestored == "" {
764 tt.wantRestored = "live"1082 tt.wantRestored = "live"
765 }1083 }
@@ -783,23 +1101,23 @@ func TestOverlayPaintBypassesComposedCache(t *testing.T) {
783 pane := sess.tabs[0].focusedPane()1101 pane := sess.tabs[0].focusedPane()
784 pane.screen.Write([]byte("live"))1102 pane.screen.Write([]byte("live"))
785 1103
786- d.paint(sess, ac, true)1104+ d.paint(sess, ac, true, nil)
787 _ = mustOutputData(t, sends)1105 _ = mustOutputData(t, sends)
788- require.True(t, ac.composed.valid)1106+ require.True(t, ac.pipelineCache.valid)
789- require.NotContains(t, frameText(ac.composed.frame), "Rename session")1107+ require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
790 1108
791 d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })1109 d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
792 shown := string(mustOutputData(t, sends))1110 shown := string(mustOutputData(t, sends))
793 require.Contains(t, shown, "Rename session")1111 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")1112+ require.False(t, ac.pipelineCache.valid, "overlay paint must not store the modal-mutated frame in the composed cache")
795- require.NotContains(t, frameText(ac.composed.frame), "Rename session")1113+ require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
796 1114
797 d.closePrompt(ac)1115 d.closePrompt(ac)
798- d.paint(sess, ac, false)1116+ d.paint(sess, ac, false, nil)
799 restored := string(mustOutputData(t, sends))1117 restored := string(mustOutputData(t, sends))
800 require.NotContains(t, restored, "Rename session")1118 require.NotContains(t, restored, "Rename session")
801- require.True(t, ac.composed.valid)1119+ require.True(t, ac.pipelineCache.valid)
802- cached := frameText(ac.composed.frame)1120+ cached := frameText(ac.pipelineCache.frame)
803 require.Contains(t, cached, "live")1121 require.Contains(t, cached, "live")
804 require.NotContains(t, cached, "Rename session")1122 require.NotContains(t, cached, "Rename session")
805 }1123 }
@@ -934,7 +1252,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
934 d.resize(sess, ac, newSize)1252 d.resize(sess, ac, newSize)
935 // This test verifies resize ordering, not the idle fallback. Consume the1253 // This test verifies resize ordering, not the idle fallback. Consume the
936 // pending resize paint deterministically under the non-firing stub clock.1254 // pending resize paint deterministically under the non-firing stub clock.
937- d.paint(sess, ac, false)1255+ d.paint(sess, ac, false, nil)
938 1256
939 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")1257 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
940 require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty")1258 require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty")
@@ -965,7 +1283,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
965 win.focusedPane().screen.Write([]byte("x"))1283 win.focusedPane().screen.Write([]byte("x"))
966 win.mu.Unlock()1284 win.mu.Unlock()
967 1285
968- d.paint(sess, ac, true)1286+ d.paint(sess, ac, true, nil)
969 1287
970 require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send")1288 require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send")
971 sess.mu.Lock()1289 sess.mu.Lock()
@@ -977,75 +1295,6 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
977 d.waitNotifies()1295 d.waitNotifies()
978 }1296 }
979 1297
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-
1049 func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {1298 func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
1050 d := newTestDaemon(t, nil, stubClock{})1299 d := newTestDaemon(t, nil, stubClock{})
1051 p := portsmocks.NewMockPTY(t)1300 p := portsmocks.NewMockPTY(t)
@@ -1089,185 +1338,25 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
1089 }1338 }
1090 }1339 }
1091 1340
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-
1252 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {1341 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
1253 p, _ := newBlockingPTY(t)1342 p, _ := newBlockingPTY(t)
1254 d, sess, ac, sends := newManualSessionWithPTYs(t, p)1343 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1255 win := sess.tabs[0]1344 win := sess.tabs[0]
1256 win.focusedPane().screen.Write([]byte("A"))1345 win.focusedPane().screen.Write([]byte("A"))
1257 1346
1258- d.paint(sess, ac, true)1347+ d.paint(sess, ac, true, nil)
1259 data := mustOutputData(t, sends)1348 data := mustOutputData(t, sends)
1260 require.Contains(t, string(data), "\x1b[1 q")1349 require.Contains(t, string(data), "\x1b[1 q")
1261 require.Contains(t, string(data), "\x1b[?25h")1350 require.Contains(t, string(data), "\x1b[?25h")
1262 1351
1263 win.focusedPane().screen.Write([]byte("\x1b[2;3H"))1352 win.focusedPane().screen.Write([]byte("\x1b[2;3H"))
1264- d.paint(sess, ac, false)1353+ d.paint(sess, ac, false, nil)
1265 data = mustOutputData(t, sends)1354 data = mustOutputData(t, sends)
1266 require.Contains(t, string(data), "\x1b[3;3H")1355 require.Contains(t, string(data), "\x1b[3;3H")
1267 require.Contains(t, string(data), "\x1b[?25h")1356 require.Contains(t, string(data), "\x1b[?25h")
1268 1357
1269 win.focusedPane().screen.Write([]byte("\x1b[?25l"))1358 win.focusedPane().screen.Write([]byte("\x1b[?25l"))
1270- d.paint(sess, ac, false)1359+ d.paint(sess, ac, false, nil)
1271 data = mustOutputData(t, sends)1360 data = mustOutputData(t, sends)
1272 require.Contains(t, string(data), "\x1b[?25l")1361 require.Contains(t, string(data), "\x1b[?25l")
1273 }1362 }
@@ -1284,7 +1373,7 @@ func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) {
1284 floating.popupGeometry = committed1373 floating.popupGeometry = committed
1285 installTestFloating(sess.activeTab(), floating, true)1374 installTestFloating(sess.activeTab(), floating, true)
1286 1375
1287- d.paint(sess, ac, true)1376+ d.paint(sess, ac, true, nil)
1288 data := mustOutputData(t, sends)1377 data := mustOutputData(t, sends)
1289 want := committed.translate(contentArea.X, contentArea.Y)1378 want := committed.translate(contentArea.X, contentArea.Y)
1290 require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, 1))1379 require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, 1))
@@ -1309,7 +1398,7 @@ func TestCursorTailUsesFocusedPanePlacement(t *testing.T) {
1309 require.True(t, ok)1398 require.True(t, ok)
1310 rightContent := placementContent(placements, right.id)1399 rightContent := placementContent(placements, right.id)
1311 1400
1312- d.paint(sess, ac, true)1401+ d.paint(sess, ac, true, nil)
1313 data := mustOutputData(t, sends)1402 data := mustOutputData(t, sends)
1314 want := cursorCSI(rightContent.Y+right.screen.CursorRow()+2, rightContent.X+right.screen.CursorCol()+1)1403 want := cursorCSI(rightContent.Y+right.screen.CursorRow()+2, rightContent.X+right.screen.CursorCol()+1)
1315 require.Contains(t, string(data), want)1404 require.Contains(t, string(data), want)
@@ -1338,7 +1427,7 @@ func TestCursorTailUsesExpandedStackContentPlacement(t *testing.T) {
1338 twoContent := placementContent(placements, two.id)1427 twoContent := placementContent(placements, two.id)
1339 require.Greater(t, twoContent.Y, 0, "stack title bars should offset content")1428 require.Greater(t, twoContent.Y, 0, "stack title bars should offset content")
1340 1429
1341- d.paint(sess, ac, true)1430+ d.paint(sess, ac, true, nil)
1342 data := mustOutputData(t, sends)1431 data := mustOutputData(t, sends)
1343 want := cursorCSI(twoContent.Y+two.screen.CursorRow()+2, twoContent.X+two.screen.CursorCol()+1)1432 want := cursorCSI(twoContent.Y+two.screen.CursorRow()+2, twoContent.X+two.screen.CursorCol()+1)
1344 require.Contains(t, string(data), want)1433 require.Contains(t, string(data), want)
@@ -1357,7 +1446,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1357 }1446 }
1358 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})1447 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
1359 p.screen.Write([]byte("ab\r\ncd"))1448 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)
1361 mode := scopy.NewMode(document)1450 mode := scopy.NewMode(document)
1362 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}1451 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
1363 1452
@@ -1417,7 +1506,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
1417 defer releaseFloating()1506 defer releaseFloating()
1418 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)1507 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
1419 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})1508 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1420- fp.scrollback.Append(testRow("flt-old"))1509+ fp.history.Append(testRow("flt-old"))
1421 fp.screen.Write([]byte("flt-live"))1510 fp.screen.Write([]byte("flt-live"))
1422 installTestFloating(sess.activeTab(), fp, true)1511 installTestFloating(sess.activeTab(), fp, true)
1423 1512
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 {
48 if !d.prepareParkAttachment(sess, ac) {48 if !d.prepareParkAttachment(sess, ac) {
49 return false49 return false
50 }50 }
51- // A parked attachment has no valid visible destination. Retire both the51+ // A parked attachment has no session owner. Release pane snapshots before
52- // deferred diff and any resize-idle callback before registering its grace52+ // publishing it in the daemon registry so headless pane/session teardown
53- // timer; resume performs a fresh full paint.53+ // cannot leave closed panes retained through its capture cache. This must
54- ac.cancelResizePaint()54+ // remain outside d.mu: sendMu is ordered before the daemon lock.
55- ac.clearPaintDeferred()55+ ac.clearCaptureFrames()
56 d.mu.Lock()56 d.mu.Lock()
57 if d.closing {57 if d.closing {
58 d.mu.Unlock()58 d.mu.Unlock()
@@ -188,6 +188,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
188 sess.client = ac188 sess.client = ac
189 sess.terminal = terminalEnv{TrueColor: h.TrueColor}189 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
190 sess.mu.Unlock()190 sess.mu.Unlock()
191+ d.attachCoordinator(sess, nil, ac, false)
191 d.touchMRU(sess)192 d.touchMRU(sess)
192 d.log.Info("client resumed", "session", sess.name)193 d.log.Info("client resumed", "session", sess.name)
193 return sess, ac, true, nil194 return sess, ac, true, nil
M internal/usecase/daemon/resume_test.go +163 -1
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "strings"
5 "sync"6 "sync"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -10,6 +11,8 @@ import (
10 11
11 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/layout"
15+ "github.com/bnema/vev/pkg/vt"
13 )16 )
14 17
15 type closeTrackingTransport struct {18 type closeTrackingTransport struct {
@@ -77,6 +80,110 @@ func TestNamedLinkLossParks(t *testing.T) {
77 require.True(t, parked, "named resume-capable link loss is parked")80 require.True(t, parked, "named resume-capable link loss is parked")
78 }81 }
79 82
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+
80 func TestEphemeralLinkLossParksAndResumes(t *testing.T) {187 func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
81 pty, release := newBlockingPTY(t)188 pty, release := newBlockingPTY(t)
82 defer release()189 defer release()
@@ -228,6 +335,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
228 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)335 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
229 require.NoError(t, err)336 require.NoError(t, err)
230 require.Same(t, ac, resumedAC)337 require.Same(t, ac, resumedAC)
338+ require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))
231 d.firstPaint(resumedSess, resumedAC, resumedAC.size)339 d.firstPaint(resumedSess, resumedAC, resumedAC.size)
232 340
233 sends := newTr.Sends()341 sends := newTr.Sends()
@@ -239,7 +347,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
239 resumedAC.ackOutputState(first.NewStateNum)347 resumedAC.ackOutputState(first.NewStateNum)
240 348
241 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))349 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))
242- d.paint(resumedSess, resumedAC, false)350+ d.paint(resumedSess, resumedAC, false, nil)
243 sends = newTr.Sends()351 sends = newTr.Sends()
244 require.Len(t, sends, 2)352 require.Len(t, sends, 2)
245 second, err := ports.UnmarshalOutput(sends[1].Payload)353 second, err := ports.UnmarshalOutput(sends[1].Payload)
@@ -247,6 +355,60 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
247 require.Equal(t, first.NewStateNum, second.BaseStateNum)355 require.Equal(t, first.NewStateNum, second.BaseStateNum)
248 }356 }
249 357
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+
250 func TestExplicitDetachDoesNotPark(t *testing.T) {412 func TestExplicitDetachDoesNotPark(t *testing.T) {
251 pty, release := newBlockingPTY(t)413 pty, release := newBlockingPTY(t)
252 defer release()414 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) {
14 wantRow string14 wantRow string
15 }{15 }{
16 {16 {
17- name: "tab owns scrollback wired to vt eviction callback",17+ name: "primary evictions enter screen owned history",
18 write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),18 write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
19 wantLen: 1,19 wantLen: 1,
20 wantRow: "AAAA",20 wantRow: "AAAA",
21 },21 },
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+ },
22 }28 }
23 29
24 for _, tt := range tests {30 for _, tt := range tests {
25 t.Run(tt.name, func(t *testing.T) {31 t.Run(tt.name, func(t *testing.T) {
26 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})32 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})
27- if win.focusedPane().scrollback == nil {33+ if win.focusedPane().history == nil {
28 t.Fatal("scrollback is nil")34 t.Fatal("scrollback is nil")
29 }35 }
30- if win.focusedPane().scrollback.Cap() != defaultScrollbackRows {36+ if win.focusedPane().history != win.focusedPane().screen.History() {
31- t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows)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)
32 }41 }
33 42
34 win.focusedPane().screen.Write(tt.write)43 win.focusedPane().screen.Write(tt.write)
35 44
36- if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {45+ if got := win.focusedPane().history.Len(); got != tt.wantLen {
37 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)46 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
38 }47 }
39- row := win.focusedPane().scrollback.View().Row(0)48+ row := win.focusedPane().history.View().Row(0)
40 runes := make([]rune, len(row))49 runes := make([]rune, len(row))
41 for i, c := range row {50 for i, c := range row {
42 runes[i] = c.Rune51 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: the1+// Package daemon holds vev's server-side session multiplexer use case.
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.
25 package daemon2 package daemon
26 3
27 import (4 import (
@@ -67,15 +44,35 @@ type session struct {
67 mruAt atomic.Uint6444 mruAt atomic.Uint64
68 snapDirty atomic.Bool45 snapDirty atomic.Bool
69 snapEligible atomic.Bool46 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]
70 // clipFiles records clipboard-image-transfer temp file paths (see60 // clipFiles records clipboard-image-transfer temp file paths (see
71 // clipboard.go) written for this session, removed best-effort in61 // clipboard.go) written for this session, removed best-effort in
72 // killSession.62 // killSession.
73 clipFiles []string63 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{}
74 }71 }
75 72
76 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.73 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
77 type tab struct {74 type tab struct {
78- mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, previewClient, and pane map membership75+ mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership
79 76
80 stableID string77 stableID string
81 name string78 name string
@@ -88,9 +85,6 @@ type tab struct {
88 // floating is independent from the normal layout tree and pane map.85 // floating is independent from the normal layout tree and pane map.
89 floating floatingSlot86 floating floatingSlot
90 87
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
94 // attention fields are guarded by the owning session.mu.88 // attention fields are guarded by the owning session.mu.
95 attention bool89 attention bool
96 attentionAt time.Time90 attentionAt time.Time
@@ -100,7 +94,7 @@ type tab struct {
100 // attachedClient is a client currently attached to a session's tab. rend is94 // attachedClient is a client currently attached to a session's tab. rend is
101 // its private renderer shadow (so each client gets output minimised against95 // its private renderer shadow (so each client gets output minimised against
102 // what it has actually seen). sendMu serialises the two senders — the render96 // what it has actually seen). sendMu serialises the two senders — the render
103-// scheduler and the connection handler — so the transport's single-writer97+// coordinator and the connection handler — so the transport's single-writer
104 98
105 func (d *Daemon) touchMRU(sess *session) {99 func (d *Daemon) touchMRU(sess *session) {
106 if d == nil || sess == nil {100 if d == nil || sess == nil {
@@ -145,7 +139,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
145 closeTabs(tabs)139 closeTabs(tabs)
146 return nil, err140 return nil, err
147 }141 }
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)
149 if err != nil {143 if err != nil {
150 closeTabs(tabs)144 closeTabs(tabs)
151 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")145 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
258 ac.setSession(nil)252 ac.setSession(nil)
259 from.mu.Unlock()253 from.mu.Unlock()
260 254
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)
261 newSess.mu.Lock()259 newSess.mu.Lock()
262 newSess.client = ac260 newSess.client = ac
263 newSess.mu.Unlock()261 newSess.mu.Unlock()
264 262
265 d.touchMRU(newSess)263 d.touchMRU(newSess)
266- ac.setSession(newSess)
267 ac.recordPreviousSession(from)264 ac.recordPreviousSession(from)
268 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)265 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
269 d.mu.Unlock()266 d.mu.Unlock()
270-267+ finishRenderLifecycleCleanups(cleanups)
271 d.firstPaint(newSess, ac, sz)268 d.firstPaint(newSess, ac, sz)
272 return nil269 return nil
273 }270 }
@@ -284,7 +281,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
284 if err != nil {281 if err != nil {
285 return err282 return err
286 }283 }
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)
288 if err != nil {285 if err != nil {
289 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")286 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
290 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)287 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) {
418 sess.mu.Unlock()415 sess.mu.Unlock()
419 d.log.Info("pane created", "session", name, "pane", p.id)416 d.log.Info("pane created", "session", name, "pane", p.id)
420 }417 }
421- d.sessWg.Add(2)418+ // Scheduler ownership was removed; this launch creates exactly one reader.
419+ d.sessWg.Add(1)
422 go d.ptyReader(sess, tb, p)420 go d.ptyReader(sess, tb, p)
423- go d.scheduler(sess, tb, p)
424 }421 }
425 422
426 // attachClient makes ac the session's current client, displacing any prior one423 // 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) {
637 markSnapshotDirty(sess)634 markSnapshotDirty(sess)
638 635
639 d.clearDestroyedTabPreview(tb)636 d.clearDestroyedTabPreview(tb)
637+ tb.mu.Lock()
638+ panes := tb.panesSnapshot()
639+ tb.mu.Unlock()
640+ if ac != nil {
641+ ac.pruneCaptureFrames(panes...)
642+ }
640 if tb.cancel != nil {643 if tb.cancel != nil {
641 tb.cancel()644 tb.cancel()
642 }645 }
643 d.teardownFloating(tb, ac)646 d.teardownFloating(tb, ac)
647+ if rc := sess.renderCoordinator(); rc != nil {
648+ for _, p := range panes {
649+ rc.noteSyncPaneRemoved(p)
650+ }
651+ }
644 tb.closeAllPanes()652 tb.closeAllPanes()
645 if wasActive {653 if wasActive {
646 d.activateTab(sess, destination)654 d.activateTab(sess, destination)
647 }655 }
648 if repaint && ac != nil {656 if repaint && ac != nil {
649- d.paint(sess, ac, true)657+ d.invalidateRender(sess, ac, true, "session.go")
650 }658 }
651 if ringing {659 if ringing {
652 d.repaintAllAttachedClients()660 d.repaintAllAttachedClients()
@@ -664,7 +672,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
664 if !purge && !isEphemeral && d.persistEnabled {672 if !purge && !isEphemeral && d.persistEnabled {
665 d.refreshSessionCwd(sess)673 d.refreshSessionCwd(sess)
666 }674 }
667- var snapshotDeleteErr error675+ var snapshotDeleteErr, terminalSnapshotErr error
668 if d.snapsEnabled && !isEphemeral {676 if d.snapsEnabled && !isEphemeral {
669 if purge {677 if purge {
670 sess.mu.Lock()678 sess.mu.Lock()
@@ -675,7 +683,16 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
675 d.log.Warn("deleting session snapshot failed", "err", err, "session", name)683 d.log.Warn("deleting session snapshot failed", "err", err, "session", name)
676 }684 }
677 } else {685 } 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+ }
679 }696 }
680 }697 }
681 d.mu.Lock()698 d.mu.Lock()
@@ -731,13 +748,24 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
731 ac := sess.client748 ac := sess.client
732 sess.client = nil749 sess.client = nil
733 sess.mu.Unlock()750 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+ }
734 if ac != nil {758 if ac != nil {
735- ac.cancelResizePaint()
736 d.unregisterPreview(ac)759 d.unregisterPreview(ac)
737 ac.clearPreviousSession()760 ac.clearPreviousSession()
738 ac.setSession(nil)761 ac.setSession(nil)
762+ ac.clearCaptureFrames()
739 }763 }
740 764
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()
741 sess.cancel()769 sess.cancel()
742 sess.mu.Lock()770 sess.mu.Lock()
743 tabs := append([]*tab(nil), sess.tabs...)771 tabs := append([]*tab(nil), sess.tabs...)
@@ -763,7 +791,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
763 if ac != nil {791 if ac != nil {
764 d.notifyDetachedAsync(ac, reason)792 d.notifyDetachedAsync(ac, reason)
765 }793 }
766- return purgeErr794+ return errors.Join(purgeErr, terminalSnapshotErr)
767 }795 }
768 796
769 // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds797 // 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) {
11 target := ac.previousSession.Get()11 target := ac.previousSession.Get()
12 if target == nil || target == current || d.sessionByID(target.id) != target {12 if target == nil || target == current || d.sessionByID(target.id) != target {
13 ac.clearPreviousSessionIf(target)13 ac.clearPreviousSessionIf(target)
14- d.paint(current, ac, true)14+ d.invalidateRender(current, ac, true, "session_back.go")
15 return15 return
16 }16 }
17 d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})17 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 (
21 )21 )
22 22
23 // --- test doubles -----------------------------------------------------------23 // --- 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+}
24 29
25 // stubClock returns timers whose channel never fires, so a scheduler under it30 // stubClock returns timers whose channel never fires, so a scheduler under it
26 // blocks in its debounce loop until the session context is cancelled. Used by31 // blocks in its debounce loop until the session context is cancelled. Used by
@@ -34,8 +39,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
34 f := portsmocks.NewMockPTYFactory(t)39 f := portsmocks.NewMockPTYFactory(t)
35 var dirs []string40 var dirs []string
36 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}41 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
37- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(42+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
38- func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {43+ func(_ context.Context, _ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
39 dirs = append(dirs, dir)44 dirs = append(dirs, dir)
40 if len(dirs) == 1 {45 if len(dirs) == 1 {
41 return first, nil46 return first, nil
@@ -44,9 +49,7 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
44 },49 },
45 ).Twice()50 ).Twice()
46 floating := newQuietPTY()51 floating := newQuietPTY()
47- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {52+ expectFloatingPrewarmOpen(f, normalSize, floating)
48- return got != normalSize && got.Valid()
49- })).Return(floating, nil).Once()
50 53
51 d := newTestDaemon(t, f, stubClock{})54 d := newTestDaemon(t, f, stubClock{})
52 tr := portsmocks.NewMockTransport(t)55 tr := portsmocks.NewMockTransport(t)
@@ -72,11 +75,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
72 releaseFirst()75 releaseFirst()
73 releaseSecond()76 releaseSecond()
74 d.sessWg.Wait()77 d.sessWg.Wait()
75- select {78+ // Teardown may cancel a queued prewarm before it enters Open; an opened
76- case <-floating.done:79+ // prewarm is closed by teardownFloating.
77- default:
78- t.Fatal("floating prewarm PTY was not closed")
79- }
80 }80 }
81 81
82 func TestHandshakeEphemeralHappy(t *testing.T) {82 func TestHandshakeEphemeralHappy(t *testing.T) {
@@ -202,7 +202,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
202 p1, release1 := newBlockingPTY(t)202 p1, release1 := newBlockingPTY(t)
203 defer release1()203 defer release1()
204 p2 := portsmocks.NewMockPTY(t)204 p2 := portsmocks.NewMockPTY(t)
205- opened := make(chan struct{})205+ openCtx := make(chan context.Context, 1)
206 releaseOpen := make(chan struct{})206 releaseOpen := make(chan struct{})
207 closed := make(chan struct{})207 closed := make(chan struct{})
208 p2.EXPECT().Close().RunAndReturn(func() error {208 p2.EXPECT().Close().RunAndReturn(func() error {
@@ -213,15 +213,16 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
213 213
214 f := portsmocks.NewMockPTYFactory(t)214 f := portsmocks.NewMockPTYFactory(t)
215 normalSize := domain.Size{Cols: 80, Rows: 22}215 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()
217 floating := newQuietPTY()217 floating := newQuietPTY()
218- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {218+ expectFloatingPrewarmOpen(f, normalSize, floating)
219- return got != normalSize && got.Valid()219+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
220- })).Return(floating, nil).Once()220+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
221- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(221+ openCtx <- ctx
222- func(string, []string, []string, string, domain.Size) (ports.PTY, error) {222+ select {
223- close(opened)223+ case <-ctx.Done():
224- <-releaseOpen224+ case <-releaseOpen:
225+ }
225 return p2, nil226 return p2, nil
226 },227 },
227 ).Once()228 ).Once()
@@ -237,10 +238,20 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
237 238
238 errCh := make(chan error, 1)239 errCh := make(chan error, 1)
239 go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()240 go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()
240- <-opened241+ ctx := <-openCtx
241 _ = d.killSession(sess, ports.ReasonSessionKilled, false)242 _ = 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+ }
242 close(releaseOpen)250 close(releaseOpen)
243 251
252+ if !cancelled {
253+ t.Error("killSession did not cancel the context passed to PTYFactory.Open")
254+ }
244 require.Error(t, <-errCh)255 require.Error(t, <-errCh)
245 select {256 select {
246 case <-closed:257 case <-closed:
@@ -251,11 +262,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
251 releaseConn()262 releaseConn()
252 hg.Wait()263 hg.Wait()
253 d.sessWg.Wait()264 d.sessWg.Wait()
254- select {265+ // A queued prewarm may be cancelled before Open during teardown.
255- case <-floating.done:
256- default:
257- t.Fatal("floating prewarm PTY was not closed")
258- }
259 }266 }
260 267
261 // --- ephemeral numbering ----------------------------------------------------268 // --- ephemeral numbering ----------------------------------------------------
@@ -508,24 +515,52 @@ func TestServeGracefulShutdownOnContextCancel(t *testing.T) {
508 // --- shutdown/create interlock ------------------------------------------------515 // --- shutdown/create interlock ------------------------------------------------
509 516
510 // TestHelloRacingShutdownIsRejected covers the interlock between the517 // TestHelloRacingShutdownIsRejected covers the interlock between the
511-// registry-empty shutdown decision and session creation: once killSession has518+// registry-empty shutdown decision and session creation. In particular, the
512-// removed the last session (making shutdown irreversible via doneOnce), a519+// old `.Once()` factory expectation was wrong: firstPaint legitimately queues
513-// Hello landing at any later instant must be rejected cleanly — never spawn a520+// a floating prewarm, so an Open that starts before killSession is valid work,
514-// child that no teardown pass would reap. The interleaving is deterministic:521+// not a leaked post-shutdown child. The factory markers classify timing at the
515-// killSession runs to completion before the racing Hellos are handled, which522+// Open boundary: this test first observes that valid prewarm, then verifies
516-// models the worst case (insertion attempt after the shutdown snapshot).523+// that d.done (closed synchronously by killSession) is never followed by an
524+// Open start.
517 func TestHelloRacingShutdownIsRejected(t *testing.T) {525 func TestHelloRacingShutdownIsRejected(t *testing.T) {
518 p, releasePTY := newBlockingPTY(t)526 p, releasePTY := newBlockingPTY(t)
527+ floating := newQuietPTY()
519 f := portsmocks.NewMockPTYFactory(t)528 f := portsmocks.NewMockPTYFactory(t)
520- // Exactly one child may ever be spawned; a second Open call (a leaked529+ normalSize := domain.Size{Cols: 80, Rows: 22}
521- // child) fails the test via mock expectations.530+ preShutdownFloatingOpen := make(chan struct{}, 1)
522- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once()531+ var opensAfterShutdown atomic.Int32
523 d := newTestDaemon(t, f, stubClock{})532 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()
524 550
525 tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))551 tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
526 var hg sync.WaitGroup552 var hg sync.WaitGroup
527 hg.Go(func() { d.handleConn(tr1) })553 hg.Go(func() { d.handleConn(tr1) })
528 awaitFrame(t, sends1, ports.MsgWelcome)554 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+ }
529 sess := firstSession(d)564 sess := firstSession(d)
530 require.NotNil(t, sess)565 require.NotNil(t, sess)
531 566
@@ -555,12 +590,14 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
555 require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name)590 require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name)
556 }591 }
557 require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began")592 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")
558 594
559 release1()595 release1()
560 releasePTY()596 releasePTY()
561 hg.Wait()597 hg.Wait()
562 d.sessWg.Wait()598 d.sessWg.Wait()
563 d.waitNotifies()599 d.waitNotifies()
600+ require.Zero(t, opensAfterShutdown.Load(), "racing Hellos must not start a PTY after shutdown")
564 }601 }
565 602
566 // --- wedged-client teardown ---------------------------------------------------603 // --- wedged-client teardown ---------------------------------------------------
@@ -1090,8 +1127,8 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
1090 1127
1091 var gotEnv []string1128 var gotEnv []string
1092 f := portsmocks.NewMockPTYFactory(t)1129 f := portsmocks.NewMockPTYFactory(t)
1093- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(1130+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1094- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1131+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1095 gotEnv = append([]string(nil), env...)1132 gotEnv = append([]string(nil), env...)
1096 return p, nil1133 return p, nil
1097 },1134 },
@@ -1243,8 +1280,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1243 var opens [][]string1280 var opens [][]string
1244 f := portsmocks.NewMockPTYFactory(t)1281 f := portsmocks.NewMockPTYFactory(t)
1245 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}1282 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1246- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(1283+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1247- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1284+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1248 opens = append(opens, append([]string(nil), env...))1285 opens = append(opens, append([]string(nil), env...))
1249 if len(opens) == 1 {1286 if len(opens) == 1 {
1250 return p1, nil1287 return p1, nil
@@ -1253,9 +1290,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1253 },1290 },
1254 ).Twice()1291 ).Twice()
1255 floating := newQuietPTY()1292 floating := newQuietPTY()
1256- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {1293+ expectFloatingPrewarmOpen(f, normalSize, floating)
1257- return got != normalSize && got.Valid()
1258- })).Return(floating, nil).Once()
1259 1294
1260 d := newTestDaemon(t, f, stubClock{})1295 d := newTestDaemon(t, f, stubClock{})
1261 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}1296 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}
@@ -1288,8 +1323,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1288 var opens [][]string1323 var opens [][]string
1289 f := portsmocks.NewMockPTYFactory(t)1324 f := portsmocks.NewMockPTYFactory(t)
1290 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}1325 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1291- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(1326+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1292- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1327+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1293 opens = append(opens, append([]string(nil), env...))1328 opens = append(opens, append([]string(nil), env...))
1294 if len(opens) == 1 {1329 if len(opens) == 1 {
1295 return p1, nil1330 return p1, nil
@@ -1298,9 +1333,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1298 },1333 },
1299 ).Twice()1334 ).Twice()
1300 floating := newQuietPTY()1335 floating := newQuietPTY()
1301- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {1336+ expectFloatingPrewarmOpen(f, normalSize, floating)
1302- return got != normalSize && got.Valid()
1303- })).Return(floating, nil).Once()
1304 1337
1305 d := newTestDaemon(t, f, stubClock{})1338 d := newTestDaemon(t, f, stubClock{})
1306 tr1 := portsmocks.NewMockTransport(t)1339 tr1 := portsmocks.NewMockTransport(t)
@@ -1335,8 +1368,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1335 var opens [][]string1368 var opens [][]string
1336 f := portsmocks.NewMockPTYFactory(t)1369 f := portsmocks.NewMockPTYFactory(t)
1337 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}1370 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1338- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(1371+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1339- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1372+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1340 opens = append(opens, append([]string(nil), env...))1373 opens = append(opens, append([]string(nil), env...))
1341 if len(opens) == 1 {1374 if len(opens) == 1 {
1342 return p1, nil1375 return p1, nil
@@ -1345,18 +1378,38 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1345 },1378 },
1346 ).Twice()1379 ).Twice()
1347 floating := newQuietPTY()1380 floating := newQuietPTY()
1348- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {1381+ expectFloatingPrewarmOpen(f, normalSize, floating)
1349- return got != normalSize && got.Valid()
1350- })).Return(floating, nil).Once()
1351 d := newTestDaemon(t, f, stubClock{})1382 d := newTestDaemon(t, f, stubClock{})
1352 tr := portsmocks.NewMockTransport(t)1383 tr := portsmocks.NewMockTransport(t)
1353 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()1384 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1354 tr.EXPECT().Close().Return(nil).Maybe()1385 tr.EXPECT().Close().Return(nil).Maybe()
1355 sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)1386 sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
1356 require.NoError(t, err)1387 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+
1357 require.NoError(t, d.createSessionAndSwitch(sess, ac, "next"))1398 require.NoError(t, d.createSessionAndSwitch(sess, ac, "next"))
1358 got := ac.sess.Get()1399 got := ac.sess.Get()
1359 require.NotNil(t, got)1400 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()
1360 got.mu.Lock()1413 got.mu.Lock()
1361 require.True(t, got.terminal.TrueColor)1414 require.True(t, got.terminal.TrueColor)
1362 got.mu.Unlock()1415 got.mu.Unlock()
M internal/usecase/daemon/snapshot.go +455 -79
@@ -4,17 +4,58 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "math"6 "math"
7+ "sync"
7 "time"8 "time"
8 9
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
10- scopy "github.com/bnema/vev/internal/usecase/copy"
11 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
12 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"12 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
13 "github.com/bnema/vev/pkg/renderer"13 "github.com/bnema/vev/pkg/renderer"
14 "github.com/bnema/vev/pkg/vt"14 "github.com/bnema/vev/pkg/vt"
15 )15 )
16 16
17-const snapshotInterval = 30 * time.Second17+// 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+)
18 59
19 func (d *Daemon) closeRestoreDone() {60 func (d *Daemon) closeRestoreDone() {
20 d.restoreOnce.Do(func() { close(d.restoreDone) })61 d.restoreOnce.Do(func() { close(d.restoreDone) })
@@ -51,6 +92,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
51 if err := ctx.Err(); err != nil {92 if err := ctx.Err(); err != nil {
52 return err93 return err
53 }94 }
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+ }
54 if snap.Name == "" {102 if snap.Name == "" {
55 return fmt.Errorf("snapshot: empty session name")103 return fmt.Errorf("snapshot: empty session name")
56 }104 }
@@ -141,7 +189,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
141 return fmt.Errorf("snapshot: generating pane identity: %w", err)189 return fmt.Errorf("snapshot: generating pane identity: %w", err)
142 }190 }
143 }191 }
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)
145 if err != nil {193 if err != nil {
146 closeOpened()194 closeOpened()
147 return err195 return err
@@ -149,7 +197,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
149 p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)197 p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)
150 p.ctx, p.cancel = context.WithCancel(tb.ctx)198 p.ctx, p.cancel = context.WithCancel(tb.ctx)
151 p.rect = contentRect199 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+ }
153 if restoreCommand != "" {207 if restoreCommand != "" {
154 if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil {208 if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil {
155 d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID)209 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
164 }218 }
165 createdAt := int64(snap.CreatedAt)219 createdAt := int64(snap.CreatedAt)
166 sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt}220 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- }
170 sess.snapEligible.Store(true)221 sess.snapEligible.Store(true)
171 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {222 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
172 sess.cwd = snap.Tabs[0].Panes[0].Cwd223 sess.cwd = snap.Tabs[0].Panes[0].Cwd
@@ -201,36 +252,51 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
201 }252 }
202 253
203 func markSnapshotDirty(sess *session) {254 func markSnapshotDirty(sess *session) {
204- if sess == nil {255+ if sess == nil || !sess.snapEligible.Load() {
205 return256 return
206 }257 }
207- if sess.snapEligible.Load() {258+ sess.snapshotMu.Lock()
208- sess.snapDirty.Store(true)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{})
209 }270 }
271+ return sess.snapshotChanged
210 }272 }
211 273
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+ }
213 p.mu.Lock()288 p.mu.Lock()
214 defer p.mu.Unlock()289 defer p.mu.Unlock()
215- p.scrollback = scopy.NewScrollback(defaultScrollbackRows)290+ screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail)
216- for _, row := range scrollback {291+ if err != nil {
217- p.scrollback.Append(row)292+ return fmt.Errorf("snapshot history: %w", err)
218- }293+ }
219- if len(visible) > 0 {294+ if err := screen.RestorePrimaryVisible(snap.Visible); err != nil {
220- p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)295+ return fmt.Errorf("snapshot visible: %w", err)
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- }
232 }296 }
233- p.screen.OnLineEvicted = p.scrollback.Append297+ p.screen = screen
298+ p.history = screen.History()
299+ return nil
234 }300 }
235 301
236 func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {302 func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
@@ -251,35 +317,135 @@ func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size {
251 return domain.Size{Cols: cols, Rows: rows}317 return domain.Size{Cols: cols, Rows: rows}
252 }318 }
253 319
254-func (d *Daemon) captureSession(sess *session) bool {320+// scheduleSnapshot captures a session only once while an immutable capture is
255- if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil {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()
256 return true340 return true
257 }341 }
342+ generation := sess.snapshotGeneration
343+ sess.snapshotPendingCaptures++
344+ sess.snapshotPending = true
345+ sess.signalSnapshotChangedLocked()
346+ sess.snapshotMu.Unlock()
258 347
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) {
259 sess.mu.Lock()421 sess.mu.Lock()
260- name := sess.name422+ 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+ }
261 ephemeral := sess.ephemeral429 ephemeral := sess.ephemeral
262- createdAt := sess.createdAt
263- active := sess.active
264 fallbackCwd := sess.cwd430 fallbackCwd := sess.cwd
265 tabs := append([]*tab(nil), sess.tabs...)431 tabs := append([]*tab(nil), sess.tabs...)
266 sess.mu.Unlock()432 sess.mu.Unlock()
267- if ephemeral || name == "" {433+ if ephemeral || capture.name == "" {
268- return true434+ return capture, false
269 }435 }
270 436
271- snap := snapcodec.Session{Name: name, CreatedAt: uint64(createdAt), Active: uint16(max(active, 0))}437+ capture.tabs = make([]snapshotCaptureTab, 0, len(tabs))
272 for _, tb := range tabs {438 for _, tb := range tabs {
273 tb.mu.Lock()439 tb.mu.Lock()
274- tabSnap := snapcodec.Tab{440+ tabCapture := snapshotCaptureTab{
275- StableID: tb.stableID,441+ stableID: tb.stableID,
276- Cols: uint16(max(tb.size.Cols, 0)),442+ cols: uint16(max(tb.size.Cols, 0)),
277- Rows: uint16(max(tb.size.Rows, 0)),443+ rows: uint16(max(tb.size.Rows, 0)),
278- NextPaneID: uint64(max(tb.nextPaneID, 0)),444+ nextPaneID: uint64(max(tb.nextPaneID, 0)),
279- Tree: tb.tree.Clone(),445+ tree: tb.tree.Clone(),
280 }446 }
281 if tb.tree != nil {447 if tb.tree != nil {
282- tabSnap.Focus = tb.tree.Focus448+ tabCapture.focus = tb.tree.Focus
283 }449 }
284 panes := make([]*pane, 0, len(tb.panes))450 panes := make([]*pane, 0, len(tb.panes))
285 for _, p := range tb.panes {451 for _, p := range tb.panes {
@@ -287,51 +453,274 @@ func (d *Daemon) captureSession(sess *session) bool {
287 }453 }
288 tb.mu.Unlock()454 tb.mu.Unlock()
289 455
290- paneIDs := make(map[layout.PaneID]struct{}, len(panes))456+ tabCapture.panes = make([]snapshotCapturePane, 0, len(panes))
291- paneSnaps := make([]snapcodec.Pane, 0, len(panes))
292 for _, p := range panes {457 for _, p := range panes {
293 p.mu.Lock()458 p.mu.Lock()
294- pid := 0
295 pty := p.pty459 pty := p.pty
460+ pid := 0
296 if pty != nil {461 if pty != nil {
297 pid = pty.Pid()462 pid = pty.Pid()
298 }463 }
299- paneSnap := snapcodec.Pane{464+ paneCapture := snapshotCapturePane{
300- ID: p.id,465+ id: p.id,
301- StableID: p.stableID,466+ stableID: p.stableID,
302- Scrollback: cloneRows(p.scrollback.Snapshot()),467+ history: p.history.SealAndView(),
303- Visible: cloneRows(p.screen.PrimaryVisibleRows()),468+ visible: p.screen.PrimaryVisibleFrame(),
304 }469 }
305 p.mu.Unlock()470 p.mu.Unlock()
306- paneSnap.Cwd = fallbackCwd471+ paneCapture.cwd = fallbackCwd
307 if d.procCwd != nil && pid > 0 {472 if d.procCwd != nil && pid > 0 {
308 if cwd, err := d.procCwd(pid); err == nil && cwd != "" {473 if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
309- paneSnap.Cwd = cwd474+ paneCapture.cwd = cwd
310 }475 }
311 }476 }
312- paneSnap.Process = d.capturePaneProcess(pty, pid)477+ paneCapture.process = d.capturePaneProcess(pty, pid)
313- paneIDs[paneSnap.ID] = struct{}{}478+ tabCapture.panes = append(tabCapture.panes, paneCapture)
314- paneSnaps = append(paneSnaps, paneSnap)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{}{}
315 }509 }
316 if tabSnap.Tree != nil {510 if tabSnap.Tree != nil {
317 pruneTreeToPanes(tabSnap.Tree.Root, paneIDs)511 pruneTreeToPanes(tabSnap.Tree.Root, paneIDs)
318 }512 }
319- tabSnap.Panes = paneSnaps
320 snap.Tabs = append(snap.Tabs, tabSnap)513 snap.Tabs = append(snap.Tabs, tabSnap)
321 }514 }
515+ return d.snapshotMarshal(snap)
516+}
322 517
323- data, err := snapcodec.Marshal(snap)518+func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) {
324- if err != nil {519+ if capture == nil {
325- d.log.Warn("marshaling session snapshot failed", "err", err, "session", name)520+ return
326- return false521+ }
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+ }
327 }639 }
328- if err := d.snaps.Write(name, data); err != nil {640+ return nil
329- d.log.Warn("writing session snapshot failed", "err", err, "session", name)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 {
330 return false647 return false
331 }648 }
649+ d.snapshotWorkerInFlight = capture
332 return true650 return true
333 }651 }
334 652
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+
335 func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {724 func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
336 if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {725 if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {
337 return nil726 return nil
@@ -368,27 +757,14 @@ func (d *Daemon) snapshotSaver(ctx context.Context) {
368 sessions := d.sessionsSnapshotLocked()757 sessions := d.sessionsSnapshotLocked()
369 d.mu.Unlock()758 d.mu.Unlock()
370 for _, sess := range sessions {759 for _, sess := range sessions {
371- if sess.snapEligible.Load() && sess.snapDirty.Swap(false) {760+ if sess.snapEligible.Load() && sess.snapDirty.Load() {
372- if !d.captureSession(sess) {761+ d.scheduleSnapshot(sess)
373- sess.snapDirty.Store(true)
374- }
375 }762 }
376 }763 }
377 t.Reset(snapshotInterval)764 t.Reset(snapshotInterval)
378 }765 }
379 }766 }
380 767
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-
392 func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool {768 func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool {
393 if n == nil {769 if n == nil {
394 return false770 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 (
18 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
19 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"19 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
20 "github.com/bnema/vev/pkg/renderer"20 "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
21 )22 )
22 23
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+
23 func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {89 func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
24 store := &restoreSnapshotStore{}90 store := &restoreSnapshotStore{}
25 store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{91 store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
@@ -37,8 +103,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
37 layout.NewLeaf("pane-2"),103 layout.NewLeaf("pane-2"),
38 }}},104 }}},
39 Panes: []snapcodec.Pane{105 Panes: []snapcodec.Pane{
40- {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},106+ terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}),
41- {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},107+ terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}),
42 },108 },
43 }},109 }},
44 })}}110 })}}
@@ -77,10 +143,11 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
77 p := tb.panes["pane-2"]143 p := tb.panes["pane-2"]
78 tb.mu.Unlock()144 tb.mu.Unlock()
79 p.mu.Lock()145 p.mu.Lock()
80- require.Equal(t, "old2", rowText(p.scrollback.View().Row(0)))146+ require.Same(t, p.screen.History(), p.history)
81- require.Equal(t, 1, p.scrollback.Len())147+ require.Equal(t, "old2", rowText(p.history.View().Row(0)))
148+ require.Equal(t, 1, p.history.Len())
82 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))149 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)
84 require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))151 require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))
85 require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))152 require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))
86 p.mu.Unlock()153 p.mu.Unlock()
@@ -108,8 +175,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
108 func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) {175 func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) {
109 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{176 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
110 Name: "work", Active: 0, Tabs: []snapcodec.Tab{177 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"}}},178+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/one")}},
112- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/two"}}},179+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}},
113 },180 },
114 })}}}181 })}}}
115 factory := &restorePTYFactory{}182 factory := &restorePTYFactory{}
@@ -138,17 +205,15 @@ func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testi
138 205
139 func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {206 func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {
140 t.Helper()207 t.Helper()
208+ pane := emptyTerminalPane(t, "pane-1", "/tmp")
209+ pane.Process = proc
141 return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{210 return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{
142 Name: name,211 Name: name,
143 Tabs: []snapcodec.Tab{{212 Tabs: []snapcodec.Tab{{
144- Cols: 80,213+ Cols: 80,
145- Rows: 24,214+ Rows: 24,
146- Tree: layout.NewTree("pane-1"),215+ Tree: layout.NewTree("pane-1"),
147- Panes: []snapcodec.Pane{{216+ Panes: []snapcodec.Pane{pane},
148- ID: "pane-1",
149- Cwd: "/tmp",
150- Process: proc,
151- }},
152 }},217 }},
153 })}}}218 })}}}
154 }219 }
@@ -185,20 +250,17 @@ func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) {
185 Cols: 80,250 Cols: 80,
186 Rows: 24,251 Rows: 24,
187 Tree: layout.NewTree("pane-1"),252 Tree: layout.NewTree("pane-1"),
188- Panes: []snapcodec.Pane{{253+ Panes: func() []snapcodec.Pane {
189- ID: "pane-1",254+ pane := emptyTerminalPane(t, "pane-1", "/one")
190- Cwd: "/one",255+ pane.Process = lessProc
191- Process: lessProc,256+ return []snapcodec.Pane{pane}
192- }},257+ }(),
193 },258 },
194 {259 {
195- Cols: 80,260+ Cols: 80,
196- Rows: 24,261+ Rows: 24,
197- Tree: layout.NewTree("pane-1"),262+ Tree: layout.NewTree("pane-1"),
198- Panes: []snapcodec.Pane{{263+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")},
199- ID: "pane-1",
200- Cwd: "/two",
201- }},
202 },264 },
203 },265 },
204 })}}},266 })}}},
@@ -246,11 +308,26 @@ func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {
246 d := newTestDaemon(t, factory, stubClock{})308 d := newTestDaemon(t, factory, stubClock{})
247 d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})309 d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
248 310
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}}}}))
250 require.Len(t, factory.opens, 1)314 require.Len(t, factory.opens, 1)
251 require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)315 require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
252 }316 }
253 317
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+
254 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {331 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
255 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{332 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
256 Name: "stacked",333 Name: "stacked",
@@ -260,7 +337,11 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
260 Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{337 Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{
261 layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),338 layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),
262 }}},339 }}},
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+ },
264 }},345 }},
265 })}}}346 })}}}
266 factory := &restorePTYFactory{}347 factory := &restorePTYFactory{}
@@ -290,7 +371,16 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
290 }371 }
291 372
292 func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {373 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+ })
294 badVersion := append([]byte(nil), valid...)384 badVersion := append([]byte(nil), valid...)
295 binary.BigEndian.PutUint16(badVersion[4:6], 1)385 binary.BigEndian.PutUint16(badVersion[4:6], 1)
296 tests := []struct {386 tests := []struct {
@@ -327,7 +417,7 @@ func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) {
327 Cols: 80,417 Cols: 80,
328 Rows: 24,418 Rows: 24,
329 Tree: layout.NewTree("pane-1"),419 Tree: layout.NewTree("pane-1"),
330- Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/bad"}},420+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/bad")},
331 }},421 }},
332 })}}}422 })}}}
333 factory := &restorePTYFactory{}423 factory := &restorePTYFactory{}
@@ -353,7 +443,15 @@ func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
353 loadFn: func() ([]ports.SnapshotBlob, error) {443 loadFn: func() ([]ports.SnapshotBlob, error) {
354 close(loaded)444 close(loaded)
355 <-releaseLoad445 <-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"}}}}})}}, nil446+ 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
357 },455 },
358 }456 }
359 factory := &restorePTYFactory{}457 factory := &restorePTYFactory{}
@@ -402,6 +500,11 @@ func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) {
402 require.Equal(t, ports.ErrServerShutdown, perr.code)500 require.Equal(t, ports.ErrServerShutdown, perr.code)
403 }501 }
404 502
503+func emptyTerminalPane(t *testing.T, id, cwd string) snapcodec.Pane {
504+ t.Helper()
505+ return terminalPane(t, id, "", cwd, nil, nil)
506+}
507+
405 func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {508 func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
406 t.Helper()509 t.Helper()
407 b, err := snapcodec.Marshal(s)510 b, err := snapcodec.Marshal(s)
@@ -438,24 +541,26 @@ type restorePTYFactory struct {
438 }541 }
439 542
440 type restorePTYOpen struct {543 type restorePTYOpen struct {
544+ ctx context.Context
441 dir string545 dir string
442 size domain.Size546 size domain.Size
443 env []string547 env []string
444 pty *restorePTY548 pty *restorePTY
445 }549 }
446 550
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) {
448 f.mu.Lock()552 f.mu.Lock()
449 defer f.mu.Unlock()553 defer f.mu.Unlock()
450 pty := newRestorePTY()554 pty := newRestorePTY()
451 pty.writeErr = f.writeErr555 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})
453 return pty, nil557 return pty, nil
454 }558 }
455 559
456 type restorePTY struct {560 type restorePTY struct {
457 mu sync.Mutex561 mu sync.Mutex
458 writes []string562 writes []string
563+ closes int
459 writeErr error564 writeErr error
460 done chan struct{}565 done chan struct{}
461 once sync.Once566 once sync.Once
@@ -475,7 +580,18 @@ func (p *restorePTY) Write(b []byte) (int, error) {
475 }580 }
476 return len(b), nil581 return len(b), nil
477 }582 }
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+}
479 func (p *restorePTY) Resize(domain.Size) error { return nil }595 func (p *restorePTY) Resize(domain.Size) error { return nil }
480 func (p *restorePTY) Pid() int { return 0 }596 func (p *restorePTY) Pid() int { return 0 }
481 func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil }597 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 collapsed to keep the page light
M internal/usecase/daemon/theme_resolve.go +0 -1
@@ -28,7 +28,6 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
28 28
29 if ac != nil {29 if ac != nil {
30 ac.setTheme(t)30 ac.setTheme(t)
31- ac.composed.invalidate()
32 }31 }
33 for _, tb := range tabs {32 for _, tb := range tabs {
34 tb.mu.Lock()33 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) {
61 require.Equal(t, "alpha", matches[0].Text)61 require.Equal(t, "alpha", matches[0].Text)
62 }62 }
63 63
64-func TestVisualSearchCloneSharesDocumentAndKeepsUIStateIndependent(t *testing.T) {64+func TestVisualSearchCloneRetainsDocumentAndKeepsUIStateIndependent(t *testing.T) {
65 m := New(testSnapshot("alpha", "beta alpha"))65 m := New(testSnapshot("alpha", "beta alpha"))
66 for _, r := range "alpha" {66 for _, r := range "alpha" {
67 m.Insert(r)67 m.Insert(r)
68 }68 }
69 clone := m.Clone()69 clone := m.Clone()
70 70
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))
72 clone.Down()72 clone.Down()
73 clone.Insert('z')73 clone.Insert('z')
74 74
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