Merge pull request #84 from bnema/next
chore(release): promote next to main
148 files · +24676 -3896
A cmd/vev-perf-harness/evidence.go +324 -0
| @@ -0,0 +1,324 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "sort" | |
| 10 | + | "strings" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func measuredEventSamples(marks []harnessMark, interval measuredInterval) ([]eventSample, error) { | |
| 14 | + | if interval.End < interval.Start { | |
| 15 | + | return nil, errors.New("negative measured interval") | |
| 16 | + | } | |
| 17 | + | starts := map[uint64]int64{} | |
| 18 | + | events := make([]eventSample, 0, len(marks)/2) | |
| 19 | + | for _, m := range marks { | |
| 20 | + | if !m.Valid { | |
| 21 | + | return nil, errors.New("invalid harness boundary") | |
| 22 | + | } | |
| 23 | + | switch m.Kind { | |
| 24 | + | case "input_injected": | |
| 25 | + | if _, exists := starts[m.Sequence]; exists { | |
| 26 | + | return nil, errors.New("duplicate input boundary") | |
| 27 | + | } | |
| 28 | + | starts[m.Sequence] = m.Tick | |
| 29 | + | case "terminal_flushed": | |
| 30 | + | start, ok := starts[m.Sequence] | |
| 31 | + | if !ok { | |
| 32 | + | return nil, errors.New("terminal flush without input pair") | |
| 33 | + | } | |
| 34 | + | if m.Tick < start { | |
| 35 | + | return nil, errors.New("negative harness duration") | |
| 36 | + | } | |
| 37 | + | delete(starts, m.Sequence) | |
| 38 | + | if start >= interval.Start && m.Tick < interval.End { | |
| 39 | + | events = append(events, eventSample{Sequence: m.Sequence, Injected: start, Flushed: m.Tick, Latency: m.Tick - start}) | |
| 40 | + | } | |
| 41 | + | default: | |
| 42 | + | return nil, errors.New("unknown harness mark") | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | if len(starts) != 0 { | |
| 46 | + | return nil, errors.New("missing terminal flush pair") | |
| 47 | + | } | |
| 48 | + | sort.Slice(events, func(i, j int) bool { | |
| 49 | + | if events[i].Injected == events[j].Injected { | |
| 50 | + | return events[i].Sequence < events[j].Sequence | |
| 51 | + | } | |
| 52 | + | return events[i].Injected < events[j].Injected | |
| 53 | + | }) | |
| 54 | + | return events, nil | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func requireMinimumEventSamples(events []eventSample) error { | |
| 58 | + | if len(events) < minimumInIntervalEventSamples { | |
| 59 | + | return fmt.Errorf("insufficient in-interval event samples: got %d, need at least %d", len(events), minimumInIntervalEventSamples) | |
| 60 | + | } | |
| 61 | + | return nil | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func eventLatencies(events []eventSample) []int64 { | |
| 65 | + | out := make([]int64, len(events)) | |
| 66 | + | for i, event := range events { | |
| 67 | + | out[i] = event.Latency | |
| 68 | + | } | |
| 69 | + | return out | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func eventCadenceSamples(events []eventSample) ([]int64, int64) { | |
| 73 | + | if len(events) < 2 { | |
| 74 | + | return nil, 0 | |
| 75 | + | } | |
| 76 | + | gaps := make([]int64, 0, len(events)-1) | |
| 77 | + | var maxGap int64 | |
| 78 | + | for i := 1; i < len(events); i++ { | |
| 79 | + | gap := events[i].Injected - events[i-1].Injected | |
| 80 | + | gaps = append(gaps, gap) | |
| 81 | + | if gap > maxGap { | |
| 82 | + | maxGap = gap | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | return gaps, maxGap | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func eventCadence(events []eventSample) (distribution, int64) { | |
| 89 | + | gaps, maxGap := eventCadenceSamples(events) | |
| 90 | + | if len(gaps) == 0 { | |
| 91 | + | return distribution{}, maxGap | |
| 92 | + | } | |
| 93 | + | return percentiles(gaps), maxGap | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func pairedSamples(marks []harnessMark) ([]int64, error) { | |
| 97 | + | starts := map[uint64]int64{} | |
| 98 | + | var out []int64 | |
| 99 | + | for _, m := range marks { | |
| 100 | + | if !m.Valid { | |
| 101 | + | return nil, errors.New("invalid harness boundary") | |
| 102 | + | } | |
| 103 | + | switch m.Kind { | |
| 104 | + | case "input_injected": | |
| 105 | + | if _, ok := starts[m.Sequence]; ok { | |
| 106 | + | return nil, errors.New("duplicate input boundary") | |
| 107 | + | } | |
| 108 | + | starts[m.Sequence] = m.Tick | |
| 109 | + | case "terminal_flushed": | |
| 110 | + | start, ok := starts[m.Sequence] | |
| 111 | + | if !ok { | |
| 112 | + | return nil, errors.New("terminal flush without input pair") | |
| 113 | + | } | |
| 114 | + | if m.Tick < start { | |
| 115 | + | return nil, errors.New("negative harness duration") | |
| 116 | + | } | |
| 117 | + | out = append(out, m.Tick-start) | |
| 118 | + | delete(starts, m.Sequence) | |
| 119 | + | default: | |
| 120 | + | return nil, errors.New("unknown harness mark") | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | if len(starts) != 0 { | |
| 124 | + | return nil, errors.New("missing terminal flush pair") | |
| 125 | + | } | |
| 126 | + | if len(out) == 0 { | |
| 127 | + | return nil, errors.New("insufficient harness samples") | |
| 128 | + | } | |
| 129 | + | return out, nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // traceRecord is intentionally local to the command. It mirrors JSONL fields | |
| 133 | + | // needed for correlation but never exposes a clock to any production component. | |
| 134 | + | type traceRecord struct { | |
| 135 | + | ProcessID string `json:"process_id"` | |
| 136 | + | Component string `json:"component"` | |
| 137 | + | Scenario string `json:"scenario"` | |
| 138 | + | Run uint64 `json:"run"` | |
| 139 | + | Sequence uint64 `json:"sequence"` | |
| 140 | + | RequestID uint64 `json:"request_id"` | |
| 141 | + | Epoch uint64 `json:"epoch"` | |
| 142 | + | Kind string `json:"kind"` | |
| 143 | + | Tick int64 `json:"tick"` | |
| 144 | + | Valid bool `json:"valid"` | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | type spanPair struct{ start, end, name string } | |
| 148 | + | ||
| 149 | + | // mergeProcessTraces permits only records from one manifest process in a span. | |
| 150 | + | // In particular, the correlation key includes process_id before ticks are read. | |
| 151 | + | func mergeProcessTraces(mappings []processMapping) ([]span, error) { | |
| 152 | + | known := map[string]processMapping{} | |
| 153 | + | for _, m := range mappings { | |
| 154 | + | if m.ProcessID == "" || m.ClockDomain == "" || m.TracePath == "" || known[m.ProcessID].ProcessID != "" { | |
| 155 | + | return nil, errors.New("duplicate or incomplete manifest process mapping") | |
| 156 | + | } | |
| 157 | + | known[m.ProcessID] = m | |
| 158 | + | } | |
| 159 | + | out := []span{} | |
| 160 | + | for _, m := range mappings { | |
| 161 | + | f, err := os.Open(m.TracePath) | |
| 162 | + | if err != nil { | |
| 163 | + | return nil, fmt.Errorf("open manifest trace for %s role: %w", m.Role, err) | |
| 164 | + | } | |
| 165 | + | var records []traceRecord | |
| 166 | + | scan := bufio.NewScanner(f) | |
| 167 | + | for scan.Scan() { | |
| 168 | + | var r traceRecord | |
| 169 | + | if err := json.Unmarshal(scan.Bytes(), &r); err != nil { | |
| 170 | + | return nil, closeFile(f, err) | |
| 171 | + | } | |
| 172 | + | if r.ProcessID != m.ProcessID || r.Scenario != m.Scenario || r.Run != uint64(m.Run) { | |
| 173 | + | return nil, closeFile(f, errors.New("trace record identity does not match manifest")) | |
| 174 | + | } | |
| 175 | + | if r.Scenario == "" || r.Run == 0 || r.Component == "" || r.Sequence == 0 || r.RequestID == 0 || r.Epoch == 0 { | |
| 176 | + | return nil, closeFile(f, errors.New("trace record has invalid correlation fields")) | |
| 177 | + | } | |
| 178 | + | if _, ok := known[r.ProcessID]; !ok { | |
| 179 | + | return nil, closeFile(f, errors.New("unknown trace process_id")) | |
| 180 | + | } | |
| 181 | + | records = append(records, r) | |
| 182 | + | } | |
| 183 | + | if err := scan.Err(); err != nil { | |
| 184 | + | return nil, closeFile(f, err) | |
| 185 | + | } | |
| 186 | + | if err := f.Close(); err != nil { | |
| 187 | + | return nil, err | |
| 188 | + | } | |
| 189 | + | // A process shares one observer across concurrent components. Correlation | |
| 190 | + | // IDs are allocated before a goroutine reaches its first mark, so a later | |
| 191 | + | // ID can be serialized before an earlier one. Their first appearance is | |
| 192 | + | // consequently not an ordering contract. Only marks in the same component | |
| 193 | + | // and exact correlation domain may pair, and each pair's ticks must retain | |
| 194 | + | // its own start-before-end ordering. | |
| 195 | + | starts := map[string]traceRecord{} | |
| 196 | + | for _, r := range records { | |
| 197 | + | for _, pair := range spanPairs { | |
| 198 | + | key := fmt.Sprintf("%s/%s/%s/%d/%d/%d/%d", r.ProcessID, r.Component, m.Scenario, m.Run, r.Sequence, r.RequestID, r.Epoch) | |
| 199 | + | if r.Kind == pair.start { | |
| 200 | + | k := pair.name + "/" + key | |
| 201 | + | if _, exists := starts[k]; exists { | |
| 202 | + | return nil, errors.New("duplicate process-local span start") | |
| 203 | + | } | |
| 204 | + | starts[k] = r | |
| 205 | + | } | |
| 206 | + | if r.Kind == pair.end { | |
| 207 | + | k := pair.name + "/" + key | |
| 208 | + | start, ok := starts[k] | |
| 209 | + | if !ok { | |
| 210 | + | return nil, errors.New("span end without same-process start") | |
| 211 | + | } | |
| 212 | + | if r.Tick < start.Tick { | |
| 213 | + | return nil, errors.New("negative same-process span") | |
| 214 | + | } | |
| 215 | + | // Validity affects only measurement eligibility, never structural | |
| 216 | + | // pairing: every start/end must still match in its exact process, | |
| 217 | + | // component, and correlation domain. A failed start, failed end, or | |
| 218 | + | // both is a paired diagnostic fact but contributes no latency sample. | |
| 219 | + | if start.Valid && r.Valid { | |
| 220 | + | out = append(out, span{Component: r.Component, Name: pair.name, Samples: []int64{r.Tick - start.Tick}}) | |
| 221 | + | } | |
| 222 | + | delete(starts, k) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | if len(starts) != 0 { | |
| 227 | + | keys := make([]string, 0, len(starts)) | |
| 228 | + | for key := range starts { | |
| 229 | + | keys = append(keys, key) | |
| 230 | + | } | |
| 231 | + | sort.Strings(keys) | |
| 232 | + | return nil, fmt.Errorf("missing process-local span pair for %s: %s", m.ProcessID, strings.Join(keys, ", ")) | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | return out, nil | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func summarizeSpans(all []span) []spanSummary { | |
| 239 | + | grouped := map[string]*span{} | |
| 240 | + | for _, s := range all { | |
| 241 | + | k := s.Component + "\x00" + s.Name | |
| 242 | + | if grouped[k] == nil { | |
| 243 | + | grouped[k] = &span{Component: s.Component, Name: s.Name} | |
| 244 | + | } | |
| 245 | + | grouped[k].Samples = append(grouped[k].Samples, s.Samples...) | |
| 246 | + | } | |
| 247 | + | keys := make([]string, 0, len(grouped)) | |
| 248 | + | for k := range grouped { | |
| 249 | + | keys = append(keys, k) | |
| 250 | + | } | |
| 251 | + | sort.Strings(keys) | |
| 252 | + | out := make([]spanSummary, 0, len(keys)) | |
| 253 | + | for _, k := range keys { | |
| 254 | + | s := grouped[k] | |
| 255 | + | if len(s.Samples) < minimumRepetitions { | |
| 256 | + | out = append(out, spanSummary{Component: s.Component, Name: s.Name, Distribution: distribution{Count: len(s.Samples)}}) | |
| 257 | + | continue | |
| 258 | + | } | |
| 259 | + | out = append(out, spanSummary{Component: s.Component, Name: s.Name, Distribution: percentiles(s.Samples)}) | |
| 260 | + | } | |
| 261 | + | return out | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func percentiles(samples []int64) distribution { | |
| 265 | + | v := append([]int64(nil), samples...) | |
| 266 | + | sort.Slice(v, func(i, j int) bool { return v[i] < v[j] }) | |
| 267 | + | at := func(p int) int64 { return v[(len(v)-1)*p/100] } | |
| 268 | + | return distribution{len(v), at(50), at(95), at(99), v[len(v)-1]} | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func closeFile(f *os.File, err error) error { | |
| 272 | + | return errors.Join(err, f.Close()) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func writeJSON(path string, v any) error { | |
| 276 | + | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) | |
| 277 | + | if err != nil { | |
| 278 | + | return err | |
| 279 | + | } | |
| 280 | + | if err := json.NewEncoder(f).Encode(v); err != nil { | |
| 281 | + | return closeFile(f, err) | |
| 282 | + | } | |
| 283 | + | return f.Close() | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func mustJSON(v any) string { | |
| 287 | + | b, err := json.Marshal(v) | |
| 288 | + | if err != nil { | |
| 289 | + | panic(fmt.Sprintf("marshal JSON: %v", err)) | |
| 290 | + | } | |
| 291 | + | return string(b) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func safeName(s string) string { | |
| 295 | + | return strings.Map(func(r rune) rune { | |
| 296 | + | if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' { | |
| 297 | + | return r | |
| 298 | + | } | |
| 299 | + | return '-' | |
| 300 | + | }, s) | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func readJSONL(path string) ([]map[string]json.RawMessage, error) { | |
| 304 | + | f, err := os.Open(path) | |
| 305 | + | if err != nil { | |
| 306 | + | return nil, err | |
| 307 | + | } | |
| 308 | + | var r []map[string]json.RawMessage | |
| 309 | + | scan := bufio.NewScanner(f) | |
| 310 | + | for scan.Scan() { | |
| 311 | + | var v map[string]json.RawMessage | |
| 312 | + | if err := json.Unmarshal(scan.Bytes(), &v); err != nil { | |
| 313 | + | return nil, closeFile(f, err) | |
| 314 | + | } | |
| 315 | + | r = append(r, v) | |
| 316 | + | } | |
| 317 | + | if err := scan.Err(); err != nil { | |
| 318 | + | return nil, closeFile(f, err) | |
| 319 | + | } | |
| 320 | + | if err := f.Close(); err != nil { | |
| 321 | + | return nil, err | |
| 322 | + | } | |
| 323 | + | return r, nil | |
| 324 | + | } | |
A cmd/vev-perf-harness/evidence_test.go +387 -0
| @@ -0,0 +1,387 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestCLITransportSeamOwnsExclusivePeerTraceAndCleanup(t *testing.T) { | |
| 15 | + | fixtures := []struct { | |
| 16 | + | name string | |
| 17 | + | tr transport | |
| 18 | + | rtt time.Duration | |
| 19 | + | loss int | |
| 20 | + | }{ | |
| 21 | + | {"baseline", transport{ID: "udp_baseline", Kind: "udp"}, 0, 0}, | |
| 22 | + | {"25ms", transport{ID: "udp_25ms", Kind: "udp", RTTMS: 25}, 25 * time.Millisecond, 0}, | |
| 23 | + | {"100ms", transport{ID: "udp_100ms", Kind: "udp", RTTMS: 100}, 100 * time.Millisecond, 0}, | |
| 24 | + | {"loss0", transport{ID: "udp_loss_0pct", Kind: "udp"}, 0, 0}, | |
| 25 | + | {"loss1", transport{ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1}, 0, 1}, | |
| 26 | + | } | |
| 27 | + | for _, tc := range fixtures { | |
| 28 | + | t.Run(tc.name, func(t *testing.T) { | |
| 29 | + | dir := t.TempDir() | |
| 30 | + | if err := os.Chmod(dir, 0o700); err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | var configs []udpNetemConfig | |
| 34 | + | netem := &fakeUDPNetem{port: 45678} | |
| 35 | + | l := &cliLauncher{bin: "/bin/true", netemFactory: func(c udpNetemConfig) (udpNetem, error) { | |
| 36 | + | configs = append(configs, c) | |
| 37 | + | return netem, nil | |
| 38 | + | }} | |
| 39 | + | m := processMapping{ProcessID: "udp-peer", TracePath: filepath.Join(dir, "udp.jsonl"), Role: "udp_peer"} | |
| 40 | + | if err := os.WriteFile(m.TracePath, nil, 0o600); err != nil { | |
| 41 | + | t.Fatal(err) | |
| 42 | + | } | |
| 43 | + | p, err := l.preparePeer(m, roleCommand{Args: []string{"_udp-proxy", "work"}, Transport: tc.tr}) | |
| 44 | + | if err != nil { | |
| 45 | + | t.Fatal(err) | |
| 46 | + | } | |
| 47 | + | if len(configs) != 1 || configs[0].RTT != tc.rtt || configs[0].LossPercent != tc.loss || configs[0].TargetPath != filepath.Join(dir, "udp-peer.target") { | |
| 48 | + | t.Fatalf("fixture did not reach netem seam: %+v", configs) | |
| 49 | + | } | |
| 50 | + | shim, err := os.ReadFile(filepath.Join(dir, "ssh")) | |
| 51 | + | if err != nil { | |
| 52 | + | t.Fatal(err) | |
| 53 | + | } | |
| 54 | + | text := string(shim) | |
| 55 | + | for _, want := range []string{"_udp-proxy", m.TracePath, m.ProcessID, "udp-peer.target", "VEV-UDP %s %s\\n' 45678", "udp-peer.pid"} { | |
| 56 | + | if !strings.Contains(text, want) { | |
| 57 | + | t.Errorf("seam does not retain %q:\n%s", want, text) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | if strings.Contains(text, "VEV_PERF_UDP_") { | |
| 61 | + | t.Fatalf("ignored vev UDP environment was used instead of netem: %s", text) | |
| 62 | + | } | |
| 63 | + | // A nonexistent pid is already-cleaned-up; Close must still close the | |
| 64 | + | // harness-owned emulator. | |
| 65 | + | if err := p.Close(); err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | if !netem.closed { | |
| 69 | + | t.Fatal("harness netem was not cleaned up") | |
| 70 | + | } | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestHarnessRejectsInvalidBoundaryPairs(t *testing.T) { | |
| 76 | + | for _, marks := range [][]harnessMark{ | |
| 77 | + | {{Sequence: 1, Kind: "terminal_flushed", Tick: 1, Valid: true}}, | |
| 78 | + | {{Sequence: 1, Kind: "input_injected", Tick: 2, Valid: true}, {Sequence: 1, Kind: "terminal_flushed", Tick: 1, Valid: true}}, | |
| 79 | + | {{Sequence: 1, Kind: "input_injected", Tick: 1, Valid: true}}, | |
| 80 | + | {{Sequence: 1, Kind: "input_injected", Tick: 1, Valid: true}, {Sequence: 1, Kind: "input_injected", Tick: 2, Valid: true}}, | |
| 81 | + | } { | |
| 82 | + | if _, err := pairedSamples(marks); err == nil { | |
| 83 | + | t.Fatalf("invalid marks accepted: %+v", marks) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | got, err := pairedSamples([]harnessMark{{Sequence: 9, Kind: "input_injected", Tick: 1, Valid: true}, {Sequence: 9, Kind: "terminal_flushed", Tick: 5, Valid: true}}) | |
| 87 | + | if err != nil || len(got) != 1 || got[0] != 4 { | |
| 88 | + | t.Fatalf("got %v %v", got, err) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestHarnessCollectsOnlyCompleteMeasuredIntervalEvents(t *testing.T) { | |
| 93 | + | interval := measuredInterval{Start: 100, End: 200} | |
| 94 | + | marks := []harnessMark{ | |
| 95 | + | {Sequence: 1, Kind: "input_injected", Tick: 10, Valid: true}, | |
| 96 | + | {Sequence: 1, Kind: "terminal_flushed", Tick: 20, Valid: true}, // warmup | |
| 97 | + | {Sequence: 2, Kind: "input_injected", Tick: 100, Valid: true}, | |
| 98 | + | {Sequence: 2, Kind: "terminal_flushed", Tick: 120, Valid: true}, | |
| 99 | + | {Sequence: 3, Kind: "input_injected", Tick: 150, Valid: true}, | |
| 100 | + | {Sequence: 3, Kind: "terminal_flushed", Tick: 201, Valid: true}, // exits interval | |
| 101 | + | {Sequence: 4, Kind: "input_injected", Tick: 99, Valid: true}, | |
| 102 | + | {Sequence: 4, Kind: "terminal_flushed", Tick: 110, Valid: true}, // enters interval | |
| 103 | + | {Sequence: 5, Kind: "input_injected", Tick: 180, Valid: true}, | |
| 104 | + | {Sequence: 5, Kind: "terminal_flushed", Tick: 200, Valid: true}, // end is excluded | |
| 105 | + | } | |
| 106 | + | events, err := measuredEventSamples(marks, interval) | |
| 107 | + | if err != nil { | |
| 108 | + | t.Fatal(err) | |
| 109 | + | } | |
| 110 | + | if len(events) != 1 || events[0].Sequence != 2 || events[0].Latency != 20 { | |
| 111 | + | t.Fatalf("measured events=%+v, want only complete sequence 2", events) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestHarnessRejectsInsufficientMeasuredEventSamples(t *testing.T) { | |
| 116 | + | marks := make([]harnessMark, 0, 2*(minimumInIntervalEventSamples-1)) | |
| 117 | + | for i := 0; i < minimumInIntervalEventSamples-1; i++ { | |
| 118 | + | marks = append(marks, | |
| 119 | + | harnessMark{Sequence: uint64(i + 1), Kind: "input_injected", Tick: int64(i * 100), Valid: true}, | |
| 120 | + | harnessMark{Sequence: uint64(i + 1), Kind: "terminal_flushed", Tick: int64(i*100 + 10), Valid: true}, | |
| 121 | + | ) | |
| 122 | + | } | |
| 123 | + | events, err := measuredEventSamples(marks, measuredInterval{Start: 0, End: int64(minimumInIntervalEventSamples * 100)}) | |
| 124 | + | if err != nil { | |
| 125 | + | t.Fatal(err) | |
| 126 | + | } | |
| 127 | + | if err := requireMinimumEventSamples(events); err == nil { | |
| 128 | + | t.Fatal("insufficient measured event samples accepted") | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestHarnessRunRejectsStraddlingEventsAsInsufficient(t *testing.T) { | |
| 133 | + | dir := t.TempDir() | |
| 134 | + | raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |
| 135 | + | if err != nil { | |
| 136 | + | t.Fatal(err) | |
| 137 | + | } | |
| 138 | + | t.Cleanup(func() { | |
| 139 | + | if err := raw.Close(); err != nil && !errors.Is(err, os.ErrClosed) { | |
| 140 | + | t.Error(err) | |
| 141 | + | } | |
| 142 | + | }) | |
| 143 | + | clock := &fakeClock{} | |
| 144 | + | h := defaultHarness() | |
| 145 | + | h.clock, h.launcher = clock, insufficientEventLauncher{clock: clock} | |
| 146 | + | _, err = h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "late", Roles: []string{"daemon", "client"}}, 1, raw) | |
| 147 | + | if err == nil || !strings.Contains(err.Error(), "insufficient in-interval event samples") { | |
| 148 | + | t.Fatalf("straddling run error=%v, want insufficient in-interval sample rejection", err) | |
| 149 | + | } | |
| 150 | + | if err := raw.Close(); err != nil { | |
| 151 | + | t.Fatal(err) | |
| 152 | + | } | |
| 153 | + | marks := readHarnessMarks(t, filepath.Join(dir, "raw.jsonl")) | |
| 154 | + | if got, want := len(marks), 4; got != want { | |
| 155 | + | t.Fatalf("raw marks=%d, want warmup and straddling pair=%d: %+v", got, want, marks) | |
| 156 | + | } | |
| 157 | + | if straddling := marks[len(marks)-1]; straddling.Sequence != 2 || straddling.Kind != "terminal_flushed" || !straddling.Valid { | |
| 158 | + | t.Fatalf("straddling boundary=%+v", straddling) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestHarnessCalculatesEventCadenceAndMaxGapFromRawPairs(t *testing.T) { | |
| 163 | + | events := []eventSample{ | |
| 164 | + | {Sequence: 1, Injected: 100, Flushed: 110, Latency: 10}, | |
| 165 | + | {Sequence: 2, Injected: 130, Flushed: 140, Latency: 10}, | |
| 166 | + | {Sequence: 3, Injected: 180, Flushed: 190, Latency: 10}, | |
| 167 | + | } | |
| 168 | + | cadence, maxGap := eventCadence(events) | |
| 169 | + | if cadence.Count != 2 || cadence.P50 != 30 || cadence.P95 != 30 || cadence.P99 != 30 || maxGap != 50 { | |
| 170 | + | t.Fatalf("cadence=%+v maxGap=%d", cadence, maxGap) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestHarnessIncludesAllRequiredProcessLocalSpans(t *testing.T) { | |
| 175 | + | got := make([]string, 0, len(spanPairs)) | |
| 176 | + | for _, pair := range spanPairs { | |
| 177 | + | got = append(got, pair.name) | |
| 178 | + | } | |
| 179 | + | want := []string{"capture_duration", "compose_duration", "diff_duration", "queue_wait", "ack_blocked_interval", "emit_duration", "adapter_send_duration", "adapter_receive_duration"} | |
| 180 | + | if !equalStrings(got, want) { | |
| 181 | + | t.Fatalf("span pairs = %v, want %v", got, want) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func TestHarnessRawRecordsAreDeterministicJSONL(t *testing.T) { | |
| 186 | + | var b bytes.Buffer | |
| 187 | + | for _, m := range []harnessMark{{Scenario: "s", Run: 1, Sequence: 1, Kind: "input_injected", Tick: 1, Valid: true}, {Scenario: "s", Run: 1, Sequence: 1, Kind: "terminal_flushed", Tick: 2, Valid: true}} { | |
| 188 | + | b.WriteString(mustJSON(m)) | |
| 189 | + | b.WriteByte('\n') | |
| 190 | + | } | |
| 191 | + | path := filepath.Join(t.TempDir(), "raw.jsonl") | |
| 192 | + | if err := os.WriteFile(path, b.Bytes(), 0o600); err != nil { | |
| 193 | + | t.Fatal(err) | |
| 194 | + | } | |
| 195 | + | if got, err := readJSONL(path); err != nil || len(got) != 2 { | |
| 196 | + | t.Fatalf("records=%d err=%v", len(got), err) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestHarnessClosesRolesBeforeMergingReceiveSpans(t *testing.T) { | |
| 201 | + | dir := t.TempDir() | |
| 202 | + | raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |
| 203 | + | if err != nil { | |
| 204 | + | t.Fatal(err) | |
| 205 | + | } | |
| 206 | + | t.Cleanup(func() { | |
| 207 | + | if err := raw.Close(); err != nil && !errors.Is(err, os.ErrClosed) { | |
| 208 | + | t.Error(err) | |
| 209 | + | } | |
| 210 | + | }) | |
| 211 | + | launcher := &closeTraceLauncher{} | |
| 212 | + | h := defaultHarness() | |
| 213 | + | h.clock, h.launcher = &fakeClock{}, launcher | |
| 214 | + | result, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, scenario{ID: "receive-cleanup", Roles: []string{"daemon", "client"}}, 1, raw) | |
| 215 | + | if err != nil { | |
| 216 | + | t.Fatal(err) | |
| 217 | + | } | |
| 218 | + | if len(result.Spans) != 2 { | |
| 219 | + | t.Fatalf("post-cleanup receive spans=%+v", result.Spans) | |
| 220 | + | } | |
| 221 | + | for _, s := range result.Spans { | |
| 222 | + | if s.Name != "adapter_receive_duration" || len(s.Samples) != 1 || s.Samples[0] != 10 { | |
| 223 | + | t.Fatalf("unmatched or wrong receive span: %+v", s) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | for i, p := range launcher.process { | |
| 227 | + | if !p.closed { | |
| 228 | + | t.Fatalf("role %s was not closed before trace merge", launcher.mappings[i].Role) | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func TestHarnessWritesRequiredEvidenceWithSufficientSpans(t *testing.T) { | |
| 234 | + | base, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json")) | |
| 235 | + | if err != nil { | |
| 236 | + | t.Fatal(err) | |
| 237 | + | } | |
| 238 | + | for i := range base.Scenarios { | |
| 239 | + | if i > 0 { | |
| 240 | + | base.Scenarios[i].InapplicableReason = "fixture limits this test to one public topology" | |
| 241 | + | base.Scenarios[i].Roles = nil | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | dir := t.TempDir() | |
| 245 | + | manifestPath := filepath.Join(dir, "manifest.json") | |
| 246 | + | if err := os.WriteFile(manifestPath, []byte(mustJSON(base)), 0o600); err != nil { | |
| 247 | + | t.Fatal(err) | |
| 248 | + | } | |
| 249 | + | l := &traceLauncher{} | |
| 250 | + | h := defaultHarness() | |
| 251 | + | h.clock = &fakeClock{} | |
| 252 | + | h.launcher = l | |
| 253 | + | h.gitSHA = func() string { return "test-sha" } | |
| 254 | + | if err := run([]string{"--vev-bin", "ignored", "--manifest", manifestPath, "--out", filepath.Join(dir, "out"), "--warmup", "1s", "--duration", "30s", "--repetitions", "10"}, h); err != nil { | |
| 255 | + | t.Fatal(err) | |
| 256 | + | } | |
| 257 | + | for _, name := range []string{"raw-harness.jsonl", "runs.json", "summary.json"} { | |
| 258 | + | if _, err := os.Stat(filepath.Join(dir, "out", name)); err != nil { | |
| 259 | + | t.Errorf("%s: %v", name, err) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | var got summary | |
| 263 | + | b, err := os.ReadFile(filepath.Join(dir, "out", "summary.json")) | |
| 264 | + | if err != nil { | |
| 265 | + | t.Fatal(err) | |
| 266 | + | } | |
| 267 | + | if err := json.Unmarshal(b, &got); err != nil { | |
| 268 | + | t.Fatal(err) | |
| 269 | + | } | |
| 270 | + | if got.EndToEnd.Count < 10*minimumInIntervalEventSamples || got.Repetitions != 10 || got.RunP50Dispersion.Count != 10 || got.Cadence.Count == 0 || got.MaxGap == 0 || len(got.Spans) == 0 { | |
| 271 | + | t.Fatalf("summary=%+v", got) | |
| 272 | + | } | |
| 273 | + | for _, s := range got.Spans { | |
| 274 | + | if s.Distribution.Count < 10 { | |
| 275 | + | t.Fatalf("insufficient span summary: %+v", s) | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func TestHarnessAcceptsConcurrentProductionTraceInterleaving(t *testing.T) { | |
| 281 | + | path := filepath.Join(t.TempDir(), "local-client.jsonl") | |
| 282 | + | if err := os.WriteFile(path, []byte(boundedLocalConcurrentTrace), 0o600); err != nil { | |
| 283 | + | t.Fatal(err) | |
| 284 | + | } | |
| 285 | + | spans, err := mergeProcessTraces([]processMapping{{ | |
| 286 | + | ProcessID: "1x4-idle-local-r002-client-02", ClockDomain: "1x4-idle-local-r002-client-02", TracePath: path, | |
| 287 | + | Scenario: "1x4-idle-local", Run: 2, | |
| 288 | + | }}) | |
| 289 | + | if err != nil { | |
| 290 | + | t.Fatalf("valid concurrent production trace rejected: %v", err) | |
| 291 | + | } | |
| 292 | + | if len(spans) != 2 || spans[0].Component != "ipc" || spans[0].Name != "adapter_send_duration" || spans[0].Samples[0] != 21900 || spans[1].Component != "ipc" || spans[1].Name != "adapter_receive_duration" || spans[1].Samples[0] != 178550 { | |
| 293 | + | t.Fatalf("spans=%+v", spans) | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func TestHarnessRejectsCrossProcessAndBadTraceSpans(t *testing.T) { | |
| 298 | + | write := func(t *testing.T, records ...traceRecord) processMapping { | |
| 299 | + | t.Helper() | |
| 300 | + | path := filepath.Join(t.TempDir(), "trace.jsonl") | |
| 301 | + | f, err := os.Create(path) | |
| 302 | + | if err != nil { | |
| 303 | + | t.Fatal(err) | |
| 304 | + | } | |
| 305 | + | for _, r := range records { | |
| 306 | + | b, err := json.Marshal(r) | |
| 307 | + | if err != nil { | |
| 308 | + | t.Fatal(err) | |
| 309 | + | } | |
| 310 | + | if _, err := f.Write(append(b, '\n')); err != nil { | |
| 311 | + | t.Fatal(err) | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | if err := f.Close(); err != nil { | |
| 315 | + | t.Fatal(err) | |
| 316 | + | } | |
| 317 | + | return processMapping{ProcessID: "one", ClockDomain: "one", TracePath: path, Scenario: "s", Run: 1} | |
| 318 | + | } | |
| 319 | + | base := func(kind string, tick int64) traceRecord { | |
| 320 | + | return traceRecord{ProcessID: "one", Component: "daemon", Scenario: "s", Run: 1, Sequence: 1, RequestID: 1, Epoch: 1, Kind: kind, Tick: tick, Valid: true} | |
| 321 | + | } | |
| 322 | + | for _, records := range [][]traceRecord{ | |
| 323 | + | {base("diff_end", 1)}, | |
| 324 | + | {base("diff_start", 2), base("diff_end", 1)}, | |
| 325 | + | {base("diff_start", 1)}, | |
| 326 | + | {base("diff_start", 1), base("diff_start", 2)}, | |
| 327 | + | {base("diff_start", 1), func() traceRecord { r := base("diff_end", 2); r.Component = "ipc"; return r }()}, | |
| 328 | + | {func() traceRecord { r := base("diff_start", 1); r.ProcessID = "other"; return r }()}, | |
| 329 | + | {func() traceRecord { r := base("diff_start", 1); r.Scenario = "other-scenario"; return r }()}, | |
| 330 | + | {func() traceRecord { r := base("diff_start", 1); r.Run = 2; return r }()}, | |
| 331 | + | } { | |
| 332 | + | if _, err := mergeProcessTraces([]processMapping{write(t, records...)}); err == nil { | |
| 333 | + | t.Fatalf("invalid trace accepted: %+v", records) | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | m := write(t, base("diff_start", 1), base("diff_end", 3)) | |
| 337 | + | spans, err := mergeProcessTraces([]processMapping{m}) | |
| 338 | + | if err != nil || len(spans) != 1 || spans[0].Samples[0] != 2 { | |
| 339 | + | t.Fatalf("spans=%+v err=%v", spans, err) | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | func TestHarnessExcludesFailedSpanDurationsWhileValidatingPairing(t *testing.T) { | |
| 344 | + | base := func(kind string, tick int64, valid bool) traceRecord { | |
| 345 | + | return traceRecord{ProcessID: "one", Component: "adapter", Scenario: "s", Run: 1, Sequence: 1, RequestID: 1, Epoch: 1, Kind: kind, Tick: tick, Valid: valid} | |
| 346 | + | } | |
| 347 | + | for _, tc := range []struct { | |
| 348 | + | name string | |
| 349 | + | records []traceRecord | |
| 350 | + | wantSpans int | |
| 351 | + | wantErr bool | |
| 352 | + | }{ | |
| 353 | + | {"successful adapter send is sampled", []traceRecord{base("adapter_send_start", 10, true), base("adapter_send_end", 20, true)}, 1, false}, | |
| 354 | + | {"failed adapter send end is excluded", []traceRecord{base("adapter_send_start", 10, true), base("adapter_send_end", 20, false)}, 0, false}, | |
| 355 | + | {"failed adapter receive end is excluded", []traceRecord{base("adapter_receive_start", 10, true), base("adapter_receive_end", 20, false)}, 0, false}, | |
| 356 | + | {"failed adapter send start is excluded", []traceRecord{base("adapter_send_start", 10, false), base("adapter_send_end", 20, true)}, 0, false}, | |
| 357 | + | {"failed adapter receive boundaries are excluded", []traceRecord{base("adapter_receive_start", 10, false), base("adapter_receive_end", 20, false)}, 0, false}, | |
| 358 | + | {"failed end without start is rejected", []traceRecord{base("adapter_send_end", 20, false)}, 0, true}, | |
| 359 | + | {"duplicate failed start is rejected", []traceRecord{base("adapter_send_start", 10, false), base("adapter_send_start", 15, false)}, 0, true}, | |
| 360 | + | {"negative failed span is rejected", []traceRecord{base("adapter_receive_start", 20, false), base("adapter_receive_end", 10, false)}, 0, true}, | |
| 361 | + | {"cross-component failed end is rejected", []traceRecord{base("adapter_send_start", 10, true), func() traceRecord { r := base("adapter_send_end", 20, false); r.Component = "other"; return r }()}, 0, true}, | |
| 362 | + | } { | |
| 363 | + | t.Run(tc.name, func(t *testing.T) { | |
| 364 | + | path := filepath.Join(t.TempDir(), "trace.jsonl") | |
| 365 | + | var lines []string | |
| 366 | + | for _, record := range tc.records { | |
| 367 | + | lines = append(lines, mustJSON(record)) | |
| 368 | + | } | |
| 369 | + | if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { | |
| 370 | + | t.Fatal(err) | |
| 371 | + | } | |
| 372 | + | spans, err := mergeProcessTraces([]processMapping{{ProcessID: "one", ClockDomain: "one", TracePath: path, Scenario: "s", Run: 1}}) | |
| 373 | + | if tc.wantErr { | |
| 374 | + | if err == nil { | |
| 375 | + | t.Fatalf("invalid trace accepted: %+v", tc.records) | |
| 376 | + | } | |
| 377 | + | return | |
| 378 | + | } | |
| 379 | + | if err != nil || len(spans) != tc.wantSpans { | |
| 380 | + | t.Fatalf("spans=%+v err=%v, want %d spans", spans, err, tc.wantSpans) | |
| 381 | + | } | |
| 382 | + | if tc.wantSpans == 1 && spans[0].Samples[0] != 10 { | |
| 383 | + | t.Fatalf("successful span=%+v, want 10ns", spans[0]) | |
| 384 | + | } | |
| 385 | + | }) | |
| 386 | + | } | |
| 387 | + | } | |
A cmd/vev-perf-harness/launcher.go +758 -0
| @@ -0,0 +1,758 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "os/exec" | |
| 11 | + | "path/filepath" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | "sync" | |
| 15 | + | "syscall" | |
| 16 | + | "time" | |
| 17 | + | ||
| 18 | + | "github.com/bnema/vev/pkg/safedir" | |
| 19 | + | "golang.org/x/sys/unix" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | type launchedProcess interface { | |
| 23 | + | Warmup([]byte) error | |
| 24 | + | Measure([]byte, func() error, func() error) error | |
| 25 | + | Close() error | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // roleCommand contains only argv accepted by app.parseArgs. Transport routing | |
| 29 | + | // metadata configures the harness's public-command seam; it is never passed as | |
| 30 | + | // an unrecognised vev flag. | |
| 31 | + | type roleCommand struct { | |
| 32 | + | Args []string | |
| 33 | + | Transport transport | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | type launcher interface { | |
| 37 | + | Launch(processMapping, roleCommand) (launchedProcess, error) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type launchedRole struct { | |
| 41 | + | mapping processMapping | |
| 42 | + | process launchedProcess | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // closeLaunchedRoles closes every role in reverse launch order. errors.Join | |
| 46 | + | // retains every cleanup failure in that stable order. | |
| 47 | + | func closeLaunchedRoles(processes []launchedRole) error { | |
| 48 | + | var failures []error | |
| 49 | + | for i := len(processes) - 1; i >= 0; i-- { | |
| 50 | + | if err := processes[i].process.Close(); err != nil { | |
| 51 | + | failures = append(failures, fmt.Errorf("close %s role: %w", processes[i].mapping.Role, err)) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | return errors.Join(failures...) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // cliLauncher is deliberately limited to executing the supplied public vev | |
| 58 | + | // binary. Scenario orchestration never reaches into an in-process daemon. | |
| 59 | + | type cliLauncher struct { | |
| 60 | + | bin string | |
| 61 | + | netemFactory udpNetemFactory | |
| 62 | + | mu sync.Mutex | |
| 63 | + | peers map[string]peerRoute | |
| 64 | + | runtimes map[string]string | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | type peerRoute struct { | |
| 68 | + | mapping processMapping | |
| 69 | + | command roleCommand | |
| 70 | + | pidPath string | |
| 71 | + | netem udpNetem | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | type preparedPeer struct { | |
| 75 | + | route peerRoute | |
| 76 | + | cleanupRuntime func() error | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (p preparedPeer) Warmup([]byte) error { return nil } | |
| 80 | + | ||
| 81 | + | func (p preparedPeer) Measure([]byte, func() error, func() error) error { return nil } | |
| 82 | + | ||
| 83 | + | func (p preparedPeer) Close() error { | |
| 84 | + | var errs []error | |
| 85 | + | if p.route.pidPath != "" { | |
| 86 | + | b, err := os.ReadFile(p.route.pidPath) | |
| 87 | + | if err == nil { | |
| 88 | + | pid, parseErr := strconv.Atoi(strings.TrimSpace(string(b))) | |
| 89 | + | if parseErr != nil || pid <= 0 { | |
| 90 | + | errs = append(errs, fmt.Errorf("invalid owned peer pid: %q", b)) | |
| 91 | + | } else if killErr := syscall.Kill(pid, syscall.SIGTERM); killErr != nil && !errors.Is(killErr, syscall.ESRCH) { | |
| 92 | + | errs = append(errs, killErr) | |
| 93 | + | } | |
| 94 | + | } else if !errors.Is(err, os.ErrNotExist) { | |
| 95 | + | errs = append(errs, err) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | if p.route.netem != nil { | |
| 99 | + | if err := p.route.netem.Close(); err != nil { | |
| 100 | + | errs = append(errs, err) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | if p.cleanupRuntime != nil { | |
| 104 | + | if err := p.cleanupRuntime(); err != nil { | |
| 105 | + | errs = append(errs, err) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | return errors.Join(errs...) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | type terminalOutput interface { | |
| 112 | + | io.Writer | |
| 113 | + | Sync() error | |
| 114 | + | Close() error | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | type cliProcess struct { | |
| 118 | + | cmd *exec.Cmd | |
| 119 | + | pty io.ReadWriteCloser | |
| 120 | + | output terminalOutput | |
| 121 | + | chunks chan []byte | |
| 122 | + | done chan struct{} | |
| 123 | + | closed sync.Once | |
| 124 | + | waitErr chan error | |
| 125 | + | waitTimeout func() <-chan time.Time | |
| 126 | + | forceCleanup func() | |
| 127 | + | forceKill func() | |
| 128 | + | shutdown func() error | |
| 129 | + | cleanupRuntime func() error | |
| 130 | + | terminalReady bool | |
| 131 | + | readyPath string | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProcess, error) { | |
| 135 | + | if len(role.Args) == 0 { | |
| 136 | + | return nil, errors.New("public CLI role has no arguments") | |
| 137 | + | } | |
| 138 | + | if info, err := os.Stat(l.bin); err != nil { | |
| 139 | + | return nil, err | |
| 140 | + | } else if info.IsDir() { | |
| 141 | + | return nil, fmt.Errorf("public CLI binary %q is a directory", l.bin) | |
| 142 | + | } | |
| 143 | + | if m.Role == "ssh_stdio_peer" || m.Role == "udp_peer" { | |
| 144 | + | return l.preparePeer(m, role) | |
| 145 | + | } | |
| 146 | + | cmd := exec.Command(l.bin, role.Args...) | |
| 147 | + | runDir := filepath.Dir(m.TracePath) | |
| 148 | + | runtimeDir, err := l.runtimeDir(runDir) | |
| 149 | + | if err != nil { | |
| 150 | + | return nil, err | |
| 151 | + | } | |
| 152 | + | // All role inputs are absolute paths resolved at harness startup. Do not set | |
| 153 | + | // a role working directory: daemon-owned subprocess cleanup may otherwise | |
| 154 | + | // treat the evidence directory as its working tree and remove a preallocated | |
| 155 | + | // trace while a later repetition is being merged. | |
| 156 | + | cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_REMOTE_TRANSPORT", "XDG_RUNTIME_DIR", "XDG_STATE_HOME"), traceEnvironment(m)...) | |
| 157 | + | cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir, | |
| 158 | + | "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "TERM=xterm-256color") | |
| 159 | + | if m.Role == "client" { | |
| 160 | + | l.mu.Lock() | |
| 161 | + | peer, routed := l.peers[runDir] | |
| 162 | + | l.mu.Unlock() | |
| 163 | + | if routed { | |
| 164 | + | mode, err := remoteMode(peer.command.Transport) | |
| 165 | + | if err != nil { | |
| 166 | + | return nil, err | |
| 167 | + | } | |
| 168 | + | cmd.Env = append(cmd.Env, "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode) | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil { | |
| 172 | + | return nil, err | |
| 173 | + | } | |
| 174 | + | p := &cliProcess{cmd: cmd, waitErr: make(chan error, 1), readyPath: filepath.Join(runtimeDir, "vev", "daemon.sock")} | |
| 175 | + | if m.Role == "daemon" { | |
| 176 | + | p.cleanupRuntime = func() error { return l.releaseRuntime(runDir) } | |
| 177 | + | } | |
| 178 | + | if m.Role == "client" { | |
| 179 | + | master, slave, err := openPTY() | |
| 180 | + | if err != nil { | |
| 181 | + | return nil, err | |
| 182 | + | } | |
| 183 | + | output, err := os.OpenFile(filepath.Join(filepath.Dir(m.TracePath), "terminal-output"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) | |
| 184 | + | if err != nil { | |
| 185 | + | _ = master.Close() | |
| 186 | + | _ = slave.Close() | |
| 187 | + | return nil, err | |
| 188 | + | } | |
| 189 | + | cmd.Stdin, cmd.Stdout, cmd.Stderr = slave, slave, slave | |
| 190 | + | // Ctty is the child descriptor number after Start remaps slave to stdin. | |
| 191 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0} | |
| 192 | + | if err := cmd.Start(); err != nil { | |
| 193 | + | _ = output.Close() | |
| 194 | + | _ = master.Close() | |
| 195 | + | _ = slave.Close() | |
| 196 | + | if p.cleanupRuntime != nil { | |
| 197 | + | _ = p.cleanupRuntime() | |
| 198 | + | } | |
| 199 | + | return nil, err | |
| 200 | + | } | |
| 201 | + | _ = slave.Close() | |
| 202 | + | p.pty, p.output, p.chunks, p.done = master, output, make(chan []byte, 32), make(chan struct{}) | |
| 203 | + | go p.copyTerminal() | |
| 204 | + | } else { | |
| 205 | + | // Every launched role owns a dedicated process group, so the bounded | |
| 206 | + | // cleanup path never signals the harness/test runner's group. | |
| 207 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 208 | + | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |
| 209 | + | if err != nil { | |
| 210 | + | return nil, err | |
| 211 | + | } | |
| 212 | + | cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull | |
| 213 | + | if err := cmd.Start(); err != nil { | |
| 214 | + | _ = devNull.Close() | |
| 215 | + | if p.cleanupRuntime != nil { | |
| 216 | + | _ = p.cleanupRuntime() | |
| 217 | + | } | |
| 218 | + | return nil, err | |
| 219 | + | } | |
| 220 | + | _ = devNull.Close() | |
| 221 | + | } | |
| 222 | + | if m.Role == "daemon" { | |
| 223 | + | // Stop the foreground daemon through its public CLI before its bounded | |
| 224 | + | // process-group fallback. This lets blocked carriage operations close and | |
| 225 | + | // serialize their failed end marks instead of being cut off by SIGKILL. | |
| 226 | + | p.shutdown = func() error { | |
| 227 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 228 | + | defer cancel() | |
| 229 | + | shutdown := exec.CommandContext(ctx, l.bin, "kill", "--daemon") | |
| 230 | + | shutdown.Env = withoutEnv(cmd.Env, "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN") | |
| 231 | + | return shutdown.Run() | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | go func() { p.waitErr <- cmd.Wait() }() | |
| 235 | + | return p, nil | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // runtimeDir returns one short, private runtime directory shared by the roles | |
| 239 | + | // of one run. Keeping the Unix socket outside deeply nested evidence output | |
| 240 | + | // avoids the platform socket-path limit without weakening run isolation. | |
| 241 | + | func (l *cliLauncher) runtimeDir(runDir string) (string, error) { | |
| 242 | + | l.mu.Lock() | |
| 243 | + | defer l.mu.Unlock() | |
| 244 | + | if path := l.runtimes[runDir]; path != "" { | |
| 245 | + | return path, nil | |
| 246 | + | } | |
| 247 | + | path, err := os.MkdirTemp("", "vev-harness-runtime-") | |
| 248 | + | if err != nil { | |
| 249 | + | return "", err | |
| 250 | + | } | |
| 251 | + | if err := safedir.EnsurePrivate(path); err != nil { | |
| 252 | + | _ = os.RemoveAll(path) | |
| 253 | + | return "", err | |
| 254 | + | } | |
| 255 | + | if l.runtimes == nil { | |
| 256 | + | l.runtimes = make(map[string]string) | |
| 257 | + | } | |
| 258 | + | l.runtimes[runDir] = path | |
| 259 | + | return path, nil | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func (l *cliLauncher) releaseRuntime(runDir string) error { | |
| 263 | + | l.mu.Lock() | |
| 264 | + | path := l.runtimes[runDir] | |
| 265 | + | delete(l.runtimes, runDir) | |
| 266 | + | l.mu.Unlock() | |
| 267 | + | if path == "" { | |
| 268 | + | return nil | |
| 269 | + | } | |
| 270 | + | return os.RemoveAll(path) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // preparePeer installs a per-run ssh command seam. The ordinary public client | |
| 274 | + | // still executes its documented remote attach command; its ssh child invokes | |
| 275 | + | // only the parsed public _stdio or _udp-proxy command. This makes the peer | |
| 276 | + | // carrying client traffic the declared, exclusively traced role rather than a | |
| 277 | + | // separately launched but unused process. | |
| 278 | + | func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedProcess, error) { | |
| 279 | + | runDir := filepath.Dir(m.TracePath) | |
| 280 | + | l.mu.Lock() | |
| 281 | + | runtimeDir := l.runtimes[runDir] | |
| 282 | + | l.mu.Unlock() | |
| 283 | + | peerOwnsRuntime := runtimeDir == "" | |
| 284 | + | fail := func(err error) error { | |
| 285 | + | if peerOwnsRuntime { | |
| 286 | + | return errors.Join(err, l.releaseRuntime(runDir)) | |
| 287 | + | } | |
| 288 | + | return err | |
| 289 | + | } | |
| 290 | + | if peerOwnsRuntime { | |
| 291 | + | var err error | |
| 292 | + | runtimeDir, err = l.runtimeDir(runDir) | |
| 293 | + | if err != nil { | |
| 294 | + | return nil, fail(err) | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | if err := safedir.EnsurePrivate(runDir); err != nil { | |
| 298 | + | return nil, fail(err) | |
| 299 | + | } | |
| 300 | + | if err := safedir.EnsurePrivate(runtimeDir); err != nil { | |
| 301 | + | return nil, fail(err) | |
| 302 | + | } | |
| 303 | + | if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil { | |
| 304 | + | return nil, fail(err) | |
| 305 | + | } | |
| 306 | + | if len(role.Args) != 2 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") { | |
| 307 | + | return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args)) | |
| 308 | + | } | |
| 309 | + | route := peerRoute{mapping: m, command: role} | |
| 310 | + | closeNetem := func(err error) error { | |
| 311 | + | if route.netem != nil { | |
| 312 | + | err = errors.Join(err, route.netem.Close()) | |
| 313 | + | } | |
| 314 | + | return fail(err) | |
| 315 | + | } | |
| 316 | + | if m.Role == "udp_peer" { | |
| 317 | + | route.pidPath = filepath.Join(runDir, "udp-peer.pid") | |
| 318 | + | factory := l.netemFactory | |
| 319 | + | if factory == nil { | |
| 320 | + | factory = newUDPNetem | |
| 321 | + | } | |
| 322 | + | netem, err := factory(udpNetemConfig{ | |
| 323 | + | RTT: time.Duration(role.Transport.RTTMS) * time.Millisecond, | |
| 324 | + | LossPercent: role.Transport.LossPercent, | |
| 325 | + | TargetPath: filepath.Join(runDir, "udp-peer.target"), | |
| 326 | + | }) | |
| 327 | + | if err != nil { | |
| 328 | + | return nil, fail(fmt.Errorf("start harness UDP netem: %w", err)) | |
| 329 | + | } | |
| 330 | + | route.netem = netem | |
| 331 | + | } | |
| 332 | + | shim := filepath.Join(runDir, "ssh") | |
| 333 | + | ready := filepath.Join(runDir, "udp-peer.ready") | |
| 334 | + | target := filepath.Join(runDir, "udp-peer.target") | |
| 335 | + | netemPort := 0 | |
| 336 | + | if route.netem != nil { | |
| 337 | + | netemPort = route.netem.Port() | |
| 338 | + | } | |
| 339 | + | stderr := filepath.Join(runDir, "udp-peer.stderr") | |
| 340 | + | // The bootstrap protocol requires the SSH command to exit after forwarding | |
| 341 | + | // the readiness line. The long-lived proxy is owned by pidPath and receives | |
| 342 | + | // the role's unique trace identity, never the bootstrap/client identity. | |
| 343 | + | body := fmt.Sprintf(`#!/bin/sh | |
| 344 | + | set -eu | |
| 345 | + | case "$*" in | |
| 346 | + | *"_stdio"*) | |
| 347 | + | exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[12]q VEV_PERF_RUN=%[13]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _stdio %[6]q | |
| 348 | + | ;; | |
| 349 | + | *"_udp-bootstrap"*) | |
| 350 | + | rm -f %[7]q %[8]q %[9]q | |
| 351 | + | env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[12]q VEV_PERF_RUN=%[13]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _udp-proxy %[6]q >%[7]q 2>%[8]q & | |
| 352 | + | peer=$! | |
| 353 | + | printf '%%s\n' "$peer" > %[11]q | |
| 354 | + | i=0 | |
| 355 | + | while [ ! -s %[7]q ] && [ "$i" -lt 1000 ]; do sleep 0.01; i=$((i+1)); done | |
| 356 | + | test -s %[7]q | |
| 357 | + | line=$(head -n 1 %[7]q) | |
| 358 | + | printf '%%s\n' "$line" > %[9]q | |
| 359 | + | set -- $line | |
| 360 | + | test "$1" = VEV-UDP | |
| 361 | + | test -n "$3" | |
| 362 | + | printf 'VEV-UDP %%s %%s\n' %[10]d "$3" | |
| 363 | + | exit 0 | |
| 364 | + | ;; | |
| 365 | + | *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;; | |
| 366 | + | esac | |
| 367 | + | `, m.TracePath, m.ProcessID, runtimeDir, filepath.Join(runDir, "state"), l.bin, role.Args[1], ready, stderr, target, netemPort, route.pidPath, m.Scenario, m.Run) | |
| 368 | + | if err := os.WriteFile(shim, []byte(body), 0o700); err != nil { | |
| 369 | + | return nil, closeNetem(err) | |
| 370 | + | } | |
| 371 | + | l.mu.Lock() | |
| 372 | + | if l.peers == nil { | |
| 373 | + | l.peers = make(map[string]peerRoute) | |
| 374 | + | } | |
| 375 | + | if _, exists := l.peers[runDir]; exists { | |
| 376 | + | l.mu.Unlock() | |
| 377 | + | return nil, closeNetem(fmt.Errorf("duplicate transport peer for %s", runDir)) | |
| 378 | + | } | |
| 379 | + | l.peers[runDir] = route | |
| 380 | + | l.mu.Unlock() | |
| 381 | + | peer := preparedPeer{route: route} | |
| 382 | + | if peerOwnsRuntime { | |
| 383 | + | peer.cleanupRuntime = func() error { return l.releaseRuntime(runDir) } | |
| 384 | + | } | |
| 385 | + | return peer, nil | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func remoteMode(t transport) (string, error) { | |
| 389 | + | switch t.Kind { | |
| 390 | + | case "ssh_stdio": | |
| 391 | + | return "stdio", nil | |
| 392 | + | case "udp": | |
| 393 | + | return "udp", nil | |
| 394 | + | default: | |
| 395 | + | return "", fmt.Errorf("transport %q cannot route through a remote peer", t.ID) | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | // traceEnvironment is the complete process-local identity supplied by the | |
| 400 | + | // persisted harness processMapping. App turns Scenario/Run into production | |
| 401 | + | // mark correlation; sequence/request/epoch remain process-local operation IDs. | |
| 402 | + | func traceEnvironment(m processMapping) []string { | |
| 403 | + | return []string{ | |
| 404 | + | "VEV_PERF_TRACE=" + m.TracePath, | |
| 405 | + | "VEV_PERF_PROCESS_ID=" + m.ProcessID, | |
| 406 | + | "VEV_PERF_SCENARIO=" + m.Scenario, | |
| 407 | + | "VEV_PERF_RUN=" + strconv.Itoa(m.Run), | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | func withoutEnv(env []string, names ...string) []string { | |
| 412 | + | remove := make(map[string]bool, len(names)) | |
| 413 | + | for _, name := range names { | |
| 414 | + | remove[name] = true | |
| 415 | + | } | |
| 416 | + | out := make([]string, 0, len(env)) | |
| 417 | + | for _, entry := range env { | |
| 418 | + | name, _, _ := strings.Cut(entry, "=") | |
| 419 | + | if !remove[name] { | |
| 420 | + | out = append(out, entry) | |
| 421 | + | } | |
| 422 | + | } | |
| 423 | + | return out | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | func (p *cliProcess) copyTerminal() { | |
| 427 | + | defer close(p.chunks) | |
| 428 | + | buf := make([]byte, 4096) | |
| 429 | + | for { | |
| 430 | + | n, err := p.pty.Read(buf) | |
| 431 | + | if n > 0 { | |
| 432 | + | chunk := append([]byte(nil), buf[:n]...) | |
| 433 | + | if _, writeErr := p.output.Write(chunk); writeErr != nil { | |
| 434 | + | return | |
| 435 | + | } | |
| 436 | + | select { | |
| 437 | + | case p.chunks <- chunk: | |
| 438 | + | case <-p.done: | |
| 439 | + | return | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | if err != nil { | |
| 443 | + | return | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // WaitReady confirms the explicitly launched public daemon owns its unique | |
| 449 | + | // socket before a client is launched. Without this barrier, the normal client | |
| 450 | + | // auto-spawn behavior can create an unmanifested daemon process during startup. | |
| 451 | + | func (p *cliProcess) WaitReady() error { | |
| 452 | + | if p.readyPath == "" { | |
| 453 | + | return errors.New("daemon readiness path is empty") | |
| 454 | + | } | |
| 455 | + | ticker := time.NewTicker(10 * time.Millisecond) | |
| 456 | + | defer ticker.Stop() | |
| 457 | + | timer := time.NewTimer(10 * time.Second) | |
| 458 | + | defer timer.Stop() | |
| 459 | + | for { | |
| 460 | + | if info, err := os.Stat(p.readyPath); err == nil { | |
| 461 | + | if info.Mode()&os.ModeSocket == 0 { | |
| 462 | + | return fmt.Errorf("daemon readiness path %s is not a socket", p.readyPath) | |
| 463 | + | } | |
| 464 | + | return nil | |
| 465 | + | } | |
| 466 | + | select { | |
| 467 | + | case err := <-p.waitErr: | |
| 468 | + | p.waitErr <- err | |
| 469 | + | if err == nil { | |
| 470 | + | return errors.New("daemon exited before readiness") | |
| 471 | + | } | |
| 472 | + | return fmt.Errorf("daemon exited before readiness: %w", err) | |
| 473 | + | case <-ticker.C: | |
| 474 | + | case <-timer.C: | |
| 475 | + | return fmt.Errorf("timed out waiting for daemon socket %s", p.readyPath) | |
| 476 | + | } | |
| 477 | + | } | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | func (p *cliProcess) Warmup(input []byte) error { | |
| 481 | + | if p.pty == nil || len(input) == 0 { | |
| 482 | + | return nil | |
| 483 | + | } | |
| 484 | + | // A client cannot write application output until it has received Welcome and | |
| 485 | + | // entered raw mode. Require that observable output before submitting any | |
| 486 | + | // warmup input, so the PTY's line discipline cannot echo it locally. | |
| 487 | + | if !p.terminalReady { | |
| 488 | + | if err := p.waitForTerminalReady(); err != nil { | |
| 489 | + | return err | |
| 490 | + | } | |
| 491 | + | p.terminalReady = true | |
| 492 | + | } | |
| 493 | + | if _, err := p.pty.Write(input); err != nil { | |
| 494 | + | return err | |
| 495 | + | } | |
| 496 | + | // Do not begin measured input until the public client has rendered a real | |
| 497 | + | // shell response to warmup input. This is intentionally not a measurement. | |
| 498 | + | return p.waitForTerminalMarker(inputMarker(input), input) | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | // Measure records a boundary only after the harness-owned terminal output file | |
| 502 | + | // has accepted application output and Sync has reported a successful flush. | |
| 503 | + | // The marker is injected through the real client PTY, never fabricated by the | |
| 504 | + | // harness. PTY local echo is discarded and cannot satisfy this boundary. | |
| 505 | + | func (p *cliProcess) Measure(input []byte, injected, flushed func() error) error { | |
| 506 | + | if p.pty == nil { | |
| 507 | + | return errors.New("measured process has no terminal PTY") | |
| 508 | + | } | |
| 509 | + | if _, err := p.pty.Write(input); err != nil { | |
| 510 | + | return err | |
| 511 | + | } | |
| 512 | + | // The injected callback runs only after the owned PTY accepts the bytes. | |
| 513 | + | if err := injected(); err != nil { | |
| 514 | + | return err | |
| 515 | + | } | |
| 516 | + | if err := p.waitForTerminalMarker(inputMarker(input), input); err != nil { | |
| 517 | + | return err | |
| 518 | + | } | |
| 519 | + | if err := p.output.Sync(); err != nil { | |
| 520 | + | return fmt.Errorf("flush terminal output: %w", err) | |
| 521 | + | } | |
| 522 | + | return flushed() | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | // waitForTerminalReady waits for public client output produced before any | |
| 526 | + | // harness input. The client enters raw mode before its output loop can render | |
| 527 | + | // this initial prompt/state, so this is an observable readiness boundary. | |
| 528 | + | func (p *cliProcess) waitForTerminalReady() error { | |
| 529 | + | timer := time.NewTimer(10 * time.Second) | |
| 530 | + | defer timer.Stop() | |
| 531 | + | for { | |
| 532 | + | select { | |
| 533 | + | case chunk, ok := <-p.chunks: | |
| 534 | + | if !ok { | |
| 535 | + | return p.terminalClosedError("readiness output") | |
| 536 | + | } | |
| 537 | + | if len(chunk) == 0 { | |
| 538 | + | continue | |
| 539 | + | } | |
| 540 | + | if err := p.output.Sync(); err != nil { | |
| 541 | + | return fmt.Errorf("flush terminal readiness output: %w", err) | |
| 542 | + | } | |
| 543 | + | return nil | |
| 544 | + | case err := <-p.waitErr: | |
| 545 | + | // Keep the exit result available to Close, which owns reaping. | |
| 546 | + | p.waitErr <- err | |
| 547 | + | if err == nil { | |
| 548 | + | return errors.New("client exited before readiness output") | |
| 549 | + | } | |
| 550 | + | return fmt.Errorf("client exited before readiness output: %w", err) | |
| 551 | + | case <-timer.C: | |
| 552 | + | return errors.New("timed out waiting for client readiness output") | |
| 553 | + | } | |
| 554 | + | } | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | func (p *cliProcess) terminalClosedError(boundary string) error { | |
| 558 | + | if p.waitErr != nil { | |
| 559 | + | select { | |
| 560 | + | case err := <-p.waitErr: | |
| 561 | + | // Close owns reaping and may need the result after a failed boundary. | |
| 562 | + | p.waitErr <- err | |
| 563 | + | if err != nil { | |
| 564 | + | return fmt.Errorf("client terminal closed before %s: %w", boundary, err) | |
| 565 | + | } | |
| 566 | + | case <-time.After(10 * time.Millisecond): | |
| 567 | + | } | |
| 568 | + | } | |
| 569 | + | return fmt.Errorf("client terminal closed before %s", boundary) | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | func (p *cliProcess) waitForTerminalMarker(marker, input []byte) error { | |
| 573 | + | var received []byte | |
| 574 | + | echo := newPTYLocalEcho(input) | |
| 575 | + | timer := time.NewTimer(10 * time.Second) | |
| 576 | + | defer timer.Stop() | |
| 577 | + | for !bytes.Contains(received, marker) { | |
| 578 | + | select { | |
| 579 | + | case chunk, ok := <-p.chunks: | |
| 580 | + | if !ok { | |
| 581 | + | return p.terminalClosedError("application output") | |
| 582 | + | } | |
| 583 | + | received = append(received, echo.applicationOutput(chunk)...) | |
| 584 | + | case err := <-p.waitErr: | |
| 585 | + | // Keep the exit result available to Close, which owns reaping. | |
| 586 | + | p.waitErr <- err | |
| 587 | + | if err == nil { | |
| 588 | + | return errors.New("client exited before application output") | |
| 589 | + | } | |
| 590 | + | return fmt.Errorf("client exited before application output: %w", err) | |
| 591 | + | case <-timer.C: | |
| 592 | + | return errors.New("timed out waiting for application terminal output") | |
| 593 | + | } | |
| 594 | + | } | |
| 595 | + | return nil | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | // ptyLocalEcho removes the one ordered input echo that a not-yet-raw slave PTY | |
| 599 | + | // can produce. It normalizes NL to the conventional ONLCR echo spelling; any | |
| 600 | + | // divergence is retained as application output. The line discipline emits the | |
| 601 | + | // echo before the child can read and respond to the same write. | |
| 602 | + | type ptyLocalEcho struct { | |
| 603 | + | expected []byte | |
| 604 | + | matched int | |
| 605 | + | done bool | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | func (e *ptyLocalEcho) applicationOutput(chunk []byte) []byte { | |
| 609 | + | if e.done || len(e.expected) == 0 { | |
| 610 | + | return chunk | |
| 611 | + | } | |
| 612 | + | for i, b := range chunk { | |
| 613 | + | if b != e.expected[e.matched] { | |
| 614 | + | e.done = true | |
| 615 | + | return append([]byte(nil), chunk[i:]...) | |
| 616 | + | } | |
| 617 | + | e.matched++ | |
| 618 | + | if e.matched == len(e.expected) { | |
| 619 | + | e.done = true | |
| 620 | + | return append([]byte(nil), chunk[i+1:]...) | |
| 621 | + | } | |
| 622 | + | } | |
| 623 | + | return nil | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | func (p *cliProcess) Close() error { | |
| 627 | + | var result error | |
| 628 | + | p.closed.Do(func() { | |
| 629 | + | if p.done != nil { | |
| 630 | + | close(p.done) | |
| 631 | + | } | |
| 632 | + | // A public terminal client owns a shell session. Ask that shell to exit | |
| 633 | + | // before closing the PTY so its transport can finish its receive span; | |
| 634 | + | // this avoids turning ordinary harness teardown into an aborted trace. | |
| 635 | + | exited := false | |
| 636 | + | if p.shutdown != nil && p.waitErr != nil { | |
| 637 | + | select { | |
| 638 | + | case <-p.waitErr: | |
| 639 | + | exited = true | |
| 640 | + | default: | |
| 641 | + | // The client may have just ended the last session and raced this | |
| 642 | + | // best-effort public shutdown. The normal bounded reaping path below | |
| 643 | + | // remains authoritative in that case. | |
| 644 | + | _ = p.shutdown() | |
| 645 | + | } | |
| 646 | + | } | |
| 647 | + | if p.pty != nil && p.waitErr != nil { | |
| 648 | + | _, _ = p.pty.Write([]byte("exit\n")) | |
| 649 | + | select { | |
| 650 | + | case <-p.waitErr: | |
| 651 | + | exited = true | |
| 652 | + | case <-p.closeDeadline(): | |
| 653 | + | } | |
| 654 | + | } | |
| 655 | + | if p.pty != nil { | |
| 656 | + | _ = p.pty.Close() | |
| 657 | + | } | |
| 658 | + | if p.waitErr != nil && !exited { | |
| 659 | + | select { | |
| 660 | + | case <-p.waitErr: | |
| 661 | + | case <-p.closeDeadline(): | |
| 662 | + | // Teardown is deliberately bounded: a stuck public CLI or child | |
| 663 | + | // must not hold a local harness run indefinitely. | |
| 664 | + | p.forceProcessGroupCleanup() | |
| 665 | + | select { | |
| 666 | + | case <-p.waitErr: | |
| 667 | + | case <-p.closeDeadline(): | |
| 668 | + | p.forceProcessGroupKill() | |
| 669 | + | select { | |
| 670 | + | case <-p.waitErr: | |
| 671 | + | case <-p.closeDeadline(): | |
| 672 | + | result = errors.New("timed out reaping public CLI process") | |
| 673 | + | } | |
| 674 | + | } | |
| 675 | + | } | |
| 676 | + | } | |
| 677 | + | if p.output != nil { | |
| 678 | + | if err := p.output.Close(); result == nil { | |
| 679 | + | result = err | |
| 680 | + | } | |
| 681 | + | } | |
| 682 | + | if p.cleanupRuntime != nil { | |
| 683 | + | if err := p.cleanupRuntime(); result == nil { | |
| 684 | + | result = err | |
| 685 | + | } | |
| 686 | + | } | |
| 687 | + | }) | |
| 688 | + | return result | |
| 689 | + | } | |
| 690 | + | ||
| 691 | + | func (p *cliProcess) closeDeadline() <-chan time.Time { | |
| 692 | + | if p.waitTimeout != nil { | |
| 693 | + | return p.waitTimeout() | |
| 694 | + | } | |
| 695 | + | if p.shutdown != nil { | |
| 696 | + | // A public kill asks the daemon to finish bounded detached notices and | |
| 697 | + | // close blocked carriage before it exits; do not turn that graceful | |
| 698 | + | // protocol into a SIGKILL at the two-second client deadline. | |
| 699 | + | return time.After(5 * time.Second) | |
| 700 | + | } | |
| 701 | + | return time.After(2 * time.Second) | |
| 702 | + | } | |
| 703 | + | ||
| 704 | + | func (p *cliProcess) forceProcessGroupCleanup() { | |
| 705 | + | if p.forceCleanup != nil { | |
| 706 | + | p.forceCleanup() | |
| 707 | + | return | |
| 708 | + | } | |
| 709 | + | if p.cmd != nil && p.cmd.Process != nil { | |
| 710 | + | // The client is a session/process-group leader; forced cleanup must | |
| 711 | + | // include its ssh seam/_stdio descendants. | |
| 712 | + | _ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGTERM) | |
| 713 | + | } | |
| 714 | + | } | |
| 715 | + | ||
| 716 | + | func (p *cliProcess) forceProcessGroupKill() { | |
| 717 | + | if p.forceKill != nil { | |
| 718 | + | p.forceKill() | |
| 719 | + | return | |
| 720 | + | } | |
| 721 | + | if p.cmd != nil && p.cmd.Process != nil { | |
| 722 | + | _ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) | |
| 723 | + | } | |
| 724 | + | } | |
| 725 | + | ||
| 726 | + | // openPTY is the small Linux PTY boundary needed to drive the public terminal | |
| 727 | + | // client. No daemon or adapter implementation is imported by this command. | |
| 728 | + | func openPTY() (*os.File, *os.File, error) { | |
| 729 | + | masterFD, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0) | |
| 730 | + | if err != nil { | |
| 731 | + | return nil, nil, err | |
| 732 | + | } | |
| 733 | + | closeMaster := func(err error) (*os.File, *os.File, error) { _ = unix.Close(masterFD); return nil, nil, err } | |
| 734 | + | number, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN) | |
| 735 | + | if err != nil { | |
| 736 | + | return closeMaster(err) | |
| 737 | + | } | |
| 738 | + | if err := unix.IoctlSetPointerInt(masterFD, unix.TIOCSPTLCK, 0); err != nil { | |
| 739 | + | return closeMaster(err) | |
| 740 | + | } | |
| 741 | + | slaveFD, err := unix.Open(fmt.Sprintf("/dev/pts/%d", number), unix.O_RDWR|unix.O_NOCTTY, 0) | |
| 742 | + | if err != nil { | |
| 743 | + | return closeMaster(err) | |
| 744 | + | } | |
| 745 | + | return os.NewFile(uintptr(masterFD), "vev-client-master"), os.NewFile(uintptr(slaveFD), "vev-client-slave"), nil | |
| 746 | + | } | |
| 747 | + | ||
| 748 | + | func inputMarker(input []byte) []byte { | |
| 749 | + | start := bytes.Index(input, []byte("__VEV_HARNESS_")) | |
| 750 | + | if start < 0 { | |
| 751 | + | return input | |
| 752 | + | } | |
| 753 | + | end := bytes.Index(input[start:], []byte(`\n`)) | |
| 754 | + | if end < 0 { | |
| 755 | + | return input[start:] | |
| 756 | + | } | |
| 757 | + | return input[start : start+end] | |
| 758 | + | } | |
A cmd/vev-perf-harness/launcher_test.go +326 -0
| @@ -0,0 +1,326 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "net" | |
| 8 | + | "os" | |
| 9 | + | "os/exec" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/pkg/safedir" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) { | |
| 19 | + | deadline := make(chan time.Time) | |
| 20 | + | close(deadline) | |
| 21 | + | var term, kill int | |
| 22 | + | p := &cliProcess{ | |
| 23 | + | waitErr: make(chan error), | |
| 24 | + | waitTimeout: func() <-chan time.Time { return deadline }, | |
| 25 | + | forceCleanup: func() { term++ }, | |
| 26 | + | forceKill: func() { kill++ }, | |
| 27 | + | } | |
| 28 | + | if err := p.Close(); err == nil { | |
| 29 | + | t.Fatal("stuck public role close succeeded") | |
| 30 | + | } | |
| 31 | + | if term != 1 || kill != 1 { | |
| 32 | + | t.Fatalf("TERM/KILL calls = %d/%d, want 1/1", term, kill) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestCLIProcessWaitReadyRequiresDaemonSocket(t *testing.T) { | |
| 37 | + | path := filepath.Join(t.TempDir(), "daemon.sock") | |
| 38 | + | var lc net.ListenConfig | |
| 39 | + | listener, err := lc.Listen(context.Background(), "unix", path) | |
| 40 | + | if err != nil { | |
| 41 | + | t.Fatal(err) | |
| 42 | + | } | |
| 43 | + | t.Cleanup(func() { | |
| 44 | + | if err := listener.Close(); err != nil { | |
| 45 | + | t.Error(err) | |
| 46 | + | } | |
| 47 | + | }) | |
| 48 | + | if err := (&cliProcess{readyPath: path, waitErr: make(chan error, 1)}).WaitReady(); err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestCLILauncherIsolatesRoleXDGEnvironmentAcrossRepetitions(t *testing.T) { | |
| 54 | + | captureDir := t.TempDir() | |
| 55 | + | t.Setenv("CAPTURE_DIR", captureDir) | |
| 56 | + | t.Setenv("XDG_RUNTIME_DIR", filepath.Join(t.TempDir(), "inherited-runtime")) | |
| 57 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(t.TempDir(), "inherited-state")) | |
| 58 | + | ||
| 59 | + | bin := filepath.Join(t.TempDir(), "capture-env") | |
| 60 | + | if err := os.WriteFile(bin, []byte("#!/bin/sh\nenv > \"$CAPTURE_DIR/$VEV_PERF_PROCESS_ID\"\n"), 0o700); err != nil { | |
| 61 | + | t.Fatal(err) | |
| 62 | + | } | |
| 63 | + | launcher := &cliLauncher{bin: bin} | |
| 64 | + | for run := 1; run <= 3; run++ { | |
| 65 | + | runDir := filepath.Join(t.TempDir(), fmt.Sprintf("run-%d", run)) | |
| 66 | + | if err := safedir.EnsurePrivate(runDir); err != nil { | |
| 67 | + | t.Fatal(err) | |
| 68 | + | } | |
| 69 | + | roles := []string{"daemon", "client"} | |
| 70 | + | for _, role := range roles { | |
| 71 | + | id := fmt.Sprintf("run-%d-%s", run, role) | |
| 72 | + | p, err := launcher.Launch(processMapping{ | |
| 73 | + | Role: role, TracePath: filepath.Join(runDir, role+".jsonl"), ProcessID: id, | |
| 74 | + | }, roleCommand{Args: []string{"test"}}) | |
| 75 | + | if err != nil { | |
| 76 | + | t.Fatalf("run %d %s launch: %v", run, role, err) | |
| 77 | + | } | |
| 78 | + | runtimeDir := launcher.runtimes[runDir] | |
| 79 | + | if err := p.Close(); err != nil { | |
| 80 | + | t.Fatalf("run %d %s close: %v", run, role, err) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | env, err := os.ReadFile(filepath.Join(captureDir, id)) | |
| 84 | + | if err != nil { | |
| 85 | + | t.Fatalf("run %d %s capture: %v", run, role, err) | |
| 86 | + | } | |
| 87 | + | for _, want := range []string{ | |
| 88 | + | "XDG_RUNTIME_DIR=" + runtimeDir, | |
| 89 | + | "XDG_STATE_HOME=" + filepath.Join(runDir, "state"), | |
| 90 | + | } { | |
| 91 | + | name := strings.SplitN(want, "=", 2)[0] | |
| 92 | + | if count := bytes.Count(env, []byte(name+"=")); count != 1 { | |
| 93 | + | t.Fatalf("run %d %s %s entries = %d, want exactly one; env:\n%s", run, role, name, count, env) | |
| 94 | + | } | |
| 95 | + | if !bytes.Contains(env, []byte(want+"\n")) { | |
| 96 | + | t.Fatalf("run %d %s missing %q; env:\n%s", run, role, want, env) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | if err := launcher.releaseRuntime(runDir); err != nil { | |
| 101 | + | t.Fatalf("run %d release runtime: %v", run, err) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestPreparePeerReleasesOwnedRuntimeOnPreparationFailure(t *testing.T) { | |
| 107 | + | t.Setenv("TMPDIR", t.TempDir()) | |
| 108 | + | runDir := filepath.Join(t.TempDir(), "run") | |
| 109 | + | if err := safedir.EnsurePrivate(runDir); err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | launcher := &cliLauncher{} | |
| 113 | + | _, err := launcher.preparePeer(processMapping{ | |
| 114 | + | Role: "ssh_stdio_peer", | |
| 115 | + | TracePath: filepath.Join(runDir, "peer.jsonl"), | |
| 116 | + | }, roleCommand{Args: []string{"invalid"}}) | |
| 117 | + | if err == nil || !strings.Contains(err.Error(), "unsupported public peer command") { | |
| 118 | + | t.Fatalf("preparePeer error = %v", err) | |
| 119 | + | } | |
| 120 | + | if runtimeDir := launcher.runtimes[runDir]; runtimeDir != "" { | |
| 121 | + | t.Fatalf("failed peer preparation leaked runtime directory %q", runtimeDir) | |
| 122 | + | } | |
| 123 | + | if paths, globErr := filepath.Glob(filepath.Join(os.TempDir(), "vev-harness-runtime-*")); globErr != nil || len(paths) != 0 { | |
| 124 | + | t.Fatalf("failed peer preparation left runtime directories %q: %v", paths, globErr) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestCLIProcessCloseUsesPublicDaemonShutdown(t *testing.T) { | |
| 129 | + | wait := make(chan error, 1) | |
| 130 | + | wait <- nil | |
| 131 | + | var shutdown int | |
| 132 | + | p := &cliProcess{waitErr: wait, shutdown: func() error { shutdown++; return nil }} | |
| 133 | + | if err := p.Close(); err != nil { | |
| 134 | + | t.Fatal(err) | |
| 135 | + | } | |
| 136 | + | if shutdown != 0 { | |
| 137 | + | t.Fatalf("shutdown calls = %d, want 0 for an exited daemon", shutdown) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | wait = make(chan error, 1) | |
| 141 | + | p = &cliProcess{waitErr: wait, shutdown: func() error { shutdown++; wait <- nil; return nil }} | |
| 142 | + | if err := p.Close(); err != nil { | |
| 143 | + | t.Fatal(err) | |
| 144 | + | } | |
| 145 | + | if shutdown != 1 { | |
| 146 | + | t.Fatalf("shutdown calls = %d, want 1", shutdown) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestHarnessCanonicalLocalSmokeRealRoles(t *testing.T) { | |
| 151 | + | if testing.Short() { | |
| 152 | + | t.Skip("public CLI smoke") | |
| 153 | + | } | |
| 154 | + | root, err := filepath.Abs(filepath.Join("..", "..")) | |
| 155 | + | if err != nil { | |
| 156 | + | t.Fatal(err) | |
| 157 | + | } | |
| 158 | + | bin := filepath.Join(t.TempDir(), "vev") | |
| 159 | + | build := exec.Command("go", "build", "-o", bin, "./") | |
| 160 | + | build.Dir = root | |
| 161 | + | if output, err := build.CombinedOutput(); err != nil { | |
| 162 | + | t.Fatalf("build public CLI: %v\n%s", err, output) | |
| 163 | + | } | |
| 164 | + | m, err := readManifest(filepath.Join(root, "testdata", "perf", "manifest.json")) | |
| 165 | + | if err != nil { | |
| 166 | + | t.Fatal(err) | |
| 167 | + | } | |
| 168 | + | if err := validateManifest(m); err != nil { | |
| 169 | + | t.Fatal(err) | |
| 170 | + | } | |
| 171 | + | var local scenario | |
| 172 | + | for _, s := range m.Scenarios { | |
| 173 | + | if s.Topology == "1x4" && s.Workload == "idle" && s.Transport == "local" { | |
| 174 | + | local = s | |
| 175 | + | break | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | if local.ID == "" { | |
| 179 | + | t.Fatal("canonical local scenario missing") | |
| 180 | + | } | |
| 181 | + | dir := t.TempDir() | |
| 182 | + | raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |
| 183 | + | if err != nil { | |
| 184 | + | t.Fatal(err) | |
| 185 | + | } | |
| 186 | + | closeTestFile(t, raw) | |
| 187 | + | h := defaultHarness() | |
| 188 | + | h.clock = &fakeClock{} // injected duration keeps the public 30s contract bounded in test | |
| 189 | + | h.launcher = &cliLauncher{bin: bin} | |
| 190 | + | result, err := h.runOne(options{vevBin: bin, out: dir, duration: minimumDuration}, m, local, 1, raw) | |
| 191 | + | if err != nil { | |
| 192 | + | t.Fatal(err) | |
| 193 | + | } | |
| 194 | + | if result.Samples < minimumInIntervalEventSamples || len(result.Processes) != 2 { | |
| 195 | + | t.Fatalf("canonical local smoke result=%+v", result) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestTraceEnvironmentMatchesProcessMapping(t *testing.T) { | |
| 200 | + | mapping := processMapping{TracePath: "/tmp/trace.jsonl", ProcessID: "p-01", Scenario: "1x4-idle-local", Run: 3} | |
| 201 | + | got := strings.Join(traceEnvironment(mapping), "\n") | |
| 202 | + | for _, want := range []string{"VEV_PERF_TRACE=/tmp/trace.jsonl", "VEV_PERF_PROCESS_ID=p-01", "VEV_PERF_SCENARIO=1x4-idle-local", "VEV_PERF_RUN=3"} { | |
| 203 | + | if !strings.Contains(got, want) { | |
| 204 | + | t.Errorf("trace environment missing %q: %s", want, got) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func TestCLIProcessCloseGracefulDetachCompletesSpanBeforeForcedCleanup(t *testing.T) { | |
| 210 | + | var order []string | |
| 211 | + | waitErr := make(chan error, 1) | |
| 212 | + | timeout := make(chan time.Time) | |
| 213 | + | p := &cliProcess{ | |
| 214 | + | pty: &orderedPTY{ | |
| 215 | + | write: func(input []byte) { | |
| 216 | + | if string(input) != "exit\n" { | |
| 217 | + | t.Fatalf("graceful exit input=%q", input) | |
| 218 | + | } | |
| 219 | + | order = append(order, "exit_written") | |
| 220 | + | // The graceful client exit has closed its transport and emitted | |
| 221 | + | // its adapter receive-end mark before cmd.Wait completes. | |
| 222 | + | order = append(order, "adapter_receive_end") | |
| 223 | + | waitErr <- nil | |
| 224 | + | }, | |
| 225 | + | close: func() { order = append(order, "pty_closed") }, | |
| 226 | + | }, | |
| 227 | + | output: &orderedOutput{close: func() { order = append(order, "output_closed") }}, | |
| 228 | + | waitErr: waitErr, | |
| 229 | + | waitTimeout: func() <-chan time.Time { return timeout }, | |
| 230 | + | forceCleanup: func() { | |
| 231 | + | order = append(order, "forced_process_group_cleanup") | |
| 232 | + | }, | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | if err := p.Close(); err != nil { | |
| 236 | + | t.Fatal(err) | |
| 237 | + | } | |
| 238 | + | if !equalStrings(order, []string{"exit_written", "adapter_receive_end", "pty_closed", "output_closed"}) { | |
| 239 | + | t.Fatalf("cleanup order=%q", order) | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func TestCLIProcessWarmupWaitsForDelayedTerminalReadiness(t *testing.T) { | |
| 244 | + | allowWrite := make(chan struct{}) | |
| 245 | + | pty, output := &stagedPTY{allow: allowWrite, writes: make(chan []byte, 1)}, &fakeOutput{} | |
| 246 | + | p := &cliProcess{pty: pty, output: output, chunks: make(chan []byte)} | |
| 247 | + | input := workloadInput(scenario{ID: "s"}, 1, "warmup") | |
| 248 | + | done := make(chan error, 1) | |
| 249 | + | go func() { done <- p.Warmup(input) }() | |
| 250 | + | ||
| 251 | + | // This is public client output from the initial application prompt/state, | |
| 252 | + | // emitted only after the client has entered raw mode. A premature PTY write | |
| 253 | + | // blocks on allowWrite and therefore cannot consume this delayed readiness. | |
| 254 | + | go func() { | |
| 255 | + | p.chunks <- []byte("shell prompt$ ") | |
| 256 | + | close(allowWrite) | |
| 257 | + | }() | |
| 258 | + | select { | |
| 259 | + | case got := <-pty.writes: | |
| 260 | + | if !bytes.Equal(got, input) { | |
| 261 | + | t.Fatalf("warmup write=%q want %q", got, input) | |
| 262 | + | } | |
| 263 | + | case <-time.After(time.Second): | |
| 264 | + | t.Fatal("warmup did not wait for and then proceed from client readiness") | |
| 265 | + | } | |
| 266 | + | go func() { p.chunks <- append([]byte("application "), inputMarker(input)...); close(p.chunks) }() | |
| 267 | + | if err := <-done; err != nil { | |
| 268 | + | t.Fatal(err) | |
| 269 | + | } | |
| 270 | + | if output.syncs != 1 { | |
| 271 | + | t.Fatalf("readiness output was not flushed: syncs=%d", output.syncs) | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func TestCLIProcessRejectsPTYLocalEchoAsFlushEvidence(t *testing.T) { | |
| 276 | + | pty, output := &fakePTY{}, &fakeOutput{} | |
| 277 | + | p := &cliProcess{pty: pty, output: output, chunks: make(chan []byte, 1)} | |
| 278 | + | input := workloadInput(scenario{ID: "s"}, 1, "measured-1") | |
| 279 | + | p.chunks <- newPTYLocalEcho(input).expected | |
| 280 | + | close(p.chunks) | |
| 281 | + | flushed := false | |
| 282 | + | if err := p.Measure(input, func() error { return nil }, func() error { flushed = true; return nil }); err == nil { | |
| 283 | + | t.Fatal("local PTY echo satisfied terminal flush boundary") | |
| 284 | + | } | |
| 285 | + | if flushed || output.syncs != 0 { | |
| 286 | + | t.Fatalf("local echo stamped a flush: flushed=%t syncs=%d", flushed, output.syncs) | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func TestCLIProcessPairsApplicationOutputWithSuccessfulFlush(t *testing.T) { | |
| 291 | + | pty, output := &fakePTY{}, &fakeOutput{} | |
| 292 | + | p := &cliProcess{pty: pty, output: output, chunks: make(chan []byte, 1)} | |
| 293 | + | input := workloadInput(scenario{ID: "s"}, 1, "measured-1") | |
| 294 | + | echo := newPTYLocalEcho(input).expected | |
| 295 | + | p.chunks <- append(append([]byte(nil), echo...), append([]byte("application "), inputMarker(input)...)...) | |
| 296 | + | order := []string{} | |
| 297 | + | if err := p.Measure(input, func() error { order = append(order, "injected"); return nil }, func() error { order = append(order, "flushed"); return nil }); err != nil { | |
| 298 | + | t.Fatal(err) | |
| 299 | + | } | |
| 300 | + | if len(pty.writes) != 1 || output.syncs != 1 || !equalStrings(order, []string{"injected", "flushed"}) { | |
| 301 | + | t.Fatalf("writes=%q syncs=%d boundary order=%q", pty.writes, output.syncs, order) | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) { | |
| 306 | + | for _, tc := range []struct { | |
| 307 | + | role string | |
| 308 | + | want []string | |
| 309 | + | }{ | |
| 310 | + | {"daemon", []string{"--daemon"}}, | |
| 311 | + | {"client", []string{"new", "perf-s-001"}}, | |
| 312 | + | {"ssh_stdio_peer", []string{"_stdio", "perf-s-001"}}, | |
| 313 | + | {"udp_peer", []string{"_udp-proxy", "perf-s-001"}}, | |
| 314 | + | } { | |
| 315 | + | t.Run(tc.role, func(t *testing.T) { | |
| 316 | + | got := roleArgs(scenario{ID: "s", Transport: "local"}, processMapping{Role: tc.role, Run: 1}) | |
| 317 | + | if !equalStrings(got.Args, tc.want) { | |
| 318 | + | t.Fatalf("args=%q want %q", got.Args, tc.want) | |
| 319 | + | } | |
| 320 | + | }) | |
| 321 | + | } | |
| 322 | + | input := string(workloadInput(scenario{ID: "s", Workload: "interactive_flood"}, 1, "measured-1")) | |
| 323 | + | if string(inputMarker([]byte(input))) != "__VEV_HARNESS_s_r1_measured-1__" || !bytes.Contains([]byte(input), []byte("while [ $i -lt 128 ]")) || !strings.HasSuffix(input, "printf '__VEV_HARNESS_s_r1_measured-1__\\n'\n") { | |
| 324 | + | t.Fatalf("workload is not real PTY shell input with an observable marker: %q", input) | |
| 325 | + | } | |
| 326 | + | } | |
A cmd/vev-perf-harness/main.go +20 -0
| @@ -0,0 +1,20 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/pkg/safedir" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func main() { | |
| 12 | + | if err := run(os.Args[1:], defaultHarness()); err != nil { | |
| 13 | + | fmt.Fprintln(os.Stderr, "vev-perf-harness:", err) | |
| 14 | + | os.Exit(2) | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func defaultHarness() *harness { | |
| 19 | + | return &harness{clock: systemClock{time.Now()}, mkdir: safedir.EnsurePrivate, createRunDir: createExclusiveRunDir, create: func(p string) (*os.File, error) { return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) }, removeAll: os.RemoveAll, gitSHA: recordedGitSHA} | |
| 20 | + | } | |
A cmd/vev-perf-harness/model.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | const ( | |
| 6 | + | minimumDuration = 30 * time.Second | |
| 7 | + | minimumRepetitions = 10 | |
| 8 | + | minimumInIntervalEventSamples = 10 | |
| 9 | + | measuredEventCadence = time.Second | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type options struct { | |
| 13 | + | vevBin, manifest, out string | |
| 14 | + | scenario string | |
| 15 | + | warmup, duration time.Duration | |
| 16 | + | repetitions int | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | type manifest struct { | |
| 20 | + | Schema uint16 `json:"schema"` | |
| 21 | + | Topologies []topology `json:"topologies"` | |
| 22 | + | Workloads []string `json:"workloads"` | |
| 23 | + | Transports []transport `json:"transports"` | |
| 24 | + | Scenarios []scenario `json:"scenarios"` | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type topology struct { | |
| 28 | + | ID string `json:"id"` | |
| 29 | + | Geometry string `json:"geometry"` | |
| 30 | + | RowsPerPane int `json:"rows_per_pane"` | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type transport struct { | |
| 34 | + | ID string `json:"id"` | |
| 35 | + | Kind string `json:"kind"` | |
| 36 | + | RTTMS int `json:"rtt_ms,omitempty"` | |
| 37 | + | LossPercent int `json:"loss_percent,omitempty"` | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type scenario struct { | |
| 41 | + | ID string `json:"id"` | |
| 42 | + | Topology string `json:"topology"` | |
| 43 | + | Workload string `json:"workload"` | |
| 44 | + | Transport string `json:"transport"` | |
| 45 | + | Roles []string `json:"roles"` | |
| 46 | + | InapplicableReason string `json:"inapplicable_reason,omitempty"` | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | type processMapping struct { | |
| 50 | + | ProcessID string `json:"process_id"` | |
| 51 | + | ClockDomain string `json:"clock_domain"` | |
| 52 | + | TracePath string `json:"trace_path"` | |
| 53 | + | Role string `json:"role"` | |
| 54 | + | Scenario string `json:"scenario"` | |
| 55 | + | Run int `json:"run"` | |
| 56 | + | Identity string `json:"identity"` | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | type runManifest struct { | |
| 60 | + | Scenario string `json:"scenario"` | |
| 61 | + | Run int `json:"run"` | |
| 62 | + | Processes []processMapping `json:"processes"` | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // measuredInterval belongs solely to the harness clock domain. A paired event | |
| 66 | + | // is eligible only when both owned boundaries fall inside this interval. | |
| 67 | + | type measuredInterval struct { | |
| 68 | + | Start int64 | |
| 69 | + | End int64 | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | type eventSample struct { | |
| 73 | + | Sequence uint64 `json:"sequence"` | |
| 74 | + | Injected int64 `json:"injected_tick"` | |
| 75 | + | Flushed int64 `json:"flushed_tick"` | |
| 76 | + | Latency int64 `json:"latency_nanos"` | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | type span struct { | |
| 80 | + | Component, Name string | |
| 81 | + | Samples []int64 | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | type runResult struct { | |
| 85 | + | Spans []span `json:"process_local_spans"` | |
| 86 | + | Scenario string `json:"scenario"` | |
| 87 | + | Run int `json:"run"` | |
| 88 | + | Samples int `json:"samples"` | |
| 89 | + | EndToEnd []int64 `json:"end_to_end_nanos"` | |
| 90 | + | Event distribution `json:"event_end_to_end"` | |
| 91 | + | Cadence distribution `json:"event_cadence_nanos"` | |
| 92 | + | CadenceSamples []int64 `json:"-"` | |
| 93 | + | MaxGap int64 `json:"event_max_gap_nanos"` | |
| 94 | + | Processes []processMapping `json:"processes"` | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | type summary struct { | |
| 98 | + | Schema uint16 `json:"schema"` | |
| 99 | + | GitSHA string `json:"git_sha"` | |
| 100 | + | Warmup string `json:"warmup"` | |
| 101 | + | Duration string `json:"duration"` | |
| 102 | + | Repetitions int `json:"repetitions"` | |
| 103 | + | EndToEnd distribution `json:"harness_end_to_end"` | |
| 104 | + | Cadence distribution `json:"harness_event_cadence_nanos"` | |
| 105 | + | MaxGap int64 `json:"harness_event_max_gap_nanos"` | |
| 106 | + | RunP50Dispersion distribution `json:"run_p50_dispersion"` | |
| 107 | + | Spans []spanSummary `json:"process_local_spans"` | |
| 108 | + | Runs int `json:"runs"` | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | type distribution struct { | |
| 112 | + | Count int `json:"count"` | |
| 113 | + | P50 int64 `json:"p50"` | |
| 114 | + | P95 int64 `json:"p95"` | |
| 115 | + | P99 int64 `json:"p99"` | |
| 116 | + | Max int64 `json:"max"` | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | type spanSummary struct { | |
| 120 | + | Component string `json:"component"` | |
| 121 | + | Name string `json:"name"` | |
| 122 | + | Distribution distribution `json:"distribution"` | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | type clock interface { | |
| 126 | + | Now() int64 | |
| 127 | + | Sleep(time.Duration) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | type systemClock struct{ start time.Time } | |
| 131 | + | ||
| 132 | + | var spanPairs = []spanPair{{"capture_start", "capture_end", "capture_duration"}, {"compose_start", "compose_end", "compose_duration"}, {"diff_start", "diff_end", "diff_duration"}, {"queue_enqueued", "queue_dequeued", "queue_wait"}, {"ack_blocked_start", "ack_blocked_end", "ack_blocked_interval"}, {"emit_start", "emit_end", "emit_duration"}, {"adapter_send_start", "adapter_send_end", "adapter_send_duration"}, {"adapter_receive_start", "adapter_receive_end", "adapter_receive_duration"}} | |
A cmd/vev-perf-harness/options_manifest.go +254 -0
| @@ -0,0 +1,254 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "errors" | |
| 6 | + | "flag" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | var ( | |
| 14 | + | canonicalTopologyIDs = []string{"1x4", "4x1", "4x4", "8x1"} | |
| 15 | + | canonicalTopologySet = stringSet(canonicalTopologyIDs) | |
| 16 | + | canonicalWorkloadIDs = []string{ | |
| 17 | + | "idle", "active_output", "all_output", "inactive_output", "interactive_flood", | |
| 18 | + | "copy_search", "resize_sweep", "snapshot_output_resize", "attach_restore_tab_switch", | |
| 19 | + | } | |
| 20 | + | canonicalWorkloadSet = stringSet(canonicalWorkloadIDs) | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | func stringSet(ids []string) map[string]bool { | |
| 24 | + | set := make(map[string]bool, len(ids)) | |
| 25 | + | for _, id := range ids { | |
| 26 | + | set[id] = true | |
| 27 | + | } | |
| 28 | + | return set | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func parseOptions(args []string) (options, error) { | |
| 32 | + | var o options | |
| 33 | + | fs := flag.NewFlagSet("vev-perf-harness", flag.ContinueOnError) | |
| 34 | + | fs.SetOutput(io.Discard) | |
| 35 | + | fs.StringVar(&o.vevBin, "vev-bin", "", "public vev binary") | |
| 36 | + | fs.StringVar(&o.manifest, "manifest", "", "manifest") | |
| 37 | + | fs.StringVar(&o.out, "out", "", "output directory") | |
| 38 | + | fs.StringVar(&o.scenario, "scenario", "", "one canonical scenario ID (after full manifest validation)") | |
| 39 | + | fs.DurationVar(&o.warmup, "warmup", 0, "warmup") | |
| 40 | + | fs.DurationVar(&o.duration, "duration", 0, "measurement duration") | |
| 41 | + | fs.IntVar(&o.repetitions, "repetitions", 0, "repetitions") | |
| 42 | + | if err := fs.Parse(args); err != nil { | |
| 43 | + | return o, err | |
| 44 | + | } | |
| 45 | + | seen := map[string]bool{} | |
| 46 | + | fs.Visit(func(f *flag.Flag) { seen[f.Name] = true }) | |
| 47 | + | for _, name := range []string{"vev-bin", "manifest", "out", "warmup", "duration", "repetitions"} { | |
| 48 | + | if !seen[name] { | |
| 49 | + | return o, fmt.Errorf("--%s is required", name) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | if o.vevBin == "" || o.manifest == "" || o.out == "" { | |
| 53 | + | return o, errors.New("--vev-bin, --manifest, and --out are required") | |
| 54 | + | } | |
| 55 | + | if o.warmup < 0 { | |
| 56 | + | return o, errors.New("--warmup must not be negative") | |
| 57 | + | } | |
| 58 | + | if o.duration < minimumDuration { | |
| 59 | + | return o, fmt.Errorf("--duration must be at least %s", minimumDuration) | |
| 60 | + | } | |
| 61 | + | if o.repetitions < minimumRepetitions { | |
| 62 | + | return o, fmt.Errorf("--repetitions must be at least %d", minimumRepetitions) | |
| 63 | + | } | |
| 64 | + | return o, nil | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // resolvePathOptions captures the harness cwd once, before role launchers | |
| 68 | + | // assign their isolated working directories. All downstream filesystem access, | |
| 69 | + | // manifests, and process environments consequently refer to the same paths. | |
| 70 | + | func resolvePathOptions(o options) (options, error) { | |
| 71 | + | cwd, err := os.Getwd() | |
| 72 | + | if err != nil { | |
| 73 | + | return o, fmt.Errorf("resolve harness working directory: %w", err) | |
| 74 | + | } | |
| 75 | + | absolute := func(path string) string { | |
| 76 | + | if filepath.IsAbs(path) { | |
| 77 | + | return filepath.Clean(path) | |
| 78 | + | } | |
| 79 | + | return filepath.Clean(filepath.Join(cwd, path)) | |
| 80 | + | } | |
| 81 | + | o.vevBin = absolute(o.vevBin) | |
| 82 | + | o.manifest = absolute(o.manifest) | |
| 83 | + | o.out = absolute(o.out) | |
| 84 | + | return o, nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func readManifest(path string) (manifest, error) { | |
| 88 | + | f, err := os.Open(path) | |
| 89 | + | if err != nil { | |
| 90 | + | return manifest{}, err | |
| 91 | + | } | |
| 92 | + | var m manifest | |
| 93 | + | if err := json.NewDecoder(f).Decode(&m); err != nil { | |
| 94 | + | return manifest{}, closeFile(f, err) | |
| 95 | + | } | |
| 96 | + | if err := f.Close(); err != nil { | |
| 97 | + | return manifest{}, err | |
| 98 | + | } | |
| 99 | + | return m, nil | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func validateManifest(m manifest) error { | |
| 103 | + | if m.Schema != 1 { | |
| 104 | + | return errors.New("unsupported manifest schema") | |
| 105 | + | } | |
| 106 | + | tops := map[string]bool{} | |
| 107 | + | for _, t := range m.Topologies { | |
| 108 | + | if t.ID == "" { | |
| 109 | + | return errors.New("empty topology id") | |
| 110 | + | } | |
| 111 | + | if tops[t.ID] { | |
| 112 | + | return fmt.Errorf("duplicate topology id %q", t.ID) | |
| 113 | + | } | |
| 114 | + | if !canonicalTopologySet[t.ID] { | |
| 115 | + | return fmt.Errorf("unknown topology id %q", t.ID) | |
| 116 | + | } | |
| 117 | + | if t.Geometry != "120x40" || t.RowsPerPane != 10000 { | |
| 118 | + | return fmt.Errorf("invalid topology %q", t.ID) | |
| 119 | + | } | |
| 120 | + | tops[t.ID] = true | |
| 121 | + | } | |
| 122 | + | for _, v := range canonicalTopologyIDs { | |
| 123 | + | if !tops[v] { | |
| 124 | + | return fmt.Errorf("missing canonical topology %s", v) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | works := map[string]bool{} | |
| 128 | + | for _, workload := range m.Workloads { | |
| 129 | + | if workload == "" { | |
| 130 | + | return errors.New("empty workload id") | |
| 131 | + | } | |
| 132 | + | if works[workload] { | |
| 133 | + | return fmt.Errorf("duplicate workload id %q", workload) | |
| 134 | + | } | |
| 135 | + | if !canonicalWorkloadSet[workload] { | |
| 136 | + | return fmt.Errorf("unknown workload id %q", workload) | |
| 137 | + | } | |
| 138 | + | works[workload] = true | |
| 139 | + | } | |
| 140 | + | for _, v := range canonicalWorkloadIDs { | |
| 141 | + | if !works[v] { | |
| 142 | + | return fmt.Errorf("missing canonical workload %s", v) | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | trans := map[string]bool{} | |
| 146 | + | for _, t := range m.Transports { | |
| 147 | + | if t.ID == "" { | |
| 148 | + | return errors.New("empty transport id") | |
| 149 | + | } | |
| 150 | + | if trans[t.ID] { | |
| 151 | + | return fmt.Errorf("duplicate transport id %q", t.ID) | |
| 152 | + | } | |
| 153 | + | if err := validateTransportFixture(t); err != nil { | |
| 154 | + | return err | |
| 155 | + | } | |
| 156 | + | trans[t.ID] = true | |
| 157 | + | } | |
| 158 | + | for _, v := range []string{"local", "ssh_stdio", "udp_baseline", "udp_25ms", "udp_100ms", "udp_loss_0pct", "udp_loss_1pct"} { | |
| 159 | + | if !trans[v] { | |
| 160 | + | return fmt.Errorf("missing canonical transport %s", v) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | seen := map[string]bool{} | |
| 164 | + | covered := map[string]bool{} | |
| 165 | + | for _, s := range m.Scenarios { | |
| 166 | + | if s.ID == "" || seen[s.ID] { | |
| 167 | + | return fmt.Errorf("duplicate or empty scenario id %q", s.ID) | |
| 168 | + | } | |
| 169 | + | seen[s.ID] = true | |
| 170 | + | if !tops[s.Topology] || !works[s.Workload] || !trans[s.Transport] { | |
| 171 | + | return fmt.Errorf("scenario %s references unknown matrix entry", s.ID) | |
| 172 | + | } | |
| 173 | + | if s.InapplicableReason == "" && len(s.Roles) == 0 { | |
| 174 | + | return fmt.Errorf("scenario %s has no process roles", s.ID) | |
| 175 | + | } | |
| 176 | + | if s.InapplicableReason != "" && len(s.Roles) != 0 { | |
| 177 | + | return fmt.Errorf("inapplicable scenario %s must not launch roles", s.ID) | |
| 178 | + | } | |
| 179 | + | if s.InapplicableReason == "" && !equalRoleSet(s.Roles, requiredRoles(s.Transport)) { | |
| 180 | + | return fmt.Errorf("scenario %s roles do not route transport %s", s.ID, s.Transport) | |
| 181 | + | } | |
| 182 | + | covered[s.Topology+"\x00"+s.Workload+"\x00"+s.Transport] = true | |
| 183 | + | } | |
| 184 | + | for topologyID := range tops { | |
| 185 | + | for workload := range works { | |
| 186 | + | for transportID := range trans { | |
| 187 | + | key := topologyID + "\x00" + workload + "\x00" + transportID | |
| 188 | + | if !covered[key] { | |
| 189 | + | return fmt.Errorf("missing scenario or inapplicable reason for %s/%s/%s", topologyID, workload, transportID) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | return nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // selectScenario is intentionally called only after validateManifest. A bounded | |
| 198 | + | // local evidence run therefore cannot mask an incomplete canonical matrix. | |
| 199 | + | func selectScenario(m manifest, id string) ([]scenario, error) { | |
| 200 | + | for _, s := range m.Scenarios { | |
| 201 | + | if s.ID != id { | |
| 202 | + | continue | |
| 203 | + | } | |
| 204 | + | if s.InapplicableReason != "" { | |
| 205 | + | return nil, fmt.Errorf("scenario %s is inapplicable: %s", id, s.InapplicableReason) | |
| 206 | + | } | |
| 207 | + | return []scenario{s}, nil | |
| 208 | + | } | |
| 209 | + | return nil, fmt.Errorf("unknown canonical scenario %s", id) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func requiredRoles(transportID string) []string { | |
| 213 | + | switch transportID { | |
| 214 | + | case "local": | |
| 215 | + | return []string{"daemon", "client"} | |
| 216 | + | case "ssh_stdio": | |
| 217 | + | return []string{"daemon", "client", "ssh_stdio_peer"} | |
| 218 | + | default: | |
| 219 | + | return []string{"daemon", "client", "udp_peer"} | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func equalRoleSet(got, want []string) bool { | |
| 224 | + | if len(got) != len(want) { | |
| 225 | + | return false | |
| 226 | + | } | |
| 227 | + | seen := make(map[string]bool, len(got)) | |
| 228 | + | for _, role := range got { | |
| 229 | + | seen[role] = true | |
| 230 | + | } | |
| 231 | + | for _, role := range want { | |
| 232 | + | if !seen[role] { | |
| 233 | + | return false | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | return true | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | func validateTransportFixture(t transport) error { | |
| 240 | + | want := map[string]transport{ | |
| 241 | + | "local": {ID: "local", Kind: "local"}, | |
| 242 | + | "ssh_stdio": {ID: "ssh_stdio", Kind: "ssh_stdio"}, | |
| 243 | + | "udp_baseline": {ID: "udp_baseline", Kind: "udp"}, | |
| 244 | + | "udp_25ms": {ID: "udp_25ms", Kind: "udp", RTTMS: 25}, | |
| 245 | + | "udp_100ms": {ID: "udp_100ms", Kind: "udp", RTTMS: 100}, | |
| 246 | + | "udp_loss_0pct": {ID: "udp_loss_0pct", Kind: "udp", LossPercent: 0}, | |
| 247 | + | "udp_loss_1pct": {ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1}, | |
| 248 | + | } | |
| 249 | + | w, ok := want[t.ID] | |
| 250 | + | if !ok || t.Kind != w.Kind || t.RTTMS != w.RTTMS || t.LossPercent != w.LossPercent { | |
| 251 | + | return fmt.Errorf("invalid canonical transport fixture %+v", t) | |
| 252 | + | } | |
| 253 | + | return nil | |
| 254 | + | } | |
A cmd/vev-perf-harness/options_manifest_test.go +279 -0
| @@ -0,0 +1,279 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestHarnessScenarioSelectionKeepsCanonicalManifestValidation(t *testing.T) { | |
| 14 | + | root, err := filepath.Abs(filepath.Join("..", "..")) | |
| 15 | + | if err != nil { | |
| 16 | + | t.Fatal(err) | |
| 17 | + | } | |
| 18 | + | manifestPath := filepath.Join(root, "testdata", "perf", "manifest.json") | |
| 19 | + | m, err := readManifest(manifestPath) | |
| 20 | + | if err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | selected := m.Scenarios[0] | |
| 24 | + | h := defaultHarness() | |
| 25 | + | h.clock, h.launcher = &fakeClock{}, &fakeLauncher{} | |
| 26 | + | out := filepath.Join(t.TempDir(), "out") | |
| 27 | + | if err := run([]string{"--vev-bin", "ignored", "--manifest", manifestPath, "--out", out, "--scenario", selected.ID, "--warmup", "0s", "--duration", "30s", "--repetitions", "10"}, h); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | var runs []runResult | |
| 31 | + | b, err := os.ReadFile(filepath.Join(out, "runs.json")) | |
| 32 | + | if err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | if err := json.Unmarshal(b, &runs); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | if len(runs) != 10 { | |
| 39 | + | t.Fatalf("selected canonical runs=%d, want 10", len(runs)) | |
| 40 | + | } | |
| 41 | + | for _, result := range runs { | |
| 42 | + | if result.Scenario != selected.ID { | |
| 43 | + | t.Fatalf("selected scenario = %q, want %q", result.Scenario, selected.ID) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestHarnessScenarioSelectionRejectsIncompleteCanonicalManifest(t *testing.T) { | |
| 49 | + | m, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json")) | |
| 50 | + | if err != nil { | |
| 51 | + | t.Fatal(err) | |
| 52 | + | } | |
| 53 | + | selected := m.Scenarios[0] | |
| 54 | + | m.Scenarios = m.Scenarios[:len(m.Scenarios)-1] | |
| 55 | + | dir := t.TempDir() | |
| 56 | + | manifestPath := filepath.Join(dir, "manifest.json") | |
| 57 | + | if err := os.WriteFile(manifestPath, []byte(mustJSON(m)), 0o600); err != nil { | |
| 58 | + | t.Fatal(err) | |
| 59 | + | } | |
| 60 | + | launcher := &fakeLauncher{} | |
| 61 | + | h := defaultHarness() | |
| 62 | + | h.clock, h.launcher = &fakeClock{}, launcher | |
| 63 | + | out := filepath.Join(dir, "out") | |
| 64 | + | err = run([]string{"--vev-bin", "ignored", "--manifest", manifestPath, "--out", out, "--scenario", selected.ID, "--warmup", "0s", "--duration", "30s", "--repetitions", "10"}, h) | |
| 65 | + | if err == nil || !strings.Contains(err.Error(), "missing scenario or inapplicable reason") { | |
| 66 | + | t.Fatalf("incomplete manifest selection error = %v", err) | |
| 67 | + | } | |
| 68 | + | if len(launcher.mappings) != 0 { | |
| 69 | + | t.Fatalf("launched with incomplete canonical manifest: %+v", launcher.mappings) | |
| 70 | + | } | |
| 71 | + | if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) { | |
| 72 | + | t.Fatalf("output created before manifest validation: %v", statErr) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestResolvePathOptionsMakesRelativeAndAbsoluteInputsIdentical(t *testing.T) { | |
| 77 | + | cwd, err := os.Getwd() | |
| 78 | + | if err != nil { | |
| 79 | + | t.Fatal(err) | |
| 80 | + | } | |
| 81 | + | absolute := options{ | |
| 82 | + | vevBin: filepath.Join(cwd, "vev"), | |
| 83 | + | manifest: filepath.Join(cwd, "testdata", "perf", "manifest.json"), | |
| 84 | + | out: filepath.Join(cwd, "testdata", "perf", "results"), | |
| 85 | + | } | |
| 86 | + | relative := options{ | |
| 87 | + | vevBin: "vev", | |
| 88 | + | manifest: "testdata/perf/manifest.json", | |
| 89 | + | out: "testdata/perf/results", | |
| 90 | + | } | |
| 91 | + | gotRelative, err := resolvePathOptions(relative) | |
| 92 | + | if err != nil { | |
| 93 | + | t.Fatal(err) | |
| 94 | + | } | |
| 95 | + | gotAbsolute, err := resolvePathOptions(absolute) | |
| 96 | + | if err != nil { | |
| 97 | + | t.Fatal(err) | |
| 98 | + | } | |
| 99 | + | if gotRelative != gotAbsolute { | |
| 100 | + | t.Fatalf("relative paths resolved to %+v, absolute paths to %+v", gotRelative, gotAbsolute) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestHarnessFlagsRejectShortMeasurements(t *testing.T) { | |
| 105 | + | for _, args := range [][]string{ | |
| 106 | + | {"--vev-bin", "vev", "--manifest", "m", "--out", "o", "--duration", "29s", "--repetitions", "10"}, | |
| 107 | + | {"--vev-bin", "vev", "--manifest", "m", "--out", "o", "--duration", "30s", "--repetitions", "9"}, | |
| 108 | + | {"--manifest", "m", "--out", "o", "--duration", "30s", "--repetitions", "10"}, | |
| 109 | + | } { | |
| 110 | + | if _, err := parseOptions(args); err == nil { | |
| 111 | + | t.Fatalf("parseOptions(%v) succeeded", args) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | if _, err := parseOptions([]string{"--vev-bin", "vev", "--manifest", "m", "--out", "o", "--duration", "30s", "--repetitions", "10"}); err == nil { | |
| 115 | + | t.Fatal("missing --warmup accepted") | |
| 116 | + | } | |
| 117 | + | if _, err := parseOptions([]string{"--vev-bin", "vev", "--manifest", "m", "--out", "o", "--warmup", "1s", "--duration", "30s", "--repetitions", "10"}); err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestValidateManifestRejectsEmptyAndDuplicateMatrixIDs(t *testing.T) { | |
| 123 | + | canonical, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json")) | |
| 124 | + | if err != nil { | |
| 125 | + | t.Fatal(err) | |
| 126 | + | } | |
| 127 | + | for _, tc := range []struct { | |
| 128 | + | name string | |
| 129 | + | mutate func(*manifest) | |
| 130 | + | want string | |
| 131 | + | }{ | |
| 132 | + | {"empty topology", func(m *manifest) { m.Topologies[0].ID = "" }, "empty topology id"}, | |
| 133 | + | {"duplicate topology", func(m *manifest) { m.Topologies[1].ID = m.Topologies[0].ID }, "duplicate topology id"}, | |
| 134 | + | {"empty workload", func(m *manifest) { m.Workloads[0] = "" }, "empty workload id"}, | |
| 135 | + | {"duplicate workload", func(m *manifest) { m.Workloads[1] = m.Workloads[0] }, "duplicate workload id"}, | |
| 136 | + | {"empty transport", func(m *manifest) { m.Transports[0].ID = "" }, "empty transport id"}, | |
| 137 | + | {"duplicate transport", func(m *manifest) { m.Transports[1].ID = m.Transports[0].ID }, "duplicate transport id"}, | |
| 138 | + | } { | |
| 139 | + | t.Run(tc.name, func(t *testing.T) { | |
| 140 | + | m := canonical | |
| 141 | + | m.Topologies = append([]topology(nil), canonical.Topologies...) | |
| 142 | + | m.Workloads = append([]string(nil), canonical.Workloads...) | |
| 143 | + | m.Transports = append([]transport(nil), canonical.Transports...) | |
| 144 | + | tc.mutate(&m) | |
| 145 | + | if err := validateManifest(m); err == nil || !strings.Contains(err.Error(), tc.want) { | |
| 146 | + | t.Fatalf("validateManifest() error = %v, want %q", err, tc.want) | |
| 147 | + | } | |
| 148 | + | }) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestValidateManifestRejectsFullyCoveredUnknownMatrixIDs(t *testing.T) { | |
| 153 | + | canonical, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json")) | |
| 154 | + | if err != nil { | |
| 155 | + | t.Fatal(err) | |
| 156 | + | } | |
| 157 | + | for _, tc := range []struct { | |
| 158 | + | name string | |
| 159 | + | mutate func(*manifest) | |
| 160 | + | want string | |
| 161 | + | }{ | |
| 162 | + | { | |
| 163 | + | name: "topology", | |
| 164 | + | mutate: func(m *manifest) { | |
| 165 | + | m.Topologies = append(m.Topologies, topology{ID: "9x9", Geometry: "120x40", RowsPerPane: 10000}) | |
| 166 | + | for _, s := range canonical.Scenarios { | |
| 167 | + | s.ID = "9x9-" + s.ID | |
| 168 | + | s.Topology = "9x9" | |
| 169 | + | m.Scenarios = append(m.Scenarios, s) | |
| 170 | + | } | |
| 171 | + | }, | |
| 172 | + | want: "unknown topology id \"9x9\"", | |
| 173 | + | }, | |
| 174 | + | { | |
| 175 | + | name: "workload", | |
| 176 | + | mutate: func(m *manifest) { | |
| 177 | + | m.Workloads = append(m.Workloads, "unknown_workload") | |
| 178 | + | for _, s := range canonical.Scenarios { | |
| 179 | + | s.ID = "unknown-workload-" + s.ID | |
| 180 | + | s.Workload = "unknown_workload" | |
| 181 | + | m.Scenarios = append(m.Scenarios, s) | |
| 182 | + | } | |
| 183 | + | }, | |
| 184 | + | want: "unknown workload id \"unknown_workload\"", | |
| 185 | + | }, | |
| 186 | + | } { | |
| 187 | + | t.Run(tc.name, func(t *testing.T) { | |
| 188 | + | m := canonical | |
| 189 | + | m.Topologies = append([]topology(nil), canonical.Topologies...) | |
| 190 | + | m.Workloads = append([]string(nil), canonical.Workloads...) | |
| 191 | + | m.Scenarios = append([]scenario(nil), canonical.Scenarios...) | |
| 192 | + | tc.mutate(&m) | |
| 193 | + | if err := validateManifest(m); err == nil || !strings.Contains(err.Error(), tc.want) { | |
| 194 | + | t.Fatalf("validateManifest() error = %v, want %q", err, tc.want) | |
| 195 | + | } | |
| 196 | + | }) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestHarnessManifestCoversCanonicalMatrix(t *testing.T) { | |
| 201 | + | m, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json")) | |
| 202 | + | if err != nil { | |
| 203 | + | t.Fatal(err) | |
| 204 | + | } | |
| 205 | + | if err := validateManifest(m); err != nil { | |
| 206 | + | t.Fatal(err) | |
| 207 | + | } | |
| 208 | + | if len(m.Scenarios) != 4*9*7 { | |
| 209 | + | t.Fatalf("scenarios=%d", len(m.Scenarios)) | |
| 210 | + | } | |
| 211 | + | m.Scenarios = m.Scenarios[:len(m.Scenarios)-1] | |
| 212 | + | if err := validateManifest(m); err == nil { | |
| 213 | + | t.Fatal("missing combination accepted") | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestHarnessCreatesExclusiveTraceManifestAndEvidence(t *testing.T) { | |
| 218 | + | dir := t.TempDir() | |
| 219 | + | raw, err := os.OpenFile(filepath.Join(dir, "raw-harness.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |
| 220 | + | if err != nil { | |
| 221 | + | t.Fatal(err) | |
| 222 | + | } | |
| 223 | + | closeTestFile(t, raw) | |
| 224 | + | launcher := &fakeLauncher{} | |
| 225 | + | h := defaultHarness() | |
| 226 | + | h.clock = &fakeClock{} | |
| 227 | + | h.launcher = launcher | |
| 228 | + | o := options{vevBin: "ignored", out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions} | |
| 229 | + | r, err := h.runOne(o, manifest{}, scenario{ID: "4x4-local", Roles: []string{"daemon", "client"}}, 1, raw) | |
| 230 | + | if err != nil { | |
| 231 | + | t.Fatal(err) | |
| 232 | + | } | |
| 233 | + | if !launcher.manifestPresent { | |
| 234 | + | t.Fatal("launched before complete run manifest") | |
| 235 | + | } | |
| 236 | + | if len(r.EndToEnd) < minimumInIntervalEventSamples || r.Samples < minimumInIntervalEventSamples { | |
| 237 | + | t.Fatalf("samples=%+v", r) | |
| 238 | + | } | |
| 239 | + | seen := map[string]bool{} | |
| 240 | + | for _, m := range r.Processes { | |
| 241 | + | if m.ProcessID == "" || m.ClockDomain != m.ProcessID || m.TracePath == "" || seen[m.TracePath] { | |
| 242 | + | t.Fatalf("bad mapping %+v", m) | |
| 243 | + | } | |
| 244 | + | seen[m.TracePath] = true | |
| 245 | + | if _, err := os.Stat(m.TracePath); err != nil { | |
| 246 | + | t.Fatal(err) | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | b, err := os.ReadFile(filepath.Join(dir, "4x4-local-run-001", "manifest.json")) | |
| 250 | + | if err != nil { | |
| 251 | + | t.Fatal(err) | |
| 252 | + | } | |
| 253 | + | var got runManifest | |
| 254 | + | if err := json.Unmarshal(b, &got); err != nil { | |
| 255 | + | t.Fatal(err) | |
| 256 | + | } | |
| 257 | + | if len(got.Processes) != 2 { | |
| 258 | + | t.Fatalf("manifest=%+v", got) | |
| 259 | + | } | |
| 260 | + | if len(launcher.process) != 2 || !launcher.process[0].closed || len(launcher.process[0].warmups) != 0 || len(launcher.process[1].warmups) != 1 || len(launcher.process[1].measures) < minimumInIntervalEventSamples { | |
| 261 | + | t.Fatalf("process lifecycle not recorded: %+v", launcher.process) | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func TestHarnessRejectsMissingManifestRoleTrace(t *testing.T) { | |
| 266 | + | dir := t.TempDir() | |
| 267 | + | missing := filepath.Join(dir, "daemon.jsonl") | |
| 268 | + | client := filepath.Join(dir, "client.jsonl") | |
| 269 | + | if err := os.WriteFile(client, nil, 0o600); err != nil { | |
| 270 | + | t.Fatal(err) | |
| 271 | + | } | |
| 272 | + | _, err := mergeProcessTraces([]processMapping{ | |
| 273 | + | {ProcessID: "daemon", ClockDomain: "daemon", TracePath: missing, Role: "daemon", Scenario: "s", Run: 1}, | |
| 274 | + | {ProcessID: "client", ClockDomain: "client", TracePath: client, Role: "client", Scenario: "s", Run: 1}, | |
| 275 | + | }) | |
| 276 | + | if !errors.Is(err, os.ErrNotExist) || !strings.Contains(err.Error(), "daemon role") { | |
| 277 | + | t.Fatalf("missing daemon trace error = %v, want daemon role not-exist error", err) | |
| 278 | + | } | |
| 279 | + | } | |
A cmd/vev-perf-harness/runner.go +439 -0
| @@ -0,0 +1,439 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "os/exec" | |
| 11 | + | "path/filepath" | |
| 12 | + | "runtime/debug" | |
| 13 | + | "sort" | |
| 14 | + | "strings" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/vev/pkg/safedir" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | type harnessMark struct { | |
| 21 | + | Scenario string `json:"scenario"` | |
| 22 | + | Run int `json:"run"` | |
| 23 | + | Sequence uint64 `json:"sequence"` | |
| 24 | + | Kind string `json:"kind"` | |
| 25 | + | Tick int64 `json:"tick"` | |
| 26 | + | Valid bool `json:"valid"` | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (c systemClock) Now() int64 { return time.Since(c.start).Nanoseconds() } | |
| 30 | + | ||
| 31 | + | func (systemClock) Sleep(d time.Duration) { time.Sleep(d) } | |
| 32 | + | ||
| 33 | + | func newPTYLocalEcho(input []byte) *ptyLocalEcho { | |
| 34 | + | expected := make([]byte, 0, len(input)) | |
| 35 | + | for _, b := range input { | |
| 36 | + | if b == '\n' { | |
| 37 | + | expected = append(expected, '\r') | |
| 38 | + | } | |
| 39 | + | expected = append(expected, b) | |
| 40 | + | } | |
| 41 | + | return &ptyLocalEcho{expected: expected} | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type harness struct { | |
| 45 | + | clock clock | |
| 46 | + | launcher launcher | |
| 47 | + | mkdir func(string) error | |
| 48 | + | createRunDir func(string) error | |
| 49 | + | create func(string) (*os.File, error) | |
| 50 | + | removeAll func(string) error | |
| 51 | + | gitSHA func() string | |
| 52 | + | selectScenarios func(manifest) []scenario // test-only bounded fixture seam | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // createExclusiveRunDir prevents stale runtime, socket, state, or trace files | |
| 56 | + | // from an earlier invocation being mistaken for the next repetition's evidence. | |
| 57 | + | func createExclusiveRunDir(path string) error { | |
| 58 | + | if err := os.Mkdir(path, 0o700); err != nil { | |
| 59 | + | return err | |
| 60 | + | } | |
| 61 | + | if err := safedir.EnsurePrivate(path); err != nil { | |
| 62 | + | return errors.Join(err, os.Remove(path)) | |
| 63 | + | } | |
| 64 | + | return nil | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // recordedGitSHA prefers build provenance so an installed public harness does | |
| 68 | + | // not depend on its launch directory being a checkout. The git fallback keeps | |
| 69 | + | // go run evidence attributable as well. | |
| 70 | + | const gitSHACommandTimeout = time.Second | |
| 71 | + | ||
| 72 | + | func recordedGitSHA() string { | |
| 73 | + | if info, ok := debug.ReadBuildInfo(); ok { | |
| 74 | + | for _, setting := range info.Settings { | |
| 75 | + | if setting.Key == "vcs.revision" && setting.Value != "" { | |
| 76 | + | return setting.Value | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ctx, cancel := context.WithTimeout(context.Background(), gitSHACommandTimeout) | |
| 81 | + | defer cancel() | |
| 82 | + | out, err := exec.CommandContext(ctx, "git", "rev-parse", "HEAD").Output() | |
| 83 | + | if err == nil && strings.TrimSpace(string(out)) != "" { | |
| 84 | + | return strings.TrimSpace(string(out)) | |
| 85 | + | } | |
| 86 | + | return "unknown" | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func run(args []string, h *harness) (err error) { | |
| 90 | + | opt, err := parseOptions(args) | |
| 91 | + | if err != nil { | |
| 92 | + | return err | |
| 93 | + | } | |
| 94 | + | opt, err = resolvePathOptions(opt) | |
| 95 | + | if err != nil { | |
| 96 | + | return err | |
| 97 | + | } | |
| 98 | + | m, err := readManifest(opt.manifest) | |
| 99 | + | if err != nil { | |
| 100 | + | return err | |
| 101 | + | } | |
| 102 | + | if err := validateManifest(m); err != nil { | |
| 103 | + | return err | |
| 104 | + | } | |
| 105 | + | if err = h.mkdir(opt.out); err != nil { | |
| 106 | + | return err | |
| 107 | + | } | |
| 108 | + | raw, err := os.OpenFile(filepath.Join(opt.out, "raw-harness.jsonl"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) | |
| 109 | + | if err != nil { | |
| 110 | + | return err | |
| 111 | + | } | |
| 112 | + | defer func() { | |
| 113 | + | if closeErr := raw.Close(); closeErr != nil { | |
| 114 | + | err = errors.Join(err, closeErr) | |
| 115 | + | } | |
| 116 | + | }() | |
| 117 | + | results := make([]runResult, 0, len(m.Scenarios)*opt.repetitions) | |
| 118 | + | var localSpans []span | |
| 119 | + | scenarios := m.Scenarios | |
| 120 | + | if opt.scenario != "" { | |
| 121 | + | scenarios, err = selectScenario(m, opt.scenario) | |
| 122 | + | if err != nil { | |
| 123 | + | return err | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | if h.selectScenarios != nil { | |
| 127 | + | scenarios = h.selectScenarios(m) | |
| 128 | + | } | |
| 129 | + | for _, s := range scenarios { | |
| 130 | + | if s.InapplicableReason != "" { | |
| 131 | + | continue | |
| 132 | + | } | |
| 133 | + | for r := 1; r <= opt.repetitions; r++ { | |
| 134 | + | rr, err := h.runOne(opt, m, s, r, raw) | |
| 135 | + | if err != nil { | |
| 136 | + | return fmt.Errorf("scenario %s run %d: %w", s.ID, r, err) | |
| 137 | + | } | |
| 138 | + | results = append(results, rr) | |
| 139 | + | localSpans = append(localSpans, rr.Spans...) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | all, cadence, maxGap, runP50s := aggregateEventResults(results) | |
| 143 | + | if len(all) == 0 { | |
| 144 | + | return errors.New("no measured harness input/flush pairs") | |
| 145 | + | } | |
| 146 | + | if err := writeJSON(filepath.Join(opt.out, "runs.json"), results); err != nil { | |
| 147 | + | return err | |
| 148 | + | } | |
| 149 | + | return writeJSON(filepath.Join(opt.out, "summary.json"), summary{Schema: 1, GitSHA: h.gitSHA(), Warmup: opt.warmup.String(), Duration: opt.duration.String(), Repetitions: opt.repetitions, EndToEnd: percentiles(all), Cadence: percentiles(cadence), MaxGap: maxGap, RunP50Dispersion: percentiles(runP50s), Spans: summarizeSpans(localSpans), Runs: len(results)}) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // aggregateEventResults deliberately preserves raw event latencies for | |
| 153 | + | // end-to-end percentiles. Per-run p50s are a separate dispersion series. | |
| 154 | + | func aggregateEventResults(results []runResult) (all, cadence []int64, maxGap int64, runP50s []int64) { | |
| 155 | + | for _, result := range results { | |
| 156 | + | all = append(all, result.EndToEnd...) | |
| 157 | + | cadence = append(cadence, result.CadenceSamples...) | |
| 158 | + | runP50s = append(runP50s, result.Event.P50) | |
| 159 | + | if result.MaxGap > maxGap { | |
| 160 | + | maxGap = result.MaxGap | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | return all, cadence, maxGap, runP50s | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func (h *harness) runOne(o options, mat manifest, s scenario, run int, raw io.Writer) (res runResult, err error) { | |
| 167 | + | dir := filepath.Join(o.out, fmt.Sprintf("%s-run-%03d", safeName(s.ID), run)) | |
| 168 | + | createRunDir := h.createRunDir | |
| 169 | + | if createRunDir == nil { | |
| 170 | + | createRunDir = createExclusiveRunDir | |
| 171 | + | } | |
| 172 | + | if err = createRunDir(dir); err != nil { | |
| 173 | + | return res, fmt.Errorf("create isolated run directory: %w", err) | |
| 174 | + | } | |
| 175 | + | success := false | |
| 176 | + | defer func() { | |
| 177 | + | if !success { | |
| 178 | + | if cleanupErr := h.removeAll(dir); cleanupErr != nil { | |
| 179 | + | err = errors.Join(err, fmt.Errorf("remove failed run directory: %w", cleanupErr)) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | }() | |
| 183 | + | maps := make([]processMapping, 0, len(s.Roles)) | |
| 184 | + | for i, role := range s.Roles { | |
| 185 | + | pid := fmt.Sprintf("%s-r%03d-%s-%02d", safeName(s.ID), run, safeName(role), i+1) | |
| 186 | + | path := filepath.Join(dir, pid+".jsonl") | |
| 187 | + | f, e := h.create(path) | |
| 188 | + | if e != nil { | |
| 189 | + | return res, fmt.Errorf("allocate exclusive trace %s: %w", role, e) | |
| 190 | + | } | |
| 191 | + | if e = f.Close(); e != nil { | |
| 192 | + | return res, e | |
| 193 | + | } | |
| 194 | + | maps = append(maps, processMapping{ProcessID: pid, ClockDomain: pid, TracePath: path, Role: role, Scenario: s.ID, Run: run, Identity: "harness-assigned:" + pid}) | |
| 195 | + | } | |
| 196 | + | // Persist the complete mapping before any process can be launched. This is | |
| 197 | + | // both crash evidence and the only legal source for later ID correlation. | |
| 198 | + | if err = writeJSON(filepath.Join(dir, "manifest.json"), runManifest{Scenario: s.ID, Run: run, Processes: maps}); err != nil { | |
| 199 | + | return res, err | |
| 200 | + | } | |
| 201 | + | if h.launcher == nil { | |
| 202 | + | h.launcher = &cliLauncher{bin: o.vevBin} | |
| 203 | + | } | |
| 204 | + | processes := make([]launchedRole, 0, len(maps)) | |
| 205 | + | closed := false | |
| 206 | + | closeRoles := func() error { | |
| 207 | + | if closed { | |
| 208 | + | return nil | |
| 209 | + | } | |
| 210 | + | closed = true | |
| 211 | + | // Clients own SSH-stdio descendants; close them before the deferred UDP | |
| 212 | + | // peer and daemon so no role survives a failed or completed run. | |
| 213 | + | return closeLaunchedRoles(processes) | |
| 214 | + | } | |
| 215 | + | defer func() { | |
| 216 | + | if cleanupErr := closeRoles(); cleanupErr != nil { | |
| 217 | + | if err == nil { | |
| 218 | + | err = cleanupErr | |
| 219 | + | } else { | |
| 220 | + | // Keep the primary workload failure first while retaining every | |
| 221 | + | // cleanup failure in reverse launch order. | |
| 222 | + | err = errors.Join(err, cleanupErr) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | }() | |
| 226 | + | selectedTransport, e := manifestTransport(mat, s.Transport) | |
| 227 | + | if e != nil { | |
| 228 | + | return res, e | |
| 229 | + | } | |
| 230 | + | for _, pm := range launchOrder(maps) { | |
| 231 | + | p, e := h.launcher.Launch(pm, routeRoleArgs(s, pm, selectedTransport)) | |
| 232 | + | if e != nil { | |
| 233 | + | return res, e | |
| 234 | + | } | |
| 235 | + | processes = append(processes, launchedRole{mapping: pm, process: p}) | |
| 236 | + | if pm.Role == "daemon" { | |
| 237 | + | if ready, ok := p.(interface{ WaitReady() error }); ok { | |
| 238 | + | if err = ready.WaitReady(); err != nil { | |
| 239 | + | return res, err | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | marks := []harnessMark{} | |
| 245 | + | recordMark := func(mark harnessMark) error { | |
| 246 | + | marks = append(marks, mark) | |
| 247 | + | if err := json.NewEncoder(raw).Encode(mark); err != nil { | |
| 248 | + | return fmt.Errorf("write raw harness mark: %w", err) | |
| 249 | + | } | |
| 250 | + | return nil | |
| 251 | + | } | |
| 252 | + | seq := uint64(0) | |
| 253 | + | for _, p := range processes { | |
| 254 | + | if p.mapping.Role != "client" { | |
| 255 | + | continue | |
| 256 | + | } | |
| 257 | + | seq++ | |
| 258 | + | sequence := seq | |
| 259 | + | if err = recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "input_injected", Tick: h.clock.Now(), Valid: true}); err != nil { | |
| 260 | + | return res, err | |
| 261 | + | } | |
| 262 | + | warmupErr := p.process.Warmup(workloadInput(s, run, "warmup")) | |
| 263 | + | if markErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "terminal_flushed", Tick: h.clock.Now(), Valid: warmupErr == nil}); markErr != nil { | |
| 264 | + | return res, errors.Join(warmupErr, markErr) | |
| 265 | + | } | |
| 266 | + | if warmupErr != nil { | |
| 267 | + | return res, warmupErr | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | // The harness clock, rather than a process clock, owns the explicit warmup | |
| 271 | + | // and measured intervals. Raw evidence retains every boundary, while only | |
| 272 | + | // complete valid pairs within [Start, End) contribute measurement samples. | |
| 273 | + | h.clock.Sleep(o.warmup) | |
| 274 | + | interval := measuredInterval{Start: h.clock.Now()} | |
| 275 | + | interval.End = interval.Start + o.duration.Nanoseconds() | |
| 276 | + | nextInjection := interval.Start | |
| 277 | + | for { | |
| 278 | + | now := h.clock.Now() | |
| 279 | + | if now < nextInjection { | |
| 280 | + | h.clock.Sleep(time.Duration(nextInjection - now)) | |
| 281 | + | now = h.clock.Now() | |
| 282 | + | } | |
| 283 | + | if now >= interval.End { | |
| 284 | + | break | |
| 285 | + | } | |
| 286 | + | for _, p := range processes { | |
| 287 | + | if p.mapping.Role != "client" { | |
| 288 | + | continue | |
| 289 | + | } | |
| 290 | + | seq++ | |
| 291 | + | sequence := seq | |
| 292 | + | injectedRecorded := false | |
| 293 | + | flushedRecorded := false | |
| 294 | + | injected := func() error { | |
| 295 | + | if markErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "input_injected", Tick: h.clock.Now(), Valid: true}); markErr != nil { | |
| 296 | + | return markErr | |
| 297 | + | } | |
| 298 | + | injectedRecorded = true | |
| 299 | + | return nil | |
| 300 | + | } | |
| 301 | + | flushed := func() error { | |
| 302 | + | if markErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "terminal_flushed", Tick: h.clock.Now(), Valid: true}); markErr != nil { | |
| 303 | + | return markErr | |
| 304 | + | } | |
| 305 | + | flushedRecorded = true | |
| 306 | + | return nil | |
| 307 | + | } | |
| 308 | + | if measureErr := p.process.Measure(workloadInput(s, run, fmt.Sprintf("measured-%d", sequence)), injected, flushed); measureErr != nil { | |
| 309 | + | if injectedRecorded && !flushedRecorded { | |
| 310 | + | diagnosticErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "terminal_flushed", Tick: h.clock.Now(), Valid: false}) | |
| 311 | + | return res, errors.Join(measureErr, diagnosticErr) | |
| 312 | + | } | |
| 313 | + | return res, measureErr | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | // Cap injection rate rather than bursting events after a slow terminal | |
| 317 | + | // flush. Any resulting spacing is retained as raw cadence evidence. | |
| 318 | + | nextInjection = h.clock.Now() + measuredEventCadence.Nanoseconds() | |
| 319 | + | } | |
| 320 | + | if now := h.clock.Now(); now < interval.End { | |
| 321 | + | h.clock.Sleep(time.Duration(interval.End - now)) | |
| 322 | + | } | |
| 323 | + | events, e := measuredEventSamples(marks, interval) | |
| 324 | + | if e != nil { | |
| 325 | + | return res, e | |
| 326 | + | } | |
| 327 | + | if err := requireMinimumEventSamples(events); err != nil { | |
| 328 | + | return res, err | |
| 329 | + | } | |
| 330 | + | samples := eventLatencies(events) | |
| 331 | + | cadenceSamples, maxGap := eventCadenceSamples(events) | |
| 332 | + | // Process teardown can unblock a receive and cause its failed end mark to | |
| 333 | + | // be serialized. Merge only the complete, post-cleanup per-process traces. | |
| 334 | + | if cleanupErr := closeRoles(); cleanupErr != nil { | |
| 335 | + | return res, cleanupErr | |
| 336 | + | } | |
| 337 | + | spans, e := mergeProcessTraces(maps) | |
| 338 | + | if e != nil { | |
| 339 | + | return res, e | |
| 340 | + | } | |
| 341 | + | success = true | |
| 342 | + | return runResult{Spans: spans, Scenario: s.ID, Run: run, Samples: len(samples), EndToEnd: samples, Event: percentiles(samples), Cadence: percentiles(cadenceSamples), CadenceSamples: cadenceSamples, MaxGap: maxGap, Processes: maps}, nil | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func roleArgs(s scenario, m processMapping) roleCommand { | |
| 346 | + | return routeRoleArgs(s, m, scenarioTransport(s)) | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand { | |
| 350 | + | session := "perf-" + safeName(s.ID) + fmt.Sprintf("-%03d", m.Run) | |
| 351 | + | remote := []string{"attach", "harness@127.0.0.1:" + session} | |
| 352 | + | switch m.Role { | |
| 353 | + | case "daemon": | |
| 354 | + | return roleCommand{Args: []string{"--daemon"}} | |
| 355 | + | case "client": | |
| 356 | + | switch s.Transport { | |
| 357 | + | case "local": | |
| 358 | + | return roleCommand{Args: []string{"new", session}} | |
| 359 | + | case "ssh_stdio", "udp_baseline", "udp_25ms", "udp_100ms", "udp_loss_0pct", "udp_loss_1pct": | |
| 360 | + | return roleCommand{Args: remote} | |
| 361 | + | } | |
| 362 | + | case "ssh_stdio_peer": | |
| 363 | + | return roleCommand{Args: []string{"_stdio", session}, Transport: transport{ID: s.Transport, Kind: "ssh_stdio"}} | |
| 364 | + | case "udp_peer": | |
| 365 | + | return roleCommand{Args: []string{"_udp-proxy", session}, Transport: selected} | |
| 366 | + | } | |
| 367 | + | return roleCommand{} | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | func manifestTransport(m manifest, id string) (transport, error) { | |
| 371 | + | for _, t := range m.Transports { | |
| 372 | + | if t.ID == id { | |
| 373 | + | return t, nil | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | // Focused unit tests may call runOne with a minimal manifest; production | |
| 377 | + | // already validates every scenario reference before this point. | |
| 378 | + | if m.Schema == 0 { | |
| 379 | + | return scenarioTransport(scenario{Transport: id}), nil | |
| 380 | + | } | |
| 381 | + | return transport{}, fmt.Errorf("scenario references missing transport %q", id) | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | func scenarioTransport(s scenario) transport { | |
| 385 | + | switch s.Transport { | |
| 386 | + | case "udp_baseline": | |
| 387 | + | return transport{ID: s.Transport, Kind: "udp"} | |
| 388 | + | case "udp_25ms": | |
| 389 | + | return transport{ID: s.Transport, Kind: "udp", RTTMS: 25} | |
| 390 | + | case "udp_100ms": | |
| 391 | + | return transport{ID: s.Transport, Kind: "udp", RTTMS: 100} | |
| 392 | + | case "udp_loss_0pct": | |
| 393 | + | return transport{ID: s.Transport, Kind: "udp", LossPercent: 0} | |
| 394 | + | case "udp_loss_1pct": | |
| 395 | + | return transport{ID: s.Transport, Kind: "udp", LossPercent: 1} | |
| 396 | + | default: | |
| 397 | + | return transport{ID: s.Transport} | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | func launchOrder(mappings []processMapping) []processMapping { | |
| 402 | + | out := append([]processMapping(nil), mappings...) | |
| 403 | + | priority := func(role string) int { | |
| 404 | + | switch role { | |
| 405 | + | case "daemon": | |
| 406 | + | return 0 | |
| 407 | + | case "ssh_stdio_peer", "udp_peer": | |
| 408 | + | return 1 | |
| 409 | + | case "client": | |
| 410 | + | return 2 | |
| 411 | + | default: | |
| 412 | + | return 3 | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | sort.SliceStable(out, func(i, j int) bool { return priority(out[i].Role) < priority(out[j].Role) }) | |
| 416 | + | return out | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | // workloadInput is shell input delivered through the client PTY. It avoids any | |
| 420 | + | // private daemon API and makes every topology/workload/transport manifest row | |
| 421 | + | // execute a real terminal workload. The marker is the output boundary awaited | |
| 422 | + | // by cliProcess; it is not a synthetic timestamp. | |
| 423 | + | func workloadInput(s scenario, run int, phase string) []byte { | |
| 424 | + | marker := fmt.Sprintf("__VEV_HARNESS_%s_r%d_%s__", safeName(s.ID), run, safeName(phase)) | |
| 425 | + | body := "printf 'vev perf %s\\n'" | |
| 426 | + | switch s.Workload { | |
| 427 | + | case "active_output", "all_output", "inactive_output", "interactive_flood": | |
| 428 | + | body = "i=0; while [ $i -lt 128 ]; do printf 'vev perf output %s\\n'; i=$((i+1)); done" | |
| 429 | + | case "resize_sweep": | |
| 430 | + | body = "printf 'vev perf resize-sweep %s\\n'" | |
| 431 | + | case "copy_search": | |
| 432 | + | body = "printf 'vev perf copy-search %s\\n'" | |
| 433 | + | case "snapshot_output_resize": | |
| 434 | + | body = "printf 'vev perf snapshot-output-resize %s\\n'" | |
| 435 | + | case "attach_restore_tab_switch": | |
| 436 | + | body = "printf 'vev perf attach-restore-tab-switch %s\\n'" | |
| 437 | + | } | |
| 438 | + | return []byte(fmt.Sprintf(body+"; printf '%s\\n'\n", s.ID, marker)) | |
| 439 | + | } | |
A cmd/vev-perf-harness/runner_test.go +458 -0
| @@ -0,0 +1,458 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "os/exec" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/pkg/safedir" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestHarnessCanonicalLocalRolesAreIsolatedAcrossRepetitions(t *testing.T) { | |
| 19 | + | if testing.Short() { | |
| 20 | + | t.Skip("public CLI repetition lifecycle") | |
| 21 | + | } | |
| 22 | + | root, err := filepath.Abs(filepath.Join("..", "..")) | |
| 23 | + | if err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | bin := filepath.Join(t.TempDir(), "vev") | |
| 27 | + | build := exec.Command("go", "build", "-o", bin, "./") | |
| 28 | + | build.Dir = root | |
| 29 | + | if output, err := build.CombinedOutput(); err != nil { | |
| 30 | + | t.Fatalf("build public CLI: %v\n%s", err, output) | |
| 31 | + | } | |
| 32 | + | m, err := readManifest(filepath.Join(root, "testdata", "perf", "manifest.json")) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | var local scenario | |
| 37 | + | for _, candidate := range m.Scenarios { | |
| 38 | + | if candidate.ID == "1x4-idle-local" { | |
| 39 | + | local = candidate | |
| 40 | + | break | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | if local.ID == "" { | |
| 44 | + | t.Fatal("canonical local scenario missing") | |
| 45 | + | } | |
| 46 | + | out, err := os.MkdirTemp("", "vev-ob-") | |
| 47 | + | if err != nil { | |
| 48 | + | t.Fatal(err) | |
| 49 | + | } | |
| 50 | + | removeTestTree(t, out) | |
| 51 | + | raw, err := os.OpenFile(filepath.Join(out, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |
| 52 | + | if err != nil { | |
| 53 | + | t.Fatal(err) | |
| 54 | + | } | |
| 55 | + | closeTestFile(t, raw) | |
| 56 | + | h := defaultHarness() | |
| 57 | + | h.clock = &fakeClock{} | |
| 58 | + | h.launcher = &cliLauncher{bin: bin} | |
| 59 | + | for run := 1; run <= 3; run++ { | |
| 60 | + | result, err := h.runOne(options{vevBin: bin, out: out, duration: minimumDuration}, m, local, run, raw) | |
| 61 | + | if err != nil { | |
| 62 | + | t.Fatalf("run %d: %v", run, err) | |
| 63 | + | } | |
| 64 | + | if len(result.Processes) != 2 { | |
| 65 | + | t.Fatalf("run %d process mappings = %+v", run, result.Processes) | |
| 66 | + | } | |
| 67 | + | runDir := filepath.Join(out, fmt.Sprintf("%s-run-%03d", safeName(local.ID), run)) | |
| 68 | + | manifestBytes, err := os.ReadFile(filepath.Join(runDir, "manifest.json")) | |
| 69 | + | if err != nil { | |
| 70 | + | t.Fatalf("run %d manifest: %v", run, err) | |
| 71 | + | } | |
| 72 | + | var persisted runManifest | |
| 73 | + | if err := json.Unmarshal(manifestBytes, &persisted); err != nil { | |
| 74 | + | t.Fatalf("run %d manifest JSON: %v", run, err) | |
| 75 | + | } | |
| 76 | + | if persisted.Run != run || len(persisted.Processes) != len(result.Processes) { | |
| 77 | + | t.Fatalf("run %d manifest = %+v", run, persisted) | |
| 78 | + | } | |
| 79 | + | for _, process := range result.Processes { | |
| 80 | + | if process.Role != "daemon" && process.Role != "client" { | |
| 81 | + | t.Fatalf("run %d unexpected role %q", run, process.Role) | |
| 82 | + | } | |
| 83 | + | if filepath.Dir(process.TracePath) != runDir { | |
| 84 | + | t.Fatalf("run %d %s trace outside its isolated run directory: %q", run, process.Role, process.TracePath) | |
| 85 | + | } | |
| 86 | + | trace, err := os.ReadFile(process.TracePath) | |
| 87 | + | if err != nil { | |
| 88 | + | t.Fatalf("run %d %s trace %q: %v", run, process.Role, process.TracePath, err) | |
| 89 | + | } | |
| 90 | + | if len(bytes.TrimSpace(trace)) == 0 { | |
| 91 | + | t.Fatalf("run %d %s did not emit its declared trace", run, process.Role) | |
| 92 | + | } | |
| 93 | + | for _, line := range bytes.Split(bytes.TrimSpace(trace), []byte{'\n'}) { | |
| 94 | + | var record traceRecord | |
| 95 | + | if err := json.Unmarshal(line, &record); err != nil { | |
| 96 | + | t.Fatalf("run %d %s trace JSON: %v", run, process.Role, err) | |
| 97 | + | } | |
| 98 | + | if record.ProcessID != process.ProcessID || record.Scenario != local.ID || record.Run != uint64(run) { | |
| 99 | + | t.Fatalf("run %d %s trace escaped its manifest identity: %+v", run, process.Role, record) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | if _, err := os.Stat(filepath.Join(runDir, "runtime", "vev", "daemon.sock")); !errors.Is(err, os.ErrNotExist) { | |
| 104 | + | t.Fatalf("run %d left daemon socket for the next repetition: %v", run, err) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestHarnessResolvesCanonicalRelativePathsBeforeRoleCWDChanges(t *testing.T) { | |
| 110 | + | if testing.Short() { | |
| 111 | + | t.Skip("public CLI relative-path lifecycle") | |
| 112 | + | } | |
| 113 | + | root, err := filepath.Abs(filepath.Join("..", "..")) | |
| 114 | + | if err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | // Keep the real Unix-socket path below its platform limit while testing the | |
| 118 | + | // exact documented relative output hierarchy. | |
| 119 | + | workspace, err := os.MkdirTemp("", "vev-rel-") | |
| 120 | + | if err != nil { | |
| 121 | + | t.Fatal(err) | |
| 122 | + | } | |
| 123 | + | removeTestTree(t, workspace) | |
| 124 | + | bin := filepath.Join(workspace, "vev") | |
| 125 | + | build := exec.Command("go", "build", "-o", bin, "./") | |
| 126 | + | build.Dir = root | |
| 127 | + | if output, err := build.CombinedOutput(); err != nil { | |
| 128 | + | t.Fatalf("build public CLI: %v\n%s", err, output) | |
| 129 | + | } | |
| 130 | + | manifestPath := filepath.Join(workspace, "testdata", "perf", "manifest.json") | |
| 131 | + | if err := safedir.EnsurePrivate(filepath.Dir(manifestPath)); err != nil { | |
| 132 | + | t.Fatal(err) | |
| 133 | + | } | |
| 134 | + | contents, err := os.ReadFile(filepath.Join(root, "testdata", "perf", "manifest.json")) | |
| 135 | + | if err != nil { | |
| 136 | + | t.Fatal(err) | |
| 137 | + | } | |
| 138 | + | if err := os.WriteFile(manifestPath, contents, 0o600); err != nil { | |
| 139 | + | t.Fatal(err) | |
| 140 | + | } | |
| 141 | + | originalWD, err := os.Getwd() | |
| 142 | + | if err != nil { | |
| 143 | + | t.Fatal(err) | |
| 144 | + | } | |
| 145 | + | if err := os.Chdir(workspace); err != nil { | |
| 146 | + | t.Fatal(err) | |
| 147 | + | } | |
| 148 | + | t.Cleanup(func() { | |
| 149 | + | if err := os.Chdir(originalWD); err != nil { | |
| 150 | + | t.Errorf("restore cwd: %v", err) | |
| 151 | + | } | |
| 152 | + | }) | |
| 153 | + | ||
| 154 | + | // These are the documented relative locations. cliLauncher changes every | |
| 155 | + | // role to its run directory, so a failure to resolve them before launch | |
| 156 | + | // would make VEV_PERF_TRACE point at a nonexistent nested directory. | |
| 157 | + | h := defaultHarness() | |
| 158 | + | h.clock = &fakeClock{} | |
| 159 | + | if err := run([]string{ | |
| 160 | + | "--vev-bin", "./vev", | |
| 161 | + | "--manifest", "testdata/perf/manifest.json", | |
| 162 | + | "--out", "testdata/perf/results", | |
| 163 | + | "--scenario", "1x4-idle-local", | |
| 164 | + | "--warmup", "0s", | |
| 165 | + | "--duration", "30s", | |
| 166 | + | "--repetitions", "10", | |
| 167 | + | }, h); err != nil { | |
| 168 | + | t.Fatal(err) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | out := filepath.Join(workspace, "testdata", "perf", "results") | |
| 172 | + | for run := 1; run <= 10; run++ { | |
| 173 | + | runDir := filepath.Join(out, fmt.Sprintf("1x4-idle-local-run-%03d", run)) | |
| 174 | + | manifestBytes, err := os.ReadFile(filepath.Join(runDir, "manifest.json")) | |
| 175 | + | if err != nil { | |
| 176 | + | t.Fatalf("run %d manifest: %v", run, err) | |
| 177 | + | } | |
| 178 | + | var persisted runManifest | |
| 179 | + | if err := json.Unmarshal(manifestBytes, &persisted); err != nil { | |
| 180 | + | t.Fatalf("run %d manifest JSON: %v", run, err) | |
| 181 | + | } | |
| 182 | + | if persisted.Run != run || len(persisted.Processes) != 2 { | |
| 183 | + | t.Fatalf("run %d manifest=%+v", run, persisted) | |
| 184 | + | } | |
| 185 | + | for _, process := range persisted.Processes { | |
| 186 | + | if !filepath.IsAbs(process.TracePath) || filepath.Dir(process.TracePath) != runDir { | |
| 187 | + | t.Fatalf("run %d %s trace path=%q, want absolute path in %q", run, process.Role, process.TracePath, runDir) | |
| 188 | + | } | |
| 189 | + | trace, err := os.ReadFile(process.TracePath) | |
| 190 | + | if err != nil || len(bytes.TrimSpace(trace)) == 0 { | |
| 191 | + | t.Fatalf("run %d %s trace=%q err=%v", run, process.Role, process.TracePath, err) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestHarnessRejectsExistingRunDirectoryBeforeLaunchingRoles(t *testing.T) { | |
| 198 | + | out := t.TempDir() | |
| 199 | + | raw, err := os.OpenFile(filepath.Join(out, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |
| 200 | + | if err != nil { | |
| 201 | + | t.Fatal(err) | |
| 202 | + | } | |
| 203 | + | closeTestFile(t, raw) | |
| 204 | + | s := scenario{ID: "isolated", Roles: []string{"daemon", "client"}} | |
| 205 | + | stale := filepath.Join(out, "isolated-run-001") | |
| 206 | + | if err := os.Mkdir(stale, 0o700); err != nil { | |
| 207 | + | t.Fatal(err) | |
| 208 | + | } | |
| 209 | + | if err := os.WriteFile(filepath.Join(stale, "preserve"), []byte("stale"), 0o600); err != nil { | |
| 210 | + | t.Fatal(err) | |
| 211 | + | } | |
| 212 | + | launcher := &fakeLauncher{} | |
| 213 | + | h := defaultHarness() | |
| 214 | + | h.clock, h.launcher = &fakeClock{}, launcher | |
| 215 | + | _, err = h.runOne(options{out: out, duration: minimumDuration}, manifest{}, s, 1, raw) | |
| 216 | + | if err == nil || !strings.Contains(err.Error(), "create isolated run directory") { | |
| 217 | + | t.Fatalf("existing run directory error = %v", err) | |
| 218 | + | } | |
| 219 | + | if len(launcher.mappings) != 0 { | |
| 220 | + | t.Fatalf("roles launched against stale run directory: %+v", launcher.mappings) | |
| 221 | + | } | |
| 222 | + | if contents, readErr := os.ReadFile(filepath.Join(stale, "preserve")); readErr != nil || string(contents) != "stale" { | |
| 223 | + | t.Fatalf("stale directory changed: contents=%q err=%v", contents, readErr) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func TestHarnessDoesNotInheritNestedSession(t *testing.T) { | |
| 228 | + | env := withoutEnv([]string{"VEV=session=old", "PATH=/bin", "VEV_PERF_TRACE=old"}, "VEV") | |
| 229 | + | if !equalStrings(env, []string{"PATH=/bin", "VEV_PERF_TRACE=old"}) { | |
| 230 | + | t.Fatalf("environment=%q", env) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) { | |
| 235 | + | cases := []struct { | |
| 236 | + | name string | |
| 237 | + | transport transport | |
| 238 | + | peer string | |
| 239 | + | wantRTT int | |
| 240 | + | wantLoss int | |
| 241 | + | }{ | |
| 242 | + | {"ssh", transport{ID: "ssh_stdio", Kind: "ssh_stdio"}, "ssh_stdio_peer", 0, 0}, | |
| 243 | + | {"udp baseline", transport{ID: "udp_baseline", Kind: "udp"}, "udp_peer", 0, 0}, | |
| 244 | + | {"udp 25ms", transport{ID: "udp_25ms", Kind: "udp", RTTMS: 25}, "udp_peer", 25, 0}, | |
| 245 | + | {"udp 100ms", transport{ID: "udp_100ms", Kind: "udp", RTTMS: 100}, "udp_peer", 100, 0}, | |
| 246 | + | {"udp loss zero", transport{ID: "udp_loss_0pct", Kind: "udp", LossPercent: 0}, "udp_peer", 0, 0}, | |
| 247 | + | {"udp loss", transport{ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1}, "udp_peer", 0, 1}, | |
| 248 | + | } | |
| 249 | + | for _, tc := range cases { | |
| 250 | + | t.Run(tc.name, func(t *testing.T) { | |
| 251 | + | s := scenario{ID: "route", Transport: tc.transport.ID} | |
| 252 | + | client := routeRoleArgs(s, processMapping{Role: "client", Run: 2}, tc.transport) | |
| 253 | + | peer := routeRoleArgs(s, processMapping{Role: tc.peer, Run: 2}, tc.transport) | |
| 254 | + | if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1:perf-route-002"}) { | |
| 255 | + | t.Fatalf("client did not use public remote attach: %q", client.Args) | |
| 256 | + | } | |
| 257 | + | if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio", "perf-route-002"}) { | |
| 258 | + | t.Fatalf("ssh peer command=%q", peer.Args) | |
| 259 | + | } | |
| 260 | + | if tc.peer == "udp_peer" && !equalStrings(peer.Args, []string{"_udp-proxy", "perf-route-002"}) { | |
| 261 | + | t.Fatalf("udp peer command=%q", peer.Args) | |
| 262 | + | } | |
| 263 | + | if peer.Transport.RTTMS != tc.wantRTT || peer.Transport.LossPercent != tc.wantLoss { | |
| 264 | + | t.Fatalf("peer lost manifest network settings: %+v", peer.Transport) | |
| 265 | + | } | |
| 266 | + | }) | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func TestHarnessFakeRunnerRoutesClientToPeerAndCleansEveryRole(t *testing.T) { | |
| 271 | + | for _, tc := range []struct { | |
| 272 | + | name, transport, peer, peerCommand string | |
| 273 | + | }{ | |
| 274 | + | {"ssh", "ssh_stdio", "ssh_stdio_peer", "_stdio"}, | |
| 275 | + | {"udp baseline", "udp_baseline", "udp_peer", "_udp-proxy"}, | |
| 276 | + | {"udp 25ms", "udp_25ms", "udp_peer", "_udp-proxy"}, | |
| 277 | + | {"udp 100ms", "udp_100ms", "udp_peer", "_udp-proxy"}, | |
| 278 | + | {"udp loss zero", "udp_loss_0pct", "udp_peer", "_udp-proxy"}, | |
| 279 | + | {"udp loss", "udp_loss_1pct", "udp_peer", "_udp-proxy"}, | |
| 280 | + | } { | |
| 281 | + | t.Run(tc.name, func(t *testing.T) { | |
| 282 | + | dir := t.TempDir() | |
| 283 | + | raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |
| 284 | + | if err != nil { | |
| 285 | + | t.Fatal(err) | |
| 286 | + | } | |
| 287 | + | closeTestFile(t, raw) | |
| 288 | + | l := &fakeLauncher{} | |
| 289 | + | h := defaultHarness() | |
| 290 | + | h.clock, h.launcher = &fakeClock{}, l | |
| 291 | + | s := scenario{ID: "routed", Transport: tc.transport, Roles: []string{"daemon", "client", tc.peer}} | |
| 292 | + | if _, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, s, 1, raw); err != nil { | |
| 293 | + | t.Fatal(err) | |
| 294 | + | } | |
| 295 | + | if len(l.mappings) != 3 || l.mappings[1].Role != tc.peer || l.mappings[2].Role != "client" { | |
| 296 | + | t.Fatalf("dependency launch order=%+v", l.mappings) | |
| 297 | + | } | |
| 298 | + | if l.commands[1].Args[0] != tc.peerCommand || l.commands[2].Args[0] != "attach" { | |
| 299 | + | t.Fatalf("commands do not connect client through declared peer: %+v", l.commands) | |
| 300 | + | } | |
| 301 | + | for i, p := range l.process { | |
| 302 | + | if !p.closed { | |
| 303 | + | t.Fatalf("role %s was not cleaned up", l.mappings[i].Role) | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | }) | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func TestHarnessAggregatesRawEventsSeparatelyFromRunDispersion(t *testing.T) { | |
| 311 | + | first := append([]int64(nil), make([]int64, minimumInIntervalEventSamples+5)...) | |
| 312 | + | for i := range first { | |
| 313 | + | first[i] = 1 | |
| 314 | + | } | |
| 315 | + | second := []int64{100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 1_000, 1_000} | |
| 316 | + | all, _, _, runP50s := aggregateEventResults([]runResult{ | |
| 317 | + | {EndToEnd: first, Event: distribution{P50: 1}}, | |
| 318 | + | {EndToEnd: second, Event: distribution{P50: 100}}, | |
| 319 | + | }) | |
| 320 | + | if got, dispersion := percentiles(all), percentiles(runP50s); got.Count != 30 || got.P99 != 1_000 || dispersion.Count != 2 || dispersion.P99 != 1 { | |
| 321 | + | t.Fatalf("event aggregation=%+v run dispersion=%+v", got, dispersion) | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | func TestHarnessRawRecordsContainEveryMarkWhileSamplesRemainFiltered(t *testing.T) { | |
| 326 | + | dir := t.TempDir() | |
| 327 | + | raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |
| 328 | + | if err != nil { | |
| 329 | + | t.Fatal(err) | |
| 330 | + | } | |
| 331 | + | h := defaultHarness() | |
| 332 | + | h.clock, h.launcher = &fakeClock{}, &fakeLauncher{} | |
| 333 | + | result, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "raw-complete", Roles: []string{"daemon", "client"}}, 1, raw) | |
| 334 | + | if err != nil { | |
| 335 | + | t.Fatal(err) | |
| 336 | + | } | |
| 337 | + | if err := raw.Close(); err != nil { | |
| 338 | + | t.Fatal(err) | |
| 339 | + | } | |
| 340 | + | marks := readHarnessMarks(t, filepath.Join(dir, "raw.jsonl")) | |
| 341 | + | if got, want := len(marks), 2*(result.Samples+1); got != want { | |
| 342 | + | t.Fatalf("raw marks=%d, want warmup plus every measured boundary=%d", got, want) | |
| 343 | + | } | |
| 344 | + | for i, mark := range marks[:2] { | |
| 345 | + | if mark.Sequence != 1 || !mark.Valid || []string{"input_injected", "terminal_flushed"}[i] != mark.Kind { | |
| 346 | + | t.Fatalf("warmup mark %d=%+v", i, mark) | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | for sequence := uint64(2); sequence <= uint64(result.Samples+1); sequence++ { | |
| 350 | + | if marks[2*(sequence-1)].Sequence != sequence || marks[2*(sequence-1)+1].Sequence != sequence { | |
| 351 | + | t.Fatalf("raw marks did not retain both boundaries for sequence %d: %+v", sequence, marks) | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func TestHarnessPersistsFailedFlushDiagnostics(t *testing.T) { | |
| 357 | + | dir := t.TempDir() | |
| 358 | + | raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |
| 359 | + | if err != nil { | |
| 360 | + | t.Fatal(err) | |
| 361 | + | } | |
| 362 | + | failure := errors.New("flush failed") | |
| 363 | + | h := defaultHarness() | |
| 364 | + | h.clock, h.launcher = &fakeClock{}, failedFlushLauncher{err: failure} | |
| 365 | + | _, err = h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "failed-flush", Roles: []string{"daemon", "client"}}, 1, raw) | |
| 366 | + | if !errors.Is(err, failure) { | |
| 367 | + | t.Fatalf("run error=%v, want failed flush", err) | |
| 368 | + | } | |
| 369 | + | if err := raw.Close(); err != nil { | |
| 370 | + | t.Fatal(err) | |
| 371 | + | } | |
| 372 | + | marks := readHarnessMarks(t, filepath.Join(dir, "raw.jsonl")) | |
| 373 | + | if got, want := len(marks), 4; got != want { | |
| 374 | + | t.Fatalf("raw marks=%d, want warmup and failed measured pair=%d: %+v", got, want, marks) | |
| 375 | + | } | |
| 376 | + | failed := marks[len(marks)-1] | |
| 377 | + | if failed.Sequence != 2 || failed.Kind != "terminal_flushed" || failed.Valid { | |
| 378 | + | t.Fatalf("failed flush diagnostic=%+v", failed) | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | func TestHarnessSchedulesMeasuredEventsAcrossFullInterval(t *testing.T) { | |
| 383 | + | dir := t.TempDir() | |
| 384 | + | raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |
| 385 | + | if err != nil { | |
| 386 | + | t.Fatal(err) | |
| 387 | + | } | |
| 388 | + | closeTestFile(t, raw) | |
| 389 | + | h := defaultHarness() | |
| 390 | + | h.clock, h.launcher = &fakeClock{}, &fakeLauncher{} | |
| 391 | + | result, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "schedule", Roles: []string{"daemon", "client"}}, 1, raw) | |
| 392 | + | if err != nil { | |
| 393 | + | t.Fatal(err) | |
| 394 | + | } | |
| 395 | + | if result.Samples < minimumInIntervalEventSamples || result.Cadence.Count != result.Samples-1 || result.MaxGap <= 0 { | |
| 396 | + | t.Fatalf("result did not include repeated measured events: %+v", result) | |
| 397 | + | } | |
| 398 | + | if err := raw.Close(); err != nil { | |
| 399 | + | t.Fatal(err) | |
| 400 | + | } | |
| 401 | + | records, err := readJSONL(filepath.Join(dir, "raw.jsonl")) | |
| 402 | + | if err != nil { | |
| 403 | + | t.Fatal(err) | |
| 404 | + | } | |
| 405 | + | if want := result.Samples*2 + 2; len(records) != want { | |
| 406 | + | t.Fatalf("raw records=%d, want %d warmup and measured boundaries", len(records), want) | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | func TestHarnessPreservesWorkloadAndCleanupErrors(t *testing.T) { | |
| 411 | + | dir := t.TempDir() | |
| 412 | + | raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |
| 413 | + | if err != nil { | |
| 414 | + | t.Fatal(err) | |
| 415 | + | } | |
| 416 | + | closeTestFile(t, raw) | |
| 417 | + | primary := errors.New("primary workload failure") | |
| 418 | + | cleanup := errors.New("daemon cleanup failure") | |
| 419 | + | launcher := &fakeLauncher{ | |
| 420 | + | measureErr: map[string]error{"client": primary}, | |
| 421 | + | closeErr: map[string]error{"daemon": cleanup}, | |
| 422 | + | } | |
| 423 | + | h := defaultHarness() | |
| 424 | + | h.clock, h.launcher = &fakeClock{}, launcher | |
| 425 | + | _, err = h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, scenario{ID: "errors", Roles: []string{"daemon", "client"}}, 1, raw) | |
| 426 | + | if !errors.Is(err, primary) || !errors.Is(err, cleanup) { | |
| 427 | + | t.Fatalf("workload and cleanup errors were not both preserved: %v", err) | |
| 428 | + | } | |
| 429 | + | if got, want := err.Error(), primary.Error()+"\nclose daemon role: "+cleanup.Error(); got != want { | |
| 430 | + | t.Fatalf("error ordering=%q want %q", got, want) | |
| 431 | + | } | |
| 432 | + | for i, p := range launcher.process { | |
| 433 | + | if !p.closed { | |
| 434 | + | t.Fatalf("role %s was not closed", launcher.mappings[i].Role) | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | func TestHarnessCleansRunDirectoryOnProcessFailure(t *testing.T) { | |
| 440 | + | dir := t.TempDir() | |
| 441 | + | raw, err := os.OpenFile(filepath.Join(dir, "raw-harness.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) | |
| 442 | + | if err != nil { | |
| 443 | + | t.Fatal(err) | |
| 444 | + | } | |
| 445 | + | closeTestFile(t, raw) | |
| 446 | + | launcher := &fakeLauncher{} | |
| 447 | + | h := defaultHarness() | |
| 448 | + | h.clock = &fakeClock{} | |
| 449 | + | // The client is the second role and fails after the daemon has started. | |
| 450 | + | h.launcher = &failingLauncher{fakeLauncher: launcher} | |
| 451 | + | o := options{vevBin: "ignored", out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions} | |
| 452 | + | if _, err := h.runOne(o, manifest{}, scenario{ID: "cleanup", Roles: []string{"daemon", "client"}}, 1, raw); err == nil { | |
| 453 | + | t.Fatal("failed process accepted") | |
| 454 | + | } | |
| 455 | + | if _, err := os.Stat(filepath.Join(dir, "cleanup-run-001")); !os.IsNotExist(err) { | |
| 456 | + | t.Fatalf("failed run directory remains: %v", err) | |
| 457 | + | } | |
| 458 | + | } | |
A cmd/vev-perf-harness/test_helpers_test.go +341 -0
| @@ -0,0 +1,341 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type fakeClock struct{ tick int64 } | |
| 15 | + | ||
| 16 | + | func (c *fakeClock) Now() int64 { c.tick += 10; return c.tick } | |
| 17 | + | ||
| 18 | + | func (c *fakeClock) Sleep(d time.Duration) { | |
| 19 | + | c.tick += d.Nanoseconds() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | type fakeUDPNetem struct { | |
| 23 | + | port int | |
| 24 | + | closed bool | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (n *fakeUDPNetem) Port() int { return n.port } | |
| 28 | + | ||
| 29 | + | func (n *fakeUDPNetem) Close() error { n.closed = true; return nil } | |
| 30 | + | ||
| 31 | + | type fakeProcess struct { | |
| 32 | + | fail bool | |
| 33 | + | measureErr error | |
| 34 | + | closeErr error | |
| 35 | + | warmups [][]byte | |
| 36 | + | measures [][]byte | |
| 37 | + | closed bool | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (p *fakeProcess) Warmup(input []byte) error { | |
| 41 | + | p.warmups = append(p.warmups, append([]byte(nil), input...)) | |
| 42 | + | return nil | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (p *fakeProcess) Measure(input []byte, injected, flush func() error) error { | |
| 46 | + | p.measures = append(p.measures, append([]byte(nil), input...)) | |
| 47 | + | if p.measureErr != nil { | |
| 48 | + | return p.measureErr | |
| 49 | + | } | |
| 50 | + | if p.fail { | |
| 51 | + | return errors.New("measure failure") | |
| 52 | + | } | |
| 53 | + | if err := injected(); err != nil { | |
| 54 | + | return err | |
| 55 | + | } | |
| 56 | + | return flush() | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (p *fakeProcess) Close() error { p.closed = true; return p.closeErr } | |
| 60 | + | ||
| 61 | + | type fakeLauncher struct { | |
| 62 | + | mappings []processMapping | |
| 63 | + | args [][]string | |
| 64 | + | commands []roleCommand | |
| 65 | + | process []*fakeProcess | |
| 66 | + | measureErr map[string]error | |
| 67 | + | closeErr map[string]error | |
| 68 | + | manifestPresent bool | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (l *fakeLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) { | |
| 72 | + | l.mappings = append(l.mappings, m) | |
| 73 | + | l.args = append(l.args, append([]string(nil), command.Args...)) | |
| 74 | + | l.commands = append(l.commands, command) | |
| 75 | + | _, e := os.Stat(filepath.Join(filepath.Dir(m.TracePath), "manifest.json")) | |
| 76 | + | l.manifestPresent = e == nil | |
| 77 | + | p := &fakeProcess{measureErr: l.measureErr[m.Role], closeErr: l.closeErr[m.Role]} | |
| 78 | + | l.process = append(l.process, p) | |
| 79 | + | return p, nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | type lateFlushProcess struct{ clock *fakeClock } | |
| 83 | + | ||
| 84 | + | func (*lateFlushProcess) Warmup([]byte) error { return nil } | |
| 85 | + | ||
| 86 | + | func (p *lateFlushProcess) Measure(_ []byte, injected, flushed func() error) error { | |
| 87 | + | if err := injected(); err != nil { | |
| 88 | + | return err | |
| 89 | + | } | |
| 90 | + | p.clock.tick += minimumDuration.Nanoseconds() | |
| 91 | + | return flushed() | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (*lateFlushProcess) Close() error { return nil } | |
| 95 | + | ||
| 96 | + | type insufficientEventLauncher struct{ clock *fakeClock } | |
| 97 | + | ||
| 98 | + | func (l insufficientEventLauncher) Launch(m processMapping, _ roleCommand) (launchedProcess, error) { | |
| 99 | + | if m.Role == "client" { | |
| 100 | + | return &lateFlushProcess{clock: l.clock}, nil | |
| 101 | + | } | |
| 102 | + | return &fakeProcess{}, nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | type failedFlushProcess struct{ err error } | |
| 106 | + | ||
| 107 | + | func (*failedFlushProcess) Warmup([]byte) error { return nil } | |
| 108 | + | ||
| 109 | + | func (p *failedFlushProcess) Measure(_ []byte, injected, _ func() error) error { | |
| 110 | + | if err := injected(); err != nil { | |
| 111 | + | return err | |
| 112 | + | } | |
| 113 | + | return p.err | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func (*failedFlushProcess) Close() error { return nil } | |
| 117 | + | ||
| 118 | + | type failedFlushLauncher struct{ err error } | |
| 119 | + | ||
| 120 | + | func (l failedFlushLauncher) Launch(m processMapping, _ roleCommand) (launchedProcess, error) { | |
| 121 | + | if m.Role == "client" { | |
| 122 | + | return &failedFlushProcess{err: l.err}, nil | |
| 123 | + | } | |
| 124 | + | return &fakeProcess{}, nil | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func closeTestFile(t *testing.T, f *os.File) { | |
| 128 | + | t.Helper() | |
| 129 | + | t.Cleanup(func() { | |
| 130 | + | if err := f.Close(); err != nil && !errors.Is(err, os.ErrClosed) { | |
| 131 | + | t.Error(err) | |
| 132 | + | } | |
| 133 | + | }) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func removeTestTree(t *testing.T, path string) { | |
| 137 | + | t.Helper() | |
| 138 | + | t.Cleanup(func() { | |
| 139 | + | if err := os.RemoveAll(path); err != nil { | |
| 140 | + | t.Error(err) | |
| 141 | + | } | |
| 142 | + | }) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func readHarnessMarks(t *testing.T, path string) []harnessMark { | |
| 146 | + | t.Helper() | |
| 147 | + | records, err := readJSONL(path) | |
| 148 | + | if err != nil { | |
| 149 | + | t.Fatal(err) | |
| 150 | + | } | |
| 151 | + | marks := make([]harnessMark, len(records)) | |
| 152 | + | for i, record := range records { | |
| 153 | + | encoded, err := json.Marshal(record) | |
| 154 | + | if err != nil { | |
| 155 | + | t.Fatal(err) | |
| 156 | + | } | |
| 157 | + | if err := json.Unmarshal(encoded, &marks[i]); err != nil { | |
| 158 | + | t.Fatal(err) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | return marks | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | type fakePTY struct{ writes [][]byte } | |
| 165 | + | ||
| 166 | + | func (p *fakePTY) Read([]byte) (int, error) { return 0, os.ErrClosed } | |
| 167 | + | ||
| 168 | + | func (p *fakePTY) Write(b []byte) (int, error) { | |
| 169 | + | p.writes = append(p.writes, append([]byte(nil), b...)) | |
| 170 | + | return len(b), nil | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (*fakePTY) Close() error { return nil } | |
| 174 | + | ||
| 175 | + | type stagedPTY struct { | |
| 176 | + | allow <-chan struct{} | |
| 177 | + | writes chan []byte | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func (*stagedPTY) Read([]byte) (int, error) { return 0, os.ErrClosed } | |
| 181 | + | ||
| 182 | + | func (p *stagedPTY) Write(b []byte) (int, error) { | |
| 183 | + | if p.allow != nil { | |
| 184 | + | <-p.allow | |
| 185 | + | } | |
| 186 | + | p.writes <- append([]byte(nil), b...) | |
| 187 | + | return len(b), nil | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func (*stagedPTY) Close() error { return nil } | |
| 191 | + | ||
| 192 | + | type fakeOutput struct { | |
| 193 | + | bytes.Buffer | |
| 194 | + | syncs int | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (o *fakeOutput) Sync() error { o.syncs++; return nil } | |
| 198 | + | ||
| 199 | + | func (*fakeOutput) Close() error { return nil } | |
| 200 | + | ||
| 201 | + | type orderedPTY struct { | |
| 202 | + | write func([]byte) | |
| 203 | + | close func() | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func (*orderedPTY) Read([]byte) (int, error) { return 0, os.ErrClosed } | |
| 207 | + | ||
| 208 | + | func (p *orderedPTY) Write(b []byte) (int, error) { | |
| 209 | + | if p.write != nil { | |
| 210 | + | p.write(b) | |
| 211 | + | } | |
| 212 | + | return len(b), nil | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (p *orderedPTY) Close() error { | |
| 216 | + | p.close() | |
| 217 | + | return nil | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | type orderedOutput struct{ close func() } | |
| 221 | + | ||
| 222 | + | func (*orderedOutput) Write(b []byte) (int, error) { return len(b), nil } | |
| 223 | + | ||
| 224 | + | func (*orderedOutput) Sync() error { return nil } | |
| 225 | + | ||
| 226 | + | func (o *orderedOutput) Close() error { | |
| 227 | + | o.close() | |
| 228 | + | return nil | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | type failingLauncher struct{ *fakeLauncher } | |
| 232 | + | ||
| 233 | + | func (l *failingLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) { | |
| 234 | + | p, err := l.fakeLauncher.Launch(m, command) | |
| 235 | + | if err != nil { | |
| 236 | + | return nil, err | |
| 237 | + | } | |
| 238 | + | if m.Role == "client" { | |
| 239 | + | fake, ok := p.(*fakeProcess) | |
| 240 | + | if !ok { | |
| 241 | + | return nil, fmt.Errorf("fake launcher returned %T, want *fakeProcess", p) | |
| 242 | + | } | |
| 243 | + | fake.fail = true | |
| 244 | + | } | |
| 245 | + | return p, nil | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func equalStrings(a, b []string) bool { | |
| 249 | + | if len(a) != len(b) { | |
| 250 | + | return false | |
| 251 | + | } | |
| 252 | + | for i := range a { | |
| 253 | + | if a[i] != b[i] { | |
| 254 | + | return false | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | return true | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | type traceLauncher struct{ fakeLauncher } | |
| 261 | + | ||
| 262 | + | func (l *traceLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) { | |
| 263 | + | p, err := l.fakeLauncher.Launch(m, command) | |
| 264 | + | if err != nil { | |
| 265 | + | return nil, err | |
| 266 | + | } | |
| 267 | + | f, err := os.OpenFile(m.TracePath, os.O_WRONLY|os.O_APPEND, 0o600) | |
| 268 | + | if err != nil { | |
| 269 | + | return nil, err | |
| 270 | + | } | |
| 271 | + | for i, pair := range spanPairs { | |
| 272 | + | for n, kind := range []string{pair.start, pair.end} { | |
| 273 | + | b, err := json.Marshal(traceRecord{ProcessID: m.ProcessID, Component: m.Role, Scenario: m.Scenario, Run: uint64(m.Run), Sequence: uint64(i + 1), RequestID: 1, Epoch: 1, Kind: kind, Tick: int64(i*20 + n*5), Valid: true}) | |
| 274 | + | if err != nil { | |
| 275 | + | return nil, errors.Join(err, f.Close()) | |
| 276 | + | } | |
| 277 | + | if _, err := f.Write(append(b, '\n')); err != nil { | |
| 278 | + | return nil, errors.Join(err, f.Close()) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | if err := f.Close(); err != nil { | |
| 283 | + | return nil, err | |
| 284 | + | } | |
| 285 | + | return p, nil | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | type closeTraceProcess struct { | |
| 289 | + | *fakeProcess | |
| 290 | + | traceEnd func() error | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | func (p *closeTraceProcess) Close() error { | |
| 294 | + | p.closed = true | |
| 295 | + | return errors.Join(p.closeErr, p.traceEnd()) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | type closeTraceLauncher struct{ fakeLauncher } | |
| 299 | + | ||
| 300 | + | func (l *closeTraceLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) { | |
| 301 | + | p, err := l.fakeLauncher.Launch(m, command) | |
| 302 | + | if err != nil { | |
| 303 | + | return nil, err | |
| 304 | + | } | |
| 305 | + | start := traceRecord{ProcessID: m.ProcessID, Component: m.Role, Scenario: m.Scenario, Run: uint64(m.Run), Sequence: 1, RequestID: 1, Epoch: 1, Kind: "adapter_receive_start", Tick: 10, Valid: true} | |
| 306 | + | if err := appendTraceRecord(m.TracePath, start); err != nil { | |
| 307 | + | return nil, err | |
| 308 | + | } | |
| 309 | + | fake, ok := p.(*fakeProcess) | |
| 310 | + | if !ok { | |
| 311 | + | return nil, fmt.Errorf("fake launcher returned %T, want *fakeProcess", p) | |
| 312 | + | } | |
| 313 | + | return &closeTraceProcess{fakeProcess: fake, traceEnd: func() error { | |
| 314 | + | return appendTraceRecord(m.TracePath, traceRecord{ProcessID: m.ProcessID, Component: m.Role, Scenario: m.Scenario, Run: uint64(m.Run), Sequence: 1, RequestID: 1, Epoch: 1, Kind: "adapter_receive_end", Tick: 20, Valid: true}) | |
| 315 | + | }}, nil | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func appendTraceRecord(path string, record traceRecord) error { | |
| 319 | + | f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600) | |
| 320 | + | if err != nil { | |
| 321 | + | return err | |
| 322 | + | } | |
| 323 | + | b, err := json.Marshal(record) | |
| 324 | + | if err != nil { | |
| 325 | + | return errors.Join(err, f.Close()) | |
| 326 | + | } | |
| 327 | + | if _, err := f.Write(append(b, '\n')); err != nil { | |
| 328 | + | return errors.Join(err, f.Close()) | |
| 329 | + | } | |
| 330 | + | return f.Close() | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // boundedLocalConcurrentTrace is an excerpt from the second run of the | |
| 334 | + | // bounded local public-CLI smoke. Receive sequence 3 is in flight while the | |
| 335 | + | // same process serializes sequence 4; shared-process records therefore cannot | |
| 336 | + | // have a global sequence ordering requirement. | |
| 337 | + | const boundedLocalConcurrentTrace = `{"schema":1,"process_id":"1x4-idle-local-r002-client-02","component":"ipc","scenario":"1x4-idle-local","run":2,"sequence":3,"request_id":3,"epoch":3,"kind":"adapter_receive_start","tick":1783902621495284782,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true} | |
| 338 | + | {"schema":1,"process_id":"1x4-idle-local-r002-client-02","component":"ipc","scenario":"1x4-idle-local","run":2,"sequence":4,"request_id":4,"epoch":4,"kind":"adapter_send_start","tick":1783902621495364552,"bytes":96,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true} | |
| 339 | + | {"schema":1,"process_id":"1x4-idle-local-r002-client-02","component":"ipc","scenario":"1x4-idle-local","run":2,"sequence":4,"request_id":4,"epoch":4,"kind":"adapter_send_end","tick":1783902621495386452,"bytes":96,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true} | |
| 340 | + | {"schema":1,"process_id":"1x4-idle-local-r002-client-02","component":"ipc","scenario":"1x4-idle-local","run":2,"sequence":3,"request_id":3,"epoch":3,"kind":"adapter_receive_end","tick":1783902621495463332,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true} | |
| 341 | + | ` | |
A cmd/vev-perf-harness/udp_netem.go +273 -0
| @@ -0,0 +1,273 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "container/heap" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "net" | |
| 9 | + | "os" | |
| 10 | + | "strconv" | |
| 11 | + | "strings" | |
| 12 | + | "sync" | |
| 13 | + | "sync/atomic" | |
| 14 | + | "time" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // udpNetem is a harness-owned, user-space UDP relay. It sits between the | |
| 18 | + | // public dgram client and vev's public _udp-proxy readiness address, so fixture | |
| 19 | + | // impairment is real network carriage rather than an ignored vev option. | |
| 20 | + | type udpNetem interface { | |
| 21 | + | Port() int | |
| 22 | + | Close() error | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | type udpNetemFactory func(udpNetemConfig) (udpNetem, error) | |
| 26 | + | ||
| 27 | + | type udpNetemConfig struct { | |
| 28 | + | // RTT is split equally across both relay directions. | |
| 29 | + | RTT time.Duration | |
| 30 | + | LossPercent int | |
| 31 | + | TargetPath string | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // udpNetemQueueCapacity bounds both packets waiting to be scheduled and | |
| 35 | + | // packets waiting for their delivery deadline. When it is full, the relay | |
| 36 | + | // drops the arriving packet and records it in queueOverflowDrops. | |
| 37 | + | const udpNetemQueueCapacity = 256 | |
| 38 | + | ||
| 39 | + | type scheduledPacket struct { | |
| 40 | + | packet []byte | |
| 41 | + | to net.Addr | |
| 42 | + | due time.Time | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | type scheduledPackets []scheduledPacket | |
| 46 | + | ||
| 47 | + | func (p scheduledPackets) Len() int { return len(p) } | |
| 48 | + | func (p scheduledPackets) Less(i, j int) bool { return p[i].due.Before(p[j].due) } | |
| 49 | + | func (p scheduledPackets) Swap(i, j int) { p[i], p[j] = p[j], p[i] } | |
| 50 | + | func (p *scheduledPackets) Push(x any) { | |
| 51 | + | packet, ok := x.(scheduledPacket) | |
| 52 | + | if !ok { | |
| 53 | + | panic(fmt.Sprintf("scheduled packet type %T", x)) | |
| 54 | + | } | |
| 55 | + | *p = append(*p, packet) | |
| 56 | + | } | |
| 57 | + | func (p *scheduledPackets) Pop() any { | |
| 58 | + | old := *p | |
| 59 | + | n := len(old) | |
| 60 | + | item := old[n-1] | |
| 61 | + | *p = old[:n-1] | |
| 62 | + | return item | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | type udpNetemRelay struct { | |
| 66 | + | config udpNetemConfig | |
| 67 | + | conn net.PacketConn | |
| 68 | + | done chan struct{} | |
| 69 | + | wg sync.WaitGroup | |
| 70 | + | once sync.Once | |
| 71 | + | closeErr error | |
| 72 | + | ||
| 73 | + | // slots bounds queued and deadline-pending packets together. The reader and | |
| 74 | + | // the sole scheduler are the relay's only goroutines. | |
| 75 | + | slots chan struct{} | |
| 76 | + | queue chan scheduledPacket | |
| 77 | + | queueOverflowDrops atomic.Uint64 | |
| 78 | + | deliveryErrors atomic.Uint64 | |
| 79 | + | ||
| 80 | + | mu sync.Mutex | |
| 81 | + | client, dst net.Addr | |
| 82 | + | packet uint64 | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func newUDPNetem(config udpNetemConfig) (udpNetem, error) { | |
| 86 | + | if config.RTT < 0 || config.LossPercent < 0 || config.LossPercent > 100 || config.TargetPath == "" { | |
| 87 | + | return nil, fmt.Errorf("invalid UDP netem fixture: rtt=%s loss=%d target=%q", config.RTT, config.LossPercent, config.TargetPath) | |
| 88 | + | } | |
| 89 | + | var lc net.ListenConfig | |
| 90 | + | conn, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0") | |
| 91 | + | if err != nil { | |
| 92 | + | return nil, err | |
| 93 | + | } | |
| 94 | + | r := &udpNetemRelay{ | |
| 95 | + | config: config, | |
| 96 | + | conn: conn, | |
| 97 | + | done: make(chan struct{}), | |
| 98 | + | slots: make(chan struct{}, udpNetemQueueCapacity), | |
| 99 | + | queue: make(chan scheduledPacket, udpNetemQueueCapacity), | |
| 100 | + | } | |
| 101 | + | r.wg.Add(2) | |
| 102 | + | go r.run() | |
| 103 | + | go r.schedule() | |
| 104 | + | return r, nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (r *udpNetemRelay) Port() int { | |
| 108 | + | addr, ok := r.conn.LocalAddr().(*net.UDPAddr) | |
| 109 | + | if !ok { | |
| 110 | + | return 0 | |
| 111 | + | } | |
| 112 | + | return addr.Port | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (r *udpNetemRelay) Close() error { | |
| 116 | + | r.once.Do(func() { | |
| 117 | + | close(r.done) | |
| 118 | + | r.closeErr = r.conn.Close() | |
| 119 | + | r.wg.Wait() | |
| 120 | + | if drops := r.queueOverflowDrops.Load(); drops != 0 { | |
| 121 | + | r.closeErr = errors.Join(r.closeErr, fmt.Errorf("udp netem queue overflow drops: %d", drops)) | |
| 122 | + | } | |
| 123 | + | if writes := r.deliveryErrors.Load(); writes != 0 { | |
| 124 | + | r.closeErr = errors.Join(r.closeErr, fmt.Errorf("udp netem delivery errors: %d", writes)) | |
| 125 | + | } | |
| 126 | + | }) | |
| 127 | + | return r.closeErr | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (r *udpNetemRelay) run() { | |
| 131 | + | defer r.wg.Done() | |
| 132 | + | buf := make([]byte, 64*1024) | |
| 133 | + | for { | |
| 134 | + | n, from, err := r.conn.ReadFrom(buf) | |
| 135 | + | if err != nil { | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | to, ok := r.destination(from) | |
| 139 | + | if !ok || r.drop() { | |
| 140 | + | continue | |
| 141 | + | } | |
| 142 | + | // Copy because PacketConn reuses buf on its next read. | |
| 143 | + | r.enqueue(scheduledPacket{packet: append([]byte(nil), buf[:n]...), to: to, due: time.Now().Add(r.config.RTT / 2)}) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // enqueue implements the explicit overflow policy: never block the reader; | |
| 148 | + | // drop a packet if the bounded scheduler capacity has been reached. | |
| 149 | + | func (r *udpNetemRelay) enqueue(packet scheduledPacket) bool { | |
| 150 | + | select { | |
| 151 | + | case r.slots <- struct{}{}: | |
| 152 | + | case <-r.done: | |
| 153 | + | return false | |
| 154 | + | default: | |
| 155 | + | r.queueOverflowDrops.Add(1) | |
| 156 | + | return false | |
| 157 | + | } | |
| 158 | + | select { | |
| 159 | + | case r.queue <- packet: | |
| 160 | + | return true | |
| 161 | + | case <-r.done: | |
| 162 | + | <-r.slots | |
| 163 | + | return false | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (r *udpNetemRelay) schedule() { | |
| 168 | + | defer r.wg.Done() | |
| 169 | + | pending := scheduledPackets{} | |
| 170 | + | heap.Init(&pending) | |
| 171 | + | timer := time.NewTimer(time.Hour) | |
| 172 | + | if !timer.Stop() { | |
| 173 | + | <-timer.C | |
| 174 | + | } | |
| 175 | + | defer timer.Stop() | |
| 176 | + | var timerC <-chan time.Time | |
| 177 | + | resetTimer := func() { | |
| 178 | + | if timerC != nil && !timer.Stop() { | |
| 179 | + | select { | |
| 180 | + | case <-timer.C: | |
| 181 | + | default: | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | if pending.Len() == 0 { | |
| 185 | + | timerC = nil | |
| 186 | + | return | |
| 187 | + | } | |
| 188 | + | delay := time.Until(pending[0].due) | |
| 189 | + | if delay < 0 { | |
| 190 | + | delay = 0 | |
| 191 | + | } | |
| 192 | + | timer.Reset(delay) | |
| 193 | + | timerC = timer.C | |
| 194 | + | } | |
| 195 | + | for { | |
| 196 | + | resetTimer() | |
| 197 | + | select { | |
| 198 | + | case <-r.done: | |
| 199 | + | return | |
| 200 | + | case packet := <-r.queue: | |
| 201 | + | heap.Push(&pending, packet) | |
| 202 | + | case <-timerC: | |
| 203 | + | now := time.Now() | |
| 204 | + | for pending.Len() > 0 && !pending[0].due.After(now) { | |
| 205 | + | packet, ok := heap.Pop(&pending).(scheduledPacket) | |
| 206 | + | if !ok { | |
| 207 | + | panic("scheduled packet heap contained unexpected type") | |
| 208 | + | } | |
| 209 | + | select { | |
| 210 | + | case <-r.done: | |
| 211 | + | return | |
| 212 | + | default: | |
| 213 | + | if _, err := r.conn.WriteTo(packet.packet, packet.to); err != nil { | |
| 214 | + | select { | |
| 215 | + | case <-r.done: | |
| 216 | + | default: | |
| 217 | + | r.deliveryErrors.Add(1) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | <-r.slots | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func (r *udpNetemRelay) destination(from net.Addr) (net.Addr, bool) { | |
| 228 | + | r.mu.Lock() | |
| 229 | + | defer r.mu.Unlock() | |
| 230 | + | if r.dst == nil { | |
| 231 | + | dst, err := udpNetemTarget(r.config.TargetPath) | |
| 232 | + | if err != nil { | |
| 233 | + | return nil, false | |
| 234 | + | } | |
| 235 | + | r.dst = dst | |
| 236 | + | } | |
| 237 | + | if sameUDPAddress(from, r.dst) { | |
| 238 | + | return r.client, r.client != nil | |
| 239 | + | } | |
| 240 | + | r.client = from | |
| 241 | + | return r.dst, true | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func (r *udpNetemRelay) drop() bool { | |
| 245 | + | r.mu.Lock() | |
| 246 | + | defer r.mu.Unlock() | |
| 247 | + | r.packet++ | |
| 248 | + | // The deterministic cycle makes the configured percentage auditable: a 1% | |
| 249 | + | // fixture drops exactly one carriage packet in every 100 attempts. | |
| 250 | + | return r.config.LossPercent > 0 && int((r.packet-1)%100) < r.config.LossPercent | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func udpNetemTarget(path string) (net.Addr, error) { | |
| 254 | + | line, err := os.ReadFile(path) | |
| 255 | + | if err != nil { | |
| 256 | + | return nil, err | |
| 257 | + | } | |
| 258 | + | fields := strings.Fields(string(line)) | |
| 259 | + | if len(fields) != 3 || fields[0] != "VEV-UDP" { | |
| 260 | + | return nil, errors.New("invalid UDP proxy readiness") | |
| 261 | + | } | |
| 262 | + | port, err := strconv.Atoi(fields[1]) | |
| 263 | + | if err != nil || port < 1 || port > 65535 || fields[2] == "" { | |
| 264 | + | return nil, errors.New("invalid UDP proxy readiness") | |
| 265 | + | } | |
| 266 | + | return &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: port}, nil | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func sameUDPAddress(a, b net.Addr) bool { | |
| 270 | + | ua, aOK := a.(*net.UDPAddr) | |
| 271 | + | ub, bOK := b.(*net.UDPAddr) | |
| 272 | + | return aOK && bOK && ua.Port == ub.Port && ua.IP.Equal(ub.IP) | |
| 273 | + | } | |
A cmd/vev-perf-harness/udp_netem_test.go +149 -0
| @@ -0,0 +1,149 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strconv" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func closeUDPFixture(t *testing.T, c interface{ Close() error }) { | |
| 14 | + | t.Helper() | |
| 15 | + | t.Cleanup(func() { | |
| 16 | + | if err := c.Close(); err != nil { | |
| 17 | + | t.Error(err) | |
| 18 | + | } | |
| 19 | + | }) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestUDPNetemDropsWhenBoundedSchedulerIsFull(t *testing.T) { | |
| 23 | + | r := &udpNetemRelay{ | |
| 24 | + | done: make(chan struct{}), | |
| 25 | + | slots: make(chan struct{}, udpNetemQueueCapacity), | |
| 26 | + | queue: make(chan scheduledPacket, udpNetemQueueCapacity), | |
| 27 | + | } | |
| 28 | + | packet := scheduledPacket{packet: []byte("bounded"), to: &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1}} | |
| 29 | + | for range udpNetemQueueCapacity { | |
| 30 | + | if !r.enqueue(packet) { | |
| 31 | + | t.Fatal("enqueue() dropped packet before scheduler capacity was full") | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | if r.enqueue(packet) { | |
| 35 | + | t.Fatal("enqueue() accepted packet beyond scheduler capacity") | |
| 36 | + | } | |
| 37 | + | if got := r.queueOverflowDrops.Load(); got != 1 { | |
| 38 | + | t.Fatalf("queue overflow drops=%d, want 1", got) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestUDPNetemCloseRejectsQueueOverflow(t *testing.T) { | |
| 43 | + | netem, err := newUDPNetem(udpNetemConfig{TargetPath: filepath.Join(t.TempDir(), "target")}) | |
| 44 | + | if err != nil { | |
| 45 | + | t.Fatal(err) | |
| 46 | + | } | |
| 47 | + | relay, ok := netem.(*udpNetemRelay) | |
| 48 | + | if !ok { | |
| 49 | + | t.Fatalf("netem type=%T, want *udpNetemRelay", netem) | |
| 50 | + | } | |
| 51 | + | relay.queueOverflowDrops.Store(3) | |
| 52 | + | if err := netem.Close(); err == nil || err.Error() != "udp netem queue overflow drops: 3" { | |
| 53 | + | t.Fatalf("Close error=%v, want overflow rejection", err) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestUDPNetemExecutesRTTAndLossFixtures(t *testing.T) { | |
| 58 | + | fixtures := []struct { | |
| 59 | + | name string | |
| 60 | + | rtt time.Duration | |
| 61 | + | loss int | |
| 62 | + | sent int | |
| 63 | + | want int | |
| 64 | + | }{ | |
| 65 | + | {"25ms RTT", 25 * time.Millisecond, 0, 1, 1}, | |
| 66 | + | {"100ms RTT", 100 * time.Millisecond, 0, 1, 1}, | |
| 67 | + | {"zero loss", 0, 0, 10, 10}, | |
| 68 | + | {"one percent loss", 0, 1, 100, 99}, | |
| 69 | + | } | |
| 70 | + | for _, tc := range fixtures { | |
| 71 | + | t.Run(tc.name, func(t *testing.T) { | |
| 72 | + | var lc net.ListenConfig | |
| 73 | + | target, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0") | |
| 74 | + | if err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | closeUDPFixture(t, target) | |
| 78 | + | targetAddr, ok := target.LocalAddr().(*net.UDPAddr) | |
| 79 | + | if !ok { | |
| 80 | + | t.Fatalf("target address type=%T, want *net.UDPAddr", target.LocalAddr()) | |
| 81 | + | } | |
| 82 | + | path := filepath.Join(t.TempDir(), "target") | |
| 83 | + | if err := os.WriteFile(path, []byte("VEV-UDP "+strconv.Itoa(targetAddr.Port)+" key\\n"), 0o600); err != nil { | |
| 84 | + | t.Fatal(err) | |
| 85 | + | } | |
| 86 | + | netem, err := newUDPNetem(udpNetemConfig{RTT: tc.rtt, LossPercent: tc.loss, TargetPath: path}) | |
| 87 | + | if err != nil { | |
| 88 | + | t.Fatal(err) | |
| 89 | + | } | |
| 90 | + | closeUDPFixture(t, netem) | |
| 91 | + | client, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0") | |
| 92 | + | if err != nil { | |
| 93 | + | t.Fatal(err) | |
| 94 | + | } | |
| 95 | + | closeUDPFixture(t, client) | |
| 96 | + | started := time.Now() | |
| 97 | + | for i := 0; i < tc.sent; i++ { | |
| 98 | + | if _, err := client.WriteTo([]byte{byte(i)}, &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: netem.Port()}); err != nil { | |
| 99 | + | t.Fatal(err) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | if err := target.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { | |
| 103 | + | t.Fatal(err) | |
| 104 | + | } | |
| 105 | + | got := 0 | |
| 106 | + | buf := make([]byte, 32) | |
| 107 | + | var relayAddr net.Addr | |
| 108 | + | for got < tc.want { | |
| 109 | + | if _, relayAddr, err = target.ReadFrom(buf); err != nil { | |
| 110 | + | t.Fatalf("received %d packets, want %d: %v", got, tc.want, err) | |
| 111 | + | } | |
| 112 | + | got++ | |
| 113 | + | } | |
| 114 | + | if tc.rtt > 0 { | |
| 115 | + | if elapsed := time.Since(started); elapsed < tc.rtt/2 { | |
| 116 | + | t.Fatalf("client-to-target netem delay=%s, want at least %s", elapsed, tc.rtt/2) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | if tc.loss == 0 { | |
| 120 | + | // The relay learns the client from the first carriage packet. Replying | |
| 121 | + | // through that same relay address verifies the target-to-client half | |
| 122 | + | // of the configured RTT as well. | |
| 123 | + | started = time.Now() | |
| 124 | + | if _, err := target.WriteTo([]byte("reply"), relayAddr); err != nil { | |
| 125 | + | t.Fatal(err) | |
| 126 | + | } | |
| 127 | + | if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { | |
| 128 | + | t.Fatal(err) | |
| 129 | + | } | |
| 130 | + | if _, _, err := client.ReadFrom(buf); err != nil { | |
| 131 | + | t.Fatalf("relay reply: %v", err) | |
| 132 | + | } | |
| 133 | + | if tc.rtt > 0 { | |
| 134 | + | if elapsed := time.Since(started); elapsed < tc.rtt/2 { | |
| 135 | + | t.Fatalf("target-to-client netem delay=%s, want at least %s", elapsed, tc.rtt/2) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | // Keep the deadline short: extra carriage would prove the exact 1%% | |
| 140 | + | // loss cycle was not enforced. | |
| 141 | + | if err := target.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); err != nil { | |
| 142 | + | t.Fatal(err) | |
| 143 | + | } | |
| 144 | + | if _, _, err := target.ReadFrom(buf); tc.want != tc.sent && err == nil { | |
| 145 | + | t.Fatalf("received more than %d packets with %d%% loss", tc.want, tc.loss) | |
| 146 | + | } | |
| 147 | + | }) | |
| 148 | + | } | |
| 149 | + | } | |
M docs/performance.md +63 -87
| @@ -1,103 +1,79 @@ | |||
| 1 | - | # Performance methodology (M5) | |
| 2 | - | ||
| 3 | - | This baseline measures in-process daemon work only. It deliberately does **not** | |
| 4 | - | measure coordinator behavior, chunk encoding, compression, GC tuning, or real | |
| 5 | - | network latency. Output metrics count frames and encoded output data produced | |
| 6 | - | by the daemon; snapshot writes and serialized bytes are local snapshot-store | |
| 7 | - | proxies. These metrics approximate local rendering, IPC payload, and persistence | |
| 8 | - | work, not a Unix socket, SSH, or UDP transport. Real transport impairment and | |
| 9 | - | latency benchmarks are deferred. | |
| 10 | - | ||
| 11 | - | ## Large-history daemon baseline | |
| 12 | - | ||
| 13 | - | Use a single iteration only as a smoke check; it is not suitable for drawing | |
| 14 | - | performance conclusions: | |
| 15 | - | ||
| 16 | - | ```sh | |
| 17 | - | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | |
| 18 | - | ``` | |
| 19 | - | ||
| 20 | - | For before/after comparison, collect repeated samples for the **full matrix** | |
| 21 | - | (all workloads and topologies), pinning CPU/governor and recording the host. | |
| 22 | - | The benchmark harness must be committed and present in **both** measured | |
| 23 | - | revisions: collect the baseline after the harness is introduced but before the | |
| 24 | - | optimization, then collect the optimized revision with that same harness. The | |
| 25 | - | fixed duration and count make the output suitable for `benchstat`: | |
| 26 | - | ||
| 27 | - | ```sh | |
| 28 | - | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1s -count=5 -benchmem > /tmp/vev-history-after.txt | |
| 29 | - | benchstat /tmp/vev-history-before.txt /tmp/vev-history-after.txt | |
| 1 | + | # Performance measurement | |
| 2 | + | ||
| 3 | + | ## S5a transport observability | |
| 4 | + | ||
| 5 | + | S5a is measurement-only. It records process-local runtime marks and harness-owned | |
| 6 | + | end-to-end boundaries without changing rendering, pacing, transport policy, wire | |
| 7 | + | bytes, or `ProtocolVersion`. The canonical matrix and workload/topology/ | |
| 8 | + | transport coverage are in [`testdata/perf/manifest.json`](../testdata/perf/manifest.json): | |
| 9 | + | `1x4`, `4x1`, `4x4`, and `8x1`, all at `120x40` with 10,000 rows per pane; | |
| 10 | + | idle, active/all/inactive output, interactive flood, copy search, resize sweep, | |
| 11 | + | snapshot/output/resize, and attach/restore/tab switch; local, SSH stdio, UDP | |
| 12 | + | baseline, UDP 25/100 ms, and UDP 0%/1% loss. It has 252 scenarios (4×9×7), | |
| 13 | + | with inapplicable cases required to be explicit. The seven transport entries are | |
| 14 | + | `local`, `ssh_stdio`, `udp_baseline`, `udp_loss_0pct`, `udp_25ms`, `udp_100ms`, | |
| 15 | + | and `udp_loss_1pct`. | |
| 16 | + | ||
| 17 | + | ### Trace schema and clock domains | |
| 18 | + | ||
| 19 | + | Every process JSONL record has exactly these fields: | |
| 20 | + | `schema` (uint), `process_id` (string), `component` (string), `scenario` | |
| 21 | + | (string), `run`, `sequence`, `request_id`, `epoch` (uint), `kind` (string), | |
| 22 | + | `tick` (int64), `bytes`, `fragments`, `retransmits`, `pending` (uint), | |
| 23 | + | `ack_rtt_nanos` (int64), and `valid` (bool). Schema 1 is represented by: | |
| 24 | + | ||
| 25 | + | ```json | |
| 26 | + | {"schema":1,"process_id":"p","component":"daemon","scenario":"s","run":1,"sequence":1,"request_id":1,"epoch":1,"kind":"diff_start","tick":0,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true} | |
| 30 | 27 | ``` | |
| 31 | 28 | ||
| 32 | - | The matrix exercises live paint, snapshot capture, copy-mode entry, copy search, | |
| 33 | - | and resize at `120x40` client geometry. Each pane is populated with 10,000 | |
| 34 | - | full-width deterministic history rows containing a stable `needle-<tab>-<row>` | |
| 35 | - | prefix and patterned text. The layouts are: | |
| 29 | + | `tick` is stamped only by the concrete JSONL observer using its process-local | |
| 30 | + | injected monotonic clock; producers supply no timestamp. The harness sets `VEV_PERF_TRACE`, | |
| 31 | + | `VEV_PERF_PROCESS_ID`, `VEV_PERF_SCENARIO`, and `VEV_PERF_RUN` for every | |
| 32 | + | launched role, identifying its exclusive trace path and process/scenario/run | |
| 33 | + | mapping. Components in one OS | |
| 34 | + | process share that observer. The harness clock is a separate monotonic domain | |
| 35 | + | and is the sole clock for input-injection → terminal-bytes-successfully-flushed | |
| 36 | + | end-to-end samples. Cross-process ticks are never ordered or subtracted; | |
| 37 | + | records correlate only by scenario/run/sequence/request/epoch IDs. | |
| 36 | 38 | ||
| 37 | - | - 1 tab x 1 pane (control) | |
| 38 | - | - 1 tab x 4 panes | |
| 39 | - | - 4 tabs x 1 pane | |
| 40 | - | - 4 tabs x 4 panes | |
| 41 | - | - 8 tabs x 1 pane | |
| 39 | + | The named process-local spans are capture, compose, diff, queue wait, | |
| 40 | + | ACK-blocked interval, emit, adapter send, and adapter receive. Their start/end | |
| 41 | + | marks are paired within one process and summarized by component/adapter with | |
| 42 | + | raw records, counts, p50/p95/p99, and max. Diagnostic fields (bytes, fragments, | |
| 43 | + | retransmits, pending, ACK RTT) remain separate from duration calculations. | |
| 42 | 44 | ||
| 43 | - | The fixture starts no PTY readers, schedulers, clocks, or transports. It primes | |
| 44 | - | the renderer shadow before timing; live paint then alternates fixed writes. The | |
| 45 | - | reported `outputframes/op` value counts output messages. `outputbytes/op` | |
| 46 | - | counts their encoded terminal data and excludes the 24-byte output header; | |
| 47 | - | `framepayloadbytes/op` counts the complete output payload including that header. | |
| 48 | - | `snapshotwrites/op` and `snapshotbytes/op` are local snapshot-store writes and | |
| 49 | - | serialized bytes. They must not be presented as network throughput or latency. | |
| 45 | + | ### Baseline procedure and validity | |
| 50 | 46 | ||
| 51 | - | Capture a pre-change result outside the repository, including Go and kernel | |
| 52 | - | context: | |
| 47 | + | Use the public CLI harness (not internal packages): | |
| 53 | 48 | ||
| 54 | 49 | ```sh | |
| 55 | - | { | |
| 56 | - | date -Is | |
| 57 | - | go version | |
| 58 | - | uname -a | |
| 59 | - | git rev-parse HEAD | |
| 60 | - | # Smoke only; do not use this result for comparisons. | |
| 61 | - | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | |
| 62 | - | # Repeated full matrix for every workload/topology used in conclusions. | |
| 63 | - | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1s -count=5 -benchmem | |
| 64 | - | } > /tmp/vev-history-before.txt 2>&1 | |
| 50 | + | go build -o /tmp/vev-perf ./ | |
| 51 | + | rm -rf testdata/perf/results | |
| 52 | + | go run ./cmd/vev-perf-harness --vev-bin /tmp/vev-perf \ | |
| 53 | + | --manifest testdata/perf/manifest.json --out testdata/perf/results \ | |
| 54 | + | --warmup 10s --duration 30s --repetitions 10 | |
| 65 | 55 | ``` | |
| 66 | 56 | ||
| 67 | - | ## Immutable document allocation gate | |
| 57 | + | The minimums are 30 seconds measured and 10 independent repetitions; warmup is | |
| 58 | + | excluded. During the measured interval the harness injects at most one event per | |
| 59 | + | second. At least 10 complete event pairs are required: both injection and | |
| 60 | + | successful terminal flush must fall inside the measured interval; straddling or | |
| 61 | + | failed events are retained only as raw diagnostics and are not samples. Each run must contain `manifest.json`, `raw-harness.jsonl`, per-process | |
| 62 | + | JSONL, `runs.json`, and `summary.json`. Reject results with missing input/flush | |
| 63 | + | boundaries, unmatched IDs, bad correlation, nonmonotonic same-process sequence, | |
| 64 | + | negative same-domain spans, fewer than 10 complete in-interval event pairs, | |
| 65 | + | invalid denominators, | |
| 66 | + | any cross-process tick arithmetic, or unmet duration/repetition minima. Raw | |
| 67 | + | JSONL and run manifests are the evidence files; no result authorizes a policy | |
| 68 | + | or optimization change. | |
| 68 | 69 | ||
| 69 | - | The copy-mode document retains an immutable view of existing scrollback rows and | |
| 70 | - | clones only the visible frame. Visual-search models share that document; cloning | |
| 71 | - | a model copies only its mutable input and match state. Check those two paths at | |
| 72 | - | 10,000 history rows with: | |
| 70 | + | ## Existing in-process smoke checks | |
| 73 | 71 | ||
| 74 | - | ```sh | |
| 75 | - | go test ./internal/usecase/copy ./internal/usecase/visualsearch -run '^$' -bench '^(BenchmarkNewSnapshot10KRows|BenchmarkVisualSearchClone10KRows)$' -benchtime=1s -count=5 -benchmem | |
| 76 | - | ``` | |
| 77 | - | ||
| 78 | - | Treat `allocs/op` and `B/op` as allocation gates: compare the complete repeated | |
| 79 | - | output with the accepted baseline before merging, and investigate any increase. | |
| 80 | - | The automated scaling gates also compare equal row/match counts at narrow and | |
| 81 | - | wide row widths; `B/op` may grow by at most 2x, a conservative relative | |
| 82 | - | allowance that catches full cell-row copies without depending on an absolute | |
| 83 | - | Go-version-specific budget. This scope does not yet include an | |
| 84 | - | incremental/full-text search index: queries still scan the immutable document | |
| 85 | - | and produce a fresh match list. Coordinator | |
| 86 | - | behavior, chunk encoding, compression, GC tuning, and real Unix-socket, SSH, or | |
| 87 | - | UDP impairment/latency remain out of scope. | |
| 88 | - | ||
| 89 | - | ## Lower-level smoke baseline | |
| 90 | - | ||
| 91 | - | Use this command to check renderer, VT, IPC, and daemon microbenchmarks without | |
| 92 | - | claiming an end-to-end transport result: | |
| 72 | + | These commands measure local daemon work only and are not end-to-end transport | |
| 73 | + | results: | |
| 93 | 74 | ||
| 94 | 75 | ```sh | |
| 76 | + | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | |
| 95 | 77 | go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem | |
| 96 | 78 | 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 | 102 | BootstrapTimeout time.Duration | |
| 103 | 103 | ProbeTimeout time.Duration | |
| 104 | 104 | Log *slog.Logger | |
| 105 | + | RuntimeObserver ports.SerializedRuntimeObserver | |
| 105 | 106 | } | |
| 106 | 107 | ||
| 107 | 108 | func NewRemoteDialer(target, session string) RemoteDialer { | |
| @@ -180,7 +181,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||
| 180 | 181 | RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) { | |
| 181 | 182 | return listenUDPPacket(ctx) | |
| 182 | 183 | }, | |
| 183 | - | }) | |
| 184 | + | }, WithRuntimeObserver(d.RuntimeObserver)) | |
| 184 | 185 | if err != nil { | |
| 185 | 186 | _ = pc.Close() | |
| 186 | 187 | return nil, udpUnavailable("create UDP transport", err, &stderr) | |
M internal/adapters/dgram/transport.go +96 -7
| @@ -45,6 +45,14 @@ var ( | |||
| 45 | 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 | 56 | type Options struct { | |
| 49 | 57 | MTU int | |
| 50 | 58 | ResendAfter time.Duration | |
| @@ -168,6 +176,11 @@ type Transport struct { | |||
| 168 | 176 | // mutation and before ordered event publication. | |
| 169 | 177 | afterLinkStateCommit func(ports.LinkState) | |
| 170 | 178 | observe DiagnosticObserver | |
| 179 | + | runtimeObserver ports.SerializedRuntimeObserver | |
| 180 | + | operationMu sync.Mutex | |
| 181 | + | operationCount int | |
| 182 | + | closing bool | |
| 183 | + | operationsDone chan struct{} | |
| 171 | 184 | diagnosticCh chan Diagnostic | |
| 172 | 185 | heartbeat time.Duration | |
| 173 | 186 | resendAfter time.Duration | |
| @@ -311,11 +324,11 @@ func (s *writeDeadlineState) expire(now time.Time) { | |||
| 311 | 324 | s.mu.Unlock() | |
| 312 | 325 | } | |
| 313 | 326 | ||
| 314 | - | func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) { | |
| 315 | - | return NewTransportWithOptions(pc, peer, key, sendDir, recvDir, Options{}) | |
| 327 | + | func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, runtimeOpts ...RuntimeOption) (*Transport, error) { | |
| 328 | + | return NewTransportWithOptions(pc, peer, key, sendDir, recvDir, Options{}, runtimeOpts...) | |
| 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 | 332 | c, err := pdgram.NewCodec(key) | |
| 320 | 333 | if err != nil { | |
| 321 | 334 | return nil, err | |
| @@ -360,6 +373,11 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD | |||
| 360 | 373 | nextRecvSeq: 1, | |
| 361 | 374 | recvBuf: make(map[uint64]ports.Frame), | |
| 362 | 375 | } | |
| 376 | + | for _, opt := range runtimeOpts { | |
| 377 | + | if opt != nil { | |
| 378 | + | opt(t) | |
| 379 | + | } | |
| 380 | + | } | |
| 363 | 381 | t.sendWake = make(chan struct{}) | |
| 364 | 382 | t.control = make(chan controlRecord, controlQueueSize) | |
| 365 | 383 | t.ackWake = make(chan struct{}, 1) | |
| @@ -387,9 +405,19 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD | |||
| 387 | 405 | 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 | 422 | // SendSynchronous owns queue reservation, fragment pacing, per-write deadlines, | |
| 395 | 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 | 598 | func (t *Transport) Recv() (ports.Frame, error) { | |
| 599 | + | end := t.beginRuntimeOperation(ports.RuntimeAdapterReceiveStart, 0) | |
| 571 | 600 | select { | |
| 572 | 601 | case f := <-t.in: | |
| 602 | + | end(true) | |
| 573 | 603 | return f, nil | |
| 574 | 604 | case <-t.done: | |
| 575 | 605 | t.mu.Lock() | |
| 576 | 606 | err := t.closeErr | |
| 577 | 607 | t.mu.Unlock() | |
| 578 | 608 | if err != nil { | |
| 609 | + | end(false) | |
| 579 | 610 | 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 | 617 | func (t *Transport) Close() error { | |
| 618 | + | done := t.beginShutdown() | |
| 585 | 619 | t.closeWithError(errors.New("dgram: closed")) | |
| 586 | 620 | t.mu.Lock() | |
| 587 | 621 | pc := t.pc | |
| 588 | 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 | 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 | 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 | 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 | 1 | package ipc | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "net" | |
| 6 | 7 | "path/filepath" | |
| @@ -8,15 +9,16 @@ import ( | |||
| 8 | 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 returns | |
| 12 | - | // a Transport speaking vev's framed protocol. A dial failure (no daemon | |
| 12 | + | // DialContext connects to the daemon's socket in dir (dir/daemon.sock) and | |
| 13 | + | // returns a Transport speaking vev's framed protocol. A dial failure (no daemon | |
| 13 | 14 | // listening, or a stale/absent socket) is reported as a wrapped error so | |
| 14 | 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 | 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 | 20 | if err != nil { | |
| 19 | 21 | return nil, fmt.Errorf("ipc: dial %s: %w", sockPath, err) | |
| 20 | 22 | } | |
| 21 | - | return NewTransport(conn), nil | |
| 23 | + | 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 | 25 | type unixListener struct { | |
| 26 | 26 | ln *net.UnixListener | |
| 27 | 27 | addr string | |
| 28 | + | opts []Option | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | // Listen creates the socket directory (0700) if needed and starts | |
| @@ -35,7 +36,7 @@ type unixListener struct { | |||
| 35 | 36 | // the previous owner died without cleaning up, so the stale socket file is | |
| 36 | 37 | // unlinked and bind is retried once. A successful dial means a live daemon | |
| 37 | 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 | 40 | if err := safedir.EnsurePrivate(dir); err != nil { | |
| 40 | 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 | 70 | return nil, fmt.Errorf("ipc: chmod %s: %w", sockPath, err) | |
| 70 | 71 | } | |
| 71 | 72 | ||
| 72 | - | return &unixListener{ln: ln, addr: sockPath}, nil | |
| 73 | + | return &unixListener{ln: ln, addr: sockPath, opts: opts}, nil | |
| 73 | 74 | } | |
| 74 | 75 | ||
| 75 | 76 | // bindUnix binds and listens on sockPath. | |
| @@ -104,7 +105,7 @@ func (l *unixListener) Accept() (ports.Transport, error) { | |||
| 104 | 105 | if err != nil { | |
| 105 | 106 | return nil, err | |
| 106 | 107 | } | |
| 107 | - | return NewTransport(conn), nil | |
| 108 | + | return NewTransport(conn, l.opts...), nil | |
| 108 | 109 | } | |
| 109 | 110 | ||
| 110 | 111 | // Close stops accepting connections. The underlying net.UnixListener | |
M internal/adapters/ipc/transport.go +276 -17
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "io" | |
| 7 | 7 | "net" | |
| 8 | + | "sync" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | ) | |
| @@ -26,17 +27,54 @@ var ErrZeroLengthFrame = errors.New("ipc: zero-length frame") | |||
| 26 | 27 | // maxFrameLen, and by Send when a payload is too large to encode. | |
| 27 | 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 | 42 | // unixTransport implements ports.Transport over a net.Conn (in practice an | |
| 30 | 43 | // AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets | |
| 31 | 44 | // tests exercise it over net.Pipe). | |
| 32 | 45 | // | |
| 33 | - | // unixTransport is safe for one concurrent caller of Send and one | |
| 34 | - | // concurrent caller of Recv at the same time (i.e. a single reader | |
| 35 | - | // goroutine and a single writer goroutine); it is not safe for concurrent | |
| 36 | - | // calls to Send from multiple goroutines, nor Recv from multiple | |
| 37 | - | // goroutines. | |
| 46 | + | // unixTransport has one writer goroutine which owns every conn.Write. Send | |
| 47 | + | // waits for that owner's wire attempt; SendAsync only performs a bounded, | |
| 48 | + | // ordered enqueue. Recv still has one caller at a time. | |
| 49 | + | type Option func(*unixTransport) | |
| 50 | + | ||
| 51 | + | // WithRuntimeObserver enables process-local transport marks. It accepts no | |
| 52 | + | // clock: timestamp ownership belongs to the configured observer. | |
| 53 | + | func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option { | |
| 54 | + | return func(t *unixTransport) { t.observer = observer } | |
| 55 | + | } | |
| 56 | + | ||
| 38 | 57 | type unixTransport struct { | |
| 39 | - | conn net.Conn | |
| 58 | + | conn net.Conn | |
| 59 | + | observer ports.SerializedRuntimeObserver | |
| 60 | + | ||
| 61 | + | operationMu sync.Mutex | |
| 62 | + | operationCount int | |
| 63 | + | observedClosing bool | |
| 64 | + | operationsDone chan struct{} | |
| 65 | + | ||
| 66 | + | // egressMu synchronizes Close with enqueue. Synchronous enqueuers are | |
| 67 | + | // tracked separately so the writer cannot drain the queue until each has | |
| 68 | + | // observed closure or finished enqueueing. | |
| 69 | + | egressMu sync.Mutex | |
| 70 | + | egressClosing bool | |
| 71 | + | egressSenders int | |
| 72 | + | egressSendersDone chan struct{} | |
| 73 | + | egress chan sendRequest | |
| 74 | + | done chan struct{} | |
| 75 | + | writerDone chan struct{} | |
| 76 | + | closeOnce sync.Once | |
| 77 | + | closeErr error | |
| 40 | 78 | ||
| 41 | 79 | // readBuf is reused across Recv calls (grow-once strategy): it grows | |
| 42 | 80 | // to fit the largest frame seen so far and is never shrunk. The Frame | |
| @@ -47,42 +85,197 @@ type unixTransport struct { | |||
| 47 | 85 | ||
| 48 | 86 | // NewTransport wraps conn as a ports.Transport speaking vev's framed | |
| 49 | 87 | // binary protocol. | |
| 50 | - | func NewTransport(conn net.Conn) ports.Transport { | |
| 51 | - | return &unixTransport{conn: conn} | |
| 88 | + | func NewTransport(conn net.Conn, opts ...Option) ports.Transport { | |
| 89 | + | t := &unixTransport{ | |
| 90 | + | conn: conn, | |
| 91 | + | egress: make(chan sendRequest, sendQueueCapacity), | |
| 92 | + | done: make(chan struct{}), | |
| 93 | + | writerDone: make(chan struct{}), | |
| 94 | + | } | |
| 95 | + | for _, opt := range opts { | |
| 96 | + | if opt != nil { | |
| 97 | + | opt(t) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | go t.writeLoop() | |
| 101 | + | return t | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | type sendRequest struct { | |
| 105 | + | data []byte | |
| 106 | + | done chan error // nil for SendAsync | |
| 107 | + | end func(bool) | |
| 52 | 108 | } | |
| 53 | 109 | ||
| 54 | - | // Send writes f as a single frame: a 4-byte big-endian length (covering | |
| 55 | - | // the type byte and payload), the type byte, then the payload — assembled | |
| 56 | - | // into one buffer and written with a single Write call. | |
| 110 | + | // Send queues f for the sole writer and waits for its wire attempt. | |
| 57 | 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 | 146 | n := 1 + len(f.Payload) // type + payload | |
| 59 | 147 | if n > maxFrameLen { | |
| 60 | - | return ErrFrameTooLarge | |
| 148 | + | return nil, ErrFrameTooLarge | |
| 61 | 149 | } | |
| 62 | - | ||
| 63 | 150 | buf := make([]byte, frameHeaderLen+n) | |
| 64 | 151 | binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n)) | |
| 65 | 152 | buf[frameHeaderLen] = byte(f.Type) | |
| 66 | 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) | |
| 69 | - | return err | |
| 181 | + | // enqueueAsync performs a bounded, nonblocking enqueue for SendAsync. | |
| 182 | + | func (t *unixTransport) enqueueAsync(req sendRequest) error { | |
| 183 | + | t.egressMu.Lock() | |
| 184 | + | defer t.egressMu.Unlock() | |
| 185 | + | if t.egressClosing { | |
| 186 | + | return errClosed | |
| 187 | + | } | |
| 188 | + | select { | |
| 189 | + | case t.egress <- req: | |
| 190 | + | return nil | |
| 191 | + | default: | |
| 192 | + | return ErrBackpressure | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (t *unixTransport) finishEgressSender() { | |
| 197 | + | t.egressMu.Lock() | |
| 198 | + | defer t.egressMu.Unlock() | |
| 199 | + | t.egressSenders-- | |
| 200 | + | if t.egressSenders == 0 { | |
| 201 | + | close(t.egressSendersDone) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (t *unixTransport) waitForEgressSenders() { | |
| 206 | + | t.egressMu.Lock() | |
| 207 | + | done := t.egressSendersDone | |
| 208 | + | t.egressMu.Unlock() | |
| 209 | + | if done != nil { | |
| 210 | + | <-done | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func (t *unixTransport) writeLoop() { | |
| 215 | + | defer close(t.writerDone) | |
| 216 | + | for { | |
| 217 | + | select { | |
| 218 | + | case req := <-t.egress: | |
| 219 | + | t.completeWrite(req, writeAll(t.conn, req.data)) | |
| 220 | + | case <-t.done: | |
| 221 | + | t.waitForEgressSenders() | |
| 222 | + | t.egressMu.Lock() | |
| 223 | + | for { | |
| 224 | + | select { | |
| 225 | + | case req := <-t.egress: | |
| 226 | + | t.completeWrite(req, errClosed) | |
| 227 | + | default: | |
| 228 | + | t.egressMu.Unlock() | |
| 229 | + | return | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func (t *unixTransport) completeWrite(req sendRequest, err error) { | |
| 237 | + | if req.end != nil { | |
| 238 | + | req.end(err == nil) | |
| 239 | + | } | |
| 240 | + | if req.done != nil { | |
| 241 | + | req.done <- err | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func writeAll(w io.Writer, data []byte) error { | |
| 246 | + | for len(data) > 0 { | |
| 247 | + | n, err := w.Write(data) | |
| 248 | + | if n > 0 { | |
| 249 | + | data = data[n:] | |
| 250 | + | } | |
| 251 | + | if err != nil { | |
| 252 | + | return err | |
| 253 | + | } | |
| 254 | + | if n == 0 { | |
| 255 | + | return io.ErrShortWrite | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | return nil | |
| 70 | 259 | } | |
| 71 | 260 | ||
| 72 | 261 | // Recv reads one frame: a 4-byte length, then a body of that many bytes | |
| 73 | 262 | // (type byte + payload). It blocks until a full frame arrives, the | |
| 74 | 263 | // connection is closed (io.EOF), or an error occurs. | |
| 75 | 264 | func (t *unixTransport) Recv() (ports.Frame, error) { | |
| 265 | + | end := t.beginOperation(ports.RuntimeAdapterReceiveStart, 0) | |
| 76 | 266 | var hdr [frameHeaderLen]byte | |
| 77 | 267 | if _, err := io.ReadFull(t.conn, hdr[:]); err != nil { | |
| 268 | + | end(false) | |
| 78 | 269 | return ports.Frame{}, err | |
| 79 | 270 | } | |
| 80 | 271 | ||
| 81 | 272 | n := binary.BigEndian.Uint32(hdr[:]) | |
| 82 | 273 | if n == 0 { | |
| 274 | + | end(false) | |
| 83 | 275 | return ports.Frame{}, ErrZeroLengthFrame | |
| 84 | 276 | } | |
| 85 | 277 | if n > maxFrameLen { | |
| 278 | + | end(false) | |
| 86 | 279 | return ports.Frame{}, ErrFrameTooLarge | |
| 87 | 280 | } | |
| 88 | 281 | ||
| @@ -92,6 +285,7 @@ func (t *unixTransport) Recv() (ports.Frame, error) { | |||
| 92 | 285 | t.readBuf = t.readBuf[:n] | |
| 93 | 286 | } | |
| 94 | 287 | if _, err := io.ReadFull(t.conn, t.readBuf); err != nil { | |
| 288 | + | end(false) | |
| 95 | 289 | return ports.Frame{}, err | |
| 96 | 290 | } | |
| 97 | 291 | ||
| @@ -100,10 +294,75 @@ func (t *unixTransport) Recv() (ports.Frame, error) { | |||
| 100 | 294 | payload = make([]byte, n-1) | |
| 101 | 295 | copy(payload, t.readBuf[1:]) | |
| 102 | 296 | } | |
| 297 | + | end(true) | |
| 103 | 298 | 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 | 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 | 6 | "io" | |
| 7 | 7 | "net" | |
| 8 | 8 | "reflect" | |
| 9 | + | "runtime" | |
| 9 | 10 | "sync" | |
| 10 | 11 | "testing" | |
| 12 | + | "time" | |
| 11 | 13 | ||
| 12 | 14 | "github.com/bnema/vev/internal/domain" | |
| 13 | 15 | "github.com/bnema/vev/internal/ports" | |
| @@ -231,3 +233,286 @@ func TestTransportSendOversizePayloadRejected(t *testing.T) { | |||
| 231 | 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 | 8 | // x/sys/unix plus the standard library. | |
| 9 | 9 | ||
| 10 | 10 | import ( | |
| 11 | + | "context" | |
| 11 | 12 | "errors" | |
| 12 | 13 | "fmt" | |
| 13 | 14 | "io" | |
| @@ -38,7 +39,10 @@ func NewFactory() *Factory { return &Factory{} } | |||
| 38 | 39 | // verbatim (nil means inherit the current process environment); the caller | |
| 39 | 40 | // decides TERM and friends. sz sets the terminal window size before the child | |
| 40 | 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 | 46 | masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0) | |
| 43 | 47 | if err != nil { | |
| 44 | 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 | 87 | cmd.Env = env | |
| 84 | 88 | cmd.Dir = platform.DirOrHome(dir) | |
| 85 | 89 | cmd.Stdin = slave | |
| @@ -93,6 +97,20 @@ func (Factory) Open(command string, args []string, env []string, dir string, sz | |||
| 93 | 97 | Setctty: true, | |
| 94 | 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 | 115 | if err := cmd.Start(); err != nil { | |
| 98 | 116 | return nil, fmt.Errorf("pty: start %q: %w", command, err) | |
| @@ -190,9 +208,8 @@ func (p *linuxPTY) Close() error { | |||
| 190 | 208 | p.closeOnce.Do(func() { | |
| 191 | 209 | pid := p.cmd.Process.Pid | |
| 192 | 210 | ||
| 193 | - | // Negative pid targets the whole process group (the child is the group | |
| 194 | - | // leader thanks to Setsid). Ignore errors: the child may already be gone. | |
| 195 | - | _ = unix.Kill(-pid, unix.SIGHUP) | |
| 211 | + | // Ignore errors: the child may already be gone. | |
| 212 | + | _ = signalProcessGroup(pid, syscall.SIGHUP) | |
| 196 | 213 | ||
| 197 | 214 | // Reap the child (exit status is intentionally discarded; the port has no | |
| 198 | 215 | // Wait — "child gone" surfaces to callers as io.EOF on Read). | |
| @@ -205,7 +222,7 @@ func (p *linuxPTY) Close() error { | |||
| 205 | 222 | select { | |
| 206 | 223 | case <-reaped: | |
| 207 | 224 | case <-time.After(killGracePeriod): | |
| 208 | - | _ = unix.Kill(-pid, unix.SIGKILL) | |
| 225 | + | _ = signalProcessGroup(pid, syscall.SIGKILL) | |
| 209 | 226 | <-reaped | |
| 210 | 227 | } | |
| 211 | 228 | ||
| @@ -214,6 +231,13 @@ func (p *linuxPTY) Close() error { | |||
| 214 | 231 | 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 | 241 | // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ. | |
| 218 | 242 | func setWinsize(fd int, sz domain.Size) error { | |
| 219 | 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 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | + | "context" | |
| 7 | 8 | "errors" | |
| 8 | 9 | "io" | |
| 9 | 10 | "os" | |
| @@ -53,7 +54,7 @@ func TestOpen_ChildOutputToEOF(t *testing.T) { | |||
| 53 | 54 | t.Skip("skipping pty integration test in -short mode") | |
| 54 | 55 | } | |
| 55 | 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 | 58 | require.NoError(t, err) | |
| 58 | 59 | t.Cleanup(func() { _ = p.Close() }) | |
| 59 | 60 | ||
| @@ -70,7 +71,7 @@ func TestOpen_EchoRoundtrip(t *testing.T) { | |||
| 70 | 71 | t.Skip("skipping pty integration test in -short mode") | |
| 71 | 72 | } | |
| 72 | 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 | 75 | require.NoError(t, err) | |
| 75 | 76 | t.Cleanup(func() { _ = p.Close() }) | |
| 76 | 77 | ||
| @@ -103,7 +104,7 @@ func TestForegroundPgid(t *testing.T) { | |||
| 103 | 104 | t.Skip("skipping pty integration test in -short mode") | |
| 104 | 105 | } | |
| 105 | 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 | 108 | require.NoError(t, err) | |
| 108 | 109 | t.Cleanup(func() { _ = p.Close() }) | |
| 109 | 110 | ||
| @@ -141,7 +142,7 @@ func TestResize_SttySize(t *testing.T) { | |||
| 141 | 142 | for _, tt := range tests { | |
| 142 | 143 | t.Run(tt.name, func(t *testing.T) { | |
| 143 | 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 | 146 | require.NoError(t, err) | |
| 146 | 147 | t.Cleanup(func() { _ = p.Close() }) | |
| 147 | 148 | ||
| @@ -160,7 +161,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) { | |||
| 160 | 161 | t.Skip("skipping pty integration test in -short mode") | |
| 161 | 162 | } | |
| 162 | 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 | 165 | require.NoError(t, err) | |
| 165 | 166 | ||
| 166 | 167 | pid := p.Pid() | |
| @@ -191,7 +192,7 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) { | |||
| 191 | 192 | t.Skip("skipping pty integration test in -short mode") | |
| 192 | 193 | } | |
| 193 | 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 | 196 | require.NoError(t, err) | |
| 196 | 197 | pid := p.Pid() | |
| 197 | 198 | ||
| @@ -204,6 +205,42 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) { | |||
| 204 | 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 | 244 | func TestResize_DeliversSIGWINCH(t *testing.T) { | |
| 208 | 245 | if testing.Short() { | |
| 209 | 246 | t.Skip("skipping pty integration test in -short mode") | |
| @@ -212,7 +249,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) { | |||
| 212 | 249 | // deliver SIGWINCH to the foreground process group. | |
| 213 | 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 | 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 | 253 | require.NoError(t, err) | |
| 217 | 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 | 13 | // DialerFactory selects the requested remote transport adapter. | |
| 14 | - | type DialerFactory struct{} | |
| 14 | + | type DialerFactory struct { | |
| 15 | + | observer ports.SerializedRuntimeObserver | |
| 16 | + | } | |
| 15 | 17 | ||
| 16 | 18 | var _ ports.RemoteDialerFactory = DialerFactory{} | |
| 17 | 19 | ||
| 18 | 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 | 29 | switch mode { | |
| 22 | 30 | case ports.RemoteTransportUDP: | |
| 23 | 31 | if log != nil { | |
| 24 | 32 | log.Info("remote transport selected", "mode", mode, "target", target, "session", session) | |
| 25 | 33 | } | |
| 26 | - | return dgram.NewRemoteDialerWithLogger(target, session, log), nil | |
| 34 | + | dialer := dgram.NewRemoteDialerWithLogger(target, session, log) | |
| 35 | + | dialer.RuntimeObserver = f.observer | |
| 36 | + | return dialer, nil | |
| 27 | 37 | case ports.RemoteTransportStdio: | |
| 28 | 38 | if log != nil { | |
| 29 | 39 | log.Info("remote transport selected", "mode", mode, "target", target, "session", session) | |
| 30 | 40 | } | |
| 31 | - | return stdioDialer{target: target, session: session, log: log}, nil | |
| 41 | + | return stdioDialer{target: target, session: session, log: log, observer: f.observer}, nil | |
| 32 | 42 | default: | |
| 33 | 43 | return nil, fmt.Errorf("vev: unsupported remote transport %q", mode) | |
| 34 | 44 | } | |
| 35 | 45 | } | |
| 36 | 46 | ||
| 37 | 47 | type stdioDialer struct { | |
| 38 | - | target string | |
| 39 | - | session string | |
| 40 | - | log *slog.Logger | |
| 48 | + | target string | |
| 49 | + | session string | |
| 50 | + | log *slog.Logger | |
| 51 | + | observer ports.SerializedRuntimeObserver | |
| 41 | 52 | } | |
| 42 | 53 | ||
| 43 | 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 | 34 | type closeFunc func() error | |
| 35 | 35 | 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 | 45 | // NewTransport wraps separate reader/writer streams as a framed Transport. | |
| 38 | - | func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc) ports.Transport { | |
| 39 | - | return newTransport(r, w, closeFn, nil) | |
| 46 | + | func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc, opts ...Option) ports.Transport { | |
| 47 | + | t := newTransport(r, w, closeFn, nil) | |
| 48 | + | for _, opt := range opts { | |
| 49 | + | if opt != nil { | |
| 50 | + | opt(t) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | return t | |
| 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 | 57 | if closeFn == nil { | |
| 44 | 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 | 63 | type transport struct { | |
| 50 | - | r io.Reader | |
| 51 | - | w io.Writer | |
| 52 | - | close closeFunc | |
| 53 | - | eofErr eofErrFunc | |
| 64 | + | r io.Reader | |
| 65 | + | w io.Writer | |
| 66 | + | close closeFunc | |
| 67 | + | eofErr eofErrFunc | |
| 68 | + | observer ports.SerializedRuntimeObserver | |
| 69 | + | operationMu sync.Mutex | |
| 70 | + | operationCount int | |
| 71 | + | closing bool | |
| 72 | + | operationsDone chan struct{} | |
| 54 | 73 | ||
| 55 | 74 | mu sync.Mutex | |
| 56 | 75 | readBuf []byte | |
| 57 | 76 | } | |
| 58 | 77 | ||
| 59 | 78 | func (t *transport) Send(f ports.Frame) error { | |
| 79 | + | end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload))) | |
| 60 | 80 | n := 1 + len(f.Payload) | |
| 61 | 81 | if n > maxFrameLen { | |
| 82 | + | end(false) | |
| 62 | 83 | return ErrFrameTooLarge | |
| 63 | 84 | } | |
| 64 | 85 | ||
| @@ -68,22 +89,28 @@ func (t *transport) Send(f ports.Frame) error { | |||
| 68 | 89 | copy(buf[frameHeaderLen+1:], f.Payload) | |
| 69 | 90 | ||
| 70 | 91 | t.mu.Lock() | |
| 71 | - | defer t.mu.Unlock() | |
| 72 | 92 | _, err := t.w.Write(buf) | |
| 93 | + | t.mu.Unlock() | |
| 94 | + | end(err == nil) | |
| 73 | 95 | return err | |
| 74 | 96 | } | |
| 75 | 97 | ||
| 76 | 98 | func (t *transport) Recv() (ports.Frame, error) { | |
| 99 | + | end := t.beginOperation(ports.RuntimeAdapterReceiveStart, 0) | |
| 77 | 100 | var hdr [frameHeaderLen]byte | |
| 78 | 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 | 107 | n := binary.BigEndian.Uint32(hdr[:]) | |
| 83 | 108 | if n == 0 { | |
| 109 | + | end(false) | |
| 84 | 110 | return ports.Frame{}, ErrZeroLengthFrame | |
| 85 | 111 | } | |
| 86 | 112 | if n > maxFrameLen { | |
| 113 | + | end(false) | |
| 87 | 114 | return ports.Frame{}, ErrFrameTooLarge | |
| 88 | 115 | } | |
| 89 | 116 | ||
| @@ -93,13 +120,35 @@ func (t *transport) Recv() (ports.Frame, error) { | |||
| 93 | 120 | t.readBuf = t.readBuf[:n] | |
| 94 | 121 | } | |
| 95 | 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 | 128 | payload := append([]byte(nil), t.readBuf[1:]...) | |
| 129 | + | end(true) | |
| 100 | 130 | 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 | 152 | func (t *transport) mapEOFError(err error) error { | |
| 104 | 153 | if t.eofErr == nil || (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) { | |
| 105 | 154 | return err | |
| @@ -110,7 +159,56 @@ func (t *transport) mapEOFError(err error) error { | |||
| 110 | 159 | 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 | 213 | type processWaiter struct { | |
| 116 | 214 | cmd *exec.Cmd | |
| @@ -222,18 +320,27 @@ func Dial(target, session string) (ports.Transport, error) { | |||
| 222 | 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 | 328 | // DialContext is like Dial, but the context is propagated to ssh startup so a | |
| 226 | 329 | // canceled attach attempt interrupts the local ssh process. Callers may pass a | |
| 227 | 330 | // logger to record ssh start failures and non-clean exits without logging the | |
| 228 | 331 | // generated command line. | |
| 229 | 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 | 333 | var log *slog.Logger | |
| 234 | 334 | if len(logger) > 0 { | |
| 235 | 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 | 344 | spec := BuildCommand(target, session) | |
| 238 | 345 | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | |
| 239 | 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 | 363 | waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session) | |
| 257 | - | return newTransport(stdout, stdin, waiter.close, waiter.eofErr), nil | |
| 364 | + | transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr) | |
| 365 | + | for _, opt := range opts { | |
| 366 | + | if opt != nil { | |
| 367 | + | opt(transport) | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | return transport, nil | |
| 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 | 74 | // attach dials, handshakes, and returns the transport plus its frame pump. | |
| 75 | 75 | func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (ports.Transport, *pump) { | |
| 76 | 76 | t.Helper() | |
| 77 | - | tr, err := ipc.Dial(dir) | |
| 77 | + | tr, err := ipc.DialContext(context.Background(), dir) | |
| 78 | 78 | require.NoError(t, err) | |
| 79 | 79 | hello := ports.Hello{Version: ports.ProtocolVersion, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color"} | |
| 80 | 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 | 92 | func listRemoteSessions(t *testing.T, dir string) ports.Sessions { | |
| 93 | 93 | t.Helper() | |
| 94 | - | tr, err := ipc.Dial(dir) | |
| 94 | + | tr, err := ipc.DialContext(context.Background(), dir) | |
| 95 | 95 | require.NoError(t, err) | |
| 96 | 96 | defer func() { _ = tr.Close() }() | |
| 97 | 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 | 106 | func killAll(dir string) error { | |
| 107 | - | tr, err := ipc.Dial(dir) | |
| 107 | + | tr, err := ipc.DialContext(context.Background(), dir) | |
| 108 | 108 | if err != nil { | |
| 109 | 109 | return err | |
| 110 | 110 | } | |
| @@ -248,7 +248,7 @@ func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) { | |||
| 248 | 248 | text = awaitScreenText(t, p, sz, " work ") | |
| 249 | 249 | require.NotContains(t, text, "work*") | |
| 250 | 250 | ||
| 251 | - | listTr, err := ipc.Dial(dir) | |
| 251 | + | listTr, err := ipc.DialContext(context.Background(), dir) | |
| 252 | 252 | require.NoError(t, err) | |
| 253 | 253 | defer func() { _ = listTr.Close() }() | |
| 254 | 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 | 367 | tr2, _ := attach(t, dir, ports.IntentNew, "two", sz) | |
| 368 | 368 | require.NoError(t, tr2.Close()) | |
| 369 | 369 | ||
| 370 | - | killTr, err := ipc.Dial(dir) | |
| 370 | + | killTr, err := ipc.DialContext(context.Background(), dir) | |
| 371 | 371 | require.NoError(t, err) | |
| 372 | 372 | defer func() { _ = killTr.Close() }() | |
| 373 | 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 | 22 | "path/filepath" | |
| 23 | 23 | "strconv" | |
| 24 | 24 | "strings" | |
| 25 | + | "sync" | |
| 25 | 26 | "syscall" | |
| 26 | 27 | "text/tabwriter" | |
| 27 | 28 | "time" | |
| @@ -31,6 +32,7 @@ import ( | |||
| 31 | 32 | "github.com/bnema/vev/internal/adapters/config" | |
| 32 | 33 | "github.com/bnema/vev/internal/adapters/dgram" | |
| 33 | 34 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 35 | + | "github.com/bnema/vev/internal/adapters/observability" | |
| 34 | 36 | "github.com/bnema/vev/internal/adapters/pty" | |
| 35 | 37 | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |
| 36 | 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 | 343 | func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.Logger, io.Closer, error) { | |
| 242 | 344 | return logging.Setup(logging.Config{ | |
| 243 | 345 | Dir: platform.StateDir(), | |
| @@ -278,14 +380,23 @@ func pprofAddrIsLoopback(addr string) bool { | |||
| 278 | 380 | // entered by an auto-spawned child): it sets up logging, binds the socket, | |
| 279 | 381 | // constructs the daemon, and serves until the last session exits or a | |
| 280 | 382 | // termination signal arrives (graceful shutdown notifies attached clients). | |
| 281 | - | func runDaemon() error { | |
| 383 | + | func runDaemon() (retErr error) { | |
| 282 | 384 | log, logCloser, err := configureLogging(logging.Daemon, true) | |
| 283 | 385 | if err != nil { | |
| 284 | 386 | return err | |
| 285 | 387 | } | |
| 286 | 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 | 400 | if err != nil { | |
| 290 | 401 | log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err) | |
| 291 | 402 | return fmt.Errorf("vev: daemon listen: %w", err) | |
| @@ -309,6 +420,9 @@ func runDaemon() error { | |||
| 309 | 420 | ||
| 310 | 421 | log.Info("daemon starting", "socket", ln.Addr()) | |
| 311 | 422 | daemonOpts := []daemon.Option(nil) | |
| 423 | + | if observer != nil { | |
| 424 | + | daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer)) | |
| 425 | + | } | |
| 312 | 426 | configPath := platform.ConfigPath() | |
| 313 | 427 | cfg, warnings, err := config.Load(configPath) | |
| 314 | 428 | if err != nil { | |
| @@ -327,7 +441,6 @@ func runDaemon() error { | |||
| 327 | 441 | log.Info("session persistence enabled", "path", storePath) | |
| 328 | 442 | daemonOpts = append(daemonOpts, daemon.WithStore(store)) | |
| 329 | 443 | } | |
| 330 | - | clk := clock.New() | |
| 331 | 444 | d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...) | |
| 332 | 445 | watchCtx, stopWatch := context.WithCancel(ctx) | |
| 333 | 446 | defer stopWatch() | |
| @@ -350,20 +463,32 @@ func runDaemon() error { | |||
| 350 | 463 | // runAttach dials (auto-spawning the daemon if needed) and runs the client | |
| 351 | 464 | // attach loop. Logging goes to the shared file: the client must never write | |
| 352 | 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 | 467 | log, logCloser, err := configureLogging(logging.Client, false) | |
| 355 | 468 | if err != nil { | |
| 356 | 469 | return err | |
| 357 | 470 | } | |
| 358 | 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 | 481 | return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{ | |
| 361 | - | localDialer: defaultLocalDialer, | |
| 362 | - | remoteDialerFactory: defaultRemoteDialerFactory(), | |
| 482 | + | localDialer: func() ports.Dialer { | |
| 483 | + | return localDaemonDialer{dir: ipc.SocketDir(), observer: observer} | |
| 484 | + | }, | |
| 485 | + | remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer), | |
| 363 | 486 | selectedRemoteTransport: os.Getenv(envRemoteTransport), | |
| 364 | - | runClient: client.Run, | |
| 365 | - | createDetached: createDetachedLocalSession, | |
| 366 | - | clipboard: clipboard.New(), | |
| 487 | + | runClient: func(ctx context.Context, dialer ports.Dialer, terminal ports.Terminal, behaviorClock ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) error { | |
| 488 | + | return runClientWithRuntimeObserver(ctx, dialer, terminal, behaviorClock, intent, name, remote, clipboard, log, observer) | |
| 489 | + | }, | |
| 490 | + | createDetached: createDetachedLocalSession, | |
| 491 | + | clipboard: clipboard.New(), | |
| 367 | 492 | }) | |
| 368 | 493 | } | |
| 369 | 494 | ||
| @@ -499,10 +624,19 @@ func isDaemonVersionDrift(err error) bool { | |||
| 499 | 624 | 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 | 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 | 642 | func detachedLocalHello(name, cwd string) ports.Hello { | |
| @@ -584,7 +718,7 @@ func requestDaemonStop(ctx context.Context) error { | |||
| 584 | 718 | ||
| 585 | 719 | done := make(chan error, 1) | |
| 586 | 720 | go func() { | |
| 587 | - | transport, err := realDial(ipc.SocketDir()) | |
| 721 | + | transport, err := realDial(ctx, ipc.SocketDir()) | |
| 588 | 722 | if err != nil { | |
| 589 | 723 | done <- fmt.Errorf("vev: no daemon running") | |
| 590 | 724 | return | |
| @@ -621,7 +755,7 @@ func requestDaemonStop(ctx context.Context) error { | |||
| 621 | 755 | // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it | |
| 622 | 756 | // connects to the per-user daemon (auto-spawning it if needed) and proxies the | |
| 623 | 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 | 759 | log, logCloser, err := configureLogging(logging.Stdio, false) | |
| 626 | 760 | if err != nil { | |
| 627 | 761 | return err | |
| @@ -629,13 +763,22 @@ func runStdio(ctx context.Context) error { | |||
| 629 | 763 | defer func() { _ = logCloser.Close() }() | |
| 630 | 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 | 777 | if err != nil { | |
| 634 | 778 | return err | |
| 635 | 779 | } | |
| 636 | 780 | defer func() { _ = transport.Close() }() | |
| 637 | - | ||
| 638 | - | stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil) | |
| 781 | + | stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil, sshstdio.WithRuntimeObserver(observer)) | |
| 639 | 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 | 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 | 866 | log, logCloser, err := configureLogging(logging.Stdio, false) | |
| 724 | 867 | if err != nil { | |
| 725 | 868 | return err | |
| @@ -727,6 +870,13 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error { | |||
| 727 | 870 | defer func() { _ = logCloser.Close() }() | |
| 728 | 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 | 880 | portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange)) | |
| 731 | 881 | if err != nil { | |
| 732 | 882 | return err | |
| @@ -740,14 +890,16 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error { | |||
| 740 | 890 | if _, err := rand.Read(key); err != nil { | |
| 741 | 891 | 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 | 896 | if err != nil { | |
| 745 | 897 | return err | |
| 746 | 898 | } | |
| 747 | 899 | defer func() { _ = daemonTr.Close() }() | |
| 748 | 900 | udpOptions := udpProxyClientTransportOptions | |
| 749 | 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 | 903 | if err != nil { | |
| 752 | 904 | return err | |
| 753 | 905 | } | |
| @@ -890,8 +1042,8 @@ func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger | |||
| 890 | 1042 | ||
| 891 | 1043 | // runList prints the daemon's session listing. With no daemon running, it | |
| 892 | 1044 | // falls back to the persisted stopped-session records. | |
| 893 | - | func runList(_ context.Context) error { | |
| 894 | - | transport, err := realDial(ipc.SocketDir()) | |
| 1045 | + | func runList(ctx context.Context) error { | |
| 1046 | + | transport, err := realDial(ctx, ipc.SocketDir()) | |
| 895 | 1047 | if err != nil { | |
| 896 | 1048 | records, loadErr := persist.LoadReadOnly(platform.StateDir()) | |
| 897 | 1049 | if loadErr != nil { | |
| @@ -955,8 +1107,8 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||
| 955 | 1107 | } | |
| 956 | 1108 | ||
| 957 | 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 { | |
| 959 | - | transport, err := realDial(ipc.SocketDir()) | |
| 1110 | + | func runKill(ctx context.Context, name string, all, daemon bool) error { | |
| 1111 | + | transport, err := realDial(ctx, ipc.SocketDir()) | |
| 960 | 1112 | if err != nil { | |
| 961 | 1113 | if name != "" && !all && !daemon { | |
| 962 | 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 | 33 | // dialFunc dials the daemon socket in dir. Injected so tests can drive the | |
| 34 | 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 | 37 | // spawnFunc launches a detached daemon process. Injected so tests never | |
| 38 | 38 | // re-exec a real binary. | |
| @@ -58,7 +58,10 @@ var defaultBackoff = backoffConfig{ | |||
| 58 | 58 | // spawner via an mkdir lock (losers simply wait), then retry-dials with | |
| 59 | 59 | // backoff until the socket is live or the budget expires. | |
| 60 | 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 | 65 | slog.Debug("daemon already reachable", "socket_dir", dir) | |
| 63 | 66 | return t, nil | |
| 64 | 67 | } | |
| @@ -73,6 +76,9 @@ func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFun | |||
| 73 | 76 | // rather than spawning a second daemon. | |
| 74 | 77 | defer release() | |
| 75 | 78 | slog.Info("spawning daemon", "socket_dir", dir) | |
| 79 | + | if err := ctx.Err(); err != nil { | |
| 80 | + | return nil, err | |
| 81 | + | } | |
| 76 | 82 | if err := spawn(); err != nil { | |
| 77 | 83 | slog.Error("daemon spawn failed", "err", err) | |
| 78 | 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 | 126 | deadline := time.Now().Add(cfg.total) | |
| 121 | 127 | backoff := cfg.initial | |
| 122 | 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 | 133 | return t, nil | |
| 125 | 134 | } | |
| 126 | 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 | 150 | // realDial is the production dialer. | |
| 142 | - | func realDial(dir string) (ports.Transport, error) { | |
| 143 | - | return ipc.Dial(dir) | |
| 151 | + | func realDial(ctx context.Context, dir string) (ports.Transport, error) { | |
| 152 | + | return ipc.DialContext(ctx, dir) | |
| 144 | 153 | } | |
| 145 | 154 | ||
| 146 | 155 | // 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 | 91 | func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) { | |
| 92 | 92 | want := portsmocks.NewMockTransport(t) | |
| 93 | 93 | var calls atomic.Int32 | |
| 94 | - | dial := func(string) (ports.Transport, error) { | |
| 94 | + | dial := func(context.Context, string) (ports.Transport, error) { | |
| 95 | 95 | if calls.Add(1) <= 3 { | |
| 96 | 96 | return nil, errDialFailed | |
| 97 | 97 | } | |
| @@ -111,7 +111,7 @@ func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) { | |||
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | 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 | 115 | _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff) | |
| 116 | 116 | if !errors.Is(err, ErrDaemonUnreachable) { | |
| 117 | 117 | t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err) | |
| @@ -121,18 +121,65 @@ func TestRetryDialPermanentFailure(t *testing.T) { | |||
| 121 | 121 | func TestRetryDialContextCancelled(t *testing.T) { | |
| 122 | 122 | ctx, cancel := context.WithCancel(context.Background()) | |
| 123 | 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 | 129 | _, err := retryDial(ctx, t.TempDir(), dial, fastBackoff) | |
| 126 | 130 | if !errors.Is(err, context.Canceled) { | |
| 127 | 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 | 178 | func TestEnsureDaemonSpawnsThenDials(t *testing.T) { | |
| 132 | 179 | dir := filepath.Join(t.TempDir(), "vev") | |
| 133 | 180 | want := portsmocks.NewMockTransport(t) | |
| 134 | 181 | var dialCalls, spawnCalls atomic.Int32 | |
| 135 | - | dial := func(string) (ports.Transport, error) { | |
| 182 | + | dial := func(context.Context, string) (ports.Transport, error) { | |
| 136 | 183 | // First (pre-spawn) dial fails; the post-spawn retry succeeds. | |
| 137 | 184 | if dialCalls.Add(1) == 1 { | |
| 138 | 185 | return nil, errDialFailed | |
| @@ -163,7 +210,7 @@ func TestEnsureDaemonSpawnsThenDials(t *testing.T) { | |||
| 163 | 210 | func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) { | |
| 164 | 211 | want := portsmocks.NewMockTransport(t) | |
| 165 | 212 | 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 | 214 | spawn := func() error { spawnCalls.Add(1); return nil } | |
| 168 | 215 | ||
| 169 | 216 | got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff) | |
| @@ -180,7 +227,7 @@ func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) { | |||
| 180 | 227 | ||
| 181 | 228 | func TestEnsureDaemonSpawnFailure(t *testing.T) { | |
| 182 | 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 | 231 | spawn := func() error { return spawnErr } | |
| 185 | 232 | ||
| 186 | 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 | 501 | // Open provides a mock function for the type MockPTYFactory | |
| 502 | - | func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { | |
| 503 | - | ret := _mock.Called(cmd, args, env, dir, sz) | |
| 502 | + | func (_mock *MockPTYFactory) Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { | |
| 503 | + | ret := _mock.Called(ctx, cmd, args, env, dir, sz) | |
| 504 | 504 | ||
| 505 | 505 | if len(ret) == 0 { | |
| 506 | 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 | 509 | var r0 ports.PTY | |
| 510 | 510 | var r1 error | |
| 511 | - | if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) (ports.PTY, error)); ok { | |
| 512 | - | return returnFunc(cmd, args, env, dir, sz) | |
| 511 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error)); ok { | |
| 512 | + | return returnFunc(ctx, cmd, args, env, dir, sz) | |
| 513 | 513 | } | |
| 514 | - | if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) ports.PTY); ok { | |
| 515 | - | r0 = returnFunc(cmd, args, env, dir, sz) | |
| 514 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) ports.PTY); ok { | |
| 515 | + | r0 = returnFunc(ctx, cmd, args, env, dir, sz) | |
| 516 | 516 | } else { | |
| 517 | 517 | if ret.Get(0) != nil { | |
| 518 | 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 { | |
| 522 | - | r1 = returnFunc(cmd, args, env, dir, sz) | |
| 521 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, []string, string, domain.Size) error); ok { | |
| 522 | + | r1 = returnFunc(ctx, cmd, args, env, dir, sz) | |
| 523 | 523 | } else { | |
| 524 | 524 | r1 = ret.Error(1) | |
| 525 | 525 | } | |
| @@ -532,36 +532,41 @@ type MockPTYFactory_Open_Call struct { | |||
| 532 | 532 | } | |
| 533 | 533 | ||
| 534 | 534 | // Open is a helper method to define mock.On call | |
| 535 | + | // - ctx context.Context | |
| 535 | 536 | // - cmd string | |
| 536 | 537 | // - args []string | |
| 537 | 538 | // - env []string | |
| 538 | 539 | // - dir string | |
| 539 | 540 | // - sz domain.Size | |
| 540 | - | func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call { | |
| 541 | - | return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", cmd, args, env, dir, sz)} | |
| 541 | + | func (_e *MockPTYFactory_Expecter) Open(ctx any, cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call { | |
| 542 | + | return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", ctx, cmd, args, env, dir, sz)} | |
| 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 | 546 | _c.Call.Run(func(args mock.Arguments) { | |
| 546 | - | var arg0 string | |
| 547 | + | var arg0 context.Context | |
| 547 | 548 | if args[0] != nil { | |
| 548 | - | arg0 = args[0].(string) | |
| 549 | + | arg0 = args[0].(context.Context) | |
| 549 | 550 | } | |
| 550 | - | var arg1 []string | |
| 551 | + | var arg1 string | |
| 551 | 552 | if args[1] != nil { | |
| 552 | - | arg1 = args[1].([]string) | |
| 553 | + | arg1 = args[1].(string) | |
| 553 | 554 | } | |
| 554 | 555 | var arg2 []string | |
| 555 | 556 | if args[2] != nil { | |
| 556 | 557 | arg2 = args[2].([]string) | |
| 557 | 558 | } | |
| 558 | - | var arg3 string | |
| 559 | + | var arg3 []string | |
| 559 | 560 | if args[3] != nil { | |
| 560 | - | arg3 = args[3].(string) | |
| 561 | + | arg3 = args[3].([]string) | |
| 561 | 562 | } | |
| 562 | - | var arg4 domain.Size | |
| 563 | + | var arg4 string | |
| 563 | 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 | 571 | run( | |
| 567 | 572 | arg0, | |
| @@ -569,6 +574,7 @@ func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env | |||
| 569 | 574 | arg2, | |
| 570 | 575 | arg3, | |
| 571 | 576 | arg4, | |
| 577 | + | arg5, | |
| 572 | 578 | ) | |
| 573 | 579 | }) | |
| 574 | 580 | return _c | |
| @@ -579,7 +585,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac | |||
| 579 | 585 | 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 | 589 | _c.Call.Return(run) | |
| 584 | 590 | return _c | |
| 585 | 591 | } | |
| @@ -3123,3 +3129,70 @@ func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(ta | |||
| 3123 | 3129 | _c.Call.Return(run) | |
| 3124 | 3130 | 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 | 25 | // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal. | |
| 26 | 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 | 32 | // Terminal is the CLIENT-side controlling terminal. | |
| @@ -38,7 +40,9 @@ type Terminal interface { | |||
| 38 | 40 | 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 | 46 | type Transport interface { | |
| 43 | 47 | Send(Frame) error | |
| 44 | 48 | 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 | 102 | // goroutine, decoupling the input/resize pumps from transport back-pressure. | |
| 103 | 103 | 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 | 109 | var reconnectSleep = sleepReconnect | |
| 106 | 110 | var reconnectSleepWithResize = sleepReconnectWithResizeEvents | |
| 107 | 111 | ||
| @@ -120,7 +124,29 @@ const ( | |||
| 120 | 124 | // ClipboardReader configured (clipboard non-nil) — local attaches forward | |
| 121 | 125 | // 0x16 untouched so the locally running agent's own clipboard handling | |
| 122 | 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 | 150 | if log == nil { | |
| 125 | 151 | log = slog.Default() | |
| 126 | 152 | } | |
| @@ -244,11 +270,13 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||
| 244 | 270 | if reporter, ok := transport.(ports.LinkStateReporter); ok { | |
| 245 | 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 | 274 | if result.welcomed { | |
| 249 | 275 | backoff = defaultReconnectBackoff.initial | |
| 250 | 276 | } | |
| 251 | - | _ = transport.Close() | |
| 277 | + | if !result.transportClosed { | |
| 278 | + | _ = transport.Close() | |
| 279 | + | } | |
| 252 | 280 | if result.resumeToken != 0 { | |
| 253 | 281 | resumeToken = result.resumeToken | |
| 254 | 282 | } | |
| @@ -311,8 +339,14 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time | |||
| 311 | 339 | // disappears, or the context is cancelled. | |
| 312 | 340 | // | |
| 313 | 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 { | |
| 315 | - | return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default()) | |
| 342 | + | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, options ...Option) error { | |
| 343 | + | var opts runtimeOptions | |
| 344 | + | for _, option := range options { | |
| 345 | + | if option != nil { | |
| 346 | + | option(&opts) | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | return run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default(), opts.observer) | |
| 316 | 350 | } | |
| 317 | 351 | ||
| 318 | 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 | 358 | type attachResult struct { | |
| 325 | - | resumeToken uint64 | |
| 326 | - | sessionName string | |
| 327 | - | welcomed bool | |
| 328 | - | err error | |
| 359 | + | resumeToken uint64 | |
| 360 | + | sessionName string | |
| 361 | + | welcomed bool | |
| 362 | + | transportClosed bool | |
| 363 | + | err error | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | // boundedPreWelcome runs one blocking handshake operation. Transport methods | |
| 367 | + | // do not accept a context, so cancellation and expiry close this attempt's | |
| 368 | + | // transport to interrupt the call, then wait for its goroutine to exit. | |
| 369 | + | func boundedPreWelcome(ctx context.Context, clk ports.Clock, transport ports.Transport, operation func() error) (bool, error) { | |
| 370 | + | if err := ctx.Err(); err != nil { | |
| 371 | + | _ = transport.Close() | |
| 372 | + | return true, err | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | timer := clk.NewTimer(preWelcomeTimeout) | |
| 376 | + | defer timer.Stop() | |
| 377 | + | completed := make(chan error, 1) | |
| 378 | + | go func() { completed <- operation() }() | |
| 379 | + | ||
| 380 | + | select { | |
| 381 | + | case err := <-completed: | |
| 382 | + | // Prefer a cancellation/expiry observed concurrently with completion. | |
| 383 | + | // That event owns the transport lifetime even though the operation has | |
| 384 | + | // already returned by the time we observe it here. | |
| 385 | + | if ctx.Err() != nil { | |
| 386 | + | _ = transport.Close() | |
| 387 | + | return true, ctx.Err() | |
| 388 | + | } | |
| 389 | + | select { | |
| 390 | + | case <-timer.C(): | |
| 391 | + | _ = transport.Close() | |
| 392 | + | return true, context.DeadlineExceeded | |
| 393 | + | default: | |
| 394 | + | return false, err | |
| 395 | + | } | |
| 396 | + | case <-ctx.Done(): | |
| 397 | + | _ = transport.Close() | |
| 398 | + | <-completed | |
| 399 | + | return true, ctx.Err() | |
| 400 | + | case <-timer.C(): | |
| 401 | + | _ = transport.Close() | |
| 402 | + | <-completed | |
| 403 | + | return true, context.DeadlineExceeded | |
| 404 | + | } | |
| 329 | 405 | } | |
| 330 | 406 | ||
| 331 | 407 | // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support. | |
| @@ -346,7 +422,11 @@ func requestedOutputWindow(transport ports.Transport) uint8 { | |||
| 346 | 422 | 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 | 430 | // 1. Handshake: send Hello with our size and TERM. | |
| 351 | 431 | size, err := term.Size() | |
| 352 | 432 | if err != nil { | |
| @@ -371,15 +451,22 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 371 | 451 | TrueColor: trueColor, | |
| 372 | 452 | MaxOutputInFlight: requestedOutputWindow(transport), | |
| 373 | 453 | } | |
| 374 | - | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | |
| 375 | - | return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)} | |
| 454 | + | if closed, err := boundedPreWelcome(ctx, clk, transport, func() error { | |
| 455 | + | return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |
| 456 | + | }); err != nil { | |
| 457 | + | return attachResult{transportClosed: closed, err: fmt.Errorf("vev: sending hello: %w", err)} | |
| 376 | 458 | } | |
| 377 | 459 | ms.helloSent = true | |
| 378 | 460 | ||
| 379 | 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 | 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 | 471 | result := func(welcomed bool, err error) attachResult { | |
| 385 | 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 | 588 | if ferr := term.Flush(); ferr != nil { | |
| 502 | 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 | 595 | if o.NewStateNum != 0 { | |
| 505 | 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 | 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 | 272 | func TestAttachHelloIncludesTrueColor(t *testing.T) { | |
| 159 | 273 | t.Setenv("TERM", "xterm-256color") | |
| 160 | 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 | 179 | 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 | 194 | func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | |
| 183 | 195 | term := newReconnectToastTerminalHarness(t) | |
| 184 | 196 | defer term.closeInput() | |
| @@ -190,7 +202,7 @@ func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | |||
| 190 | 202 | resultCh := make(chan attachResult, 1) | |
| 191 | 203 | ms := milestones{} | |
| 192 | 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 | 206 | _, err := term.term.EnterRaw() | |
| 195 | 207 | return err | |
| 196 | 208 | }, func() { clears <- struct{}{} }, func(stage reconnectStage) { | |
| @@ -229,7 +241,7 @@ func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) { | |||
| 229 | 241 | resultCh := make(chan attachResult, 1) | |
| 230 | 242 | ms := milestones{} | |
| 231 | 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 | 245 | _, err := term.term.EnterRaw() | |
| 234 | 246 | return err | |
| 235 | 247 | }, func() {}, func(stage reconnectStage) { | |
| @@ -400,7 +412,7 @@ func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) { | |||
| 400 | 412 | }} | |
| 401 | 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 | 416 | require.ErrorIs(t, err, context.Canceled) | |
| 405 | 417 | require.True(t, out.failed) | |
| 406 | 418 | assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH))) | |
| @@ -435,7 +447,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | |||
| 435 | 447 | }} | |
| 436 | 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 | 451 | require.NoError(t, err) | |
| 440 | 452 | require.True(t, tr1.closed) | |
| 441 | 453 | require.True(t, tr2.closed) | |
| @@ -479,7 +491,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) { | |||
| 479 | 491 | }} | |
| 480 | 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 | 495 | require.NoError(t, err) | |
| 484 | 496 | require.Equal(t, 2, dialer.calls) | |
| 485 | 497 | ||
| @@ -564,7 +576,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) { | |||
| 564 | 576 | dialer := portsmocks.NewMockDialer(t) | |
| 565 | 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 | 580 | tt.wantErr(t, err) | |
| 569 | 581 | out := term.out.String() | |
| 570 | 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 | 7 | "unicode" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/pkg/renderer" | |
| 10 | + | "github.com/bnema/vev/pkg/vt" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits | |
| @@ -17,45 +18,49 @@ import ( | |||
| 17 | 18 | const OSC52MaxPayloadBytes = 75_000 | |
| 18 | 19 | ||
| 19 | 20 | // Snapshot is the immutable scrollback-mode document: history followed by the | |
| 20 | - | // visible screen. History rows are immutable by Scrollback's append contract; | |
| 21 | - | // the visible frame is cloned once when the snapshot is constructed. | |
| 21 | + | // visible screen. VT HistoryView retains immutable chunks; the visible frame is | |
| 22 | + | // cloned once when the snapshot is constructed. | |
| 22 | 23 | type Snapshot struct { | |
| 23 | - | history HistoryView | |
| 24 | + | history vt.HistoryView | |
| 24 | 25 | screen renderer.Frame | |
| 25 | 26 | Width int | |
| 26 | 27 | Height int | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | - | // NewSnapshot freezes the current scrollback view and clones the visible screen. | |
| 30 | - | func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot { | |
| 31 | - | var history HistoryView | |
| 32 | - | if sb != nil { | |
| 33 | - | history = sb.View() | |
| 30 | + | // NewSnapshot seals the current VT history tail, then freezes that immutable | |
| 31 | + | // view and clones the visible screen. Sealing prevents repeated copy entry from | |
| 32 | + | // cloning every row in a partial tail. | |
| 33 | + | func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { | |
| 34 | + | var history vt.HistoryView | |
| 35 | + | if historySource != nil { | |
| 36 | + | history = historySource.SealAndView() | |
| 34 | 37 | } | |
| 35 | 38 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | |
| 36 | 39 | } | |
| 37 | 40 | ||
| 38 | 41 | // NewSnapshotFromRows constructs a snapshot that owns explicit caller rows. | |
| 39 | 42 | // 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 | 45 | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |
| 42 | - | owned := make([][]renderer.Cell, len(rows)) | |
| 43 | - | for i, row := range rows { | |
| 44 | - | owned[i] = append([]renderer.Cell(nil), row...) | |
| 46 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256}) | |
| 47 | + | for _, row := range rows { | |
| 48 | + | history.Append(row) | |
| 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 | 53 | // Len returns the number of document rows. | |
| 50 | 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 | 58 | func (s Snapshot) Row(i int) []renderer.Cell { | |
| 54 | 59 | if i < 0 { | |
| 55 | 60 | return nil | |
| 56 | 61 | } | |
| 57 | 62 | if i < s.history.Len() { | |
| 58 | - | return s.history.Row(i) | |
| 63 | + | return s.history.BorrowedRow(i) | |
| 59 | 64 | } | |
| 60 | 65 | i -= s.history.Len() | |
| 61 | 66 | if i >= s.screen.Height { | |
| @@ -64,6 +69,27 @@ func (s Snapshot) Row(i int) []renderer.Cell { | |||
| 64 | 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 | 93 | // Mode stores per-client scrollback viewport and line-selection state. | |
| 68 | 94 | type SearchMatch struct { | |
| 69 | 95 | Row int | |
| @@ -102,8 +128,7 @@ func FindMatches(s Snapshot, query string) []SearchMatch { | |||
| 102 | 128 | } | |
| 103 | 129 | needle := lowerRunes(query) | |
| 104 | 130 | matches := make([]SearchMatch, 0) | |
| 105 | - | for row := range s.Len() { | |
| 106 | - | cells := s.Row(row) | |
| 131 | + | s.rangeRows(func(row int, cells []renderer.Cell) bool { | |
| 107 | 132 | haystack, cellIndexes := searchableCells(cells) | |
| 108 | 133 | var text string | |
| 109 | 134 | for start := 0; start+len(needle) <= len(haystack); { | |
| @@ -122,7 +147,8 @@ func FindMatches(s Snapshot, query string) []SearchMatch { | |||
| 122 | 147 | matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text}) | |
| 123 | 148 | start += len(needle) | |
| 124 | 149 | } | |
| 125 | - | } | |
| 150 | + | return true | |
| 151 | + | }) | |
| 126 | 152 | return matches | |
| 127 | 153 | } | |
| 128 | 154 | ||
M internal/usecase/copy/mode_test.go +128 -3
| @@ -7,11 +7,30 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/pkg/renderer" | |
| 10 | + | "github.com/bnema/vev/pkg/vt" | |
| 10 | 11 | "github.com/stretchr/testify/require" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | 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 | 34 | func snapshot(lines []string, height int) Snapshot { | |
| 16 | 35 | rows := make([][]renderer.Cell, len(lines)) | |
| 17 | 36 | for i, line := range lines { | |
| @@ -355,6 +374,24 @@ func TestFindMatchesKeepsRuneAndCellIndexesAlignedWhenLowercasing(t *testing.T) | |||
| 355 | 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 | 395 | func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) { | |
| 359 | 396 | row := []renderer.Cell{ | |
| 360 | 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 | 606 | func BenchmarkNewSnapshot10KRows(b *testing.B) { | |
| 482 | 607 | const ( | |
| 483 | 608 | width = 120 | |
| @@ -485,9 +610,9 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) { | |||
| 485 | 610 | historyRows = 10_000 | |
| 486 | 611 | ) | |
| 487 | 612 | ||
| 488 | - | scrollback := NewScrollback(historyRows) | |
| 613 | + | history := newHistory(historyRows) | |
| 489 | 614 | for range historyRows { | |
| 490 | - | scrollback.Append(row(strings.Repeat("x", width))) | |
| 615 | + | history.Append(row(strings.Repeat("x", width))) | |
| 491 | 616 | } | |
| 492 | 617 | screen := renderer.NewFrame(width, height) | |
| 493 | 618 | for y := range height { | |
| @@ -497,7 +622,7 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) { | |||
| 497 | 622 | b.ReportAllocs() | |
| 498 | 623 | b.ResetTimer() | |
| 499 | 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 | 49 | if idx, ok := oldestAttentionTab(sess); ok { | |
| 50 | 50 | if sess.switchTab(idx) { | |
| 51 | 51 | d.activateTab(sess, sess.activeTab()) | |
| 52 | - | d.paint(sess, ac, true) | |
| 52 | + | d.invalidateRender(sess, ac, true, "attention.go") | |
| 53 | 53 | } | |
| 54 | 54 | return | |
| 55 | 55 | } | |
| @@ -119,7 +119,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) { | |||
| 119 | 119 | ac := sess.client | |
| 120 | 120 | sess.mu.Unlock() | |
| 121 | 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 | 53 | d.setAttentionFrame(0) | |
| 54 | 54 | d.noteAttention(sess, sess.tabs[0]) | |
| 55 | 55 | ||
| 56 | - | d.paint(sess, ac, true) | |
| 56 | + | d.paint(sess, ac, true, nil) | |
| 57 | 57 | data := mustOutputData(t, sends) | |
| 58 | 58 | require.NotContains(t, string(data), string(ui.AttentionGlyph)) | |
| 59 | 59 | ||
| @@ -62,7 +62,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) { | |||
| 62 | 62 | sess.mu.Unlock() | |
| 63 | 63 | ||
| 64 | 64 | d.setAttentionFrame(1) | |
| 65 | - | d.paint(sess, ac, true) | |
| 65 | + | d.paint(sess, ac, true, nil) | |
| 66 | 66 | data = mustOutputData(t, sends) | |
| 67 | 67 | require.Contains(t, string(data), string(ui.AttentionGlyph)) | |
| 68 | 68 | ||
| @@ -97,7 +97,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing. | |||
| 97 | 97 | return sess.tabs[0].attention && !sess.tabs[0].attentionAt.IsZero() | |
| 98 | 98 | }, 2*time.Second, 5*time.Millisecond) | |
| 99 | 99 | ||
| 100 | - | d.paint(sess, ac, true) | |
| 100 | + | d.paint(sess, ac, true, nil) | |
| 101 | 101 | data := mustOutputData(t, sends) | |
| 102 | 102 | require.Contains(t, string(data), string(ui.AttentionGlyph)) | |
| 103 | 103 | ||
| @@ -242,7 +242,7 @@ func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) { | |||
| 242 | 242 | sess.tabs[1].attentionAt = time.Unix(2, 0) | |
| 243 | 243 | sess.mu.Unlock() | |
| 244 | 244 | ||
| 245 | - | d.paint(sess, ac, true) | |
| 245 | + | d.paint(sess, ac, true, nil) | |
| 246 | 246 | _ = mustOutputData(t, sends) | |
| 247 | 247 | // The deferred repaint (from ackAttention) targets all attached clients, | |
| 248 | 248 | // including this one, but the bars it would draw are already exactly what | |
| @@ -272,7 +272,7 @@ func TestPaintPreservesBackgroundAttention(t *testing.T) { | |||
| 272 | 272 | sess.mu.Unlock() | |
| 273 | 273 | d.setAttentionFrame(1) | |
| 274 | 274 | ||
| 275 | - | d.paint(sess, ac, true) | |
| 275 | + | d.paint(sess, ac, true, nil) | |
| 276 | 276 | data := mustOutputData(t, sends) | |
| 277 | 277 | ||
| 278 | 278 | require.Contains(t, string(data), "") | |
| @@ -292,7 +292,7 @@ func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) { | |||
| 292 | 292 | sess.mu.Unlock() | |
| 293 | 293 | ||
| 294 | 294 | require.True(t, sess.switchTab(1)) | |
| 295 | - | d.paint(sess, ac, true) | |
| 295 | + | d.paint(sess, ac, true, nil) | |
| 296 | 296 | data := mustOutputData(t, sends) | |
| 297 | 297 | // As above: the deferred all-clients repaint has nothing new to say about | |
| 298 | 298 | // this client's bars, so no second frame follows. | |
| @@ -321,7 +321,7 @@ func TestCloseRingingTabClearsClientBar(t *testing.T) { | |||
| 321 | 321 | sess.mu.Unlock() | |
| 322 | 322 | d.setAttentionFrame(1) | |
| 323 | 323 | ||
| 324 | - | d.paint(sess, ac, true) | |
| 324 | + | d.paint(sess, ac, true, nil) | |
| 325 | 325 | before := mustOutputData(t, sends) | |
| 326 | 326 | require.Contains(t, string(before), "") | |
| 327 | 327 | ||
| @@ -346,7 +346,7 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) { | |||
| 346 | 346 | sess.tabs[1].attentionAt = time.Unix(1, 0) | |
| 347 | 347 | sess.mu.Unlock() | |
| 348 | 348 | ||
| 349 | - | d.paint(sess, ac, true) | |
| 349 | + | d.paint(sess, ac, true, nil) | |
| 350 | 350 | full := mustOutputData(t, sends) | |
| 351 | 351 | require.Contains(t, string(full), "MIDSCREENMARKER") | |
| 352 | 352 | ||
| @@ -428,7 +428,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||
| 428 | 428 | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil) | |
| 429 | 429 | d.sessions[sessB.id] = sessB | |
| 430 | 430 | ||
| 431 | - | d.paint(sessB, acB, true) | |
| 431 | + | d.paint(sessB, acB, true, nil) | |
| 432 | 432 | before := mustOutputData(t, sendsB) | |
| 433 | 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 | 306 | if sess == nil { | |
| 307 | 307 | return | |
| 308 | 308 | } | |
| 309 | - | if tb := sess.activeTab(); tb != nil { | |
| 310 | - | tb.mu.Lock() | |
| 311 | - | p := tb.focusedPane() | |
| 312 | - | tb.mu.Unlock() | |
| 313 | - | if p != nil { | |
| 314 | - | signal(p.dirty) | |
| 315 | - | } | |
| 316 | - | } | |
| 309 | + | sess.mu.Lock() | |
| 310 | + | ac := sess.client | |
| 311 | + | sess.mu.Unlock() | |
| 312 | + | // A headless session can still supply a picker preview to an attachment | |
| 313 | + | // owned by another session. Its coordinator fans this wake out to those | |
| 314 | + | // preview subscribers even though no primary attachment is bound. | |
| 315 | + | d.invalidateRender(sess, ac, false, "bar_refresh.go") | |
| 317 | 316 | } | |
M internal/usecase/daemon/bar_refresh_test.go +12 -0
| @@ -277,6 +277,18 @@ func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) { | |||
| 277 | 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 | 292 | func TestBarScriptRefreshIsPerSession(t *testing.T) { | |
| 281 | 293 | r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}} | |
| 282 | 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: the | |
| 2 | - | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |
| 3 | - | // and VT screen, and the per-client debounced render scheduler. | |
| 1 | + | // Package daemon holds vev's server-side session multiplexer use case. | |
| 4 | 2 | // | |
| 5 | - | // Concurrency model (sessions own one or more PTY-backed tabs): | |
| 6 | - | // | |
| 7 | - | // - Serve runs the accept loop. Each accepted connection is handled by its | |
| 8 | - | // own goroutine (handleConn): it reads the first frame and routes it to a | |
| 9 | - | // session create/attach, a list, or a kill. | |
| 10 | - | // - Per session there are exactly two long-lived goroutines: the PTY reader | |
| 11 | - | // (drains child output into the VT screen and pokes a cap-1 dirty channel) | |
| 12 | - | // and the render scheduler (debounces dirties and paints the attached | |
| 13 | - | // client). Both are tied to the session context and unwind when the | |
| 14 | - | // session is killed (pty.Close unblocks the reader; ctx cancel stops the | |
| 15 | - | // scheduler). | |
| 16 | - | // - The daemon exits (Serve returns) when the last session is removed, or | |
| 17 | - | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | - | // attached clients with ReasonServerShutdown). | |
| 19 | - | // | |
| 20 | - | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | - | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 3 | + | // Lock ordering: never acquire sendMu or session/daemon locks while holding a | |
| 4 | + | // pane lock. Coordinator callbacks and timer methods run without coordinator.mu; | |
| 5 | + | // see the lock-specific comments on attachedClient and renderCoordinator. | |
| 25 | 6 | package daemon | |
| 26 | 7 | ||
| 27 | 8 | import ( | |
| @@ -37,28 +18,40 @@ import ( | |||
| 37 | 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 | 26 | type attachedClient struct { | |
| 41 | - | tr ports.Transport | |
| 42 | - | output *outputStateStream | |
| 43 | - | overlays *overlayRuntime | |
| 44 | - | clientID [16]byte | |
| 45 | - | resumeCapable bool | |
| 46 | - | resumeToken uint64 | |
| 47 | - | parked bool | |
| 48 | - | echoAck atomic.Uint64 | |
| 49 | - | bars barCache // only touched while sendMu is held | |
| 50 | - | composed composedFrameCache // only touched while sendMu is held | |
| 51 | - | resizePaint pendingByteTimer // guarded by sendMu | |
| 52 | - | resizePaintGeneration uint64 // guarded by sendMu | |
| 53 | - | resizePaintPending bool // guarded by sendMu | |
| 54 | - | size domain.Size | |
| 55 | - | keys *keys.Router | |
| 56 | - | sess Guarded[*session] | |
| 57 | - | mouseScan mouse.Scanner | |
| 58 | - | themeMu sync.Mutex | |
| 59 | - | theme themeui.Theme | |
| 60 | - | clientTheme themeui.Theme | |
| 61 | - | lastCursor cursorOut | |
| 27 | + | tr ports.Transport | |
| 28 | + | transportIncarnation uint64 | |
| 29 | + | output *outputStateStream | |
| 30 | + | overlays *overlayRuntime | |
| 31 | + | overlayOnce sync.Once | |
| 32 | + | clientID [16]byte | |
| 33 | + | resumeCapable bool | |
| 34 | + | resumeToken uint64 | |
| 35 | + | parked bool | |
| 36 | + | echoAck atomic.Uint64 | |
| 37 | + | // pipelineCache is the last successfully emitted composition. pipelineScratch | |
| 38 | + | // is its attachment-owned alternate buffer; both are only touched under | |
| 39 | + | // sendMu and must never share mutable backing storage. | |
| 40 | + | pipelineCache composeCacheInput | |
| 41 | + | pipelineScratch composeCacheInput | |
| 42 | + | renderScratch renderCaptureScratch // only touched while sendMu is held | |
| 43 | + | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so | |
| 44 | + | // snapshots cannot leak when an attachment switches tabs or sessions. | |
| 45 | + | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held | |
| 46 | + | size domain.Size | |
| 47 | + | keys *keys.Router | |
| 48 | + | sess Guarded[*session] | |
| 49 | + | mouseScan mouse.Scanner | |
| 50 | + | themeMu sync.Mutex | |
| 51 | + | theme themeui.Theme | |
| 52 | + | clientTheme themeui.Theme | |
| 53 | + | lastCursor cursorOut | |
| 54 | + | renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held | |
| 62 | 55 | // previousSession is guarded independently. It is retained through temporary | |
| 63 | 56 | // setSession(nil) hand-offs and cleared only on terminal teardown. | |
| 64 | 57 | previousSession Guarded[*session] | |
| @@ -75,6 +68,8 @@ type cursorOut struct { | |||
| 75 | 68 | hasStyle bool | |
| 76 | 69 | } | |
| 77 | 70 | ||
| 71 | + | // pendingByteTimer is retained for overlay input timers. Resize timing belongs | |
| 72 | + | // exclusively to renderCoordinator. | |
| 78 | 73 | type pendingByteTimer struct { | |
| 79 | 74 | timer ports.Timer | |
| 80 | 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 | 102 | func (ac *attachedClient) initOverlays() { | |
| 118 | - | if ac.overlays == nil { | |
| 119 | - | ac.overlays = newOverlayRuntime(ac) | |
| 120 | - | } | |
| 103 | + | ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) }) | |
| 121 | 104 | } | |
| 122 | 105 | ||
| 123 | 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 | 140 | func (ac *attachedClient) getTheme() themeui.Theme { | |
| 134 | 141 | ac.themeMu.Lock() | |
| 135 | 142 | defer ac.themeMu.Unlock() | |
| @@ -163,9 +170,41 @@ func (ac *attachedClient) transport() ports.Transport { | |||
| 163 | 170 | func (ac *attachedClient) replaceTransport(tr ports.Transport) { | |
| 164 | 171 | ac.linkMu.Lock() | |
| 165 | 172 | ac.tr = tr | |
| 173 | + | ac.transportIncarnation++ | |
| 166 | 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 | 208 | func (ac *attachedClient) closeTransport() error { | |
| 170 | 209 | tr := ac.transport() | |
| 171 | 210 | if tr == nil { | |
| @@ -197,23 +236,6 @@ func (ac *attachedClient) ackOutputState(state uint64) { | |||
| 197 | 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 | 239 | // send serialises a frame onto the client's transport. | |
| 218 | 240 | func (ac *attachedClient) send(f ports.Frame) error { | |
| 219 | 241 | _, err := ac.sendTransport(f) | |
| @@ -230,6 +252,22 @@ func (ac *attachedClient) sendTransport(f ports.Frame) (ports.Transport, error) | |||
| 230 | 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 | 271 | // boundedSend sends f to ac with a deadline watchdog: if the send (including | |
| 234 | 272 | // waiting on sendMu behind a wedged paint) does not complete within | |
| 235 | 273 | // 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 | 280 | func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error { | |
| 243 | - | tr, err := d.boundedSendWith(func(capture func(ports.Transport)) error { | |
| 244 | - | tr, err := ac.sendTransport(f) | |
| 245 | - | capture(tr) | |
| 246 | - | return err | |
| 281 | + | expected := ac.transportSnapshot() | |
| 282 | + | if expected.transport == nil { | |
| 283 | + | return errors.New("client transport is nil") | |
| 284 | + | } | |
| 285 | + | tr, err := d.boundedSendWith(expected.transport, func() error { | |
| 286 | + | return ac.sendExpectedTransport(expected, f) | |
| 247 | 287 | }) | |
| 248 | 288 | if err != nil && errors.Is(err, errSendTimedOut) { | |
| 249 | 289 | _ = ac.closeCapturedTransport(tr) | |
| @@ -258,68 +298,50 @@ func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error { | |||
| 258 | 298 | ||
| 259 | 299 | func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) { | |
| 260 | 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 | 305 | ac.sendMu.Lock() | |
| 262 | - | tr := ac.transport() | |
| 263 | - | if tr == nil { | |
| 306 | + | if !ac.transportSnapshotCurrent(expected) { | |
| 264 | 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 | 311 | err := owned.SendSynchronous(frame) | |
| 269 | 312 | ac.sendMu.Unlock() | |
| 270 | - | return tr, err | |
| 313 | + | return expected.transport, err | |
| 271 | 314 | } | |
| 272 | 315 | ac.sendMu.Unlock() | |
| 273 | - | return d.boundedSendWith(func(capture func(ports.Transport)) error { | |
| 274 | - | ac.sendMu.Lock() | |
| 275 | - | defer ac.sendMu.Unlock() | |
| 276 | - | tr := ac.transport() | |
| 277 | - | capture(tr) | |
| 278 | - | if tr == nil { | |
| 279 | - | return errors.New("client transport is nil") | |
| 280 | - | } | |
| 281 | - | return tr.Send(frame) | |
| 316 | + | return d.boundedSendWith(expected.transport, func() error { | |
| 317 | + | return ac.sendExpectedTransport(expected, frame) | |
| 282 | 318 | }) | |
| 283 | 319 | } | |
| 284 | 320 | ||
| 285 | 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) { | |
| 288 | - | return d.boundedSendWithTimeout(detachNotifyTimeout, send) | |
| 323 | + | func (d *Daemon) boundedSendWith(tr ports.Transport, send func() error) (ports.Transport, error) { | |
| 324 | + | return d.boundedSendWithTimeout(detachNotifyTimeout, tr, send) | |
| 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 | 328 | timer := d.clock.NewTimer(timeout) | |
| 293 | 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 | 330 | go func() { | |
| 309 | - | result <- send(capture) | |
| 331 | + | result <- send() | |
| 310 | 332 | }() | |
| 311 | 333 | select { | |
| 312 | 334 | case err := <-result: | |
| 313 | 335 | timer.Stop() | |
| 314 | - | return capturedTransport(), err | |
| 336 | + | return tr, err | |
| 315 | 337 | case <-timer.C(): | |
| 316 | 338 | select { | |
| 317 | 339 | case err := <-result: | |
| 318 | - | return capturedTransport(), err | |
| 340 | + | return tr, err | |
| 319 | 341 | default: | |
| 320 | 342 | } | |
| 321 | 343 | d.log.Warn("bounded send timed out; force closing client transport") | |
| 322 | - | return capturedTransport(), errSendTimedOut | |
| 344 | + | return tr, errSendTimedOut | |
| 323 | 345 | } | |
| 324 | 346 | } | |
| 325 | 347 | ||
| @@ -376,6 +398,12 @@ type attachClientOptions struct { | |||
| 376 | 398 | } | |
| 377 | 399 | ||
| 378 | 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 | 407 | resumeToken := uint64(0) | |
| 380 | 408 | if opts.resumeCapable { | |
| 381 | 409 | resumeToken = d.nextResumeTokenLocked() | |
| @@ -391,15 +419,96 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||
| 391 | 419 | ac.initOverlays() | |
| 392 | 420 | ac.setSession(sess) | |
| 393 | 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 | 425 | sess.mu.Lock() | |
| 395 | 426 | old := sess.client | |
| 396 | - | sess.client = ac | |
| 397 | 427 | name := sess.name | |
| 398 | 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 | 433 | d.touchMRU(sess) | |
| 400 | 434 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | |
| 401 | 435 | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true) | |
| 402 | - | return ac, old | |
| 436 | + | return ac, old, cleanup | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | // handoffCoordinator prepares a cross-session ownership transfer before the | |
| 440 | + | // destination publishes sess.client. It never takes daemon/session locks while | |
| 441 | + | // taking sendMu, avoiding a d.mu/sendMu cycle with transport callbacks. | |
| 442 | + | func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) []renderLifecycleCleanup { | |
| 443 | + | cleanups := make([]renderLifecycleCleanup, 0, 2) | |
| 444 | + | if rc := from.renderCoordinator(); rc != nil { | |
| 445 | + | cleanups = append(cleanups, rc.beginDetach(current)) | |
| 446 | + | } | |
| 447 | + | current.sendMu.Lock() | |
| 448 | + | current.output.rebase() | |
| 449 | + | // Capture snapshots are attachment-owned. Discard them while the attachment | |
| 450 | + | // is exclusively held so the transfer releases panes no longer reachable | |
| 451 | + | // from the target session. | |
| 452 | + | current.captureFrames = nil | |
| 453 | + | current.sendMu.Unlock() | |
| 454 | + | _, cleanup := d.attachCoordinatorDeferred(target, old, current, true) | |
| 455 | + | return append(cleanups, cleanup) | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | func finishRenderLifecycleCleanups(cleanups []renderLifecycleCleanup) { | |
| 459 | + | for _, cleanup := range cleanups { | |
| 460 | + | cleanup.finish() | |
| 461 | + | } | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | // attachCoordinator is the sole direct attachment handoff. It creates at | |
| 465 | + | // most one coordinator for sess and changes its identity before any caller | |
| 466 | + | // can publish resize or render state for the new client. | |
| 467 | + | func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator { | |
| 468 | + | rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready) | |
| 469 | + | cleanup.finish() | |
| 470 | + | return rc | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) { | |
| 474 | + | sess.mu.Lock() | |
| 475 | + | rc := sess.renderCoordinator() | |
| 476 | + | if rc == nil { | |
| 477 | + | rc = newRenderCoordinator(renderCoordinatorOptions{ | |
| 478 | + | clock: d.clock, | |
| 479 | + | observer: d.runtimeObserver, | |
| 480 | + | wake: func(w renderWake) { | |
| 481 | + | // Never reread sess.client here: a wake is bound to both its | |
| 482 | + | // attachment and its coordinator incarnation. The second check in | |
| 483 | + | // paint occurs under sendMu, closing a park/resume race after this | |
| 484 | + | // unlocked coordinator validation. | |
| 485 | + | if w.lease != nil && rc.wakeCurrent(w) { | |
| 486 | + | d.paint(sess, w.lease.attachment, w.reset, w.lease) | |
| 487 | + | } | |
| 488 | + | }, | |
| 489 | + | ackReady: func() bool { | |
| 490 | + | sess.mu.Lock() | |
| 491 | + | attached := sess.client | |
| 492 | + | sess.mu.Unlock() | |
| 493 | + | if attached == nil { | |
| 494 | + | return false | |
| 495 | + | } | |
| 496 | + | attached.sendMu.Lock() | |
| 497 | + | ready := !attached.output.atCapacity() | |
| 498 | + | attached.sendMu.Unlock() | |
| 499 | + | return ready | |
| 500 | + | }, | |
| 501 | + | }) | |
| 502 | + | sess.installRenderCoordinator(rc) | |
| 503 | + | } | |
| 504 | + | sess.mu.Unlock() | |
| 505 | + | var cleanup renderLifecycleCleanup | |
| 506 | + | if old != nil { | |
| 507 | + | cleanup = rc.beginReplace(old, current, ready) | |
| 508 | + | } else if current != nil { | |
| 509 | + | rc.attachWithReadiness(current, ready) | |
| 510 | + | } | |
| 511 | + | return rc, cleanup | |
| 403 | 512 | } | |
| 404 | 513 | ||
| 405 | 514 | // resetScreenDefaultColors clears the known default foreground/background | |
| @@ -417,19 +526,36 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) { | |||
| 417 | 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 | 534 | if old == nil { | |
| 535 | + | d.attachmentCleanupWg.Go(cleanup.finish) | |
| 422 | 536 | 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) | |
| 427 | - | old.clearPreviousSession() | |
| 428 | - | old.setSession(nil) | |
| 429 | - | d.log.Info("client displaced") | |
| 430 | - | // Async + bounded: a dead or wedged old client must not stall the new | |
| 431 | - | // client's handshake. | |
| 432 | - | d.notifyDetachedAsync(old, ports.ReasonDetach) | |
| 544 | + | d.attachmentCleanupWg.Go(func() { | |
| 545 | + | if !blockedRender { | |
| 546 | + | // A healthy idle client observes the required replacement notice. Its | |
| 547 | + | // watchdog still bounds a peer that stops draining after this check. | |
| 548 | + | d.boundedSend(old, frameDetached(ports.ReasonDetach)) | |
| 549 | + | } | |
| 550 | + | _ = old.closeCapturedTransport(old.revokeTransport(oldTransport)) | |
| 551 | + | d.unregisterPreview(old) | |
| 552 | + | old.clearPreviousSession() | |
| 553 | + | old.setSession(nil) | |
| 554 | + | d.log.Info("client displaced") | |
| 555 | + | }) | |
| 556 | + | // Join independently: the close above releases a blocked render while the | |
| 557 | + | // new attachment is already fully published. | |
| 558 | + | d.attachmentCleanupWg.Go(cleanup.finish) | |
| 433 | 559 | } | |
| 434 | 560 | ||
| 435 | 561 | // 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 | 571 | wsz := tb.size | |
| 446 | 572 | tb.mu.Unlock() | |
| 447 | 573 | ||
| 574 | + | outerResizeAccepted := false | |
| 448 | 575 | if clientSize.Valid() && wsz != tabSize(clientSize) { | |
| 449 | - | d.resizeForFirstPaint(sess, ac, clientSize) | |
| 576 | + | outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize) | |
| 450 | 577 | } | |
| 451 | 578 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 452 | - | d.paint(sess, ac, true) | |
| 453 | - | d.activateTab(sess, tb) | |
| 579 | + | // Activation can synchronously resize a retained floating pane. An accepted | |
| 580 | + | // synchronous outer request already includes that pane and emits the reset, | |
| 581 | + | // but activation still performs its warmup work. | |
| 582 | + | activationResized := d.activateTabAfterResize(sess, tb, outerResizeAccepted) | |
| 583 | + | if !outerResizeAccepted && !activationResized { | |
| 584 | + | d.invalidateRenderNow(sess, ac, true, "client.go") | |
| 585 | + | } | |
| 454 | 586 | } | |
| 455 | 587 | ||
| 456 | 588 | // runConnLoop is the per-connection input router: it pumps client messages | |
| @@ -503,8 +635,8 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 503 | 635 | case ports.MsgAck: | |
| 504 | 636 | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | |
| 505 | 637 | ac.ackOutputState(ack.AckedStateNum) | |
| 506 | - | if reset, ok := ac.takeDeferredPaint(); ok { | |
| 507 | - | d.paint(sess, ac, reset) | |
| 638 | + | if rc := sess.renderCoordinator(); rc != nil { | |
| 639 | + | rc.notifyAck() | |
| 508 | 640 | } | |
| 509 | 641 | } | |
| 510 | 642 | case ports.MsgPing: | |
| @@ -525,7 +657,9 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||
| 525 | 657 | if !sess.detachIfCurrent(ac) { | |
| 526 | 658 | 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 | 663 | d.unregisterPreview(ac) | |
| 530 | 664 | sess.mu.Lock() | |
| 531 | 665 | ephemeral := sess.ephemeral | |
| @@ -576,57 +710,17 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||
| 576 | 710 | if !d.applyHostTheme(sess, ac, t, false) { | |
| 577 | 711 | return | |
| 578 | 712 | } | |
| 579 | - | d.paint(sess, ac, true) | |
| 713 | + | d.invalidateRender(sess, ac, true, "client.go") | |
| 580 | 714 | } | |
| 581 | 715 | ||
| 582 | 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: | |
| 587 | - | // firstPaint immediately emits the required full frame itself. | |
| 588 | - | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) { | |
| 589 | - | d.resizeWithPaint(sess, ac, sz, false) | |
| 590 | - | } | |
| 591 | - | ||
| 592 | - | func (d *Daemon) resizeWithPaint(sess *session, ac *attachedClient, sz domain.Size, schedulePaint bool) { | |
| 593 | - | if !sz.Valid() { | |
| 594 | - | return | |
| 595 | - | } | |
| 596 | - | d.exitCopyMode(ac) | |
| 597 | - | if ac != nil { | |
| 598 | - | // Suspend emission until every pane has adopted this geometry and the | |
| 599 | - | // bounded resize paint is armed. PTY readers remain independent. | |
| 600 | - | ac.sendMu.Lock() | |
| 601 | - | defer ac.sendMu.Unlock() | |
| 602 | - | if ac.currentSession() != sess { | |
| 603 | - | return | |
| 604 | - | } | |
| 605 | - | ac.size = sz | |
| 606 | - | } | |
| 607 | - | tbSize := tabSize(sz) | |
| 608 | - | ||
| 609 | - | sess.mu.Lock() | |
| 610 | - | tabs := append([]*tab(nil), sess.tabs...) | |
| 611 | - | sess.mu.Unlock() | |
| 612 | - | if len(tabs) == 0 { | |
| 613 | - | return | |
| 614 | - | } | |
| 615 | - | ||
| 616 | - | for _, tb := range tabs { | |
| 617 | - | tb.mu.Lock() | |
| 618 | - | tb.size = tbSize | |
| 619 | - | d.applyLayoutLocked(tb) | |
| 620 | - | tb.mu.Unlock() | |
| 621 | - | } | |
| 622 | - | // Only the shown tab's floating terminal tracks the client size. This is | |
| 623 | - | // outside tab.mu so its PTY resize cannot block tab state. | |
| 624 | - | d.resizeActiveFloating(sess.activeTab()) | |
| 625 | - | markSnapshotDirty(sess) | |
| 626 | - | if ac != nil && schedulePaint { | |
| 627 | - | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 628 | - | d.scheduleResizePaintLocked(sess, ac) | |
| 629 | - | } | |
| 720 | + | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The | |
| 721 | + | // returned value reports whether the synchronous request was accepted. | |
| 722 | + | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool { | |
| 723 | + | return d.requestTransactionalResize(sess, ac, sz, true) | |
| 630 | 724 | } | |
| 631 | 725 | ||
| 632 | 726 | // 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 | 730 | return | |
| 637 | 731 | } | |
| 638 | 732 | if sess.detachIfCurrent(ac) { | |
| 639 | - | ac.cancelResizePaint() | |
| 733 | + | if rc := sess.renderCoordinator(); rc != nil { | |
| 734 | + | rc.noteDetach(ac) | |
| 735 | + | } | |
| 640 | 736 | d.unregisterPreview(ac) | |
| 641 | 737 | if d.parkAttachment(sess, ac) { | |
| 642 | 738 | _ = ac.closeCapturedTransport(failed) | |
M internal/usecase/daemon/clipboard.go +1 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // Package daemon holds vev's server-side session multiplexer use case: the | |
| 2 | 2 | // 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 | 4 | package daemon | |
| 5 | 5 | ||
| 6 | 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: the | |
| 2 | - | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |
| 3 | - | // and VT screen, and the per-client debounced render scheduler. | |
| 4 | - | // | |
| 5 | - | // Concurrency model (sessions own one or more PTY-backed tabs): | |
| 6 | - | // | |
| 7 | - | // - Serve runs the accept loop. Each accepted connection is handled by its | |
| 8 | - | // own goroutine (handleConn): it reads the first frame and routes it to a | |
| 9 | - | // session create/attach, a list, or a kill. | |
| 10 | - | // - Per session there are exactly two long-lived goroutines: the PTY reader | |
| 11 | - | // (drains child output into the VT screen and pokes a cap-1 dirty channel) | |
| 12 | - | // and the render scheduler (debounces dirties and paints the attached | |
| 13 | - | // client). Both are tied to the session context and unwind when the | |
| 14 | - | // session is killed (pty.Close unblocks the reader; ctx cancel stops the | |
| 15 | - | // scheduler). | |
| 16 | - | // - The daemon exits (Serve returns) when the last session is removed, or | |
| 17 | - | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | - | // attached clients with ReasonServerShutdown). | |
| 19 | - | // | |
| 20 | - | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | - | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 1 | + | // Package daemon holds vev's server-side session multiplexer use case. | |
| 25 | 2 | package daemon | |
| 26 | 3 | ||
| 27 | 4 | import ( | |
| @@ -62,7 +39,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||
| 62 | 39 | if delta > 0 && rt.copyMode.AtBottom(*document) { | |
| 63 | 40 | rt.clearCopyModeLocked() | |
| 64 | 41 | rt.copyMu.Unlock() | |
| 65 | - | d.paint(sess, ac, true) | |
| 42 | + | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 66 | 43 | return | |
| 67 | 44 | } | |
| 68 | 45 | rt.copyMode.Move(*document, delta) | |
| @@ -72,7 +49,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||
| 72 | 49 | } | |
| 73 | 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 | 55 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |
| @@ -89,12 +66,12 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 89 | 66 | // Capture the live pane under pane.mu; all subsequent copy interaction uses | |
| 90 | 67 | // the immutable document while holding only copyMu. | |
| 91 | 68 | p.mu.Lock() | |
| 92 | - | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 69 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 93 | 70 | p.mu.Unlock() | |
| 94 | 71 | if !d.publishCopyMode(sess, ac, tb, p, document, nil) { | |
| 95 | 72 | return | |
| 96 | 73 | } | |
| 97 | - | d.paint(sess, ac, true) | |
| 74 | + | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 98 | 75 | } | |
| 99 | 76 | ||
| 100 | 77 | // 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 | 167 | rt.copyMu.Unlock() | |
| 191 | 168 | ||
| 192 | 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 | 197 | changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data) | |
| 221 | 198 | rt.copyMu.Unlock() | |
| 222 | 199 | if changed || closeSearch || accepted { | |
| 223 | - | d.paint(sess, ac, true) | |
| 200 | + | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 224 | 201 | } | |
| 225 | 202 | return | |
| 226 | 203 | } | |
| @@ -309,11 +286,11 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 309 | 286 | rt.copyMu.Unlock() | |
| 310 | 287 | } | |
| 311 | 288 | if exit { | |
| 312 | - | d.paint(sess, ac, true) | |
| 289 | + | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 313 | 290 | return | |
| 314 | 291 | } | |
| 315 | 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 | 363 | rt.copyMu.Unlock() | |
| 387 | 364 | ||
| 388 | 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 | 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 | 405 | func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) { | |
| 434 | 406 | if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | |
| 435 | 407 | return frame, nil | |
M internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
| @@ -54,7 +54,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||
| 54 | 54 | sess.client = ac | |
| 55 | 55 | ac.setSession(sess) | |
| 56 | 56 | closing.mu.Lock() | |
| 57 | - | document := scopy.NewSnapshot(closing.scrollback, closing.screen.Frame) | |
| 57 | + | document := scopy.NewSnapshot(closing.history, closing.screen.Frame) | |
| 58 | 58 | closing.mu.Unlock() | |
| 59 | 59 | ac.overlays.copyMu.Lock() | |
| 60 | 60 | ac.overlays.copyPane = closing | |
| @@ -94,7 +94,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||
| 94 | 94 | tb := sess.activeTab() | |
| 95 | 95 | p := tb.focusedPane() | |
| 96 | 96 | p.mu.Lock() | |
| 97 | - | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 97 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 98 | 98 | p.mu.Unlock() | |
| 99 | 99 | ||
| 100 | 100 | // Hold session validation after publication and make the captured target | |
| @@ -157,7 +157,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||
| 157 | 157 | tb := sess.activeTab() | |
| 158 | 158 | p := tb.focusedPane() | |
| 159 | 159 | p.mu.Lock() | |
| 160 | - | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 160 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 161 | 161 | p.mu.Unlock() | |
| 162 | 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 | 112 | tb := sess.activeTab() | |
| 113 | 113 | pane := tb.focusedPane() | |
| 114 | 114 | pane.mu.Lock() | |
| 115 | - | snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame) | |
| 115 | + | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame) | |
| 116 | 116 | pane.mu.Unlock() | |
| 117 | 117 | mode := scopy.NewMode(snap) | |
| 118 | 118 | bars := barState{status: sess.statusSegments(true)} | |
| @@ -147,7 +147,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 147 | 147 | tb := sess.activeTab() | |
| 148 | 148 | tb.focusedPane().screen = vt.NewScreen(12, 3) | |
| 149 | 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 | 151 | mode := scopy.NewMode(snap) | |
| 152 | 152 | ||
| 153 | 153 | bars := barState{status: sess.statusSegments(true)} | |
| @@ -166,7 +166,7 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) { | |||
| 166 | 166 | writes := make(chan []byte, 1) | |
| 167 | 167 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 168 | 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 | 170 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 171 | 171 | ||
| 172 | 172 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| @@ -293,8 +293,8 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) { | |||
| 293 | 293 | p, _ := newBlockingPTY(t) | |
| 294 | 294 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 295 | 295 | pane := sess.tabs[0].focusedPane() | |
| 296 | - | pane.scrollback = scopy.NewScrollback(4) | |
| 297 | - | pane.scrollback.Append(testRow("old alpha")) | |
| 296 | + | installTestHistory(pane, vt.HistoryConfig{MaxRows: 4}) | |
| 297 | + | pane.history.Append(testRow("old alpha")) | |
| 298 | 298 | copy(pane.screen.Frame.Row(0), testRow("live alpha")) | |
| 299 | 299 | ||
| 300 | 300 | d.enterCopyMode(sess, ac) | |
| @@ -322,9 +322,9 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||
| 322 | 322 | writes := make(chan []byte, 1) | |
| 323 | 323 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 324 | 324 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 325 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 326 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 ")) | |
| 327 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 ")) | |
| 325 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | |
| 326 | + | sess.tabs[0].focusedPane().history.Append(testRow("old1 ")) | |
| 327 | + | sess.tabs[0].focusedPane().history.Append(testRow("old2 ")) | |
| 328 | 328 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 329 | 329 | ||
| 330 | 330 | d.enterCopyMode(sess, ac) | |
| @@ -358,7 +358,7 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||
| 358 | 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 | 362 | followup := awaitFrame(t, sends, ports.MsgOutput) | |
| 363 | 363 | followupMsg, err := ports.UnmarshalOutput(followup.Payload) | |
| 364 | 364 | require.NoError(t, err) | |
| @@ -385,11 +385,26 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 385 | 385 | p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe() | |
| 386 | 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 | 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 | 404 | var hg sync.WaitGroup | |
| 391 | 405 | hg.Go(func() { d.handleConn(tr) }) | |
| 392 | 406 | awaitFrame(t, sends, ports.MsgWelcome) | |
| 407 | + | advanceRender() // initial coordinator invalidation | |
| 393 | 408 | awaitFrame(t, sends, ports.MsgOutput) | |
| 394 | 409 | ||
| 395 | 410 | for i := range 12 { | |
| @@ -412,7 +427,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 412 | 427 | } | |
| 413 | 428 | p.mu.Lock() | |
| 414 | 429 | defer p.mu.Unlock() | |
| 415 | - | return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 12 | |
| 430 | + | return scopy.NewSnapshot(p.history, p.screen.Frame).Len() >= 12 | |
| 416 | 431 | }, 2*time.Second, 5*time.Millisecond) | |
| 417 | 432 | ||
| 418 | 433 | sess := firstSession(d) | |
| @@ -420,9 +435,9 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 420 | 435 | ac := sess.client | |
| 421 | 436 | require.NotNil(t, ac) | |
| 422 | 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 | 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 | 441 | d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'}) | |
| 427 | 442 | ||
| 428 | 443 | var payload string | |
| @@ -448,7 +463,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 448 | 463 | func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | |
| 449 | 464 | p, _ := newBlockingPTY(t) | |
| 450 | 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 | 467 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 453 | 468 | ||
| 454 | 469 | d.enterCopyMode(sess, ac) | |
| @@ -482,9 +497,9 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||
| 482 | 497 | t.Run(tc.name, func(t *testing.T) { | |
| 483 | 498 | p, _ := newBlockingPTY(t) | |
| 484 | 499 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 485 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 486 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 ")) | |
| 487 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 ")) | |
| 500 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | |
| 501 | + | sess.tabs[0].focusedPane().history.Append(testRow("old1 ")) | |
| 502 | + | sess.tabs[0].focusedPane().history.Append(testRow("old2 ")) | |
| 488 | 503 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 489 | 504 | ||
| 490 | 505 | d.enterCopyMode(sess, ac) | |
| @@ -502,7 +517,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||
| 502 | 517 | func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) { | |
| 503 | 518 | p, _ := newBlockingPTY(t) | |
| 504 | 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 | 521 | longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1) | |
| 507 | 522 | sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1) | |
| 508 | 523 | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine)) | |
| @@ -527,7 +542,7 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) { | |||
| 527 | 542 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 528 | 543 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 529 | 544 | d.clock = clk | |
| 530 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 545 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | |
| 531 | 546 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 532 | 547 | ||
| 533 | 548 | d.enterCopyMode(sess, ac) | |
| @@ -551,7 +566,7 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) { | |||
| 551 | 566 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 552 | 567 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 553 | 568 | d.clock = clk | |
| 554 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 569 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) | |
| 555 | 570 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 556 | 571 | ||
| 557 | 572 | d.enterCopyMode(sess, ac) | |
| @@ -594,7 +609,7 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | |||
| 594 | 609 | t.Run(tc.name, func(t *testing.T) { | |
| 595 | 610 | p, _ := newBlockingPTY(t) | |
| 596 | 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 | 613 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 599 | 614 | ||
| 600 | 615 | d.enterCopyMode(sess, ac) | |
| @@ -643,7 +658,7 @@ func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) { | |||
| 643 | 658 | func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | |
| 644 | 659 | p, _ := newBlockingPTY(t) | |
| 645 | 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 | 662 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 648 | 663 | ||
| 649 | 664 | done := make(chan struct{}) | |
| @@ -665,7 +680,7 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | |||
| 665 | 680 | d.handleInput(sess, ac, []byte("q")) | |
| 666 | 681 | }) | |
| 667 | 682 | wg.Go(func() { | |
| 668 | - | d.paint(sess, ac, true) | |
| 683 | + | d.paint(sess, ac, true, nil) | |
| 669 | 684 | }) | |
| 670 | 685 | } | |
| 671 | 686 | wg.Wait() | |
| @@ -717,11 +732,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) { | |||
| 717 | 732 | var want *pane | |
| 718 | 733 | if tc.useFloating { | |
| 719 | 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 | 736 | want.screen.Write([]byte("flt-live")) | |
| 722 | 737 | } else { | |
| 723 | 738 | want = main | |
| 724 | - | want.scrollback.Append(testRow("one-old")) | |
| 739 | + | want.history.Append(testRow("one-old")) | |
| 725 | 740 | want.screen.Write([]byte("one-live")) | |
| 726 | 741 | } | |
| 727 | 742 | ||
| @@ -758,11 +773,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||
| 758 | 773 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 759 | 774 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |
| 760 | 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 | 778 | fp.screen.Write([]byte("live")) | |
| 764 | 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 | 781 | fp.mu.Unlock() | |
| 767 | 782 | ||
| 768 | 783 | d.enterCopyMode(sess, ac) | |
| @@ -784,7 +799,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||
| 784 | 799 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 785 | 800 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |
| 786 | 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 | 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: the | |
| 2 | - | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |
| 3 | - | // and VT screen, and the per-client debounced render scheduler. | |
| 4 | - | // | |
| 5 | - | // Concurrency model (sessions own one or more PTY-backed tabs): | |
| 6 | - | // | |
| 7 | - | // - Serve runs the accept loop. Each accepted connection is handled by its | |
| 8 | - | // own goroutine (handleConn): it reads the first frame and routes it to a | |
| 9 | - | // session create/attach, a list, or a kill. | |
| 10 | - | // - Per session there are exactly two long-lived goroutines: the PTY reader | |
| 11 | - | // (drains child output into the VT screen and pokes a cap-1 dirty channel) | |
| 12 | - | // and the render scheduler (debounces dirties and paints the attached | |
| 13 | - | // client). Both are tied to the session context and unwind when the | |
| 14 | - | // session is killed (pty.Close unblocks the reader; ctx cancel stops the | |
| 15 | - | // scheduler). | |
| 16 | - | // - The daemon exits (Serve returns) when the last session is removed, or | |
| 17 | - | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | - | // attached clients with ReasonServerShutdown). Client detach does not end | |
| 19 | - | // a session: ephemeral sessions stay in memory until explicitly killed or | |
| 20 | - | // until the daemon exits, and named sessions can also persist on disk. | |
| 21 | - | // | |
| 22 | - | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 23 | - | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 24 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 25 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 26 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 1 | + | // Package daemon holds vev's server-side session multiplexer use case. | |
| 27 | 2 | package daemon | |
| 28 | 3 | ||
| 29 | 4 | import ( | |
| @@ -41,14 +16,13 @@ import ( | |||
| 41 | 16 | "github.com/bnema/vev/internal/persist" | |
| 42 | 17 | "github.com/bnema/vev/internal/ports" | |
| 43 | 18 | "github.com/bnema/vev/internal/usecase/keys" | |
| 19 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 44 | 20 | ) | |
| 45 | 21 | ||
| 46 | 22 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | |
| 47 | 23 | // sustained floods step toward the maximum to reduce frame/syscall pressure. | |
| 48 | 24 | const ( | |
| 49 | 25 | minDebounceInterval = 2 * time.Millisecond | |
| 50 | - | maxDebounceInterval = 16 * time.Millisecond | |
| 51 | - | debounceStep = 2 * time.Millisecond | |
| 52 | 26 | maxSyncUpdateDuration = 500 * time.Millisecond | |
| 53 | 27 | ) | |
| 54 | 28 | ||
| @@ -117,17 +91,33 @@ type Daemon struct { | |||
| 117 | 91 | // beforeRecentSessionHandoff is a deterministic test seam for the narrow | |
| 118 | 92 | // interval between JRS validation and its committed hand-off. | |
| 119 | 93 | beforeRecentSessionHandoff func() | |
| 120 | - | ||
| 121 | - | ptys ports.PTYFactory | |
| 122 | - | clock ports.Clock | |
| 123 | - | log *slog.Logger | |
| 124 | - | baseEnv []string | |
| 125 | - | shell string | |
| 126 | - | shellArgs []string | |
| 127 | - | persist *persist.Persister | |
| 128 | - | persistEnabled bool | |
| 129 | - | snaps ports.SnapshotStore | |
| 130 | - | snapsEnabled bool | |
| 94 | + | ptys ports.PTYFactory | |
| 95 | + | clock ports.Clock | |
| 96 | + | log *slog.Logger | |
| 97 | + | runtimeObserver ports.RuntimeObserver | |
| 98 | + | baseEnv []string | |
| 99 | + | shell string | |
| 100 | + | shellArgs []string | |
| 101 | + | persist *persist.Persister | |
| 102 | + | persistEnabled bool | |
| 103 | + | snaps ports.SnapshotStore | |
| 104 | + | snapsEnabled bool | |
| 105 | + | snapshotMarshal func(snapcodec.Session) ([]byte, error) | |
| 106 | + | snapshotJobs chan *snapshotCapture | |
| 107 | + | snapshotWorkerMu sync.Mutex | |
| 108 | + | snapshotWorkerID uint64 | |
| 109 | + | snapshotWorkerCtx context.Context | |
| 110 | + | snapshotWorkerCancel context.CancelFunc | |
| 111 | + | snapshotWorkerDone chan struct{} | |
| 112 | + | snapshotWorkerFlush chan struct{} | |
| 113 | + | snapshotWorkerFinalWake chan struct{} | |
| 114 | + | // snapshotFinalJobs coalesces terminal captures by session when the bounded | |
| 115 | + | // regular queue is full. It retains at most snapshotFinalQueueCapacity named | |
| 116 | + | // sessions, each with only its newest terminal state while the worker blocks. | |
| 117 | + | snapshotFinalJobs map[*session]*snapshotCapture | |
| 118 | + | snapshotFinalOrder []*session | |
| 119 | + | snapshotWorkerClosing bool | |
| 120 | + | snapshotWorkerInFlight *snapshotCapture | |
| 131 | 121 | restoreDone chan struct{} | |
| 132 | 122 | restoreOnce sync.Once | |
| 133 | 123 | procCwd func(int) (string, error) | |
| @@ -160,8 +150,14 @@ type Daemon struct { | |||
| 160 | 150 | done chan struct{} | |
| 161 | 151 | doneOnce sync.Once | |
| 162 | 152 | ||
| 163 | - | sessWg sync.WaitGroup // PTY reader + scheduler goroutines | |
| 153 | + | // sessWg owns attention animation, bar-script polling, CWD sampling, | |
| 154 | + | // snapshot save/restore, and floating-session launch goroutines. | |
| 155 | + | sessWg sync.WaitGroup | |
| 164 | 156 | 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 | 163 | type parkedAttachment struct { | |
| @@ -194,6 +190,13 @@ func (s stoppedSession) same(other stoppedSession) bool { | |||
| 194 | 190 | ||
| 195 | 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 | 200 | // WithShell overrides the command (and its args) each session spawns. The | |
| 198 | 201 | // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a | |
| 199 | 202 | // deterministic program. | |
| @@ -312,6 +315,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 312 | 315 | done: make(chan struct{}), | |
| 313 | 316 | restoreDone: make(chan struct{}), | |
| 314 | 317 | animWake: make(chan struct{}, 1), | |
| 318 | + | snapshotMarshal: snapcodec.Marshal, | |
| 319 | + | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | |
| 315 | 320 | resumeParkGrace: defaultResumeParkGrace, | |
| 316 | 321 | barScripts: &barScriptState{ | |
| 317 | 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 | 389 | if d.snapsEnabled { | |
| 390 | + | d.startSnapshotEncodeWorker() | |
| 385 | 391 | d.sessWg.Go(func() { | |
| 386 | 392 | d.snapshotSaver(d.serveCtx) | |
| 387 | 393 | }) | |
| @@ -425,12 +431,17 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||
| 425 | 431 | // remaining parked connections, so graceful notices are never raced by the | |
| 426 | 432 | // force-close. Finally drain the conn handlers, run one defensive sweep in | |
| 427 | 433 | // 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 | 435 | d.shutdownAll(ports.ReasonServerShutdown) | |
| 430 | 436 | d.waitNotifies() | |
| 431 | 437 | d.hardCancel() | |
| 432 | 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 | 442 | d.connWg.Wait() | |
| 443 | + | d.attachmentCleanupWg.Wait() | |
| 444 | + | d.stopSnapshotEncodeWorker() | |
| 434 | 445 | d.shutdownAll(ports.ReasonServerShutdown) | |
| 435 | 446 | d.sessWg.Wait() | |
| 436 | 447 | d.waitNotifies() | |
| @@ -459,7 +470,9 @@ func (d *Daemon) shutdownAll(reason uint8) { | |||
| 459 | 470 | return | |
| 460 | 471 | } | |
| 461 | 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 | 633 | 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 | 653 | d.clientGone(sess, ac, tr, false) | |
| 625 | 654 | return | |
| 626 | 655 | } | |
| @@ -637,6 +666,23 @@ type protoErr struct { | |||
| 637 | 666 | ||
| 638 | 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 | 686 | // route resolves a Hello to a session and a freshly attached client, creating | |
| 641 | 687 | // the session for ephemeral/new intents. | |
| 642 | 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 | 735 | d.purgeParkedForSessionLocked(sess) | |
| 690 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)}) | |
| 691 | - | sess.mu.Lock() | |
| 692 | - | sess.terminal = term | |
| 693 | - | sess.mu.Unlock() | |
| 694 | - | d.mu.Unlock() | |
| 695 | - | d.detachReplacedClient(old) | |
| 696 | - | return sess, ac, nil | |
| 736 | + | return sess, d.finishAttach(sess, tr, sz, term, h), nil | |
| 697 | 737 | ||
| 698 | 738 | case ports.IntentEphemeral: | |
| 699 | 739 | name := d.allocEphemeralNameLocked() | |
| @@ -703,13 +743,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 703 | 743 | return nil, nil, err | |
| 704 | 744 | } | |
| 705 | 745 | d.purgeParkedForSessionLocked(sess) | |
| 706 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)}) | |
| 707 | - | sess.mu.Lock() | |
| 708 | - | sess.terminal = term | |
| 709 | - | sess.mu.Unlock() | |
| 710 | - | d.mu.Unlock() | |
| 711 | - | d.detachReplacedClient(old) | |
| 712 | - | return sess, ac, nil | |
| 746 | + | return sess, d.finishAttach(sess, tr, sz, term, h), nil | |
| 713 | 747 | ||
| 714 | 748 | case ports.IntentNew: | |
| 715 | 749 | if h.Name == "" { | |
| @@ -730,13 +764,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 730 | 764 | return nil, nil, err | |
| 731 | 765 | } | |
| 732 | 766 | d.purgeParkedForSessionLocked(sess) | |
| 733 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)}) | |
| 734 | - | sess.mu.Lock() | |
| 735 | - | sess.terminal = term | |
| 736 | - | sess.mu.Unlock() | |
| 737 | - | d.mu.Unlock() | |
| 738 | - | d.detachReplacedClient(old) | |
| 739 | - | return sess, ac, nil | |
| 767 | + | return sess, d.finishAttach(sess, tr, sz, term, h), nil | |
| 740 | 768 | ||
| 741 | 769 | case ports.IntentAttach: | |
| 742 | 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 | 785 | d.purgeParkedForSessionLocked(sess) | |
| 758 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)}) | |
| 759 | - | sess.mu.Lock() | |
| 760 | - | sess.terminal = term | |
| 761 | - | sess.mu.Unlock() | |
| 762 | - | d.mu.Unlock() | |
| 763 | - | d.detachReplacedClient(old) | |
| 764 | - | return sess, ac, nil | |
| 786 | + | return sess, d.finishAttach(sess, tr, sz, term, h), nil | |
| 765 | 787 | ||
| 766 | 788 | default: | |
| 767 | 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 | 142 | 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 | 146 | <-f.release | |
| 147 | 147 | return nil, io.ErrClosedPipe | |
| 148 | 148 | } | |
| @@ -154,8 +154,8 @@ func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory { | |||
| 154 | 154 | t.Helper() | |
| 155 | 155 | f := portsmocks.NewMockPTYFactory(t) | |
| 156 | 156 | var normal domain.Size | |
| 157 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 158 | - | func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) { | |
| 157 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 158 | + | func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) { | |
| 159 | 159 | if !normal.Valid() { | |
| 160 | 160 | normal = sz | |
| 161 | 161 | } | |
| @@ -174,8 +174,8 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory { | |||
| 174 | 174 | var mu sync.Mutex | |
| 175 | 175 | var normal domain.Size | |
| 176 | 176 | next := 0 | |
| 177 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 178 | - | func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) { | |
| 177 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 178 | + | func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) { | |
| 179 | 179 | mu.Lock() | |
| 180 | 180 | defer mu.Unlock() | |
| 181 | 181 | if !normal.Valid() { | |
| @@ -286,6 +286,54 @@ func listSessions(t *testing.T, d *Daemon) ports.Sessions { | |||
| 286 | 286 | 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 | 337 | func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports.Frame) { | |
| 290 | 338 | t.Helper() | |
| 291 | 339 | tr := portsmocks.NewMockTransport(t) | |
| @@ -395,6 +443,52 @@ func (r realTimer) C() <-chan time.Time { return r.t.C } | |||
| 395 | 443 | func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) } | |
| 396 | 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 | 492 | // TestServeReturnsDespiteWedgedClientOnShutdown: killSession's teardown must | |
| 399 | 493 | // not be gated behind the Detached notice. The client transport wedges every | |
| 400 | 494 | // 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 | 154 | // activateTab performs the work associated with making a tab the destination. | |
| 155 | 155 | // It is deliberately idempotent: every tab transition can use this single | |
| 156 | 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 | 164 | if d == nil || sess == nil || tb == nil { | |
| 159 | - | return | |
| 165 | + | return false | |
| 160 | 166 | } | |
| 161 | 167 | // A headless session has no actual terminal destination. Deferring warmup | |
| 162 | 168 | // until firstPaint keeps restored tabs cold and avoids launching children | |
| @@ -166,10 +172,20 @@ func (d *Daemon) activateTab(sess *session, tb *tab) { | |||
| 166 | 172 | sess.mu.Unlock() | |
| 167 | 173 | d.exitCopyMode(ac) | |
| 168 | 174 | if ac == nil { | |
| 169 | - | return | |
| 175 | + | return false | |
| 170 | 176 | } | |
| 171 | 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 | 191 | // 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 | 201 | cfg := d.currentFloatingConfig() | |
| 186 | 202 | tb.mu.Lock() | |
| 203 | + | wasVisible := tb.floating.state == floatingVisible | |
| 187 | 204 | start, generation := tb.toggleFloatingLocked() | |
| 188 | 205 | // desiredVisible is meaningful only while a launch is warming. Installed | |
| 189 | 206 | // slots derive their resize and paint work from the resulting slot state. | |
| 190 | 207 | visible := tb.floating.state == floatingVisible | |
| 208 | + | hidden := wasVisible && tb.floating.state == floatingHidden | |
| 191 | 209 | tb.mu.Unlock() | |
| 192 | 210 | if start { | |
| 193 | 211 | d.launchFloating(sess, tb, cfg, generation, true) | |
| 194 | 212 | if ac != nil { | |
| 195 | - | d.paint(sess, ac, true) | |
| 213 | + | d.invalidateRender(sess, ac, true, "floating.go") | |
| 196 | 214 | } | |
| 197 | 215 | return nil | |
| 198 | 216 | } | |
| 199 | - | if visible { | |
| 200 | - | d.resizeActiveFloating(tb) | |
| 217 | + | if hidden { | |
| 218 | + | d.invalidateRender(sess, ac, true, "floating.go") | |
| 219 | + | return nil | |
| 201 | 220 | } | |
| 202 | - | if ac != nil { | |
| 203 | - | d.paint(sess, ac, true) | |
| 221 | + | if visible { | |
| 222 | + | tb.mu.Lock() | |
| 223 | + | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |
| 224 | + | tb.mu.Unlock() | |
| 225 | + | d.requestTransactionalResize(sess, ac, size, true) | |
| 204 | 226 | } | |
| 205 | 227 | return nil | |
| 206 | 228 | } | |
| @@ -218,6 +240,40 @@ func (d *Daemon) startFloating(sess *session, tb *tab, visible bool) { | |||
| 218 | 240 | ||
| 219 | 241 | // floatingLaunchSpec is an immutable snapshot of one launch. It is built | |
| 220 | 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 | 277 | type floatingLaunchSpec struct { | |
| 222 | 278 | sessionName string | |
| 223 | 279 | cwd string | |
| @@ -233,17 +289,25 @@ type floatingLaunchSpec struct { | |||
| 233 | 289 | } | |
| 234 | 290 | ||
| 235 | 291 | // 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 | 293 | func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) { | |
| 238 | 294 | spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen) | |
| 239 | 295 | if err != nil { | |
| 240 | 296 | d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err) | |
| 241 | 297 | 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 | 304 | // Count the launch before its goroutine starts. It remains counted through | |
| 244 | - | // install, including the reader/scheduler Adds, so shutdown cannot return | |
| 305 | + | // install, including the reader/coordinator Adds, so shutdown cannot return | |
| 245 | 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 | 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 | 364 | // openAndInstallFloating runs entirely in the launch worker. No PTY operation | |
| 301 | 365 | // occurs under tb.mu; the pane is fully initialized before publication. | |
| 302 | 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 | 383 | if err != nil { | |
| 305 | 384 | d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err) | |
| 306 | 385 | return | |
| @@ -309,7 +388,14 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||
| 309 | 388 | p.rect = spec.geometry.Inner | |
| 310 | 389 | p.popupGeometry = spec.geometry | |
| 311 | 390 | 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 | 399 | // The reader may run as soon as install returns; make its exit policy | |
| 314 | 400 | // immutable before publishing the pane to the slot. | |
| 315 | 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 | 429 | d.reapplyThemeSession(sess) | |
| 344 | 430 | d.startPaneGoroutines(sess, tb, p) | |
| 345 | 431 | if visible { | |
| 346 | - | d.resizeActiveFloating(tb) | |
| 347 | 432 | sess.mu.Lock() | |
| 348 | 433 | ac := sess.client | |
| 349 | 434 | sess.mu.Unlock() | |
| 350 | - | if ac != nil { | |
| 351 | - | d.paint(sess, ac, true) | |
| 352 | - | } | |
| 435 | + | tb.mu.Lock() | |
| 436 | + | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |
| 437 | + | tb.mu.Unlock() | |
| 438 | + | d.requestTransactionalResize(sess, ac, size, true) | |
| 353 | 439 | } | |
| 354 | 440 | } | |
| 355 | 441 | ||
| @@ -367,9 +453,12 @@ func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64 | |||
| 367 | 453 | sess.mu.Lock() | |
| 368 | 454 | ac := sess.client | |
| 369 | 455 | sess.mu.Unlock() | |
| 456 | + | if ac != nil { | |
| 457 | + | ac.pruneCaptureFrames(p) | |
| 458 | + | } | |
| 370 | 459 | copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p) | |
| 371 | 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 | 472 | tb.mu.Unlock() | |
| 384 | 473 | if ac != nil { | |
| 385 | 474 | ac.overlays.clearCopyModeForPane(p) | |
| 475 | + | ac.pruneCaptureFrames(p) | |
| 386 | 476 | } | |
| 387 | 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 composed | |
| 89 | - | // destination. In particular it never writes the VT screen frame: that frame | |
| 90 | - | // is shared with the PTY reader and is the source for future renders. | |
| 91 | - | func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) { | |
| 92 | - | var frame renderer.Frame | |
| 93 | - | if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height { | |
| 94 | - | frame = cache.floatingFrame | |
| 95 | - | for y := 0; y < base.Height; y++ { | |
| 96 | - | copy(frame.Row(y), base.Row(y)) | |
| 97 | - | } | |
| 98 | - | } else { | |
| 99 | - | frame = base.Clone() | |
| 100 | - | if cache != nil { | |
| 101 | - | cache.floatingFrame = frame | |
| 102 | - | } | |
| 103 | - | } | |
| 104 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme) | |
| 105 | - | // The PTY reader mutates both the frame and its damage under p.mu. Keep | |
| 106 | - | // every read of them, including the blit, in the same critical section. | |
| 107 | - | // In particular, Frame is not safe to retain as an alias after unlocking. | |
| 108 | - | p.mu.Lock() | |
| 109 | - | // Committed popup geometry is content-relative; translate it exactly once | |
| 110 | - | // while holding the same lock that protects the committed state and frame. | |
| 111 | - | geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y) | |
| 112 | - | title := p.displayTitleLocked() | |
| 113 | - | titleGeneration := p.title.generation | |
| 114 | - | screenDamage := p.screen.Damage() | |
| 115 | - | blitPaneFrame(frame, geometry.Inner, p.screen.Frame, false, theme) | |
| 88 | + | func composeCapturedFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, floating capturedFloatingRenderState, content domain.Rect, layoutSnap capturedTabLayout, theme themeui.Theme, cache composeCacheInput, full bool) (renderer.Frame, []renderer.Damage) { | |
| 89 | + | frame := base.Clone() | |
| 90 | + | legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid} | |
| 91 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme) | |
| 92 | + | geometry := floating.geometry.translate(content.X, content.Y) | |
| 93 | + | blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, theme) | |
| 116 | 94 | damage := append([]renderer.Damage(nil), baseDamage...) | |
| 117 | - | for _, d := range screenDamage { | |
| 95 | + | for _, d := range floating.pane.damage { | |
| 118 | 96 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | |
| 119 | 97 | } | |
| 120 | - | p.screen.ClearDamage() | |
| 121 | - | p.mu.Unlock() | |
| 122 | - | ||
| 123 | - | // The committed geometry, rather than the requested config geometry, is | |
| 124 | - | // what was actually rendered. Track it so movement (including a same-size | |
| 125 | - | // resize) cannot reuse a cached popup at its former position. | |
| 126 | - | popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry | |
| 127 | - | titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration | |
| 128 | - | drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border) | |
| 129 | - | if cache != nil { | |
| 130 | - | cache.floating = p | |
| 131 | - | cache.floatingGeneration = generation | |
| 132 | - | cache.floatingGeometry = geometry | |
| 133 | - | cache.floatingTitleGeneration = titleGeneration | |
| 134 | - | } | |
| 98 | + | popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry | |
| 99 | + | titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration | |
| 100 | + | drawFloatingBorder(frame, geometry.Bounds, floating.title, newThemeStyles(theme).border) | |
| 135 | 101 | if full || popupChanged { | |
| 136 | - | return frame, []renderer.Damage{renderer.FullRedraw()}, geometry | |
| 102 | + | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 137 | 103 | } | |
| 138 | 104 | if titleChanged && geometry.Bounds.Height >= 3 { | |
| 139 | 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, geometry | |
| 107 | + | return frame, damage | |
| 142 | 108 | } | |
| 143 | 109 | ||
| 144 | 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 | 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 | 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 | 36 | func TestFloatingGeometryTranslate(t *testing.T) { | |
| 18 | 37 | geometry := floatingGeometry{ | |
| 19 | 38 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | |
| @@ -200,7 +219,7 @@ func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T) | |||
| 200 | 219 | p.rect, p.popupGeometry = initial.Inner, initial | |
| 201 | 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 | 223 | require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY") | |
| 205 | 224 | require.Equal(t, tt.next.Inner, p.rect) | |
| 206 | 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 | 290 | func BenchmarkComposeFloatingFrameCached(b *testing.B) { | |
| 307 | 291 | p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18}) | |
| 308 | 292 | p.title.displayFallback = "float" | |
| @@ -364,14 +348,14 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||
| 364 | 348 | d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42}) | |
| 365 | 349 | require.Empty(t, pty.sizes()) | |
| 366 | 350 | ||
| 367 | - | // Showing attempts the current size before paint. A failed resize leaves the | |
| 368 | - | // old screen and rect untouched, so a later hide/show can retry. | |
| 351 | + | // Showing attempts the current size before paint. A failed resize retains | |
| 352 | + | // the old screen, but commits the new popup rect so capture clips/pads it. | |
| 369 | 353 | require.NoError(t, d.toggleFloating(sess, nil)) | |
| 370 | 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 | 356 | require.Equal(t, initial.Width, floating.screen.Frame.Width) | |
| 373 | 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 | 360 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | |
| 377 | 361 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | |
| @@ -397,7 +381,7 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) | |||
| 397 | 381 | d := newTestDaemon(t, nil, stubClock{}) | |
| 398 | 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 | 385 | tb.mu.Lock() | |
| 402 | 386 | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | |
| 403 | 387 | copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y)) | |
| @@ -423,7 +407,7 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||
| 423 | 407 | p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4} | |
| 424 | 408 | d := newTestDaemon(t, nil, stubClock{}) | |
| 425 | 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 | 411 | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | |
| 428 | 412 | require.Equal(t, floatingGeometry{}, p.popupGeometry) | |
| 429 | 413 | require.Equal(t, 5, p.screen.Frame.Width) | |
| @@ -439,11 +423,11 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||
| 439 | 423 | done1 := make(chan bool, 1) | |
| 440 | 424 | done2 := make(chan bool, 1) | |
| 441 | 425 | secondStarted := make(chan struct{}) | |
| 442 | - | go func() { done1 <- d.resizeFloatingPane(p, first) }() | |
| 426 | + | go func() { done1 <- applyFloatingResizePlanForTest(d, p, first) }() | |
| 443 | 427 | <-pty.entered | |
| 444 | 428 | go func() { | |
| 445 | 429 | close(secondStarted) | |
| 446 | - | done2 <- d.resizeFloatingPane(p, second) | |
| 430 | + | done2 <- applyFloatingResizePlanForTest(d, p, second) | |
| 447 | 431 | }() | |
| 448 | 432 | <-secondStarted | |
| 449 | 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 | 36 | var releaseOnce sync.Once | |
| 37 | 37 | releaseOpen := func() { releaseOnce.Do(func() { close(release) }) } | |
| 38 | 38 | t.Cleanup(releaseOpen) | |
| 39 | - | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 40 | - | func(command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) { | |
| 39 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 40 | + | func(_ context.Context, command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) { | |
| 41 | 41 | opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size} | |
| 42 | 42 | <-release | |
| 43 | 43 | return result, openErr | |
| @@ -364,7 +364,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) { | |||
| 364 | 364 | ||
| 365 | 365 | // Establish a renderer shadow first. The EOF repaint must reset it and | |
| 366 | 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 | 368 | baseline := awaitFrame(t, sends, ports.MsgOutput) | |
| 369 | 369 | baselineOutput, err := ports.UnmarshalOutput(baseline.Payload) | |
| 370 | 370 | require.NoError(t, err) | |
| @@ -467,8 +467,8 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) { | |||
| 467 | 467 | t.Run(tt.name, func(t *testing.T) { | |
| 468 | 468 | factory := portsmocks.NewMockPTYFactory(t) | |
| 469 | 469 | opened := make(chan string, 1) | |
| 470 | - | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 471 | - | func(_ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) { | |
| 470 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 471 | + | func(_ context.Context, _ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) { | |
| 472 | 472 | opened <- cwd | |
| 473 | 473 | return nil, io.ErrUnexpectedEOF | |
| 474 | 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: the | |
| 2 | - | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |
| 3 | - | // and VT screen, and the per-client debounced render scheduler. | |
| 4 | - | // | |
| 5 | - | // Concurrency model (sessions own one or more PTY-backed tabs): | |
| 6 | - | // | |
| 7 | - | // - Serve runs the accept loop. Each accepted connection is handled by its | |
| 8 | - | // own goroutine (handleConn): it reads the first frame and routes it to a | |
| 9 | - | // session create/attach, a list, or a kill. | |
| 10 | - | // - Per session there are exactly two long-lived goroutines: the PTY reader | |
| 11 | - | // (drains child output into the VT screen and pokes a cap-1 dirty channel) | |
| 12 | - | // and the render scheduler (debounces dirties and paints the attached | |
| 13 | - | // client). Both are tied to the session context and unwind when the | |
| 14 | - | // session is killed (pty.Close unblocks the reader; ctx cancel stops the | |
| 15 | - | // scheduler). | |
| 16 | - | // - The daemon exits (Serve returns) when the last session is removed, or | |
| 17 | - | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | - | // attached clients with ReasonServerShutdown). | |
| 19 | - | // | |
| 20 | - | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | - | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 1 | + | // Package daemon holds vev's server-side session multiplexer use case. | |
| 25 | 2 | package daemon | |
| 26 | 3 | ||
| 27 | 4 | import ( | |
| @@ -118,7 +95,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 118 | 95 | if target.ID != oldFocus { | |
| 119 | 96 | d.refreshPaneTitleOnFocus(sess, target.ID) | |
| 120 | 97 | } | |
| 121 | - | d.paint(sess, ac, true) | |
| 98 | + | d.invalidateRender(sess, ac, true, "input.go") | |
| 122 | 99 | return | |
| 123 | 100 | } | |
| 124 | 101 | } | |
| @@ -174,7 +151,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 174 | 151 | d.exitCopyMode(ac) | |
| 175 | 152 | d.refreshPaneTitleOnFocus(sess, pl.ID) | |
| 176 | 153 | } | |
| 177 | - | d.paint(sess, ac, true) | |
| 154 | + | d.invalidateRender(sess, ac, true, "input.go") | |
| 178 | 155 | return | |
| 179 | 156 | } | |
| 180 | 157 | var p *pane | |
| @@ -195,7 +172,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 195 | 172 | if isMouseFocusPress(ev) && pl.ID != oldFocus { | |
| 196 | 173 | d.exitCopyMode(ac) | |
| 197 | 174 | d.refreshPaneTitleOnFocus(sess, pl.ID) | |
| 198 | - | d.paint(sess, ac, true) | |
| 175 | + | d.invalidateRender(sess, ac, true, "input.go") | |
| 199 | 176 | } | |
| 200 | 177 | if multi { | |
| 201 | 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 | 202 | mouseMode, mouseSGR := p.screen.MouseMode() | |
| 226 | 203 | altScreen := p.screen.AltScreenActive() | |
| 227 | 204 | scrollbackRows := 0 | |
| 228 | - | if p.scrollback != nil { | |
| 229 | - | scrollbackRows = p.scrollback.Len() | |
| 205 | + | if p.history != nil { | |
| 206 | + | scrollbackRows = p.history.Len() | |
| 230 | 207 | } | |
| 231 | 208 | p.mu.Unlock() | |
| 232 | 209 | ||
| @@ -272,7 +249,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 272 | 249 | ||
| 273 | 250 | // The immutable snapshot is the only live-pane access needed for drag entry. | |
| 274 | 251 | p.mu.Lock() | |
| 275 | - | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 252 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 276 | 253 | p.mu.Unlock() | |
| 277 | 254 | tb := sess.activeTab() | |
| 278 | 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 | 267 | rt.copyDragging = true | |
| 291 | 268 | } | |
| 292 | 269 | rt.copyMu.Unlock() | |
| 293 | - | d.paint(sess, ac, true) | |
| 270 | + | d.invalidateRender(sess, ac, true, "input.go") | |
| 294 | 271 | case mouse.Release: | |
| 295 | 272 | rt.copyMu.Lock() | |
| 296 | 273 | rt.normalMousePressValid = false | |
| @@ -432,7 +409,7 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 432 | 409 | idx := int(action - keys.ActionSwitchTab1) | |
| 433 | 410 | if sess.switchTab(idx) { | |
| 434 | 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 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "encoding/base64" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "strings" | |
| @@ -14,10 +15,10 @@ import ( | |||
| 14 | 15 | "github.com/bnema/vev/internal/domain" | |
| 15 | 16 | "github.com/bnema/vev/internal/ports" | |
| 16 | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 17 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 18 | 18 | "github.com/bnema/vev/internal/usecase/keys" | |
| 19 | 19 | "github.com/bnema/vev/internal/usecase/layout" | |
| 20 | 20 | "github.com/bnema/vev/internal/usecase/picker" | |
| 21 | + | "github.com/bnema/vev/pkg/vt" | |
| 21 | 22 | ) | |
| 22 | 23 | ||
| 23 | 24 | // --- test doubles ----------------------------------------------------------- | |
| @@ -57,11 +58,11 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |||
| 57 | 58 | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3 | |
| 58 | 59 | }, 2*time.Second, 5*time.Millisecond) | |
| 59 | 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 | 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 | 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 | 67 | d.mu.Lock() | |
| 67 | 68 | var sess *session | |
| @@ -87,6 +88,77 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |||
| 87 | 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 | 162 | func TestAltCForwardsToPTY(t *testing.T) { | |
| 91 | 163 | writes := make(chan []byte, 2) | |
| 92 | 164 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| @@ -94,10 +166,74 @@ func TestAltCForwardsToPTY(t *testing.T) { | |||
| 94 | 166 | ||
| 95 | 167 | d.handleInput(sess, ac, []byte("\x1bc")) | |
| 96 | 168 | ||
| 97 | - | require.Equal(t, []byte("\x1bc"), <-writes) | |
| 169 | + | requirePTYWrite(t, writes, []byte("\x1bc")) | |
| 98 | 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 | 237 | func requireFloatingInitialized(t *testing.T, tb *tab) { | |
| 102 | 238 | t.Helper() | |
| 103 | 239 | tb.mu.Lock() | |
| @@ -260,7 +396,7 @@ func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | |||
| 260 | 396 | paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~") | |
| 261 | 397 | d.handleInput(sess, ac, paste) | |
| 262 | 398 | ||
| 263 | - | require.Equal(t, paste, <-writes) | |
| 399 | + | requirePTYWrite(t, writes, paste) | |
| 264 | 400 | releasePTY() | |
| 265 | 401 | } | |
| 266 | 402 | ||
| @@ -342,7 +478,7 @@ func TestOverlayInputPrecedence(t *testing.T) { | |||
| 342 | 478 | input: []byte("Z"), | |
| 343 | 479 | check: func(t *testing.T, ac *attachedClient, writes chan []byte) { | |
| 344 | 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 | 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 | 644 | func TestSwitchSourcePreviousSessionContracts(t *testing.T) { | |
| 470 | 645 | for _, tc := range []struct { | |
| 471 | 646 | name string | |
| @@ -592,7 +767,7 @@ func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) { | |||
| 592 | 767 | require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab") | |
| 593 | 768 | d.handleInput(sess, ac, []byte("Z")) | |
| 594 | 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 | 772 | releasePTY1() | |
| 598 | 773 | releasePTY2() | |
| @@ -696,8 +871,8 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) { | |||
| 696 | 871 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | |
| 697 | 872 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | |
| 698 | 873 | ||
| 699 | - | require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes) | |
| 700 | - | require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes) | |
| 874 | + | requirePTYWrite(t, writes, []byte("\x1b[A\x1b[A\x1b[A")) | |
| 875 | + | requirePTYWrite(t, writes, []byte("\x1b[B\x1b[B\x1b[B")) | |
| 701 | 876 | require.Nil(t, ac.overlays.copyMode) | |
| 702 | 877 | } | |
| 703 | 878 | ||
| @@ -752,7 +927,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 752 | 927 | ||
| 753 | 928 | sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h")) | |
| 754 | 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 | 932 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |
| 758 | 933 | select { | |
| @@ -892,7 +1067,7 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |||
| 892 | 1067 | func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |
| 893 | 1068 | p, _ := newBlockingPTY(t) | |
| 894 | 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 | 1071 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | |
| 897 | 1072 | ||
| 898 | 1073 | // Press on a content row while scrollback is empty: the anchor is | |
| @@ -902,7 +1077,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |||
| 902 | 1077 | // Simulate 5 lines evicted into scrollback between the Press and the | |
| 903 | 1078 | // first Motion (e.g. the child kept producing output). | |
| 904 | 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 | 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 | 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 | 67 | func layoutFingerprint(root *layout.Node) string { | |
| 72 | 68 | var b strings.Builder | |
| 73 | 69 | writeLayoutFingerprint(&b, root) | |
M internal/usecase/daemon/nil_pty_test.go +1 -1
| @@ -24,7 +24,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||
| 24 | 24 | sess.tabs = []*tab{first, second} | |
| 25 | 25 | ||
| 26 | 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 | 29 | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | |
| 30 | 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 | 17 | renderer *renderer.Renderer | |
| 18 | 18 | next uint64 | |
| 19 | 19 | acked uint64 | |
| 20 | - | deferred bool | |
| 21 | - | deferredReset bool | |
| 22 | 20 | maxOutstanding uint64 | |
| 23 | 21 | } | |
| 24 | 22 | ||
| @@ -89,37 +87,10 @@ func (s *outputStateStream) ack(state uint64) { | |||
| 89 | 87 | func (s *outputStateStream) rebase() { | |
| 90 | 88 | s.acked = s.next | |
| 91 | 89 | s.renderer.Reset() | |
| 92 | - | s.clearDeferred() | |
| 93 | 90 | } | |
| 94 | 91 | ||
| 95 | 92 | func (s *outputStateStream) atCapacity() bool { | |
| 96 | 93 | 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 | 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 | 71 | func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) { | |
| 119 | 72 | t.Helper() | |
| 120 | 73 | data, err := stream.render(frame, damage, reset) | |
| @@ -124,34 +77,6 @@ func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Fra | |||
| 124 | 77 | 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 | 80 | func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) { | |
| 156 | 81 | require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding) | |
| 157 | 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 | 82 | require.Same(t, sess, routed) | |
| 83 | 83 | ||
| 84 | 84 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | |
| 85 | - | d.paint(sess, ac, false) | |
| 85 | + | d.paint(sess, ac, false, nil) | |
| 86 | 86 | first := awaitFrame(t, tr.sends, ports.MsgOutput) | |
| 87 | 87 | out, err := ports.UnmarshalOutput(first.Payload) | |
| 88 | 88 | require.NoError(t, err) | |
| @@ -90,7 +90,7 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) { | |||
| 90 | 90 | ||
| 91 | 91 | // Before the MsgAck, the renderer has already advanced along the ordered | |
| 92 | 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 | 94 | requireNoOutputFrame(t, tr.sends) | |
| 95 | 95 | ||
| 96 | 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 | 100 | // The production MsgAck path retires retained states without moving the | |
| 101 | 101 | // renderer baseline backward. | |
| 102 | - | d.paint(sess, ac, false) | |
| 102 | + | d.paint(sess, ac, false, nil) | |
| 103 | 103 | requireNoOutputFrame(t, tr.sends) | |
| 104 | 104 | } | |
| 105 | 105 | ||
| @@ -111,7 +111,7 @@ func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) { | |||
| 111 | 111 | ac.replaceTransport(tr) | |
| 112 | 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 | 116 | awaitFrame(t, tr.asyncSends, ports.MsgOutput) | |
| 117 | 117 | select { | |
| @@ -144,7 +144,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) { | |||
| 144 | 144 | } | |
| 145 | 145 | pane.screen.ClearDamage() | |
| 146 | 146 | client := vt.NewScreen(80, 25) | |
| 147 | - | d.paint(sess, ac, true) | |
| 147 | + | d.paint(sess, ac, true, nil) | |
| 148 | 148 | first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput)) | |
| 149 | 149 | ac.ackOutputState(first.NewStateNum) | |
| 150 | 150 | ||
| @@ -164,7 +164,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) { | |||
| 164 | 164 | pane.screen.Col = 0 | |
| 165 | 165 | pane.screen.Write([]byte("\nq")) | |
| 166 | 166 | pane.screen.Frame = desired.Clone() | |
| 167 | - | d.paint(sess, ac, false) | |
| 167 | + | d.paint(sess, ac, false, nil) | |
| 168 | 168 | mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput)) | |
| 169 | 169 | } | |
| 170 | 170 | ||
| @@ -202,11 +202,11 @@ func TestLocalAttachStillAdvancesRendererOnSend(t *testing.T) { | |||
| 202 | 202 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 203 | 203 | ||
| 204 | 204 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | |
| 205 | - | d.paint(sess, ac, false) | |
| 205 | + | d.paint(sess, ac, false, nil) | |
| 206 | 206 | awaitFrame(t, sends, ports.MsgOutput) | |
| 207 | 207 | ||
| 208 | 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 | 210 | requireNoOutputFrame(t, sends) | |
| 211 | 211 | } | |
| 212 | 212 | ||
| @@ -217,17 +217,17 @@ func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) { | |||
| 217 | 217 | pane := sess.tabs[0].focusedPane() | |
| 218 | 218 | ||
| 219 | 219 | pane.screen.Write([]byte("A")) | |
| 220 | - | d.paint(sess, ac, false) | |
| 220 | + | d.paint(sess, ac, false, nil) | |
| 221 | 221 | first := awaitFrame(t, sends, ports.MsgOutput) | |
| 222 | 222 | out, err := ports.UnmarshalOutput(first.Payload) | |
| 223 | 223 | require.NoError(t, err) | |
| 224 | 224 | ||
| 225 | 225 | pane.screen.Write([]byte("\rB")) | |
| 226 | - | d.paint(sess, ac, false) | |
| 226 | + | d.paint(sess, ac, false, nil) | |
| 227 | 227 | awaitFrame(t, sends, ports.MsgOutput) | |
| 228 | 228 | ||
| 229 | 229 | ac.ackOutputState(out.NewStateNum) | |
| 230 | - | d.paint(sess, ac, false) | |
| 230 | + | d.paint(sess, ac, false, nil) | |
| 231 | 231 | requireNoOutputFrame(t, sends) | |
| 232 | 232 | } | |
| 233 | 233 | ||
| @@ -264,7 +264,7 @@ func TestFailedPaintSendRollsBackOutputState(t *testing.T) { | |||
| 264 | 264 | ac.replaceTransport(failingOutputTransport{}) | |
| 265 | 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 | 269 | require.Zero(t, ac.output.next) | |
| 270 | 270 | require.Zero(t, ac.output.acked) | |
| @@ -279,30 +279,50 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) { | |||
| 279 | 279 | {name: "stream window", window: maxUnackedOutputStates}, | |
| 280 | 280 | } { | |
| 281 | 281 | t.Run(tc.name, func(t *testing.T) { | |
| 282 | - | p, releasePTY := newBlockingPTY(t) | |
| 283 | - | defer releasePTY() | |
| 282 | + | // resizeReaderPTY delivers the child's SIGWINCH redraw through the | |
| 283 | + | // actual ptyReader. Its Resize callback does not return until Read has | |
| 284 | + | // accepted the bytes, at which point resizeApplying is still true. | |
| 285 | + | p := newResizeReaderPTY([]byte("\x1b[1;81H" + strings.Repeat("B", 40))) | |
| 284 | 286 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 285 | 287 | ac.output.maxOutstanding = tc.window | |
| 286 | 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 | 294 | pane.screen.Write([]byte(strings.Repeat("A", 79))) | |
| 289 | - | d.paint(sess, ac, true) | |
| 295 | + | d.paint(sess, ac, true, nil) | |
| 290 | 296 | client := vt.NewScreen(80, 24) | |
| 291 | 297 | initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 292 | 298 | ac.ackOutputState(initial.NewStateNum) | |
| 293 | 299 | ||
| 294 | - | // The child can redraw the newly exposed columns while the resize | |
| 295 | - | // output is being prepared. The first visible grown frame must contain | |
| 296 | - | // that redraw, regardless of the negotiated output window. | |
| 300 | + | d.sessWg.Add(1) | |
| 301 | + | pane.onExit = func() {} | |
| 302 | + | go d.ptyReader(sess, sess.activeTab(), pane) | |
| 303 | + | defer func() { p.close(); d.sessWg.Wait() }() | |
| 304 | + | ||
| 305 | + | // The resize deadline drives coordinator prepare/apply/commit. No test | |
| 306 | + | // writes the grown columns directly. | |
| 307 | + | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |
| 308 | + | d.clock = clock | |
| 297 | 309 | d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24}) | |
| 298 | - | pane.screen.Write([]byte("\x1b[1;81H" + strings.Repeat("B", 40))) | |
| 299 | - | d.paint(sess, ac, false) | |
| 310 | + | timer := <-clock.timers | |
| 311 | + | done := captureResizeCallbackDone(t, sess.renderCoordinator()) | |
| 312 | + | timer.ch <- time.Time{} | |
| 313 | + | awaitTestCompletion(t, done, "resize callback did not complete") | |
| 314 | + | require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate") | |
| 315 | + | require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit") | |
| 300 | 316 | ||
| 301 | 317 | client.Resize(120, 24) | |
| 302 | 318 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 303 | 319 | require.Equal(t, 'B', client.Frame.At(100, 1).Rune, | |
| 304 | 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 | 344 | requireNoOutputFrame(t, sends) | |
| 325 | 345 | ||
| 346 | + | done := captureResizeCallbackDone(t, sess.renderCoordinator()) | |
| 326 | 347 | timer.ch <- time.Now() | |
| 348 | + | awaitTestCompletion(t, done, "resize callback did not complete") | |
| 327 | 349 | frame := awaitFrame(t, sends, ports.MsgOutput) | |
| 328 | 350 | client := vt.NewScreen(120, 24) | |
| 329 | 351 | out := mustApplyOutput(t, client, frame) | |
| @@ -348,6 +370,7 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) { | |||
| 348 | 370 | case <-time.After(time.Second): | |
| 349 | 371 | t.Fatal("first resize did not schedule a bounded paint") | |
| 350 | 372 | } | |
| 373 | + | firstDone := captureResizeCallbackDone(t, sess.renderCoordinator()) | |
| 351 | 374 | d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24}) | |
| 352 | 375 | var latest *signalTimer | |
| 353 | 376 | select { | |
| @@ -356,9 +379,12 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) { | |||
| 356 | 379 | t.Fatal("latest resize did not replace the bounded paint") | |
| 357 | 380 | } | |
| 358 | 381 | ||
| 382 | + | latestDone := captureResizeCallbackDone(t, sess.renderCoordinator()) | |
| 359 | 383 | first.ch <- time.Now() | |
| 384 | + | awaitTestCompletion(t, firstDone, "obsolete resize callback did not complete") | |
| 360 | 385 | requireNoOutputFrame(t, sends) | |
| 361 | 386 | latest.ch <- time.Now() | |
| 387 | + | awaitTestCompletion(t, latestDone, "latest resize callback did not complete") | |
| 362 | 388 | awaitFrame(t, sends, ports.MsgOutput) | |
| 363 | 389 | require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size) | |
| 364 | 390 | require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width) | |
| @@ -378,20 +404,21 @@ func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) { | |||
| 378 | 404 | MaxOutputInFlight: 1, | |
| 379 | 405 | }, tr) | |
| 380 | 406 | require.NoError(t, err) | |
| 407 | + | require.True(t, sess.renderCoordinator().markAttachmentReady(sess.renderCoordinator().attachmentLease(ac))) | |
| 381 | 408 | ||
| 382 | 409 | pane := sess.tabs[0].focusedPane() | |
| 383 | 410 | pane.screen.Write([]byte("A")) | |
| 384 | - | d.paint(sess, ac, false) | |
| 411 | + | d.paint(sess, ac, false, nil) | |
| 385 | 412 | first := awaitFrame(t, tr.sends, ports.MsgOutput) | |
| 386 | 413 | firstOutput, err := ports.UnmarshalOutput(first.Payload) | |
| 387 | 414 | require.NoError(t, err) | |
| 388 | 415 | require.Equal(t, uint64(1), firstOutput.NewStateNum) | |
| 389 | 416 | for range 100 { | |
| 390 | 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 | 420 | pane.screen.Write([]byte("LATEST")) | |
| 394 | - | d.paint(sess, ac, false) | |
| 421 | + | d.invalidateRender(sess, ac, false, "output_state_sync_test.go") | |
| 395 | 422 | requireNoOutputFrame(t, tr.sends) | |
| 396 | 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 | 13 | type overlayRuntime struct { | |
| 14 | 14 | ac *attachedClient | |
| 15 | 15 | ||
| 16 | - | pickerMu sync.Mutex | |
| 17 | - | picker *picker.Model | |
| 18 | - | pickerPreview *tab | |
| 19 | - | pickerPending []byte | |
| 20 | - | pickerESC pendingByteTimer | |
| 16 | + | pickerMu sync.Mutex | |
| 17 | + | picker *picker.Model | |
| 18 | + | pickerPreview *tab | |
| 19 | + | pickerPreviewSession *session | |
| 20 | + | pickerPreviewGeneration uint64 | |
| 21 | + | pickerPending []byte | |
| 22 | + | pickerESC pendingByteTimer | |
| 21 | 23 | ||
| 22 | 24 | paletteMu sync.Mutex | |
| 23 | 25 | palette *palette.Model | |
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
| @@ -173,7 +173,7 @@ func TestOverlayRuntimeSnapshotCarriesCopyDocumentAndClearReleasesIt(t *testing. | |||
| 173 | 173 | func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) { | |
| 174 | 174 | ac := &attachedClient{} | |
| 175 | 175 | ac.initOverlays() | |
| 176 | - | history := scopy.NewScrollback(1) | |
| 176 | + | history := newTestHistory(1) | |
| 177 | 177 | history.Append(testRow("before")) | |
| 178 | 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 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "io" | |
| 5 | 4 | "testing" | |
| 6 | 5 | "time" | |
| 7 | 6 | ||
| 8 | 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 | 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 | 42 | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 43 | 43 | ac.overlays.palettePending = nil | |
| 44 | 44 | ac.overlays.paletteMu.Unlock() | |
| 45 | - | d.paint(sess, ac, true) | |
| 45 | + | d.invalidateRender(sess, ac, true, "palette.go") | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | 48 | func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints { | |
| @@ -167,12 +167,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 167 | 167 | } | |
| 168 | 168 | ac.overlays.paletteMu.Unlock() | |
| 169 | 169 | if cancel { | |
| 170 | - | d.paint(sess, ac, true) | |
| 170 | + | d.invalidateRender(sess, ac, true, "palette.go") | |
| 171 | 171 | return | |
| 172 | 172 | } | |
| 173 | 173 | if !execute { | |
| 174 | 174 | if changed { | |
| 175 | - | d.paint(sess, ac, true) | |
| 175 | + | d.invalidateRender(sess, ac, true, "palette.go") | |
| 176 | 176 | } | |
| 177 | 177 | return | |
| 178 | 178 | } | |
| @@ -183,7 +183,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 183 | 183 | rank, err := command.ParsePositiveDecimal(args) | |
| 184 | 184 | if err != nil { | |
| 185 | 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 | 187 | return | |
| 188 | 188 | } | |
| 189 | 189 | // 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 | 191 | exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent} | |
| 192 | 192 | if err := exec.JumpRecentSession(rank); err != nil { | |
| 193 | 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 | 195 | return | |
| 196 | 196 | } | |
| 197 | 197 | // Publication remains generation-safe; do not let stale work close a | |
| 198 | 198 | // newer palette interaction. | |
| 199 | 199 | if ac.closeExecutedPalette(generation, rawQuery) { | |
| 200 | 200 | d.recordPaletteUse(cmd.Code) | |
| 201 | - | d.paint(ac.currentSession(), ac, true) | |
| 201 | + | d.invalidateRender(ac.currentSession(), ac, true, "palette.go") | |
| 202 | 202 | } | |
| 203 | 203 | return | |
| 204 | 204 | } | |
| @@ -259,7 +259,7 @@ type paletteExec struct { | |||
| 259 | 259 | } | |
| 260 | 260 | ||
| 261 | 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 | 263 | return e.d.createTab(e.sess, e.ac.size) | |
| 264 | 264 | } | |
| 265 | 265 | ||
| @@ -325,7 +325,7 @@ func (e paletteExec) NextTab() error { | |||
| 325 | 325 | if e.sess.switchRelative(1) { | |
| 326 | 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 | 329 | return nil | |
| 330 | 330 | } | |
| 331 | 331 | ||
| @@ -333,7 +333,7 @@ func (e paletteExec) PrevTab() error { | |||
| 333 | 333 | if e.sess.switchRelative(-1) { | |
| 334 | 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 | 337 | 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 | 371 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 372 | 372 | defer releases[0]() | |
| 373 | 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 | 375 | d.ptys = ptys | |
| 376 | 376 | ||
| 377 | 377 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| @@ -518,7 +518,7 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) { | |||
| 518 | 518 | go func() { | |
| 519 | 519 | defer wg.Done() | |
| 520 | 520 | for range 200 { | |
| 521 | - | d.paint(sess, ac, true) | |
| 521 | + | d.paint(sess, ac, true, nil) | |
| 522 | 522 | } | |
| 523 | 523 | }() | |
| 524 | 524 | go func() { | |
M internal/usecase/daemon/pane.go +26 -32
| @@ -6,27 +6,32 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | 8 | "github.com/bnema/vev/internal/ports" | |
| 9 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 10 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | 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 | 14 | // Lock order when multiple locks are held is: | |
| 16 | 15 | // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 17 | 16 | // The PTY reader takes only pane.mu, so child output never waits on client IO. | |
| 18 | 17 | type pane struct { | |
| 19 | - | id layout.PaneID | |
| 20 | - | stableID string | |
| 21 | - | pty ports.PTY | |
| 22 | - | mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title | |
| 23 | - | resizeMu sync.Mutex // serializes PTY resizes without holding mu | |
| 24 | - | screen *vt.Screen | |
| 25 | - | scrollback *scopy.Scrollback | |
| 26 | - | dirty chan struct{} | |
| 27 | - | flush chan struct{} | |
| 28 | - | syncGen uint64 | |
| 29 | - | rect domain.Rect | |
| 18 | + | id layout.PaneID | |
| 19 | + | stableID string | |
| 20 | + | pty ports.PTY | |
| 21 | + | mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title | |
| 22 | + | resizeMu sync.Mutex // serializes PTY resizes without holding mu | |
| 23 | + | screen *vt.Screen | |
| 24 | + | history *vt.History | |
| 25 | + | syncGen uint64 | |
| 26 | + | rect domain.Rect | |
| 27 | + | // resizeApplying gates VT parsing across PTY.Resize. The reader continues | |
| 28 | + | // draining into resizePending so output is replayed against the target (or | |
| 29 | + | // retained old) screen only after apply resolves. | |
| 30 | + | resizeApplying bool | |
| 31 | + | resizePending []byte | |
| 32 | + | ptyResponses []byte | |
| 33 | + | ptyClipboards []string | |
| 34 | + | ptyAttention bool | |
| 30 | 35 | popupGeometry floatingGeometry // last geometry committed after a successful floating resize | |
| 31 | 36 | title paneTitleState | |
| 32 | 37 | ctx context.Context | |
| @@ -42,25 +47,14 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane { | |||
| 42 | 47 | } | |
| 43 | 48 | ||
| 44 | 49 | func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane { | |
| 45 | - | sb := scopy.NewScrollback(defaultScrollbackRows) | |
| 46 | - | screen := vt.NewScreen(sz.Cols, sz.Rows) | |
| 47 | - | screen.OnLineEvicted = sb.Append | |
| 50 | + | screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows}) | |
| 48 | 51 | return &pane{ | |
| 49 | - | id: id, | |
| 50 | - | stableID: stableID, | |
| 51 | - | pty: pty, | |
| 52 | - | screen: screen, | |
| 53 | - | scrollback: sb, | |
| 54 | - | dirty: make(chan struct{}, 1), | |
| 55 | - | flush: make(chan struct{}, 1), | |
| 56 | - | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | |
| 57 | - | title: paneTitleState{displayFallback: "sh"}, | |
| 52 | + | id: id, | |
| 53 | + | stableID: stableID, | |
| 54 | + | pty: pty, | |
| 55 | + | screen: screen, | |
| 56 | + | history: screen.History(), | |
| 57 | + | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | |
| 58 | + | title: paneTitleState{displayFallback: "sh"}, | |
| 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 | 23 | ) error { | |
| 24 | 24 | if d.ptys == nil { | |
| 25 | 25 | if ac != nil { | |
| 26 | - | d.paint(sess, ac, true) | |
| 26 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 27 | 27 | } | |
| 28 | 28 | return nil | |
| 29 | 29 | } | |
| @@ -69,7 +69,7 @@ func (d *Daemon) spawnPaneOp( | |||
| 69 | 69 | tb.mu.Unlock() | |
| 70 | 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 | 73 | if err != nil { | |
| 74 | 74 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | |
| 75 | 75 | tb.mu.Lock() | |
| @@ -99,7 +99,7 @@ func (d *Daemon) spawnPaneOp( | |||
| 99 | 99 | d.startPaneGoroutines(sess, tb, p) | |
| 100 | 100 | markSnapshotDirty(sess) | |
| 101 | 101 | if ac != nil { | |
| 102 | - | d.paint(sess, ac, true) | |
| 102 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 103 | 103 | } | |
| 104 | 104 | return nil | |
| 105 | 105 | } | |
| @@ -111,7 +111,7 @@ func (d *Daemon) applyLayoutLocked(tb *tab) { | |||
| 111 | 111 | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 112 | 112 | if !ok { | |
| 113 | 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 | 116 | return | |
| 117 | 117 | } | |
| @@ -120,12 +120,12 @@ func (d *Daemon) applyLayoutLocked(tb *tab) { | |||
| 120 | 120 | continue | |
| 121 | 121 | } | |
| 122 | 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 | 129 | if p == nil { | |
| 130 | 130 | return | |
| 131 | 131 | } | |
| @@ -167,7 +167,7 @@ func (d *Daemon) stackPane(sess *session, ac *attachedClient) error { | |||
| 167 | 167 | func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | |
| 168 | 168 | if d.ptys == nil { | |
| 169 | 169 | if ac != nil { | |
| 170 | - | d.paint(sess, ac, true) | |
| 170 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 171 | 171 | } | |
| 172 | 172 | return nil | |
| 173 | 173 | } | |
| @@ -188,7 +188,7 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | |||
| 188 | 188 | if err == nil { | |
| 189 | 189 | markSnapshotDirty(sess) | |
| 190 | 190 | if ac != nil { | |
| 191 | - | d.paint(sess, ac, true) | |
| 191 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 192 | 192 | } | |
| 193 | 193 | } | |
| 194 | 194 | return err | |
| @@ -197,7 +197,7 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | |||
| 197 | 197 | func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error { | |
| 198 | 198 | if d.ptys == nil { | |
| 199 | 199 | if ac != nil { | |
| 200 | - | d.paint(sess, ac, true) | |
| 200 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 201 | 201 | } | |
| 202 | 202 | return nil | |
| 203 | 203 | } | |
| @@ -255,8 +255,12 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 255 | 255 | ||
| 256 | 256 | if ac != nil { | |
| 257 | 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 | 264 | if p.cancel != nil { | |
| 261 | 265 | p.cancel() | |
| 262 | 266 | } | |
| @@ -267,7 +271,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 267 | 271 | markSnapshotDirty(sess) | |
| 268 | 272 | if repaint { | |
| 269 | 273 | if ac != nil { | |
| 270 | - | d.paint(sess, ac, true) | |
| 274 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 271 | 275 | } | |
| 272 | 276 | } | |
| 273 | 277 | return nil | |
| @@ -305,7 +309,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 305 | 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 | 314 | if errors.Is(err, layout.ErrNoPane) { | |
| 311 | 315 | return nil | |
M internal/usecase/daemon/pane_actions_test.go +9 -9
| @@ -39,7 +39,7 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |||
| 39 | 39 | newPTY := portsmocks.NewMockPTY(t) | |
| 40 | 40 | oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once() | |
| 41 | 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 | 44 | require.NoError(t, d.splitPane(sess, nil, tt.dir)) | |
| 45 | 45 | ||
| @@ -68,7 +68,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | |||
| 68 | 68 | newPTY := portsmocks.NewMockPTY(t) | |
| 69 | 69 | stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once() | |
| 70 | 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 | 73 | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | |
| 74 | 74 | ||
| @@ -84,7 +84,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | |||
| 84 | 84 | ||
| 85 | 85 | func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) { | |
| 86 | 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 | 89 | tb := sess.activeTab() | |
| 90 | 90 | tb.mu.Lock() | |
| @@ -231,7 +231,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) { | |||
| 231 | 231 | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5}) | |
| 232 | 232 | newPTY := portsmocks.NewMockPTY(t) | |
| 233 | 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 | 236 | require.NoError(t, d.stackPane(sess, nil)) | |
| 237 | 237 | tb := sess.activeTab() | |
| @@ -251,7 +251,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) { | |||
| 251 | 251 | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3}) | |
| 252 | 252 | newPTY := portsmocks.NewMockPTY(t) | |
| 253 | 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 | 255 | require.NoError(t, d.stackPane(sess, nil)) | |
| 256 | 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 | 261 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus) | |
| 262 | 262 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded) | |
| 263 | 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 | 265 | require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall) | |
| 266 | 266 | require.Len(t, tb.panes, 2) | |
| 267 | 267 | } | |
| @@ -291,7 +291,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) { | |||
| 291 | 291 | defer releaseNew() | |
| 292 | 292 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | |
| 293 | 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 | 296 | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | |
| 297 | 297 | tb := sess.activeTab() | |
| @@ -350,8 +350,8 @@ func TestSplitPaneInheritsTerminalEnv(t *testing.T) { | |||
| 350 | 350 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | |
| 351 | 351 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |
| 352 | 352 | var gotEnv []string | |
| 353 | - | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn( | |
| 354 | - | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 353 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn( | |
| 354 | + | func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 355 | 355 | gotEnv = append([]string(nil), env...) | |
| 356 | 356 | return newPTY, nil | |
| 357 | 357 | }, | |
M internal/usecase/daemon/performance_benchmark_test.go +650 -54
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "io" | |
| 6 | 6 | "sync" | |
| 7 | 7 | "testing" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/stretchr/testify/require" | |
| 10 | 11 | ||
| @@ -49,8 +50,15 @@ func TestPerformanceFixtureCounters(t *testing.T) { | |||
| 49 | 50 | fixture.paintLive() | |
| 50 | 51 | metrics := fixture.metrics() | |
| 51 | 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 | 56 | require.Positive(t, metrics.outputBytes) | |
| 53 | 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 | 63 | fixture.captureSnapshot() | |
| 56 | 64 | metrics = fixture.metrics() | |
| @@ -64,14 +72,70 @@ func TestPerformanceFixtureCounters(t *testing.T) { | |||
| 64 | 72 | require.True(t, fixture.resized()) | |
| 65 | 73 | } | |
| 66 | 74 | ||
| 67 | - | func TestPerformanceFixtureResizeAlternatesRealDimensions(t *testing.T) { | |
| 68 | - | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}}) | |
| 75 | + | func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) { | |
| 76 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 77 | + | d.paint(sess, ac, true, nil) | |
| 78 | + | _ = awaitFrame(t, sends, ports.MsgOutput) | |
| 79 | + | var captures, compositions, emissions int | |
| 80 | + | ac.renderStages = renderStageHooks{ | |
| 81 | + | capture: func() { captures++ }, | |
| 82 | + | compose: func() { compositions++ }, | |
| 83 | + | emit: func() { emissions++ }, | |
| 84 | + | } | |
| 85 | + | ac.replaceTransport(cacheFailTransport{}) | |
| 86 | + | p := sess.tabs[0].focusedPane() | |
| 87 | + | p.mu.Lock() | |
| 88 | + | p.screen.Write([]byte("hook")) | |
| 89 | + | p.mu.Unlock() | |
| 90 | + | d.paint(sess, ac, false, nil) | |
| 91 | + | require.Equal(t, 1, captures, "a completed capture counts even when its emission fails") | |
| 92 | + | require.Equal(t, 1, compositions, "a completed composition counts even when its emission fails") | |
| 93 | + | require.Zero(t, emissions, "emit counts only prepare and transport success") | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestPerformanceFixtureResize(t *testing.T) { | |
| 97 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 98 | + | sequence := []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}, {Cols: 160, Rows: 50}, {Cols: 80, Rows: 24}} | |
| 69 | 99 | ||
| 70 | - | for _, want := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}} { | |
| 71 | - | fixture.resize() | |
| 100 | + | for _, want := range sequence { | |
| 101 | + | fixture.resizeTo(want) | |
| 72 | 102 | require.Equal(t, want, fixture.ac.size) | |
| 73 | 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 | 141 | func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) { | |
| @@ -81,45 +145,137 @@ func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) { | |||
| 81 | 145 | for _, tb := range fixture.sess.tabs { | |
| 82 | 146 | require.Len(t, tb.panes, 4) | |
| 83 | 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 | 208 | func BenchmarkDaemonHistoryLivePaint(b *testing.B) { | |
| 90 | 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 | 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 | 235 | func BenchmarkDaemonHistoryCopyEnter(b *testing.B) { | |
| 96 | 236 | benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) }) | |
| 97 | 237 | } | |
| 98 | 238 | func BenchmarkDaemonHistoryCopySearch(b *testing.B) { | |
| 99 | 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) { | |
| 102 | - | benchmarkDaemonLargeHistory(b, "resize", func(f *performanceFixture) { f.resize() }) | |
| 241 | + | ||
| 242 | + | // BenchmarkDaemonHistoryResizeSweep alternates deterministic grow/shrink | |
| 243 | + | // geometry. Metrics are fixture counters; no benchmark result depends on wall | |
| 244 | + | // time or scheduler delivery. | |
| 245 | + | func BenchmarkDaemonHistoryResizeSweep(b *testing.B) { | |
| 246 | + | sequence := []domain.Size{{Cols: 80, Rows: 24}, {Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}, {Cols: 160, Rows: 50}, {Cols: 120, Rows: 40}, {Cols: 100, Rows: 30}} | |
| 247 | + | benchmarkDaemonLargeHistory(b, "resize-sweep", func(f *performanceFixture) { | |
| 248 | + | f.resizeTo(sequence[f.resizes%len(sequence)]) | |
| 249 | + | }) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // BenchmarkDaemonHistoryResizeRetry measures one deterministic failed apply | |
| 253 | + | // followed by its successful full-reset retry for each operation. | |
| 254 | + | func BenchmarkDaemonHistoryResizeRetry(b *testing.B) { | |
| 255 | + | benchmarkDaemonLargeHistory(b, "resize-retry", func(f *performanceFixture) { | |
| 256 | + | f.pty.setErrors([]error{fmt.Errorf("scripted resize failure"), nil}) | |
| 257 | + | size := domain.Size{Cols: 100, Rows: 30} | |
| 258 | + | if f.resizes%2 != 0 { | |
| 259 | + | size = domain.Size{Cols: 120, Rows: 40} | |
| 260 | + | } | |
| 261 | + | f.resizeTo(size) | |
| 262 | + | f.retryLatest() | |
| 263 | + | }) | |
| 103 | 264 | } | |
| 104 | 265 | ||
| 105 | 266 | func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) { | |
| 106 | 267 | b.Helper() | |
| 107 | - | for _, topology := range []struct { | |
| 108 | - | name string | |
| 109 | - | tabs, panes int | |
| 110 | - | }{ | |
| 111 | - | {name: "1tab-1pane-control", tabs: 1, panes: 1}, | |
| 112 | - | {name: "1tab-4panes", tabs: 1, panes: 4}, | |
| 113 | - | {name: "4tabs-1pane", tabs: 4, panes: 1}, | |
| 114 | - | {name: "4tabs-4panes", tabs: 4, panes: 4}, | |
| 115 | - | {name: "8tabs-1pane", tabs: 8, panes: 1}, | |
| 116 | - | } { | |
| 268 | + | for _, topology := range daemonHistoryTopologies { | |
| 117 | 269 | b.Run(topology.name, func(b *testing.B) { | |
| 118 | 270 | fixture := newPerformanceFixture(b, performanceConfig{ | |
| 119 | 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 | 276 | if workload == "copy-search" { | |
| 122 | - | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | |
| 277 | + | benchmarkDaemonCopySearch(b, fixture, run) | |
| 278 | + | return | |
| 123 | 279 | } | |
| 124 | 280 | b.ReportAllocs() | |
| 125 | 281 | fixture.resetMetrics() | |
| @@ -133,26 +289,180 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor | |||
| 133 | 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 | 292 | if workload == "live-paint" && metrics.outputFrames != uint64(b.N) { | |
| 146 | 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 | 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 | 466 | func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations, historyRows int) { | |
| 157 | 467 | b.Helper() | |
| 158 | 468 | if operations == 0 { | |
| @@ -164,9 +474,89 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations | |||
| 164 | 474 | b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op") | |
| 165 | 475 | b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op") | |
| 166 | 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 | 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 | 560 | func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) { | |
| 171 | 561 | fixture := newPerformanceFixture(t, performanceConfig{}) | |
| 172 | 562 | ||
| @@ -189,29 +579,119 @@ type performanceConfig struct { | |||
| 189 | 579 | tabs int | |
| 190 | 580 | panes int | |
| 191 | 581 | historyRows int | |
| 582 | + | ptyErrors []error | |
| 192 | 583 | } | |
| 193 | 584 | ||
| 194 | 585 | type performanceMetrics struct { | |
| 195 | - | outputFrames uint64 | |
| 196 | - | outputBytes uint64 | |
| 197 | - | outputPayloadBytes uint64 | |
| 198 | - | snapshotWrites uint64 | |
| 199 | - | snapshotBytes uint64 | |
| 586 | + | outputFrames uint64 | |
| 587 | + | outputBytes uint64 | |
| 588 | + | outputPayloadBytes uint64 | |
| 589 | + | snapshotWrites uint64 | |
| 590 | + | snapshotBytes uint64 | |
| 591 | + | coordinatorInvalidations uint64 | |
| 592 | + | coordinatorWakes uint64 | |
| 593 | + | coordinatorCoalesced uint64 | |
| 594 | + | renderCaptures uint64 | |
| 595 | + | renderCompositions uint64 | |
| 596 | + | renderEmissions uint64 | |
| 597 | + | resizeRequests uint64 | |
| 598 | + | resizeCommits uint64 | |
| 599 | + | resizeCommitNanos uint64 | |
| 600 | + | skippedEpochs uint64 | |
| 601 | + | ptyFailures uint64 | |
| 602 | + | ptyRetries uint64 | |
| 603 | + | frameGapEpochs uint64 | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | // performanceClock makes transition latency a reproducible counter rather | |
| 607 | + | // than a wall-clock observation. A nil timer channel deliberately executes | |
| 608 | + | // coordinator work synchronously. | |
| 609 | + | type performanceClock struct { | |
| 610 | + | mu sync.Mutex | |
| 611 | + | now time.Time | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | func (c *performanceClock) Now() time.Time { | |
| 615 | + | c.mu.Lock() | |
| 616 | + | defer c.mu.Unlock() | |
| 617 | + | c.now = c.now.Add(time.Microsecond) | |
| 618 | + | return c.now | |
| 619 | + | } | |
| 620 | + | func (*performanceClock) NewTimer(time.Duration) ports.Timer { return performanceTimer{} } | |
| 621 | + | ||
| 622 | + | type performanceTimer struct{} | |
| 623 | + | ||
| 624 | + | func (performanceTimer) C() <-chan time.Time { return nil } | |
| 625 | + | func (performanceTimer) Reset(time.Duration) bool { return true } | |
| 626 | + | func (performanceTimer) Stop() bool { return true } | |
| 627 | + | ||
| 628 | + | // scriptedPerformancePTY records every requested terminal size and returns a | |
| 629 | + | // caller-provided error script. It has no reads or scheduling side effects. | |
| 630 | + | type scriptedPerformancePTY struct { | |
| 631 | + | mu sync.Mutex | |
| 632 | + | sizes []domain.Size | |
| 633 | + | errs []error | |
| 634 | + | fails uint64 | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | func (p *scriptedPerformancePTY) Resize(size domain.Size) error { | |
| 638 | + | p.mu.Lock() | |
| 639 | + | defer p.mu.Unlock() | |
| 640 | + | p.sizes = append(p.sizes, size) | |
| 641 | + | index := len(p.sizes) - 1 | |
| 642 | + | if index < len(p.errs) && p.errs[index] != nil { | |
| 643 | + | p.fails++ | |
| 644 | + | return p.errs[index] | |
| 645 | + | } | |
| 646 | + | return nil | |
| 647 | + | } | |
| 648 | + | func (*scriptedPerformancePTY) Read([]byte) (int, error) { return 0, io.EOF } | |
| 649 | + | func (*scriptedPerformancePTY) Write(b []byte) (int, error) { return len(b), nil } | |
| 650 | + | func (*scriptedPerformancePTY) Close() error { return nil } | |
| 651 | + | func (*scriptedPerformancePTY) Pid() int { return 0 } | |
| 652 | + | func (*scriptedPerformancePTY) ForegroundPgid() (int, error) { return 0, nil } | |
| 653 | + | func (p *scriptedPerformancePTY) requested() []domain.Size { | |
| 654 | + | p.mu.Lock() | |
| 655 | + | defer p.mu.Unlock() | |
| 656 | + | return append([]domain.Size(nil), p.sizes...) | |
| 657 | + | } | |
| 658 | + | func (p *scriptedPerformancePTY) metrics() (uint64, uint64) { | |
| 659 | + | p.mu.Lock() | |
| 660 | + | defer p.mu.Unlock() | |
| 661 | + | return p.fails, uint64(len(p.sizes)) | |
| 662 | + | } | |
| 663 | + | func (p *scriptedPerformancePTY) setErrors(errs []error) { | |
| 664 | + | p.mu.Lock() | |
| 665 | + | defer p.mu.Unlock() | |
| 666 | + | p.errs = append([]error(nil), errs...) | |
| 667 | + | p.sizes = nil | |
| 668 | + | p.fails = 0 | |
| 200 | 669 | } | |
| 201 | 670 | ||
| 202 | 671 | type performanceFixture struct { | |
| 203 | - | t testing.TB | |
| 204 | - | d *Daemon | |
| 205 | - | sess *session | |
| 206 | - | ac *attachedClient | |
| 207 | - | output *countingOutputTransport | |
| 208 | - | snaps *countingSnapshotStore | |
| 209 | - | activePane *pane | |
| 210 | - | liveWrites [][]byte | |
| 211 | - | paints int | |
| 212 | - | resizeSizes [2]domain.Size | |
| 213 | - | resizes int | |
| 214 | - | resizedSize domain.Size | |
| 672 | + | t testing.TB | |
| 673 | + | d *Daemon | |
| 674 | + | sess *session | |
| 675 | + | ac *attachedClient | |
| 676 | + | output *countingOutputTransport | |
| 677 | + | snaps *countingSnapshotStore | |
| 678 | + | activePane *pane | |
| 679 | + | liveWrites [][]byte | |
| 680 | + | paints int | |
| 681 | + | resizeSizes [2]domain.Size | |
| 682 | + | resizes int | |
| 683 | + | resizedSize domain.Size | |
| 684 | + | pty *scriptedPerformancePTY | |
| 685 | + | clock *performanceClock | |
| 686 | + | resizeRequests uint64 | |
| 687 | + | resizeCommits uint64 | |
| 688 | + | resizeCommitNanos uint64 | |
| 689 | + | ptyFailures uint64 | |
| 690 | + | ptyRetries uint64 | |
| 691 | + | coordinatorBaseline renderCoordinatorBurstMetricsSnapshot | |
| 692 | + | renderCaptures uint64 | |
| 693 | + | renderCompositions uint64 | |
| 694 | + | renderEmissions uint64 | |
| 215 | 695 | } | |
| 216 | 696 | ||
| 217 | 697 | func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture { | |
| @@ -228,8 +708,12 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||
| 228 | 708 | if config.historyRows == 0 { | |
| 229 | 709 | config.historyRows = 1 | |
| 230 | 710 | } | |
| 711 | + | pty := &scriptedPerformancePTY{} | |
| 231 | 712 | ptys := make([]ports.PTY, config.tabs) | |
| 713 | + | ptys[0] = pty | |
| 232 | 714 | d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...) | |
| 715 | + | clock := &performanceClock{} | |
| 716 | + | d.clock = clock | |
| 233 | 717 | output := &countingOutputTransport{} | |
| 234 | 718 | ac.tr = output | |
| 235 | 719 | ac.size = config.size | |
| @@ -244,18 +728,34 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||
| 244 | 728 | ac: ac, | |
| 245 | 729 | output: output, | |
| 246 | 730 | snaps: &countingSnapshotStore{}, | |
| 731 | + | pty: pty, | |
| 732 | + | clock: clock, | |
| 247 | 733 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | |
| 248 | 734 | resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size}, | |
| 249 | 735 | } | |
| 250 | 736 | WithSnapshotStore(fixture.snaps)(d) | |
| 737 | + | d.startSnapshotEncodeWorker() | |
| 738 | + | t.Cleanup(d.stopSnapshotEncodeWorker) | |
| 251 | 739 | for tabIndex, tb := range sess.tabs { | |
| 252 | 740 | fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size)) | |
| 253 | 741 | } | |
| 254 | 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 | 756 | // Prime the real renderer shadow before measurements. Subsequent paints use | |
| 257 | 757 | // actual production diffs rather than the initial full frame. | |
| 258 | - | d.paint(sess, ac, true) | |
| 758 | + | d.paint(sess, ac, true, nil) | |
| 259 | 759 | fixture.resetMetrics() | |
| 260 | 760 | return fixture | |
| 261 | 761 | } | |
| @@ -278,7 +778,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR | |||
| 278 | 778 | for _, p := range tb.panes { | |
| 279 | 779 | p.mu.Lock() | |
| 280 | 780 | width := p.screen.Frame.Width | |
| 281 | - | for row := 0; p.scrollback.Len() < historyRows; row++ { | |
| 781 | + | for row := 0; p.history.Len() < historyRows; row++ { | |
| 282 | 782 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | |
| 283 | 783 | } | |
| 284 | 784 | p.mu.Unlock() | |
| @@ -311,13 +811,20 @@ func (f *performanceFixture) paintLive() { | |||
| 311 | 811 | p.mu.Lock() | |
| 312 | 812 | p.screen.Write(f.liveWrites[f.paints%len(f.liveWrites)]) | |
| 313 | 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 | 819 | f.paints++ | |
| 316 | 820 | } | |
| 317 | 821 | ||
| 318 | 822 | func (f *performanceFixture) captureSnapshot() { | |
| 319 | 823 | f.t.Helper() | |
| 320 | 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 | 830 | func (f *performanceFixture) enterCopySearch() { | |
| @@ -326,6 +833,30 @@ func (f *performanceFixture) enterCopySearch() { | |||
| 326 | 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 | 860 | func (f *performanceFixture) searchMatches() int { | |
| 330 | 861 | f.ac.overlays.copyMu.Lock() | |
| 331 | 862 | defer f.ac.overlays.copyMu.Unlock() | |
| @@ -335,10 +866,48 @@ func (f *performanceFixture) searchMatches() int { | |||
| 335 | 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 | 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 | 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 | 913 | func (f *performanceFixture) resized() bool { | |
| @@ -359,15 +928,42 @@ func (f *performanceFixture) resized() bool { | |||
| 359 | 928 | func (f *performanceFixture) resetMetrics() { | |
| 360 | 929 | f.output.reset() | |
| 361 | 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 | 944 | func (f *performanceFixture) metrics() performanceMetrics { | |
| 365 | 945 | output := f.output.metrics() | |
| 366 | 946 | snapshots := f.snaps.metrics() | |
| 367 | - | return performanceMetrics{ | |
| 947 | + | metrics := performanceMetrics{ | |
| 368 | 948 | outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes, | |
| 369 | 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 | 969 | const outputPayloadHeaderBytes = 24 | |
M internal/usecase/daemon/picker.go +173 -128
| @@ -1,27 +1,4 @@ | |||
| 1 | - | // Package daemon holds vev's server-side session multiplexer use case: the | |
| 2 | - | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |
| 3 | - | // and VT screen, and the per-client debounced render scheduler. | |
| 4 | - | // | |
| 5 | - | // Concurrency model (sessions own one or more PTY-backed tabs): | |
| 6 | - | // | |
| 7 | - | // - Serve runs the accept loop. Each accepted connection is handled by its | |
| 8 | - | // own goroutine (handleConn): it reads the first frame and routes it to a | |
| 9 | - | // session create/attach, a list, or a kill. | |
| 10 | - | // - Per session there are exactly two long-lived goroutines: the PTY reader | |
| 11 | - | // (drains child output into the VT screen and pokes a cap-1 dirty channel) | |
| 12 | - | // and the render scheduler (debounces dirties and paints the attached | |
| 13 | - | // client). Both are tied to the session context and unwind when the | |
| 14 | - | // session is killed (pty.Close unblocks the reader; ctx cancel stops the | |
| 15 | - | // scheduler). | |
| 16 | - | // - The daemon exits (Serve returns) when the last session is removed, or | |
| 17 | - | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | - | // attached clients with ReasonServerShutdown). | |
| 19 | - | // | |
| 20 | - | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | - | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 1 | + | // Package daemon holds vev's server-side session multiplexer use case. | |
| 25 | 2 | package daemon | |
| 26 | 3 | ||
| 27 | 4 | import ( | |
| @@ -32,7 +9,6 @@ import ( | |||
| 32 | 9 | "github.com/bnema/vev/internal/usecase/keys" | |
| 33 | 10 | "github.com/bnema/vev/internal/usecase/layout" | |
| 34 | 11 | "github.com/bnema/vev/internal/usecase/picker" | |
| 35 | - | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 36 | 12 | "github.com/bnema/vev/internal/usecase/ui" | |
| 37 | 13 | "github.com/bnema/vev/pkg/renderer" | |
| 38 | 14 | ) | |
| @@ -47,7 +23,7 @@ func (d *Daemon) enterPicker(sess *session, ac *attachedClient) { | |||
| 47 | 23 | ac.overlays.pickerPreview = nil | |
| 48 | 24 | ac.overlays.pickerMu.Unlock() | |
| 49 | 25 | d.registerPreviewForSelection(ac) | |
| 50 | - | d.paint(sess, ac, true) | |
| 26 | + | d.invalidateRender(sess, ac, true, "picker.go") | |
| 51 | 27 | } | |
| 52 | 28 | ||
| 53 | 29 | func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |
| @@ -141,7 +117,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||
| 141 | 117 | d.killPickerTarget(target) | |
| 142 | 118 | } | |
| 143 | 119 | d.refreshPicker(ac) | |
| 144 | - | d.paint(sess, ac, true) | |
| 120 | + | d.invalidateRender(sess, ac, true, "picker.go") | |
| 145 | 121 | return | |
| 146 | 122 | case 'j': | |
| 147 | 123 | ac.overlays.picker.Down() | |
| @@ -191,7 +167,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||
| 191 | 167 | return | |
| 192 | 168 | } | |
| 193 | 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 | 183 | ac.overlays.pickerESC.timer = nil | |
| 208 | 184 | ac.overlays.pickerESC.done = nil | |
| 209 | 185 | ac.overlays.picker = nil | |
| 186 | + | generation := ac.overlays.pickerPreviewGeneration | |
| 210 | 187 | ac.overlays.pickerMu.Unlock() | |
| 211 | 188 | ||
| 212 | - | d.unregisterPreview(ac) | |
| 189 | + | d.clearPreviewGeneration(ac, generation) | |
| 213 | 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 | 218 | func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |
| 219 | + | if ac == nil || ac.overlays == nil { | |
| 220 | + | return | |
| 221 | + | } | |
| 242 | 222 | ac.overlays.pickerMu.Lock() | |
| 243 | 223 | var target picker.Target | |
| 244 | 224 | var ok bool | |
| 245 | 225 | if ac.overlays.picker != nil { | |
| 246 | 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 | 233 | ac.overlays.pickerMu.Unlock() | |
| 249 | - | ||
| 250 | - | var next *tab | |
| 251 | - | if ok { | |
| 252 | - | next = d.tabByTarget(target) | |
| 234 | + | d.teardownPreviewSubscription(ac, previous, previousGeneration) | |
| 235 | + | if !ok { | |
| 236 | + | return | |
| 237 | + | } | |
| 238 | + | next := d.tabByTarget(target) | |
| 239 | + | targetSess := d.sessionByID(target.Session) | |
| 240 | + | if next == nil || targetSess == nil { | |
| 241 | + | return | |
| 253 | 242 | } | |
| 254 | - | ||
| 255 | 243 | ac.overlays.pickerMu.Lock() | |
| 256 | - | old := ac.overlays.pickerPreview | |
| 257 | - | if ac.overlays.picker == nil { | |
| 258 | - | next = nil | |
| 244 | + | // Selection may have changed while the target was resolved. | |
| 245 | + | selected, stillSelected := ac.overlays.picker.Selected() | |
| 246 | + | valid := ac.overlays.pickerPreviewGeneration == generation && stillSelected && selected == target | |
| 247 | + | if valid { | |
| 248 | + | ac.overlays.pickerPreview = next | |
| 249 | + | ac.overlays.pickerPreviewSession = targetSess | |
| 259 | 250 | } | |
| 260 | - | ac.overlays.pickerPreview = next | |
| 261 | 251 | ac.overlays.pickerMu.Unlock() | |
| 262 | - | ||
| 263 | - | if old != nil && old != next { | |
| 264 | - | old.mu.Lock() | |
| 265 | - | if old.previewClient == ac { | |
| 266 | - | old.previewClient = nil | |
| 252 | + | if !valid { | |
| 253 | + | return | |
| 254 | + | } | |
| 255 | + | // A same-session preview is already invalidated by its own coordinator. | |
| 256 | + | // Cross-session previews subscribe to the target's producer wake instead | |
| 257 | + | // of starting a direct paint/timer path. | |
| 258 | + | if targetSess == ac.currentSession() { | |
| 259 | + | return | |
| 260 | + | } | |
| 261 | + | rc := d.attachCoordinator(targetSess, nil, nil, false) | |
| 262 | + | rc.subscribePreviewFor(ac, generation, func(renderWake) { | |
| 263 | + | if pickerPreviewCurrent(ac, targetSess, next, generation) { | |
| 264 | + | if owner := ac.currentSession(); owner != nil { | |
| 265 | + | d.invalidateRender(owner, ac, false, "picker preview") | |
| 266 | + | } | |
| 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 { | |
| 271 | - | next.mu.Lock() | |
| 272 | - | next.previewClient = ac | |
| 273 | - | next.mu.Unlock() | |
| 286 | + | } | |
| 274 | 287 | ||
| 275 | - | ac.overlays.pickerMu.Lock() | |
| 276 | - | keep := ac.overlays.pickerPreview == next | |
| 277 | - | ac.overlays.pickerMu.Unlock() | |
| 278 | - | if !keep { | |
| 279 | - | next.mu.Lock() | |
| 280 | - | if next.previewClient == ac { | |
| 281 | - | next.previewClient = nil | |
| 282 | - | } | |
| 283 | - | next.mu.Unlock() | |
| 288 | + | func (d *Daemon) teardownPreviewSubscription(ac *attachedClient, previous *session, generation uint64) { | |
| 289 | + | if previous != nil { | |
| 290 | + | if rc := previous.renderCoordinator(); rc != nil { | |
| 291 | + | rc.teardownPreviewFor(ac, generation) | |
| 284 | 292 | } | |
| 285 | 293 | } | |
| 286 | 294 | } | |
| 287 | 295 | ||
| 288 | 296 | func (d *Daemon) unregisterPreview(ac *attachedClient) { | |
| 297 | + | if ac == nil || ac.overlays == nil { | |
| 298 | + | return | |
| 299 | + | } | |
| 289 | 300 | ac.overlays.pickerMu.Lock() | |
| 290 | - | old := ac.overlays.pickerPreview | |
| 291 | - | ac.overlays.pickerPreview = nil | |
| 301 | + | generation := ac.overlays.pickerPreviewGeneration | |
| 292 | 302 | ac.overlays.pickerMu.Unlock() | |
| 303 | + | d.clearPreviewGeneration(ac, generation) | |
| 304 | + | } | |
| 293 | 305 | ||
| 294 | - | if old != nil { | |
| 295 | - | old.mu.Lock() | |
| 296 | - | if old.previewClient == ac { | |
| 297 | - | old.previewClient = nil | |
| 298 | - | } | |
| 299 | - | old.mu.Unlock() | |
| 306 | + | // clearPreviewGeneration removes the preview only if it is still the observed | |
| 307 | + | // selection. Teardown paths may race a new picker selection. | |
| 308 | + | func (d *Daemon) clearPreviewGeneration(ac *attachedClient, generation uint64) bool { | |
| 309 | + | ac.overlays.pickerMu.Lock() | |
| 310 | + | if ac.overlays.pickerPreviewGeneration != generation { | |
| 311 | + | ac.overlays.pickerMu.Unlock() | |
| 312 | + | return false | |
| 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 | 325 | func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |
| 304 | - | tb.mu.Lock() | |
| 305 | - | previewer := tb.previewClient | |
| 306 | - | tb.mu.Unlock() | |
| 307 | - | if previewer == nil { | |
| 326 | + | if d == nil || d.sessions == nil { | |
| 308 | 327 | return | |
| 309 | 328 | } | |
| 310 | - | cleared := false | |
| 311 | - | previewer.overlays.pickerMu.Lock() | |
| 312 | - | if previewer.overlays.pickerPreview == tb { | |
| 313 | - | previewer.overlays.pickerPreview = nil | |
| 314 | - | cleared = true | |
| 315 | - | } | |
| 316 | - | previewer.overlays.pickerMu.Unlock() | |
| 317 | - | tb.mu.Lock() | |
| 318 | - | if tb.previewClient == previewer { | |
| 319 | - | tb.previewClient = nil | |
| 329 | + | d.mu.Lock() | |
| 330 | + | var clients []*attachedClient | |
| 331 | + | for _, sess := range d.sessions { | |
| 332 | + | sess.mu.Lock() | |
| 333 | + | if sess.client != nil { | |
| 334 | + | clients = append(clients, sess.client) | |
| 335 | + | } | |
| 336 | + | sess.mu.Unlock() | |
| 320 | 337 | } | |
| 321 | - | tb.mu.Unlock() | |
| 322 | - | if cleared { | |
| 323 | - | if sess := previewer.currentSession(); sess != nil { | |
| 324 | - | d.paint(sess, previewer, true) | |
| 338 | + | d.mu.Unlock() | |
| 339 | + | for _, ac := range clients { | |
| 340 | + | if ac == nil || ac.overlays == nil { | |
| 341 | + | continue | |
| 342 | + | } | |
| 343 | + | ac.overlays.pickerMu.Lock() | |
| 344 | + | generation := ac.overlays.pickerPreviewGeneration | |
| 345 | + | observed := ac.overlays.pickerPreview == tb | |
| 346 | + | ac.overlays.pickerMu.Unlock() | |
| 347 | + | if observed && d.clearPreviewGeneration(ac, generation) { | |
| 348 | + | if sess := ac.currentSession(); sess != nil { | |
| 349 | + | d.invalidateRender(sess, ac, true, "picker.go") | |
| 350 | + | } | |
| 325 | 351 | } | |
| 326 | 352 | } | |
| 327 | 353 | } | |
| @@ -331,8 +357,9 @@ func (d *Daemon) closePicker(ac *attachedClient) { | |||
| 331 | 357 | ac.overlays.picker = nil | |
| 332 | 358 | ac.overlays.pickerPending = nil | |
| 333 | 359 | d.stopPickerPendingTimerLocked(ac) | |
| 360 | + | generation := ac.overlays.pickerPreviewGeneration | |
| 334 | 361 | ac.overlays.pickerMu.Unlock() | |
| 335 | - | d.unregisterPreview(ac) | |
| 362 | + | d.clearPreviewGeneration(ac, generation) | |
| 336 | 363 | } | |
| 337 | 364 | ||
| 338 | 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 | 390 | targetSess := d.sessionByID(target.Session) | |
| 364 | 391 | if targetSess == nil { | |
| 365 | - | d.paint(sess, ac, true) | |
| 392 | + | d.invalidateRender(sess, ac, true, "picker.go") | |
| 366 | 393 | return false | |
| 367 | 394 | } | |
| 368 | 395 | if targetSess == sess { | |
| 369 | 396 | if sess.switchTab(target.TabIndex) { | |
| 370 | 397 | d.activateTab(sess, sess.activeTab()) | |
| 371 | 398 | } | |
| 372 | - | d.paint(sess, ac, true) | |
| 399 | + | d.invalidateRender(sess, ac, true, "picker.go") | |
| 373 | 400 | return true | |
| 374 | 401 | } | |
| 375 | 402 | old := d.stealClientForTarget(sess, ac, targetSess, target) | |
| 376 | 403 | if ac.currentSession() != targetSess { | |
| 377 | - | d.paint(sess, ac, true) | |
| 404 | + | d.invalidateRender(sess, ac, true, "picker.go") | |
| 378 | 405 | return false | |
| 379 | 406 | } | |
| 380 | 407 | if old != nil && old != ac { | |
| @@ -407,16 +434,22 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 407 | 434 | targetSess.mu.Lock() | |
| 408 | 435 | old := targetSess.client | |
| 409 | 436 | targetSess.terminal = term | |
| 410 | - | targetSess.client = ac | |
| 411 | 437 | if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) { | |
| 412 | 438 | targetSess.active = target.TabIndex | |
| 413 | 439 | } | |
| 414 | 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 | 445 | ac.setSession(targetSess) | |
| 446 | + | targetSess.mu.Lock() | |
| 447 | + | targetSess.client = ac | |
| 448 | + | targetSess.mu.Unlock() | |
| 449 | + | d.touchMRU(targetSess) | |
| 418 | 450 | ac.recordPreviousSession(from) | |
| 419 | 451 | d.mu.Unlock() | |
| 452 | + | finishRenderLifecycleCleanups(cleanups) | |
| 420 | 453 | return old | |
| 421 | 454 | } | |
| 422 | 455 | ||
| @@ -425,7 +458,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 425 | 458 | stopped, ok := d.stopped[target.Name] | |
| 426 | 459 | if !ok || stopped.purging { | |
| 427 | 460 | d.mu.Unlock() | |
| 428 | - | d.paint(from, ac, true) | |
| 461 | + | d.invalidateRender(from, ac, true, "picker.go") | |
| 429 | 462 | return false | |
| 430 | 463 | } | |
| 431 | 464 | from.mu.Lock() | |
| @@ -441,20 +474,22 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 441 | 474 | from.mu.Unlock() | |
| 442 | 475 | d.mu.Unlock() | |
| 443 | 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 | 478 | return false | |
| 446 | 479 | } | |
| 447 | 480 | from.client = nil | |
| 448 | 481 | ac.setSession(nil) | |
| 449 | 482 | from.mu.Unlock() | |
| 483 | + | ||
| 484 | + | cleanups := d.handoffCoordinator(from, targetSess, nil, ac) | |
| 485 | + | ac.setSession(targetSess) | |
| 450 | 486 | targetSess.mu.Lock() | |
| 451 | 487 | targetSess.client = ac | |
| 452 | 488 | targetSess.mu.Unlock() | |
| 453 | - | ||
| 454 | 489 | d.touchMRU(targetSess) | |
| 455 | - | ac.setSession(targetSess) | |
| 456 | 490 | ac.recordPreviousSession(from) | |
| 457 | 491 | d.mu.Unlock() | |
| 492 | + | finishRenderLifecycleCleanups(cleanups) | |
| 458 | 493 | d.firstPaint(targetSess, ac, ac.size) | |
| 459 | 494 | return true | |
| 460 | 495 | } | |
| @@ -496,49 +531,59 @@ func (d *Daemon) refreshPicker(ac *attachedClient) { | |||
| 496 | 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 | 534 | func snapshotPickerPreview(tb *tab) picker.Preview { | |
| 515 | 535 | if tb == nil { | |
| 516 | 536 | return picker.Preview{} | |
| 517 | 537 | } | |
| 518 | 538 | tb.mu.Lock() | |
| 519 | - | defer tb.mu.Unlock() | |
| 520 | - | return pickerPreviewFromLockedTab(tb) | |
| 521 | - | } | |
| 522 | - | ||
| 523 | - | func pickerPreviewFromLockedTab(tb *tab) picker.Preview { | |
| 524 | - | p := tb.focusedPane() | |
| 525 | - | if p == nil { | |
| 526 | - | return picker.Preview{} | |
| 527 | - | } | |
| 528 | - | if tb.tree == nil || tb.tree.Root == nil || tb.tree.Root.Kind == layout.Leaf { | |
| 539 | + | layoutSnap := solveTabLayoutLocked(tb) | |
| 540 | + | if !layoutSnap.ok { | |
| 541 | + | p := tb.focusedPane() | |
| 542 | + | if p == nil { | |
| 543 | + | tb.mu.Unlock() | |
| 544 | + | return picker.Preview{} | |
| 545 | + | } | |
| 529 | 546 | p.mu.Lock() | |
| 530 | - | defer p.mu.Unlock() | |
| 531 | - | return pickerPreviewFromLockedPane(p) | |
| 547 | + | preview := pickerPreviewFromLockedPane(p) | |
| 548 | + | p.mu.Unlock() | |
| 549 | + | tb.mu.Unlock() | |
| 550 | + | return preview | |
| 551 | + | } | |
| 552 | + | state := capturedRenderState{layout: capturedTabLayout{ | |
| 553 | + | root: tb.tree.Clone().Root, area: layoutSnap.area, focus: layoutSnap.focus, | |
| 554 | + | placements: append([]layout.Placement(nil), layoutSnap.placements...), | |
| 555 | + | fingerprint: layoutSnap.fingerprint, valid: true, | |
| 556 | + | }} | |
| 557 | + | for _, placement := range layoutSnap.placements { | |
| 558 | + | p := tb.panes[placement.ID] | |
| 559 | + | if p == nil { | |
| 560 | + | continue | |
| 561 | + | } | |
| 562 | + | visible := placement.Content | |
| 563 | + | if placement.Collapsed { | |
| 564 | + | visible = domain.Rect{} | |
| 565 | + | } | |
| 566 | + | p.mu.Lock() | |
| 567 | + | captured := capturePaneRenderStateLocked(p, visible) | |
| 568 | + | p.mu.Unlock() | |
| 569 | + | captured.placement = placement | |
| 570 | + | captured.focused = placement.ID == layoutSnap.focus | |
| 571 | + | state.panes = append(state.panes, captured) | |
| 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} | |
| 535 | - | if area.Width <= 0 || area.Height <= 0 { | |
| 536 | - | p.mu.Lock() | |
| 537 | - | defer p.mu.Unlock() | |
| 538 | - | return pickerPreviewFromLockedPane(p) | |
| 577 | + | func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview { | |
| 578 | + | composed := composeFrame(state, composeCacheInput{}, composeCacheInput{}).frame | |
| 579 | + | if composed.Height < 2 { | |
| 580 | + | return pickerPreviewFromFrame(composed) | |
| 581 | + | } | |
| 582 | + | rows := make([][]renderer.Cell, composed.Height-2) | |
| 583 | + | for y := range rows { | |
| 584 | + | rows[y] = append([]renderer.Cell(nil), composed.Row(y+1)...) | |
| 539 | 585 | } | |
| 540 | - | frame, _ := composeTabFrame(tb, area, themeui.Theme{}) | |
| 541 | - | return pickerPreviewFromFrame(frame) | |
| 586 | + | return picker.Preview{Rows: rows, Width: composed.Width, Height: len(rows)} | |
| 542 | 587 | } | |
| 543 | 588 | ||
| 544 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "sync" | |
| 5 | + | "strings" | |
| 6 | 6 | "testing" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "github.com/bnema/vev/internal/usecase/keys" | |
| 16 | 16 | "github.com/bnema/vev/internal/usecase/layout" | |
| 17 | 17 | "github.com/bnema/vev/internal/usecase/picker" | |
| 18 | + | "github.com/bnema/vev/pkg/vt" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | // --- test doubles ----------------------------------------------------------- | |
| @@ -217,6 +218,47 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) { | |||
| 217 | 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 | 262 | func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |
| 221 | 263 | p1, releasePTY1 := newBlockingPTY(t) | |
| 222 | 264 | p2, releasePTY2 := newBlockingPTY(t) | |
| @@ -259,6 +301,40 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||
| 259 | 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 | 338 | func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |
| 263 | 339 | p1, releasePTY1 := newBlockingPTY(t) | |
| 264 | 340 | p2, releasePTY2 := newBlockingPTY(t) | |
| @@ -295,7 +371,7 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |||
| 295 | 371 | oldPane.screen.Write([]byte("stale-damage")) | |
| 296 | 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 | 376 | require.Zero(t, len(sends1), "stale paint from old session sent a frame") | |
| 301 | 377 | require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage") | |
| @@ -329,7 +405,7 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||
| 329 | 405 | sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching")) | |
| 330 | 406 | paintDone := make(chan struct{}) | |
| 331 | 407 | go func() { | |
| 332 | - | d.paint(sess1, ac, false) | |
| 408 | + | d.paint(sess1, ac, false, nil) | |
| 333 | 409 | close(paintDone) | |
| 334 | 410 | }() | |
| 335 | 411 | <-enteredSend | |
| @@ -368,8 +444,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||
| 368 | 444 | defer releasePTY3() | |
| 369 | 445 | var openedEnv []string | |
| 370 | 446 | f := portsmocks.NewMockPTYFactory(t) | |
| 371 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 372 | - | func(_ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) { | |
| 447 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 448 | + | func(_ context.Context, _ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) { | |
| 373 | 449 | if sz != (domain.Size{Cols: 80, Rows: 22}) { | |
| 374 | 450 | return newQuietPTY(), nil | |
| 375 | 451 | } | |
| @@ -403,6 +479,72 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||
| 403 | 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 | 548 | func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) { | |
| 407 | 549 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |
| 408 | 550 | p := tb.focusedPane() | |
| @@ -448,123 +590,6 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) { | |||
| 448 | 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 | 593 | func TestPickerModalGeometry(t *testing.T) { | |
| 569 | 594 | base := domain.Size{Cols: 100, Rows: 40} | |
| 570 | 595 | ||
| @@ -582,7 +607,7 @@ func TestPickerResizeRecomposesModal(t *testing.T) { | |||
| 582 | 607 | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) | |
| 583 | 608 | // Picker recomposition is the relevant event here; do not depend on a real | |
| 584 | 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 | 612 | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 588 | 613 | msg, err := ports.UnmarshalOutput(out.Payload) | |
| @@ -600,8 +625,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 600 | 625 | var opens [][]string | |
| 601 | 626 | f := portsmocks.NewMockPTYFactory(t) | |
| 602 | 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( | |
| 604 | - | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 628 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | |
| 629 | + | func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 605 | 630 | opens = append(opens, append([]string(nil), env...)) | |
| 606 | 631 | if len(opens) == 1 { | |
| 607 | 632 | return p1, nil | |
| @@ -610,7 +635,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 610 | 635 | }, | |
| 611 | 636 | ).Twice() | |
| 612 | 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 | 639 | return got != normalSize && got.Valid() | |
| 615 | 640 | })).Return(floating, nil).Once() | |
| 616 | 641 | d := newTestDaemon(t, f, stubClock{}) | |
M internal/usecase/daemon/prompt.go +5 -11
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | 7 | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| 8 | 8 | "github.com/bnema/vev/internal/usecase/ui" | |
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 12 | 11 | func promptModalFor(title string) ui.Modal { | |
| @@ -20,7 +19,7 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s | |||
| 20 | 19 | ac.overlays.promptSubmit = submit | |
| 21 | 20 | ac.overlays.promptPending = nil | |
| 22 | 21 | ac.overlays.promptMu.Unlock() | |
| 23 | - | d.paint(sess, ac, true) | |
| 22 | + | d.invalidateRender(sess, ac, true, "prompt.go") | |
| 24 | 23 | } | |
| 25 | 24 | ||
| 26 | 25 | func (d *Daemon) closePrompt(ac *attachedClient) { | |
| @@ -77,26 +76,21 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |||
| 77 | 76 | ac.overlays.prompt.SetError(err.Error()) | |
| 78 | 77 | } | |
| 79 | 78 | ac.overlays.promptMu.Unlock() | |
| 80 | - | d.paint(sess, ac, true) | |
| 79 | + | d.invalidateRender(sess, ac, true, "prompt.go") | |
| 81 | 80 | return | |
| 82 | 81 | } | |
| 83 | 82 | d.closePrompt(ac) | |
| 84 | 83 | if current := ac.currentSession(); current != nil { | |
| 85 | - | d.paint(current, ac, true) | |
| 84 | + | d.invalidateRender(current, ac, true, "prompt.go") | |
| 86 | 85 | } | |
| 87 | 86 | return | |
| 88 | 87 | } | |
| 89 | 88 | if exit { | |
| 90 | 89 | d.closePrompt(ac) | |
| 91 | - | d.paint(sess, ac, true) | |
| 90 | + | d.invalidateRender(sess, ac, true, "prompt.go") | |
| 92 | 91 | return | |
| 93 | 92 | } | |
| 94 | 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: the | |
| 2 | - | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |
| 3 | - | // and VT screen, and the per-client debounced render scheduler. | |
| 4 | - | // | |
| 5 | - | // Concurrency model (sessions own one or more PTY-backed tabs): | |
| 6 | - | // | |
| 7 | - | // - Serve runs the accept loop. Each accepted connection is handled by its | |
| 8 | - | // own goroutine (handleConn): it reads the first frame and routes it to a | |
| 9 | - | // session create/attach, a list, or a kill. | |
| 10 | - | // - Per session there are exactly two long-lived goroutines: the PTY reader | |
| 11 | - | // (drains child output into the VT screen and pokes a cap-1 dirty channel) | |
| 12 | - | // and the render scheduler (debounces dirties and paints the attached | |
| 13 | - | // client). Both are tied to the session context and unwind when the | |
| 14 | - | // session is killed (pty.Close unblocks the reader; ctx cancel stops the | |
| 15 | - | // scheduler). | |
| 16 | - | // - The daemon exits (Serve returns) when the last session is removed, or | |
| 17 | - | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | - | // attached clients with ReasonServerShutdown). | |
| 19 | - | // | |
| 20 | - | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | - | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 1 | + | // Package daemon holds vev's server-side session multiplexer use case. | |
| 25 | 2 | package daemon | |
| 26 | 3 | ||
| 27 | 4 | import ( | |
| 28 | 5 | "bytes" | |
| 29 | - | "errors" | |
| 30 | - | "strconv" | |
| 31 | - | "time" | |
| 32 | 6 | ||
| 33 | 7 | "github.com/bnema/vev/internal/domain" | |
| 34 | 8 | "github.com/bnema/vev/internal/ports" | |
| 35 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 36 | 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 37 | - | "github.com/bnema/vev/internal/usecase/ui" | |
| 38 | 11 | "github.com/bnema/vev/pkg/renderer" | |
| 39 | - | "github.com/bnema/vev/pkg/vt" | |
| 40 | 12 | ) | |
| 41 | 13 | ||
| 42 | - | func signal(ch chan struct{}) { | |
| 43 | - | select { | |
| 44 | - | case ch <- struct{}{}: | |
| 45 | - | default: | |
| 14 | + | // completedSynchronizedUpdate reports a full DEC 2026 batch contained in one | |
| 15 | + | // PTY read. The VT's final state is inactive in this case, so it cannot be | |
| 16 | + | // inferred from before/after SyncUpdateActive alone. | |
| 17 | + | func completedSynchronizedUpdate(data []byte) bool { | |
| 18 | + | _, tail, found := bytes.Cut(data, []byte(renderer.SyncStartCSI)) | |
| 19 | + | return found && bytes.Contains(tail, []byte(renderer.SyncEndCSI)) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // paneRenderable reports dynamic composition visibility. Callers must not | |
| 23 | + | // hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks. | |
| 24 | + | func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool { | |
| 25 | + | if sess == nil || tb == nil || p == nil { | |
| 26 | + | return false | |
| 27 | + | } | |
| 28 | + | sess.mu.Lock() | |
| 29 | + | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | |
| 30 | + | attached := sess.client != nil | |
| 31 | + | sess.mu.Unlock() | |
| 32 | + | ||
| 33 | + | // The normal attached render path needs no cross-session picker lookup. | |
| 34 | + | // Only inactive or headless tabs can be renderable as a picker preview. | |
| 35 | + | if (!active || !attached) && !d.tabIsPickerPreview(tb) { | |
| 36 | + | return false | |
| 37 | + | } | |
| 38 | + | tb.mu.Lock() | |
| 39 | + | defer tb.mu.Unlock() | |
| 40 | + | if tb.floating.pane == p { | |
| 41 | + | return active && attached && tb.floating.state == floatingVisible | |
| 42 | + | } | |
| 43 | + | placements, ok := solvedPlacementsLocked(tb) | |
| 44 | + | if !ok { | |
| 45 | + | return false | |
| 46 | + | } | |
| 47 | + | for _, placement := range placements { | |
| 48 | + | if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 { | |
| 49 | + | return true | |
| 50 | + | } | |
| 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 | 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 | 88 | return | |
| 53 | 89 | } | |
| 54 | 90 | buf := make([]byte, ptyReadBufSize) | |
| 55 | - | var resp []byte | |
| 56 | - | var clipboards []string | |
| 57 | - | attentionCh := make(chan struct{}, 1) | |
| 58 | 91 | p.mu.Lock() | |
| 59 | - | p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | |
| 60 | - | p.screen.OnBell = func() { signal(attentionCh) } | |
| 61 | - | p.screen.OnNotify = func(string, string) { signal(attentionCh) } | |
| 62 | - | p.screen.OnProgress = func(bool) { signal(attentionCh) } | |
| 63 | - | p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) } | |
| 92 | + | p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) } | |
| 93 | + | p.screen.OnBell = func() { p.ptyAttention = true } | |
| 94 | + | p.screen.OnNotify = func(string, string) { p.ptyAttention = true } | |
| 95 | + | p.screen.OnProgress = func(bool) { p.ptyAttention = true } | |
| 96 | + | p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) } | |
| 64 | 97 | p.mu.Unlock() | |
| 65 | 98 | for { | |
| 66 | 99 | n, err := p.pty.Read(buf) | |
| 67 | 100 | if n > 0 { | |
| 68 | - | data := buf[:n] | |
| 69 | - | p.mu.Lock() | |
| 70 | - | wasSyncing := p.screen.SyncUpdateActive() | |
| 71 | - | p.screen.Write(data) | |
| 72 | - | p.refreshTerminalTitleLocked() | |
| 73 | - | isSyncing := p.screen.SyncUpdateActive() | |
| 74 | - | p.mu.Unlock() | |
| 75 | - | markSnapshotDirty(sess) | |
| 76 | - | select { | |
| 77 | - | case <-attentionCh: | |
| 78 | - | d.noteAttention(sess, tb) | |
| 79 | - | default: | |
| 80 | - | } | |
| 81 | - | if len(clipboards) > 0 { | |
| 82 | - | for _, b64 := range clipboards { | |
| 83 | - | d.forwardClipboardAsync(sess, b64) | |
| 84 | - | } | |
| 85 | - | clipboards = clipboards[:0] | |
| 86 | - | } | |
| 87 | - | if len(resp) > 0 { | |
| 88 | - | if _, writeErr := p.pty.Write(resp); writeErr != nil { | |
| 89 | - | d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name) | |
| 90 | - | } | |
| 91 | - | resp = resp[:0] | |
| 92 | - | } | |
| 93 | - | if wasSyncing != isSyncing { | |
| 94 | - | p.mu.Lock() | |
| 95 | - | p.syncGen++ | |
| 96 | - | gen := p.syncGen | |
| 97 | - | p.mu.Unlock() | |
| 98 | - | if isSyncing { | |
| 99 | - | go d.syncWatchdog(p, gen) | |
| 100 | - | } | |
| 101 | - | } | |
| 102 | - | if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) { | |
| 103 | - | signal(p.flush) | |
| 104 | - | continue | |
| 105 | - | } | |
| 106 | - | if isSyncing { | |
| 107 | - | continue | |
| 108 | - | } | |
| 109 | - | signal(p.dirty) | |
| 101 | + | d.processPTYData(sess, tb, p, buf[:n], true) | |
| 110 | 102 | } | |
| 111 | 103 | if err != nil { | |
| 112 | 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; | |
| 123 | - | // sustained floods progressively widen that tab, while isolated updates | |
| 124 | - | // return to the minimum delay for interactive latency. | |
| 125 | - | func (d *Daemon) scheduler(sess *session, tb *tab, p *pane) { | |
| 126 | - | defer d.sessWg.Done() | |
| 127 | - | if p == nil { | |
| 114 | + | // processPTYData is the sole VT parsing path. While a resize apply owns the | |
| 115 | + | // pane, reader calls append exact bytes and returns immediately; it never waits | |
| 116 | + | // for PTY.Resize or loses a short read. | |
| 117 | + | func (d *Daemon) processPTYData(sess *session, tb *tab, p *pane, data []byte, bufferDuringApply bool) { | |
| 118 | + | rc := sess.renderCoordinator() | |
| 119 | + | p.mu.Lock() | |
| 120 | + | if bufferDuringApply && p.resizeApplying { | |
| 121 | + | p.resizePending = append(p.resizePending, data...) | |
| 122 | + | p.mu.Unlock() | |
| 128 | 123 | return | |
| 129 | 124 | } | |
| 130 | - | delay := minDebounceInterval | |
| 131 | - | lastRender := d.clock.Now() | |
| 132 | - | outer: | |
| 133 | - | for { | |
| 134 | - | select { | |
| 135 | - | case <-sess.ctx.Done(): | |
| 136 | - | return | |
| 137 | - | case <-paneDone(p): | |
| 138 | - | return | |
| 139 | - | case <-p.flush: | |
| 140 | - | d.render(sess, tb, p) | |
| 141 | - | lastRender = d.clock.Now() | |
| 142 | - | continue | |
| 143 | - | case <-p.dirty: | |
| 144 | - | if d.clock.Now().Sub(lastRender) >= maxDebounceInterval { | |
| 145 | - | delay = minDebounceInterval | |
| 125 | + | wasSyncing := p.screen.SyncUpdateActive() | |
| 126 | + | p.screen.Write(data) | |
| 127 | + | p.refreshTerminalTitleLocked() | |
| 128 | + | isSyncing := p.screen.SyncUpdateActive() | |
| 129 | + | completeSyncRead := !wasSyncing && !isSyncing && completedSynchronizedUpdate(data) | |
| 130 | + | var syncGen uint64 | |
| 131 | + | syncEnded := false | |
| 132 | + | if wasSyncing != isSyncing { | |
| 133 | + | if isSyncing { | |
| 134 | + | syncGen = sess.syncGen.Add(1) | |
| 135 | + | p.syncGen = syncGen | |
| 136 | + | if rc != nil { | |
| 137 | + | rc.noteSyncBeginWithRenderability(p, syncGen, func() bool { return d.paneRenderable(sess, tb, p) }, func() { | |
| 138 | + | p.mu.Lock() | |
| 139 | + | if p.syncGen == syncGen && p.screen.SyncUpdateActive() { | |
| 140 | + | p.screen.ForceSyncEnd() | |
| 141 | + | } | |
| 142 | + | p.mu.Unlock() | |
| 143 | + | }) | |
| 146 | 144 | } | |
| 145 | + | } else { | |
| 146 | + | syncGen = p.syncGen | |
| 147 | + | syncEnded = rc != nil && rc.removeSyncEnd(p, syncGen, true) | |
| 147 | 148 | } | |
| 148 | - | ||
| 149 | - | coalesced := 0 | |
| 150 | - | timer := d.clock.NewTimer(delay) | |
| 151 | - | absorb: | |
| 152 | - | for { | |
| 153 | - | select { | |
| 154 | - | case <-sess.ctx.Done(): | |
| 155 | - | timer.Stop() | |
| 156 | - | return | |
| 157 | - | case <-paneDone(p): | |
| 158 | - | timer.Stop() | |
| 159 | - | return | |
| 160 | - | case <-p.flush: | |
| 161 | - | if !timer.Stop() { | |
| 162 | - | select { | |
| 163 | - | case <-timer.C(): | |
| 164 | - | default: | |
| 165 | - | } | |
| 149 | + | } | |
| 150 | + | p.mu.Unlock() | |
| 151 | + | renderable := d.paneRenderable(sess, tb, p) | |
| 152 | + | markSnapshotDirty(sess) | |
| 153 | + | d.flushPTYEffects(sess, tb, p) | |
| 154 | + | if rc != nil { | |
| 155 | + | if wasSyncing != isSyncing { | |
| 156 | + | if isSyncing { | |
| 157 | + | if renderable { | |
| 158 | + | rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |
| 166 | 159 | } | |
| 167 | - | d.render(sess, tb, p) | |
| 168 | - | lastRender = d.clock.Now() | |
| 169 | - | continue outer | |
| 170 | - | case <-p.dirty: | |
| 171 | - | coalesced++ | |
| 172 | - | case <-timer.C(): | |
| 173 | - | break absorb | |
| 160 | + | } else if syncEnded && renderable { | |
| 161 | + | rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"}) | |
| 174 | 162 | } | |
| 175 | 163 | } | |
| 176 | - | delay = nextDebounceDelay(delay, coalesced) | |
| 177 | - | d.render(sess, tb, p) | |
| 178 | - | lastRender = d.clock.Now() | |
| 179 | - | } | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration { | |
| 183 | - | if coalesced == 0 { | |
| 184 | - | return minDebounceInterval | |
| 185 | - | } | |
| 186 | - | if delay >= maxDebounceInterval { | |
| 187 | - | return maxDebounceInterval | |
| 188 | - | } | |
| 189 | - | delay += debounceStep | |
| 190 | - | if delay > maxDebounceInterval { | |
| 191 | - | return maxDebounceInterval | |
| 164 | + | if completeSyncRead && renderable { | |
| 165 | + | rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"}) | |
| 166 | + | } else if renderable && !isSyncing && wasSyncing == isSyncing { | |
| 167 | + | rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |
| 168 | + | } | |
| 192 | 169 | } | |
| 193 | - | return delay | |
| 194 | 170 | } | |
| 195 | 171 | ||
| 196 | - | func (d *Daemon) syncWatchdog(p *pane, gen uint64) { | |
| 197 | - | timer := d.clock.NewTimer(maxSyncUpdateDuration) | |
| 198 | - | select { | |
| 199 | - | case <-paneDone(p): | |
| 200 | - | timer.Stop() | |
| 201 | - | return | |
| 202 | - | case <-timer.C(): | |
| 203 | - | } | |
| 204 | - | ||
| 172 | + | // flushPTYEffects preserves the normal response/title/attention/clipboard | |
| 173 | + | // behavior for both direct reads and resize-buffer replay. | |
| 174 | + | func (d *Daemon) flushPTYEffects(sess *session, tb *tab, p *pane) { | |
| 205 | 175 | p.mu.Lock() | |
| 206 | - | if p.syncGen != gen || !p.screen.SyncUpdateActive() { | |
| 207 | - | p.mu.Unlock() | |
| 208 | - | return | |
| 209 | - | } | |
| 210 | - | p.screen.ForceSyncEnd() | |
| 176 | + | attention := p.ptyAttention | |
| 177 | + | p.ptyAttention = false | |
| 178 | + | responses := append([]byte(nil), p.ptyResponses...) | |
| 179 | + | p.ptyResponses = p.ptyResponses[:0] | |
| 180 | + | clipboards := append([]string(nil), p.ptyClipboards...) | |
| 181 | + | p.ptyClipboards = p.ptyClipboards[:0] | |
| 211 | 182 | p.mu.Unlock() | |
| 212 | - | signal(p.flush) | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | func syncUpdateEndIn(data []byte) bool { | |
| 216 | - | return bytes.Contains(data, []byte("\x1b[?2026l")) | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | // render paints the current client, or (when detached) just clears accumulated | |
| 220 | - | // damage so it never grows unbounded while headless. | |
| 221 | - | func (d *Daemon) render(sess *session, tb *tab, p *pane) { | |
| 222 | - | if p == nil { | |
| 223 | - | return | |
| 183 | + | if attention { | |
| 184 | + | d.noteAttention(sess, tb) | |
| 224 | 185 | } | |
| 225 | - | p.mu.Lock() | |
| 226 | - | if p.screen.SyncUpdateActive() { | |
| 227 | - | p.mu.Unlock() | |
| 228 | - | return | |
| 186 | + | for _, b64 := range clipboards { | |
| 187 | + | d.forwardClipboardAsync(sess, b64) | |
| 229 | 188 | } | |
| 230 | - | p.mu.Unlock() | |
| 231 | - | ||
| 232 | - | // A retained floating pane continues to drain its PTY while hidden. Its | |
| 233 | - | // scheduler must not route through picker preview (or the active client), | |
| 234 | - | // but it must consume damage so a long-hidden command cannot retain it. | |
| 235 | - | tb.mu.Lock() | |
| 236 | - | hiddenFloating := tb.floating.pane == p && tb.floating.state != floatingVisible | |
| 237 | - | previewer := tb.previewClient | |
| 238 | - | tb.mu.Unlock() | |
| 239 | - | if hiddenFloating { | |
| 240 | - | p.mu.Lock() | |
| 241 | - | p.screen.ClearDamage() | |
| 242 | - | p.mu.Unlock() | |
| 243 | - | return | |
| 244 | - | } | |
| 245 | - | ||
| 246 | - | sess.mu.Lock() | |
| 247 | - | ac := sess.client | |
| 248 | - | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | |
| 249 | - | sess.mu.Unlock() | |
| 250 | - | ||
| 251 | - | if previewer != nil { | |
| 252 | - | if previewSess := previewer.currentSession(); previewSess != nil { | |
| 253 | - | d.paint(previewSess, previewer, false) | |
| 189 | + | if len(responses) > 0 { | |
| 190 | + | if _, err := p.pty.Write(responses); err != nil { | |
| 191 | + | d.log.Warn("pty response write failed", "err", err, "session", sess.name) | |
| 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 | 196 | // paint draws the composed client frame (active tab plus status bar) and | |
| 276 | 197 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | |
| 277 | 198 | // such as switch/create/close/rename/resize so the repaint is complete. | |
| 278 | - | // copyTargetRectLocked maps a captured copy source into the already-composed | |
| 279 | - | // client frame. The caller holds tb.mu, preserving the layout/floating | |
| 280 | - | // snapshot while the rectangle is chosen. | |
| 281 | - | func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, floatingFrameGeometry floatingGeometry) domain.Rect { | |
| 282 | - | if p == nil { | |
| 283 | - | return domain.Rect{} | |
| 284 | - | } | |
| 285 | - | if hasFloating && p == floating { | |
| 286 | - | return floatingFrameGeometry.Inner | |
| 287 | - | } | |
| 288 | - | for _, placement := range layoutSnap.placements { | |
| 289 | - | if placement.ID == p.id && !placement.Collapsed { | |
| 290 | - | r := placement.Content | |
| 291 | - | r.X += contentArea.X | |
| 292 | - | r.Y += contentArea.Y | |
| 293 | - | return r | |
| 294 | - | } | |
| 199 | + | func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid bool) { | |
| 200 | + | if d != nil && d.runtimeObserver != nil { | |
| 201 | + | d.runtimeObserver.ObserveRuntime(ports.NewRuntimeMark("daemon", kind, bytes, valid)) | |
| 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 { | |
| 303 | - | if !ok { | |
| 304 | - | return nil | |
| 305 | - | } | |
| 306 | - | ids := make([]layout.PaneID, 0, len(placements)) | |
| 307 | - | for _, pl := range placements { | |
| 308 | - | if pl.TitleBar.Height > 0 { | |
| 309 | - | ids = append(ids, pl.ID) | |
| 310 | - | } | |
| 205 | + | // runtimeMarkBatch captures immutable runtime marks during a render transaction. | |
| 206 | + | // paint owns attachment sendMu while it captures, composes, and emits; JSONL | |
| 207 | + | // observer I/O must therefore happen only after that ownership is released. | |
| 208 | + | type runtimeMarkBatch struct { | |
| 209 | + | observer ports.RuntimeObserver | |
| 210 | + | marks []ports.RuntimeMark | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch { | |
| 214 | + | if d == nil { | |
| 215 | + | return runtimeMarkBatch{} | |
| 311 | 216 | } | |
| 312 | - | return ids | |
| 217 | + | return runtimeMarkBatch{observer: d.runtimeObserver} | |
| 313 | 218 | } | |
| 314 | 219 | ||
| 315 | - | func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) { | |
| 316 | - | ac.resizePaint.stop() | |
| 317 | - | ac.resizePaintGeneration++ | |
| 318 | - | generation := ac.resizePaintGeneration | |
| 319 | - | ac.resizePaintPending = true | |
| 320 | - | ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) { | |
| 321 | - | d.paintForResizeGeneration(sess, ac, true, generation) | |
| 322 | - | }) | |
| 220 | + | func (b *runtimeMarkBatch) span(start, end ports.RuntimeMarkKind, bytes uint64) func(uint64, bool) { | |
| 221 | + | if b == nil || b.observer == nil { | |
| 222 | + | return func(uint64, bool) {} | |
| 223 | + | } | |
| 224 | + | correlation := ports.NewRuntimeCorrelation() | |
| 225 | + | b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, start, bytes, true)) | |
| 226 | + | return func(endBytes uint64, valid bool) { | |
| 227 | + | b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, end, endBytes, valid)) | |
| 228 | + | } | |
| 323 | 229 | } | |
| 324 | 230 | ||
| 325 | - | func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |
| 326 | - | d.paintForResizeGeneration(sess, ac, reset, 0) | |
| 231 | + | func (b *runtimeMarkBatch) flush() { | |
| 232 | + | if b == nil || b.observer == nil { | |
| 233 | + | return | |
| 234 | + | } | |
| 235 | + | for _, mark := range b.marks { | |
| 236 | + | b.observer.ObserveRuntime(mark) | |
| 237 | + | } | |
| 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 | 242 | tb := sess.activeTab() | |
| 331 | 243 | if tb == nil { | |
| 332 | 244 | return | |
| 333 | 245 | } | |
| 334 | 246 | ||
| 335 | - | ac.initOverlays() | |
| 336 | 247 | ac.sendMu.Lock() | |
| 337 | - | if ac.currentSession() != sess { | |
| 338 | - | ac.sendMu.Unlock() | |
| 339 | - | return | |
| 340 | - | } | |
| 341 | - | if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) { | |
| 248 | + | // sendMu is the attachment ownership boundary. Check the session's | |
| 249 | + | // published identity while holding it so a deadline captured before an | |
| 250 | + | // attach/replace cannot emit on either the old or new output chain. | |
| 251 | + | sess.mu.Lock() | |
| 252 | + | owned := sess.client == ac | |
| 253 | + | sess.mu.Unlock() | |
| 254 | + | if !owned || ac.currentSession() != sess { | |
| 342 | 255 | ac.sendMu.Unlock() | |
| 343 | 256 | return | |
| 344 | 257 | } | |
| 345 | - | if ac.resizePaintPending { | |
| 346 | - | ac.resizePaint.stop() | |
| 347 | - | ac.resizePaintPending = false | |
| 348 | - | reset = true | |
| 258 | + | if lease != nil { | |
| 259 | + | rc := sess.renderCoordinator() | |
| 260 | + | if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) { | |
| 261 | + | ac.sendMu.Unlock() | |
| 262 | + | return | |
| 263 | + | } | |
| 349 | 264 | } | |
| 350 | - | // Ack-gated coalescing: while the client is at least maxUnackedOutputStates | |
| 351 | - | // behind, skip composing another diff and mark the paint deferred. The | |
| 352 | - | // MsgOutputAck handler flushes one cumulative paint once acks catch up. A | |
| 353 | - | // deferred reset stays sticky so the cumulative paint is dependency-free. | |
| 354 | - | if ac.output.deferIfAtCapacity(reset) { | |
| 265 | + | // Capacity is checked before any destructive capture. The coordinator is | |
| 266 | + | // the normal gate, but this ownership check also protects direct resize and | |
| 267 | + | // test paint paths from consuming damage that cannot be emitted. | |
| 268 | + | if ac.output != nil && ac.output.atCapacity() { | |
| 269 | + | // The coordinator owns the blocked interval; this guard only protects | |
| 270 | + | // direct test/resize paint calls from destructive capture. | |
| 355 | 271 | ac.sendMu.Unlock() | |
| 356 | 272 | 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 | 278 | overlays := ac.overlays.SnapshotForRender() | |
| 359 | 279 | repaintAttachedClients := false | |
| 360 | 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 | 286 | if repaintAttachedClients { | |
| 362 | 287 | d.repaintAllAttachedClients() | |
| 363 | 288 | } | |
| 364 | 289 | }() | |
| 365 | - | defer overlays.Unlock() | |
| 366 | 290 | preview := snapshotPickerPreview(nil) | |
| 367 | 291 | if overlays.previewTab != tb { | |
| 368 | 292 | preview = snapshotPickerPreview(overlays.previewTab) | |
| @@ -375,11 +299,16 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 375 | 299 | attentionVisible := pulseVisible(bars.attentionFrame) | |
| 376 | 300 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | |
| 377 | 301 | ||
| 378 | - | styles := newThemeStyles(ac.getTheme()) | |
| 379 | 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 | 306 | tb.mu.Lock() | |
| 381 | - | layoutSnap := solveTabLayoutLocked(tb) | |
| 382 | - | titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok) | |
| 307 | + | titleIDs := ac.renderScratch.titleIDs[:0] | |
| 308 | + | if tb.tree != nil { | |
| 309 | + | titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root) | |
| 310 | + | } | |
| 311 | + | ac.renderScratch.titleIDs = titleIDs | |
| 383 | 312 | floating := tb.floating.pane | |
| 384 | 313 | hasFloating := tb.floating.state == floatingVisible && floating != nil | |
| 385 | 314 | tb.mu.Unlock() | |
| @@ -390,189 +319,35 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 390 | 319 | d.refreshPaneDisplayTitle(sess, floating, false) | |
| 391 | 320 | } | |
| 392 | 321 | ||
| 393 | - | tb.mu.Lock() | |
| 394 | - | if !layoutSnap.matchesLocked(tb) { | |
| 395 | - | layoutSnap = solveTabLayoutLocked(tb) | |
| 322 | + | capturedOverlays := capturedOverlayRenderState{ | |
| 323 | + | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, | |
| 324 | + | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, | |
| 325 | + | copyFeedback: overlays.copyFeedback, | |
| 396 | 326 | } | |
| 397 | - | p := tb.focusedPane() | |
| 398 | - | if p == nil { | |
| 399 | - | tb.mu.Unlock() | |
| 400 | - | ac.sendMu.Unlock() | |
| 401 | - | return | |
| 402 | - | } | |
| 403 | - | // Recheck the slot after refreshing its title outside the tab lock. One | |
| 404 | - | // paint uses one immutable floating config snapshot across composition, | |
| 405 | - | // copy targeting, and cursor placement. | |
| 406 | - | floating = tb.floating.pane | |
| 407 | - | hasFloating = tb.floating.state == floatingVisible && floating != nil | |
| 408 | - | floatingCfg := d.currentFloatingConfig() | |
| 409 | - | overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | |
| 410 | - | if reset || overlayActive { | |
| 411 | - | ac.bars.Reset() | |
| 412 | - | ac.composed.invalidate() | |
| 413 | - | } | |
| 414 | - | if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive { | |
| 415 | - | ac.lastCursor.valid = false | |
| 416 | - | } | |
| 417 | - | var frame renderer.Frame | |
| 418 | - | var damage []renderer.Damage | |
| 419 | - | if overlayActive { | |
| 420 | - | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, nil) | |
| 421 | - | } else { | |
| 422 | - | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed) | |
| 423 | - | } | |
| 424 | - | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | |
| 425 | - | floatingFrameGeometry := floatingGeometry{} | |
| 426 | - | if hasFloating { | |
| 427 | - | desiredFloatingGeometry := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, floatingCfg) | |
| 428 | - | frame, damage, floatingFrameGeometry = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, desiredFloatingGeometry, layoutSnap, bars.theme, &ac.composed, reset || overlayActive) | |
| 429 | - | } | |
| 430 | - | if overlays.copyActive { | |
| 431 | - | copyPane := overlays.copyPane | |
| 432 | - | if copyPane == nil { | |
| 433 | - | copyPane = p | |
| 434 | - | } | |
| 435 | - | copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry) | |
| 436 | - | frame, damage = composeCopyClientFrame(overlays.copyMode, overlays.copySnapshot, copyTarget, frame, bars) | |
| 437 | - | } | |
| 438 | - | // A palette above normal/copy content dims that composed content. When a | |
| 439 | - | // floating pane is present its own backdrop already dims normal pane | |
| 440 | - | // contents; applying the palette backdrop here would also dim the popup. | |
| 441 | - | if overlays.paletteActive && !hasFloating { | |
| 442 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme) | |
| 443 | - | } | |
| 444 | - | if overlays.copySearchModel != nil { | |
| 445 | - | frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles) | |
| 446 | - | } | |
| 447 | - | if overlays.pickerActive { | |
| 448 | - | if overlays.previewTab == tb { | |
| 449 | - | if layoutSnap.ok && tb.tree != nil && tb.tree.Root != nil && tb.tree.Root.Kind != layout.Leaf { | |
| 450 | - | previewFrame, _ := composeTabFrameWithLayout(tb, layoutSnap.area, themeui.Theme{}, layoutSnap) | |
| 451 | - | preview = pickerPreviewFromFrame(previewFrame) | |
| 452 | - | } else { | |
| 453 | - | preview = pickerPreviewFromLockedTab(tb) | |
| 454 | - | } | |
| 455 | - | } | |
| 456 | - | frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles) | |
| 457 | - | } | |
| 458 | - | if overlays.paletteActive { | |
| 459 | - | guidance := "" | |
| 460 | - | if overlays.paletteHints != nil { | |
| 461 | - | guidance = overlays.paletteHints.Feedback | |
| 462 | - | } | |
| 463 | - | frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, guidance, styles) | |
| 464 | - | } | |
| 465 | - | if overlays.promptActive { | |
| 466 | - | frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles) | |
| 467 | - | } | |
| 468 | - | overlays.Unlock() | |
| 469 | - | cursorPane := p | |
| 470 | - | cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id) | |
| 471 | - | cursorContent.X += contentArea.X | |
| 472 | - | cursorContent.Y += contentArea.Y | |
| 473 | - | if hasFloating { | |
| 474 | - | cursorPane = floating | |
| 475 | - | cursorContent, cursorVisible = floatingFrameGeometry.Inner, floatingFrameGeometry.Inner.Width > 0 && floatingFrameGeometry.Inner.Height > 0 | |
| 476 | - | } | |
| 477 | - | cursorPane.mu.Lock() | |
| 478 | - | desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive) | |
| 479 | - | cursorPane.mu.Unlock() | |
| 480 | - | ac.sess.mu.Lock() | |
| 481 | - | if ac.sess.v != sess { | |
| 482 | - | ac.sess.mu.Unlock() | |
| 483 | - | tb.mu.Unlock() | |
| 327 | + | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | |
| 328 | + | state, ok := capturePrimaryRenderState(sess, ac, bars, capturedOverlays, preview, floatingCfg, reset, lease) | |
| 329 | + | endCapture(0, ok) | |
| 330 | + | if !ok { | |
| 484 | 331 | ac.sendMu.Unlock() | |
| 485 | 332 | return | |
| 486 | 333 | } | |
| 487 | - | prepared, err := ac.output.prepare(frame, damage, reset || overlayActive) | |
| 488 | - | var data []byte | |
| 489 | - | if err == nil { | |
| 490 | - | data = prepared.data | |
| 491 | - | } | |
| 492 | - | var cursorTail []byte | |
| 493 | - | if err == nil { | |
| 494 | - | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | |
| 495 | - | } | |
| 496 | - | tb.mu.Unlock() | |
| 497 | - | ||
| 498 | - | var serr error | |
| 499 | - | var sendTr ports.Transport | |
| 500 | - | if err == nil { | |
| 501 | - | data = append(data, cursorTail...) | |
| 502 | - | if len(data) > 0 { | |
| 503 | - | sendTr = ac.transport() | |
| 504 | - | if sendTr == nil { | |
| 505 | - | serr = errors.New("client transport is nil") | |
| 506 | - | } else { | |
| 507 | - | send := sendTr.Send | |
| 508 | - | if async, ok := sendTr.(ports.AsyncTransport); ok { | |
| 509 | - | send = async.SendAsync | |
| 510 | - | } | |
| 511 | - | serr = prepared.send(data, ac.echoAck.Load(), send) | |
| 512 | - | } | |
| 513 | - | } | |
| 514 | - | } | |
| 515 | - | ac.sess.mu.Unlock() | |
| 516 | - | ac.sendMu.Unlock() | |
| 517 | - | ||
| 518 | - | if err != nil { | |
| 519 | - | d.log.Error("render draw failed", "err", err, "session", sess.name) | |
| 520 | - | return | |
| 521 | - | } | |
| 522 | - | if serr != nil { | |
| 523 | - | d.detachOnSendError(sess, ac, sendTr) | |
| 334 | + | if ac.renderStages.capture != nil { | |
| 335 | + | ac.renderStages.capture() | |
| 524 | 336 | } | |
| 525 | - | } | |
| 526 | - | ||
| 527 | - | func focusedPaneContentRect(layoutSnap tabLayoutSnapshot, id layout.PaneID) (domain.Rect, bool) { | |
| 528 | - | if !layoutSnap.ok { | |
| 529 | - | return layoutSnap.area, layoutSnap.area.Width > 0 && layoutSnap.area.Height > 0 | |
| 337 | + | if overlays.pickerActive && overlays.previewTab == tb { | |
| 338 | + | previewState := *state | |
| 339 | + | previewState.overlays = capturedOverlayRenderState{} | |
| 340 | + | previewState.reset = true | |
| 341 | + | state.preview = pickerPreviewFromCapturedRender(previewState) | |
| 530 | 342 | } | |
| 531 | - | for _, pl := range layoutSnap.placements { | |
| 532 | - | if pl.ID == id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | |
| 533 | - | return pl.Content, true | |
| 534 | - | } | |
| 343 | + | captureOverlayLayers(state, overlays, paletteCfg) | |
| 344 | + | endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0) | |
| 345 | + | composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch) | |
| 346 | + | endCompose(0, true) | |
| 347 | + | if ac.renderStages.compose != nil { | |
| 348 | + | ac.renderStages.compose() | |
| 535 | 349 | } | |
| 536 | - | return domain.Rect{}, false | |
| 537 | - | } | |
| 538 | - | ||
| 539 | - | // desiredCursorOut computes the terminal cursor state that should be shown to | |
| 540 | - | // the client for the current pane placement and overlay mode. | |
| 541 | - | func desiredCursorOut(s *vt.Screen, content domain.Rect, hide bool) cursorOut { | |
| 542 | - | if hide || !s.CursorVisible() { | |
| 543 | - | return cursorOut{hidden: true} | |
| 544 | - | } | |
| 545 | - | style, ok := s.CursorStyle() | |
| 546 | - | if !ok { | |
| 547 | - | style = 1 | |
| 548 | - | } | |
| 549 | - | return cursorOut{row: content.Y + s.CursorRow(), col: content.X + s.CursorCol(), style: style, hasStyle: true} | |
| 550 | - | } | |
| 551 | - | ||
| 552 | - | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | |
| 553 | - | changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle | |
| 554 | - | if !changed { | |
| 555 | - | return nil | |
| 556 | - | } | |
| 557 | - | prev := ac.lastCursor | |
| 558 | - | ac.lastCursor = desired | |
| 559 | - | ac.lastCursor.valid = true | |
| 560 | - | if desired.hidden { | |
| 561 | - | return []byte("\x1b[?25l") | |
| 562 | - | } | |
| 563 | - | var b []byte | |
| 564 | - | b = append(b, "\x1b["...) | |
| 565 | - | b = strconv.AppendInt(b, int64(desired.row+1), 10) | |
| 566 | - | b = append(b, ';') | |
| 567 | - | b = strconv.AppendInt(b, int64(desired.col+1), 10) | |
| 568 | - | b = append(b, 'H') | |
| 569 | - | if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle { | |
| 570 | - | b = append(b, "\x1b["...) | |
| 571 | - | b = strconv.AppendInt(b, int64(desired.style), 10) | |
| 572 | - | b = append(b, " q"...) | |
| 573 | - | } | |
| 574 | - | b = append(b, "\x1b[?25h"...) | |
| 575 | - | return b | |
| 350 | + | d.emitFrame(sess, ac, state, composed, &marks) | |
| 576 | 351 | } | |
| 577 | 352 | ||
| 578 | 353 | type themeStyles struct { | |
| @@ -638,211 +413,6 @@ func (c *barCache) Reset() { | |||
| 638 | 413 | 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 | 416 | func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement { | |
| 847 | 417 | pl.Content.X += dx | |
| 848 | 418 | pl.Content.Y += dy | |
| @@ -851,15 +421,6 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement { | |||
| 851 | 421 | 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 | 424 | func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) { | |
| 864 | 425 | rows := min(r.Height, src.Height) | |
| 865 | 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 | 438 | func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) { | |
| 897 | 439 | if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 { | |
| 898 | 440 | 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 | 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 | 95 | func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | |
| 59 | 96 | p, release := newBlockingPTY(t) | |
| 60 | 97 | defer release() | |
| @@ -84,7 +121,7 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||
| 84 | 121 | client := vt.NewScreen(80, 25) | |
| 85 | 122 | pane := sess.tabs[0].focusedPane() | |
| 86 | 123 | pane.screen.Write([]byte("X")) | |
| 87 | - | d.paint(sess, ac, true) | |
| 124 | + | d.paint(sess, ac, true, nil) | |
| 88 | 125 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 89 | 126 | undimmed := client.Frame.At(0, 1) | |
| 90 | 127 | topBar := client.Frame.At(0, 0) | |
| @@ -110,6 +147,451 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||
| 110 | 147 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 111 | 148 | // 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 | 595 | func TestPTYWriteErrorIsLogged(t *testing.T) { | |
| 114 | 596 | var logs bytes.Buffer | |
| 115 | 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 | 615 | func TestAltXClosesFinalTabAndDetaches(t *testing.T) { | |
| 184 | 616 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 185 | 617 | defer releases[0]() | |
| @@ -262,87 +694,6 @@ func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) { | |||
| 262 | 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 | 697 | // --- resize ordering -------------------------------------------------------- | |
| 347 | 698 | ||
| 348 | 699 | func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |
| @@ -372,14 +723,14 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||
| 372 | 723 | d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4}) | |
| 373 | 724 | require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0)) | |
| 374 | 725 | require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1)) | |
| 375 | - | require.Equal(t, 2, win.focusedPane().scrollback.Len()) | |
| 376 | - | require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0))) | |
| 377 | - | require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1))) | |
| 726 | + | require.Equal(t, 2, win.focusedPane().history.Len()) | |
| 727 | + | require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0))) | |
| 728 | + | require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1))) | |
| 378 | 729 | ||
| 379 | 730 | d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6}) | |
| 380 | 731 | require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0)) | |
| 381 | 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 | 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 | 805 | func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) { | |
| 488 | 806 | win := newTab(nil, domain.Size{Cols: 41, Rows: 4}) | |
| 489 | 807 | left := win.focusedPane() | |
| @@ -735,7 +1053,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | |||
| 735 | 1053 | { | |
| 736 | 1054 | name: "picker", | |
| 737 | 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 | 1057 | visible: "Sessions", | |
| 740 | 1058 | notVisible: "Sessions", | |
| 741 | 1059 | }, | |
| @@ -744,7 +1062,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | |||
| 744 | 1062 | open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) }, | |
| 745 | 1063 | close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) }, | |
| 746 | 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 | 1067 | visible: "[SCROLL]", | |
| 750 | 1068 | notVisible: "[SCROLL]", | |
| @@ -756,10 +1074,10 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | |||
| 756 | 1074 | p, release := newBlockingPTY(t) | |
| 757 | 1075 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 758 | 1076 | defer release() | |
| 759 | - | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 760 | 1077 | if tt.prepareScreen != nil { | |
| 761 | 1078 | tt.prepareScreen(sess) | |
| 762 | 1079 | } | |
| 1080 | + | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 763 | 1081 | if tt.wantRestored == "" { | |
| 764 | 1082 | tt.wantRestored = "live" | |
| 765 | 1083 | } | |
| @@ -783,23 +1101,23 @@ func TestOverlayPaintBypassesComposedCache(t *testing.T) { | |||
| 783 | 1101 | pane := sess.tabs[0].focusedPane() | |
| 784 | 1102 | pane.screen.Write([]byte("live")) | |
| 785 | 1103 | ||
| 786 | - | d.paint(sess, ac, true) | |
| 1104 | + | d.paint(sess, ac, true, nil) | |
| 787 | 1105 | _ = mustOutputData(t, sends) | |
| 788 | - | require.True(t, ac.composed.valid) | |
| 789 | - | require.NotContains(t, frameText(ac.composed.frame), "Rename session") | |
| 1106 | + | require.True(t, ac.pipelineCache.valid) | |
| 1107 | + | require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session") | |
| 790 | 1108 | ||
| 791 | 1109 | d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil }) | |
| 792 | 1110 | shown := string(mustOutputData(t, sends)) | |
| 793 | 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") | |
| 795 | - | require.NotContains(t, frameText(ac.composed.frame), "Rename session") | |
| 1112 | + | require.False(t, ac.pipelineCache.valid, "overlay paint must not store the modal-mutated frame in the composed cache") | |
| 1113 | + | require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session") | |
| 796 | 1114 | ||
| 797 | 1115 | d.closePrompt(ac) | |
| 798 | - | d.paint(sess, ac, false) | |
| 1116 | + | d.paint(sess, ac, false, nil) | |
| 799 | 1117 | restored := string(mustOutputData(t, sends)) | |
| 800 | 1118 | require.NotContains(t, restored, "Rename session") | |
| 801 | - | require.True(t, ac.composed.valid) | |
| 802 | - | cached := frameText(ac.composed.frame) | |
| 1119 | + | require.True(t, ac.pipelineCache.valid) | |
| 1120 | + | cached := frameText(ac.pipelineCache.frame) | |
| 803 | 1121 | require.Contains(t, cached, "live") | |
| 804 | 1122 | require.NotContains(t, cached, "Rename session") | |
| 805 | 1123 | } | |
| @@ -934,7 +1252,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||
| 934 | 1252 | d.resize(sess, ac, newSize) | |
| 935 | 1253 | // This test verifies resize ordering, not the idle fallback. Consume the | |
| 936 | 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 | 1257 | require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize") | |
| 940 | 1258 | require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty") | |
| @@ -965,7 +1283,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |||
| 965 | 1283 | win.focusedPane().screen.Write([]byte("x")) | |
| 966 | 1284 | win.mu.Unlock() | |
| 967 | 1285 | ||
| 968 | - | d.paint(sess, ac, true) | |
| 1286 | + | d.paint(sess, ac, true, nil) | |
| 969 | 1287 | ||
| 970 | 1288 | require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send") | |
| 971 | 1289 | sess.mu.Lock() | |
| @@ -977,75 +1295,6 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |||
| 977 | 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 | 1298 | func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |
| 1050 | 1299 | d := newTestDaemon(t, nil, stubClock{}) | |
| 1051 | 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 | 1341 | func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | |
| 1253 | 1342 | p, _ := newBlockingPTY(t) | |
| 1254 | 1343 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1255 | 1344 | win := sess.tabs[0] | |
| 1256 | 1345 | win.focusedPane().screen.Write([]byte("A")) | |
| 1257 | 1346 | ||
| 1258 | - | d.paint(sess, ac, true) | |
| 1347 | + | d.paint(sess, ac, true, nil) | |
| 1259 | 1348 | data := mustOutputData(t, sends) | |
| 1260 | 1349 | require.Contains(t, string(data), "\x1b[1 q") | |
| 1261 | 1350 | require.Contains(t, string(data), "\x1b[?25h") | |
| 1262 | 1351 | ||
| 1263 | 1352 | win.focusedPane().screen.Write([]byte("\x1b[2;3H")) | |
| 1264 | - | d.paint(sess, ac, false) | |
| 1353 | + | d.paint(sess, ac, false, nil) | |
| 1265 | 1354 | data = mustOutputData(t, sends) | |
| 1266 | 1355 | require.Contains(t, string(data), "\x1b[3;3H") | |
| 1267 | 1356 | require.Contains(t, string(data), "\x1b[?25h") | |
| 1268 | 1357 | ||
| 1269 | 1358 | win.focusedPane().screen.Write([]byte("\x1b[?25l")) | |
| 1270 | - | d.paint(sess, ac, false) | |
| 1359 | + | d.paint(sess, ac, false, nil) | |
| 1271 | 1360 | data = mustOutputData(t, sends) | |
| 1272 | 1361 | require.Contains(t, string(data), "\x1b[?25l") | |
| 1273 | 1362 | } | |
| @@ -1284,7 +1373,7 @@ func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) { | |||
| 1284 | 1373 | floating.popupGeometry = committed | |
| 1285 | 1374 | installTestFloating(sess.activeTab(), floating, true) | |
| 1286 | 1375 | ||
| 1287 | - | d.paint(sess, ac, true) | |
| 1376 | + | d.paint(sess, ac, true, nil) | |
| 1288 | 1377 | data := mustOutputData(t, sends) | |
| 1289 | 1378 | want := committed.translate(contentArea.X, contentArea.Y) | |
| 1290 | 1379 | require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, 1)) | |
| @@ -1309,7 +1398,7 @@ func TestCursorTailUsesFocusedPanePlacement(t *testing.T) { | |||
| 1309 | 1398 | require.True(t, ok) | |
| 1310 | 1399 | rightContent := placementContent(placements, right.id) | |
| 1311 | 1400 | ||
| 1312 | - | d.paint(sess, ac, true) | |
| 1401 | + | d.paint(sess, ac, true, nil) | |
| 1313 | 1402 | data := mustOutputData(t, sends) | |
| 1314 | 1403 | want := cursorCSI(rightContent.Y+right.screen.CursorRow()+2, rightContent.X+right.screen.CursorCol()+1) | |
| 1315 | 1404 | require.Contains(t, string(data), want) | |
| @@ -1338,7 +1427,7 @@ func TestCursorTailUsesExpandedStackContentPlacement(t *testing.T) { | |||
| 1338 | 1427 | twoContent := placementContent(placements, two.id) | |
| 1339 | 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 | 1431 | data := mustOutputData(t, sends) | |
| 1343 | 1432 | want := cursorCSI(twoContent.Y+two.screen.CursorRow()+2, twoContent.X+two.screen.CursorCol()+1) | |
| 1344 | 1433 | require.Contains(t, string(data), want) | |
| @@ -1357,7 +1446,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||
| 1357 | 1446 | } | |
| 1358 | 1447 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | |
| 1359 | 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 | 1450 | mode := scopy.NewMode(document) | |
| 1362 | 1451 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | |
| 1363 | 1452 | ||
| @@ -1417,7 +1506,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) { | |||
| 1417 | 1506 | defer releaseFloating() | |
| 1418 | 1507 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 1419 | 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 | 1510 | fp.screen.Write([]byte("flt-live")) | |
| 1422 | 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 | 48 | if !d.prepareParkAttachment(sess, ac) { | |
| 49 | 49 | return false | |
| 50 | 50 | } | |
| 51 | - | // A parked attachment has no valid visible destination. Retire both the | |
| 52 | - | // deferred diff and any resize-idle callback before registering its grace | |
| 53 | - | // timer; resume performs a fresh full paint. | |
| 54 | - | ac.cancelResizePaint() | |
| 55 | - | ac.clearPaintDeferred() | |
| 51 | + | // A parked attachment has no session owner. Release pane snapshots before | |
| 52 | + | // publishing it in the daemon registry so headless pane/session teardown | |
| 53 | + | // cannot leave closed panes retained through its capture cache. This must | |
| 54 | + | // remain outside d.mu: sendMu is ordered before the daemon lock. | |
| 55 | + | ac.clearCaptureFrames() | |
| 56 | 56 | d.mu.Lock() | |
| 57 | 57 | if d.closing { | |
| 58 | 58 | d.mu.Unlock() | |
| @@ -188,6 +188,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 188 | 188 | sess.client = ac | |
| 189 | 189 | sess.terminal = terminalEnv{TrueColor: h.TrueColor} | |
| 190 | 190 | sess.mu.Unlock() | |
| 191 | + | d.attachCoordinator(sess, nil, ac, false) | |
| 191 | 192 | d.touchMRU(sess) | |
| 192 | 193 | d.log.Info("client resumed", "session", sess.name) | |
| 193 | 194 | return sess, ac, true, nil | |
M internal/usecase/daemon/resume_test.go +163 -1
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "strings" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| @@ -10,6 +11,8 @@ import ( | |||
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/vev/internal/domain" | |
| 12 | 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 | 18 | type closeTrackingTransport struct { | |
| @@ -77,6 +80,110 @@ func TestNamedLinkLossParks(t *testing.T) { | |||
| 77 | 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 | 187 | func TestEphemeralLinkLossParksAndResumes(t *testing.T) { | |
| 81 | 188 | pty, release := newBlockingPTY(t) | |
| 82 | 189 | defer release() | |
| @@ -228,6 +335,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||
| 228 | 335 | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr) | |
| 229 | 336 | require.NoError(t, err) | |
| 230 | 337 | require.Same(t, ac, resumedAC) | |
| 338 | + | require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC))) | |
| 231 | 339 | d.firstPaint(resumedSess, resumedAC, resumedAC.size) | |
| 232 | 340 | ||
| 233 | 341 | sends := newTr.Sends() | |
| @@ -239,7 +347,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||
| 239 | 347 | resumedAC.ackOutputState(first.NewStateNum) | |
| 240 | 348 | ||
| 241 | 349 | resumedSess.tabs[0].focusedPane().screen.Write([]byte("A")) | |
| 242 | - | d.paint(resumedSess, resumedAC, false) | |
| 350 | + | d.paint(resumedSess, resumedAC, false, nil) | |
| 243 | 351 | sends = newTr.Sends() | |
| 244 | 352 | require.Len(t, sends, 2) | |
| 245 | 353 | second, err := ports.UnmarshalOutput(sends[1].Payload) | |
| @@ -247,6 +355,60 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||
| 247 | 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 | 412 | func TestExplicitDetachDoesNotPark(t *testing.T) { | |
| 251 | 413 | pty, release := newBlockingPTY(t) | |
| 252 | 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 | 14 | 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 | 18 | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | |
| 19 | 19 | wantLen: 1, | |
| 20 | 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 | 30 | for _, tt := range tests { | |
| 25 | 31 | t.Run(tt.name, func(t *testing.T) { | |
| 26 | 32 | win := newTab(nil, domain.Size{Cols: 4, Rows: 3}) | |
| 27 | - | if win.focusedPane().scrollback == nil { | |
| 33 | + | if win.focusedPane().history == nil { | |
| 28 | 34 | t.Fatal("scrollback is nil") | |
| 29 | 35 | } | |
| 30 | - | if win.focusedPane().scrollback.Cap() != defaultScrollbackRows { | |
| 31 | - | t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows) | |
| 36 | + | if win.focusedPane().history != win.focusedPane().screen.History() { | |
| 37 | + | t.Fatal("pane history is not owned by its screen") | |
| 38 | + | } | |
| 39 | + | if win.focusedPane().history.Cap() != defaultScrollbackRows { | |
| 40 | + | t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().history.Cap(), defaultScrollbackRows) | |
| 32 | 41 | } | |
| 33 | 42 | ||
| 34 | 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 | 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 | 49 | runes := make([]rune, len(row)) | |
| 41 | 50 | for i, c := range row { | |
| 42 | 51 | runes[i] = c.Rune | |
M internal/usecase/daemon/session.go +68 -40
| @@ -1,27 +1,4 @@ | |||
| 1 | - | // Package daemon holds vev's server-side session multiplexer use case: the | |
| 2 | - | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |
| 3 | - | // and VT screen, and the per-client debounced render scheduler. | |
| 4 | - | // | |
| 5 | - | // Concurrency model (sessions own one or more PTY-backed tabs): | |
| 6 | - | // | |
| 7 | - | // - Serve runs the accept loop. Each accepted connection is handled by its | |
| 8 | - | // own goroutine (handleConn): it reads the first frame and routes it to a | |
| 9 | - | // session create/attach, a list, or a kill. | |
| 10 | - | // - Per session there are exactly two long-lived goroutines: the PTY reader | |
| 11 | - | // (drains child output into the VT screen and pokes a cap-1 dirty channel) | |
| 12 | - | // and the render scheduler (debounces dirties and paints the attached | |
| 13 | - | // client). Both are tied to the session context and unwind when the | |
| 14 | - | // session is killed (pty.Close unblocks the reader; ctx cancel stops the | |
| 15 | - | // scheduler). | |
| 16 | - | // - The daemon exits (Serve returns) when the last session is removed, or | |
| 17 | - | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | - | // attached clients with ReasonServerShutdown). | |
| 19 | - | // | |
| 20 | - | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | - | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 1 | + | // Package daemon holds vev's server-side session multiplexer use case. | |
| 25 | 2 | package daemon | |
| 26 | 3 | ||
| 27 | 4 | import ( | |
| @@ -67,15 +44,35 @@ type session struct { | |||
| 67 | 44 | mruAt atomic.Uint64 | |
| 68 | 45 | snapDirty atomic.Bool | |
| 69 | 46 | 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 | 60 | // clipFiles records clipboard-image-transfer temp file paths (see | |
| 71 | 61 | // clipboard.go) written for this session, removed best-effort in | |
| 72 | 62 | // killSession. | |
| 73 | 63 | 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 | 73 | // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. | |
| 77 | 74 | type tab struct { | |
| 78 | - | mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, previewClient, and pane map membership | |
| 75 | + | mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership | |
| 79 | 76 | ||
| 80 | 77 | stableID string | |
| 81 | 78 | name string | |
| @@ -88,9 +85,6 @@ type tab struct { | |||
| 88 | 85 | // floating is independent from the normal layout tree and pane map. | |
| 89 | 86 | 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 | 88 | // attention fields are guarded by the owning session.mu. | |
| 95 | 89 | attention bool | |
| 96 | 90 | attentionAt time.Time | |
| @@ -100,7 +94,7 @@ type tab struct { | |||
| 100 | 94 | // attachedClient is a client currently attached to a session's tab. rend is | |
| 101 | 95 | // its private renderer shadow (so each client gets output minimised against | |
| 102 | 96 | // what it has actually seen). sendMu serialises the two senders — the render | |
| 103 | - | // scheduler and the connection handler — so the transport's single-writer | |
| 97 | + | // coordinator and the connection handler — so the transport's single-writer | |
| 104 | 98 | ||
| 105 | 99 | func (d *Daemon) touchMRU(sess *session) { | |
| 106 | 100 | if d == nil || sess == nil { | |
| @@ -145,7 +139,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 145 | 139 | closeTabs(tabs) | |
| 146 | 140 | 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 | 143 | if err != nil { | |
| 150 | 144 | closeTabs(tabs) | |
| 151 | 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 | 252 | ac.setSession(nil) | |
| 259 | 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 | 259 | newSess.mu.Lock() | |
| 262 | 260 | newSess.client = ac | |
| 263 | 261 | newSess.mu.Unlock() | |
| 264 | 262 | ||
| 265 | 263 | d.touchMRU(newSess) | |
| 266 | - | ac.setSession(newSess) | |
| 267 | 264 | ac.recordPreviousSession(from) | |
| 268 | 265 | d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable) | |
| 269 | 266 | d.mu.Unlock() | |
| 270 | - | ||
| 267 | + | finishRenderLifecycleCleanups(cleanups) | |
| 271 | 268 | d.firstPaint(newSess, ac, sz) | |
| 272 | 269 | return nil | |
| 273 | 270 | } | |
| @@ -284,7 +281,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 284 | 281 | if err != nil { | |
| 285 | 282 | 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 | 285 | if err != nil { | |
| 289 | 286 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | |
| 290 | 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 | 415 | sess.mu.Unlock() | |
| 419 | 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 | 420 | go d.ptyReader(sess, tb, p) | |
| 423 | - | go d.scheduler(sess, tb, p) | |
| 424 | 421 | } | |
| 425 | 422 | ||
| 426 | 423 | // 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 | 634 | markSnapshotDirty(sess) | |
| 638 | 635 | ||
| 639 | 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 | 643 | if tb.cancel != nil { | |
| 641 | 644 | tb.cancel() | |
| 642 | 645 | } | |
| 643 | 646 | d.teardownFloating(tb, ac) | |
| 647 | + | if rc := sess.renderCoordinator(); rc != nil { | |
| 648 | + | for _, p := range panes { | |
| 649 | + | rc.noteSyncPaneRemoved(p) | |
| 650 | + | } | |
| 651 | + | } | |
| 644 | 652 | tb.closeAllPanes() | |
| 645 | 653 | if wasActive { | |
| 646 | 654 | d.activateTab(sess, destination) | |
| 647 | 655 | } | |
| 648 | 656 | if repaint && ac != nil { | |
| 649 | - | d.paint(sess, ac, true) | |
| 657 | + | d.invalidateRender(sess, ac, true, "session.go") | |
| 650 | 658 | } | |
| 651 | 659 | if ringing { | |
| 652 | 660 | d.repaintAllAttachedClients() | |
| @@ -664,7 +672,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 664 | 672 | if !purge && !isEphemeral && d.persistEnabled { | |
| 665 | 673 | d.refreshSessionCwd(sess) | |
| 666 | 674 | } | |
| 667 | - | var snapshotDeleteErr error | |
| 675 | + | var snapshotDeleteErr, terminalSnapshotErr error | |
| 668 | 676 | if d.snapsEnabled && !isEphemeral { | |
| 669 | 677 | if purge { | |
| 670 | 678 | sess.mu.Lock() | |
| @@ -675,7 +683,16 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 675 | 683 | d.log.Warn("deleting session snapshot failed", "err", err, "session", name) | |
| 676 | 684 | } | |
| 677 | 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 | 698 | d.mu.Lock() | |
| @@ -731,13 +748,24 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 731 | 748 | ac := sess.client | |
| 732 | 749 | sess.client = nil | |
| 733 | 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 | 758 | if ac != nil { | |
| 735 | - | ac.cancelResizePaint() | |
| 736 | 759 | d.unregisterPreview(ac) | |
| 737 | 760 | ac.clearPreviousSession() | |
| 738 | 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 | 769 | sess.cancel() | |
| 742 | 770 | sess.mu.Lock() | |
| 743 | 771 | tabs := append([]*tab(nil), sess.tabs...) | |
| @@ -763,7 +791,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 763 | 791 | if ac != nil { | |
| 764 | 792 | d.notifyDetachedAsync(ac, reason) | |
| 765 | 793 | } | |
| 766 | - | return purgeErr | |
| 794 | + | return errors.Join(purgeErr, terminalSnapshotErr) | |
| 767 | 795 | } | |
| 768 | 796 | ||
| 769 | 797 | // 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 | 11 | target := ac.previousSession.Get() | |
| 12 | 12 | if target == nil || target == current || d.sessionByID(target.id) != target { | |
| 13 | 13 | ac.clearPreviousSessionIf(target) | |
| 14 | - | d.paint(current, ac, true) | |
| 14 | + | d.invalidateRender(current, ac, true, "session_back.go") | |
| 15 | 15 | return | |
| 16 | 16 | } | |
| 17 | 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 | 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 | 30 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 26 | 31 | // blocks in its debounce loop until the session context is cancelled. Used by | |
| @@ -34,8 +39,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) { | |||
| 34 | 39 | f := portsmocks.NewMockPTYFactory(t) | |
| 35 | 40 | var dirs []string | |
| 36 | 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( | |
| 38 | - | func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) { | |
| 42 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | |
| 43 | + | func(_ context.Context, _ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) { | |
| 39 | 44 | dirs = append(dirs, dir) | |
| 40 | 45 | if len(dirs) == 1 { | |
| 41 | 46 | return first, nil | |
| @@ -44,9 +49,7 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) { | |||
| 44 | 49 | }, | |
| 45 | 50 | ).Twice() | |
| 46 | 51 | floating := newQuietPTY() | |
| 47 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | |
| 48 | - | return got != normalSize && got.Valid() | |
| 49 | - | })).Return(floating, nil).Once() | |
| 52 | + | expectFloatingPrewarmOpen(f, normalSize, floating) | |
| 50 | 53 | ||
| 51 | 54 | d := newTestDaemon(t, f, stubClock{}) | |
| 52 | 55 | tr := portsmocks.NewMockTransport(t) | |
| @@ -72,11 +75,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) { | |||
| 72 | 75 | releaseFirst() | |
| 73 | 76 | releaseSecond() | |
| 74 | 77 | d.sessWg.Wait() | |
| 75 | - | select { | |
| 76 | - | case <-floating.done: | |
| 77 | - | default: | |
| 78 | - | t.Fatal("floating prewarm PTY was not closed") | |
| 79 | - | } | |
| 78 | + | // Teardown may cancel a queued prewarm before it enters Open; an opened | |
| 79 | + | // prewarm is closed by teardownFloating. | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | 82 | func TestHandshakeEphemeralHappy(t *testing.T) { | |
| @@ -202,7 +202,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||
| 202 | 202 | p1, release1 := newBlockingPTY(t) | |
| 203 | 203 | defer release1() | |
| 204 | 204 | p2 := portsmocks.NewMockPTY(t) | |
| 205 | - | opened := make(chan struct{}) | |
| 205 | + | openCtx := make(chan context.Context, 1) | |
| 206 | 206 | releaseOpen := make(chan struct{}) | |
| 207 | 207 | closed := make(chan struct{}) | |
| 208 | 208 | p2.EXPECT().Close().RunAndReturn(func() error { | |
| @@ -213,15 +213,16 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||
| 213 | 213 | ||
| 214 | 214 | f := portsmocks.NewMockPTYFactory(t) | |
| 215 | 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 | 217 | floating := newQuietPTY() | |
| 218 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | |
| 219 | - | return got != normalSize && got.Valid() | |
| 220 | - | })).Return(floating, nil).Once() | |
| 221 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | |
| 222 | - | func(string, []string, []string, string, domain.Size) (ports.PTY, error) { | |
| 223 | - | close(opened) | |
| 224 | - | <-releaseOpen | |
| 218 | + | expectFloatingPrewarmOpen(f, normalSize, floating) | |
| 219 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | |
| 220 | + | func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 221 | + | openCtx <- ctx | |
| 222 | + | select { | |
| 223 | + | case <-ctx.Done(): | |
| 224 | + | case <-releaseOpen: | |
| 225 | + | } | |
| 225 | 226 | return p2, nil | |
| 226 | 227 | }, | |
| 227 | 228 | ).Once() | |
| @@ -237,10 +238,20 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||
| 237 | 238 | ||
| 238 | 239 | errCh := make(chan error, 1) | |
| 239 | 240 | go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }() | |
| 240 | - | <-opened | |
| 241 | + | ctx := <-openCtx | |
| 241 | 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 | 250 | close(releaseOpen) | |
| 243 | 251 | ||
| 252 | + | if !cancelled { | |
| 253 | + | t.Error("killSession did not cancel the context passed to PTYFactory.Open") | |
| 254 | + | } | |
| 244 | 255 | require.Error(t, <-errCh) | |
| 245 | 256 | select { | |
| 246 | 257 | case <-closed: | |
| @@ -251,11 +262,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||
| 251 | 262 | releaseConn() | |
| 252 | 263 | hg.Wait() | |
| 253 | 264 | d.sessWg.Wait() | |
| 254 | - | select { | |
| 255 | - | case <-floating.done: | |
| 256 | - | default: | |
| 257 | - | t.Fatal("floating prewarm PTY was not closed") | |
| 258 | - | } | |
| 265 | + | // A queued prewarm may be cancelled before Open during teardown. | |
| 259 | 266 | } | |
| 260 | 267 | ||
| 261 | 268 | // --- ephemeral numbering ---------------------------------------------------- | |
| @@ -508,24 +515,52 @@ func TestServeGracefulShutdownOnContextCancel(t *testing.T) { | |||
| 508 | 515 | // --- shutdown/create interlock ------------------------------------------------ | |
| 509 | 516 | ||
| 510 | 517 | // TestHelloRacingShutdownIsRejected covers the interlock between the | |
| 511 | - | // registry-empty shutdown decision and session creation: once killSession has | |
| 512 | - | // removed the last session (making shutdown irreversible via doneOnce), a | |
| 513 | - | // Hello landing at any later instant must be rejected cleanly — never spawn a | |
| 514 | - | // child that no teardown pass would reap. The interleaving is deterministic: | |
| 515 | - | // killSession runs to completion before the racing Hellos are handled, which | |
| 516 | - | // models the worst case (insertion attempt after the shutdown snapshot). | |
| 518 | + | // registry-empty shutdown decision and session creation. In particular, the | |
| 519 | + | // old `.Once()` factory expectation was wrong: firstPaint legitimately queues | |
| 520 | + | // a floating prewarm, so an Open that starts before killSession is valid work, | |
| 521 | + | // not a leaked post-shutdown child. The factory markers classify timing at the | |
| 522 | + | // Open boundary: this test first observes that valid prewarm, then verifies | |
| 523 | + | // that d.done (closed synchronously by killSession) is never followed by an | |
| 524 | + | // Open start. | |
| 517 | 525 | func TestHelloRacingShutdownIsRejected(t *testing.T) { | |
| 518 | 526 | p, releasePTY := newBlockingPTY(t) | |
| 527 | + | floating := newQuietPTY() | |
| 519 | 528 | f := portsmocks.NewMockPTYFactory(t) | |
| 520 | - | // Exactly one child may ever be spawned; a second Open call (a leaked | |
| 521 | - | // child) fails the test via mock expectations. | |
| 522 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once() | |
| 529 | + | normalSize := domain.Size{Cols: 80, Rows: 22} | |
| 530 | + | preShutdownFloatingOpen := make(chan struct{}, 1) | |
| 531 | + | var opensAfterShutdown atomic.Int32 | |
| 523 | 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 | 551 | tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24})) | |
| 526 | 552 | var hg sync.WaitGroup | |
| 527 | 553 | hg.Go(func() { d.handleConn(tr1) }) | |
| 528 | 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 | 564 | sess := firstSession(d) | |
| 530 | 565 | require.NotNil(t, sess) | |
| 531 | 566 | ||
| @@ -555,12 +590,14 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) { | |||
| 555 | 590 | require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name) | |
| 556 | 591 | } | |
| 557 | 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 | 595 | release1() | |
| 560 | 596 | releasePTY() | |
| 561 | 597 | hg.Wait() | |
| 562 | 598 | d.sessWg.Wait() | |
| 563 | 599 | d.waitNotifies() | |
| 600 | + | require.Zero(t, opensAfterShutdown.Load(), "racing Hellos must not start a PTY after shutdown") | |
| 564 | 601 | } | |
| 565 | 602 | ||
| 566 | 603 | // --- wedged-client teardown --------------------------------------------------- | |
| @@ -1090,8 +1127,8 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) { | |||
| 1090 | 1127 | ||
| 1091 | 1128 | var gotEnv []string | |
| 1092 | 1129 | f := portsmocks.NewMockPTYFactory(t) | |
| 1093 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 1094 | - | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 1130 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 1131 | + | func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 1095 | 1132 | gotEnv = append([]string(nil), env...) | |
| 1096 | 1133 | return p, nil | |
| 1097 | 1134 | }, | |
| @@ -1243,8 +1280,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||
| 1243 | 1280 | var opens [][]string | |
| 1244 | 1281 | f := portsmocks.NewMockPTYFactory(t) | |
| 1245 | 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( | |
| 1247 | - | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 1283 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | |
| 1284 | + | func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 1248 | 1285 | opens = append(opens, append([]string(nil), env...)) | |
| 1249 | 1286 | if len(opens) == 1 { | |
| 1250 | 1287 | return p1, nil | |
| @@ -1253,9 +1290,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||
| 1253 | 1290 | }, | |
| 1254 | 1291 | ).Twice() | |
| 1255 | 1292 | floating := newQuietPTY() | |
| 1256 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | |
| 1257 | - | return got != normalSize && got.Valid() | |
| 1258 | - | })).Return(floating, nil).Once() | |
| 1293 | + | expectFloatingPrewarmOpen(f, normalSize, floating) | |
| 1259 | 1294 | ||
| 1260 | 1295 | d := newTestDaemon(t, f, stubClock{}) | |
| 1261 | 1296 | d.baseEnv = []string{"KEEP=1", "COLORTERM=old"} | |
| @@ -1288,8 +1323,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||
| 1288 | 1323 | var opens [][]string | |
| 1289 | 1324 | f := portsmocks.NewMockPTYFactory(t) | |
| 1290 | 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( | |
| 1292 | - | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 1326 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | |
| 1327 | + | func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 1293 | 1328 | opens = append(opens, append([]string(nil), env...)) | |
| 1294 | 1329 | if len(opens) == 1 { | |
| 1295 | 1330 | return p1, nil | |
| @@ -1298,9 +1333,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||
| 1298 | 1333 | }, | |
| 1299 | 1334 | ).Twice() | |
| 1300 | 1335 | floating := newQuietPTY() | |
| 1301 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | |
| 1302 | - | return got != normalSize && got.Valid() | |
| 1303 | - | })).Return(floating, nil).Once() | |
| 1336 | + | expectFloatingPrewarmOpen(f, normalSize, floating) | |
| 1304 | 1337 | ||
| 1305 | 1338 | d := newTestDaemon(t, f, stubClock{}) | |
| 1306 | 1339 | tr1 := portsmocks.NewMockTransport(t) | |
| @@ -1335,8 +1368,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 1335 | 1368 | var opens [][]string | |
| 1336 | 1369 | f := portsmocks.NewMockPTYFactory(t) | |
| 1337 | 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( | |
| 1339 | - | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 1371 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | |
| 1372 | + | func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 1340 | 1373 | opens = append(opens, append([]string(nil), env...)) | |
| 1341 | 1374 | if len(opens) == 1 { | |
| 1342 | 1375 | return p1, nil | |
| @@ -1345,18 +1378,38 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 1345 | 1378 | }, | |
| 1346 | 1379 | ).Twice() | |
| 1347 | 1380 | floating := newQuietPTY() | |
| 1348 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | |
| 1349 | - | return got != normalSize && got.Valid() | |
| 1350 | - | })).Return(floating, nil).Once() | |
| 1381 | + | expectFloatingPrewarmOpen(f, normalSize, floating) | |
| 1351 | 1382 | d := newTestDaemon(t, f, stubClock{}) | |
| 1352 | 1383 | tr := portsmocks.NewMockTransport(t) | |
| 1353 | 1384 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 1354 | 1385 | tr.EXPECT().Close().Return(nil).Maybe() | |
| 1355 | 1386 | sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr) | |
| 1356 | 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 | 1398 | require.NoError(t, d.createSessionAndSwitch(sess, ac, "next")) | |
| 1358 | 1399 | got := ac.sess.Get() | |
| 1359 | 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 | 1413 | got.mu.Lock() | |
| 1361 | 1414 | require.True(t, got.terminal.TrueColor) | |
| 1362 | 1415 | got.mu.Unlock() | |
M internal/usecase/daemon/snapshot.go +455 -79
| @@ -4,17 +4,58 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "math" | |
| 7 | + | "sync" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 10 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 11 | 11 | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 12 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 13 | 13 | "github.com/bnema/vev/pkg/renderer" | |
| 14 | 14 | "github.com/bnema/vev/pkg/vt" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | - | const snapshotInterval = 30 * time.Second | |
| 17 | + | // snapshotQueueCapacity bounds retained immutable captures. A full queue never | |
| 18 | + | // stalls session producers; the session remains dirty for the next interval. | |
| 19 | + | const snapshotQueueCapacity = 1 | |
| 20 | + | ||
| 21 | + | // snapshotFinalQueueCapacity is a global bound for terminal captures retained | |
| 22 | + | // while the worker is blocked. On saturation a new terminal capture is rejected | |
| 23 | + | // (and left dirty) rather than silently dropping a persistence acknowledgement. | |
| 24 | + | const snapshotFinalQueueCapacity = 32 | |
| 25 | + | ||
| 26 | + | type snapshotCapture struct { | |
| 27 | + | session *session | |
| 28 | + | generation uint64 | |
| 29 | + | name string | |
| 30 | + | createdAt uint64 | |
| 31 | + | active uint16 | |
| 32 | + | tabs []snapshotCaptureTab | |
| 33 | + | finishOnce sync.Once | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | type snapshotCaptureTab struct { | |
| 37 | + | stableID string | |
| 38 | + | cols uint16 | |
| 39 | + | rows uint16 | |
| 40 | + | nextPaneID uint64 | |
| 41 | + | focus layout.PaneID | |
| 42 | + | tree *layout.Tree | |
| 43 | + | panes []snapshotCapturePane | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | type snapshotCapturePane struct { | |
| 47 | + | id layout.PaneID | |
| 48 | + | stableID string | |
| 49 | + | cwd string | |
| 50 | + | history vt.HistoryView | |
| 51 | + | visible renderer.Frame | |
| 52 | + | process *snapcodec.Process | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | const ( | |
| 56 | + | snapshotInterval = 30 * time.Second | |
| 57 | + | snapshotFinalFlushTimeout = time.Second | |
| 58 | + | ) | |
| 18 | 59 | ||
| 19 | 60 | func (d *Daemon) closeRestoreDone() { | |
| 20 | 61 | d.restoreOnce.Do(func() { close(d.restoreDone) }) | |
| @@ -51,6 +92,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 51 | 92 | if err := ctx.Err(); err != nil { | |
| 52 | 93 | 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 | 102 | if snap.Name == "" { | |
| 55 | 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 | 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 | 193 | if err != nil { | |
| 146 | 194 | closeOpened() | |
| 147 | 195 | return err | |
| @@ -149,7 +197,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 149 | 197 | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize) | |
| 150 | 198 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | |
| 151 | 199 | 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 | 207 | if restoreCommand != "" { | |
| 154 | 208 | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | |
| 155 | 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 | 219 | createdAt := int64(snap.CreatedAt) | |
| 166 | 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 | 221 | sess.snapEligible.Store(true) | |
| 171 | 222 | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | |
| 172 | 223 | 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 | 254 | func markSnapshotDirty(sess *session) { | |
| 204 | - | if sess == nil { | |
| 255 | + | if sess == nil || !sess.snapEligible.Load() { | |
| 205 | 256 | return | |
| 206 | 257 | } | |
| 207 | - | if sess.snapEligible.Load() { | |
| 208 | - | sess.snapDirty.Store(true) | |
| 258 | + | sess.snapshotMu.Lock() | |
| 259 | + | sess.snapshotGeneration++ | |
| 260 | + | sess.snapDirty.Store(true) | |
| 261 | + | sess.signalSnapshotChangedLocked() | |
| 262 | + | sess.snapshotMu.Unlock() | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // snapshotChangeLocked returns a channel closed by the next snapshot state | |
| 266 | + | // transition. snapshotMu must be held. | |
| 267 | + | func (sess *session) snapshotChangeLocked() chan struct{} { | |
| 268 | + | if sess.snapshotChanged == nil { | |
| 269 | + | sess.snapshotChanged = make(chan struct{}) | |
| 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 | 288 | p.mu.Lock() | |
| 214 | 289 | defer p.mu.Unlock() | |
| 215 | - | p.scrollback = scopy.NewScrollback(defaultScrollbackRows) | |
| 216 | - | for _, row := range scrollback { | |
| 217 | - | p.scrollback.Append(row) | |
| 218 | - | } | |
| 219 | - | if len(visible) > 0 { | |
| 220 | - | p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height) | |
| 221 | - | for y, row := range visible { | |
| 222 | - | if y >= p.screen.Frame.Height { | |
| 223 | - | break | |
| 224 | - | } | |
| 225 | - | for x, cell := range row { | |
| 226 | - | if x >= p.screen.Frame.Width { | |
| 227 | - | break | |
| 228 | - | } | |
| 229 | - | p.screen.Frame.Set(x, y, cell) | |
| 230 | - | } | |
| 231 | - | } | |
| 290 | + | screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail) | |
| 291 | + | if err != nil { | |
| 292 | + | return fmt.Errorf("snapshot history: %w", err) | |
| 293 | + | } | |
| 294 | + | if err := screen.RestorePrimaryVisible(snap.Visible); err != nil { | |
| 295 | + | return fmt.Errorf("snapshot visible: %w", err) | |
| 232 | 296 | } | |
| 233 | - | p.screen.OnLineEvicted = p.scrollback.Append | |
| 297 | + | p.screen = screen | |
| 298 | + | p.history = screen.History() | |
| 299 | + | return nil | |
| 234 | 300 | } | |
| 235 | 301 | ||
| 236 | 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 | 317 | return domain.Size{Cols: cols, Rows: rows} | |
| 252 | 318 | } | |
| 253 | 319 | ||
| 254 | - | func (d *Daemon) captureSession(sess *session) bool { | |
| 255 | - | if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil { | |
| 320 | + | // scheduleSnapshot captures a session only once while an immutable capture is | |
| 321 | + | // queued or being written. It never waits for the bounded worker queue. | |
| 322 | + | func (d *Daemon) scheduleSnapshot(sess *session) bool { | |
| 323 | + | return d.scheduleSnapshotWithFinalFallback(sess, false) | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | // scheduleFinalSnapshot captures the terminal state even when an older capture | |
| 327 | + | // is pending. A removed session cannot be retried by snapshotSaver, so the | |
| 328 | + | // worker drains its retained terminal state before it stops. | |
| 329 | + | func (d *Daemon) scheduleFinalSnapshot(sess *session) bool { | |
| 330 | + | return d.scheduleSnapshotWithFinalFallback(sess, true) | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool { | |
| 334 | + | if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil || !sess.snapEligible.Load() { | |
| 335 | + | return true | |
| 336 | + | } | |
| 337 | + | sess.snapshotMu.Lock() | |
| 338 | + | if !sess.snapDirty.Load() || (!final && sess.snapshotPending) { | |
| 339 | + | sess.snapshotMu.Unlock() | |
| 256 | 340 | 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 | 421 | sess.mu.Lock() | |
| 260 | - | name := sess.name | |
| 422 | + | capture := &snapshotCapture{ | |
| 423 | + | session: sess, | |
| 424 | + | generation: generation, | |
| 425 | + | name: sess.name, | |
| 426 | + | createdAt: uint64(sess.createdAt), | |
| 427 | + | active: uint16(max(sess.active, 0)), | |
| 428 | + | } | |
| 261 | 429 | ephemeral := sess.ephemeral | |
| 262 | - | createdAt := sess.createdAt | |
| 263 | - | active := sess.active | |
| 264 | 430 | fallbackCwd := sess.cwd | |
| 265 | 431 | tabs := append([]*tab(nil), sess.tabs...) | |
| 266 | 432 | sess.mu.Unlock() | |
| 267 | - | if ephemeral || name == "" { | |
| 268 | - | return true | |
| 433 | + | if ephemeral || capture.name == "" { | |
| 434 | + | 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 | 438 | for _, tb := range tabs { | |
| 273 | 439 | tb.mu.Lock() | |
| 274 | - | tabSnap := snapcodec.Tab{ | |
| 275 | - | StableID: tb.stableID, | |
| 276 | - | Cols: uint16(max(tb.size.Cols, 0)), | |
| 277 | - | Rows: uint16(max(tb.size.Rows, 0)), | |
| 278 | - | NextPaneID: uint64(max(tb.nextPaneID, 0)), | |
| 279 | - | Tree: tb.tree.Clone(), | |
| 440 | + | tabCapture := snapshotCaptureTab{ | |
| 441 | + | stableID: tb.stableID, | |
| 442 | + | cols: uint16(max(tb.size.Cols, 0)), | |
| 443 | + | rows: uint16(max(tb.size.Rows, 0)), | |
| 444 | + | nextPaneID: uint64(max(tb.nextPaneID, 0)), | |
| 445 | + | tree: tb.tree.Clone(), | |
| 280 | 446 | } | |
| 281 | 447 | if tb.tree != nil { | |
| 282 | - | tabSnap.Focus = tb.tree.Focus | |
| 448 | + | tabCapture.focus = tb.tree.Focus | |
| 283 | 449 | } | |
| 284 | 450 | panes := make([]*pane, 0, len(tb.panes)) | |
| 285 | 451 | for _, p := range tb.panes { | |
| @@ -287,51 +453,274 @@ func (d *Daemon) captureSession(sess *session) bool { | |||
| 287 | 453 | } | |
| 288 | 454 | tb.mu.Unlock() | |
| 289 | 455 | ||
| 290 | - | paneIDs := make(map[layout.PaneID]struct{}, len(panes)) | |
| 291 | - | paneSnaps := make([]snapcodec.Pane, 0, len(panes)) | |
| 456 | + | tabCapture.panes = make([]snapshotCapturePane, 0, len(panes)) | |
| 292 | 457 | for _, p := range panes { | |
| 293 | 458 | p.mu.Lock() | |
| 294 | - | pid := 0 | |
| 295 | 459 | pty := p.pty | |
| 460 | + | pid := 0 | |
| 296 | 461 | if pty != nil { | |
| 297 | 462 | pid = pty.Pid() | |
| 298 | 463 | } | |
| 299 | - | paneSnap := snapcodec.Pane{ | |
| 300 | - | ID: p.id, | |
| 301 | - | StableID: p.stableID, | |
| 302 | - | Scrollback: cloneRows(p.scrollback.Snapshot()), | |
| 303 | - | Visible: cloneRows(p.screen.PrimaryVisibleRows()), | |
| 464 | + | paneCapture := snapshotCapturePane{ | |
| 465 | + | id: p.id, | |
| 466 | + | stableID: p.stableID, | |
| 467 | + | history: p.history.SealAndView(), | |
| 468 | + | visible: p.screen.PrimaryVisibleFrame(), | |
| 304 | 469 | } | |
| 305 | 470 | p.mu.Unlock() | |
| 306 | - | paneSnap.Cwd = fallbackCwd | |
| 471 | + | paneCapture.cwd = fallbackCwd | |
| 307 | 472 | if d.procCwd != nil && pid > 0 { | |
| 308 | 473 | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | |
| 309 | - | paneSnap.Cwd = cwd | |
| 474 | + | paneCapture.cwd = cwd | |
| 310 | 475 | } | |
| 311 | 476 | } | |
| 312 | - | paneSnap.Process = d.capturePaneProcess(pty, pid) | |
| 313 | - | paneIDs[paneSnap.ID] = struct{}{} | |
| 314 | - | paneSnaps = append(paneSnaps, paneSnap) | |
| 477 | + | paneCapture.process = d.capturePaneProcess(pty, pid) | |
| 478 | + | tabCapture.panes = append(tabCapture.panes, paneCapture) | |
| 479 | + | } | |
| 480 | + | capture.tabs = append(capture.tabs, tabCapture) | |
| 481 | + | } | |
| 482 | + | return capture, true | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | // captureSession remains the synchronous producer-facing trigger for callers | |
| 486 | + | // such as teardown and benchmarks; the actual encoding and Write stay async. | |
| 487 | + | func (d *Daemon) captureSession(sess *session) bool { | |
| 488 | + | markSnapshotDirty(sess) | |
| 489 | + | return d.scheduleSnapshot(sess) | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error) { | |
| 493 | + | snap := snapcodec.Session{Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.Tab, 0, len(capture.tabs))} | |
| 494 | + | for _, tabCapture := range capture.tabs { | |
| 495 | + | tabSnap := snapcodec.Tab{ | |
| 496 | + | StableID: tabCapture.stableID, Cols: tabCapture.cols, Rows: tabCapture.rows, | |
| 497 | + | NextPaneID: tabCapture.nextPaneID, Focus: tabCapture.focus, Tree: tabCapture.tree, | |
| 498 | + | Panes: make([]snapcodec.Pane, 0, len(tabCapture.panes)), | |
| 499 | + | } | |
| 500 | + | paneIDs := make(map[layout.PaneID]struct{}, len(tabCapture.panes)) | |
| 501 | + | for _, paneCapture := range tabCapture.panes { | |
| 502 | + | sealed, tail, historyErr := vt.MarshalSealedHistory(paneCapture.history) | |
| 503 | + | visible, visibleErr := vt.MarshalVisible(paneCapture.visible) | |
| 504 | + | if historyErr != nil || visibleErr != nil { | |
| 505 | + | return nil, fmt.Errorf("terminal snapshot pane %s: history=%w visible=%w", paneCapture.id, historyErr, visibleErr) | |
| 506 | + | } | |
| 507 | + | tabSnap.Panes = append(tabSnap.Panes, snapcodec.Pane{ID: paneCapture.id, StableID: paneCapture.stableID, Cwd: paneCapture.cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: paneCapture.process}) | |
| 508 | + | paneIDs[paneCapture.id] = struct{}{} | |
| 315 | 509 | } | |
| 316 | 510 | if tabSnap.Tree != nil { | |
| 317 | 511 | pruneTreeToPanes(tabSnap.Tree.Root, paneIDs) | |
| 318 | 512 | } | |
| 319 | - | tabSnap.Panes = paneSnaps | |
| 320 | 513 | snap.Tabs = append(snap.Tabs, tabSnap) | |
| 321 | 514 | } | |
| 515 | + | return d.snapshotMarshal(snap) | |
| 516 | + | } | |
| 322 | 517 | ||
| 323 | - | data, err := snapcodec.Marshal(snap) | |
| 324 | - | if err != nil { | |
| 325 | - | d.log.Warn("marshaling session snapshot failed", "err", err, "session", name) | |
| 326 | - | return false | |
| 518 | + | func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) { | |
| 519 | + | if capture == nil { | |
| 520 | + | return | |
| 521 | + | } | |
| 522 | + | capture.finishOnce.Do(func() { | |
| 523 | + | capture.session.snapshotMu.Lock() | |
| 524 | + | if capture.session.snapshotPendingCaptures > 0 { | |
| 525 | + | capture.session.snapshotPendingCaptures-- | |
| 526 | + | } | |
| 527 | + | capture.session.snapshotPending = capture.session.snapshotPendingCaptures > 0 | |
| 528 | + | if succeeded && capture.session.snapshotGeneration == capture.generation { | |
| 529 | + | capture.session.snapDirty.Store(false) | |
| 530 | + | } else if !succeeded { | |
| 531 | + | capture.session.snapDirty.Store(true) | |
| 532 | + | } | |
| 533 | + | capture.session.signalSnapshotChangedLocked() | |
| 534 | + | capture.session.snapshotMu.Unlock() | |
| 535 | + | }) | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | func (d *Daemon) startSnapshotEncodeWorker() { | |
| 539 | + | if d == nil { | |
| 540 | + | return | |
| 541 | + | } | |
| 542 | + | d.snapshotWorkerMu.Lock() | |
| 543 | + | if d.snapshotWorkerCancel != nil { | |
| 544 | + | d.snapshotWorkerMu.Unlock() | |
| 545 | + | return | |
| 546 | + | } | |
| 547 | + | // Shutdown owns the worker lifetime so it can flush captures after Serve | |
| 548 | + | // cancels its parent context. The worker is always stopped explicitly. | |
| 549 | + | workerCtx, cancel := context.WithCancel(context.Background()) | |
| 550 | + | d.snapshotWorkerID++ | |
| 551 | + | workerID := d.snapshotWorkerID | |
| 552 | + | d.snapshotWorkerCtx = workerCtx | |
| 553 | + | d.snapshotWorkerCancel = cancel | |
| 554 | + | d.snapshotWorkerDone = make(chan struct{}) | |
| 555 | + | d.snapshotWorkerFlush = make(chan struct{}) | |
| 556 | + | d.snapshotWorkerFinalWake = make(chan struct{}, 1) | |
| 557 | + | d.snapshotFinalJobs = make(map[*session]*snapshotCapture) | |
| 558 | + | d.snapshotFinalOrder = nil | |
| 559 | + | d.snapshotWorkerClosing = false | |
| 560 | + | d.snapshotWorkerInFlight = nil | |
| 561 | + | done := d.snapshotWorkerDone | |
| 562 | + | flush := d.snapshotWorkerFlush | |
| 563 | + | finalWake := d.snapshotWorkerFinalWake | |
| 564 | + | d.snapshotWorkerMu.Unlock() | |
| 565 | + | go func() { | |
| 566 | + | defer close(done) | |
| 567 | + | write := func(capture *snapshotCapture) bool { | |
| 568 | + | if workerCtx.Err() != nil || !d.setSnapshotWorkerInFlight(workerID, capture) { | |
| 569 | + | d.finishSnapshotCapture(capture, false) | |
| 570 | + | return false | |
| 571 | + | } | |
| 572 | + | data, err := d.encodeSnapshotCapture(capture) | |
| 573 | + | if err == nil && workerCtx.Err() == nil { | |
| 574 | + | err = d.snaps.Write(capture.name, data) | |
| 575 | + | } | |
| 576 | + | d.clearSnapshotWorkerInFlight(workerID, capture) | |
| 577 | + | if err != nil && workerCtx.Err() == nil { | |
| 578 | + | d.log.Warn("writing session snapshot failed", "err", err, "session", capture.name) | |
| 579 | + | } | |
| 580 | + | d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil) | |
| 581 | + | return workerCtx.Err() == nil | |
| 582 | + | } | |
| 583 | + | drainFinal := func() bool { | |
| 584 | + | for { | |
| 585 | + | capture := d.takeFinalSnapshotCapture() | |
| 586 | + | if capture == nil { | |
| 587 | + | return true | |
| 588 | + | } | |
| 589 | + | if !write(capture) { | |
| 590 | + | return false | |
| 591 | + | } | |
| 592 | + | } | |
| 593 | + | } | |
| 594 | + | flushQueued := func() { | |
| 595 | + | for { | |
| 596 | + | select { | |
| 597 | + | case capture := <-d.snapshotJobs: | |
| 598 | + | if !write(capture) { | |
| 599 | + | return | |
| 600 | + | } | |
| 601 | + | default: | |
| 602 | + | _ = drainFinal() | |
| 603 | + | return | |
| 604 | + | } | |
| 605 | + | } | |
| 606 | + | } | |
| 607 | + | for { | |
| 608 | + | select { | |
| 609 | + | case <-workerCtx.Done(): | |
| 610 | + | return | |
| 611 | + | case <-flush: | |
| 612 | + | flushQueued() | |
| 613 | + | return | |
| 614 | + | case <-finalWake: | |
| 615 | + | if !drainFinal() { | |
| 616 | + | return | |
| 617 | + | } | |
| 618 | + | case capture := <-d.snapshotJobs: | |
| 619 | + | if !write(capture) { | |
| 620 | + | return | |
| 621 | + | } | |
| 622 | + | } | |
| 623 | + | } | |
| 624 | + | }() | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture { | |
| 628 | + | d.snapshotWorkerMu.Lock() | |
| 629 | + | defer d.snapshotWorkerMu.Unlock() | |
| 630 | + | for len(d.snapshotFinalOrder) > 0 { | |
| 631 | + | sess := d.snapshotFinalOrder[0] | |
| 632 | + | d.snapshotFinalOrder[0] = nil | |
| 633 | + | d.snapshotFinalOrder = d.snapshotFinalOrder[1:] | |
| 634 | + | capture := d.snapshotFinalJobs[sess] | |
| 635 | + | delete(d.snapshotFinalJobs, sess) | |
| 636 | + | if capture != nil { | |
| 637 | + | return capture | |
| 638 | + | } | |
| 327 | 639 | } | |
| 328 | - | if err := d.snaps.Write(name, data); err != nil { | |
| 329 | - | d.log.Warn("writing session snapshot failed", "err", err, "session", name) | |
| 640 | + | return nil | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool { | |
| 644 | + | d.snapshotWorkerMu.Lock() | |
| 645 | + | defer d.snapshotWorkerMu.Unlock() | |
| 646 | + | if d.snapshotWorkerID != workerID || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |
| 330 | 647 | return false | |
| 331 | 648 | } | |
| 649 | + | d.snapshotWorkerInFlight = capture | |
| 332 | 650 | 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 | 724 | func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process { | |
| 336 | 725 | if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil { | |
| 337 | 726 | return nil | |
| @@ -368,27 +757,14 @@ func (d *Daemon) snapshotSaver(ctx context.Context) { | |||
| 368 | 757 | sessions := d.sessionsSnapshotLocked() | |
| 369 | 758 | d.mu.Unlock() | |
| 370 | 759 | for _, sess := range sessions { | |
| 371 | - | if sess.snapEligible.Load() && sess.snapDirty.Swap(false) { | |
| 372 | - | if !d.captureSession(sess) { | |
| 373 | - | sess.snapDirty.Store(true) | |
| 374 | - | } | |
| 760 | + | if sess.snapEligible.Load() && sess.snapDirty.Load() { | |
| 761 | + | d.scheduleSnapshot(sess) | |
| 375 | 762 | } | |
| 376 | 763 | } | |
| 377 | 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 | 768 | func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool { | |
| 393 | 769 | if n == nil { | |
| 394 | 770 | 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 | 18 | "github.com/bnema/vev/internal/usecase/layout" | |
| 19 | 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 20 | 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 | 89 | func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |
| 24 | 90 | store := &restoreSnapshotStore{} | |
| 25 | 91 | store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| @@ -37,8 +103,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 37 | 103 | layout.NewLeaf("pane-2"), | |
| 38 | 104 | }}}, | |
| 39 | 105 | Panes: []snapcodec.Pane{ | |
| 40 | - | {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}}, | |
| 41 | - | {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}}, | |
| 106 | + | terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}), | |
| 107 | + | terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}), | |
| 42 | 108 | }, | |
| 43 | 109 | }}, | |
| 44 | 110 | })}} | |
| @@ -77,10 +143,11 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 77 | 143 | p := tb.panes["pane-2"] | |
| 78 | 144 | tb.mu.Unlock() | |
| 79 | 145 | p.mu.Lock() | |
| 80 | - | require.Equal(t, "old2", rowText(p.scrollback.View().Row(0))) | |
| 81 | - | require.Equal(t, 1, p.scrollback.Len()) | |
| 146 | + | require.Same(t, p.screen.History(), p.history) | |
| 147 | + | require.Equal(t, "old2", rowText(p.history.View().Row(0))) | |
| 148 | + | require.Equal(t, 1, p.history.Len()) | |
| 82 | 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 | 151 | require.Equal(t, "old2", rowText(copySnap.Row(0)[:4])) | |
| 85 | 152 | require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4])) | |
| 86 | 153 | p.mu.Unlock() | |
| @@ -108,8 +175,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 108 | 175 | func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) { | |
| 109 | 176 | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 110 | 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"}}}, | |
| 112 | - | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/two"}}}, | |
| 178 | + | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/one")}}, | |
| 179 | + | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}}, | |
| 113 | 180 | }, | |
| 114 | 181 | })}}} | |
| 115 | 182 | factory := &restorePTYFactory{} | |
| @@ -138,17 +205,15 @@ func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testi | |||
| 138 | 205 | ||
| 139 | 206 | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | |
| 140 | 207 | t.Helper() | |
| 208 | + | pane := emptyTerminalPane(t, "pane-1", "/tmp") | |
| 209 | + | pane.Process = proc | |
| 141 | 210 | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 142 | 211 | Name: name, | |
| 143 | 212 | Tabs: []snapcodec.Tab{{ | |
| 144 | - | Cols: 80, | |
| 145 | - | Rows: 24, | |
| 146 | - | Tree: layout.NewTree("pane-1"), | |
| 147 | - | Panes: []snapcodec.Pane{{ | |
| 148 | - | ID: "pane-1", | |
| 149 | - | Cwd: "/tmp", | |
| 150 | - | Process: proc, | |
| 151 | - | }}, | |
| 213 | + | Cols: 80, | |
| 214 | + | Rows: 24, | |
| 215 | + | Tree: layout.NewTree("pane-1"), | |
| 216 | + | Panes: []snapcodec.Pane{pane}, | |
| 152 | 217 | }}, | |
| 153 | 218 | })}}} | |
| 154 | 219 | } | |
| @@ -185,20 +250,17 @@ func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) { | |||
| 185 | 250 | Cols: 80, | |
| 186 | 251 | Rows: 24, | |
| 187 | 252 | Tree: layout.NewTree("pane-1"), | |
| 188 | - | Panes: []snapcodec.Pane{{ | |
| 189 | - | ID: "pane-1", | |
| 190 | - | Cwd: "/one", | |
| 191 | - | Process: lessProc, | |
| 192 | - | }}, | |
| 253 | + | Panes: func() []snapcodec.Pane { | |
| 254 | + | pane := emptyTerminalPane(t, "pane-1", "/one") | |
| 255 | + | pane.Process = lessProc | |
| 256 | + | return []snapcodec.Pane{pane} | |
| 257 | + | }(), | |
| 193 | 258 | }, | |
| 194 | 259 | { | |
| 195 | - | Cols: 80, | |
| 196 | - | Rows: 24, | |
| 197 | - | Tree: layout.NewTree("pane-1"), | |
| 198 | - | Panes: []snapcodec.Pane{{ | |
| 199 | - | ID: "pane-1", | |
| 200 | - | Cwd: "/two", | |
| 201 | - | }}, | |
| 260 | + | Cols: 80, | |
| 261 | + | Rows: 24, | |
| 262 | + | Tree: layout.NewTree("pane-1"), | |
| 263 | + | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}, | |
| 202 | 264 | }, | |
| 203 | 265 | }, | |
| 204 | 266 | })}}}, | |
| @@ -246,11 +308,26 @@ func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) { | |||
| 246 | 308 | d := newTestDaemon(t, factory, stubClock{}) | |
| 247 | 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 | 314 | require.Len(t, factory.opens, 1) | |
| 251 | 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 | 331 | func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) { | |
| 255 | 332 | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 256 | 333 | Name: "stacked", | |
| @@ -260,7 +337,11 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) | |||
| 260 | 337 | Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{ | |
| 261 | 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 | 347 | factory := &restorePTYFactory{} | |
| @@ -290,7 +371,16 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) | |||
| 290 | 371 | } | |
| 291 | 372 | ||
| 292 | 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 | 384 | badVersion := append([]byte(nil), valid...) | |
| 295 | 385 | binary.BigEndian.PutUint16(badVersion[4:6], 1) | |
| 296 | 386 | tests := []struct { | |
| @@ -327,7 +417,7 @@ func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) { | |||
| 327 | 417 | Cols: 80, | |
| 328 | 418 | Rows: 24, | |
| 329 | 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 | 423 | factory := &restorePTYFactory{} | |
| @@ -353,7 +443,15 @@ func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) { | |||
| 353 | 443 | loadFn: func() ([]ports.SnapshotBlob, error) { | |
| 354 | 444 | close(loaded) | |
| 355 | 445 | <-releaseLoad | |
| 356 | - | return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/restored"}}}}})}}, nil | |
| 446 | + | return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 447 | + | Name: "work", | |
| 448 | + | Tabs: []snapcodec.Tab{{ | |
| 449 | + | Cols: 80, | |
| 450 | + | Rows: 24, | |
| 451 | + | Tree: layout.NewTree("pane-1"), | |
| 452 | + | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/restored")}, | |
| 453 | + | }}, | |
| 454 | + | })}}, nil | |
| 357 | 455 | }, | |
| 358 | 456 | } | |
| 359 | 457 | factory := &restorePTYFactory{} | |
| @@ -402,6 +500,11 @@ func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) { | |||
| 402 | 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 | 508 | func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte { | |
| 406 | 509 | t.Helper() | |
| 407 | 510 | b, err := snapcodec.Marshal(s) | |
| @@ -438,24 +541,26 @@ type restorePTYFactory struct { | |||
| 438 | 541 | } | |
| 439 | 542 | ||
| 440 | 543 | type restorePTYOpen struct { | |
| 544 | + | ctx context.Context | |
| 441 | 545 | dir string | |
| 442 | 546 | size domain.Size | |
| 443 | 547 | env []string | |
| 444 | 548 | 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 | 552 | f.mu.Lock() | |
| 449 | 553 | defer f.mu.Unlock() | |
| 450 | 554 | pty := newRestorePTY() | |
| 451 | 555 | 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 | 557 | return pty, nil | |
| 454 | 558 | } | |
| 455 | 559 | ||
| 456 | 560 | type restorePTY struct { | |
| 457 | 561 | mu sync.Mutex | |
| 458 | 562 | writes []string | |
| 563 | + | closes int | |
| 459 | 564 | writeErr error | |
| 460 | 565 | done chan struct{} | |
| 461 | 566 | once sync.Once | |
| @@ -475,7 +580,18 @@ func (p *restorePTY) Write(b []byte) (int, error) { | |||
| 475 | 580 | } | |
| 476 | 581 | 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 | 595 | func (p *restorePTY) Resize(domain.Size) error { return nil } | |
| 480 | 596 | func (p *restorePTY) Pid() int { return 0 } | |
| 481 | 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 | 29 | if ac != nil { | |
| 30 | 30 | ac.setTheme(t) | |
| 31 | - | ac.composed.invalidate() | |
| 32 | 31 | } | |
| 33 | 32 | for _, tb := range tabs { | |
| 34 | 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 | 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 | 65 | m := New(testSnapshot("alpha", "beta alpha")) | |
| 66 | 66 | for _, r := range "alpha" { | |
| 67 | 67 | m.Insert(r) | |
| 68 | 68 | } | |
| 69 | 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 | 72 | clone.Down() | |
| 73 | 73 | clone.Insert('z') | |
| 74 | 74 | ||
M pkg/vt/benchmark_test.go +158 -1
| @@ -1,6 +1,163 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | var ( | |
| 10 | + | benchmarkHistoryViewSink HistoryView | |
| 11 | + | benchmarkHistoryBlobSink []byte | |
| 12 | + | benchmarkHistoryBlobsSink [][]byte | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // TestHistoryViewPartialTailAllocationBounded guards the capture property used | |
| 16 | + | // by persistence: retained sealed chunks are shared, while only the small | |
| 17 | + | // mutable tail is copied into a view. | |
| 18 | + | func TestHistoryViewPartialTailAllocationBounded(t *testing.T) { | |
| 19 | + | const ( | |
| 20 | + | width = 120 | |
| 21 | + | chunkRows = 64 | |
| 22 | + | tailRows = 7 | |
| 23 | + | ) | |
| 24 | + | sealed := benchmarkHistory(t, chunkRows*4, width, chunkRows) | |
| 25 | + | sealed.SealAndView() | |
| 26 | + | partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows) | |
| 27 | + | partialBefore := partial.View() | |
| 28 | + | ||
| 29 | + | sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() }) | |
| 30 | + | partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() }) | |
| 31 | + | if partialAllocs > sealedAllocs+tailRows+3 { | |
| 32 | + | t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows) | |
| 33 | + | } | |
| 34 | + | if partial.View().Chunk(0) != partialBefore.Chunk(0) { | |
| 35 | + | t.Fatal("unchanged sealed chunks must retain identity across partial-tail views") | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func BenchmarkHistoryView10KRowsSealedReuse(b *testing.B) { | |
| 40 | + | history := benchmarkHistory(b, 10_000, 120, 256) | |
| 41 | + | sealed := history.SealAndView() | |
| 42 | + | if sealed.Len() != 10_000 || sealed.ChunkCount() != 40 || sealed.Chunk(0) == nil { | |
| 43 | + | b.Fatalf("invalid sealed history fixture: rows=%d chunks=%d", sealed.Len(), sealed.ChunkCount()) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | b.ReportAllocs() | |
| 47 | + | b.ResetTimer() | |
| 48 | + | for b.Loop() { | |
| 49 | + | benchmarkHistoryViewSink = history.View() | |
| 50 | + | } | |
| 51 | + | b.StopTimer() | |
| 52 | + | if got := benchmarkHistoryViewSink; got.Len() != sealed.Len() || got.Chunk(0) != sealed.Chunk(0) { | |
| 53 | + | b.Fatal("unchanged sealed history chunks were not reused") | |
| 54 | + | } | |
| 55 | + | b.ReportMetric(float64(sealed.ChunkCount()), "sealedchunks/view") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func BenchmarkHistoryView10KRowsPartialTail(b *testing.B) { | |
| 59 | + | history := benchmarkHistory(b, 10_000, 120, 256) | |
| 60 | + | before := history.View() | |
| 61 | + | if before.Len() != 10_000 || before.ChunkCount() != 40 || before.Chunk(0) == nil { | |
| 62 | + | b.Fatalf("invalid partial-tail history fixture: rows=%d chunks=%d", before.Len(), before.ChunkCount()) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | b.ReportAllocs() | |
| 66 | + | b.ResetTimer() | |
| 67 | + | for b.Loop() { | |
| 68 | + | benchmarkHistoryViewSink = history.View() | |
| 69 | + | } | |
| 70 | + | b.StopTimer() | |
| 71 | + | if got := benchmarkHistoryViewSink; got.Len() != before.Len() || got.Chunk(0) != before.Chunk(0) { | |
| 72 | + | b.Fatal("partial-tail capture did not reuse its sealed chunks") | |
| 73 | + | } | |
| 74 | + | b.ReportMetric(float64(10_000%256), "tailrows/view") | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func BenchmarkMarshalHistoryChunk256x120(b *testing.B) { | |
| 78 | + | history := benchmarkHistory(b, 256, 120, 256) | |
| 79 | + | chunk := history.SealAndView().Chunk(0) | |
| 80 | + | if chunk == nil { | |
| 81 | + | b.Fatal("invalid history chunk fixture") | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | b.ReportAllocs() | |
| 85 | + | b.ResetTimer() | |
| 86 | + | for b.Loop() { | |
| 87 | + | var err error | |
| 88 | + | benchmarkHistoryBlobSink, err = MarshalHistoryChunk(chunk) | |
| 89 | + | if err != nil { | |
| 90 | + | b.Fatal(err) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | b.StopTimer() | |
| 94 | + | if _, err := PreflightHistoryBlob(benchmarkHistoryBlobSink); err != nil { | |
| 95 | + | b.Fatalf("encoded history chunk is invalid: %v", err) | |
| 96 | + | } | |
| 97 | + | b.SetBytes(int64(len(benchmarkHistoryBlobSink))) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func BenchmarkUnmarshalHistoryChunk256x120(b *testing.B) { | |
| 101 | + | history := benchmarkHistory(b, 256, 120, 256) | |
| 102 | + | blob, err := MarshalHistoryChunk(history.SealAndView().Chunk(0)) | |
| 103 | + | if err != nil { | |
| 104 | + | b.Fatal(err) | |
| 105 | + | } | |
| 106 | + | if stats, err := PreflightHistoryBlob(blob); err != nil || stats.Rows != 256 || stats.Cells != 256*120 { | |
| 107 | + | b.Fatalf("invalid encoded history chunk fixture: stats=%+v err=%v", stats, err) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | b.ReportAllocs() | |
| 111 | + | b.SetBytes(int64(len(blob))) | |
| 112 | + | b.ResetTimer() | |
| 113 | + | for b.Loop() { | |
| 114 | + | var err error | |
| 115 | + | benchmarkHistoryViewSink, err = UnmarshalHistory(blob) | |
| 116 | + | if err != nil { | |
| 117 | + | b.Fatal(err) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | b.StopTimer() | |
| 121 | + | if benchmarkHistoryViewSink.Len() != 256 || benchmarkHistoryViewSink.ChunkCount() != 1 { | |
| 122 | + | b.Fatal("decoded history chunk lost rows") | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func BenchmarkMarshalSealedHistory10KRows(b *testing.B) { | |
| 127 | + | history := benchmarkHistory(b, 10_000, 120, 256) | |
| 128 | + | view := history.SealAndView() | |
| 129 | + | if view.Len() != 10_000 || view.ChunkCount() != 40 { | |
| 130 | + | b.Fatalf("invalid sealed history fixture: rows=%d chunks=%d", view.Len(), view.ChunkCount()) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | b.ReportAllocs() | |
| 134 | + | b.ResetTimer() | |
| 135 | + | for b.Loop() { | |
| 136 | + | var err error | |
| 137 | + | benchmarkHistoryBlobsSink, benchmarkHistoryBlobSink, err = MarshalSealedHistory(view) | |
| 138 | + | if err != nil { | |
| 139 | + | b.Fatal(err) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | b.StopTimer() | |
| 143 | + | if len(benchmarkHistoryBlobsSink) != view.ChunkCount() || len(benchmarkHistoryBlobSink) == 0 { | |
| 144 | + | b.Fatal("sealed-history encoder produced an incomplete capture") | |
| 145 | + | } | |
| 146 | + | b.ReportMetric(float64(len(benchmarkHistoryBlobsSink)), "sealedchunks/op") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History { | |
| 150 | + | t.Helper() | |
| 151 | + | history := NewHistory(HistoryConfig{MaxRows: rows, ChunkRows: chunkRows}) | |
| 152 | + | for row := range rows { | |
| 153 | + | cells := make([]renderer.Cell, width) | |
| 154 | + | for col := range cells { | |
| 155 | + | cells[col] = renderer.Cell{Rune: rune('a' + (row+col)%26)} | |
| 156 | + | } | |
| 157 | + | history.Append(cells) | |
| 158 | + | } | |
| 159 | + | return history | |
| 160 | + | } | |
| 4 | 161 | ||
| 5 | 162 | func BenchmarkScreenShellRedrawBurst(b *testing.B) { | |
| 6 | 163 | chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C") | |