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) | 1 | + | # Performance measurement |
| 2 | - | 2 | + | ||
| 3 | - | This baseline measures in-process daemon work only. It deliberately does **not** | 3 | + | ## S5a transport observability |
| 4 | - | measure coordinator behavior, chunk encoding, compression, GC tuning, or real | 4 | + | |
| 5 | - | network latency. Output metrics count frames and encoded output data produced | 5 | + | S5a is measurement-only. It records process-local runtime marks and harness-owned |
| 6 | - | by the daemon; snapshot writes and serialized bytes are local snapshot-store | 6 | + | end-to-end boundaries without changing rendering, pacing, transport policy, wire |
| 7 | - | proxies. These metrics approximate local rendering, IPC payload, and persistence | 7 | + | bytes, or `ProtocolVersion`. The canonical matrix and workload/topology/ |
| 8 | - | work, not a Unix socket, SSH, or UDP transport. Real transport impairment and | 8 | + | transport coverage are in [`testdata/perf/manifest.json`](../testdata/perf/manifest.json): |
| 9 | - | latency benchmarks are deferred. | 9 | + | `1x4`, `4x1`, `4x4`, and `8x1`, all at `120x40` with 10,000 rows per pane; |
| 10 | - | 10 | + | idle, active/all/inactive output, interactive flood, copy search, resize sweep, | |
| 11 | - | ## Large-history daemon baseline | 11 | + | snapshot/output/resize, and attach/restore/tab switch; local, SSH stdio, UDP |
| 12 | - | 12 | + | baseline, UDP 25/100 ms, and UDP 0%/1% loss. It has 252 scenarios (4×9×7), | |
| 13 | - | Use a single iteration only as a smoke check; it is not suitable for drawing | 13 | + | with inapplicable cases required to be explicit. The seven transport entries are |
| 14 | - | performance conclusions: | 14 | + | `local`, `ssh_stdio`, `udp_baseline`, `udp_loss_0pct`, `udp_25ms`, `udp_100ms`, |
| 15 | - | 15 | + | and `udp_loss_1pct`. | |
| 16 | - | ```sh | 16 | + | |
| 17 | - | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | 17 | + | ### Trace schema and clock domains |
| 18 | - | ``` | 18 | + | |
| 19 | - | 19 | + | Every process JSONL record has exactly these fields: | |
| 20 | - | For before/after comparison, collect repeated samples for the **full matrix** | 20 | + | `schema` (uint), `process_id` (string), `component` (string), `scenario` |
| 21 | - | (all workloads and topologies), pinning CPU/governor and recording the host. | 21 | + | (string), `run`, `sequence`, `request_id`, `epoch` (uint), `kind` (string), |
| 22 | - | The benchmark harness must be committed and present in **both** measured | 22 | + | `tick` (int64), `bytes`, `fragments`, `retransmits`, `pending` (uint), |
| 23 | - | revisions: collect the baseline after the harness is introduced but before the | 23 | + | `ack_rtt_nanos` (int64), and `valid` (bool). Schema 1 is represented by: |
| 24 | - | optimization, then collect the optimized revision with that same harness. The | 24 | + | |
| 25 | - | fixed duration and count make the output suitable for `benchstat`: | 25 | + | ```json |
| 26 | - | 26 | + | {"schema":1,"process_id":"p","component":"daemon","scenario":"s","run":1,"sequence":1,"request_id":1,"epoch":1,"kind":"diff_start","tick":0,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true} | |
| 27 | - | ```sh | |||
| 28 | - | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1s -count=5 -benchmem > /tmp/vev-history-after.txt | |||
| 29 | - | benchstat /tmp/vev-history-before.txt /tmp/vev-history-after.txt | |||
| 30 | ``` | 27 | ``` | ||
| 31 | 28 | ||||
| 32 | - | The matrix exercises live paint, snapshot capture, copy-mode entry, copy search, | 29 | + | `tick` is stamped only by the concrete JSONL observer using its process-local |
| 33 | - | and resize at `120x40` client geometry. Each pane is populated with 10,000 | 30 | + | injected monotonic clock; producers supply no timestamp. The harness sets `VEV_PERF_TRACE`, |
| 34 | - | full-width deterministic history rows containing a stable `needle-<tab>-<row>` | 31 | + | `VEV_PERF_PROCESS_ID`, `VEV_PERF_SCENARIO`, and `VEV_PERF_RUN` for every |
| 35 | - | prefix and patterned text. The layouts are: | 32 | + | launched role, identifying its exclusive trace path and process/scenario/run |
| 33 | + | mapping. Components in one OS | |||
| 34 | + | process share that observer. The harness clock is a separate monotonic domain | |||
| 35 | + | and is the sole clock for input-injection → terminal-bytes-successfully-flushed | |||
| 36 | + | end-to-end samples. Cross-process ticks are never ordered or subtracted; | |||
| 37 | + | records correlate only by scenario/run/sequence/request/epoch IDs. | |||
| 36 | 38 | ||||
| 37 | - | - 1 tab x 1 pane (control) | 39 | + | The named process-local spans are capture, compose, diff, queue wait, |
| 38 | - | - 1 tab x 4 panes | 40 | + | ACK-blocked interval, emit, adapter send, and adapter receive. Their start/end |
| 39 | - | - 4 tabs x 1 pane | 41 | + | marks are paired within one process and summarized by component/adapter with |
| 40 | - | - 4 tabs x 4 panes | 42 | + | raw records, counts, p50/p95/p99, and max. Diagnostic fields (bytes, fragments, |
| 41 | - | - 8 tabs x 1 pane | 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 | 45 | + | ### Baseline procedure and validity |
| 44 | - | the renderer shadow before timing; live paint then alternates fixed writes. The | |||
| 45 | - | reported `outputframes/op` value counts output messages. `outputbytes/op` | |||
| 46 | - | counts their encoded terminal data and excludes the 24-byte output header; | |||
| 47 | - | `framepayloadbytes/op` counts the complete output payload including that header. | |||
| 48 | - | `snapshotwrites/op` and `snapshotbytes/op` are local snapshot-store writes and | |||
| 49 | - | serialized bytes. They must not be presented as network throughput or latency. | |||
| 50 | 46 | ||||
| 51 | - | Capture a pre-change result outside the repository, including Go and kernel | 47 | + | Use the public CLI harness (not internal packages): |
| 52 | - | context: | |||
| 53 | 48 | ||||
| 54 | ```sh | 49 | ```sh | ||
| 55 | - | { | 50 | + | go build -o /tmp/vev-perf ./ |
| 56 | - | date -Is | 51 | + | rm -rf testdata/perf/results |
| 57 | - | go version | 52 | + | go run ./cmd/vev-perf-harness --vev-bin /tmp/vev-perf \ |
| 58 | - | uname -a | 53 | + | --manifest testdata/perf/manifest.json --out testdata/perf/results \ |
| 59 | - | git rev-parse HEAD | 54 | + | --warmup 10s --duration 30s --repetitions 10 |
| 60 | - | # Smoke only; do not use this result for comparisons. | |||
| 61 | - | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | |||
| 62 | - | # Repeated full matrix for every workload/topology used in conclusions. | |||
| 63 | - | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1s -count=5 -benchmem | |||
| 64 | - | } > /tmp/vev-history-before.txt 2>&1 | |||
| 65 | ``` | 55 | ``` | ||
| 66 | 56 | ||||
| 67 | - | ## Immutable document allocation 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 | + | ## Existing in-process smoke checks |
| 70 | - | clones only the visible frame. Visual-search models share that document; cloning | |||
| 71 | - | a model copies only its mutable input and match state. Check those two paths at | |||
| 72 | - | 10,000 history rows with: | |||
| 73 | 71 | ||||
| 74 | - | ```sh | 72 | + | These commands measure local daemon work only and are not end-to-end transport |
| 75 | - | go test ./internal/usecase/copy ./internal/usecase/visualsearch -run '^$' -bench '^(BenchmarkNewSnapshot10KRows|BenchmarkVisualSearchClone10KRows)$' -benchtime=1s -count=5 -benchmem | 73 | + | results: |
| 76 | - | ``` | |||
| 77 | - | ||||
| 78 | - | Treat `allocs/op` and `B/op` as allocation gates: compare the complete repeated | |||
| 79 | - | output with the accepted baseline before merging, and investigate any increase. | |||
| 80 | - | The automated scaling gates also compare equal row/match counts at narrow and | |||
| 81 | - | wide row widths; `B/op` may grow by at most 2x, a conservative relative | |||
| 82 | - | allowance that catches full cell-row copies without depending on an absolute | |||
| 83 | - | Go-version-specific budget. This scope does not yet include an | |||
| 84 | - | incremental/full-text search index: queries still scan the immutable document | |||
| 85 | - | and produce a fresh match list. Coordinator | |||
| 86 | - | behavior, chunk encoding, compression, GC tuning, and real Unix-socket, SSH, or | |||
| 87 | - | UDP impairment/latency remain out of scope. | |||
| 88 | - | ||||
| 89 | - | ## Lower-level smoke baseline | |||
| 90 | - | ||||
| 91 | - | Use this command to check renderer, VT, IPC, and daemon microbenchmarks without | |||
| 92 | - | claiming an end-to-end transport result: | |||
| 93 | 74 | ||||
| 94 | ```sh | 75 | ```sh | ||
| 76 | + | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | |||
| 95 | go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem | 77 | go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem | ||
| 96 | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeFloatingFrameCached$' -benchmem | 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 | BootstrapTimeout time.Duration | 102 | BootstrapTimeout time.Duration | ||
| 103 | ProbeTimeout time.Duration | 103 | ProbeTimeout time.Duration | ||
| 104 | Log *slog.Logger | 104 | Log *slog.Logger | ||
| 105 | + | RuntimeObserver ports.SerializedRuntimeObserver | |||
| 105 | } | 106 | } | ||
| 106 | 107 | ||||
| 107 | func NewRemoteDialer(target, session string) RemoteDialer { | 108 | func NewRemoteDialer(target, session string) RemoteDialer { | ||
| @@ -180,7 +181,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||||
| 180 | RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) { | 181 | RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) { | ||
| 181 | return listenUDPPacket(ctx) | 182 | return listenUDPPacket(ctx) | ||
| 182 | }, | 183 | }, | ||
| 183 | - | }) | 184 | + | }, WithRuntimeObserver(d.RuntimeObserver)) |
| 184 | if err != nil { | 185 | if err != nil { | ||
| 185 | _ = pc.Close() | 186 | _ = pc.Close() | ||
| 186 | return nil, udpUnavailable("create UDP transport", err, &stderr) | 187 | return nil, udpUnavailable("create UDP transport", err, &stderr) | ||
M internal/adapters/dgram/transport.go +96 -7
| @@ -45,6 +45,14 @@ var ( | |||||
| 45 | errControlFull = errors.New("dgram: control queue full") | 45 | errControlFull = errors.New("dgram: control queue full") | ||
| 46 | ) | 46 | ) | ||
| 47 | 47 | ||||
| 48 | + | type RuntimeOption func(*Transport) | |||
| 49 | + | ||||
| 50 | + | // WithRuntimeObserver enables process-local marks without changing dgram's | |||
| 51 | + | // behavior clock or carriage policy. | |||
| 52 | + | func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) RuntimeOption { | |||
| 53 | + | return func(t *Transport) { t.runtimeObserver = observer } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 48 | type Options struct { | 56 | type Options struct { | ||
| 49 | MTU int | 57 | MTU int | ||
| 50 | ResendAfter time.Duration | 58 | ResendAfter time.Duration | ||
| @@ -168,6 +176,11 @@ type Transport struct { | |||||
| 168 | // mutation and before ordered event publication. | 176 | // mutation and before ordered event publication. | ||
| 169 | afterLinkStateCommit func(ports.LinkState) | 177 | afterLinkStateCommit func(ports.LinkState) | ||
| 170 | observe DiagnosticObserver | 178 | observe DiagnosticObserver | ||
| 179 | + | runtimeObserver ports.SerializedRuntimeObserver | |||
| 180 | + | operationMu sync.Mutex | |||
| 181 | + | operationCount int | |||
| 182 | + | closing bool | |||
| 183 | + | operationsDone chan struct{} | |||
| 171 | diagnosticCh chan Diagnostic | 184 | diagnosticCh chan Diagnostic | ||
| 172 | heartbeat time.Duration | 185 | heartbeat time.Duration | ||
| 173 | resendAfter time.Duration | 186 | resendAfter time.Duration | ||
| @@ -311,11 +324,11 @@ func (s *writeDeadlineState) expire(now time.Time) { | |||||
| 311 | s.mu.Unlock() | 324 | s.mu.Unlock() | ||
| 312 | } | 325 | } | ||
| 313 | 326 | ||||
| 314 | - | func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) { | 327 | + | func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, runtimeOpts ...RuntimeOption) (*Transport, error) { |
| 315 | - | return NewTransportWithOptions(pc, peer, key, sendDir, recvDir, Options{}) | 328 | + | return NewTransportWithOptions(pc, peer, key, sendDir, recvDir, Options{}, runtimeOpts...) |
| 316 | } | 329 | } | ||
| 317 | 330 | ||||
| 318 | - | func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, opts Options) (*Transport, error) { | 331 | + | func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, opts Options, runtimeOpts ...RuntimeOption) (*Transport, error) { |
| 319 | c, err := pdgram.NewCodec(key) | 332 | c, err := pdgram.NewCodec(key) | ||
| 320 | if err != nil { | 333 | if err != nil { | ||
| 321 | return nil, err | 334 | return nil, err | ||
| @@ -360,6 +373,11 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD | |||||
| 360 | nextRecvSeq: 1, | 373 | nextRecvSeq: 1, | ||
| 361 | recvBuf: make(map[uint64]ports.Frame), | 374 | recvBuf: make(map[uint64]ports.Frame), | ||
| 362 | } | 375 | } | ||
| 376 | + | for _, opt := range runtimeOpts { | |||
| 377 | + | if opt != nil { | |||
| 378 | + | opt(t) | |||
| 379 | + | } | |||
| 380 | + | } | |||
| 363 | t.sendWake = make(chan struct{}) | 381 | t.sendWake = make(chan struct{}) | ||
| 364 | t.control = make(chan controlRecord, controlQueueSize) | 382 | t.control = make(chan controlRecord, controlQueueSize) | ||
| 365 | t.ackWake = make(chan struct{}, 1) | 383 | t.ackWake = make(chan struct{}, 1) | ||
| @@ -387,9 +405,19 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD | |||||
| 387 | return t, nil | 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 | // SendSynchronous owns queue reservation, fragment pacing, per-write deadlines, | 422 | // SendSynchronous owns queue reservation, fragment pacing, per-write deadlines, | ||
| 395 | // and close cancellation. Callers must not layer a shorter preflight timer over it. | 423 | // and close cancellation. Callers must not layer a shorter preflight timer over it. | ||
| @@ -568,25 +596,35 @@ func (t *Transport) removePendingLocked(seq uint64) bool { | |||||
| 568 | } | 596 | } | ||
| 569 | 597 | ||||
| 570 | func (t *Transport) Recv() (ports.Frame, error) { | 598 | func (t *Transport) Recv() (ports.Frame, error) { | ||
| 599 | + | end := t.beginRuntimeOperation(ports.RuntimeAdapterReceiveStart, 0) | |||
| 571 | select { | 600 | select { | ||
| 572 | case f := <-t.in: | 601 | case f := <-t.in: | ||
| 602 | + | end(true) | |||
| 573 | return f, nil | 603 | return f, nil | ||
| 574 | case <-t.done: | 604 | case <-t.done: | ||
| 575 | t.mu.Lock() | 605 | t.mu.Lock() | ||
| 576 | err := t.closeErr | 606 | err := t.closeErr | ||
| 577 | t.mu.Unlock() | 607 | t.mu.Unlock() | ||
| 578 | if err != nil { | 608 | if err != nil { | ||
| 609 | + | end(false) | |||
| 579 | return ports.Frame{}, err | 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 | func (t *Transport) Close() error { | 617 | func (t *Transport) Close() error { | ||
| 618 | + | done := t.beginShutdown() | |||
| 585 | t.closeWithError(errors.New("dgram: closed")) | 619 | t.closeWithError(errors.New("dgram: closed")) | ||
| 586 | t.mu.Lock() | 620 | t.mu.Lock() | ||
| 587 | pc := t.pc | 621 | pc := t.pc | ||
| 588 | t.mu.Unlock() | 622 | t.mu.Unlock() | ||
| 589 | - | return pc.Close() | 623 | + | err := pc.Close() |
| 624 | + | if done != nil { | |||
| 625 | + | <-done | |||
| 626 | + | } | |||
| 627 | + | return err | |||
| 590 | } | 628 | } | ||
| 591 | 629 | ||||
| 592 | func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer } | 630 | func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer } | ||
| @@ -1585,4 +1623,55 @@ func decodeData(b []byte) (uint64, bool, ports.Frame, bool) { | |||||
| 1585 | } | 1623 | } | ||
| 1586 | 1624 | ||||
| 1587 | // DatagramTransport marks Transport as a UDP-style datagram transport. | 1625 | // DatagramTransport marks Transport as a UDP-style datagram transport. | ||
| 1626 | + | func (t *Transport) beginRuntimeOperation(start ports.RuntimeMarkKind, bytes uint64) func(bool) { | |||
| 1627 | + | if t.runtimeObserver == nil { | |||
| 1628 | + | return func(bool) {} | |||
| 1629 | + | } | |||
| 1630 | + | if !t.beginObservedOperation() { | |||
| 1631 | + | return func(bool) {} | |||
| 1632 | + | } | |||
| 1633 | + | correlation := ports.NewRuntimeCorrelation() | |||
| 1634 | + | t.runtimeObserver.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("dgram", correlation, start, bytes, true)) | |||
| 1635 | + | end := ports.RuntimeAdapterSendEnd | |||
| 1636 | + | if start == ports.RuntimeAdapterReceiveStart { | |||
| 1637 | + | end = ports.RuntimeAdapterReceiveEnd | |||
| 1638 | + | } | |||
| 1639 | + | return func(valid bool) { | |||
| 1640 | + | defer t.finishObservedOperation() | |||
| 1641 | + | t.runtimeObserver.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("dgram", correlation, end, bytes, valid)) | |||
| 1642 | + | } | |||
| 1643 | + | } | |||
| 1644 | + | ||||
| 1645 | + | func (t *Transport) beginObservedOperation() bool { | |||
| 1646 | + | t.operationMu.Lock() | |||
| 1647 | + | defer t.operationMu.Unlock() | |||
| 1648 | + | if t.closing { | |||
| 1649 | + | return false | |||
| 1650 | + | } | |||
| 1651 | + | if t.operationCount == 0 { | |||
| 1652 | + | t.operationsDone = make(chan struct{}) | |||
| 1653 | + | } | |||
| 1654 | + | t.operationCount++ | |||
| 1655 | + | return true | |||
| 1656 | + | } | |||
| 1657 | + | ||||
| 1658 | + | func (t *Transport) finishObservedOperation() { | |||
| 1659 | + | t.operationMu.Lock() | |||
| 1660 | + | defer t.operationMu.Unlock() | |||
| 1661 | + | t.operationCount-- | |||
| 1662 | + | if t.operationCount == 0 { | |||
| 1663 | + | close(t.operationsDone) | |||
| 1664 | + | } | |||
| 1665 | + | } | |||
| 1666 | + | ||||
| 1667 | + | func (t *Transport) beginShutdown() <-chan struct{} { | |||
| 1668 | + | t.operationMu.Lock() | |||
| 1669 | + | defer t.operationMu.Unlock() | |||
| 1670 | + | t.closing = true | |||
| 1671 | + | if t.operationCount == 0 { | |||
| 1672 | + | return nil | |||
| 1673 | + | } | |||
| 1674 | + | return t.operationsDone | |||
| 1675 | + | } | |||
| 1676 | + | ||||
| 1588 | func (*Transport) DatagramTransport() {} | 1677 | func (*Transport) DatagramTransport() {} | ||
A internal/adapters/dgram/transport_observability_test.go +208 -0
| @@ -0,0 +1,208 @@ | |||||
| 1 | + | package dgram | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "errors" | |||
| 6 | + | "net" | |||
| 7 | + | "os" | |||
| 8 | + | "reflect" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/vev/internal/adapters/observability" | |||
| 15 | + | "github.com/bnema/vev/internal/ports" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestTransportObservabilityDgramFailedCloseEndsReceiveAndSend(t *testing.T) { | |||
| 19 | + | behaviorClock := newManualClock(time.Unix(0, 417)) | |||
| 20 | + | link := newSimulatedLink(behaviorClock, packetPolicy{}) | |||
| 21 | + | aPC, _ := newPairWithCapacity(link, 64) | |||
| 22 | + | closeErr := errors.New("packet connection close failed") | |||
| 23 | + | tracePath := t.TempDir() + "/dgram-close.jsonl" | |||
| 24 | + | observer, closer, err := observability.NewJSONL(tracePath, dgramObserverClock{now: time.Unix(0, 521)}, "dgram-process") | |||
| 25 | + | if err != nil { | |||
| 26 | + | t.Fatalf("NewJSONL() error = %v", err) | |||
| 27 | + | } | |||
| 28 | + | receiveStarted := make(chan struct{}) | |||
| 29 | + | signalingObserver := &dgramStartObserver{RuntimeObserver: observer, receiveStarted: receiveStarted} | |||
| 30 | + | reporter := ports.NewSerializedRuntimeObserver(signalingObserver, 64) | |||
| 31 | + | defer reporter.Close() | |||
| 32 | + | transport, err := NewTransportWithOptions(&dgramCloseFailPC{fakePC: aPC, err: closeErr}, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter)) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatalf("NewTransportWithOptions() error = %v", err) | |||
| 35 | + | } | |||
| 36 | + | if err := transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("write failure")}); err == nil { | |||
| 37 | + | t.Fatal("Send() error = nil on packet write failure") | |||
| 38 | + | } | |||
| 39 | + | recvDone := make(chan error, 1) | |||
| 40 | + | go func() { _, err := transport.Recv(); recvDone <- err }() | |||
| 41 | + | <-receiveStarted | |||
| 42 | + | if err := transport.Close(); !errors.Is(err, closeErr) { | |||
| 43 | + | t.Fatalf("Close() error = %v, want %v", err, closeErr) | |||
| 44 | + | } | |||
| 45 | + | if err := <-recvDone; err == nil { | |||
| 46 | + | t.Fatal("Recv() error = nil after failed Close()") | |||
| 47 | + | } | |||
| 48 | + | reporter.Close() | |||
| 49 | + | if err := closer.Close(); err != nil { | |||
| 50 | + | t.Fatalf("trace Close() error = %v", err) | |||
| 51 | + | } | |||
| 52 | + | assertDgramFailedSpanPairs(t, tracePath) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | type dgramStartObserver struct { | |||
| 56 | + | ports.RuntimeObserver | |||
| 57 | + | receiveStarted chan struct{} | |||
| 58 | + | once sync.Once | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (o *dgramStartObserver) ObserveRuntime(mark ports.RuntimeMark) { | |||
| 62 | + | if mark.Kind == ports.RuntimeAdapterReceiveStart { | |||
| 63 | + | o.once.Do(func() { close(o.receiveStarted) }) | |||
| 64 | + | } | |||
| 65 | + | o.RuntimeObserver.ObserveRuntime(mark) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | type dgramCloseFailPC struct { | |||
| 69 | + | *fakePC | |||
| 70 | + | err error | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (p *dgramCloseFailPC) WriteTo([]byte, net.Addr) (int, error) { | |||
| 74 | + | return 0, errors.New("packet write failed") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (p *dgramCloseFailPC) Close() error { | |||
| 78 | + | _ = p.fakePC.Close() | |||
| 79 | + | return p.err | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func assertDgramFailedSpanPairs(t *testing.T, path string) { | |||
| 83 | + | t.Helper() | |||
| 84 | + | data, err := os.ReadFile(path) | |||
| 85 | + | if err != nil { | |||
| 86 | + | t.Fatalf("ReadFile(trace): %v", err) | |||
| 87 | + | } | |||
| 88 | + | type mark struct { | |||
| 89 | + | ProcessID string `json:"process_id"` | |||
| 90 | + | Scenario string `json:"scenario"` | |||
| 91 | + | Run uint64 `json:"run"` | |||
| 92 | + | Sequence uint64 `json:"sequence"` | |||
| 93 | + | RequestID uint64 `json:"request_id"` | |||
| 94 | + | Epoch uint64 `json:"epoch"` | |||
| 95 | + | Kind string `json:"kind"` | |||
| 96 | + | Valid bool `json:"valid"` | |||
| 97 | + | } | |||
| 98 | + | type spanKey struct { | |||
| 99 | + | processID, scenario string | |||
| 100 | + | run, sequence, requestID, epoch uint64 | |||
| 101 | + | kind string | |||
| 102 | + | } | |||
| 103 | + | starts := make(map[spanKey]mark) | |||
| 104 | + | pairs := map[string]int{} | |||
| 105 | + | for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { | |||
| 106 | + | var m mark | |||
| 107 | + | if err := json.Unmarshal([]byte(line), &m); err != nil { | |||
| 108 | + | t.Fatalf("Unmarshal(trace): %v", err) | |||
| 109 | + | } | |||
| 110 | + | key := spanKey{m.ProcessID, m.Scenario, m.Run, m.Sequence, m.RequestID, m.Epoch, m.Kind} | |||
| 111 | + | switch m.Kind { | |||
| 112 | + | case "adapter_receive_start", "adapter_send_start": | |||
| 113 | + | if _, exists := starts[key]; exists { | |||
| 114 | + | t.Fatalf("duplicate adapter start: %#v", m) | |||
| 115 | + | } | |||
| 116 | + | starts[key] = m | |||
| 117 | + | case "adapter_receive_end", "adapter_send_end": | |||
| 118 | + | key.kind = strings.TrimSuffix(m.Kind, "_end") + "_start" | |||
| 119 | + | start, ok := starts[key] | |||
| 120 | + | if !ok || m.Valid { | |||
| 121 | + | t.Fatalf("unmatched or valid failed end: start=%#v end=%#v", start, m) | |||
| 122 | + | } | |||
| 123 | + | pairs[strings.TrimSuffix(m.Kind, "_end")]++ | |||
| 124 | + | delete(starts, key) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | if len(starts) != 0 { | |||
| 128 | + | t.Fatalf("unmatched adapter starts: %#v", starts) | |||
| 129 | + | } | |||
| 130 | + | if pairs["adapter_send"] != 1 || pairs["adapter_receive"] != 1 || len(pairs) != 2 { | |||
| 131 | + | t.Fatalf("failed adapter span pairs=%v, want exactly one send and one receive", pairs) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestTransportObservabilityDgramKeepsBehaviorClockSeparate(t *testing.T) { | |||
| 136 | + | behaviorClock := newManualClock(time.Unix(0, 401)) | |||
| 137 | + | link := newSimulatedLink(behaviorClock, packetPolicy{}) | |||
| 138 | + | aPC, bPC := newPairWithCapacity(link, 64) | |||
| 139 | + | tracePath := t.TempDir() + "/dgram.jsonl" | |||
| 140 | + | observer, closer, err := observability.NewJSONL(tracePath, dgramObserverClock{now: time.Unix(0, 509)}, "dgram-process") | |||
| 141 | + | if err != nil { | |||
| 142 | + | t.Fatalf("NewJSONL() error = %v", err) | |||
| 143 | + | } | |||
| 144 | + | reporter := ports.NewSerializedRuntimeObserver(observer, 64) | |||
| 145 | + | defer reporter.Close() | |||
| 146 | + | ||||
| 147 | + | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter)) | |||
| 148 | + | if err != nil { | |||
| 149 | + | t.Fatalf("NewTransportWithOptions(a) error = %v", err) | |||
| 150 | + | } | |||
| 151 | + | defer func() { _ = a.Close() }() | |||
| 152 | + | b, err := NewTransportWithOptions(bPC, testAddr("a"), key(), 2, 1, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter)) | |||
| 153 | + | if err != nil { | |||
| 154 | + | t.Fatalf("NewTransportWithOptions(b) error = %v", err) | |||
| 155 | + | } | |||
| 156 | + | defer func() { _ = b.Close() }() | |||
| 157 | + | ||||
| 158 | + | want := ports.Frame{Type: ports.MsgOutput, Payload: []byte("datagram carriage remains exact")} | |||
| 159 | + | if err := a.Send(want); err != nil { | |||
| 160 | + | t.Fatalf("Send() error = %v", err) | |||
| 161 | + | } | |||
| 162 | + | gotCh := make(chan ports.Frame, 1) | |||
| 163 | + | errCh := make(chan error, 1) | |||
| 164 | + | go func() { | |||
| 165 | + | frame, recvErr := b.Recv() | |||
| 166 | + | if recvErr != nil { | |||
| 167 | + | errCh <- recvErr | |||
| 168 | + | return | |||
| 169 | + | } | |||
| 170 | + | gotCh <- frame | |||
| 171 | + | }() | |||
| 172 | + | select { | |||
| 173 | + | case err := <-errCh: | |||
| 174 | + | t.Fatalf("Recv() error = %v", err) | |||
| 175 | + | case got := <-gotCh: | |||
| 176 | + | if !reflect.DeepEqual(got, want) { | |||
| 177 | + | t.Fatalf("carried frame = %#v, want %#v", got, want) | |||
| 178 | + | } | |||
| 179 | + | case <-time.After(time.Second): | |||
| 180 | + | t.Fatal("Recv() did not receive deterministic simulated datagram") | |||
| 181 | + | } | |||
| 182 | + | reporter.Close() | |||
| 183 | + | if err := closer.Close(); err != nil { | |||
| 184 | + | t.Fatalf("trace Close() error = %v", err) | |||
| 185 | + | } | |||
| 186 | + | trace, err := os.ReadFile(tracePath) | |||
| 187 | + | if err != nil { | |||
| 188 | + | t.Fatalf("ReadFile(trace): %v", err) | |||
| 189 | + | } | |||
| 190 | + | for _, kind := range []string{"adapter_send_start", "adapter_send_end", "adapter_receive_start", "adapter_receive_end"} { | |||
| 191 | + | if !strings.Contains(string(trace), `"kind":"`+kind+`"`) { | |||
| 192 | + | t.Errorf("trace missing %s", kind) | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | type dgramObserverClock struct{ now time.Time } | |||
| 198 | + | ||||
| 199 | + | func (c dgramObserverClock) Now() time.Time { return c.now } | |||
| 200 | + | func (c dgramObserverClock) NewTimer(d time.Duration) ports.Timer { | |||
| 201 | + | return dgramObserverTimer{timer: time.NewTimer(d)} | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | type dgramObserverTimer struct{ timer *time.Timer } | |||
| 205 | + | ||||
| 206 | + | func (t dgramObserverTimer) C() <-chan time.Time { return t.timer.C } | |||
| 207 | + | func (t dgramObserverTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) } | |||
| 208 | + | func (t dgramObserverTimer) Stop() bool { return t.timer.Stop() } | |||
A internal/adapters/dgram/transport_replay_test.go +31 -0
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package dgram | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/ports" | |||
| 7 | + | "github.com/bnema/vev/internal/testutil/replaytest" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestTransportReplay(t *testing.T) { | |||
| 12 | + | replaytest.Run(t, func(t *testing.T, frames []ports.Frame) []ports.Frame { | |||
| 13 | + | link := newSimulatedLink(fixedClock{}, packetPolicy{}) | |||
| 14 | + | leftPC, rightPC := newPairWithCapacity(link, 32) | |||
| 15 | + | left, err := NewTransport(leftPC, testAddr("b"), key(), 1, 2) | |||
| 16 | + | require.NoError(t, err) | |||
| 17 | + | right, err := NewTransport(rightPC, testAddr("a"), key(), 2, 1) | |||
| 18 | + | require.NoError(t, err) | |||
| 19 | + | t.Cleanup(func() { _ = left.Close(); _ = right.Close() }) | |||
| 20 | + | ||||
| 21 | + | got := make([]ports.Frame, 0, len(frames)) | |||
| 22 | + | for _, frame := range frames { | |||
| 23 | + | require.NoError(t, left.Send(frame)) | |||
| 24 | + | link.flush(rightPC) | |||
| 25 | + | received, recvErr := right.Recv() | |||
| 26 | + | require.NoError(t, recvErr) | |||
| 27 | + | got = append(got, received) | |||
| 28 | + | } | |||
| 29 | + | return got | |||
| 30 | + | }) | |||
| 31 | + | } | |||
M internal/adapters/ipc/dial.go +7 -5
| @@ -1,6 +1,7 @@ | |||||
| 1 | package ipc | 1 | package ipc | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | "net" | 6 | "net" | ||
| 6 | "path/filepath" | 7 | "path/filepath" | ||
| @@ -8,15 +9,16 @@ import ( | |||||
| 8 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 11 | - | // Dial connects to the daemon's socket in dir (dir/daemon.sock) and returns | 12 | + | // DialContext connects to the daemon's socket in dir (dir/daemon.sock) and |
| 12 | - | // a Transport speaking vev's framed protocol. A dial failure (no daemon | 13 | + | // returns a Transport speaking vev's framed protocol. A dial failure (no daemon |
| 13 | // listening, or a stale/absent socket) is reported as a wrapped error so | 14 | // listening, or a stale/absent socket) is reported as a wrapped error so | ||
| 14 | // callers can decide whether to spawn a daemon and retry. | 15 | // callers can decide whether to spawn a daemon and retry. | ||
| 15 | - | func Dial(dir string) (ports.Transport, error) { | 16 | + | func DialContext(ctx context.Context, dir string, opts ...Option) (ports.Transport, error) { |
| 16 | sockPath := filepath.Join(dir, socketFileName) | 17 | sockPath := filepath.Join(dir, socketFileName) | ||
| 17 | - | conn, err := net.Dial("unix", sockPath) | 18 | + | var dialer net.Dialer |
| 19 | + | conn, err := dialer.DialContext(ctx, "unix", sockPath) | |||
| 18 | if err != nil { | 20 | if err != nil { | ||
| 19 | return nil, fmt.Errorf("ipc: dial %s: %w", sockPath, err) | 21 | return nil, fmt.Errorf("ipc: dial %s: %w", sockPath, err) | ||
| 20 | } | 22 | } | ||
| 21 | - | return NewTransport(conn), 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 | type unixListener struct { | 25 | type unixListener struct { | ||
| 26 | ln *net.UnixListener | 26 | ln *net.UnixListener | ||
| 27 | addr string | 27 | addr string | ||
| 28 | + | opts []Option | |||
| 28 | } | 29 | } | ||
| 29 | 30 | ||||
| 30 | // Listen creates the socket directory (0700) if needed and starts | 31 | // Listen creates the socket directory (0700) if needed and starts | ||
| @@ -35,7 +36,7 @@ type unixListener struct { | |||||
| 35 | // the previous owner died without cleaning up, so the stale socket file is | 36 | // the previous owner died without cleaning up, so the stale socket file is | ||
| 36 | // unlinked and bind is retried once. A successful dial means a live daemon | 37 | // unlinked and bind is retried once. A successful dial means a live daemon | ||
| 37 | // owns the socket, and Listen returns ErrDaemonRunning. | 38 | // owns the socket, and Listen returns ErrDaemonRunning. | ||
| 38 | - | func Listen(dir string) (ports.Listener, error) { | 39 | + | func Listen(dir string, opts ...Option) (ports.Listener, error) { |
| 39 | if err := safedir.EnsurePrivate(dir); err != nil { | 40 | if err := safedir.EnsurePrivate(dir); err != nil { | ||
| 40 | return nil, fmt.Errorf("ipc: securing socket directory: %w", err) | 41 | return nil, fmt.Errorf("ipc: securing socket directory: %w", err) | ||
| 41 | } | 42 | } | ||
| @@ -69,7 +70,7 @@ func Listen(dir string) (ports.Listener, error) { | |||||
| 69 | return nil, fmt.Errorf("ipc: chmod %s: %w", sockPath, err) | 70 | return nil, fmt.Errorf("ipc: chmod %s: %w", sockPath, err) | ||
| 70 | } | 71 | } | ||
| 71 | 72 | ||||
| 72 | - | return &unixListener{ln: ln, addr: sockPath}, nil | 73 | + | return &unixListener{ln: ln, addr: sockPath, opts: opts}, nil |
| 73 | } | 74 | } | ||
| 74 | 75 | ||||
| 75 | // bindUnix binds and listens on sockPath. | 76 | // bindUnix binds and listens on sockPath. | ||
| @@ -104,7 +105,7 @@ func (l *unixListener) Accept() (ports.Transport, error) { | |||||
| 104 | if err != nil { | 105 | if err != nil { | ||
| 105 | return nil, err | 106 | return nil, err | ||
| 106 | } | 107 | } | ||
| 107 | - | return NewTransport(conn), nil | 108 | + | return NewTransport(conn, l.opts...), nil |
| 108 | } | 109 | } | ||
| 109 | 110 | ||||
| 110 | // Close stops accepting connections. The underlying net.UnixListener | 111 | // Close stops accepting connections. The underlying net.UnixListener | ||
M internal/adapters/ipc/transport.go +276 -17
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "io" | 6 | "io" | ||
| 7 | "net" | 7 | "net" | ||
| 8 | + | "sync" | |||
| 8 | 9 | ||||
| 9 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 10 | ) | 11 | ) | ||
| @@ -26,17 +27,54 @@ var ErrZeroLengthFrame = errors.New("ipc: zero-length frame") | |||||
| 26 | // maxFrameLen, and by Send when a payload is too large to encode. | 27 | // maxFrameLen, and by Send when a payload is too large to encode. | ||
| 27 | var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length") | 28 | var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length") | ||
| 28 | 29 | ||||
| 30 | + | // ErrBackpressure means the bounded IPC egress queue is full. Callers using | |||
| 31 | + | // SendAsync must treat it as a failed render emission rather than spawning an | |||
| 32 | + | // unbounded queue of their own. | |||
| 33 | + | var ErrBackpressure = errors.New("ipc: egress queue full") | |||
| 34 | + | ||||
| 35 | + | var errClosed = errors.New("ipc: closed") | |||
| 36 | + | ||||
| 37 | + | // sendQueueCapacity bounds frames accepted while a peer is not reading. It is | |||
| 38 | + | // deliberately small: render output can be large and must never turn a wedged | |||
| 39 | + | // peer into an unbounded memory commitment. | |||
| 40 | + | const sendQueueCapacity = 8 | |||
| 41 | + | ||||
| 29 | // unixTransport implements ports.Transport over a net.Conn (in practice an | 42 | // unixTransport implements ports.Transport over a net.Conn (in practice an | ||
| 30 | // AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets | 43 | // AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets | ||
| 31 | // tests exercise it over net.Pipe). | 44 | // tests exercise it over net.Pipe). | ||
| 32 | // | 45 | // | ||
| 33 | - | // unixTransport is safe for one concurrent caller of Send and one | 46 | + | // unixTransport has one writer goroutine which owns every conn.Write. Send |
| 34 | - | // concurrent caller of Recv at the same time (i.e. a single reader | 47 | + | // waits for that owner's wire attempt; SendAsync only performs a bounded, |
| 35 | - | // goroutine and a single writer goroutine); it is not safe for concurrent | 48 | + | // ordered enqueue. Recv still has one caller at a time. |
| 36 | - | // calls to Send from multiple goroutines, nor Recv from multiple | 49 | + | type Option func(*unixTransport) |
| 37 | - | // goroutines. | 50 | + | |
| 51 | + | // WithRuntimeObserver enables process-local transport marks. It accepts no | |||
| 52 | + | // clock: timestamp ownership belongs to the configured observer. | |||
| 53 | + | func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option { | |||
| 54 | + | return func(t *unixTransport) { t.observer = observer } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 38 | type unixTransport struct { | 57 | type unixTransport struct { | ||
| 39 | - | conn net.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 | // readBuf is reused across Recv calls (grow-once strategy): it grows | 79 | // readBuf is reused across Recv calls (grow-once strategy): it grows | ||
| 42 | // to fit the largest frame seen so far and is never shrunk. The Frame | 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 | // NewTransport wraps conn as a ports.Transport speaking vev's framed | 86 | // NewTransport wraps conn as a ports.Transport speaking vev's framed | ||
| 49 | // binary protocol. | 87 | // binary protocol. | ||
| 50 | - | func NewTransport(conn net.Conn) ports.Transport { | 88 | + | func NewTransport(conn net.Conn, opts ...Option) ports.Transport { |
| 51 | - | return &unixTransport{conn: conn} | 89 | + | t := &unixTransport{ |
| 90 | + | conn: conn, | |||
| 91 | + | egress: make(chan sendRequest, sendQueueCapacity), | |||
| 92 | + | done: make(chan struct{}), | |||
| 93 | + | writerDone: make(chan struct{}), | |||
| 94 | + | } | |||
| 95 | + | for _, opt := range opts { | |||
| 96 | + | if opt != nil { | |||
| 97 | + | opt(t) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | go t.writeLoop() | |||
| 101 | + | return t | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | type sendRequest struct { | |||
| 105 | + | data []byte | |||
| 106 | + | done chan error // nil for SendAsync | |||
| 107 | + | end func(bool) | |||
| 52 | } | 108 | } | ||
| 53 | 109 | ||||
| 54 | - | // Send writes f as a single frame: a 4-byte big-endian length (covering | 110 | + | // Send queues f for the sole writer and waits for its wire attempt. |
| 55 | - | // the type byte and payload), the type byte, then the payload — assembled | |||
| 56 | - | // into one buffer and written with a single Write call. | |||
| 57 | func (t *unixTransport) Send(f ports.Frame) error { | 111 | func (t *unixTransport) Send(f ports.Frame) error { | ||
| 112 | + | data, err := marshalFrame(f) | |||
| 113 | + | if err != nil { | |||
| 114 | + | return err | |||
| 115 | + | } | |||
| 116 | + | end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload))) | |||
| 117 | + | result := make(chan error, 1) | |||
| 118 | + | if err := t.enqueueWait(sendRequest{data: data, done: result, end: end}); err != nil { | |||
| 119 | + | end(false) | |||
| 120 | + | return err | |||
| 121 | + | } | |||
| 122 | + | select { | |||
| 123 | + | case err := <-result: | |||
| 124 | + | return err | |||
| 125 | + | case <-t.done: | |||
| 126 | + | return errClosed | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // SendAsync accepts f for ordered background transmission without waiting for | |||
| 131 | + | // a socket write. The queue is bounded and ownership includes a payload copy. | |||
| 132 | + | func (t *unixTransport) SendAsync(f ports.Frame) error { | |||
| 133 | + | data, err := marshalFrame(f) | |||
| 134 | + | if err != nil { | |||
| 135 | + | return err | |||
| 136 | + | } | |||
| 137 | + | end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload))) | |||
| 138 | + | if err := t.enqueueAsync(sendRequest{data: data, end: end}); err != nil { | |||
| 139 | + | end(false) | |||
| 140 | + | return err | |||
| 141 | + | } | |||
| 142 | + | return nil | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func marshalFrame(f ports.Frame) ([]byte, error) { | |||
| 58 | n := 1 + len(f.Payload) // type + payload | 146 | n := 1 + len(f.Payload) // type + payload | ||
| 59 | if n > maxFrameLen { | 147 | if n > maxFrameLen { | ||
| 60 | - | return ErrFrameTooLarge | 148 | + | return nil, ErrFrameTooLarge |
| 61 | } | 149 | } | ||
| 62 | - | ||||
| 63 | buf := make([]byte, frameHeaderLen+n) | 150 | buf := make([]byte, frameHeaderLen+n) | ||
| 64 | binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n)) | 151 | binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n)) | ||
| 65 | buf[frameHeaderLen] = byte(f.Type) | 152 | buf[frameHeaderLen] = byte(f.Type) | ||
| 66 | copy(buf[frameHeaderLen+1:], f.Payload) | 153 | copy(buf[frameHeaderLen+1:], f.Payload) | ||
| 154 | + | return buf, nil | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // enqueueWait waits for space in the egress queue. Close wakes waiters and | |||
| 158 | + | // the writer waits for all in-flight enqueues before draining queued requests. | |||
| 159 | + | func (t *unixTransport) enqueueWait(req sendRequest) error { | |||
| 160 | + | t.egressMu.Lock() | |||
| 161 | + | if t.egressClosing { | |||
| 162 | + | t.egressMu.Unlock() | |||
| 163 | + | return errClosed | |||
| 164 | + | } | |||
| 165 | + | if t.egressSenders == 0 { | |||
| 166 | + | t.egressSendersDone = make(chan struct{}) | |||
| 167 | + | } | |||
| 168 | + | t.egressSenders++ | |||
| 169 | + | t.egressMu.Unlock() | |||
| 170 | + | ||||
| 171 | + | select { | |||
| 172 | + | case <-t.done: | |||
| 173 | + | t.finishEgressSender() | |||
| 174 | + | return errClosed | |||
| 175 | + | case t.egress <- req: | |||
| 176 | + | t.finishEgressSender() | |||
| 177 | + | return nil | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 67 | 180 | ||||
| 68 | - | _, err := t.conn.Write(buf) | 181 | + | // enqueueAsync performs a bounded, nonblocking enqueue for SendAsync. |
| 69 | - | return err | 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 | // Recv reads one frame: a 4-byte length, then a body of that many bytes | 261 | // Recv reads one frame: a 4-byte length, then a body of that many bytes | ||
| 73 | // (type byte + payload). It blocks until a full frame arrives, the | 262 | // (type byte + payload). It blocks until a full frame arrives, the | ||
| 74 | // connection is closed (io.EOF), or an error occurs. | 263 | // connection is closed (io.EOF), or an error occurs. | ||
| 75 | func (t *unixTransport) Recv() (ports.Frame, error) { | 264 | func (t *unixTransport) Recv() (ports.Frame, error) { | ||
| 265 | + | end := t.beginOperation(ports.RuntimeAdapterReceiveStart, 0) | |||
| 76 | var hdr [frameHeaderLen]byte | 266 | var hdr [frameHeaderLen]byte | ||
| 77 | if _, err := io.ReadFull(t.conn, hdr[:]); err != nil { | 267 | if _, err := io.ReadFull(t.conn, hdr[:]); err != nil { | ||
| 268 | + | end(false) | |||
| 78 | return ports.Frame{}, err | 269 | return ports.Frame{}, err | ||
| 79 | } | 270 | } | ||
| 80 | 271 | ||||
| 81 | n := binary.BigEndian.Uint32(hdr[:]) | 272 | n := binary.BigEndian.Uint32(hdr[:]) | ||
| 82 | if n == 0 { | 273 | if n == 0 { | ||
| 274 | + | end(false) | |||
| 83 | return ports.Frame{}, ErrZeroLengthFrame | 275 | return ports.Frame{}, ErrZeroLengthFrame | ||
| 84 | } | 276 | } | ||
| 85 | if n > maxFrameLen { | 277 | if n > maxFrameLen { | ||
| 278 | + | end(false) | |||
| 86 | return ports.Frame{}, ErrFrameTooLarge | 279 | return ports.Frame{}, ErrFrameTooLarge | ||
| 87 | } | 280 | } | ||
| 88 | 281 | ||||
| @@ -92,6 +285,7 @@ func (t *unixTransport) Recv() (ports.Frame, error) { | |||||
| 92 | t.readBuf = t.readBuf[:n] | 285 | t.readBuf = t.readBuf[:n] | ||
| 93 | } | 286 | } | ||
| 94 | if _, err := io.ReadFull(t.conn, t.readBuf); err != nil { | 287 | if _, err := io.ReadFull(t.conn, t.readBuf); err != nil { | ||
| 288 | + | end(false) | |||
| 95 | return ports.Frame{}, err | 289 | return ports.Frame{}, err | ||
| 96 | } | 290 | } | ||
| 97 | 291 | ||||
| @@ -100,10 +294,75 @@ func (t *unixTransport) Recv() (ports.Frame, error) { | |||||
| 100 | payload = make([]byte, n-1) | 294 | payload = make([]byte, n-1) | ||
| 101 | copy(payload, t.readBuf[1:]) | 295 | copy(payload, t.readBuf[1:]) | ||
| 102 | } | 296 | } | ||
| 297 | + | end(true) | |||
| 103 | return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil | 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 | func (t *unixTransport) Close() error { | 354 | func (t *unixTransport) Close() error { | ||
| 108 | - | return t.conn.Close() | 355 | + | t.closeOnce.Do(func() { |
| 356 | + | operationsDone := t.beginShutdown() | |||
| 357 | + | t.egressMu.Lock() | |||
| 358 | + | t.egressClosing = true | |||
| 359 | + | close(t.done) | |||
| 360 | + | t.egressMu.Unlock() | |||
| 361 | + | t.closeErr = t.conn.Close() | |||
| 362 | + | <-t.writerDone | |||
| 363 | + | if operationsDone != nil { | |||
| 364 | + | <-operationsDone | |||
| 365 | + | } | |||
| 366 | + | }) | |||
| 367 | + | return t.closeErr | |||
| 109 | } | 368 | } | ||
A internal/adapters/ipc/transport_observability_test.go +197 -0
| @@ -0,0 +1,197 @@ | |||||
| 1 | + | package ipc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "net" | |||
| 6 | + | "os" | |||
| 7 | + | "reflect" | |||
| 8 | + | "strings" | |||
| 9 | + | "sync" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/adapters/observability" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestTransportObservabilityIPCEOFAndCloseEndFailedSpans(t *testing.T) { | |||
| 18 | + | trace := t.TempDir() + "/ipc-shutdown.jsonl" | |||
| 19 | + | observer, closer, err := observability.NewJSONL(trace, ipcRuntimeClock{now: time.Unix(0, 223)}, "ipc-process") | |||
| 20 | + | if err != nil { | |||
| 21 | + | t.Fatalf("NewJSONL() error = %v", err) | |||
| 22 | + | } | |||
| 23 | + | reporter := ports.NewSerializedRuntimeObserver(observer, 64) | |||
| 24 | + | defer reporter.Close() | |||
| 25 | + | ||||
| 26 | + | // EOF from the peer and a locally closed blocked receive are distinct | |||
| 27 | + | // shutdown paths; both must record a failed, correlated end. | |||
| 28 | + | for _, closeLocal := range []bool{false, true} { | |||
| 29 | + | left, right := net.Pipe() | |||
| 30 | + | var started <-chan struct{} | |||
| 31 | + | conn := left | |||
| 32 | + | if closeLocal { | |||
| 33 | + | observedConn := &ipcReadStartedConn{Conn: left, started: make(chan struct{})} | |||
| 34 | + | conn, started = observedConn, observedConn.started | |||
| 35 | + | } | |||
| 36 | + | transport := NewTransport(conn, WithRuntimeObserver(reporter)) | |||
| 37 | + | recvDone := make(chan error, 1) | |||
| 38 | + | go func() { _, err := transport.Recv(); recvDone <- err }() | |||
| 39 | + | if closeLocal { | |||
| 40 | + | <-started | |||
| 41 | + | if err := transport.Close(); err != nil { | |||
| 42 | + | t.Fatalf("Close() error = %v", err) | |||
| 43 | + | } | |||
| 44 | + | } else if err := right.Close(); err != nil { | |||
| 45 | + | t.Fatalf("peer Close() error = %v", err) | |||
| 46 | + | } | |||
| 47 | + | if err := <-recvDone; err == nil { | |||
| 48 | + | t.Fatal("Recv() error = nil after transport shutdown") | |||
| 49 | + | } | |||
| 50 | + | _ = left.Close() | |||
| 51 | + | _ = right.Close() | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // A peer shutdown also makes Send fail after its start mark. | |||
| 55 | + | left, right := net.Pipe() | |||
| 56 | + | transport := NewTransport(left, WithRuntimeObserver(reporter)) | |||
| 57 | + | _ = right.Close() | |||
| 58 | + | if err := transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("failed")}); err == nil { | |||
| 59 | + | t.Fatal("Send() error = nil after peer close") | |||
| 60 | + | } | |||
| 61 | + | _ = transport.Close() | |||
| 62 | + | reporter.Close() | |||
| 63 | + | if err := closer.Close(); err != nil { | |||
| 64 | + | t.Fatalf("trace Close() error = %v", err) | |||
| 65 | + | } | |||
| 66 | + | assertIPCFailedSpanPairs(t, trace) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | type ipcReadStartedConn struct { | |||
| 70 | + | net.Conn | |||
| 71 | + | started chan struct{} | |||
| 72 | + | once sync.Once | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (c *ipcReadStartedConn) Read(p []byte) (int, error) { | |||
| 76 | + | c.once.Do(func() { close(c.started) }) | |||
| 77 | + | return c.Conn.Read(p) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func assertIPCFailedSpanPairs(t *testing.T, path string) { | |||
| 81 | + | t.Helper() | |||
| 82 | + | data, err := os.ReadFile(path) | |||
| 83 | + | if err != nil { | |||
| 84 | + | t.Fatalf("ReadFile(trace): %v", err) | |||
| 85 | + | } | |||
| 86 | + | type mark struct { | |||
| 87 | + | ProcessID string `json:"process_id"` | |||
| 88 | + | Component string `json:"component"` | |||
| 89 | + | Scenario string `json:"scenario"` | |||
| 90 | + | Run uint64 `json:"run"` | |||
| 91 | + | Sequence uint64 `json:"sequence"` | |||
| 92 | + | RequestID uint64 `json:"request_id"` | |||
| 93 | + | Epoch uint64 `json:"epoch"` | |||
| 94 | + | Kind string `json:"kind"` | |||
| 95 | + | Valid bool `json:"valid"` | |||
| 96 | + | } | |||
| 97 | + | type spanKey struct { | |||
| 98 | + | processID, scenario string | |||
| 99 | + | run, sequence, requestID, epoch uint64 | |||
| 100 | + | } | |||
| 101 | + | starts := make(map[spanKey]mark) | |||
| 102 | + | startCounts := map[string]int{} | |||
| 103 | + | for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { | |||
| 104 | + | var m mark | |||
| 105 | + | if err := json.Unmarshal([]byte(line), &m); err != nil { | |||
| 106 | + | t.Fatalf("Unmarshal(trace): %v", err) | |||
| 107 | + | } | |||
| 108 | + | key := spanKey{m.ProcessID, m.Scenario, m.Run, m.Sequence, m.RequestID, m.Epoch} | |||
| 109 | + | switch m.Kind { | |||
| 110 | + | case "adapter_receive_start", "adapter_send_start": | |||
| 111 | + | if m.ProcessID != "ipc-process" || m.Component != "ipc" || m.Scenario != "runtime" || m.Run != 1 || m.Sequence == 0 || m.RequestID != m.Sequence || m.Epoch != m.Sequence || !m.Valid { | |||
| 112 | + | t.Fatalf("adapter start has invalid identity: %#v", m) | |||
| 113 | + | } | |||
| 114 | + | if _, exists := starts[key]; exists { | |||
| 115 | + | t.Fatalf("duplicate adapter start for %#v", key) | |||
| 116 | + | } | |||
| 117 | + | starts[key] = m | |||
| 118 | + | startCounts[m.Kind]++ | |||
| 119 | + | case "adapter_receive_end", "adapter_send_end": | |||
| 120 | + | start, ok := starts[key] | |||
| 121 | + | if !ok || strings.TrimSuffix(start.Kind, "_start") != strings.TrimSuffix(m.Kind, "_end") { | |||
| 122 | + | t.Fatalf("unmatched adapter end %#v", m) | |||
| 123 | + | } | |||
| 124 | + | if m.Valid { | |||
| 125 | + | t.Fatalf("failed adapter end is valid: %#v", m) | |||
| 126 | + | } | |||
| 127 | + | delete(starts, key) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | if len(starts) != 0 { | |||
| 131 | + | t.Fatalf("unmatched adapter starts: %#v", starts) | |||
| 132 | + | } | |||
| 133 | + | if got := startCounts["adapter_receive_start"]; got != 2 { | |||
| 134 | + | t.Errorf("adapter receive starts = %d, want 2", got) | |||
| 135 | + | } | |||
| 136 | + | if got := startCounts["adapter_send_start"]; got != 1 { | |||
| 137 | + | t.Errorf("adapter send starts = %d, want 1", got) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestTransportObservabilityIPCMarksCarriageWithoutChangingBytes(t *testing.T) { | |||
| 142 | + | trace := t.TempDir() + "/ipc.jsonl" | |||
| 143 | + | observer, closer, err := observability.NewJSONL(trace, ipcRuntimeClock{now: time.Unix(0, 211)}, "ipc-process") | |||
| 144 | + | if err != nil { | |||
| 145 | + | t.Fatalf("NewJSONL() error = %v", err) | |||
| 146 | + | } | |||
| 147 | + | reporter := ports.NewSerializedRuntimeObserver(observer, 64) | |||
| 148 | + | defer reporter.Close() | |||
| 149 | + | ||||
| 150 | + | left, right := net.Pipe() | |||
| 151 | + | defer func() { _ = left.Close() }() | |||
| 152 | + | defer func() { _ = right.Close() }() | |||
| 153 | + | client := NewTransport(left, WithRuntimeObserver(reporter)) | |||
| 154 | + | server := NewTransport(right, WithRuntimeObserver(reporter)) | |||
| 155 | + | want := ports.Frame{Type: ports.MsgOutput, Payload: []byte("wire bytes stay exact")} | |||
| 156 | + | ||||
| 157 | + | var wg sync.WaitGroup | |||
| 158 | + | wg.Go(func() { | |||
| 159 | + | if err := client.Send(want); err != nil { | |||
| 160 | + | t.Errorf("Send() error = %v", err) | |||
| 161 | + | } | |||
| 162 | + | }) | |||
| 163 | + | got, err := server.Recv() | |||
| 164 | + | wg.Wait() | |||
| 165 | + | if err != nil { | |||
| 166 | + | t.Fatalf("Recv() error = %v", err) | |||
| 167 | + | } | |||
| 168 | + | if !reflect.DeepEqual(got, want) { | |||
| 169 | + | t.Fatalf("carried frame = %#v, want %#v", got, want) | |||
| 170 | + | } | |||
| 171 | + | reporter.Close() | |||
| 172 | + | if err := closer.Close(); err != nil { | |||
| 173 | + | t.Fatalf("trace Close() error = %v", err) | |||
| 174 | + | } | |||
| 175 | + | traceBytes, err := os.ReadFile(trace) | |||
| 176 | + | if err != nil { | |||
| 177 | + | t.Fatalf("ReadFile(trace): %v", err) | |||
| 178 | + | } | |||
| 179 | + | for _, kind := range []string{"adapter_send_start", "adapter_send_end", "adapter_receive_start", "adapter_receive_end"} { | |||
| 180 | + | if !strings.Contains(string(traceBytes), `"kind":"`+kind+`"`) { | |||
| 181 | + | t.Errorf("trace missing %s", kind) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | type ipcRuntimeClock struct{ now time.Time } | |||
| 187 | + | ||||
| 188 | + | func (c ipcRuntimeClock) Now() time.Time { return c.now } | |||
| 189 | + | func (c ipcRuntimeClock) NewTimer(d time.Duration) ports.Timer { | |||
| 190 | + | return ipcRuntimeTimer{timer: time.NewTimer(d)} | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | type ipcRuntimeTimer struct{ timer *time.Timer } | |||
| 194 | + | ||||
| 195 | + | func (t ipcRuntimeTimer) C() <-chan time.Time { return t.timer.C } | |||
| 196 | + | func (t ipcRuntimeTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) } | |||
| 197 | + | func (t ipcRuntimeTimer) Stop() bool { return t.timer.Stop() } | |||
A internal/adapters/ipc/transport_replay_test.go +40 -0
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package ipc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | "github.com/bnema/vev/internal/testutil/replaytest" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestTransportReplay(t *testing.T) { | |||
| 13 | + | replaytest.Run(t, func(t *testing.T, frames []ports.Frame) []ports.Frame { | |||
| 14 | + | left, right := net.Pipe() | |||
| 15 | + | t.Cleanup(func() { _ = left.Close(); _ = right.Close() }) | |||
| 16 | + | sender, receiver := NewTransport(left), NewTransport(right) | |||
| 17 | + | ||||
| 18 | + | var wg sync.WaitGroup | |||
| 19 | + | wg.Go(func() { | |||
| 20 | + | for _, frame := range frames { | |||
| 21 | + | if err := sender.Send(frame); err != nil { | |||
| 22 | + | t.Errorf("Send: %v", err) | |||
| 23 | + | return | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | }) | |||
| 27 | + | got := make([]ports.Frame, 0, len(frames)) | |||
| 28 | + | for range frames { | |||
| 29 | + | frame, err := receiver.Recv() | |||
| 30 | + | if err != nil { | |||
| 31 | + | t.Errorf("Recv: %v", err) | |||
| 32 | + | _ = receiver.Close() | |||
| 33 | + | break | |||
| 34 | + | } | |||
| 35 | + | got = append(got, frame) | |||
| 36 | + | } | |||
| 37 | + | wg.Wait() | |||
| 38 | + | return got | |||
| 39 | + | }) | |||
| 40 | + | } | |||
M internal/adapters/ipc/transport_test.go +285 -0
| @@ -6,8 +6,10 @@ import ( | |||||
| 6 | "io" | 6 | "io" | ||
| 7 | "net" | 7 | "net" | ||
| 8 | "reflect" | 8 | "reflect" | ||
| 9 | + | "runtime" | |||
| 9 | "sync" | 10 | "sync" | ||
| 10 | "testing" | 11 | "testing" | ||
| 12 | + | "time" | |||
| 11 | 13 | ||||
| 12 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 13 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| @@ -231,3 +233,286 @@ func TestTransportSendOversizePayloadRejected(t *testing.T) { | |||||
| 231 | t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err) | 233 | t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err) | ||
| 232 | } | 234 | } | ||
| 233 | } | 235 | } | ||
| 236 | + | ||||
| 237 | + | func TestTransportAsyncEgressPreservesWelcomeBeforeOutput(t *testing.T) { | |||
| 238 | + | const timeout = time.Second | |||
| 239 | + | ||||
| 240 | + | serverConn, clientConn := net.Pipe() | |||
| 241 | + | defer func() { _ = serverConn.Close() }() | |||
| 242 | + | defer func() { _ = clientConn.Close() }() | |||
| 243 | + | if err := serverConn.SetDeadline(time.Now().Add(timeout)); err != nil { | |||
| 244 | + | t.Fatalf("SetDeadline server connection: %v", err) | |||
| 245 | + | } | |||
| 246 | + | if err := clientConn.SetDeadline(time.Now().Add(timeout)); err != nil { | |||
| 247 | + | t.Fatalf("SetDeadline client connection: %v", err) | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | server := NewTransport(serverConn) | |||
| 251 | + | defer closeTransport(t, server) | |||
| 252 | + | client := NewTransport(clientConn) | |||
| 253 | + | defer closeTransport(t, client) | |||
| 254 | + | async, ok := server.(ports.AsyncTransport) | |||
| 255 | + | if !ok { | |||
| 256 | + | t.Fatal("IPC transport does not implement AsyncTransport") | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | welcome := ports.Frame{Type: ports.MsgWelcome, Payload: []byte("welcome")} | |||
| 260 | + | sent := make(chan error, 1) | |||
| 261 | + | go func() { sent <- server.Send(welcome) }() | |||
| 262 | + | got, err := client.Recv() | |||
| 263 | + | if err != nil { | |||
| 264 | + | t.Fatalf("Recv welcome: %v", err) | |||
| 265 | + | } | |||
| 266 | + | if !reflect.DeepEqual(got, welcome) { | |||
| 267 | + | t.Fatalf("welcome = %#v, want %#v", got, welcome) | |||
| 268 | + | } | |||
| 269 | + | select { | |||
| 270 | + | case err := <-sent: | |||
| 271 | + | if err != nil { | |||
| 272 | + | t.Fatalf("Send welcome: %v", err) | |||
| 273 | + | } | |||
| 274 | + | case <-time.After(timeout): | |||
| 275 | + | t.Fatal("Send welcome did not return") | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | payload := []byte("first") | |||
| 279 | + | if err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil { | |||
| 280 | + | t.Fatalf("SendAsync first output: %v", err) | |||
| 281 | + | } | |||
| 282 | + | payload[0] = 'X' // async ownership must not retain caller memory. | |||
| 283 | + | if err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")}); err != nil { | |||
| 284 | + | t.Fatalf("SendAsync second output: %v", err) | |||
| 285 | + | } | |||
| 286 | + | for _, want := range []ports.Frame{ | |||
| 287 | + | {Type: ports.MsgOutput, Payload: []byte("first")}, | |||
| 288 | + | {Type: ports.MsgOutput, Payload: []byte("second")}, | |||
| 289 | + | } { | |||
| 290 | + | got, err := client.Recv() | |||
| 291 | + | if err != nil { | |||
| 292 | + | t.Fatalf("Recv output: %v", err) | |||
| 293 | + | } | |||
| 294 | + | if !reflect.DeepEqual(got, want) { | |||
| 295 | + | t.Fatalf("output = %#v, want %#v", got, want) | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | func TestTransportSendWaitsForEgressCapacity(t *testing.T) { | |||
| 301 | + | const timeout = time.Second | |||
| 302 | + | ||||
| 303 | + | serverConn, clientConn := net.Pipe() | |||
| 304 | + | defer func() { _ = serverConn.Close() }() | |||
| 305 | + | defer func() { _ = clientConn.Close() }() | |||
| 306 | + | if err := clientConn.SetDeadline(time.Now().Add(timeout)); err != nil { | |||
| 307 | + | t.Fatalf("SetDeadline client connection: %v", err) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | server := NewTransport(serverConn) | |||
| 311 | + | defer closeTransport(t, server) | |||
| 312 | + | transport, ok := server.(*unixTransport) | |||
| 313 | + | if !ok { | |||
| 314 | + | t.Fatal("IPC transport is not a unixTransport") | |||
| 315 | + | } | |||
| 316 | + | client := NewTransport(clientConn) | |||
| 317 | + | defer closeTransport(t, client) | |||
| 318 | + | async, ok := server.(ports.AsyncTransport) | |||
| 319 | + | if !ok { | |||
| 320 | + | t.Fatal("IPC transport does not implement AsyncTransport") | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | queued := fillAsyncEgress(t, transport, async, timeout) | |||
| 324 | + | want := ports.Frame{Type: ports.MsgOutput, Payload: []byte("synchronous")} | |||
| 325 | + | sent := make(chan error, 1) | |||
| 326 | + | go func() { sent <- server.Send(want) }() | |||
| 327 | + | waitForEgressSender(t, transport, timeout) | |||
| 328 | + | ||||
| 329 | + | select { | |||
| 330 | + | case err := <-sent: | |||
| 331 | + | t.Fatalf("Send returned before capacity was available: %v", err) | |||
| 332 | + | default: | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | for _, want := range append(queued, want) { | |||
| 336 | + | got, err := client.Recv() | |||
| 337 | + | if err != nil { | |||
| 338 | + | t.Fatalf("Recv: %v", err) | |||
| 339 | + | } | |||
| 340 | + | if !reflect.DeepEqual(got, want) { | |||
| 341 | + | t.Fatalf("Recv = %#v, want %#v", got, want) | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | select { | |||
| 345 | + | case err := <-sent: | |||
| 346 | + | if err != nil { | |||
| 347 | + | t.Fatalf("Send after draining: %v", err) | |||
| 348 | + | } | |||
| 349 | + | case <-time.After(timeout): | |||
| 350 | + | t.Fatal("Send did not return after draining") | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | func TestTransportCloseInterruptsSendWaitingForEgressCapacity(t *testing.T) { | |||
| 355 | + | const timeout = time.Second | |||
| 356 | + | ||||
| 357 | + | serverConn, clientConn := net.Pipe() | |||
| 358 | + | defer func() { _ = clientConn.Close() }() | |||
| 359 | + | ||||
| 360 | + | server := NewTransport(serverConn) | |||
| 361 | + | defer closeTransport(t, server) | |||
| 362 | + | transport, ok := server.(*unixTransport) | |||
| 363 | + | if !ok { | |||
| 364 | + | t.Fatal("IPC transport is not a unixTransport") | |||
| 365 | + | } | |||
| 366 | + | async, ok := server.(ports.AsyncTransport) | |||
| 367 | + | if !ok { | |||
| 368 | + | t.Fatal("IPC transport does not implement AsyncTransport") | |||
| 369 | + | } | |||
| 370 | + | fillAsyncEgress(t, transport, async, timeout) | |||
| 371 | + | ||||
| 372 | + | sent := make(chan error, 1) | |||
| 373 | + | go func() { sent <- server.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("synchronous")}) }() | |||
| 374 | + | waitForEgressSender(t, transport, timeout) | |||
| 375 | + | ||||
| 376 | + | closeTransport(t, server) | |||
| 377 | + | select { | |||
| 378 | + | case err := <-sent: | |||
| 379 | + | if !errors.Is(err, errClosed) { | |||
| 380 | + | t.Fatalf("Send after Close = %v, want errClosed", err) | |||
| 381 | + | } | |||
| 382 | + | case <-time.After(timeout): | |||
| 383 | + | t.Fatal("Close did not interrupt Send") | |||
| 384 | + | } | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | func waitForEgressSender(t *testing.T, transport *unixTransport, timeout time.Duration) { | |||
| 388 | + | t.Helper() | |||
| 389 | + | ||||
| 390 | + | timer := time.NewTimer(timeout) | |||
| 391 | + | defer timer.Stop() | |||
| 392 | + | for { | |||
| 393 | + | transport.egressMu.Lock() | |||
| 394 | + | senders := transport.egressSenders | |||
| 395 | + | transport.egressMu.Unlock() | |||
| 396 | + | if senders > 0 { | |||
| 397 | + | return | |||
| 398 | + | } | |||
| 399 | + | select { | |||
| 400 | + | case <-timer.C: | |||
| 401 | + | t.Fatal("Send did not register as an egress capacity waiter") | |||
| 402 | + | default: | |||
| 403 | + | runtime.Gosched() | |||
| 404 | + | } | |||
| 405 | + | } | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | func closeTransport(t *testing.T, transport ports.Transport) { | |||
| 409 | + | t.Helper() | |||
| 410 | + | ||||
| 411 | + | closed := make(chan error, 1) | |||
| 412 | + | go func() { closed <- transport.Close() }() | |||
| 413 | + | select { | |||
| 414 | + | case err := <-closed: | |||
| 415 | + | if err != nil { | |||
| 416 | + | t.Fatalf("Close: %v", err) | |||
| 417 | + | } | |||
| 418 | + | case <-time.After(time.Second): | |||
| 419 | + | t.Fatal("Close did not return") | |||
| 420 | + | } | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | func fillAsyncEgress(t *testing.T, transport *unixTransport, async ports.AsyncTransport, timeout time.Duration) []ports.Frame { | |||
| 424 | + | t.Helper() | |||
| 425 | + | ||||
| 426 | + | first := ports.Frame{Type: ports.MsgOutput, Payload: []byte{0}} | |||
| 427 | + | if err := async.SendAsync(first); err != nil { | |||
| 428 | + | t.Fatalf("SendAsync blocked frame: %v", err) | |||
| 429 | + | } | |||
| 430 | + | waitForEgressDequeue(t, transport, timeout) | |||
| 431 | + | ||||
| 432 | + | queued := []ports.Frame{first} | |||
| 433 | + | for i := 1; ; i++ { | |||
| 434 | + | frame := ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}} | |||
| 435 | + | err := async.SendAsync(frame) | |||
| 436 | + | switch { | |||
| 437 | + | case err == nil: | |||
| 438 | + | queued = append(queued, frame) | |||
| 439 | + | case errors.Is(err, ErrBackpressure): | |||
| 440 | + | return queued | |||
| 441 | + | default: | |||
| 442 | + | t.Fatalf("SendAsync queued frame: %v", err) | |||
| 443 | + | } | |||
| 444 | + | } | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | func waitForEgressDequeue(t *testing.T, transport *unixTransport, timeout time.Duration) { | |||
| 448 | + | t.Helper() | |||
| 449 | + | ||||
| 450 | + | timer := time.NewTimer(timeout) | |||
| 451 | + | defer timer.Stop() | |||
| 452 | + | for { | |||
| 453 | + | if len(transport.egress) == 0 { | |||
| 454 | + | return | |||
| 455 | + | } | |||
| 456 | + | select { | |||
| 457 | + | case <-timer.C: | |||
| 458 | + | t.Fatal("IPC writer did not dequeue the blocked frame") | |||
| 459 | + | default: | |||
| 460 | + | runtime.Gosched() | |||
| 461 | + | } | |||
| 462 | + | } | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | func TestTransportAsyncEgressIsBoundedAndCloseInterruptsWorkers(t *testing.T) { | |||
| 466 | + | serverConn, clientConn := net.Pipe() | |||
| 467 | + | defer func() { _ = clientConn.Close() }() | |||
| 468 | + | ||||
| 469 | + | tr := NewTransport(serverConn) | |||
| 470 | + | defer closeTransport(t, tr) | |||
| 471 | + | transport, ok := tr.(*unixTransport) | |||
| 472 | + | if !ok { | |||
| 473 | + | t.Fatal("IPC transport is not a unixTransport") | |||
| 474 | + | } | |||
| 475 | + | async, ok := tr.(ports.AsyncTransport) | |||
| 476 | + | if !ok { | |||
| 477 | + | t.Fatal("IPC transport does not implement AsyncTransport") | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | // The first write blocks in net.Pipe because the peer never drains it. | |||
| 481 | + | if err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("blocked")}); err != nil { | |||
| 482 | + | t.Fatalf("SendAsync blocked frame: %v", err) | |||
| 483 | + | } | |||
| 484 | + | var backpressure error | |||
| 485 | + | for range sendQueueCapacity + 2 { | |||
| 486 | + | err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}) | |||
| 487 | + | if errors.Is(err, ErrBackpressure) { | |||
| 488 | + | backpressure = err | |||
| 489 | + | break | |||
| 490 | + | } | |||
| 491 | + | if err != nil { | |||
| 492 | + | t.Fatalf("SendAsync unexpected error: %v", err) | |||
| 493 | + | } | |||
| 494 | + | } | |||
| 495 | + | if !errors.Is(backpressure, ErrBackpressure) { | |||
| 496 | + | t.Fatalf("SendAsync never reported bounded backpressure, got %v", backpressure) | |||
| 497 | + | } | |||
| 498 | + | ||||
| 499 | + | recvDone := make(chan error, 1) | |||
| 500 | + | go func() { | |||
| 501 | + | _, err := tr.Recv() | |||
| 502 | + | recvDone <- err | |||
| 503 | + | }() | |||
| 504 | + | closeTransport(t, tr) | |||
| 505 | + | select { | |||
| 506 | + | case err := <-recvDone: | |||
| 507 | + | if err == nil { | |||
| 508 | + | t.Fatal("Recv succeeded after Close") | |||
| 509 | + | } | |||
| 510 | + | case <-time.After(time.Second): | |||
| 511 | + | t.Fatal("Close did not interrupt Recv") | |||
| 512 | + | } | |||
| 513 | + | select { | |||
| 514 | + | case <-transport.writerDone: | |||
| 515 | + | default: | |||
| 516 | + | t.Fatal("Close returned before the IPC writer terminated") | |||
| 517 | + | } | |||
| 518 | + | } | |||
A internal/adapters/observability/jsonl.go +100 -0
| @@ -0,0 +1,100 @@ | |||||
| 1 | + | // Package observability contains concrete process-local trace sinks. | |||
| 2 | + | package observability | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | "sync" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // NewJSONL opens path as the sole timestamp owner for one OS process. Marks | |||
| 14 | + | // carrying a caller timestamp are rejected rather than rewritten, preventing | |||
| 15 | + | // accidental arithmetic across clock domains. | |||
| 16 | + | func NewJSONL(path string, clock ports.Clock, processID string) (ports.RuntimeObserver, io.Closer, error) { | |||
| 17 | + | if path == "" || clock == nil || processID == "" { | |||
| 18 | + | return nil, nil, os.ErrInvalid | |||
| 19 | + | } | |||
| 20 | + | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) | |||
| 21 | + | if err != nil { | |||
| 22 | + | return nil, nil, err | |||
| 23 | + | } | |||
| 24 | + | j := &jsonl{file: f, clock: clock, processID: processID} | |||
| 25 | + | return j, j, nil | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | type jsonl struct { | |||
| 29 | + | mu sync.Mutex | |||
| 30 | + | file *os.File | |||
| 31 | + | clock ports.Clock | |||
| 32 | + | processID string | |||
| 33 | + | closed bool | |||
| 34 | + | err error | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | type record struct { | |||
| 38 | + | Schema uint16 `json:"schema"` | |||
| 39 | + | ProcessID string `json:"process_id"` | |||
| 40 | + | Component string `json:"component"` | |||
| 41 | + | Scenario string `json:"scenario"` | |||
| 42 | + | Run uint64 `json:"run"` | |||
| 43 | + | Sequence uint64 `json:"sequence"` | |||
| 44 | + | RequestID uint64 `json:"request_id"` | |||
| 45 | + | Epoch uint64 `json:"epoch"` | |||
| 46 | + | Kind ports.RuntimeMarkKind `json:"kind"` | |||
| 47 | + | Tick int64 `json:"tick"` | |||
| 48 | + | Bytes uint64 `json:"bytes"` | |||
| 49 | + | Fragments uint64 `json:"fragments"` | |||
| 50 | + | Retransmits uint64 `json:"retransmits"` | |||
| 51 | + | Pending uint64 `json:"pending"` | |||
| 52 | + | AckRTTNanos int64 `json:"ack_rtt_nanos"` | |||
| 53 | + | Valid bool `json:"valid"` | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (j *jsonl) ObserveRuntime(mark ports.RuntimeMark) { | |||
| 57 | + | // Tick is a write-once value owned by this adapter. Reject it before any | |||
| 58 | + | // mutation so callers cannot smuggle a timestamp from their own clock. | |||
| 59 | + | if j == nil || mark.Tick != 0 { | |||
| 60 | + | return | |||
| 61 | + | } | |||
| 62 | + | mark.ProcessID = j.processID | |||
| 63 | + | if !ports.RuntimeMarkValid(mark) { | |||
| 64 | + | return | |||
| 65 | + | } | |||
| 66 | + | j.mu.Lock() | |||
| 67 | + | defer j.mu.Unlock() | |||
| 68 | + | if j.closed { | |||
| 69 | + | return | |||
| 70 | + | } | |||
| 71 | + | r := record{Schema: mark.Schema, ProcessID: mark.ProcessID, Component: mark.Component, Scenario: mark.Scenario, Run: mark.Run, Sequence: mark.Sequence, RequestID: mark.RequestID, Epoch: mark.Epoch, Kind: mark.Kind, Tick: j.clock.Now().UnixNano(), Bytes: mark.Bytes, Fragments: mark.Fragments, Retransmits: mark.Retransmits, Pending: mark.Pending, AckRTTNanos: mark.AckRTTNanos, Valid: mark.Valid} | |||
| 72 | + | encoded, err := json.Marshal(r) | |||
| 73 | + | if err != nil { | |||
| 74 | + | j.recordError(err) | |||
| 75 | + | return | |||
| 76 | + | } | |||
| 77 | + | if _, err := j.file.Write(append(encoded, '\n')); err != nil { | |||
| 78 | + | j.recordError(err) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (j *jsonl) recordError(err error) { | |||
| 83 | + | if err != nil && j.err == nil { | |||
| 84 | + | j.err = err | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (j *jsonl) Close() error { | |||
| 89 | + | j.mu.Lock() | |||
| 90 | + | defer j.mu.Unlock() | |||
| 91 | + | if j.closed { | |||
| 92 | + | return nil | |||
| 93 | + | } | |||
| 94 | + | j.closed = true | |||
| 95 | + | closeErr := j.file.Close() | |||
| 96 | + | if j.err != nil { | |||
| 97 | + | return j.err | |||
| 98 | + | } | |||
| 99 | + | return closeErr | |||
| 100 | + | } | |||
A internal/adapters/observability/jsonl_test.go +193 -0
| @@ -0,0 +1,193 @@ | |||||
| 1 | + | package observability | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "os" | |||
| 7 | + | "sync" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestTransportObservabilityJSONLOwnsTicksAndSchema(t *testing.T) { | |||
| 15 | + | path := t.TempDir() + "/daemon.jsonl" | |||
| 16 | + | observer, closer, err := NewJSONL(path, fixedRuntimeClock{now: time.Unix(0, 41)}, "daemon-process") | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Fatalf("NewJSONL() error = %v", err) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | observer.ObserveRuntime(validRuntimeMark(ports.RuntimeComposeStart)) | |||
| 22 | + | // A producer's clock domain must never enter a JSONL record. Rejection is | |||
| 23 | + | // observable as no second record, rather than a silently rewritten mark. | |||
| 24 | + | producerTicked := validRuntimeMark(ports.RuntimeComposeEnd) | |||
| 25 | + | producerTicked.Tick = 99 | |||
| 26 | + | observer.ObserveRuntime(producerTicked) | |||
| 27 | + | if err := closer.Close(); err != nil { | |||
| 28 | + | t.Fatalf("Close() error = %v", err) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | records := readJSONLRecords(t, path) | |||
| 32 | + | if len(records) != 1 { | |||
| 33 | + | t.Fatalf("JSONL records = %d, want 1 (caller Tick must be rejected)", len(records)) | |||
| 34 | + | } | |||
| 35 | + | wantKeys := map[string]bool{ | |||
| 36 | + | "schema": true, "process_id": true, "component": true, "scenario": true, | |||
| 37 | + | "run": true, "sequence": true, "request_id": true, "epoch": true, | |||
| 38 | + | "kind": true, "tick": true, "bytes": true, "fragments": true, | |||
| 39 | + | "retransmits": true, "pending": true, "ack_rtt_nanos": true, "valid": true, | |||
| 40 | + | } | |||
| 41 | + | if len(records[0]) != len(wantKeys) { | |||
| 42 | + | t.Fatalf("JSONL fields = %#v, want exactly canonical schema", records[0]) | |||
| 43 | + | } | |||
| 44 | + | for key := range wantKeys { | |||
| 45 | + | if _, ok := records[0][key]; !ok { | |||
| 46 | + | t.Errorf("JSONL missing %q", key) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | if got := records[0]["tick"]; got != float64(41) { | |||
| 50 | + | t.Errorf("JSONL tick = %v, want observer clock tick 41", got) | |||
| 51 | + | } | |||
| 52 | + | if got := records[0]["process_id"]; got != "daemon-process" { | |||
| 53 | + | t.Errorf("JSONL process_id = %v, want observer process", got) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestTransportObservabilityJSONLRejectsInvalidRequiredFields(t *testing.T) { | |||
| 58 | + | for _, mark := range []ports.RuntimeMark{ | |||
| 59 | + | {}, | |||
| 60 | + | {Schema: 1, ProcessID: "p", Component: "daemon", Scenario: "s", Run: 1, Sequence: 1, Kind: ports.RuntimeEmitStart}, | |||
| 61 | + | {Schema: 2, ProcessID: "p", Component: "daemon", Scenario: "s", Run: 1, Sequence: 1, Kind: ports.RuntimeEmitStart, Valid: true}, | |||
| 62 | + | } { | |||
| 63 | + | path := t.TempDir() + "/invalid.jsonl" | |||
| 64 | + | observer, closer, err := NewJSONL(path, fixedRuntimeClock{now: time.Unix(0, 5)}, "p") | |||
| 65 | + | if err != nil { | |||
| 66 | + | t.Fatalf("NewJSONL() error = %v", err) | |||
| 67 | + | } | |||
| 68 | + | observer.ObserveRuntime(mark) | |||
| 69 | + | if err := closer.Close(); err != nil { | |||
| 70 | + | t.Fatalf("Close() error = %v", err) | |||
| 71 | + | } | |||
| 72 | + | if records := readJSONLRecords(t, path); len(records) != 0 { | |||
| 73 | + | t.Errorf("invalid mark %#v wrote %#v", mark, records) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestTransportObservabilityJSONLWriteErrorSurfacesOnClose(t *testing.T) { | |||
| 79 | + | // /dev/full accepts Open but rejects every write, exercising the concrete | |||
| 80 | + | // observer lifecycle without changing the one-way RuntimeObserver port. | |||
| 81 | + | observer, closer, err := NewJSONL("/dev/full", fixedRuntimeClock{now: time.Unix(0, 1)}, "p") | |||
| 82 | + | if err != nil { | |||
| 83 | + | t.Skipf("/dev/full unavailable: %v", err) | |||
| 84 | + | } | |||
| 85 | + | observer.ObserveRuntime(validRuntimeMark(ports.RuntimeEmitStart)) | |||
| 86 | + | if err := closer.Close(); err == nil { | |||
| 87 | + | t.Fatal("Close() error = nil, want surfaced JSONL write error") | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestTransportObservabilitySameProcessSpanClockDomains(t *testing.T) { | |||
| 92 | + | for _, tc := range []struct { | |||
| 93 | + | name string | |||
| 94 | + | start ports.RuntimeMarkKind | |||
| 95 | + | end ports.RuntimeMarkKind | |||
| 96 | + | field string | |||
| 97 | + | }{ | |||
| 98 | + | {"diff", ports.RuntimeDiffStart, ports.RuntimeDiffEnd, "diff_duration"}, | |||
| 99 | + | {"queue", ports.RuntimeQueueEnqueued, ports.RuntimeQueueDequeued, "queue_wait"}, | |||
| 100 | + | {"ack", ports.RuntimeACKBlockedStart, ports.RuntimeACKBlockedEnd, "ack_blocked_interval"}, | |||
| 101 | + | {"send", ports.RuntimeAdapterSendStart, ports.RuntimeAdapterSendEnd, "adapter_send_duration"}, | |||
| 102 | + | {"receive", ports.RuntimeAdapterReceiveStart, ports.RuntimeAdapterReceiveEnd, "adapter_receive_duration"}, | |||
| 103 | + | } { | |||
| 104 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 105 | + | start := validRuntimeMark(tc.start) | |||
| 106 | + | start.Tick = 11 | |||
| 107 | + | end := start | |||
| 108 | + | end.Kind, end.Tick = tc.end, 19 | |||
| 109 | + | if got, err := sameProcessDuration(start, end); err != nil || got != 8 { | |||
| 110 | + | t.Fatalf("%s = %d, %v; want 8, nil", tc.field, got, err) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | end.ProcessID = "other-process" | |||
| 114 | + | if _, err := sameProcessDuration(start, end); err == nil { | |||
| 115 | + | t.Fatalf("%s accepted cross-process ticks", tc.field) | |||
| 116 | + | } | |||
| 117 | + | }) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func TestTransportObservabilityJSONLSerializesConcurrentMarks(t *testing.T) { | |||
| 122 | + | path := t.TempDir() + "/concurrent.jsonl" | |||
| 123 | + | observer, closer, err := NewJSONL(path, fixedRuntimeClock{now: time.Unix(0, 73)}, "ipc-process") | |||
| 124 | + | if err != nil { | |||
| 125 | + | t.Fatalf("NewJSONL() error = %v", err) | |||
| 126 | + | } | |||
| 127 | + | var wg sync.WaitGroup | |||
| 128 | + | for i := uint64(0); i < 32; i++ { | |||
| 129 | + | wg.Add(1) | |||
| 130 | + | go func(sequence uint64) { | |||
| 131 | + | defer wg.Done() | |||
| 132 | + | mark := validRuntimeMark(ports.RuntimeAdapterSendEnd) | |||
| 133 | + | mark.Sequence = sequence + 1 | |||
| 134 | + | observer.ObserveRuntime(mark) | |||
| 135 | + | }(i) | |||
| 136 | + | } | |||
| 137 | + | wg.Wait() | |||
| 138 | + | if err := closer.Close(); err != nil { | |||
| 139 | + | t.Fatalf("Close() error = %v", err) | |||
| 140 | + | } | |||
| 141 | + | if records := readJSONLRecords(t, path); len(records) != 32 { | |||
| 142 | + | t.Fatalf("concurrently serialized records = %d, want 32", len(records)) | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func sameProcessDuration(start, end ports.RuntimeMark) (int64, error) { | |||
| 147 | + | if start.ProcessID == "" || start.ProcessID != end.ProcessID { | |||
| 148 | + | return 0, os.ErrInvalid | |||
| 149 | + | } | |||
| 150 | + | if start.Scenario != end.Scenario || start.Run != end.Run || start.Sequence != end.Sequence || start.RequestID != end.RequestID || start.Epoch != end.Epoch || end.Tick < start.Tick { | |||
| 151 | + | return 0, os.ErrInvalid | |||
| 152 | + | } | |||
| 153 | + | return end.Tick - start.Tick, nil | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func validRuntimeMark(kind ports.RuntimeMarkKind) ports.RuntimeMark { | |||
| 157 | + | return ports.RuntimeMark{Schema: 1, ProcessID: "daemon-process", Component: "daemon", Scenario: "resize", Run: 7, Sequence: 9, RequestID: 11, Epoch: 13, Kind: kind, Valid: true} | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func readJSONLRecords(t *testing.T, path string) []map[string]any { | |||
| 161 | + | t.Helper() | |||
| 162 | + | f, err := os.Open(path) | |||
| 163 | + | if err != nil { | |||
| 164 | + | t.Fatalf("Open(%q): %v", path, err) | |||
| 165 | + | } | |||
| 166 | + | defer func() { _ = f.Close() }() | |||
| 167 | + | var records []map[string]any | |||
| 168 | + | scanner := bufio.NewScanner(f) | |||
| 169 | + | for scanner.Scan() { | |||
| 170 | + | var record map[string]any | |||
| 171 | + | if err := json.Unmarshal(scanner.Bytes(), &record); err != nil { | |||
| 172 | + | t.Fatalf("invalid JSONL record %q: %v", scanner.Text(), err) | |||
| 173 | + | } | |||
| 174 | + | records = append(records, record) | |||
| 175 | + | } | |||
| 176 | + | if err := scanner.Err(); err != nil { | |||
| 177 | + | t.Fatalf("reading JSONL: %v", err) | |||
| 178 | + | } | |||
| 179 | + | return records | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | type fixedRuntimeClock struct{ now time.Time } | |||
| 183 | + | ||||
| 184 | + | func (c fixedRuntimeClock) Now() time.Time { return c.now } | |||
| 185 | + | func (c fixedRuntimeClock) NewTimer(d time.Duration) ports.Timer { | |||
| 186 | + | return fixedRuntimeTimer{timer: time.NewTimer(d)} | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | type fixedRuntimeTimer struct{ timer *time.Timer } | |||
| 190 | + | ||||
| 191 | + | func (t fixedRuntimeTimer) C() <-chan time.Time { return t.timer.C } | |||
| 192 | + | func (t fixedRuntimeTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) } | |||
| 193 | + | func (t fixedRuntimeTimer) Stop() bool { return t.timer.Stop() } | |||
M internal/adapters/pty/pty_linux.go +30 -6
| @@ -8,6 +8,7 @@ package pty | |||||
| 8 | // x/sys/unix plus the standard library. | 8 | // x/sys/unix plus the standard library. | ||
| 9 | 9 | ||||
| 10 | import ( | 10 | import ( | ||
| 11 | + | "context" | |||
| 11 | "errors" | 12 | "errors" | ||
| 12 | "fmt" | 13 | "fmt" | ||
| 13 | "io" | 14 | "io" | ||
| @@ -38,7 +39,10 @@ func NewFactory() *Factory { return &Factory{} } | |||||
| 38 | // verbatim (nil means inherit the current process environment); the caller | 39 | // verbatim (nil means inherit the current process environment); the caller | ||
| 39 | // decides TERM and friends. sz sets the terminal window size before the child | 40 | // decides TERM and friends. sz sets the terminal window size before the child | ||
| 40 | // starts, so the child observes the correct dimensions on its first query. | 41 | // starts, so the child observes the correct dimensions on its first query. | ||
| 41 | - | func (Factory) Open(command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { | 42 | + | func (Factory) Open(ctx context.Context, command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { |
| 43 | + | if err := ctx.Err(); err != nil { | |||
| 44 | + | return nil, err | |||
| 45 | + | } | |||
| 42 | masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0) | 46 | masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0) | ||
| 43 | if err != nil { | 47 | if err != nil { | ||
| 44 | return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err) | 48 | return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err) | ||
| @@ -79,7 +83,7 @@ func (Factory) Open(command string, args []string, env []string, dir string, sz | |||||
| 79 | } | 83 | } | ||
| 80 | } | 84 | } | ||
| 81 | 85 | ||||
| 82 | - | cmd := exec.Command(command, args...) | 86 | + | cmd := exec.CommandContext(ctx, command, args...) |
| 83 | cmd.Env = env | 87 | cmd.Env = env | ||
| 84 | cmd.Dir = platform.DirOrHome(dir) | 88 | cmd.Dir = platform.DirOrHome(dir) | ||
| 85 | cmd.Stdin = slave | 89 | cmd.Stdin = slave | ||
| @@ -93,6 +97,20 @@ func (Factory) Open(command string, args []string, env []string, dir string, sz | |||||
| 93 | Setctty: true, | 97 | Setctty: true, | ||
| 94 | Ctty: 0, | 98 | Ctty: 0, | ||
| 95 | } | 99 | } | ||
| 100 | + | // CommandContext's default cancellation kills only the direct child. The | |||
| 101 | + | // child leads a process group, so cancellation must target that group too. | |||
| 102 | + | cmd.Cancel = func() error { | |||
| 103 | + | if cmd.Process == nil { | |||
| 104 | + | return os.ErrProcessDone | |||
| 105 | + | } | |||
| 106 | + | if err := signalProcessGroup(cmd.Process.Pid, syscall.SIGKILL); err != nil { | |||
| 107 | + | if errors.Is(err, syscall.ESRCH) { | |||
| 108 | + | return os.ErrProcessDone | |||
| 109 | + | } | |||
| 110 | + | return err | |||
| 111 | + | } | |||
| 112 | + | return nil | |||
| 113 | + | } | |||
| 96 | 114 | ||||
| 97 | if err := cmd.Start(); err != nil { | 115 | if err := cmd.Start(); err != nil { | ||
| 98 | return nil, fmt.Errorf("pty: start %q: %w", command, err) | 116 | return nil, fmt.Errorf("pty: start %q: %w", command, err) | ||
| @@ -190,9 +208,8 @@ func (p *linuxPTY) Close() error { | |||||
| 190 | p.closeOnce.Do(func() { | 208 | p.closeOnce.Do(func() { | ||
| 191 | pid := p.cmd.Process.Pid | 209 | pid := p.cmd.Process.Pid | ||
| 192 | 210 | ||||
| 193 | - | // Negative pid targets the whole process group (the child is the group | 211 | + | // Ignore errors: the child may already be gone. |
| 194 | - | // leader thanks to Setsid). Ignore errors: the child may already be gone. | 212 | + | _ = signalProcessGroup(pid, syscall.SIGHUP) |
| 195 | - | _ = unix.Kill(-pid, unix.SIGHUP) | |||
| 196 | 213 | ||||
| 197 | // Reap the child (exit status is intentionally discarded; the port has no | 214 | // Reap the child (exit status is intentionally discarded; the port has no | ||
| 198 | // Wait — "child gone" surfaces to callers as io.EOF on Read). | 215 | // Wait — "child gone" surfaces to callers as io.EOF on Read). | ||
| @@ -205,7 +222,7 @@ func (p *linuxPTY) Close() error { | |||||
| 205 | select { | 222 | select { | ||
| 206 | case <-reaped: | 223 | case <-reaped: | ||
| 207 | case <-time.After(killGracePeriod): | 224 | case <-time.After(killGracePeriod): | ||
| 208 | - | _ = unix.Kill(-pid, unix.SIGKILL) | 225 | + | _ = signalProcessGroup(pid, syscall.SIGKILL) |
| 209 | <-reaped | 226 | <-reaped | ||
| 210 | } | 227 | } | ||
| 211 | 228 | ||||
| @@ -214,6 +231,13 @@ func (p *linuxPTY) Close() error { | |||||
| 214 | return p.closeErr | 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 | // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ. | 241 | // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ. | ||
| 218 | func setWinsize(fd int, sz domain.Size) error { | 242 | func setWinsize(fd int, sz domain.Size) error { | ||
| 219 | return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{ | 243 | return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{ | ||
M internal/adapters/pty/pty_linux_test.go +44 -7
| @@ -4,6 +4,7 @@ package pty_test | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bytes" | 6 | "bytes" | ||
| 7 | + | "context" | |||
| 7 | "errors" | 8 | "errors" | ||
| 8 | "io" | 9 | "io" | ||
| 9 | "os" | 10 | "os" | ||
| @@ -53,7 +54,7 @@ func TestOpen_ChildOutputToEOF(t *testing.T) { | |||||
| 53 | t.Skip("skipping pty integration test in -short mode") | 54 | t.Skip("skipping pty integration test in -short mode") | ||
| 54 | } | 55 | } | ||
| 55 | f := newFactory() | 56 | f := newFactory() | ||
| 56 | - | p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | 57 | + | p, err := f.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) |
| 57 | require.NoError(t, err) | 58 | require.NoError(t, err) | ||
| 58 | t.Cleanup(func() { _ = p.Close() }) | 59 | t.Cleanup(func() { _ = p.Close() }) | ||
| 59 | 60 | ||||
| @@ -70,7 +71,7 @@ func TestOpen_EchoRoundtrip(t *testing.T) { | |||||
| 70 | t.Skip("skipping pty integration test in -short mode") | 71 | t.Skip("skipping pty integration test in -short mode") | ||
| 71 | } | 72 | } | ||
| 72 | f := newFactory() | 73 | f := newFactory() | ||
| 73 | - | p, err := f.Open("cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | 74 | + | p, err := f.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) |
| 74 | require.NoError(t, err) | 75 | require.NoError(t, err) | ||
| 75 | t.Cleanup(func() { _ = p.Close() }) | 76 | t.Cleanup(func() { _ = p.Close() }) | ||
| 76 | 77 | ||||
| @@ -103,7 +104,7 @@ func TestForegroundPgid(t *testing.T) { | |||||
| 103 | t.Skip("skipping pty integration test in -short mode") | 104 | t.Skip("skipping pty integration test in -short mode") | ||
| 104 | } | 105 | } | ||
| 105 | f := newFactory() | 106 | f := newFactory() | ||
| 106 | - | p, err := f.Open("sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | 107 | + | p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) |
| 107 | require.NoError(t, err) | 108 | require.NoError(t, err) | ||
| 108 | t.Cleanup(func() { _ = p.Close() }) | 109 | t.Cleanup(func() { _ = p.Close() }) | ||
| 109 | 110 | ||||
| @@ -141,7 +142,7 @@ func TestResize_SttySize(t *testing.T) { | |||||
| 141 | for _, tt := range tests { | 142 | for _, tt := range tests { | ||
| 142 | t.Run(tt.name, func(t *testing.T) { | 143 | t.Run(tt.name, func(t *testing.T) { | ||
| 143 | f := newFactory() | 144 | f := newFactory() | ||
| 144 | - | p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial) | 145 | + | p, err := f.Open(context.Background(), "sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial) |
| 145 | require.NoError(t, err) | 146 | require.NoError(t, err) | ||
| 146 | t.Cleanup(func() { _ = p.Close() }) | 147 | t.Cleanup(func() { _ = p.Close() }) | ||
| 147 | 148 | ||||
| @@ -160,7 +161,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) { | |||||
| 160 | t.Skip("skipping pty integration test in -short mode") | 161 | t.Skip("skipping pty integration test in -short mode") | ||
| 161 | } | 162 | } | ||
| 162 | f := newFactory() | 163 | f := newFactory() | ||
| 163 | - | p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | 164 | + | p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) |
| 164 | require.NoError(t, err) | 165 | require.NoError(t, err) | ||
| 165 | 166 | ||||
| 166 | pid := p.Pid() | 167 | pid := p.Pid() | ||
| @@ -191,7 +192,7 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) { | |||||
| 191 | t.Skip("skipping pty integration test in -short mode") | 192 | t.Skip("skipping pty integration test in -short mode") | ||
| 192 | } | 193 | } | ||
| 193 | f := newFactory() | 194 | f := newFactory() | ||
| 194 | - | p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | 195 | + | p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) |
| 195 | require.NoError(t, err) | 196 | require.NoError(t, err) | ||
| 196 | pid := p.Pid() | 197 | pid := p.Pid() | ||
| 197 | 198 | ||||
| @@ -204,6 +205,42 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) { | |||||
| 204 | }, 3*time.Second, 20*time.Millisecond) | 205 | }, 3*time.Second, 20*time.Millisecond) | ||
| 205 | } | 206 | } | ||
| 206 | 207 | ||||
| 208 | + | func TestOpen_ContextCancellationTerminatesProcessGroup(t *testing.T) { | |||
| 209 | + | if testing.Short() { | |||
| 210 | + | t.Skip("skipping pty integration test in -short mode") | |||
| 211 | + | } | |||
| 212 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 213 | + | defer cancel() | |||
| 214 | + | ||||
| 215 | + | // The background sleep ignores SIGHUP so killing only the session leader | |||
| 216 | + | // cannot accidentally pass via the terminal's hangup behavior. | |||
| 217 | + | p, err := newFactory().Open(ctx, "sh", []string{"-c", `(trap '' HUP; exec sleep 60) & child=$!; printf '%s\n' "$child"; wait "$child"`}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | |||
| 218 | + | require.NoError(t, err) | |||
| 219 | + | t.Cleanup(func() { _ = p.Close() }) | |||
| 220 | + | ||||
| 221 | + | buf := make([]byte, 64) | |||
| 222 | + | n, err := p.Read(buf) | |||
| 223 | + | require.NoError(t, err) | |||
| 224 | + | descendantPID, err := strconv.Atoi(strings.TrimSpace(string(buf[:n]))) | |||
| 225 | + | require.NoError(t, err) | |||
| 226 | + | ||||
| 227 | + | cancel() | |||
| 228 | + | ||||
| 229 | + | // The direct shell may remain as a zombie until Close reaps it; a zombie has | |||
| 230 | + | // exited, so it and the background sleep must not remain running. | |||
| 231 | + | require.Eventually(t, func() bool { | |||
| 232 | + | return processTerminated(p.Pid()) && processTerminated(descendantPID) | |||
| 233 | + | }, 3*time.Second, 20*time.Millisecond) | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func processTerminated(pid int) bool { | |||
| 237 | + | if errors.Is(syscall.Kill(pid, 0), syscall.ESRCH) { | |||
| 238 | + | return true | |||
| 239 | + | } | |||
| 240 | + | status, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status") | |||
| 241 | + | return err != nil || strings.Contains(string(status), "State:\tZ") | |||
| 242 | + | } | |||
| 243 | + | ||||
| 207 | func TestResize_DeliversSIGWINCH(t *testing.T) { | 244 | func TestResize_DeliversSIGWINCH(t *testing.T) { | ||
| 208 | if testing.Short() { | 245 | if testing.Short() { | ||
| 209 | t.Skip("skipping pty integration test in -short mode") | 246 | t.Skip("skipping pty integration test in -short mode") | ||
| @@ -212,7 +249,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) { | |||||
| 212 | // deliver SIGWINCH to the foreground process group. | 249 | // deliver SIGWINCH to the foreground process group. | ||
| 213 | script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done` | 250 | script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done` | ||
| 214 | f := newFactory() | 251 | f := newFactory() | ||
| 215 | - | p, err := f.Open("sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | 252 | + | p, err := f.Open(context.Background(), "sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) |
| 216 | require.NoError(t, err) | 253 | require.NoError(t, err) | ||
| 217 | t.Cleanup(func() { _ = p.Close() }) | 254 | t.Cleanup(func() { _ = p.Close() }) | ||
| 218 | 255 | ||||
M internal/adapters/remote/remote_factory.go +19 -8
| @@ -11,35 +11,46 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | // DialerFactory selects the requested remote transport adapter. | 13 | // DialerFactory selects the requested remote transport adapter. | ||
| 14 | - | type DialerFactory struct{} | 14 | + | type DialerFactory struct { |
| 15 | + | observer ports.SerializedRuntimeObserver | |||
| 16 | + | } | |||
| 15 | 17 | ||||
| 16 | var _ ports.RemoteDialerFactory = DialerFactory{} | 18 | var _ ports.RemoteDialerFactory = DialerFactory{} | ||
| 17 | 19 | ||||
| 18 | func NewDialerFactory() DialerFactory { return DialerFactory{} } | 20 | func NewDialerFactory() DialerFactory { return DialerFactory{} } | ||
| 19 | 21 | ||||
| 20 | - | func (DialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) { | 22 | + | // NewDialerFactoryWithRuntimeObserver wires the one process-local sink into |
| 23 | + | // whichever concrete remote carriage adapter is selected. | |||
| 24 | + | func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserver) DialerFactory { | |||
| 25 | + | return DialerFactory{observer: observer} | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (f DialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) { | |||
| 21 | switch mode { | 29 | switch mode { | ||
| 22 | case ports.RemoteTransportUDP: | 30 | case ports.RemoteTransportUDP: | ||
| 23 | if log != nil { | 31 | if log != nil { | ||
| 24 | log.Info("remote transport selected", "mode", mode, "target", target, "session", session) | 32 | log.Info("remote transport selected", "mode", mode, "target", target, "session", session) | ||
| 25 | } | 33 | } | ||
| 26 | - | return dgram.NewRemoteDialerWithLogger(target, session, log), nil | 34 | + | dialer := dgram.NewRemoteDialerWithLogger(target, session, log) |
| 35 | + | dialer.RuntimeObserver = f.observer | |||
| 36 | + | return dialer, nil | |||
| 27 | case ports.RemoteTransportStdio: | 37 | case ports.RemoteTransportStdio: | ||
| 28 | if log != nil { | 38 | if log != nil { | ||
| 29 | log.Info("remote transport selected", "mode", mode, "target", target, "session", session) | 39 | log.Info("remote transport selected", "mode", mode, "target", target, "session", session) | ||
| 30 | } | 40 | } | ||
| 31 | - | return stdioDialer{target: target, session: session, log: log}, nil | 41 | + | return stdioDialer{target: target, session: session, log: log, observer: f.observer}, nil |
| 32 | default: | 42 | default: | ||
| 33 | return nil, fmt.Errorf("vev: unsupported remote transport %q", mode) | 43 | return nil, fmt.Errorf("vev: unsupported remote transport %q", mode) | ||
| 34 | } | 44 | } | ||
| 35 | } | 45 | } | ||
| 36 | 46 | ||||
| 37 | type stdioDialer struct { | 47 | type stdioDialer struct { | ||
| 38 | - | target string | 48 | + | target string |
| 39 | - | session string | 49 | + | session string |
| 40 | - | log *slog.Logger | 50 | + | log *slog.Logger |
| 51 | + | observer ports.SerializedRuntimeObserver | |||
| 41 | } | 52 | } | ||
| 42 | 53 | ||||
| 43 | func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) { | 54 | func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) { | ||
| 44 | - | return sshstdio.DialContext(ctx, d.target, d.session, d.log) | 55 | + | return sshstdio.DialContextWithRuntimeObserver(ctx, d.target, d.session, d.log, sshstdio.WithRuntimeObserver(d.observer)) |
| 45 | } | 56 | } | ||
M internal/adapters/sshstdio/transport.go +128 -15
| @@ -34,12 +34,26 @@ var ( | |||||
| 34 | type closeFunc func() error | 34 | type closeFunc func() error | ||
| 35 | type eofErrFunc func() error | 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 | // NewTransport wraps separate reader/writer streams as a framed Transport. | 45 | // NewTransport wraps separate reader/writer streams as a framed Transport. | ||
| 38 | - | func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc) ports.Transport { | 46 | + | func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc, opts ...Option) ports.Transport { |
| 39 | - | return newTransport(r, w, closeFn, nil) | 47 | + | t := newTransport(r, w, closeFn, nil) |
| 48 | + | for _, opt := range opts { | |||
| 49 | + | if opt != nil { | |||
| 50 | + | opt(t) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | return t | |||
| 40 | } | 54 | } | ||
| 41 | 55 | ||||
| 42 | - | func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc) ports.Transport { | 56 | + | func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc) *transport { |
| 43 | if closeFn == nil { | 57 | if closeFn == nil { | ||
| 44 | closeFn = func() error { return nil } | 58 | closeFn = func() error { return nil } | ||
| 45 | } | 59 | } | ||
| @@ -47,18 +61,25 @@ func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc | |||||
| 47 | } | 61 | } | ||
| 48 | 62 | ||||
| 49 | type transport struct { | 63 | type transport struct { | ||
| 50 | - | r io.Reader | 64 | + | r io.Reader |
| 51 | - | w io.Writer | 65 | + | w io.Writer |
| 52 | - | close closeFunc | 66 | + | close closeFunc |
| 53 | - | eofErr eofErrFunc | 67 | + | eofErr eofErrFunc |
| 68 | + | observer ports.SerializedRuntimeObserver | |||
| 69 | + | operationMu sync.Mutex | |||
| 70 | + | operationCount int | |||
| 71 | + | closing bool | |||
| 72 | + | operationsDone chan struct{} | |||
| 54 | 73 | ||||
| 55 | mu sync.Mutex | 74 | mu sync.Mutex | ||
| 56 | readBuf []byte | 75 | readBuf []byte | ||
| 57 | } | 76 | } | ||
| 58 | 77 | ||||
| 59 | func (t *transport) Send(f ports.Frame) error { | 78 | func (t *transport) Send(f ports.Frame) error { | ||
| 79 | + | end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload))) | |||
| 60 | n := 1 + len(f.Payload) | 80 | n := 1 + len(f.Payload) | ||
| 61 | if n > maxFrameLen { | 81 | if n > maxFrameLen { | ||
| 82 | + | end(false) | |||
| 62 | return ErrFrameTooLarge | 83 | return ErrFrameTooLarge | ||
| 63 | } | 84 | } | ||
| 64 | 85 | ||||
| @@ -68,22 +89,28 @@ func (t *transport) Send(f ports.Frame) error { | |||||
| 68 | copy(buf[frameHeaderLen+1:], f.Payload) | 89 | copy(buf[frameHeaderLen+1:], f.Payload) | ||
| 69 | 90 | ||||
| 70 | t.mu.Lock() | 91 | t.mu.Lock() | ||
| 71 | - | defer t.mu.Unlock() | |||
| 72 | _, err := t.w.Write(buf) | 92 | _, err := t.w.Write(buf) | ||
| 93 | + | t.mu.Unlock() | |||
| 94 | + | end(err == nil) | |||
| 73 | return err | 95 | return err | ||
| 74 | } | 96 | } | ||
| 75 | 97 | ||||
| 76 | func (t *transport) Recv() (ports.Frame, error) { | 98 | func (t *transport) Recv() (ports.Frame, error) { | ||
| 99 | + | end := t.beginOperation(ports.RuntimeAdapterReceiveStart, 0) | |||
| 77 | var hdr [frameHeaderLen]byte | 100 | var hdr [frameHeaderLen]byte | ||
| 78 | if _, err := io.ReadFull(t.r, hdr[:]); err != nil { | 101 | if _, err := io.ReadFull(t.r, hdr[:]); err != nil { | ||
| 79 | - | return ports.Frame{}, t.mapEOFError(err) | 102 | + | err = t.mapEOFError(err) |
| 103 | + | end(false) | |||
| 104 | + | return ports.Frame{}, err | |||
| 80 | } | 105 | } | ||
| 81 | 106 | ||||
| 82 | n := binary.BigEndian.Uint32(hdr[:]) | 107 | n := binary.BigEndian.Uint32(hdr[:]) | ||
| 83 | if n == 0 { | 108 | if n == 0 { | ||
| 109 | + | end(false) | |||
| 84 | return ports.Frame{}, ErrZeroLengthFrame | 110 | return ports.Frame{}, ErrZeroLengthFrame | ||
| 85 | } | 111 | } | ||
| 86 | if n > maxFrameLen { | 112 | if n > maxFrameLen { | ||
| 113 | + | end(false) | |||
| 87 | return ports.Frame{}, ErrFrameTooLarge | 114 | return ports.Frame{}, ErrFrameTooLarge | ||
| 88 | } | 115 | } | ||
| 89 | 116 | ||||
| @@ -93,13 +120,35 @@ func (t *transport) Recv() (ports.Frame, error) { | |||||
| 93 | t.readBuf = t.readBuf[:n] | 120 | t.readBuf = t.readBuf[:n] | ||
| 94 | } | 121 | } | ||
| 95 | if _, err := io.ReadFull(t.r, t.readBuf); err != nil { | 122 | if _, err := io.ReadFull(t.r, t.readBuf); err != nil { | ||
| 96 | - | return ports.Frame{}, t.mapEOFError(err) | 123 | + | err = t.mapEOFError(err) |
| 124 | + | end(false) | |||
| 125 | + | return ports.Frame{}, err | |||
| 97 | } | 126 | } | ||
| 98 | 127 | ||||
| 99 | payload := append([]byte(nil), t.readBuf[1:]...) | 128 | payload := append([]byte(nil), t.readBuf[1:]...) | ||
| 129 | + | end(true) | |||
| 100 | return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil | 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 | func (t *transport) mapEOFError(err error) error { | 152 | func (t *transport) mapEOFError(err error) error { | ||
| 104 | if t.eofErr == nil || (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) { | 153 | if t.eofErr == nil || (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) { | ||
| 105 | return err | 154 | return err | ||
| @@ -110,7 +159,56 @@ func (t *transport) mapEOFError(err error) error { | |||||
| 110 | return err | 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 | type processWaiter struct { | 213 | type processWaiter struct { | ||
| 116 | cmd *exec.Cmd | 214 | cmd *exec.Cmd | ||
| @@ -222,18 +320,27 @@ func Dial(target, session string) (ports.Transport, error) { | |||||
| 222 | return DialContext(context.Background(), target, session) | 320 | return DialContext(context.Background(), target, session) | ||
| 223 | } | 321 | } | ||
| 224 | 322 | ||||
| 323 | + | // DialContextWithRuntimeObserver is the option-bearing production constructor. | |||
| 324 | + | func DialContextWithRuntimeObserver(ctx context.Context, target, session string, logger *slog.Logger, opts ...Option) (ports.Transport, error) { | |||
| 325 | + | return dialContext(ctx, target, session, logger, opts...) | |||
| 326 | + | } | |||
| 327 | + | ||||
| 225 | // DialContext is like Dial, but the context is propagated to ssh startup so a | 328 | // DialContext is like Dial, but the context is propagated to ssh startup so a | ||
| 226 | // canceled attach attempt interrupts the local ssh process. Callers may pass a | 329 | // canceled attach attempt interrupts the local ssh process. Callers may pass a | ||
| 227 | // logger to record ssh start failures and non-clean exits without logging the | 330 | // logger to record ssh start failures and non-clean exits without logging the | ||
| 228 | // generated command line. | 331 | // generated command line. | ||
| 229 | func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) { | 332 | func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) { | ||
| 230 | - | if err := ctx.Err(); err != nil { | |||
| 231 | - | return nil, err | |||
| 232 | - | } | |||
| 233 | var log *slog.Logger | 333 | var log *slog.Logger | ||
| 234 | if len(logger) > 0 { | 334 | if len(logger) > 0 { | ||
| 235 | log = logger[0] | 335 | log = logger[0] | ||
| 236 | } | 336 | } | ||
| 337 | + | return dialContext(ctx, target, session, log) | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | func dialContext(ctx context.Context, target, session string, log *slog.Logger, opts ...Option) (ports.Transport, error) { | |||
| 341 | + | if err := ctx.Err(); err != nil { | |||
| 342 | + | return nil, err | |||
| 343 | + | } | |||
| 237 | spec := BuildCommand(target, session) | 344 | spec := BuildCommand(target, session) | ||
| 238 | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | 345 | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | ||
| 239 | stdin, err := cmd.StdinPipe() | 346 | stdin, err := cmd.StdinPipe() | ||
| @@ -254,5 +361,11 @@ func DialContext(ctx context.Context, target, session string, logger ...*slog.Lo | |||||
| 254 | } | 361 | } | ||
| 255 | 362 | ||||
| 256 | waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session) | 363 | waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session) | ||
| 257 | - | return newTransport(stdout, stdin, waiter.close, waiter.eofErr), 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 | // attach dials, handshakes, and returns the transport plus its frame pump. | 74 | // attach dials, handshakes, and returns the transport plus its frame pump. | ||
| 75 | func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (ports.Transport, *pump) { | 75 | func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (ports.Transport, *pump) { | ||
| 76 | t.Helper() | 76 | t.Helper() | ||
| 77 | - | tr, err := ipc.Dial(dir) | 77 | + | tr, err := ipc.DialContext(context.Background(), dir) |
| 78 | require.NoError(t, err) | 78 | require.NoError(t, err) | ||
| 79 | hello := ports.Hello{Version: ports.ProtocolVersion, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color"} | 79 | hello := ports.Hello{Version: ports.ProtocolVersion, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color"} | ||
| 80 | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})) | 80 | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})) | ||
| @@ -91,7 +91,7 @@ func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) | |||||
| 91 | 91 | ||||
| 92 | func listRemoteSessions(t *testing.T, dir string) ports.Sessions { | 92 | func listRemoteSessions(t *testing.T, dir string) ports.Sessions { | ||
| 93 | t.Helper() | 93 | t.Helper() | ||
| 94 | - | tr, err := ipc.Dial(dir) | 94 | + | tr, err := ipc.DialContext(context.Background(), dir) |
| 95 | require.NoError(t, err) | 95 | require.NoError(t, err) | ||
| 96 | defer func() { _ = tr.Close() }() | 96 | defer func() { _ = tr.Close() }() | ||
| 97 | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})})) | 97 | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})})) | ||
| @@ -104,7 +104,7 @@ func listRemoteSessions(t *testing.T, dir string) ports.Sessions { | |||||
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | func killAll(dir string) error { | 106 | func killAll(dir string) error { | ||
| 107 | - | tr, err := ipc.Dial(dir) | 107 | + | tr, err := ipc.DialContext(context.Background(), dir) |
| 108 | if err != nil { | 108 | if err != nil { | ||
| 109 | return err | 109 | return err | ||
| 110 | } | 110 | } | ||
| @@ -248,7 +248,7 @@ func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) { | |||||
| 248 | text = awaitScreenText(t, p, sz, " work ") | 248 | text = awaitScreenText(t, p, sz, " work ") | ||
| 249 | require.NotContains(t, text, "work*") | 249 | require.NotContains(t, text, "work*") | ||
| 250 | 250 | ||||
| 251 | - | listTr, err := ipc.Dial(dir) | 251 | + | listTr, err := ipc.DialContext(context.Background(), dir) |
| 252 | require.NoError(t, err) | 252 | require.NoError(t, err) | ||
| 253 | defer func() { _ = listTr.Close() }() | 253 | defer func() { _ = listTr.Close() }() | ||
| 254 | require.NoError(t, listTr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})})) | 254 | require.NoError(t, listTr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})})) | ||
| @@ -367,7 +367,7 @@ func TestIntegration_KillAllShutsDownDaemon(t *testing.T) { | |||||
| 367 | tr2, _ := attach(t, dir, ports.IntentNew, "two", sz) | 367 | tr2, _ := attach(t, dir, ports.IntentNew, "two", sz) | ||
| 368 | require.NoError(t, tr2.Close()) | 368 | require.NoError(t, tr2.Close()) | ||
| 369 | 369 | ||||
| 370 | - | killTr, err := ipc.Dial(dir) | 370 | + | killTr, err := ipc.DialContext(context.Background(), dir) |
| 371 | require.NoError(t, err) | 371 | require.NoError(t, err) | ||
| 372 | defer func() { _ = killTr.Close() }() | 372 | defer func() { _ = killTr.Close() }() | ||
| 373 | require.NoError(t, killTr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})})) | 373 | require.NoError(t, killTr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})})) | ||
M internal/app/run.go +175 -23
| @@ -22,6 +22,7 @@ import ( | |||||
| 22 | "path/filepath" | 22 | "path/filepath" | ||
| 23 | "strconv" | 23 | "strconv" | ||
| 24 | "strings" | 24 | "strings" | ||
| 25 | + | "sync" | |||
| 25 | "syscall" | 26 | "syscall" | ||
| 26 | "text/tabwriter" | 27 | "text/tabwriter" | ||
| 27 | "time" | 28 | "time" | ||
| @@ -31,6 +32,7 @@ import ( | |||||
| 31 | "github.com/bnema/vev/internal/adapters/config" | 32 | "github.com/bnema/vev/internal/adapters/config" | ||
| 32 | "github.com/bnema/vev/internal/adapters/dgram" | 33 | "github.com/bnema/vev/internal/adapters/dgram" | ||
| 33 | "github.com/bnema/vev/internal/adapters/ipc" | 34 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 35 | + | "github.com/bnema/vev/internal/adapters/observability" | |||
| 34 | "github.com/bnema/vev/internal/adapters/pty" | 36 | "github.com/bnema/vev/internal/adapters/pty" | ||
| 35 | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | 37 | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | ||
| 36 | "github.com/bnema/vev/internal/adapters/shellcmd" | 38 | "github.com/bnema/vev/internal/adapters/shellcmd" | ||
| @@ -238,6 +240,106 @@ func dispatch(ctx context.Context, cmd command) error { | |||||
| 238 | } | 240 | } | ||
| 239 | } | 241 | } | ||
| 240 | 242 | ||||
| 243 | + | // performanceTrace creates one serialized timestamp owner for this process. | |||
| 244 | + | // An empty trace environment leaves all production behavior and wire bytes | |||
| 245 | + | // unchanged. | |||
| 246 | + | // Composition-root factory seams keep observer propagation testable without | |||
| 247 | + | // opening real transports. | |||
| 248 | + | var ( | |||
| 249 | + | newPerformanceTrace = performanceTrace | |||
| 250 | + | newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { | |||
| 251 | + | return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer) | |||
| 252 | + | } | |||
| 253 | + | runClientWithRuntimeObserver = client.RunWithRuntimeObserver | |||
| 254 | + | ) | |||
| 255 | + | ||||
| 256 | + | func performanceTrace(clk ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) { | |||
| 257 | + | return performanceTraceWithFactories(clk, observability.NewJSONL, ports.NewRuntimeCorrelationObserver) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // performanceTraceWithFactories keeps setup rollback behavior directly | |||
| 261 | + | // testable without requiring an operating-system file close failure. | |||
| 262 | + | func performanceTraceWithFactories( | |||
| 263 | + | clk ports.Clock, | |||
| 264 | + | newSink func(string, ports.Clock, string) (ports.RuntimeObserver, io.Closer, error), | |||
| 265 | + | newCorrelation func(ports.RuntimeObserver, ports.RuntimeCorrelationInputs) (ports.RuntimeObserver, error), | |||
| 266 | + | ) (ports.SerializedRuntimeObserver, io.Closer, error) { | |||
| 267 | + | path, processID := os.Getenv("VEV_PERF_TRACE"), os.Getenv("VEV_PERF_PROCESS_ID") | |||
| 268 | + | if path == "" { | |||
| 269 | + | return nil, nil, nil | |||
| 270 | + | } | |||
| 271 | + | observer, closer, err := newSink(path, clk, processID) | |||
| 272 | + | if err != nil { | |||
| 273 | + | return nil, nil, err | |||
| 274 | + | } | |||
| 275 | + | // The harness supplies these manifest fields to every launched role. Keep a | |||
| 276 | + | // valid standalone trace for operators that set only the original trace | |||
| 277 | + | // variables, while ensuring harness traces match their process mapping. | |||
| 278 | + | inputs := ports.RuntimeCorrelationInputs{Scenario: os.Getenv("VEV_PERF_SCENARIO"), Run: 1} | |||
| 279 | + | if inputs.Scenario == "" { | |||
| 280 | + | inputs.Scenario = "runtime" | |||
| 281 | + | } | |||
| 282 | + | if rawRun := os.Getenv("VEV_PERF_RUN"); rawRun != "" { | |||
| 283 | + | inputs.Run, err = strconv.ParseUint(rawRun, 10, 64) | |||
| 284 | + | if err != nil { | |||
| 285 | + | setupErr := fmt.Errorf("invalid VEV_PERF_RUN %q: %w", rawRun, err) | |||
| 286 | + | return nil, nil, closeTraceAfterSetupFailure(closer, setupErr) | |||
| 287 | + | } | |||
| 288 | + | if inputs.Run == 0 { | |||
| 289 | + | setupErr := fmt.Errorf("invalid VEV_PERF_RUN %q", rawRun) | |||
| 290 | + | return nil, nil, closeTraceAfterSetupFailure(closer, setupErr) | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | observer, err = newCorrelation(observer, inputs) | |||
| 294 | + | if err != nil { | |||
| 295 | + | setupErr := fmt.Errorf("configure runtime trace correlation: %w", err) | |||
| 296 | + | return nil, nil, closeTraceAfterSetupFailure(closer, setupErr) | |||
| 297 | + | } | |||
| 298 | + | reporter := ports.NewSerializedRuntimeObserver(observer, runtimeTraceQueueDepth) | |||
| 299 | + | return reporter, &runtimeTraceCloser{reporter: reporter, closer: closer}, nil | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | func closeTraceAfterSetupFailure(closer io.Closer, setupErr error) error { | |||
| 303 | + | if closer == nil { | |||
| 304 | + | return setupErr | |||
| 305 | + | } | |||
| 306 | + | if closeErr := closer.Close(); closeErr != nil { | |||
| 307 | + | return errors.Join(setupErr, fmt.Errorf("close performance trace after setup failure: %w", closeErr)) | |||
| 308 | + | } | |||
| 309 | + | return setupErr | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // runtimeTraceQueueDepth bounds all process-local trace producer handoffs. | |||
| 313 | + | // A full queue emits the serialized diagnostic gap rather than delaying a | |||
| 314 | + | // terminal, transport, or ACK progress path. | |||
| 315 | + | const runtimeTraceQueueDepth = 256 | |||
| 316 | + | ||||
| 317 | + | // runtimeTraceCloser owns the two-stage trace shutdown: drain the reporter | |||
| 318 | + | // before closing the concrete timestamp/file owner. sync.Once makes every | |||
| 319 | + | // deferred and explicit close path safe without closing shared workers twice. | |||
| 320 | + | type runtimeTraceCloser struct { | |||
| 321 | + | reporter ports.SerializedRuntimeObserver | |||
| 322 | + | closer io.Closer | |||
| 323 | + | once sync.Once | |||
| 324 | + | err error | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | func (c *runtimeTraceCloser) Close() error { | |||
| 328 | + | if c == nil { | |||
| 329 | + | return nil | |||
| 330 | + | } | |||
| 331 | + | c.once.Do(func() { | |||
| 332 | + | if c.reporter != nil { | |||
| 333 | + | c.reporter.Flush() | |||
| 334 | + | c.reporter.Close() | |||
| 335 | + | } | |||
| 336 | + | if c.closer != nil { | |||
| 337 | + | c.err = c.closer.Close() | |||
| 338 | + | } | |||
| 339 | + | }) | |||
| 340 | + | return c.err | |||
| 341 | + | } | |||
| 342 | + | ||||
| 241 | func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.Logger, io.Closer, error) { | 343 | func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.Logger, io.Closer, error) { | ||
| 242 | return logging.Setup(logging.Config{ | 344 | return logging.Setup(logging.Config{ | ||
| 243 | Dir: platform.StateDir(), | 345 | Dir: platform.StateDir(), | ||
| @@ -278,14 +380,23 @@ func pprofAddrIsLoopback(addr string) bool { | |||||
| 278 | // entered by an auto-spawned child): it sets up logging, binds the socket, | 380 | // entered by an auto-spawned child): it sets up logging, binds the socket, | ||
| 279 | // constructs the daemon, and serves until the last session exits or a | 381 | // constructs the daemon, and serves until the last session exits or a | ||
| 280 | // termination signal arrives (graceful shutdown notifies attached clients). | 382 | // termination signal arrives (graceful shutdown notifies attached clients). | ||
| 281 | - | func runDaemon() error { | 383 | + | func runDaemon() (retErr error) { |
| 282 | log, logCloser, err := configureLogging(logging.Daemon, true) | 384 | log, logCloser, err := configureLogging(logging.Daemon, true) | ||
| 283 | if err != nil { | 385 | if err != nil { | ||
| 284 | return err | 386 | return err | ||
| 285 | } | 387 | } | ||
| 286 | defer func() { _ = logCloser.Close() }() | 388 | defer func() { _ = logCloser.Close() }() | ||
| 287 | 389 | ||||
| 288 | - | ln, err := ipc.Listen(ipc.SocketDir()) | 390 | + | clk := clock.New() |
| 391 | + | observer, observerCloser, err := newPerformanceTrace(clk) | |||
| 392 | + | if err != nil { | |||
| 393 | + | return fmt.Errorf("vev: performance trace: %w", err) | |||
| 394 | + | } | |||
| 395 | + | if observerCloser != nil { | |||
| 396 | + | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | |||
| 397 | + | } | |||
| 398 | + | // Every accepted local connection shares this process's serialized sink. | |||
| 399 | + | ln, err := ipc.Listen(ipc.SocketDir(), ipc.WithRuntimeObserver(observer)) | |||
| 289 | if err != nil { | 400 | if err != nil { | ||
| 290 | log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err) | 401 | log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err) | ||
| 291 | return fmt.Errorf("vev: daemon listen: %w", err) | 402 | return fmt.Errorf("vev: daemon listen: %w", err) | ||
| @@ -309,6 +420,9 @@ func runDaemon() error { | |||||
| 309 | 420 | ||||
| 310 | log.Info("daemon starting", "socket", ln.Addr()) | 421 | log.Info("daemon starting", "socket", ln.Addr()) | ||
| 311 | daemonOpts := []daemon.Option(nil) | 422 | daemonOpts := []daemon.Option(nil) | ||
| 423 | + | if observer != nil { | |||
| 424 | + | daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer)) | |||
| 425 | + | } | |||
| 312 | configPath := platform.ConfigPath() | 426 | configPath := platform.ConfigPath() | ||
| 313 | cfg, warnings, err := config.Load(configPath) | 427 | cfg, warnings, err := config.Load(configPath) | ||
| 314 | if err != nil { | 428 | if err != nil { | ||
| @@ -327,7 +441,6 @@ func runDaemon() error { | |||||
| 327 | log.Info("session persistence enabled", "path", storePath) | 441 | log.Info("session persistence enabled", "path", storePath) | ||
| 328 | daemonOpts = append(daemonOpts, daemon.WithStore(store)) | 442 | daemonOpts = append(daemonOpts, daemon.WithStore(store)) | ||
| 329 | } | 443 | } | ||
| 330 | - | clk := clock.New() | |||
| 331 | d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...) | 444 | d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...) | ||
| 332 | watchCtx, stopWatch := context.WithCancel(ctx) | 445 | watchCtx, stopWatch := context.WithCancel(ctx) | ||
| 333 | defer stopWatch() | 446 | defer stopWatch() | ||
| @@ -350,20 +463,32 @@ func runDaemon() error { | |||||
| 350 | // runAttach dials (auto-spawning the daemon if needed) and runs the client | 463 | // runAttach dials (auto-spawning the daemon if needed) and runs the client | ||
| 351 | // attach loop. Logging goes to the shared file: the client must never write | 464 | // attach loop. Logging goes to the shared file: the client must never write | ||
| 352 | // to the console while the terminal is raw. | 465 | // to the console while the terminal is raw. | ||
| 353 | - | func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error { | 466 | + | func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (retErr error) { |
| 354 | log, logCloser, err := configureLogging(logging.Client, false) | 467 | log, logCloser, err := configureLogging(logging.Client, false) | ||
| 355 | if err != nil { | 468 | if err != nil { | ||
| 356 | return err | 469 | return err | ||
| 357 | } | 470 | } | ||
| 358 | defer func() { _ = logCloser.Close() }() | 471 | defer func() { _ = logCloser.Close() }() | ||
| 359 | 472 | ||||
| 473 | + | clk := clock.New() | |||
| 474 | + | observer, observerCloser, err := newPerformanceTrace(clk) | |||
| 475 | + | if err != nil { | |||
| 476 | + | return fmt.Errorf("vev: performance trace: %w", err) | |||
| 477 | + | } | |||
| 478 | + | if observerCloser != nil { | |||
| 479 | + | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | |||
| 480 | + | } | |||
| 360 | return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{ | 481 | return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{ | ||
| 361 | - | localDialer: defaultLocalDialer, | 482 | + | localDialer: func() ports.Dialer { |
| 362 | - | remoteDialerFactory: defaultRemoteDialerFactory(), | 483 | + | return localDaemonDialer{dir: ipc.SocketDir(), observer: observer} |
| 484 | + | }, | |||
| 485 | + | remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer), | |||
| 363 | selectedRemoteTransport: os.Getenv(envRemoteTransport), | 486 | selectedRemoteTransport: os.Getenv(envRemoteTransport), | ||
| 364 | - | runClient: client.Run, | 487 | + | runClient: func(ctx context.Context, dialer ports.Dialer, terminal ports.Terminal, behaviorClock ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) error { |
| 365 | - | createDetached: createDetachedLocalSession, | 488 | + | return runClientWithRuntimeObserver(ctx, dialer, terminal, behaviorClock, intent, name, remote, clipboard, log, observer) |
| 366 | - | clipboard: clipboard.New(), | 489 | + | }, |
| 490 | + | createDetached: createDetachedLocalSession, | |||
| 491 | + | clipboard: clipboard.New(), | |||
| 367 | }) | 492 | }) | ||
| 368 | } | 493 | } | ||
| 369 | 494 | ||||
| @@ -499,10 +624,19 @@ func isDaemonVersionDrift(err error) bool { | |||||
| 499 | return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal | 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 | func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) { | 632 | func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) { | ||
| 505 | - | return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff) | 633 | + | dial := realDial |
| 634 | + | if d.observer != nil { | |||
| 635 | + | dial = func(ctx context.Context, dir string) (ports.Transport, error) { | |||
| 636 | + | return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(d.observer)) | |||
| 637 | + | } | |||
| 638 | + | } | |||
| 639 | + | return ensureDaemon(ctx, d.dir, dial, realSpawn, defaultBackoff) | |||
| 506 | } | 640 | } | ||
| 507 | 641 | ||||
| 508 | func detachedLocalHello(name, cwd string) ports.Hello { | 642 | func detachedLocalHello(name, cwd string) ports.Hello { | ||
| @@ -584,7 +718,7 @@ func requestDaemonStop(ctx context.Context) error { | |||||
| 584 | 718 | ||||
| 585 | done := make(chan error, 1) | 719 | done := make(chan error, 1) | ||
| 586 | go func() { | 720 | go func() { | ||
| 587 | - | transport, err := realDial(ipc.SocketDir()) | 721 | + | transport, err := realDial(ctx, ipc.SocketDir()) |
| 588 | if err != nil { | 722 | if err != nil { | ||
| 589 | done <- fmt.Errorf("vev: no daemon running") | 723 | done <- fmt.Errorf("vev: no daemon running") | ||
| 590 | return | 724 | return | ||
| @@ -621,7 +755,7 @@ func requestDaemonStop(ctx context.Context) error { | |||||
| 621 | // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it | 755 | // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it | ||
| 622 | // connects to the per-user daemon (auto-spawning it if needed) and proxies the | 756 | // connects to the per-user daemon (auto-spawning it if needed) and proxies the | ||
| 623 | // framed protocol between process stdio and the daemon socket. | 757 | // framed protocol between process stdio and the daemon socket. | ||
| 624 | - | func runStdio(ctx context.Context) error { | 758 | + | func runStdio(ctx context.Context) (retErr error) { |
| 625 | log, logCloser, err := configureLogging(logging.Stdio, false) | 759 | log, logCloser, err := configureLogging(logging.Stdio, false) | ||
| 626 | if err != nil { | 760 | if err != nil { | ||
| 627 | return err | 761 | return err | ||
| @@ -629,13 +763,22 @@ func runStdio(ctx context.Context) error { | |||||
| 629 | defer func() { _ = logCloser.Close() }() | 763 | defer func() { _ = logCloser.Close() }() | ||
| 630 | log.Debug("stdio proxy starting") | 764 | log.Debug("stdio proxy starting") | ||
| 631 | 765 | ||||
| 632 | - | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | 766 | + | clk := clock.New() |
| 767 | + | observer, observerCloser, err := newPerformanceTrace(clk) | |||
| 768 | + | if err != nil { | |||
| 769 | + | return fmt.Errorf("vev: performance trace: %w", err) | |||
| 770 | + | } | |||
| 771 | + | if observerCloser != nil { | |||
| 772 | + | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | |||
| 773 | + | } | |||
| 774 | + | transport, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) { | |||
| 775 | + | return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer)) | |||
| 776 | + | }, realSpawn, defaultBackoff) | |||
| 633 | if err != nil { | 777 | if err != nil { | ||
| 634 | return err | 778 | return err | ||
| 635 | } | 779 | } | ||
| 636 | defer func() { _ = transport.Close() }() | 780 | defer func() { _ = transport.Close() }() | ||
| 637 | - | 781 | + | stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil, sshstdio.WithRuntimeObserver(observer)) | |
| 638 | - | stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil) | |||
| 639 | return proxyTransports(ctx, stdio, transport, log) | 782 | return proxyTransports(ctx, stdio, transport, log) | ||
| 640 | } | 783 | } | ||
| 641 | 784 | ||||
| @@ -719,7 +862,7 @@ func runUDPBootstrap(ctx context.Context, session string) error { | |||||
| 719 | } | 862 | } | ||
| 720 | 863 | ||||
| 721 | // runUDPProxy is the detached long-lived remote-side datagram proxy. | 864 | // runUDPProxy is the detached long-lived remote-side datagram proxy. | ||
| 722 | - | func runUDPProxy(ctx context.Context, session string, ready io.Writer) error { | 865 | + | func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr error) { |
| 723 | log, logCloser, err := configureLogging(logging.Stdio, false) | 866 | log, logCloser, err := configureLogging(logging.Stdio, false) | ||
| 724 | if err != nil { | 867 | if err != nil { | ||
| 725 | return err | 868 | return err | ||
| @@ -727,6 +870,13 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error { | |||||
| 727 | defer func() { _ = logCloser.Close() }() | 870 | defer func() { _ = logCloser.Close() }() | ||
| 728 | log.Debug("udp proxy starting", "session", session) | 871 | log.Debug("udp proxy starting", "session", session) | ||
| 729 | 872 | ||||
| 873 | + | observer, observerCloser, err := newPerformanceTrace(clock.New()) | |||
| 874 | + | if err != nil { | |||
| 875 | + | return fmt.Errorf("vev: performance trace: %w", err) | |||
| 876 | + | } | |||
| 877 | + | if observerCloser != nil { | |||
| 878 | + | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | |||
| 879 | + | } | |||
| 730 | portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange)) | 880 | portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange)) | ||
| 731 | if err != nil { | 881 | if err != nil { | ||
| 732 | return err | 882 | return err | ||
| @@ -740,14 +890,16 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error { | |||||
| 740 | if _, err := rand.Read(key); err != nil { | 890 | if _, err := rand.Read(key); err != nil { | ||
| 741 | return err | 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 | if err != nil { | 896 | if err != nil { | ||
| 745 | return err | 897 | return err | ||
| 746 | } | 898 | } | ||
| 747 | defer func() { _ = daemonTr.Close() }() | 899 | defer func() { _ = daemonTr.Close() }() | ||
| 748 | udpOptions := udpProxyClientTransportOptions | 900 | udpOptions := udpProxyClientTransportOptions | ||
| 749 | udpOptions.Observe = dgram.DiagnosticLogObserver(log) | 901 | udpOptions.Observe = dgram.DiagnosticLogObserver(log) | ||
| 750 | - | dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions) | 902 | + | dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer)) |
| 751 | if err != nil { | 903 | if err != nil { | ||
| 752 | return err | 904 | return err | ||
| 753 | } | 905 | } | ||
| @@ -890,8 +1042,8 @@ func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger | |||||
| 890 | 1042 | ||||
| 891 | // runList prints the daemon's session listing. With no daemon running, it | 1043 | // runList prints the daemon's session listing. With no daemon running, it | ||
| 892 | // falls back to the persisted stopped-session records. | 1044 | // falls back to the persisted stopped-session records. | ||
| 893 | - | func runList(_ context.Context) error { | 1045 | + | func runList(ctx context.Context) error { |
| 894 | - | transport, err := realDial(ipc.SocketDir()) | 1046 | + | transport, err := realDial(ctx, ipc.SocketDir()) |
| 895 | if err != nil { | 1047 | if err != nil { | ||
| 896 | records, loadErr := persist.LoadReadOnly(platform.StateDir()) | 1048 | records, loadErr := persist.LoadReadOnly(platform.StateDir()) | ||
| 897 | if loadErr != nil { | 1049 | if loadErr != nil { | ||
| @@ -955,8 +1107,8 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||||
| 955 | } | 1107 | } | ||
| 956 | 1108 | ||||
| 957 | // runKill asks the daemon to terminate a named session, every session, or the daemon. | 1109 | // runKill asks the daemon to terminate a named session, every session, or the daemon. | ||
| 958 | - | func runKill(_ context.Context, name string, all, daemon bool) error { | 1110 | + | func runKill(ctx context.Context, name string, all, daemon bool) error { |
| 959 | - | transport, err := realDial(ipc.SocketDir()) | 1111 | + | transport, err := realDial(ctx, ipc.SocketDir()) |
| 960 | if err != nil { | 1112 | if err != nil { | ||
| 961 | if name != "" && !all && !daemon { | 1113 | if name != "" && !all && !daemon { | ||
| 962 | storePath := persist.StorePath(platform.StateDir()) | 1114 | storePath := persist.StorePath(platform.StateDir()) | ||
M internal/app/spawn.go +14 -5
| @@ -32,7 +32,7 @@ const staleLockAge = 10 * time.Second | |||||
| 32 | 32 | ||||
| 33 | // dialFunc dials the daemon socket in dir. Injected so tests can drive the | 33 | // dialFunc dials the daemon socket in dir. Injected so tests can drive the | ||
| 34 | // spawn/backoff logic without a real socket. | 34 | // spawn/backoff logic without a real socket. | ||
| 35 | - | type dialFunc func(dir string) (ports.Transport, error) | 35 | + | type dialFunc func(ctx context.Context, dir string) (ports.Transport, error) |
| 36 | 36 | ||||
| 37 | // spawnFunc launches a detached daemon process. Injected so tests never | 37 | // spawnFunc launches a detached daemon process. Injected so tests never | ||
| 38 | // re-exec a real binary. | 38 | // re-exec a real binary. | ||
| @@ -58,7 +58,10 @@ var defaultBackoff = backoffConfig{ | |||||
| 58 | // spawner via an mkdir lock (losers simply wait), then retry-dials with | 58 | // spawner via an mkdir lock (losers simply wait), then retry-dials with | ||
| 59 | // backoff until the socket is live or the budget expires. | 59 | // backoff until the socket is live or the budget expires. | ||
| 60 | func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) { | 60 | func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) { | ||
| 61 | - | if t, err := dial(dir); err == nil { | 61 | + | if err := ctx.Err(); err != nil { |
| 62 | + | return nil, err | |||
| 63 | + | } | |||
| 64 | + | if t, err := dial(ctx, dir); err == nil { | |||
| 62 | slog.Debug("daemon already reachable", "socket_dir", dir) | 65 | slog.Debug("daemon already reachable", "socket_dir", dir) | ||
| 63 | return t, nil | 66 | return t, nil | ||
| 64 | } | 67 | } | ||
| @@ -73,6 +76,9 @@ func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFun | |||||
| 73 | // rather than spawning a second daemon. | 76 | // rather than spawning a second daemon. | ||
| 74 | defer release() | 77 | defer release() | ||
| 75 | slog.Info("spawning daemon", "socket_dir", dir) | 78 | slog.Info("spawning daemon", "socket_dir", dir) | ||
| 79 | + | if err := ctx.Err(); err != nil { | |||
| 80 | + | return nil, err | |||
| 81 | + | } | |||
| 76 | if err := spawn(); err != nil { | 82 | if err := spawn(); err != nil { | ||
| 77 | slog.Error("daemon spawn failed", "err", err) | 83 | slog.Error("daemon spawn failed", "err", err) | ||
| 78 | return nil, fmt.Errorf("vev: spawning daemon: %w", err) | 84 | return nil, fmt.Errorf("vev: spawning daemon: %w", err) | ||
| @@ -120,7 +126,10 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig | |||||
| 120 | deadline := time.Now().Add(cfg.total) | 126 | deadline := time.Now().Add(cfg.total) | ||
| 121 | backoff := cfg.initial | 127 | backoff := cfg.initial | ||
| 122 | for { | 128 | for { | ||
| 123 | - | if t, err := dial(dir); err == nil { | 129 | + | if err := ctx.Err(); err != nil { |
| 130 | + | return nil, err | |||
| 131 | + | } | |||
| 132 | + | if t, err := dial(ctx, dir); err == nil { | |||
| 124 | return t, nil | 133 | return t, nil | ||
| 125 | } | 134 | } | ||
| 126 | if !time.Now().Before(deadline) { | 135 | if !time.Now().Before(deadline) { | ||
| @@ -139,8 +148,8 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig | |||||
| 139 | } | 148 | } | ||
| 140 | 149 | ||||
| 141 | // realDial is the production dialer. | 150 | // realDial is the production dialer. | ||
| 142 | - | func realDial(dir string) (ports.Transport, error) { | 151 | + | func realDial(ctx context.Context, dir string) (ports.Transport, error) { |
| 143 | - | return ipc.Dial(dir) | 152 | + | return ipc.DialContext(ctx, dir) |
| 144 | } | 153 | } | ||
| 145 | 154 | ||||
| 146 | // realSpawn re-execs this binary as a detached daemon: a new session | 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 | func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) { | 91 | func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) { | ||
| 92 | want := portsmocks.NewMockTransport(t) | 92 | want := portsmocks.NewMockTransport(t) | ||
| 93 | var calls atomic.Int32 | 93 | var calls atomic.Int32 | ||
| 94 | - | dial := func(string) (ports.Transport, error) { | 94 | + | dial := func(context.Context, string) (ports.Transport, error) { |
| 95 | if calls.Add(1) <= 3 { | 95 | if calls.Add(1) <= 3 { | ||
| 96 | return nil, errDialFailed | 96 | return nil, errDialFailed | ||
| 97 | } | 97 | } | ||
| @@ -111,7 +111,7 @@ func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) { | |||||
| 111 | } | 111 | } | ||
| 112 | 112 | ||||
| 113 | func TestRetryDialPermanentFailure(t *testing.T) { | 113 | func TestRetryDialPermanentFailure(t *testing.T) { | ||
| 114 | - | dial := func(string) (ports.Transport, error) { return nil, errDialFailed } | 114 | + | dial := func(context.Context, string) (ports.Transport, error) { return nil, errDialFailed } |
| 115 | _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff) | 115 | _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff) | ||
| 116 | if !errors.Is(err, ErrDaemonUnreachable) { | 116 | if !errors.Is(err, ErrDaemonUnreachable) { | ||
| 117 | t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err) | 117 | t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err) | ||
| @@ -121,18 +121,65 @@ func TestRetryDialPermanentFailure(t *testing.T) { | |||||
| 121 | func TestRetryDialContextCancelled(t *testing.T) { | 121 | func TestRetryDialContextCancelled(t *testing.T) { | ||
| 122 | ctx, cancel := context.WithCancel(context.Background()) | 122 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 123 | cancel() | 123 | cancel() | ||
| 124 | - | dial := func(string) (ports.Transport, error) { return nil, errDialFailed } | 124 | + | var calls atomic.Int32 |
| 125 | + | dial := func(context.Context, string) (ports.Transport, error) { | |||
| 126 | + | calls.Add(1) | |||
| 127 | + | return nil, errDialFailed | |||
| 128 | + | } | |||
| 125 | _, err := retryDial(ctx, t.TempDir(), dial, fastBackoff) | 129 | _, err := retryDial(ctx, t.TempDir(), dial, fastBackoff) | ||
| 126 | if !errors.Is(err, context.Canceled) { | 130 | if !errors.Is(err, context.Canceled) { | ||
| 127 | t.Fatalf("retryDial error = %v, want context.Canceled", err) | 131 | t.Fatalf("retryDial error = %v, want context.Canceled", err) | ||
| 128 | } | 132 | } | ||
| 133 | + | if calls.Load() != 0 { | |||
| 134 | + | t.Fatalf("dial called %d times for canceled context, want 0", calls.Load()) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestEnsureDaemonContextCancelledDoesNotDialOrSpawn(t *testing.T) { | |||
| 139 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 140 | + | cancel() | |||
| 141 | + | dial := func(context.Context, string) (ports.Transport, error) { | |||
| 142 | + | t.Fatal("ensureDaemon dialed with a canceled context") | |||
| 143 | + | return nil, nil | |||
| 144 | + | } | |||
| 145 | + | spawn := func() error { | |||
| 146 | + | t.Fatal("ensureDaemon spawned with a canceled context") | |||
| 147 | + | return nil | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | _, err := ensureDaemon(ctx, t.TempDir(), dial, spawn, fastBackoff) | |||
| 151 | + | if !errors.Is(err, context.Canceled) { | |||
| 152 | + | t.Fatalf("ensureDaemon error = %v, want context.Canceled", err) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestEnsureDaemonDoesNotSpawnWhenContextCancelsAfterFailedDial(t *testing.T) { | |||
| 157 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 158 | + | defer cancel() | |||
| 159 | + | var spawnCalls atomic.Int32 | |||
| 160 | + | dial := func(context.Context, string) (ports.Transport, error) { | |||
| 161 | + | cancel() | |||
| 162 | + | return nil, errDialFailed | |||
| 163 | + | } | |||
| 164 | + | spawn := func() error { | |||
| 165 | + | spawnCalls.Add(1) | |||
| 166 | + | return nil | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | _, err := ensureDaemon(ctx, filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff) | |||
| 170 | + | if !errors.Is(err, context.Canceled) { | |||
| 171 | + | t.Fatalf("ensureDaemon error = %v, want context.Canceled", err) | |||
| 172 | + | } | |||
| 173 | + | if got := spawnCalls.Load(); got != 0 { | |||
| 174 | + | t.Fatalf("spawn called %d times after canceled failed dial, want 0", got) | |||
| 175 | + | } | |||
| 129 | } | 176 | } | ||
| 130 | 177 | ||||
| 131 | func TestEnsureDaemonSpawnsThenDials(t *testing.T) { | 178 | func TestEnsureDaemonSpawnsThenDials(t *testing.T) { | ||
| 132 | dir := filepath.Join(t.TempDir(), "vev") | 179 | dir := filepath.Join(t.TempDir(), "vev") | ||
| 133 | want := portsmocks.NewMockTransport(t) | 180 | want := portsmocks.NewMockTransport(t) | ||
| 134 | var dialCalls, spawnCalls atomic.Int32 | 181 | var dialCalls, spawnCalls atomic.Int32 | ||
| 135 | - | dial := func(string) (ports.Transport, error) { | 182 | + | dial := func(context.Context, string) (ports.Transport, error) { |
| 136 | // First (pre-spawn) dial fails; the post-spawn retry succeeds. | 183 | // First (pre-spawn) dial fails; the post-spawn retry succeeds. | ||
| 137 | if dialCalls.Add(1) == 1 { | 184 | if dialCalls.Add(1) == 1 { | ||
| 138 | return nil, errDialFailed | 185 | return nil, errDialFailed | ||
| @@ -163,7 +210,7 @@ func TestEnsureDaemonSpawnsThenDials(t *testing.T) { | |||||
| 163 | func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) { | 210 | func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) { | ||
| 164 | want := portsmocks.NewMockTransport(t) | 211 | want := portsmocks.NewMockTransport(t) | ||
| 165 | var spawnCalls atomic.Int32 | 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 | spawn := func() error { spawnCalls.Add(1); return nil } | 214 | spawn := func() error { spawnCalls.Add(1); return nil } | ||
| 168 | 215 | ||||
| 169 | got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff) | 216 | got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff) | ||
| @@ -180,7 +227,7 @@ func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) { | |||||
| 180 | 227 | ||||
| 181 | func TestEnsureDaemonSpawnFailure(t *testing.T) { | 228 | func TestEnsureDaemonSpawnFailure(t *testing.T) { | ||
| 182 | spawnErr := errors.New("exec boom") | 229 | spawnErr := errors.New("exec boom") | ||
| 183 | - | dial := func(string) (ports.Transport, error) { return nil, errDialFailed } | 230 | + | dial := func(context.Context, string) (ports.Transport, error) { return nil, errDialFailed } |
| 184 | spawn := func() error { return spawnErr } | 231 | spawn := func() error { return spawnErr } | ||
| 185 | 232 | ||||
| 186 | _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff) | 233 | _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff) | ||
A internal/app/transport_observability_test.go +250 -0
| @@ -0,0 +1,250 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "errors" | |||
| 8 | + | "io" | |||
| 9 | + | "log/slog" | |||
| 10 | + | "os" | |||
| 11 | + | "path/filepath" | |||
| 12 | + | "strconv" | |||
| 13 | + | "strings" | |||
| 14 | + | "testing" | |||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/vev/internal/ports" | |||
| 18 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 19 | + | "github.com/stretchr/testify/mock" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | func TestPerformanceTraceUsesHarnessProcessMapping(t *testing.T) { | |||
| 23 | + | path := filepath.Join(t.TempDir(), "process.jsonl") | |||
| 24 | + | t.Setenv("VEV_PERF_TRACE", path) | |||
| 25 | + | t.Setenv("VEV_PERF_PROCESS_ID", "idle-local-r001-daemon-01") | |||
| 26 | + | t.Setenv("VEV_PERF_SCENARIO", "1x4-idle-local") | |||
| 27 | + | t.Setenv("VEV_PERF_RUN", "1") | |||
| 28 | + | ||||
| 29 | + | clock := portsmocks.NewMockClock(t) | |||
| 30 | + | clock.EXPECT().Now().Return(time.Unix(0, 1)).Once() | |||
| 31 | + | observer, closer, err := performanceTrace(clock) | |||
| 32 | + | if err != nil { | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | if observer == nil || closer == nil { | |||
| 36 | + | t.Fatal("performance trace was not configured") | |||
| 37 | + | } | |||
| 38 | + | // performanceTrace's serialized return type makes a raw JSONL sink | |||
| 39 | + | // unrepresentable to application transport wiring. | |||
| 40 | + | observer.ObserveRuntime(ports.RuntimeMark{Schema: ports.RuntimeMarkSchema, Component: "daemon", Scenario: "runtime", Run: 1, Kind: ports.RuntimeEmitEnd, Valid: true}) | |||
| 41 | + | if err := closer.Close(); err != nil { | |||
| 42 | + | t.Fatal(err) | |||
| 43 | + | } | |||
| 44 | + | if err := closer.Close(); err != nil { | |||
| 45 | + | t.Fatal(err) | |||
| 46 | + | } | |||
| 47 | + | f, err := os.Open(path) | |||
| 48 | + | if err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | defer func() { | |||
| 52 | + | if err := f.Close(); err != nil { | |||
| 53 | + | t.Errorf("close trace file: %v", err) | |||
| 54 | + | } | |||
| 55 | + | }() | |||
| 56 | + | var mark struct { | |||
| 57 | + | ProcessID string `json:"process_id"` | |||
| 58 | + | Scenario string `json:"scenario"` | |||
| 59 | + | Run uint64 `json:"run"` | |||
| 60 | + | Sequence uint64 `json:"sequence"` | |||
| 61 | + | RequestID uint64 `json:"request_id"` | |||
| 62 | + | Epoch uint64 `json:"epoch"` | |||
| 63 | + | } | |||
| 64 | + | if err := json.NewDecoder(bufio.NewReader(f)).Decode(&mark); err != nil { | |||
| 65 | + | t.Fatal(err) | |||
| 66 | + | } | |||
| 67 | + | if mark.ProcessID != "idle-local-r001-daemon-01" || mark.Scenario != "1x4-idle-local" || mark.Run != 1 || mark.Sequence == 0 || mark.RequestID == 0 || mark.Epoch == 0 { | |||
| 68 | + | t.Fatalf("mark does not match harness mapping: %+v", mark) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestPerformanceTraceWithFactoriesCreatesOneConfiguredObserver(t *testing.T) { | |||
| 73 | + | t.Setenv("VEV_PERF_TRACE", "trace.jsonl") | |||
| 74 | + | t.Setenv("VEV_PERF_PROCESS_ID", "client-01") | |||
| 75 | + | t.Setenv("VEV_PERF_SCENARIO", "lossy-remote") | |||
| 76 | + | t.Setenv("VEV_PERF_RUN", "7") | |||
| 77 | + | ||||
| 78 | + | clock := portsmocks.NewMockClock(t) | |||
| 79 | + | sink := portsmocks.NewMockRuntimeObserver(t) | |||
| 80 | + | sink.EXPECT().ObserveRuntime(mock.MatchedBy(func(mark ports.RuntimeMark) bool { | |||
| 81 | + | return mark.Component == "client" && mark.Scenario == "lossy-remote" && mark.Run == 7 && | |||
| 82 | + | mark.Sequence != 0 && mark.RequestID != 0 && mark.Epoch != 0 && mark.Kind == ports.RuntimeEmitEnd | |||
| 83 | + | })).Once() | |||
| 84 | + | ||||
| 85 | + | var sinkCalls, correlationCalls int | |||
| 86 | + | observer, closer, err := performanceTraceWithFactories(clock, | |||
| 87 | + | func(path string, gotClock ports.Clock, processID string) (ports.RuntimeObserver, io.Closer, error) { | |||
| 88 | + | sinkCalls++ | |||
| 89 | + | if path != "trace.jsonl" || gotClock != clock || processID != "client-01" { | |||
| 90 | + | t.Fatalf("trace sink config = (%q, %v, %q)", path, gotClock, processID) | |||
| 91 | + | } | |||
| 92 | + | return sink, errorCloser{}, nil | |||
| 93 | + | }, | |||
| 94 | + | func(got ports.RuntimeObserver, inputs ports.RuntimeCorrelationInputs) (ports.RuntimeObserver, error) { | |||
| 95 | + | correlationCalls++ | |||
| 96 | + | if got != sink || inputs != (ports.RuntimeCorrelationInputs{Scenario: "lossy-remote", Run: 7}) { | |||
| 97 | + | t.Fatalf("trace correlation config = (%v, %+v)", got, inputs) | |||
| 98 | + | } | |||
| 99 | + | return ports.NewRuntimeCorrelationObserver(got, inputs) | |||
| 100 | + | }, | |||
| 101 | + | ) | |||
| 102 | + | if err != nil { | |||
| 103 | + | t.Fatal(err) | |||
| 104 | + | } | |||
| 105 | + | if sinkCalls != 1 || correlationCalls != 1 || observer == nil || closer == nil { | |||
| 106 | + | t.Fatalf("trace setup = sink calls %d, correlation calls %d, observer %v, closer %v; want one configured observer", sinkCalls, correlationCalls, observer, closer) | |||
| 107 | + | } | |||
| 108 | + | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeEmitEnd, 0, true)) | |||
| 109 | + | if err := closer.Close(); err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestPerformanceTraceSetupRollbackJoinsCloseError(t *testing.T) { | |||
| 115 | + | setupErr := errors.New("correlation setup failed") | |||
| 116 | + | closeErr := errors.New("rollback close failed") | |||
| 117 | + | ||||
| 118 | + | tests := []struct { | |||
| 119 | + | name string | |||
| 120 | + | rawRun string | |||
| 121 | + | correlationErr error | |||
| 122 | + | wantPrimaryText string | |||
| 123 | + | wantPrimaryErr error | |||
| 124 | + | wantNumError bool | |||
| 125 | + | }{ | |||
| 126 | + | { | |||
| 127 | + | name: "validation", | |||
| 128 | + | rawRun: "not-a-run", | |||
| 129 | + | wantPrimaryText: `invalid VEV_PERF_RUN "not-a-run"`, | |||
| 130 | + | wantNumError: true, | |||
| 131 | + | }, | |||
| 132 | + | { | |||
| 133 | + | name: "correlation", | |||
| 134 | + | rawRun: "1", | |||
| 135 | + | correlationErr: setupErr, | |||
| 136 | + | wantPrimaryText: "runtime trace correlation", | |||
| 137 | + | wantPrimaryErr: setupErr, | |||
| 138 | + | }, | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | for _, tc := range tests { | |||
| 142 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 143 | + | t.Setenv("VEV_PERF_TRACE", "unused-by-test-sink") | |||
| 144 | + | t.Setenv("VEV_PERF_PROCESS_ID", "test-process") | |||
| 145 | + | t.Setenv("VEV_PERF_RUN", tc.rawRun) | |||
| 146 | + | ||||
| 147 | + | newSink := func(string, ports.Clock, string) (ports.RuntimeObserver, io.Closer, error) { | |||
| 148 | + | return runtimeObserverFunc(func(ports.RuntimeMark) {}), errorCloser{err: closeErr}, nil | |||
| 149 | + | } | |||
| 150 | + | newCorrelation := func(observer ports.RuntimeObserver, inputs ports.RuntimeCorrelationInputs) (ports.RuntimeObserver, error) { | |||
| 151 | + | if tc.correlationErr != nil { | |||
| 152 | + | return nil, tc.correlationErr | |||
| 153 | + | } | |||
| 154 | + | return ports.NewRuntimeCorrelationObserver(observer, inputs) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | observer, closer, err := performanceTraceWithFactories(nil, newSink, newCorrelation) | |||
| 158 | + | if observer != nil || closer != nil { | |||
| 159 | + | t.Fatalf("performanceTrace() returned observer=%v closer=%v after setup failure", observer, closer) | |||
| 160 | + | } | |||
| 161 | + | if !errors.Is(err, closeErr) { | |||
| 162 | + | t.Errorf("performanceTrace() error = %v, want joined rollback error %v", err, closeErr) | |||
| 163 | + | } | |||
| 164 | + | if tc.wantPrimaryErr != nil && !errors.Is(err, tc.wantPrimaryErr) { | |||
| 165 | + | t.Errorf("performanceTrace() error = %v, want primary setup error %v", err, tc.wantPrimaryErr) | |||
| 166 | + | } | |||
| 167 | + | if tc.wantNumError { | |||
| 168 | + | var numErr *strconv.NumError | |||
| 169 | + | if !errors.As(err, &numErr) { | |||
| 170 | + | t.Errorf("performanceTrace() error = %v, want discoverable parse error", err) | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | if !strings.Contains(err.Error(), tc.wantPrimaryText) { | |||
| 174 | + | t.Errorf("performanceTrace() error = %q, want context %q", err, tc.wantPrimaryText) | |||
| 175 | + | } | |||
| 176 | + | }) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestRunAttachJoinsTraceCloseError(t *testing.T) { | |||
| 181 | + | traceCloseErr := errors.New("trace close failed") | |||
| 182 | + | original := newPerformanceTrace | |||
| 183 | + | newPerformanceTrace = func(ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) { | |||
| 184 | + | return ports.NewSerializedRuntimeObserver(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1), errorCloser{err: traceCloseErr}, nil | |||
| 185 | + | } | |||
| 186 | + | t.Cleanup(func() { newPerformanceTrace = original }) | |||
| 187 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |||
| 188 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |||
| 189 | + | t.Setenv(envRemoteTransport, "invalid") | |||
| 190 | + | ||||
| 191 | + | err := runAttach(context.Background(), ports.IntentEphemeral, "", "host") | |||
| 192 | + | if !errors.Is(err, traceCloseErr) { | |||
| 193 | + | t.Fatalf("runAttach() error = %v, want joined trace close error %v", err, traceCloseErr) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | type errorCloser struct{ err error } | |||
| 198 | + | ||||
| 199 | + | func (c errorCloser) Close() error { return c.err } | |||
| 200 | + | ||||
| 201 | + | type runtimeObserverFunc func(ports.RuntimeMark) | |||
| 202 | + | ||||
| 203 | + | func (f runtimeObserverFunc) ObserveRuntime(mark ports.RuntimeMark) { f(mark) } | |||
| 204 | + | ||||
| 205 | + | func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) { | |||
| 206 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |||
| 207 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |||
| 208 | + | t.Setenv(envRemoteTransport, string(ports.RemoteTransportStdio)) | |||
| 209 | + | ||||
| 210 | + | observer := ports.NewSerializedRuntimeObserver(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1) | |||
| 211 | + | originalTrace := newPerformanceTrace | |||
| 212 | + | traceCalls := 0 | |||
| 213 | + | newPerformanceTrace = func(ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) { | |||
| 214 | + | traceCalls++ | |||
| 215 | + | return observer, &runtimeTraceCloser{reporter: observer}, nil | |||
| 216 | + | } | |||
| 217 | + | t.Cleanup(func() { newPerformanceTrace = originalTrace }) | |||
| 218 | + | ||||
| 219 | + | factory := portsmocks.NewMockRemoteDialerFactory(t) | |||
| 220 | + | factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportStdio, mock.Anything).Return(namedDialer{name: "remote"}, nil) | |||
| 221 | + | originalFactory := newRemoteDialerFactoryWithRuntimeObserver | |||
| 222 | + | factoryCalls := 0 | |||
| 223 | + | newRemoteDialerFactoryWithRuntimeObserver = func(got ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { | |||
| 224 | + | factoryCalls++ | |||
| 225 | + | if got != observer { | |||
| 226 | + | t.Fatalf("remote transport observer = %v, want process observer %v", got, observer) | |||
| 227 | + | } | |||
| 228 | + | return factory | |||
| 229 | + | } | |||
| 230 | + | t.Cleanup(func() { newRemoteDialerFactoryWithRuntimeObserver = originalFactory }) | |||
| 231 | + | ||||
| 232 | + | originalRunClient := runClientWithRuntimeObserver | |||
| 233 | + | runClientWithRuntimeObserver = func(_ context.Context, gotDialer ports.Dialer, _ ports.Terminal, _ ports.Clock, _ uint8, _ string, _ bool, _ ports.ClipboardReader, _ *slog.Logger, got ports.SerializedRuntimeObserver) error { | |||
| 234 | + | if got != observer { | |||
| 235 | + | t.Fatalf("client transport observer = %v, want process observer %v", got, observer) | |||
| 236 | + | } | |||
| 237 | + | if gotDialer != (namedDialer{name: "remote"}) { | |||
| 238 | + | t.Fatalf("remote transport dialer = %v, want configured factory dialer", gotDialer) | |||
| 239 | + | } | |||
| 240 | + | return nil | |||
| 241 | + | } | |||
| 242 | + | t.Cleanup(func() { runClientWithRuntimeObserver = originalRunClient }) | |||
| 243 | + | ||||
| 244 | + | if err := runAttach(context.Background(), ports.IntentAttach, "work", "remote.example"); err != nil { | |||
| 245 | + | t.Fatal(err) | |||
| 246 | + | } | |||
| 247 | + | if traceCalls != 1 || factoryCalls != 1 { | |||
| 248 | + | t.Fatalf("trace/factory calls = %d/%d, want exactly one observer and one transport factory", traceCalls, factoryCalls) | |||
| 249 | + | } | |||
| 250 | + | } | |||
M internal/ports/mocks/mocks.go +93 -20
| @@ -499,8 +499,8 @@ func (_m *MockPTYFactory) EXPECT() *MockPTYFactory_Expecter { | |||||
| 499 | } | 499 | } | ||
| 500 | 500 | ||||
| 501 | // Open provides a mock function for the type MockPTYFactory | 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) { | 502 | + | func (_mock *MockPTYFactory) Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { |
| 503 | - | ret := _mock.Called(cmd, args, env, dir, sz) | 503 | + | ret := _mock.Called(ctx, cmd, args, env, dir, sz) |
| 504 | 504 | ||||
| 505 | if len(ret) == 0 { | 505 | if len(ret) == 0 { | ||
| 506 | panic("no return value specified for Open") | 506 | panic("no return value specified for Open") | ||
| @@ -508,18 +508,18 @@ func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir s | |||||
| 508 | 508 | ||||
| 509 | var r0 ports.PTY | 509 | var r0 ports.PTY | ||
| 510 | var r1 error | 510 | var r1 error | ||
| 511 | - | if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) (ports.PTY, error)); ok { | 511 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error)); ok { |
| 512 | - | return returnFunc(cmd, args, env, dir, sz) | 512 | + | return returnFunc(ctx, cmd, args, env, dir, sz) |
| 513 | } | 513 | } | ||
| 514 | - | if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) ports.PTY); ok { | 514 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) ports.PTY); ok { |
| 515 | - | r0 = returnFunc(cmd, args, env, dir, sz) | 515 | + | r0 = returnFunc(ctx, cmd, args, env, dir, sz) |
| 516 | } else { | 516 | } else { | ||
| 517 | if ret.Get(0) != nil { | 517 | if ret.Get(0) != nil { | ||
| 518 | r0 = ret.Get(0).(ports.PTY) | 518 | r0 = ret.Get(0).(ports.PTY) | ||
| 519 | } | 519 | } | ||
| 520 | } | 520 | } | ||
| 521 | - | if returnFunc, ok := ret.Get(1).(func(string, []string, []string, string, domain.Size) error); ok { | 521 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, []string, string, domain.Size) error); ok { |
| 522 | - | r1 = returnFunc(cmd, args, env, dir, sz) | 522 | + | r1 = returnFunc(ctx, cmd, args, env, dir, sz) |
| 523 | } else { | 523 | } else { | ||
| 524 | r1 = ret.Error(1) | 524 | r1 = ret.Error(1) | ||
| 525 | } | 525 | } | ||
| @@ -532,36 +532,41 @@ type MockPTYFactory_Open_Call struct { | |||||
| 532 | } | 532 | } | ||
| 533 | 533 | ||||
| 534 | // Open is a helper method to define mock.On call | 534 | // Open is a helper method to define mock.On call | ||
| 535 | + | // - ctx context.Context | |||
| 535 | // - cmd string | 536 | // - cmd string | ||
| 536 | // - args []string | 537 | // - args []string | ||
| 537 | // - env []string | 538 | // - env []string | ||
| 538 | // - dir string | 539 | // - dir string | ||
| 539 | // - sz domain.Size | 540 | // - sz domain.Size | ||
| 540 | - | func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call { | 541 | + | func (_e *MockPTYFactory_Expecter) Open(ctx any, cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call { |
| 541 | - | return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", cmd, args, env, dir, sz)} | 542 | + | return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", ctx, cmd, args, env, dir, sz)} |
| 542 | } | 543 | } | ||
| 543 | 544 | ||||
| 544 | - | func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call { | 545 | + | func (_c *MockPTYFactory_Open_Call) Run(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call { |
| 545 | _c.Call.Run(func(args mock.Arguments) { | 546 | _c.Call.Run(func(args mock.Arguments) { | ||
| 546 | - | var arg0 string | 547 | + | var arg0 context.Context |
| 547 | if args[0] != nil { | 548 | if args[0] != nil { | ||
| 548 | - | arg0 = args[0].(string) | 549 | + | arg0 = args[0].(context.Context) |
| 549 | } | 550 | } | ||
| 550 | - | var arg1 []string | 551 | + | var arg1 string |
| 551 | if args[1] != nil { | 552 | if args[1] != nil { | ||
| 552 | - | arg1 = args[1].([]string) | 553 | + | arg1 = args[1].(string) |
| 553 | } | 554 | } | ||
| 554 | var arg2 []string | 555 | var arg2 []string | ||
| 555 | if args[2] != nil { | 556 | if args[2] != nil { | ||
| 556 | arg2 = args[2].([]string) | 557 | arg2 = args[2].([]string) | ||
| 557 | } | 558 | } | ||
| 558 | - | var arg3 string | 559 | + | var arg3 []string |
| 559 | if args[3] != nil { | 560 | if args[3] != nil { | ||
| 560 | - | arg3 = args[3].(string) | 561 | + | arg3 = args[3].([]string) |
| 561 | } | 562 | } | ||
| 562 | - | var arg4 domain.Size | 563 | + | var arg4 string |
| 563 | if args[4] != nil { | 564 | if args[4] != nil { | ||
| 564 | - | arg4 = args[4].(domain.Size) | 565 | + | arg4 = args[4].(string) |
| 566 | + | } | |||
| 567 | + | var arg5 domain.Size | |||
| 568 | + | if args[5] != nil { | |||
| 569 | + | arg5 = args[5].(domain.Size) | |||
| 565 | } | 570 | } | ||
| 566 | run( | 571 | run( | ||
| 567 | arg0, | 572 | arg0, | ||
| @@ -569,6 +574,7 @@ func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env | |||||
| 569 | arg2, | 574 | arg2, | ||
| 570 | arg3, | 575 | arg3, | ||
| 571 | arg4, | 576 | arg4, | ||
| 577 | + | arg5, | |||
| 572 | ) | 578 | ) | ||
| 573 | }) | 579 | }) | ||
| 574 | return _c | 580 | return _c | ||
| @@ -579,7 +585,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac | |||||
| 579 | return _c | 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 | _c.Call.Return(run) | 589 | _c.Call.Return(run) | ||
| 584 | return _c | 590 | return _c | ||
| 585 | } | 591 | } | ||
| @@ -3123,3 +3129,70 @@ func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(ta | |||||
| 3123 | _c.Call.Return(run) | 3129 | _c.Call.Return(run) | ||
| 3124 | return _c | 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 | // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal. | 25 | // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal. | ||
| 26 | type PTYFactory interface { | 26 | type PTYFactory interface { | ||
| 27 | - | Open(cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error) | 27 | + | // Open creates a PTY while honoring ctx cancellation. Implementations must |
| 28 | + | // return promptly once ctx is cancelled and must not leave a child running. | |||
| 29 | + | Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error) | |||
| 28 | } | 30 | } | ||
| 29 | 31 | ||||
| 30 | // Terminal is the CLIENT-side controlling terminal. | 32 | // Terminal is the CLIENT-side controlling terminal. | ||
| @@ -38,7 +40,9 @@ type Terminal interface { | |||||
| 38 | Flush() error | 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 | type Transport interface { | 46 | type Transport interface { | ||
| 43 | Send(Frame) error | 47 | Send(Frame) error | ||
| 44 | Recv() (Frame, error) // blocking; io.EOF on close | 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 | // goroutine, decoupling the input/resize pumps from transport back-pressure. | 102 | // goroutine, decoupling the input/resize pumps from transport back-pressure. | ||
| 103 | const sendQueueDepth = 64 | 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 | var reconnectSleep = sleepReconnect | 109 | var reconnectSleep = sleepReconnect | ||
| 106 | var reconnectSleepWithResize = sleepReconnectWithResizeEvents | 110 | var reconnectSleepWithResize = sleepReconnectWithResizeEvents | ||
| 107 | 111 | ||||
| @@ -120,7 +124,29 @@ const ( | |||||
| 120 | // ClipboardReader configured (clipboard non-nil) — local attaches forward | 124 | // ClipboardReader configured (clipboard non-nil) — local attaches forward | ||
| 121 | // 0x16 untouched so the locally running agent's own clipboard handling | 125 | // 0x16 untouched so the locally running agent's own clipboard handling | ||
| 122 | // applies. | 126 | // applies. | ||
| 123 | - | func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) (retErr error) { | 127 | + | // Option configures opt-in client runtime observation. |
| 128 | + | type Option func(*runtimeOptions) | |||
| 129 | + | ||||
| 130 | + | type runtimeOptions struct { | |||
| 131 | + | observer ports.SerializedRuntimeObserver | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // WithRuntimeObserver accepts only a composition-root serialized observer, so | |||
| 135 | + | // terminal and transport progress can never wait for a raw trace sink. | |||
| 136 | + | func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option { | |||
| 137 | + | return func(opts *runtimeOptions) { opts.observer = observer } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) error { | |||
| 141 | + | return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, nil) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // RunWithRuntimeObserver is the application wiring entry point. | |||
| 145 | + | func RunWithRuntimeObserver(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.SerializedRuntimeObserver) error { | |||
| 146 | + | return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, observer) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.SerializedRuntimeObserver) (retErr error) { | |||
| 124 | if log == nil { | 150 | if log == nil { | ||
| 125 | log = slog.Default() | 151 | log = slog.Default() | ||
| 126 | } | 152 | } | ||
| @@ -244,11 +270,13 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||||
| 244 | if reporter, ok := transport.(ports.LinkStateReporter); ok { | 270 | if reporter, ok := transport.(ports.LinkStateReporter); ok { | ||
| 245 | linkEvents = reporter.LinkEvents() | 271 | linkEvents = reporter.LinkEvents() | ||
| 246 | } | 272 | } | ||
| 247 | - | result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log) | 273 | + | result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log, observer) |
| 248 | if result.welcomed { | 274 | if result.welcomed { | ||
| 249 | backoff = defaultReconnectBackoff.initial | 275 | backoff = defaultReconnectBackoff.initial | ||
| 250 | } | 276 | } | ||
| 251 | - | _ = transport.Close() | 277 | + | if !result.transportClosed { |
| 278 | + | _ = transport.Close() | |||
| 279 | + | } | |||
| 252 | if result.resumeToken != 0 { | 280 | if result.resumeToken != 0 { | ||
| 253 | resumeToken = result.resumeToken | 281 | resumeToken = result.resumeToken | ||
| 254 | } | 282 | } | ||
| @@ -311,8 +339,14 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time | |||||
| 311 | // disappears, or the context is cancelled. | 339 | // disappears, or the context is cancelled. | ||
| 312 | // | 340 | // | ||
| 313 | // It is kept as a compatibility wrapper for callers that still own dialing. | 341 | // It is kept as a compatibility wrapper for callers that still own dialing. | ||
| 314 | - | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error { | 342 | + | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, options ...Option) error { |
| 315 | - | return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default()) | 343 | + | var opts runtimeOptions |
| 344 | + | for _, option := range options { | |||
| 345 | + | if option != nil { | |||
| 346 | + | option(&opts) | |||
| 347 | + | } | |||
| 348 | + | } | |||
| 349 | + | return run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default(), opts.observer) | |||
| 316 | } | 350 | } | ||
| 317 | 351 | ||||
| 318 | type singleTransportDialer struct{ transport ports.Transport } | 352 | type singleTransportDialer struct{ transport ports.Transport } | ||
| @@ -322,10 +356,52 @@ func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) { | |||||
| 322 | } | 356 | } | ||
| 323 | 357 | ||||
| 324 | type attachResult struct { | 358 | type attachResult struct { | ||
| 325 | - | resumeToken uint64 | 359 | + | resumeToken uint64 |
| 326 | - | sessionName string | 360 | + | sessionName string |
| 327 | - | welcomed bool | 361 | + | welcomed bool |
| 328 | - | err error | 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 | // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support. | 407 | // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support. | ||
| @@ -346,7 +422,11 @@ func requestedOutputWindow(transport ports.Transport) uint8 { | |||||
| 346 | return 8 | 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 | // 1. Handshake: send Hello with our size and TERM. | 430 | // 1. Handshake: send Hello with our size and TERM. | ||
| 351 | size, err := term.Size() | 431 | size, err := term.Size() | ||
| 352 | if err != nil { | 432 | if err != nil { | ||
| @@ -371,15 +451,22 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 371 | TrueColor: trueColor, | 451 | TrueColor: trueColor, | ||
| 372 | MaxOutputInFlight: requestedOutputWindow(transport), | 452 | MaxOutputInFlight: requestedOutputWindow(transport), | ||
| 373 | } | 453 | } | ||
| 374 | - | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | 454 | + | if closed, err := boundedPreWelcome(ctx, clk, transport, func() error { |
| 375 | - | return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)} | 455 | + | return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) |
| 456 | + | }); err != nil { | |||
| 457 | + | return attachResult{transportClosed: closed, err: fmt.Errorf("vev: sending hello: %w", err)} | |||
| 376 | } | 458 | } | ||
| 377 | ms.helloSent = true | 459 | ms.helloSent = true | ||
| 378 | 460 | ||||
| 379 | // 2. Await Welcome or a typed rejection. | 461 | // 2. Await Welcome or a typed rejection. | ||
| 380 | - | reply, err := transport.Recv() | 462 | + | var reply ports.Frame |
| 463 | + | closed, err := boundedPreWelcome(ctx, clk, transport, func() error { | |||
| 464 | + | var recvErr error | |||
| 465 | + | reply, recvErr = transport.Recv() | |||
| 466 | + | return recvErr | |||
| 467 | + | }) | |||
| 381 | if err != nil { | 468 | if err != nil { | ||
| 382 | - | return attachResult{err: fmt.Errorf("vev: awaiting welcome: %w", err)} | 469 | + | return attachResult{transportClosed: closed, err: fmt.Errorf("vev: awaiting welcome: %w", err)} |
| 383 | } | 470 | } | ||
| 384 | result := func(welcomed bool, err error) attachResult { | 471 | result := func(welcomed bool, err error) attachResult { | ||
| 385 | return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err} | 472 | return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err} | ||
| @@ -501,6 +588,10 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 501 | if ferr := term.Flush(); ferr != nil { | 588 | if ferr := term.Flush(); ferr != nil { | ||
| 502 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | 589 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | ||
| 503 | } | 590 | } | ||
| 591 | + | // The terminal boundary is after a successful flush and before ACK. | |||
| 592 | + | if observer != nil { | |||
| 593 | + | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true)) | |||
| 594 | + | } | |||
| 504 | if o.NewStateNum != 0 { | 595 | if o.NewStateNum != 0 { | ||
| 505 | ackQueue.offer(o.NewStateNum) | 596 | ackQueue.offer(o.NewStateNum) | ||
| 506 | } | 597 | } | ||
M internal/usecase/client/client_test.go +114 -0
| @@ -155,6 +155,120 @@ func isType(typ ports.MsgType) any { | |||||
| 155 | return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ }) | 155 | return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ }) | ||
| 156 | } | 156 | } | ||
| 157 | 157 | ||||
| 158 | + | const testPreWelcomeTimeout = 15 * time.Second | |||
| 159 | + | ||||
| 160 | + | func newHandshakeClock(t *testing.T, timerCount int) (*portsmocks.MockClock, <-chan chan time.Time) { | |||
| 161 | + | t.Helper() | |||
| 162 | + | created := make(chan chan time.Time, timerCount) | |||
| 163 | + | clk := portsmocks.NewMockClock(t) | |||
| 164 | + | clk.EXPECT().NewTimer(testPreWelcomeTimeout).RunAndReturn(func(time.Duration) ports.Timer { | |||
| 165 | + | timerC := make(chan time.Time, 1) | |||
| 166 | + | timer := portsmocks.NewMockTimer(t) | |||
| 167 | + | timer.EXPECT().C().Return((<-chan time.Time)(timerC)).Maybe() | |||
| 168 | + | timer.EXPECT().Stop().Return(true).Once() | |||
| 169 | + | created <- timerC | |||
| 170 | + | return timer | |||
| 171 | + | }).Times(timerCount) | |||
| 172 | + | return clk, created | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestRunBoundsPreWelcomeOperations(t *testing.T) { | |||
| 176 | + | tests := []struct { | |||
| 177 | + | name string | |||
| 178 | + | phase string | |||
| 179 | + | end string | |||
| 180 | + | want error | |||
| 181 | + | }{ | |||
| 182 | + | {name: "cancel while sending hello", phase: "send", end: "cancel", want: context.Canceled}, | |||
| 183 | + | {name: "timeout while sending hello", phase: "send", end: "timeout", want: context.DeadlineExceeded}, | |||
| 184 | + | {name: "cancel while receiving welcome", phase: "recv", end: "cancel", want: context.Canceled}, | |||
| 185 | + | {name: "timeout while receiving welcome", phase: "recv", end: "timeout", want: context.DeadlineExceeded}, | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | for _, tt := range tests { | |||
| 189 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 190 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 191 | + | defer cancel() | |||
| 192 | + | timerCount := 1 | |||
| 193 | + | if tt.phase == "recv" { | |||
| 194 | + | timerCount = 2 | |||
| 195 | + | } | |||
| 196 | + | clk, createdTimers := newHandshakeClock(t, timerCount) | |||
| 197 | + | ||||
| 198 | + | term := portsmocks.NewMockTerminal(t) | |||
| 199 | + | term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |||
| 200 | + | // No EnterRaw expectation: it must not run before Welcome. | |||
| 201 | + | ||||
| 202 | + | started := make(chan struct{}) | |||
| 203 | + | closed := make(chan struct{}) | |||
| 204 | + | operationExited := make(chan struct{}) | |||
| 205 | + | tr := portsmocks.NewMockTransport(t) | |||
| 206 | + | tr.EXPECT().Close().Run(func() { close(closed) }).Return(nil).Once() | |||
| 207 | + | ||||
| 208 | + | block := func() error { | |||
| 209 | + | close(started) | |||
| 210 | + | <-closed | |||
| 211 | + | close(operationExited) | |||
| 212 | + | return io.ErrClosedPipe | |||
| 213 | + | } | |||
| 214 | + | if tt.phase == "send" { | |||
| 215 | + | tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(ports.Frame) error { | |||
| 216 | + | return block() | |||
| 217 | + | }).Once() | |||
| 218 | + | } else { | |||
| 219 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 220 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 221 | + | return ports.Frame{}, block() | |||
| 222 | + | }).Once() | |||
| 223 | + | } | |||
| 224 | + | dialer := portsmocks.NewMockDialer(t) | |||
| 225 | + | dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once() | |||
| 226 | + | ||||
| 227 | + | errCh := make(chan error, 1) | |||
| 228 | + | go func() { | |||
| 229 | + | errCh <- client.Run(ctx, dialer, term, clk, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler)) | |||
| 230 | + | }() | |||
| 231 | + | ||||
| 232 | + | var timerC chan time.Time | |||
| 233 | + | select { | |||
| 234 | + | case timerC = <-createdTimers: | |||
| 235 | + | case <-time.After(time.Second): | |||
| 236 | + | t.Fatal("pre-Welcome timer was not created") | |||
| 237 | + | } | |||
| 238 | + | select { | |||
| 239 | + | case <-started: | |||
| 240 | + | case <-time.After(time.Second): | |||
| 241 | + | t.Fatal("pre-Welcome operation did not start") | |||
| 242 | + | } | |||
| 243 | + | if tt.phase == "recv" { | |||
| 244 | + | select { | |||
| 245 | + | case timerC = <-createdTimers: | |||
| 246 | + | case <-time.After(time.Second): | |||
| 247 | + | t.Fatal("Welcome timer was not created") | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | if tt.end == "cancel" { | |||
| 252 | + | cancel() | |||
| 253 | + | } else { | |||
| 254 | + | timerC <- time.Time{} | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | select { | |||
| 258 | + | case err := <-errCh: | |||
| 259 | + | require.ErrorIs(t, err, tt.want) | |||
| 260 | + | case <-time.After(time.Second): | |||
| 261 | + | t.Fatal("Run did not return after pre-Welcome cancellation") | |||
| 262 | + | } | |||
| 263 | + | select { | |||
| 264 | + | case <-operationExited: | |||
| 265 | + | case <-time.After(time.Second): | |||
| 266 | + | t.Fatal("pre-Welcome operation leaked after Run returned") | |||
| 267 | + | } | |||
| 268 | + | }) | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 158 | func TestAttachHelloIncludesTrueColor(t *testing.T) { | 272 | func TestAttachHelloIncludesTrueColor(t *testing.T) { | ||
| 159 | t.Setenv("TERM", "xterm-256color") | 273 | t.Setenv("TERM", "xterm-256color") | ||
| 160 | t.Setenv("COLORTERM", "truecolor") | 274 | t.Setenv("COLORTERM", "truecolor") | ||
M internal/usecase/client/reconnect_toast_test.go +18 -6
| @@ -179,6 +179,18 @@ func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransp | |||||
| 179 | return hello | 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 | func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | 194 | func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | ||
| 183 | term := newReconnectToastTerminalHarness(t) | 195 | term := newReconnectToastTerminalHarness(t) | ||
| 184 | defer term.closeInput() | 196 | defer term.closeInput() | ||
| @@ -190,7 +202,7 @@ func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | |||||
| 190 | resultCh := make(chan attachResult, 1) | 202 | resultCh := make(chan attachResult, 1) | ||
| 191 | ms := milestones{} | 203 | ms := milestones{} | ||
| 192 | go func() { | 204 | go func() { | ||
| 193 | - | resultCh <- attachOnce(context.Background(), tr, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { | 205 | + | resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { |
| 194 | _, err := term.term.EnterRaw() | 206 | _, err := term.term.EnterRaw() | ||
| 195 | return err | 207 | return err | ||
| 196 | }, func() { clears <- struct{}{} }, func(stage reconnectStage) { | 208 | }, func() { clears <- struct{}{} }, func(stage reconnectStage) { | ||
| @@ -229,7 +241,7 @@ func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) { | |||||
| 229 | resultCh := make(chan attachResult, 1) | 241 | resultCh := make(chan attachResult, 1) | ||
| 230 | ms := milestones{} | 242 | ms := milestones{} | ||
| 231 | go func() { | 243 | go func() { | ||
| 232 | - | resultCh <- attachOnce(context.Background(), tr, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { | 244 | + | resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { |
| 233 | _, err := term.term.EnterRaw() | 245 | _, err := term.term.EnterRaw() | ||
| 234 | return err | 246 | return err | ||
| 235 | }, func() {}, func(stage reconnectStage) { | 247 | }, func() {}, func(stage reconnectStage) { | ||
| @@ -400,7 +412,7 @@ func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) { | |||||
| 400 | }} | 412 | }} | ||
| 401 | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}} | 413 | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}} | ||
| 402 | 414 | ||||
| 403 | - | err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | 415 | + | err := Run(ctx, dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) |
| 404 | require.ErrorIs(t, err, context.Canceled) | 416 | require.ErrorIs(t, err, context.Canceled) | ||
| 405 | require.True(t, out.failed) | 417 | require.True(t, out.failed) | ||
| 406 | assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH))) | 418 | assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH))) | ||
| @@ -435,7 +447,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | |||||
| 435 | }} | 447 | }} | ||
| 436 | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}} | 448 | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}} | ||
| 437 | 449 | ||||
| 438 | - | err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | 450 | + | err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) |
| 439 | require.NoError(t, err) | 451 | require.NoError(t, err) | ||
| 440 | require.True(t, tr1.closed) | 452 | require.True(t, tr1.closed) | ||
| 441 | require.True(t, tr2.closed) | 453 | require.True(t, tr2.closed) | ||
| @@ -479,7 +491,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) { | |||||
| 479 | }} | 491 | }} | ||
| 480 | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}} | 492 | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}} | ||
| 481 | 493 | ||||
| 482 | - | err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentEphemeral, "", true, nil, slog.New(slog.DiscardHandler)) | 494 | + | err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentEphemeral, "", true, nil, slog.New(slog.DiscardHandler)) |
| 483 | require.NoError(t, err) | 495 | require.NoError(t, err) | ||
| 484 | require.Equal(t, 2, dialer.calls) | 496 | require.Equal(t, 2, dialer.calls) | ||
| 485 | 497 | ||||
| @@ -564,7 +576,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) { | |||||
| 564 | dialer := portsmocks.NewMockDialer(t) | 576 | dialer := portsmocks.NewMockDialer(t) | ||
| 565 | tt.configure(t, dialer) | 577 | tt.configure(t, dialer) | ||
| 566 | 578 | ||||
| 567 | - | err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | 579 | + | err := Run(ctx, dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) |
| 568 | tt.wantErr(t, err) | 580 | tt.wantErr(t, err) | ||
| 569 | out := term.out.String() | 581 | out := term.out.String() | ||
| 570 | require.Contains(t, out, reconnectToastMessage) | 582 | require.Contains(t, out, reconnectToastMessage) | ||
A internal/usecase/client/transport_observability_test.go +154 -0
| @@ -0,0 +1,154 @@ | |||||
| 1 | + | package client_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "reflect" | |||
| 7 | + | "sync" | |||
| 8 | + | "sync/atomic" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/vev/internal/domain" | |||
| 15 | + | "github.com/bnema/vev/internal/ports" | |||
| 16 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 17 | + | "github.com/bnema/vev/internal/usecase/client" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func TestRuntimeObserverOptionRequiresSerializedContract(t *testing.T) { | |||
| 21 | + | got := reflect.TypeOf(client.WithRuntimeObserver).In(0) | |||
| 22 | + | want := reflect.TypeFor[ports.SerializedRuntimeObserver]() | |||
| 23 | + | if got != want { | |||
| 24 | + | t.Fatalf("WithRuntimeObserver accepts %v, want %v; raw blocking sinks must not enter the hot path", got, want) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) { | |||
| 29 | + | var out bytes.Buffer | |||
| 30 | + | var restores atomic.Int32 | |||
| 31 | + | resizeCh := make(chan domain.Size) | |||
| 32 | + | flushed := make(chan struct{}) | |||
| 33 | + | term := portsmocks.NewMockTerminal(t) | |||
| 34 | + | term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |||
| 35 | + | term.EXPECT().EnterRaw().Return(func() error { restores.Add(1); return nil }, nil).Once() | |||
| 36 | + | in := newBlockingReader() | |||
| 37 | + | defer in.unblock() | |||
| 38 | + | term.EXPECT().In().Return(in).Maybe() | |||
| 39 | + | term.EXPECT().Out().Return(&out).Maybe() | |||
| 40 | + | term.EXPECT().Flush().Run(func() { close(flushed) }).Return(nil).Once() | |||
| 41 | + | term.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |||
| 42 | + | ||||
| 43 | + | observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})} | |||
| 44 | + | reporter := ports.NewSerializedRuntimeObserver(observer, 1) | |||
| 45 | + | defer reporter.Close() | |||
| 46 | + | acked := make(chan struct{}) | |||
| 47 | + | transport := portsmocks.NewMockTransport(t) | |||
| 48 | + | transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 49 | + | transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once() | |||
| 50 | + | unblock := scriptRecv(transport, | |||
| 51 | + | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))}, | |||
| 52 | + | recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("flush-before-observe"), NewStateNum: 3}))}, | |||
| 53 | + | ) | |||
| 54 | + | defer unblock() | |||
| 55 | + | transport.EXPECT().Close().Return(nil).Once() | |||
| 56 | + | ||||
| 57 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 58 | + | result := make(chan error, 1) | |||
| 59 | + | go func() { | |||
| 60 | + | result <- client.Attach(ctx, transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(reporter)) | |||
| 61 | + | }() | |||
| 62 | + | awaitClientRuntime(t, observer.entered, "blocking observer") | |||
| 63 | + | awaitClientRuntime(t, flushed, "terminal flush") | |||
| 64 | + | awaitClientRuntime(t, acked, "ACK") | |||
| 65 | + | cancel() | |||
| 66 | + | close(observer.release) | |||
| 67 | + | select { | |||
| 68 | + | case err := <-result: | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | case <-time.After(time.Second): | |||
| 71 | + | t.Fatal("Attach did not complete after observer release") | |||
| 72 | + | } | |||
| 73 | + | require.Equal(t, "flush-before-observe", out.String()) | |||
| 74 | + | require.Equal(t, int32(1), restores.Load()) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestTerminalFlushBoundaryTransportObservability(t *testing.T) { | |||
| 78 | + | var out bytes.Buffer | |||
| 79 | + | var restores atomic.Int32 | |||
| 80 | + | resizeCh := make(chan domain.Size) | |||
| 81 | + | term, in := newHappyTerminal(t, &out, &restores, resizeCh) | |||
| 82 | + | defer in.unblock() | |||
| 83 | + | ||||
| 84 | + | observer := &clientRuntimeObserver{} | |||
| 85 | + | reporter := ports.NewSerializedRuntimeObserver(observer, 64) | |||
| 86 | + | defer reporter.Close() | |||
| 87 | + | transport := portsmocks.NewMockTransport(t) | |||
| 88 | + | transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 89 | + | unblock := scriptRecv(transport, | |||
| 90 | + | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))}, | |||
| 91 | + | recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("unchanged-by-observer"), NewStateNum: 3}))}, | |||
| 92 | + | recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | |||
| 93 | + | ) | |||
| 94 | + | defer unblock() | |||
| 95 | + | transport.EXPECT().Send(isType(ports.MsgAck)).Return(nil).Maybe() | |||
| 96 | + | transport.EXPECT().Close().Return(nil).Once() | |||
| 97 | + | ||||
| 98 | + | err := client.Attach(context.Background(), transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(reporter)) | |||
| 99 | + | require.NoError(t, err) | |||
| 100 | + | reporter.Flush() | |||
| 101 | + | require.Equal(t, "unchanged-by-observer", out.String(), "observer must not alter terminal bytes") | |||
| 102 | + | require.Equal(t, int32(1), restores.Load()) | |||
| 103 | + | // Carriage spans belong only to concrete adapters. The client owns the | |||
| 104 | + | // post-successful-flush terminal boundary, before its ACK is queued. | |||
| 105 | + | observer.requireOrdered(t, ports.RuntimeTerminalFlushed) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | type blockingClientRuntimeObserver struct { | |||
| 109 | + | entered chan struct{} | |||
| 110 | + | release chan struct{} | |||
| 111 | + | once sync.Once | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (o *blockingClientRuntimeObserver) ObserveRuntime(ports.RuntimeMark) { | |||
| 115 | + | o.once.Do(func() { | |||
| 116 | + | close(o.entered) | |||
| 117 | + | <-o.release | |||
| 118 | + | }) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func awaitClientRuntime(t *testing.T, ch <-chan struct{}, what string) { | |||
| 122 | + | t.Helper() | |||
| 123 | + | select { | |||
| 124 | + | case <-ch: | |||
| 125 | + | case <-time.After(time.Second): | |||
| 126 | + | t.Fatalf("timed out waiting for %s", what) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | type clientRuntimeObserver struct { | |||
| 131 | + | mu sync.Mutex | |||
| 132 | + | marks []ports.RuntimeMark | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func (o *clientRuntimeObserver) ObserveRuntime(mark ports.RuntimeMark) { | |||
| 136 | + | o.mu.Lock() | |||
| 137 | + | defer o.mu.Unlock() | |||
| 138 | + | o.marks = append(o.marks, mark) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func (o *clientRuntimeObserver) requireOrdered(t *testing.T, kinds ...ports.RuntimeMarkKind) { | |||
| 142 | + | t.Helper() | |||
| 143 | + | o.mu.Lock() | |||
| 144 | + | defer o.mu.Unlock() | |||
| 145 | + | next := 0 | |||
| 146 | + | for _, mark := range o.marks { | |||
| 147 | + | if next < len(kinds) && mark.Kind == kinds[next] { | |||
| 148 | + | next++ | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | if next != len(kinds) { | |||
| 152 | + | t.Fatalf("runtime marks %#v do not contain ordered %#v", o.marks, kinds) | |||
| 153 | + | } | |||
| 154 | + | } | |||
M internal/usecase/copy/mode.go +44 -18
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "unicode" | 7 | "unicode" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/vev/pkg/renderer" | 9 | "github.com/bnema/vev/pkg/renderer" | ||
| 10 | + | "github.com/bnema/vev/pkg/vt" | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits | 13 | // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits | ||
| @@ -17,45 +18,49 @@ import ( | |||||
| 17 | const OSC52MaxPayloadBytes = 75_000 | 18 | const OSC52MaxPayloadBytes = 75_000 | ||
| 18 | 19 | ||||
| 19 | // Snapshot is the immutable scrollback-mode document: history followed by the | 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 | + | // visible screen. VT HistoryView retains immutable chunks; the visible frame is |
| 21 | - | // the visible frame is cloned once when the snapshot is constructed. | 22 | + | // cloned once when the snapshot is constructed. |
| 22 | type Snapshot struct { | 23 | type Snapshot struct { | ||
| 23 | - | history HistoryView | 24 | + | history vt.HistoryView |
| 24 | screen renderer.Frame | 25 | screen renderer.Frame | ||
| 25 | Width int | 26 | Width int | ||
| 26 | Height int | 27 | Height int | ||
| 27 | } | 28 | } | ||
| 28 | 29 | ||||
| 29 | - | // NewSnapshot freezes the current scrollback view and clones the visible screen. | 30 | + | // NewSnapshot seals the current VT history tail, then freezes that immutable |
| 30 | - | func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot { | 31 | + | // view and clones the visible screen. Sealing prevents repeated copy entry from |
| 31 | - | var history HistoryView | 32 | + | // cloning every row in a partial tail. |
| 32 | - | if sb != nil { | 33 | + | func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { |
| 33 | - | history = sb.View() | 34 | + | var history vt.HistoryView |
| 35 | + | if historySource != nil { | |||
| 36 | + | history = historySource.SealAndView() | |||
| 34 | } | 37 | } | ||
| 35 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | 38 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | ||
| 36 | } | 39 | } | ||
| 37 | 40 | ||||
| 38 | // NewSnapshotFromRows constructs a snapshot that owns explicit caller rows. | 41 | // NewSnapshotFromRows constructs a snapshot that owns explicit caller rows. | ||
| 39 | // It is intended for callers that already have a complete document rather than | 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 | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | 45 | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | ||
| 42 | - | owned := make([][]renderer.Cell, len(rows)) | 46 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256}) |
| 43 | - | for i, row := range rows { | 47 | + | for _, row := range rows { |
| 44 | - | owned[i] = append([]renderer.Cell(nil), row...) | 48 | + | history.Append(row) |
| 45 | } | 49 | } | ||
| 46 | - | return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height} | 50 | + | return Snapshot{history: history.SealAndView(), Width: width, Height: height} |
| 47 | } | 51 | } | ||
| 48 | 52 | ||||
| 49 | // Len returns the number of document rows. | 53 | // Len returns the number of document rows. | ||
| 50 | func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height } | 54 | func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height } | ||
| 51 | 55 | ||||
| 52 | - | // Row returns document row i, or nil when i is out of range. | 56 | + | // Row returns document row i, or nil when i is out of range. History rows |
| 57 | + | // borrow immutable history storage and must be treated as read-only. | |||
| 53 | func (s Snapshot) Row(i int) []renderer.Cell { | 58 | func (s Snapshot) Row(i int) []renderer.Cell { | ||
| 54 | if i < 0 { | 59 | if i < 0 { | ||
| 55 | return nil | 60 | return nil | ||
| 56 | } | 61 | } | ||
| 57 | if i < s.history.Len() { | 62 | if i < s.history.Len() { | ||
| 58 | - | return s.history.Row(i) | 63 | + | return s.history.BorrowedRow(i) |
| 59 | } | 64 | } | ||
| 60 | i -= s.history.Len() | 65 | i -= s.history.Len() | ||
| 61 | if i >= s.screen.Height { | 66 | if i >= s.screen.Height { | ||
| @@ -64,6 +69,27 @@ func (s Snapshot) Row(i int) []renderer.Cell { | |||||
| 64 | return s.screen.Row(i) | 69 | return s.screen.Row(i) | ||
| 65 | } | 70 | } | ||
| 66 | 71 | ||||
| 72 | + | func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) { | |||
| 73 | + | rowIndex := 0 | |||
| 74 | + | stopped := false | |||
| 75 | + | s.history.Range(func(row []renderer.Cell) bool { | |||
| 76 | + | if !yield(rowIndex, row) { | |||
| 77 | + | stopped = true | |||
| 78 | + | return false | |||
| 79 | + | } | |||
| 80 | + | rowIndex++ | |||
| 81 | + | return true | |||
| 82 | + | }) | |||
| 83 | + | if stopped { | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | for y := range s.screen.Height { | |||
| 87 | + | if !yield(rowIndex+y, s.screen.Row(y)) { | |||
| 88 | + | return | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 67 | // Mode stores per-client scrollback viewport and line-selection state. | 93 | // Mode stores per-client scrollback viewport and line-selection state. | ||
| 68 | type SearchMatch struct { | 94 | type SearchMatch struct { | ||
| 69 | Row int | 95 | Row int | ||
| @@ -102,8 +128,7 @@ func FindMatches(s Snapshot, query string) []SearchMatch { | |||||
| 102 | } | 128 | } | ||
| 103 | needle := lowerRunes(query) | 129 | needle := lowerRunes(query) | ||
| 104 | matches := make([]SearchMatch, 0) | 130 | matches := make([]SearchMatch, 0) | ||
| 105 | - | for row := range s.Len() { | 131 | + | s.rangeRows(func(row int, cells []renderer.Cell) bool { |
| 106 | - | cells := s.Row(row) | |||
| 107 | haystack, cellIndexes := searchableCells(cells) | 132 | haystack, cellIndexes := searchableCells(cells) | ||
| 108 | var text string | 133 | var text string | ||
| 109 | for start := 0; start+len(needle) <= len(haystack); { | 134 | for start := 0; start+len(needle) <= len(haystack); { | ||
| @@ -122,7 +147,8 @@ func FindMatches(s Snapshot, query string) []SearchMatch { | |||||
| 122 | matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text}) | 147 | matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text}) | ||
| 123 | start += len(needle) | 148 | start += len(needle) | ||
| 124 | } | 149 | } | ||
| 125 | - | } | 150 | + | return true |
| 151 | + | }) | |||
| 126 | return matches | 152 | return matches | ||
| 127 | } | 153 | } | ||
| 128 | 154 | ||||
M internal/usecase/copy/mode_test.go +128 -3
| @@ -7,11 +7,30 @@ import ( | |||||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/vev/pkg/renderer" | 9 | "github.com/bnema/vev/pkg/renderer" | ||
| 10 | + | "github.com/bnema/vev/pkg/vt" | |||
| 10 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| 13 | var benchmarkSnapshotSink Snapshot | 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 | func snapshot(lines []string, height int) Snapshot { | 34 | func snapshot(lines []string, height int) Snapshot { | ||
| 16 | rows := make([][]renderer.Cell, len(lines)) | 35 | rows := make([][]renderer.Cell, len(lines)) | ||
| 17 | for i, line := range lines { | 36 | for i, line := range lines { | ||
| @@ -355,6 +374,24 @@ func TestFindMatchesKeepsRuneAndCellIndexesAlignedWhenLowercasing(t *testing.T) | |||||
| 355 | require.Equal(t, 2, matches[0].End) | 374 | require.Equal(t, 2, matches[0].End) | ||
| 356 | } | 375 | } | ||
| 357 | 376 | ||||
| 377 | + | func TestFindMatchesDoesNotCopyEveryHistoryRow(t *testing.T) { | |||
| 378 | + | const rows = 100 | |||
| 379 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows, ChunkRows: 10}) | |||
| 380 | + | for range rows { | |||
| 381 | + | history.Append(row(strings.Repeat("x", 120))) | |||
| 382 | + | } | |||
| 383 | + | s := NewSnapshot(history, renderer.NewFrame(120, 1)) | |||
| 384 | + | ||||
| 385 | + | allocs := testing.AllocsPerRun(10, func() { | |||
| 386 | + | if matches := FindMatches(s, "missing"); len(matches) != 0 { | |||
| 387 | + | t.Fatalf("unexpected matches: %d", len(matches)) | |||
| 388 | + | } | |||
| 389 | + | }) | |||
| 390 | + | if allocs >= 250 { | |||
| 391 | + | t.Fatalf("FindMatches allocations = %.0f, want < 250 (no row copy per history row)", allocs) | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | ||||
| 358 | func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) { | 395 | func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) { | ||
| 359 | row := []renderer.Cell{ | 396 | row := []renderer.Cell{ | ||
| 360 | {Rune: '界'}, | 397 | {Rune: '界'}, | ||
| @@ -478,6 +515,94 @@ func TestOSC52FromBase64(t *testing.T) { | |||||
| 478 | } | 515 | } | ||
| 479 | } | 516 | } | ||
| 480 | 517 | ||||
| 518 | + | func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) { | |||
| 519 | + | history := newHistory(1) | |||
| 520 | + | history.Append(row("old")) | |||
| 521 | + | screen := renderer.NewFrame(3, 1) | |||
| 522 | + | copy(screen.Row(0), row("one")) | |||
| 523 | + | ||||
| 524 | + | document := NewSnapshot(history, screen) | |||
| 525 | + | history.Append(row("new")) | |||
| 526 | + | screen.Set(0, 0, renderer.Cell{Rune: 'x'}) | |||
| 527 | + | ||||
| 528 | + | require.Equal(t, 2, document.Len()) | |||
| 529 | + | require.Equal(t, "old", rowText(document.Row(0))) | |||
| 530 | + | require.Equal(t, "one", rowText(document.Row(1))) | |||
| 531 | + | require.Nil(t, document.Row(-1)) | |||
| 532 | + | require.Nil(t, document.Row(2)) | |||
| 533 | + | } | |||
| 534 | + | ||||
| 535 | + | func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) { | |||
| 536 | + | rows := [][]renderer.Cell{row("one")} | |||
| 537 | + | document := NewSnapshotFromRows(rows, 3, 1) | |||
| 538 | + | rows[0][0].Rune = 'x' | |||
| 539 | + | require.Equal(t, "one", rowText(document.Row(0))) | |||
| 540 | + | } | |||
| 541 | + | ||||
| 542 | + | func TestNewSnapshotFromRowsKeepsDocumentAndViewportDimensionsIndependent(t *testing.T) { | |||
| 543 | + | document := NewSnapshotFromRows([][]renderer.Cell{row("one"), row("two")}, 7, 5) | |||
| 544 | + | ||||
| 545 | + | require.Equal(t, 2, document.Len()) | |||
| 546 | + | require.Equal(t, 7, document.Width) | |||
| 547 | + | require.Equal(t, 5, document.Height) | |||
| 548 | + | require.Equal(t, "one", rowText(document.Row(0))) | |||
| 549 | + | require.Equal(t, "two", rowText(document.Row(1))) | |||
| 550 | + | require.Nil(t, document.Row(2)) | |||
| 551 | + | } | |||
| 552 | + | ||||
| 553 | + | // This scaling gate deliberately compares equal row counts rather than an | |||
| 554 | + | // absolute budget, which varies between Go releases. A HistoryView shares | |||
| 555 | + | // sealed chunks, so its allocation must not grow with history-row width. | |||
| 556 | + | func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) { | |||
| 557 | + | if testing.Short() { | |||
| 558 | + | t.Skip("skipping benchmark-driven allocation gate in short mode") | |||
| 559 | + | } | |||
| 560 | + | narrow := benchmarkNewSnapshotBytes(16) | |||
| 561 | + | wide := benchmarkNewSnapshotBytes(512) | |||
| 562 | + | const conservativeWidthTolerance = 2 | |||
| 563 | + | require.LessOrEqual(t, wide, narrow*conservativeWidthTolerance, | |||
| 564 | + | "NewSnapshot B/op scaled with row width: narrow=%d wide=%d", narrow, wide) | |||
| 565 | + | } | |||
| 566 | + | ||||
| 567 | + | func benchmarkNewSnapshotBytes(width int) int64 { | |||
| 568 | + | const historyRows = 256 | |||
| 569 | + | history := newHistory(historyRows) | |||
| 570 | + | for range historyRows { | |||
| 571 | + | history.Append(make([]renderer.Cell, width)) | |||
| 572 | + | } | |||
| 573 | + | // Keep visible-frame work constant so the comparison isolates history rows. | |||
| 574 | + | screen := renderer.NewFrame(8, 4) | |||
| 575 | + | result := testing.Benchmark(func(b *testing.B) { | |||
| 576 | + | b.ReportAllocs() | |||
| 577 | + | b.ResetTimer() | |||
| 578 | + | for b.Loop() { | |||
| 579 | + | benchmarkSnapshotSink = NewSnapshot(history, screen) | |||
| 580 | + | } | |||
| 581 | + | }) | |||
| 582 | + | return result.AllocedBytesPerOp() | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | // This regression replaces the removed Scrollback ring tests: the VT history | |||
| 586 | + | // remains bounded oldest-first and a captured copy document survives eviction. | |||
| 587 | + | func TestNewSnapshotRetainsBoundedHistoryAcrossLaterEviction(t *testing.T) { | |||
| 588 | + | history := newHistory(2) | |||
| 589 | + | history.Append(row("one")) | |||
| 590 | + | history.Append(row("two")) | |||
| 591 | + | document := NewSnapshot(history, renderer.NewFrame(3, 1)) | |||
| 592 | + | history.Append(row("three")) | |||
| 593 | + | history.Append(row("four")) | |||
| 594 | + | ||||
| 595 | + | // A copy document is history followed by the visible frame. Its total is | |||
| 596 | + | // therefore the bounded retained history plus the one visible row. | |||
| 597 | + | require.Equal(t, 3, document.Len()) | |||
| 598 | + | require.Equal(t, "one", rowText(document.Row(0))) | |||
| 599 | + | require.Equal(t, "two", rowText(document.Row(1))) | |||
| 600 | + | require.Equal(t, " ", rowText(document.Row(2)), "the visible frame remains part of the document") | |||
| 601 | + | view := history.View() | |||
| 602 | + | require.Equal(t, "three", rowText(view.Row(0))) | |||
| 603 | + | require.Equal(t, "four", rowText(view.Row(1))) | |||
| 604 | + | } | |||
| 605 | + | ||||
| 481 | func BenchmarkNewSnapshot10KRows(b *testing.B) { | 606 | func BenchmarkNewSnapshot10KRows(b *testing.B) { | ||
| 482 | const ( | 607 | const ( | ||
| 483 | width = 120 | 608 | width = 120 | ||
| @@ -485,9 +610,9 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) { | |||||
| 485 | historyRows = 10_000 | 610 | historyRows = 10_000 | ||
| 486 | ) | 611 | ) | ||
| 487 | 612 | ||||
| 488 | - | scrollback := NewScrollback(historyRows) | 613 | + | history := newHistory(historyRows) |
| 489 | for range historyRows { | 614 | for range historyRows { | ||
| 490 | - | scrollback.Append(row(strings.Repeat("x", width))) | 615 | + | history.Append(row(strings.Repeat("x", width))) |
| 491 | } | 616 | } | ||
| 492 | screen := renderer.NewFrame(width, height) | 617 | screen := renderer.NewFrame(width, height) | ||
| 493 | for y := range height { | 618 | for y := range height { | ||
| @@ -497,7 +622,7 @@ func BenchmarkNewSnapshot10KRows(b *testing.B) { | |||||
| 497 | b.ReportAllocs() | 622 | b.ReportAllocs() | ||
| 498 | b.ResetTimer() | 623 | b.ResetTimer() | ||
| 499 | for b.Loop() { | 624 | for b.Loop() { | ||
| 500 | - | benchmarkSnapshotSink = NewSnapshot(scrollback, screen) | 625 | + | benchmarkSnapshotSink = NewSnapshot(history, screen) |
| 501 | } | 626 | } | ||
| 502 | } | 627 | } | ||
| 503 | 628 | ||||
D internal/usecase/copy/scrollback.go +0 -83
| @@ -1,83 +0,0 @@ | |||||
| 1 | - | package copy | |||
| 2 | - | ||||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | - | ||||
| 5 | - | // Scrollback stores evicted terminal rows in a fixed-size ring buffer. | |||
| 6 | - | type Scrollback struct { | |||
| 7 | - | rows [][]renderer.Cell | |||
| 8 | - | head int | |||
| 9 | - | len int | |||
| 10 | - | } | |||
| 11 | - | ||||
| 12 | - | // NewScrollback constructs a scrollback ring capped at capacity rows. | |||
| 13 | - | func NewScrollback(capacity int) *Scrollback { | |||
| 14 | - | if capacity < 0 { | |||
| 15 | - | capacity = 0 | |||
| 16 | - | } | |||
| 17 | - | return &Scrollback{rows: make([][]renderer.Cell, capacity)} | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | // Cap returns the maximum number of rows retained. | |||
| 21 | - | func (s *Scrollback) Cap() int { return len(s.rows) } | |||
| 22 | - | ||||
| 23 | - | // Len returns the number of retained rows. | |||
| 24 | - | func (s *Scrollback) Len() int { return s.len } | |||
| 25 | - | ||||
| 26 | - | // Head returns the physical ring slot holding the oldest retained row. | |||
| 27 | - | func (s *Scrollback) Head() int { return s.head } | |||
| 28 | - | ||||
| 29 | - | // Append stores a copy of row, evicting the oldest row when the ring is full. | |||
| 30 | - | func (s *Scrollback) Append(row []renderer.Cell) { | |||
| 31 | - | if len(s.rows) == 0 { | |||
| 32 | - | return | |||
| 33 | - | } | |||
| 34 | - | cp := append([]renderer.Cell(nil), row...) | |||
| 35 | - | if s.len < len(s.rows) { | |||
| 36 | - | s.rows[(s.head+s.len)%len(s.rows)] = cp | |||
| 37 | - | s.len++ | |||
| 38 | - | return | |||
| 39 | - | } | |||
| 40 | - | s.rows[s.head] = cp | |||
| 41 | - | s.head = (s.head + 1) % len(s.rows) | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | // Snapshot returns retained rows oldest-first. | |||
| 45 | - | // | |||
| 46 | - | // The returned outer slice is a copy, but row slices are shared with the | |||
| 47 | - | // scrollback storage. Callers must treat the returned rows as read-only. | |||
| 48 | - | func (s *Scrollback) Snapshot() [][]renderer.Cell { | |||
| 49 | - | if s.len == 0 || len(s.rows) == 0 { | |||
| 50 | - | return nil | |||
| 51 | - | } | |||
| 52 | - | rows := make([][]renderer.Cell, s.len) | |||
| 53 | - | for i := range s.len { | |||
| 54 | - | rows[i] = s.rows[(s.head+i)%len(s.rows)] | |||
| 55 | - | } | |||
| 56 | - | return rows | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | // HistoryView is a frozen oldest-first view of scrollback rows. | |||
| 60 | - | // | |||
| 61 | - | // Its rows share cell slices with the scrollback at the time View is called. | |||
| 62 | - | // Callers must treat rows returned by Row as read-only. | |||
| 63 | - | type HistoryView struct { | |||
| 64 | - | rows [][]renderer.Cell | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | // View returns a frozen scrollback view. Appending to the scrollback cannot | |||
| 68 | - | // alter the view because Append replaces ring slots rather than mutating rows. | |||
| 69 | - | func (s *Scrollback) View() HistoryView { | |||
| 70 | - | return HistoryView{rows: s.Snapshot()} | |||
| 71 | - | } | |||
| 72 | - | ||||
| 73 | - | // Len returns the number of rows in the view. | |||
| 74 | - | func (v HistoryView) Len() int { return len(v.rows) } | |||
| 75 | - | ||||
| 76 | - | // Row returns the row at logical index i, oldest first, or nil when i is out | |||
| 77 | - | // of range. Returned rows are shared with the view and must be read-only. | |||
| 78 | - | func (v HistoryView) Row(i int) []renderer.Cell { | |||
| 79 | - | if i < 0 || i >= len(v.rows) { | |||
| 80 | - | return nil | |||
| 81 | - | } | |||
| 82 | - | return v.rows[i] | |||
| 83 | - | } | |||
D internal/usecase/copy/scrollback_test.go +0 -209
| @@ -1,209 +0,0 @@ | |||||
| 1 | - | package copy | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | func row(text string) []renderer.Cell { | |||
| 10 | - | runes := []rune(text) | |||
| 11 | - | cells := make([]renderer.Cell, len(runes)) | |||
| 12 | - | for i, r := range runes { | |||
| 13 | - | cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |||
| 14 | - | } | |||
| 15 | - | return cells | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | func rowText(cells []renderer.Cell) string { | |||
| 19 | - | runes := make([]rune, len(cells)) | |||
| 20 | - | for i, c := range cells { | |||
| 21 | - | runes[i] = c.Rune | |||
| 22 | - | } | |||
| 23 | - | return string(runes) | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | - | func TestScrollbackRing(t *testing.T) { | |||
| 27 | - | tests := []struct { | |||
| 28 | - | name string | |||
| 29 | - | cap int | |||
| 30 | - | append []string | |||
| 31 | - | wantHead int | |||
| 32 | - | wantLen int | |||
| 33 | - | wantRows []string | |||
| 34 | - | }{ | |||
| 35 | - | { | |||
| 36 | - | name: "empty ring drops rows", | |||
| 37 | - | cap: 0, | |||
| 38 | - | append: []string{"aa", "bb"}, | |||
| 39 | - | wantHead: 0, | |||
| 40 | - | wantLen: 0, | |||
| 41 | - | }, | |||
| 42 | - | { | |||
| 43 | - | name: "below capacity keeps head at zero", | |||
| 44 | - | cap: 3, | |||
| 45 | - | append: []string{"aa", "bb"}, | |||
| 46 | - | wantHead: 0, | |||
| 47 | - | wantLen: 2, | |||
| 48 | - | wantRows: []string{"aa", "bb"}, | |||
| 49 | - | }, | |||
| 50 | - | { | |||
| 51 | - | name: "at capacity fills without rotating head", | |||
| 52 | - | cap: 3, | |||
| 53 | - | append: []string{"aa", "bb", "cc"}, | |||
| 54 | - | wantHead: 0, | |||
| 55 | - | wantLen: 3, | |||
| 56 | - | wantRows: []string{"aa", "bb", "cc"}, | |||
| 57 | - | }, | |||
| 58 | - | { | |||
| 59 | - | name: "over capacity evicts oldest and rotates head", | |||
| 60 | - | cap: 3, | |||
| 61 | - | append: []string{"aa", "bb", "cc", "dd", "ee"}, | |||
| 62 | - | wantHead: 2, | |||
| 63 | - | wantLen: 3, | |||
| 64 | - | wantRows: []string{"cc", "dd", "ee"}, | |||
| 65 | - | }, | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | - | for _, tt := range tests { | |||
| 69 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 70 | - | sb := NewScrollback(tt.cap) | |||
| 71 | - | for _, r := range tt.append { | |||
| 72 | - | sb.Append(row(r)) | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | if got := sb.Cap(); got != tt.cap { | |||
| 76 | - | t.Fatalf("Cap() = %d, want %d", got, tt.cap) | |||
| 77 | - | } | |||
| 78 | - | if got := sb.Head(); got != tt.wantHead { | |||
| 79 | - | t.Fatalf("Head() = %d, want %d", got, tt.wantHead) | |||
| 80 | - | } | |||
| 81 | - | if got := sb.Len(); got != tt.wantLen { | |||
| 82 | - | t.Fatalf("Len() = %d, want %d", got, tt.wantLen) | |||
| 83 | - | } | |||
| 84 | - | for i, want := range tt.wantRows { | |||
| 85 | - | if got := rowText(sb.View().Row(i)); got != want { | |||
| 86 | - | t.Fatalf("Row(%d) = %q, want %q", i, got, want) | |||
| 87 | - | } | |||
| 88 | - | } | |||
| 89 | - | }) | |||
| 90 | - | } | |||
| 91 | - | } | |||
| 92 | - | ||||
| 93 | - | func TestScrollbackCopiesRows(t *testing.T) { | |||
| 94 | - | tests := []struct { | |||
| 95 | - | name string | |||
| 96 | - | }{ | |||
| 97 | - | {name: "append copies caller row"}, | |||
| 98 | - | } | |||
| 99 | - | for _, tt := range tests { | |||
| 100 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 101 | - | sb := NewScrollback(1) | |||
| 102 | - | r := row("ab") | |||
| 103 | - | sb.Append(r) | |||
| 104 | - | r[0].Rune = 'z' | |||
| 105 | - | ||||
| 106 | - | if text := rowText(sb.View().Row(0)); text != "ab" { | |||
| 107 | - | t.Fatalf("stored row = %q, want copy unaffected by caller mutation", text) | |||
| 108 | - | } | |||
| 109 | - | }) | |||
| 110 | - | } | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | func TestScrollbackSnapshot(t *testing.T) { | |||
| 114 | - | tests := []struct { | |||
| 115 | - | name string | |||
| 116 | - | cap int | |||
| 117 | - | append []string | |||
| 118 | - | wantRows []string | |||
| 119 | - | }{ | |||
| 120 | - | { | |||
| 121 | - | name: "empty capacity snapshots no rows", | |||
| 122 | - | cap: 0, | |||
| 123 | - | append: []string{"aa", "bb"}, | |||
| 124 | - | wantRows: nil, | |||
| 125 | - | }, | |||
| 126 | - | { | |||
| 127 | - | name: "oldest first after ring wrap", | |||
| 128 | - | cap: 3, | |||
| 129 | - | append: []string{"aa", "bb", "cc", "dd"}, | |||
| 130 | - | wantRows: []string{"bb", "cc", "dd"}, | |||
| 131 | - | }, | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | for _, tt := range tests { | |||
| 135 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 136 | - | sb := NewScrollback(tt.cap) | |||
| 137 | - | for _, r := range tt.append { | |||
| 138 | - | sb.Append(row(r)) | |||
| 139 | - | } | |||
| 140 | - | ||||
| 141 | - | got := sb.Snapshot() | |||
| 142 | - | if len(got) != len(tt.wantRows) { | |||
| 143 | - | t.Fatalf("Snapshot() len = %d, want %d", len(got), len(tt.wantRows)) | |||
| 144 | - | } | |||
| 145 | - | for i, want := range tt.wantRows { | |||
| 146 | - | if text := rowText(got[i]); text != want { | |||
| 147 | - | t.Fatalf("Snapshot()[%d] = %q, want %q", i, text, want) | |||
| 148 | - | } | |||
| 149 | - | } | |||
| 150 | - | if len(got) > 0 { | |||
| 151 | - | got[0] = row("zz") | |||
| 152 | - | if text := rowText(sb.View().Row(0)); text != tt.wantRows[0] { | |||
| 153 | - | t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0]) | |||
| 154 | - | } | |||
| 155 | - | } | |||
| 156 | - | }) | |||
| 157 | - | } | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | func TestScrollbackAppendDoesNotMutateSnapshottedRows(t *testing.T) { | |||
| 161 | - | sb := NewScrollback(2) | |||
| 162 | - | sb.Append(row("aa")) | |||
| 163 | - | sb.Append(row("bb")) | |||
| 164 | - | snapshot := sb.Snapshot() | |||
| 165 | - | ||||
| 166 | - | sb.Append(row("cc")) | |||
| 167 | - | sb.Append(row("dd")) | |||
| 168 | - | ||||
| 169 | - | if text := rowText(snapshot[0]); text != "aa" { | |||
| 170 | - | t.Fatalf("snapshot[0] after append = %q, want original row", text) | |||
| 171 | - | } | |||
| 172 | - | if text := rowText(snapshot[1]); text != "bb" { | |||
| 173 | - | t.Fatalf("snapshot[1] after append = %q, want original row", text) | |||
| 174 | - | } | |||
| 175 | - | } | |||
| 176 | - | ||||
| 177 | - | func TestScrollbackViewSurvivesCompleteRingOverwrite(t *testing.T) { | |||
| 178 | - | sb := NewScrollback(2) | |||
| 179 | - | sb.Append(row("aa")) | |||
| 180 | - | sb.Append(row("bb")) | |||
| 181 | - | view := sb.View() | |||
| 182 | - | if &view.Row(0)[0] != &sb.rows[0][0] { | |||
| 183 | - | t.Fatal("View() deep-copied cells, want shared row storage") | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | sb.Append(row("cc")) | |||
| 187 | - | sb.Append(row("dd")) | |||
| 188 | - | ||||
| 189 | - | if got := view.Len(); got != 2 { | |||
| 190 | - | t.Fatalf("View().Len() = %d, want 2", got) | |||
| 191 | - | } | |||
| 192 | - | for i, want := range []string{"aa", "bb"} { | |||
| 193 | - | if got := rowText(view.Row(i)); got != want { | |||
| 194 | - | t.Fatalf("View().Row(%d) = %q, want %q", i, got, want) | |||
| 195 | - | } | |||
| 196 | - | } | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | - | func TestHistoryViewRowBounds(t *testing.T) { | |||
| 200 | - | sb := NewScrollback(1) | |||
| 201 | - | sb.Append(row("aa")) | |||
| 202 | - | view := sb.View() | |||
| 203 | - | ||||
| 204 | - | for _, i := range []int{-1, 1} { | |||
| 205 | - | if got := view.Row(i); got != nil { | |||
| 206 | - | t.Errorf("View().Row(%d) = %v, want nil", i, got) | |||
| 207 | - | } | |||
| 208 | - | } | |||
| 209 | - | } | |||
D internal/usecase/copy/snapshot_test.go +0 -85
| @@ -1,85 +0,0 @@ | |||||
| 1 | - | package copy | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) { | |||
| 10 | - | sb := NewScrollback(1) | |||
| 11 | - | sb.Append(row("old")) | |||
| 12 | - | screen := renderer.NewFrame(3, 1) | |||
| 13 | - | copy(screen.Row(0), row("one")) | |||
| 14 | - | ||||
| 15 | - | snapshot := NewSnapshot(sb, screen) | |||
| 16 | - | if &snapshot.Row(0)[0] != &sb.View().Row(0)[0] { | |||
| 17 | - | t.Fatal("NewSnapshot() deep-copied immutable history") | |||
| 18 | - | } | |||
| 19 | - | sb.Append(row("new")) | |||
| 20 | - | screen.Set(0, 0, renderer.Cell{Rune: 'x'}) | |||
| 21 | - | ||||
| 22 | - | if got := snapshot.Len(); got != 2 { | |||
| 23 | - | t.Fatalf("Len() = %d, want 2", got) | |||
| 24 | - | } | |||
| 25 | - | for i, want := range []string{"old", "one"} { | |||
| 26 | - | if got := rowText(snapshot.Row(i)); got != want { | |||
| 27 | - | t.Errorf("Row(%d) = %q, want %q", i, got, want) | |||
| 28 | - | } | |||
| 29 | - | } | |||
| 30 | - | for _, i := range []int{-1, 2} { | |||
| 31 | - | if got := snapshot.Row(i); got != nil { | |||
| 32 | - | t.Errorf("Row(%d) = %v, want nil", i, got) | |||
| 33 | - | } | |||
| 34 | - | } | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) { | |||
| 38 | - | rows := [][]renderer.Cell{row("one")} | |||
| 39 | - | snapshot := NewSnapshotFromRows(rows, 3, 1) | |||
| 40 | - | rows[0][0].Rune = 'x' | |||
| 41 | - | if got := rowText(snapshot.Row(0)); got != "one" { | |||
| 42 | - | t.Fatalf("Row(0) = %q, want deep-owned caller row", got) | |||
| 43 | - | } | |||
| 44 | - | } | |||
| 45 | - | ||||
| 46 | - | // This scaling gate deliberately compares equal row counts rather than an | |||
| 47 | - | // absolute budget, which varies between Go releases. A full history-row clone | |||
| 48 | - | // grows with row width; the immutable HistoryView allocation does not. | |||
| 49 | - | func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) { | |||
| 50 | - | if testing.Short() { | |||
| 51 | - | t.Skip("skipping benchmark-driven allocation gate in short mode") | |||
| 52 | - | } | |||
| 53 | - | narrow := benchmarkNewSnapshotBytes(16) | |||
| 54 | - | wide := benchmarkNewSnapshotBytes(512) | |||
| 55 | - | assertWidthIndependentAllocations(t, "NewSnapshot", narrow, wide) | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | func benchmarkNewSnapshotBytes(width int) int64 { | |||
| 59 | - | const historyRows = 256 | |||
| 60 | - | ||||
| 61 | - | scrollback := NewScrollback(historyRows) | |||
| 62 | - | for range historyRows { | |||
| 63 | - | scrollback.Append(make([]renderer.Cell, width)) | |||
| 64 | - | } | |||
| 65 | - | // Keep visible-frame work constant so the comparison isolates history rows. | |||
| 66 | - | screen := renderer.NewFrame(8, 4) | |||
| 67 | - | result := testing.Benchmark(func(b *testing.B) { | |||
| 68 | - | b.ReportAllocs() | |||
| 69 | - | b.ResetTimer() | |||
| 70 | - | for b.Loop() { | |||
| 71 | - | benchmarkSnapshotSink = NewSnapshot(scrollback, screen) | |||
| 72 | - | } | |||
| 73 | - | }) | |||
| 74 | - | return result.AllocedBytesPerOp() | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | func assertWidthIndependentAllocations(t *testing.T, operation string, narrow, wide int64) { | |||
| 78 | - | t.Helper() | |||
| 79 | - | // Two times permits allocator noise while decisively rejecting a cell-row | |||
| 80 | - | // copy, whose allocation grows by 32x for the widths above. | |||
| 81 | - | const conservativeWidthTolerance = 2 | |||
| 82 | - | if wide > narrow*conservativeWidthTolerance { | |||
| 83 | - | t.Fatalf("%s B/op scaled with row width: narrow=%d wide=%d (limit=%d)", operation, narrow, wide, narrow*conservativeWidthTolerance) | |||
| 84 | - | } | |||
| 85 | - | } | |||
M internal/usecase/daemon/attention.go +2 -2
| @@ -49,7 +49,7 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) { | |||||
| 49 | if idx, ok := oldestAttentionTab(sess); ok { | 49 | if idx, ok := oldestAttentionTab(sess); ok { | ||
| 50 | if sess.switchTab(idx) { | 50 | if sess.switchTab(idx) { | ||
| 51 | d.activateTab(sess, sess.activeTab()) | 51 | d.activateTab(sess, sess.activeTab()) | ||
| 52 | - | d.paint(sess, ac, true) | 52 | + | d.invalidateRender(sess, ac, true, "attention.go") |
| 53 | } | 53 | } | ||
| 54 | return | 54 | return | ||
| 55 | } | 55 | } | ||
| @@ -119,7 +119,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) { | |||||
| 119 | ac := sess.client | 119 | ac := sess.client | ||
| 120 | sess.mu.Unlock() | 120 | sess.mu.Unlock() | ||
| 121 | if ac != nil { | 121 | if ac != nil { | ||
| 122 | - | d.paint(sess, ac, false) | 122 | + | d.invalidateRender(sess, ac, false, "attention.go") |
| 123 | } | 123 | } | ||
| 124 | } | 124 | } | ||
| 125 | 125 | ||||
M internal/usecase/daemon/attention_test.go +9 -9
| @@ -53,7 +53,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) { | |||||
| 53 | d.setAttentionFrame(0) | 53 | d.setAttentionFrame(0) | ||
| 54 | d.noteAttention(sess, sess.tabs[0]) | 54 | d.noteAttention(sess, sess.tabs[0]) | ||
| 55 | 55 | ||||
| 56 | - | d.paint(sess, ac, true) | 56 | + | d.paint(sess, ac, true, nil) |
| 57 | data := mustOutputData(t, sends) | 57 | data := mustOutputData(t, sends) | ||
| 58 | require.NotContains(t, string(data), string(ui.AttentionGlyph)) | 58 | require.NotContains(t, string(data), string(ui.AttentionGlyph)) | ||
| 59 | 59 | ||||
| @@ -62,7 +62,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) { | |||||
| 62 | sess.mu.Unlock() | 62 | sess.mu.Unlock() | ||
| 63 | 63 | ||||
| 64 | d.setAttentionFrame(1) | 64 | d.setAttentionFrame(1) | ||
| 65 | - | d.paint(sess, ac, true) | 65 | + | d.paint(sess, ac, true, nil) |
| 66 | data = mustOutputData(t, sends) | 66 | data = mustOutputData(t, sends) | ||
| 67 | require.Contains(t, string(data), string(ui.AttentionGlyph)) | 67 | require.Contains(t, string(data), string(ui.AttentionGlyph)) | ||
| 68 | 68 | ||||
| @@ -97,7 +97,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing. | |||||
| 97 | return sess.tabs[0].attention && !sess.tabs[0].attentionAt.IsZero() | 97 | return sess.tabs[0].attention && !sess.tabs[0].attentionAt.IsZero() | ||
| 98 | }, 2*time.Second, 5*time.Millisecond) | 98 | }, 2*time.Second, 5*time.Millisecond) | ||
| 99 | 99 | ||||
| 100 | - | d.paint(sess, ac, true) | 100 | + | d.paint(sess, ac, true, nil) |
| 101 | data := mustOutputData(t, sends) | 101 | data := mustOutputData(t, sends) | ||
| 102 | require.Contains(t, string(data), string(ui.AttentionGlyph)) | 102 | require.Contains(t, string(data), string(ui.AttentionGlyph)) | ||
| 103 | 103 | ||||
| @@ -242,7 +242,7 @@ func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) { | |||||
| 242 | sess.tabs[1].attentionAt = time.Unix(2, 0) | 242 | sess.tabs[1].attentionAt = time.Unix(2, 0) | ||
| 243 | sess.mu.Unlock() | 243 | sess.mu.Unlock() | ||
| 244 | 244 | ||||
| 245 | - | d.paint(sess, ac, true) | 245 | + | d.paint(sess, ac, true, nil) |
| 246 | _ = mustOutputData(t, sends) | 246 | _ = mustOutputData(t, sends) | ||
| 247 | // The deferred repaint (from ackAttention) targets all attached clients, | 247 | // The deferred repaint (from ackAttention) targets all attached clients, | ||
| 248 | // including this one, but the bars it would draw are already exactly what | 248 | // including this one, but the bars it would draw are already exactly what | ||
| @@ -272,7 +272,7 @@ func TestPaintPreservesBackgroundAttention(t *testing.T) { | |||||
| 272 | sess.mu.Unlock() | 272 | sess.mu.Unlock() | ||
| 273 | d.setAttentionFrame(1) | 273 | d.setAttentionFrame(1) | ||
| 274 | 274 | ||||
| 275 | - | d.paint(sess, ac, true) | 275 | + | d.paint(sess, ac, true, nil) |
| 276 | data := mustOutputData(t, sends) | 276 | data := mustOutputData(t, sends) | ||
| 277 | 277 | ||||
| 278 | require.Contains(t, string(data), "") | 278 | require.Contains(t, string(data), "") | ||
| @@ -292,7 +292,7 @@ func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) { | |||||
| 292 | sess.mu.Unlock() | 292 | sess.mu.Unlock() | ||
| 293 | 293 | ||||
| 294 | require.True(t, sess.switchTab(1)) | 294 | require.True(t, sess.switchTab(1)) | ||
| 295 | - | d.paint(sess, ac, true) | 295 | + | d.paint(sess, ac, true, nil) |
| 296 | data := mustOutputData(t, sends) | 296 | data := mustOutputData(t, sends) | ||
| 297 | // As above: the deferred all-clients repaint has nothing new to say about | 297 | // As above: the deferred all-clients repaint has nothing new to say about | ||
| 298 | // this client's bars, so no second frame follows. | 298 | // this client's bars, so no second frame follows. | ||
| @@ -321,7 +321,7 @@ func TestCloseRingingTabClearsClientBar(t *testing.T) { | |||||
| 321 | sess.mu.Unlock() | 321 | sess.mu.Unlock() | ||
| 322 | d.setAttentionFrame(1) | 322 | d.setAttentionFrame(1) | ||
| 323 | 323 | ||||
| 324 | - | d.paint(sess, ac, true) | 324 | + | d.paint(sess, ac, true, nil) |
| 325 | before := mustOutputData(t, sends) | 325 | before := mustOutputData(t, sends) | ||
| 326 | require.Contains(t, string(before), "") | 326 | require.Contains(t, string(before), "") | ||
| 327 | 327 | ||||
| @@ -346,7 +346,7 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) { | |||||
| 346 | sess.tabs[1].attentionAt = time.Unix(1, 0) | 346 | sess.tabs[1].attentionAt = time.Unix(1, 0) | ||
| 347 | sess.mu.Unlock() | 347 | sess.mu.Unlock() | ||
| 348 | 348 | ||||
| 349 | - | d.paint(sess, ac, true) | 349 | + | d.paint(sess, ac, true, nil) |
| 350 | full := mustOutputData(t, sends) | 350 | full := mustOutputData(t, sends) | ||
| 351 | require.Contains(t, string(full), "MIDSCREENMARKER") | 351 | require.Contains(t, string(full), "MIDSCREENMARKER") | ||
| 352 | 352 | ||||
| @@ -428,7 +428,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||||
| 428 | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil) | 428 | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil) | ||
| 429 | d.sessions[sessB.id] = sessB | 429 | d.sessions[sessB.id] = sessB | ||
| 430 | 430 | ||||
| 431 | - | d.paint(sessB, acB, true) | 431 | + | d.paint(sessB, acB, true, nil) |
| 432 | before := mustOutputData(t, sendsB) | 432 | before := mustOutputData(t, sendsB) | ||
| 433 | require.Contains(t, string(before), string(ui.AttentionGlyph)) | 433 | require.Contains(t, string(before), string(ui.AttentionGlyph)) | ||
| 434 | 434 | ||||
M internal/usecase/daemon/bar_refresh.go +7 -8
| @@ -306,12 +306,11 @@ func (d *Daemon) pokeSessionRender(sess *session) { | |||||
| 306 | if sess == nil { | 306 | if sess == nil { | ||
| 307 | return | 307 | return | ||
| 308 | } | 308 | } | ||
| 309 | - | if tb := sess.activeTab(); tb != nil { | 309 | + | sess.mu.Lock() |
| 310 | - | tb.mu.Lock() | 310 | + | ac := sess.client |
| 311 | - | p := tb.focusedPane() | 311 | + | sess.mu.Unlock() |
| 312 | - | tb.mu.Unlock() | 312 | + | // A headless session can still supply a picker preview to an attachment |
| 313 | - | if p != nil { | 313 | + | // owned by another session. Its coordinator fans this wake out to those |
| 314 | - | signal(p.dirty) | 314 | + | // preview subscribers even though no primary attachment is bound. |
| 315 | - | } | 315 | + | d.invalidateRender(sess, ac, false, "bar_refresh.go") |
| 316 | - | } | |||
| 317 | } | 316 | } | ||
M internal/usecase/daemon/bar_refresh_test.go +12 -0
| @@ -277,6 +277,18 @@ func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) { | |||||
| 277 | require.Empty(t, state.bottomRight) | 277 | require.Empty(t, state.bottomRight) | ||
| 278 | } | 278 | } | ||
| 279 | 279 | ||||
| 280 | + | func TestPokeSessionRenderWakesHeadlessPreviewCoordinator(t *testing.T) { | |||
| 281 | + | d := newBarRefreshTestDaemon(nil, time.Second) | |||
| 282 | + | sess := newBarRefreshTestSession() | |||
| 283 | + | var invalidations int | |||
| 284 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(renderInvalidation) { invalidations++ }}) | |||
| 285 | + | sess.installRenderCoordinator(rc) | |||
| 286 | + | ||||
| 287 | + | d.pokeSessionRender(sess) | |||
| 288 | + | ||||
| 289 | + | require.Equal(t, 1, invalidations) | |||
| 290 | + | } | |||
| 291 | + | ||||
| 280 | func TestBarScriptRefreshIsPerSession(t *testing.T) { | 292 | func TestBarScriptRefreshIsPerSession(t *testing.T) { | ||
| 281 | r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}} | 293 | r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}} | ||
| 282 | d := newBarRefreshTestDaemon(r, time.Second) | 294 | d := newBarRefreshTestDaemon(r, time.Second) | ||
M internal/usecase/daemon/client.go +273 -177
| @@ -1,27 +1,8 @@ | |||||
| 1 | - | // Package daemon holds vev's server-side session multiplexer use case: the | 1 | + | // Package daemon holds vev's server-side session multiplexer use case. |
| 2 | - | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |||
| 3 | - | // and VT screen, and the per-client debounced render scheduler. | |||
| 4 | // | 2 | // | ||
| 5 | - | // Concurrency model (sessions own one or more PTY-backed tabs): | 3 | + | // Lock ordering: never acquire sendMu or session/daemon locks while holding a |
| 6 | - | // | 4 | + | // pane lock. Coordinator callbacks and timer methods run without coordinator.mu; |
| 7 | - | // - Serve runs the accept loop. Each accepted connection is handled by its | 5 | + | // see the lock-specific comments on attachedClient and renderCoordinator. |
| 8 | - | // own goroutine (handleConn): it reads the first frame and routes it to a | |||
| 9 | - | // session create/attach, a list, or a kill. | |||
| 10 | - | // - Per session there are exactly two long-lived goroutines: the PTY reader | |||
| 11 | - | // (drains child output into the VT screen and pokes a cap-1 dirty channel) | |||
| 12 | - | // and the render scheduler (debounces dirties and paints the attached | |||
| 13 | - | // client). Both are tied to the session context and unwind when the | |||
| 14 | - | // session is killed (pty.Close unblocks the reader; ctx cancel stops the | |||
| 15 | - | // scheduler). | |||
| 16 | - | // - The daemon exits (Serve returns) when the last session is removed, or | |||
| 17 | - | // when the parent context is cancelled (graceful shutdown notifies any | |||
| 18 | - | // attached clients with ReasonServerShutdown). | |||
| 19 | - | // | |||
| 20 | - | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |||
| 21 | - | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |||
| 22 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | |||
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |||
| 24 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |||
| 25 | package daemon | 6 | package daemon | ||
| 26 | 7 | ||||
| 27 | import ( | 8 | import ( | ||
| @@ -37,28 +18,40 @@ import ( | |||||
| 37 | themeui "github.com/bnema/vev/internal/usecase/theme" | 18 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 38 | ) | 19 | ) | ||
| 39 | 20 | ||||
| 21 | + | type transportSnapshot struct { | |||
| 22 | + | transport ports.Transport | |||
| 23 | + | incarnation uint64 | |||
| 24 | + | } | |||
| 25 | + | ||||
| 40 | type attachedClient struct { | 26 | type attachedClient struct { | ||
| 41 | - | tr ports.Transport | 27 | + | tr ports.Transport |
| 42 | - | output *outputStateStream | 28 | + | transportIncarnation uint64 |
| 43 | - | overlays *overlayRuntime | 29 | + | output *outputStateStream |
| 44 | - | clientID [16]byte | 30 | + | overlays *overlayRuntime |
| 45 | - | resumeCapable bool | 31 | + | overlayOnce sync.Once |
| 46 | - | resumeToken uint64 | 32 | + | clientID [16]byte |
| 47 | - | parked bool | 33 | + | resumeCapable bool |
| 48 | - | echoAck atomic.Uint64 | 34 | + | resumeToken uint64 |
| 49 | - | bars barCache // only touched while sendMu is held | 35 | + | parked bool |
| 50 | - | composed composedFrameCache // only touched while sendMu is held | 36 | + | echoAck atomic.Uint64 |
| 51 | - | resizePaint pendingByteTimer // guarded by sendMu | 37 | + | // pipelineCache is the last successfully emitted composition. pipelineScratch |
| 52 | - | resizePaintGeneration uint64 // guarded by sendMu | 38 | + | // is its attachment-owned alternate buffer; both are only touched under |
| 53 | - | resizePaintPending bool // guarded by sendMu | 39 | + | // sendMu and must never share mutable backing storage. |
| 54 | - | size domain.Size | 40 | + | pipelineCache composeCacheInput |
| 55 | - | keys *keys.Router | 41 | + | pipelineScratch composeCacheInput |
| 56 | - | sess Guarded[*session] | 42 | + | renderScratch renderCaptureScratch // only touched while sendMu is held |
| 57 | - | mouseScan mouse.Scanner | 43 | + | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so |
| 58 | - | themeMu sync.Mutex | 44 | + | // snapshots cannot leak when an attachment switches tabs or sessions. |
| 59 | - | theme themeui.Theme | 45 | + | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held |
| 60 | - | clientTheme themeui.Theme | 46 | + | size domain.Size |
| 61 | - | lastCursor cursorOut | 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 | // previousSession is guarded independently. It is retained through temporary | 55 | // previousSession is guarded independently. It is retained through temporary | ||
| 63 | // setSession(nil) hand-offs and cleared only on terminal teardown. | 56 | // setSession(nil) hand-offs and cleared only on terminal teardown. | ||
| 64 | previousSession Guarded[*session] | 57 | previousSession Guarded[*session] | ||
| @@ -75,6 +68,8 @@ type cursorOut struct { | |||||
| 75 | hasStyle bool | 68 | hasStyle bool | ||
| 76 | } | 69 | } | ||
| 77 | 70 | ||||
| 71 | + | // pendingByteTimer is retained for overlay input timers. Resize timing belongs | |||
| 72 | + | // exclusively to renderCoordinator. | |||
| 78 | type pendingByteTimer struct { | 73 | type pendingByteTimer struct { | ||
| 79 | timer ports.Timer | 74 | timer ports.Timer | ||
| 80 | done chan struct{} | 75 | done chan struct{} | ||
| @@ -104,20 +99,8 @@ func (p *pendingByteTimer) stop() { | |||||
| 104 | } | 99 | } | ||
| 105 | } | 100 | } | ||
| 106 | 101 | ||||
| 107 | - | // cancelResizePaint invalidates callbacks before detaching or replacing an | |||
| 108 | - | // attachment. It deliberately takes sendMu before any session/daemon lock. | |||
| 109 | - | func (ac *attachedClient) cancelResizePaint() { | |||
| 110 | - | ac.sendMu.Lock() | |||
| 111 | - | ac.resizePaintGeneration++ | |||
| 112 | - | ac.resizePaintPending = false | |||
| 113 | - | ac.resizePaint.stop() | |||
| 114 | - | ac.sendMu.Unlock() | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | func (ac *attachedClient) initOverlays() { | 102 | func (ac *attachedClient) initOverlays() { | ||
| 118 | - | if ac.overlays == nil { | 103 | + | ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) }) |
| 119 | - | ac.overlays = newOverlayRuntime(ac) | |||
| 120 | - | } | |||
| 121 | } | 104 | } | ||
| 122 | 105 | ||||
| 123 | func (ac *attachedClient) currentSession() *session { return ac.sess.Get() } | 106 | func (ac *attachedClient) currentSession() *session { return ac.sess.Get() } | ||
| @@ -130,6 +113,30 @@ func (ac *attachedClient) clearPreviousSession() { | |||||
| 130 | } | 113 | } | ||
| 131 | } | 114 | } | ||
| 132 | 115 | ||||
| 116 | + | // pruneCaptureFrames releases snapshots for panes that have left their owner. | |||
| 117 | + | // Callers must not hold daemon, session, tab, or pane locks. | |||
| 118 | + | func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) { | |||
| 119 | + | if ac == nil || len(panes) == 0 { | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 122 | + | ac.sendMu.Lock() | |||
| 123 | + | for _, p := range panes { | |||
| 124 | + | delete(ac.captureFrames, p) | |||
| 125 | + | } | |||
| 126 | + | ac.sendMu.Unlock() | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // clearCaptureFrames releases all pane snapshots when the attachment no longer | |||
| 130 | + | // owns a session. | |||
| 131 | + | func (ac *attachedClient) clearCaptureFrames() { | |||
| 132 | + | if ac == nil { | |||
| 133 | + | return | |||
| 134 | + | } | |||
| 135 | + | ac.sendMu.Lock() | |||
| 136 | + | ac.captureFrames = nil | |||
| 137 | + | ac.sendMu.Unlock() | |||
| 138 | + | } | |||
| 139 | + | ||||
| 133 | func (ac *attachedClient) getTheme() themeui.Theme { | 140 | func (ac *attachedClient) getTheme() themeui.Theme { | ||
| 134 | ac.themeMu.Lock() | 141 | ac.themeMu.Lock() | ||
| 135 | defer ac.themeMu.Unlock() | 142 | defer ac.themeMu.Unlock() | ||
| @@ -163,9 +170,41 @@ func (ac *attachedClient) transport() ports.Transport { | |||||
| 163 | func (ac *attachedClient) replaceTransport(tr ports.Transport) { | 170 | func (ac *attachedClient) replaceTransport(tr ports.Transport) { | ||
| 164 | ac.linkMu.Lock() | 171 | ac.linkMu.Lock() | ||
| 165 | ac.tr = tr | 172 | ac.tr = tr | ||
| 173 | + | ac.transportIncarnation++ | |||
| 166 | ac.linkMu.Unlock() | 174 | ac.linkMu.Unlock() | ||
| 167 | } | 175 | } | ||
| 168 | 176 | ||||
| 177 | + | // revokeTransport removes tr only if it is still this attachment's current | |||
| 178 | + | // link. It returns the revoked transport, so asynchronous retirement can | |||
| 179 | + | // close exactly that link without touching a later resume/replacement. | |||
| 180 | + | func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport { | |||
| 181 | + | if tr == nil { | |||
| 182 | + | return nil | |||
| 183 | + | } | |||
| 184 | + | ac.linkMu.Lock() | |||
| 185 | + | defer ac.linkMu.Unlock() | |||
| 186 | + | if ac.tr != tr { | |||
| 187 | + | return nil | |||
| 188 | + | } | |||
| 189 | + | ac.tr = nil | |||
| 190 | + | ac.transportIncarnation++ | |||
| 191 | + | return tr | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // transportSnapshot binds a send to one concrete link lifetime. Callers must | |||
| 195 | + | // revalidate it after acquiring sendMu before writing to that link. | |||
| 196 | + | func (ac *attachedClient) transportSnapshot() transportSnapshot { | |||
| 197 | + | ac.linkMu.Lock() | |||
| 198 | + | defer ac.linkMu.Unlock() | |||
| 199 | + | return transportSnapshot{transport: ac.tr, incarnation: ac.transportIncarnation} | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func (ac *attachedClient) transportSnapshotCurrent(expected transportSnapshot) bool { | |||
| 203 | + | ac.linkMu.Lock() | |||
| 204 | + | defer ac.linkMu.Unlock() | |||
| 205 | + | return ac.tr == expected.transport && ac.transportIncarnation == expected.incarnation | |||
| 206 | + | } | |||
| 207 | + | ||||
| 169 | func (ac *attachedClient) closeTransport() error { | 208 | func (ac *attachedClient) closeTransport() error { | ||
| 170 | tr := ac.transport() | 209 | tr := ac.transport() | ||
| 171 | if tr == nil { | 210 | if tr == nil { | ||
| @@ -197,23 +236,6 @@ func (ac *attachedClient) ackOutputState(state uint64) { | |||||
| 197 | ac.output.ack(state) | 236 | ac.output.ack(state) | ||
| 198 | } | 237 | } | ||
| 199 | 238 | ||||
| 200 | - | // takeDeferredPaint reports whether a deferred diff paint is now flushable | |||
| 201 | - | // because acks have caught up, clearing the flag when it is. The caller must | |||
| 202 | - | // then paint outside sendMu (paint re-acquires it). | |||
| 203 | - | func (ac *attachedClient) takeDeferredPaint() (reset bool, ok bool) { | |||
| 204 | - | ac.sendMu.Lock() | |||
| 205 | - | defer ac.sendMu.Unlock() | |||
| 206 | - | return ac.output.takeDeferred() | |||
| 207 | - | } | |||
| 208 | - | ||||
| 209 | - | // clearPaintDeferred drops any pending deferred-paint flag. Used on park so a | |||
| 210 | - | // resumed client does not inherit a stale deferral. | |||
| 211 | - | func (ac *attachedClient) clearPaintDeferred() { | |||
| 212 | - | ac.sendMu.Lock() | |||
| 213 | - | ac.output.clearDeferred() | |||
| 214 | - | ac.sendMu.Unlock() | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | // send serialises a frame onto the client's transport. | 239 | // send serialises a frame onto the client's transport. | ||
| 218 | func (ac *attachedClient) send(f ports.Frame) error { | 240 | func (ac *attachedClient) send(f ports.Frame) error { | ||
| 219 | _, err := ac.sendTransport(f) | 241 | _, err := ac.sendTransport(f) | ||
| @@ -230,6 +252,22 @@ func (ac *attachedClient) sendTransport(f ports.Frame) (ports.Transport, error) | |||||
| 230 | return tr, tr.Send(f) | 252 | return tr, tr.Send(f) | ||
| 231 | } | 253 | } | ||
| 232 | 254 | ||||
| 255 | + | var errTransportReplaced = errors.New("client transport was replaced") | |||
| 256 | + | ||||
| 257 | + | // sendExpectedTransport writes only when expected is still the attachment's | |||
| 258 | + | // current transport incarnation. It preserves sendMu -> linkMu lock ordering. | |||
| 259 | + | func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f ports.Frame) error { | |||
| 260 | + | ac.sendMu.Lock() | |||
| 261 | + | defer ac.sendMu.Unlock() | |||
| 262 | + | if !ac.transportSnapshotCurrent(expected) { | |||
| 263 | + | return errTransportReplaced | |||
| 264 | + | } | |||
| 265 | + | if expected.transport == nil { | |||
| 266 | + | return errors.New("client transport is nil") | |||
| 267 | + | } | |||
| 268 | + | return expected.transport.Send(f) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 233 | // boundedSend sends f to ac with a deadline watchdog: if the send (including | 271 | // boundedSend sends f to ac with a deadline watchdog: if the send (including | ||
| 234 | // waiting on sendMu behind a wedged paint) does not complete within | 272 | // waiting on sendMu behind a wedged paint) does not complete within | ||
| 235 | // detachNotifyTimeout, the transport is force-closed, failing the in-flight | 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 | func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error { | 280 | func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error { | ||
| 243 | - | tr, err := d.boundedSendWith(func(capture func(ports.Transport)) error { | 281 | + | expected := ac.transportSnapshot() |
| 244 | - | tr, err := ac.sendTransport(f) | 282 | + | if expected.transport == nil { |
| 245 | - | capture(tr) | 283 | + | return errors.New("client transport is nil") |
| 246 | - | return err | 284 | + | } |
| 285 | + | tr, err := d.boundedSendWith(expected.transport, func() error { | |||
| 286 | + | return ac.sendExpectedTransport(expected, f) | |||
| 247 | }) | 287 | }) | ||
| 248 | if err != nil && errors.Is(err, errSendTimedOut) { | 288 | if err != nil && errors.Is(err, errSendTimedOut) { | ||
| 249 | _ = ac.closeCapturedTransport(tr) | 289 | _ = ac.closeCapturedTransport(tr) | ||
| @@ -258,68 +298,50 @@ func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error { | |||||
| 258 | 298 | ||||
| 259 | func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) { | 299 | func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) { | ||
| 260 | frame := ac.output.sideEffect(b, ac.echoAck.Load()) | 300 | frame := ac.output.sideEffect(b, ac.echoAck.Load()) | ||
| 301 | + | expected := ac.transportSnapshot() | |||
| 302 | + | if expected.transport == nil { | |||
| 303 | + | return nil, errors.New("client transport is nil") | |||
| 304 | + | } | |||
| 261 | ac.sendMu.Lock() | 305 | ac.sendMu.Lock() | ||
| 262 | - | tr := ac.transport() | 306 | + | if !ac.transportSnapshotCurrent(expected) { |
| 263 | - | if tr == nil { | |||
| 264 | ac.sendMu.Unlock() | 307 | ac.sendMu.Unlock() | ||
| 265 | - | return nil, errors.New("client transport is nil") | 308 | + | return expected.transport, errTransportReplaced |
| 266 | } | 309 | } | ||
| 267 | - | if owned, ok := tr.(ports.OwnedSynchronousTransport); ok { | 310 | + | if owned, ok := expected.transport.(ports.OwnedSynchronousTransport); ok { |
| 268 | err := owned.SendSynchronous(frame) | 311 | err := owned.SendSynchronous(frame) | ||
| 269 | ac.sendMu.Unlock() | 312 | ac.sendMu.Unlock() | ||
| 270 | - | return tr, err | 313 | + | return expected.transport, err |
| 271 | } | 314 | } | ||
| 272 | ac.sendMu.Unlock() | 315 | ac.sendMu.Unlock() | ||
| 273 | - | return d.boundedSendWith(func(capture func(ports.Transport)) error { | 316 | + | return d.boundedSendWith(expected.transport, func() error { |
| 274 | - | ac.sendMu.Lock() | 317 | + | return ac.sendExpectedTransport(expected, frame) |
| 275 | - | defer ac.sendMu.Unlock() | |||
| 276 | - | tr := ac.transport() | |||
| 277 | - | capture(tr) | |||
| 278 | - | if tr == nil { | |||
| 279 | - | return errors.New("client transport is nil") | |||
| 280 | - | } | |||
| 281 | - | return tr.Send(frame) | |||
| 282 | }) | 318 | }) | ||
| 283 | } | 319 | } | ||
| 284 | 320 | ||||
| 285 | var errSendTimedOut = errors.New("send timed out") | 321 | var errSendTimedOut = errors.New("send timed out") | ||
| 286 | 322 | ||||
| 287 | - | func (d *Daemon) boundedSendWith(send func(capture func(ports.Transport)) error) (ports.Transport, error) { | 323 | + | func (d *Daemon) boundedSendWith(tr ports.Transport, send func() error) (ports.Transport, error) { |
| 288 | - | return d.boundedSendWithTimeout(detachNotifyTimeout, send) | 324 | + | return d.boundedSendWithTimeout(detachNotifyTimeout, tr, send) |
| 289 | } | 325 | } | ||
| 290 | 326 | ||||
| 291 | - | func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, send func(capture func(ports.Transport)) error) (ports.Transport, error) { | 327 | + | func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, tr ports.Transport, send func() error) (ports.Transport, error) { |
| 292 | timer := d.clock.NewTimer(timeout) | 328 | timer := d.clock.NewTimer(timeout) | ||
| 293 | result := make(chan error, 1) | 329 | result := make(chan error, 1) | ||
| 294 | - | var ( | |||
| 295 | - | capturedMu sync.Mutex | |||
| 296 | - | captured ports.Transport | |||
| 297 | - | ) | |||
| 298 | - | capture := func(tr ports.Transport) { | |||
| 299 | - | capturedMu.Lock() | |||
| 300 | - | captured = tr | |||
| 301 | - | capturedMu.Unlock() | |||
| 302 | - | } | |||
| 303 | - | capturedTransport := func() ports.Transport { | |||
| 304 | - | capturedMu.Lock() | |||
| 305 | - | defer capturedMu.Unlock() | |||
| 306 | - | return captured | |||
| 307 | - | } | |||
| 308 | go func() { | 330 | go func() { | ||
| 309 | - | result <- send(capture) | 331 | + | result <- send() |
| 310 | }() | 332 | }() | ||
| 311 | select { | 333 | select { | ||
| 312 | case err := <-result: | 334 | case err := <-result: | ||
| 313 | timer.Stop() | 335 | timer.Stop() | ||
| 314 | - | return capturedTransport(), err | 336 | + | return tr, err |
| 315 | case <-timer.C(): | 337 | case <-timer.C(): | ||
| 316 | select { | 338 | select { | ||
| 317 | case err := <-result: | 339 | case err := <-result: | ||
| 318 | - | return capturedTransport(), err | 340 | + | return tr, err |
| 319 | default: | 341 | default: | ||
| 320 | } | 342 | } | ||
| 321 | d.log.Warn("bounded send timed out; force closing client transport") | 343 | d.log.Warn("bounded send timed out; force closing client transport") | ||
| 322 | - | return capturedTransport(), errSendTimedOut | 344 | + | return tr, errSendTimedOut |
| 323 | } | 345 | } | ||
| 324 | } | 346 | } | ||
| 325 | 347 | ||||
| @@ -376,6 +398,12 @@ type attachClientOptions struct { | |||||
| 376 | } | 398 | } | ||
| 377 | 399 | ||||
| 378 | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) { | 400 | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) { | ||
| 401 | + | ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, opts) | |||
| 402 | + | cleanup.finish() | |||
| 403 | + | return ac, old | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, renderLifecycleCleanup) { | |||
| 379 | resumeToken := uint64(0) | 407 | resumeToken := uint64(0) | ||
| 380 | if opts.resumeCapable { | 408 | if opts.resumeCapable { | ||
| 381 | resumeToken = d.nextResumeTokenLocked() | 409 | resumeToken = d.nextResumeTokenLocked() | ||
| @@ -391,15 +419,96 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 391 | ac.initOverlays() | 419 | ac.initOverlays() | ||
| 392 | ac.setSession(sess) | 420 | ac.setSession(sess) | ||
| 393 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | 421 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | ||
| 422 | + | // Daemon attachment callers hold d.mu, serialising this preparation with | |||
| 423 | + | // other publications. Bind coordinator ownership before sess.client becomes | |||
| 424 | + | // visible: an old deadline can then never target this new output chain. | |||
| 394 | sess.mu.Lock() | 425 | sess.mu.Lock() | ||
| 395 | old := sess.client | 426 | old := sess.client | ||
| 396 | - | sess.client = ac | |||
| 397 | name := sess.name | 427 | name := sess.name | ||
| 398 | sess.mu.Unlock() | 428 | sess.mu.Unlock() | ||
| 429 | + | _, cleanup := d.attachCoordinatorDeferred(sess, old, ac, false) | |||
| 430 | + | sess.mu.Lock() | |||
| 431 | + | sess.client = ac | |||
| 432 | + | sess.mu.Unlock() | |||
| 399 | d.touchMRU(sess) | 433 | d.touchMRU(sess) | ||
| 400 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | 434 | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | ||
| 401 | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true) | 435 | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true) | ||
| 402 | - | return ac, 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 | // resetScreenDefaultColors clears the known default foreground/background | 514 | // resetScreenDefaultColors clears the known default foreground/background | ||
| @@ -417,19 +526,36 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) { | |||||
| 417 | d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true) | 526 | d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true) | ||
| 418 | } | 527 | } | ||
| 419 | 528 | ||||
| 420 | - | func (d *Daemon) detachReplacedClient(old *attachedClient) { | 529 | + | // retireReplacedClient owns old-link retirement and obsolete render-worker |
| 530 | + | // joins outside the replacement handshake. If a render already owns sendMu, | |||
| 531 | + | // close immediately interrupts it; no Detached frame can safely overtake that | |||
| 532 | + | // render. Otherwise the old link gets its bounded protocol notice first. | |||
| 533 | + | func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecycleCleanup) { | |||
| 421 | if old == nil { | 534 | if old == nil { | ||
| 535 | + | d.attachmentCleanupWg.Go(cleanup.finish) | |||
| 422 | return | 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) | 544 | + | d.attachmentCleanupWg.Go(func() { |
| 427 | - | old.clearPreviousSession() | 545 | + | if !blockedRender { |
| 428 | - | old.setSession(nil) | 546 | + | // A healthy idle client observes the required replacement notice. Its |
| 429 | - | d.log.Info("client displaced") | 547 | + | // watchdog still bounds a peer that stops draining after this check. |
| 430 | - | // Async + bounded: a dead or wedged old client must not stall the new | 548 | + | d.boundedSend(old, frameDetached(ports.ReasonDetach)) |
| 431 | - | // client's handshake. | 549 | + | } |
| 432 | - | d.notifyDetachedAsync(old, ports.ReasonDetach) | 550 | + | _ = old.closeCapturedTransport(old.revokeTransport(oldTransport)) |
| 551 | + | d.unregisterPreview(old) | |||
| 552 | + | old.clearPreviousSession() | |||
| 553 | + | old.setSession(nil) | |||
| 554 | + | d.log.Info("client displaced") | |||
| 555 | + | }) | |||
| 556 | + | // Join independently: the close above releases a blocked render while the | |||
| 557 | + | // new attachment is already fully published. | |||
| 558 | + | d.attachmentCleanupWg.Go(cleanup.finish) | |||
| 433 | } | 559 | } | ||
| 434 | 560 | ||||
| 435 | // firstPaint guarantees the freshly attached client sees the full screen: if | 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 | wsz := tb.size | 571 | wsz := tb.size | ||
| 446 | tb.mu.Unlock() | 572 | tb.mu.Unlock() | ||
| 447 | 573 | ||||
| 574 | + | outerResizeAccepted := false | |||
| 448 | if clientSize.Valid() && wsz != tabSize(clientSize) { | 575 | if clientSize.Valid() && wsz != tabSize(clientSize) { | ||
| 449 | - | d.resizeForFirstPaint(sess, ac, clientSize) | 576 | + | outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize) |
| 450 | } | 577 | } | ||
| 451 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | 578 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | ||
| 452 | - | d.paint(sess, ac, true) | 579 | + | // Activation can synchronously resize a retained floating pane. An accepted |
| 453 | - | d.activateTab(sess, tb) | 580 | + | // synchronous outer request already includes that pane and emits the reset, |
| 581 | + | // but activation still performs its warmup work. | |||
| 582 | + | activationResized := d.activateTabAfterResize(sess, tb, outerResizeAccepted) | |||
| 583 | + | if !outerResizeAccepted && !activationResized { | |||
| 584 | + | d.invalidateRenderNow(sess, ac, true, "client.go") | |||
| 585 | + | } | |||
| 454 | } | 586 | } | ||
| 455 | 587 | ||||
| 456 | // runConnLoop is the per-connection input router: it pumps client messages | 588 | // runConnLoop is the per-connection input router: it pumps client messages | ||
| @@ -503,8 +635,8 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 503 | case ports.MsgAck: | 635 | case ports.MsgAck: | ||
| 504 | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | 636 | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | ||
| 505 | ac.ackOutputState(ack.AckedStateNum) | 637 | ac.ackOutputState(ack.AckedStateNum) | ||
| 506 | - | if reset, ok := ac.takeDeferredPaint(); ok { | 638 | + | if rc := sess.renderCoordinator(); rc != nil { |
| 507 | - | d.paint(sess, ac, reset) | 639 | + | rc.notifyAck() |
| 508 | } | 640 | } | ||
| 509 | } | 641 | } | ||
| 510 | case ports.MsgPing: | 642 | case ports.MsgPing: | ||
| @@ -525,7 +657,9 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||||
| 525 | if !sess.detachIfCurrent(ac) { | 657 | if !sess.detachIfCurrent(ac) { | ||
| 526 | return // already displaced by a newer client; nothing to do | 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 | d.unregisterPreview(ac) | 663 | d.unregisterPreview(ac) | ||
| 530 | sess.mu.Lock() | 664 | sess.mu.Lock() | ||
| 531 | ephemeral := sess.ephemeral | 665 | ephemeral := sess.ephemeral | ||
| @@ -576,57 +710,17 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||||
| 576 | if !d.applyHostTheme(sess, ac, t, false) { | 710 | if !d.applyHostTheme(sess, ac, t, false) { | ||
| 577 | return | 711 | return | ||
| 578 | } | 712 | } | ||
| 579 | - | d.paint(sess, ac, true) | 713 | + | d.invalidateRender(sess, ac, true, "client.go") |
| 580 | } | 714 | } | ||
| 581 | 715 | ||||
| 582 | func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | 716 | func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | ||
| 583 | - | d.resizeWithPaint(sess, ac, sz, true) | 717 | + | d.requestTransactionalResize(sess, ac, sz, false) |
| 584 | } | 718 | } | ||
| 585 | 719 | ||||
| 586 | - | // resizeForFirstPaint applies attach geometry without installing an idle timer: | 720 | + | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The |
| 587 | - | // firstPaint immediately emits the required full frame itself. | 721 | + | // returned value reports whether the synchronous request was accepted. |
| 588 | - | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) { | 722 | + | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool { |
| 589 | - | d.resizeWithPaint(sess, ac, sz, false) | 723 | + | return d.requestTransactionalResize(sess, ac, sz, true) |
| 590 | - | } | |||
| 591 | - | ||||
| 592 | - | func (d *Daemon) resizeWithPaint(sess *session, ac *attachedClient, sz domain.Size, schedulePaint bool) { | |||
| 593 | - | if !sz.Valid() { | |||
| 594 | - | return | |||
| 595 | - | } | |||
| 596 | - | d.exitCopyMode(ac) | |||
| 597 | - | if ac != nil { | |||
| 598 | - | // Suspend emission until every pane has adopted this geometry and the | |||
| 599 | - | // bounded resize paint is armed. PTY readers remain independent. | |||
| 600 | - | ac.sendMu.Lock() | |||
| 601 | - | defer ac.sendMu.Unlock() | |||
| 602 | - | if ac.currentSession() != sess { | |||
| 603 | - | return | |||
| 604 | - | } | |||
| 605 | - | ac.size = sz | |||
| 606 | - | } | |||
| 607 | - | tbSize := tabSize(sz) | |||
| 608 | - | ||||
| 609 | - | sess.mu.Lock() | |||
| 610 | - | tabs := append([]*tab(nil), sess.tabs...) | |||
| 611 | - | sess.mu.Unlock() | |||
| 612 | - | if len(tabs) == 0 { | |||
| 613 | - | return | |||
| 614 | - | } | |||
| 615 | - | ||||
| 616 | - | for _, tb := range tabs { | |||
| 617 | - | tb.mu.Lock() | |||
| 618 | - | tb.size = tbSize | |||
| 619 | - | d.applyLayoutLocked(tb) | |||
| 620 | - | tb.mu.Unlock() | |||
| 621 | - | } | |||
| 622 | - | // Only the shown tab's floating terminal tracks the client size. This is | |||
| 623 | - | // outside tab.mu so its PTY resize cannot block tab state. | |||
| 624 | - | d.resizeActiveFloating(sess.activeTab()) | |||
| 625 | - | markSnapshotDirty(sess) | |||
| 626 | - | if ac != nil && schedulePaint { | |||
| 627 | - | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |||
| 628 | - | d.scheduleResizePaintLocked(sess, ac) | |||
| 629 | - | } | |||
| 630 | } | 724 | } | ||
| 631 | 725 | ||||
| 632 | // detachOnSendError drops a client whose transport failed, leaving the session | 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 | return | 730 | return | ||
| 637 | } | 731 | } | ||
| 638 | if sess.detachIfCurrent(ac) { | 732 | if sess.detachIfCurrent(ac) { | ||
| 639 | - | ac.cancelResizePaint() | 733 | + | if rc := sess.renderCoordinator(); rc != nil { |
| 734 | + | rc.noteDetach(ac) | |||
| 735 | + | } | |||
| 640 | d.unregisterPreview(ac) | 736 | d.unregisterPreview(ac) | ||
| 641 | if d.parkAttachment(sess, ac) { | 737 | if d.parkAttachment(sess, ac) { | ||
| 642 | _ = ac.closeCapturedTransport(failed) | 738 | _ = ac.closeCapturedTransport(failed) | ||
M internal/usecase/daemon/clipboard.go +1 -1
| @@ -1,6 +1,6 @@ | |||||
| 1 | // Package daemon holds vev's server-side session multiplexer use case: the | 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 | 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 | package daemon | 4 | package daemon | ||
| 5 | 5 | ||||
| 6 | import ( | 6 | import ( | ||
A internal/usecase/daemon/copy_enter_allocation_budget_race_test.go +8 -0
| @@ -0,0 +1,8 @@ | |||||
| 1 | + | //go:build race | |||
| 2 | + | ||||
| 3 | + | package daemon | |||
| 4 | + | ||||
| 5 | + | // Race instrumentation allocations are not part of the parent benchmark's | |||
| 6 | + | // allocation baseline. TestCopyEnterAllocationBudget still exercises copy | |||
| 7 | + | // entry and validates its result under -race. | |||
| 8 | + | const copyEnterAllocationBudgetEnabled = false | |||
A internal/usecase/daemon/copy_enter_allocation_budget_test.go +7 -0
| @@ -0,0 +1,7 @@ | |||||
| 1 | + | //go:build !race | |||
| 2 | + | ||||
| 3 | + | package daemon | |||
| 4 | + | ||||
| 5 | + | // copyEnterAllocationBudgetEnabled keeps the allocation gate aligned with the | |||
| 6 | + | // non-race parent benchmark, whose allocation baseline defines the budget. | |||
| 7 | + | const copyEnterAllocationBudgetEnabled = true | |||
M internal/usecase/daemon/copymode.go +10 -38
| @@ -1,27 +1,4 @@ | |||||
| 1 | - | // Package daemon holds vev's server-side session multiplexer use case: the | 1 | + | // Package daemon holds vev's server-side session multiplexer use case. |
| 2 | - | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |||
| 3 | - | // and VT screen, and the per-client debounced render scheduler. | |||
| 4 | - | // | |||
| 5 | - | // Concurrency model (sessions own one or more PTY-backed tabs): | |||
| 6 | - | // | |||
| 7 | - | // - Serve runs the accept loop. Each accepted connection is handled by its | |||
| 8 | - | // own goroutine (handleConn): it reads the first frame and routes it to a | |||
| 9 | - | // session create/attach, a list, or a kill. | |||
| 10 | - | // - Per session there are exactly two long-lived goroutines: the PTY reader | |||
| 11 | - | // (drains child output into the VT screen and pokes a cap-1 dirty channel) | |||
| 12 | - | // and the render scheduler (debounces dirties and paints the attached | |||
| 13 | - | // client). Both are tied to the session context and unwind when the | |||
| 14 | - | // session is killed (pty.Close unblocks the reader; ctx cancel stops the | |||
| 15 | - | // scheduler). | |||
| 16 | - | // - The daemon exits (Serve returns) when the last session is removed, or | |||
| 17 | - | // when the parent context is cancelled (graceful shutdown notifies any | |||
| 18 | - | // attached clients with ReasonServerShutdown). | |||
| 19 | - | // | |||
| 20 | - | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |||
| 21 | - | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |||
| 22 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | |||
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |||
| 24 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |||
| 25 | package daemon | 2 | package daemon | ||
| 26 | 3 | ||||
| 27 | import ( | 4 | import ( | ||
| @@ -62,7 +39,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||||
| 62 | if delta > 0 && rt.copyMode.AtBottom(*document) { | 39 | if delta > 0 && rt.copyMode.AtBottom(*document) { | ||
| 63 | rt.clearCopyModeLocked() | 40 | rt.clearCopyModeLocked() | ||
| 64 | rt.copyMu.Unlock() | 41 | rt.copyMu.Unlock() | ||
| 65 | - | d.paint(sess, ac, true) | 42 | + | d.invalidateRender(sess, ac, true, "copymode.go") |
| 66 | return | 43 | return | ||
| 67 | } | 44 | } | ||
| 68 | rt.copyMode.Move(*document, delta) | 45 | rt.copyMode.Move(*document, delta) | ||
| @@ -72,7 +49,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||||
| 72 | } | 49 | } | ||
| 73 | rt.copyMu.Unlock() | 50 | rt.copyMu.Unlock() | ||
| 74 | 51 | ||||
| 75 | - | d.paint(sess, ac, true) | 52 | + | d.invalidateRender(sess, ac, true, "copymode.go") |
| 76 | } | 53 | } | ||
| 77 | 54 | ||||
| 78 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | 55 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | ||
| @@ -89,12 +66,12 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 89 | // Capture the live pane under pane.mu; all subsequent copy interaction uses | 66 | // Capture the live pane under pane.mu; all subsequent copy interaction uses | ||
| 90 | // the immutable document while holding only copyMu. | 67 | // the immutable document while holding only copyMu. | ||
| 91 | p.mu.Lock() | 68 | p.mu.Lock() | ||
| 92 | - | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | 69 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame) |
| 93 | p.mu.Unlock() | 70 | p.mu.Unlock() | ||
| 94 | if !d.publishCopyMode(sess, ac, tb, p, document, nil) { | 71 | if !d.publishCopyMode(sess, ac, tb, p, document, nil) { | ||
| 95 | return | 72 | return | ||
| 96 | } | 73 | } | ||
| 97 | - | d.paint(sess, ac, true) | 74 | + | d.invalidateRender(sess, ac, true, "copymode.go") |
| 98 | } | 75 | } | ||
| 99 | 76 | ||||
| 100 | // publishCopyMode installs a non-renderable candidate, validates its captured | 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 | rt.copyMu.Unlock() | 167 | rt.copyMu.Unlock() | ||
| 191 | 168 | ||||
| 192 | if changed { | 169 | if changed { | ||
| 193 | - | d.paint(sess, ac, true) | 170 | + | d.invalidateRender(sess, ac, true, "copymode.go") |
| 194 | } | 171 | } | ||
| 195 | } | 172 | } | ||
| 196 | 173 | ||||
| @@ -220,7 +197,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||||
| 220 | changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data) | 197 | changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data) | ||
| 221 | rt.copyMu.Unlock() | 198 | rt.copyMu.Unlock() | ||
| 222 | if changed || closeSearch || accepted { | 199 | if changed || closeSearch || accepted { | ||
| 223 | - | d.paint(sess, ac, true) | 200 | + | d.invalidateRender(sess, ac, true, "copymode.go") |
| 224 | } | 201 | } | ||
| 225 | return | 202 | return | ||
| 226 | } | 203 | } | ||
| @@ -309,11 +286,11 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||||
| 309 | rt.copyMu.Unlock() | 286 | rt.copyMu.Unlock() | ||
| 310 | } | 287 | } | ||
| 311 | if exit { | 288 | if exit { | ||
| 312 | - | d.paint(sess, ac, true) | 289 | + | d.invalidateRender(sess, ac, true, "copymode.go") |
| 313 | return | 290 | return | ||
| 314 | } | 291 | } | ||
| 315 | if changed { | 292 | if changed { | ||
| 316 | - | d.paint(sess, ac, true) | 293 | + | d.invalidateRender(sess, ac, true, "copymode.go") |
| 317 | } | 294 | } | ||
| 318 | } | 295 | } | ||
| 319 | 296 | ||||
| @@ -386,7 +363,7 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) { | |||||
| 386 | rt.copyMu.Unlock() | 363 | rt.copyMu.Unlock() | ||
| 387 | 364 | ||||
| 388 | if sess := ac.currentSession(); sess != nil { | 365 | if sess := ac.currentSession(); sess != nil { | ||
| 389 | - | d.paint(sess, ac, true) | 366 | + | d.invalidateRender(sess, ac, true, "copymode.go") |
| 390 | } | 367 | } | ||
| 391 | }) | 368 | }) | ||
| 392 | } | 369 | } | ||
| @@ -425,11 +402,6 @@ func isCopyEscapePrefix(data []byte) bool { | |||||
| 425 | len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | 402 | len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | ||
| 426 | } | 403 | } | ||
| 427 | 404 | ||||
| 428 | - | func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |||
| 429 | - | styleSet := resolveThemeStyles(styles) | |||
| 430 | - | return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render) | |||
| 431 | - | } | |||
| 432 | - | ||||
| 433 | func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) { | 405 | func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) { | ||
| 434 | if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | 406 | if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | ||
| 435 | return frame, nil | 407 | return frame, nil | ||
M internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
| @@ -54,7 +54,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||||
| 54 | sess.client = ac | 54 | sess.client = ac | ||
| 55 | ac.setSession(sess) | 55 | ac.setSession(sess) | ||
| 56 | closing.mu.Lock() | 56 | closing.mu.Lock() | ||
| 57 | - | document := scopy.NewSnapshot(closing.scrollback, closing.screen.Frame) | 57 | + | document := scopy.NewSnapshot(closing.history, closing.screen.Frame) |
| 58 | closing.mu.Unlock() | 58 | closing.mu.Unlock() | ||
| 59 | ac.overlays.copyMu.Lock() | 59 | ac.overlays.copyMu.Lock() | ||
| 60 | ac.overlays.copyPane = closing | 60 | ac.overlays.copyPane = closing | ||
| @@ -94,7 +94,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||||
| 94 | tb := sess.activeTab() | 94 | tb := sess.activeTab() | ||
| 95 | p := tb.focusedPane() | 95 | p := tb.focusedPane() | ||
| 96 | p.mu.Lock() | 96 | p.mu.Lock() | ||
| 97 | - | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | 97 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame) |
| 98 | p.mu.Unlock() | 98 | p.mu.Unlock() | ||
| 99 | 99 | ||||
| 100 | // Hold session validation after publication and make the captured target | 100 | // Hold session validation after publication and make the captured target | ||
| @@ -157,7 +157,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||||
| 157 | tb := sess.activeTab() | 157 | tb := sess.activeTab() | ||
| 158 | p := tb.focusedPane() | 158 | p := tb.focusedPane() | ||
| 159 | p.mu.Lock() | 159 | p.mu.Lock() | ||
| 160 | - | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | 160 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame) |
| 161 | p.mu.Unlock() | 161 | p.mu.Unlock() | ||
| 162 | tc.invalidate(sess, tb, p) | 162 | tc.invalidate(sess, tb, p) | ||
| 163 | 163 | ||||
M internal/usecase/daemon/copymode_test.go +43 -28
| @@ -112,7 +112,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||||
| 112 | tb := sess.activeTab() | 112 | tb := sess.activeTab() | ||
| 113 | pane := tb.focusedPane() | 113 | pane := tb.focusedPane() | ||
| 114 | pane.mu.Lock() | 114 | pane.mu.Lock() | ||
| 115 | - | snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame) | 115 | + | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame) |
| 116 | pane.mu.Unlock() | 116 | pane.mu.Unlock() | ||
| 117 | mode := scopy.NewMode(snap) | 117 | mode := scopy.NewMode(snap) | ||
| 118 | bars := barState{status: sess.statusSegments(true)} | 118 | bars := barState{status: sess.statusSegments(true)} | ||
| @@ -147,7 +147,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||||
| 147 | tb := sess.activeTab() | 147 | tb := sess.activeTab() | ||
| 148 | tb.focusedPane().screen = vt.NewScreen(12, 3) | 148 | tb.focusedPane().screen = vt.NewScreen(12, 3) | ||
| 149 | tb.focusedPane().screen.Write([]byte("live")) | 149 | tb.focusedPane().screen.Write([]byte("live")) | ||
| 150 | - | snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame) | 150 | + | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame) |
| 151 | mode := scopy.NewMode(snap) | 151 | mode := scopy.NewMode(snap) | ||
| 152 | 152 | ||||
| 153 | bars := barState{status: sess.statusSegments(true)} | 153 | bars := barState{status: sess.statusSegments(true)} | ||
| @@ -166,7 +166,7 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) { | |||||
| 166 | writes := make(chan []byte, 1) | 166 | writes := make(chan []byte, 1) | ||
| 167 | p, _ := newBlockingPTYWithWrites(t, writes) | 167 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 168 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 168 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 169 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 169 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 170 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 170 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 171 | 171 | ||||
| 172 | d.handleInput(sess, ac, []byte("\x1b ")) | 172 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| @@ -293,8 +293,8 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) { | |||||
| 293 | p, _ := newBlockingPTY(t) | 293 | p, _ := newBlockingPTY(t) | ||
| 294 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 294 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 295 | pane := sess.tabs[0].focusedPane() | 295 | pane := sess.tabs[0].focusedPane() | ||
| 296 | - | pane.scrollback = scopy.NewScrollback(4) | 296 | + | installTestHistory(pane, vt.HistoryConfig{MaxRows: 4}) |
| 297 | - | pane.scrollback.Append(testRow("old alpha")) | 297 | + | pane.history.Append(testRow("old alpha")) |
| 298 | copy(pane.screen.Frame.Row(0), testRow("live alpha")) | 298 | copy(pane.screen.Frame.Row(0), testRow("live alpha")) | ||
| 299 | 299 | ||||
| 300 | d.enterCopyMode(sess, ac) | 300 | d.enterCopyMode(sess, ac) | ||
| @@ -322,9 +322,9 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||||
| 322 | writes := make(chan []byte, 1) | 322 | writes := make(chan []byte, 1) | ||
| 323 | p, _ := newBlockingPTYWithWrites(t, writes) | 323 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 324 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 324 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 325 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 325 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 326 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 ")) | 326 | + | sess.tabs[0].focusedPane().history.Append(testRow("old1 ")) |
| 327 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 ")) | 327 | + | sess.tabs[0].focusedPane().history.Append(testRow("old2 ")) |
| 328 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 328 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 329 | 329 | ||||
| 330 | d.enterCopyMode(sess, ac) | 330 | d.enterCopyMode(sess, ac) | ||
| @@ -358,7 +358,7 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||||
| 358 | t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data)) | 358 | t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data)) | ||
| 359 | } | 359 | } | ||
| 360 | 360 | ||||
| 361 | - | d.paint(sess, ac, true) | 361 | + | d.paint(sess, ac, true, nil) |
| 362 | followup := awaitFrame(t, sends, ports.MsgOutput) | 362 | followup := awaitFrame(t, sends, ports.MsgOutput) | ||
| 363 | followupMsg, err := ports.UnmarshalOutput(followup.Payload) | 363 | followupMsg, err := ports.UnmarshalOutput(followup.Payload) | ||
| 364 | require.NoError(t, err) | 364 | require.NoError(t, err) | ||
| @@ -385,11 +385,26 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 385 | p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe() | 385 | p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe() | ||
| 386 | p.EXPECT().Pid().Return(4242).Maybe() | 386 | p.EXPECT().Pid().Return(4242).Maybe() | ||
| 387 | 387 | ||||
| 388 | - | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | 388 | + | // Rendering is coordinator-driven: advance a controllable clock through the |
| 389 | + | // retained resize timer and its resulting coordinator wake rather than | |||
| 390 | + | // relying on wall-clock debounce delivery. | |||
| 391 | + | clk := newCoordinatorMockClock(t, 16) | |||
| 392 | + | d := newTestDaemon(t, newFactory(t, p), clk.clock) | |||
| 389 | tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5})) | 393 | tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5})) | ||
| 394 | + | advanceRender := func() { | |||
| 395 | + | deadline := time.NewTimer(2 * time.Second) | |||
| 396 | + | defer deadline.Stop() | |||
| 397 | + | select { | |||
| 398 | + | case timer := <-clk.timers: | |||
| 399 | + | timer.ch <- time.Time{} | |||
| 400 | + | case <-deadline.C: | |||
| 401 | + | t.Fatal("coordinator did not arm a controllable render timer") | |||
| 402 | + | } | |||
| 403 | + | } | |||
| 390 | var hg sync.WaitGroup | 404 | var hg sync.WaitGroup | ||
| 391 | hg.Go(func() { d.handleConn(tr) }) | 405 | hg.Go(func() { d.handleConn(tr) }) | ||
| 392 | awaitFrame(t, sends, ports.MsgWelcome) | 406 | awaitFrame(t, sends, ports.MsgWelcome) | ||
| 407 | + | advanceRender() // initial coordinator invalidation | |||
| 393 | awaitFrame(t, sends, ports.MsgOutput) | 408 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 394 | 409 | ||||
| 395 | for i := range 12 { | 410 | for i := range 12 { | ||
| @@ -412,7 +427,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 412 | } | 427 | } | ||
| 413 | p.mu.Lock() | 428 | p.mu.Lock() | ||
| 414 | defer p.mu.Unlock() | 429 | defer p.mu.Unlock() | ||
| 415 | - | return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 12 | 430 | + | return scopy.NewSnapshot(p.history, p.screen.Frame).Len() >= 12 |
| 416 | }, 2*time.Second, 5*time.Millisecond) | 431 | }, 2*time.Second, 5*time.Millisecond) | ||
| 417 | 432 | ||||
| 418 | sess := firstSession(d) | 433 | sess := firstSession(d) | ||
| @@ -420,9 +435,9 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 420 | ac := sess.client | 435 | ac := sess.client | ||
| 421 | require.NotNil(t, ac) | 436 | require.NotNil(t, ac) | ||
| 422 | d.handleInput(sess, ac, []byte("\x1b ")) | 437 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| 423 | - | awaitFrame(t, sends, ports.MsgOutput) | 438 | + | awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame") |
| 424 | d.handleInput(sess, ac, []byte("VIS\r")) | 439 | d.handleInput(sess, ac, []byte("VIS\r")) | ||
| 425 | - | awaitFrame(t, sends, ports.MsgOutput) | 440 | + | awaitCoordinatorOutput(t, sends, clk.timers, "while advancing render clock", "controllable timers did not produce an output frame") |
| 426 | d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'}) | 441 | d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'}) | ||
| 427 | 442 | ||||
| 428 | var payload string | 443 | var payload string | ||
| @@ -448,7 +463,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 448 | func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | 463 | func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | ||
| 449 | p, _ := newBlockingPTY(t) | 464 | p, _ := newBlockingPTY(t) | ||
| 450 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 465 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 451 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 466 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 452 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 467 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 453 | 468 | ||||
| 454 | d.enterCopyMode(sess, ac) | 469 | d.enterCopyMode(sess, ac) | ||
| @@ -482,9 +497,9 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||||
| 482 | t.Run(tc.name, func(t *testing.T) { | 497 | t.Run(tc.name, func(t *testing.T) { | ||
| 483 | p, _ := newBlockingPTY(t) | 498 | p, _ := newBlockingPTY(t) | ||
| 484 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 499 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 485 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 500 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 486 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 ")) | 501 | + | sess.tabs[0].focusedPane().history.Append(testRow("old1 ")) |
| 487 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 ")) | 502 | + | sess.tabs[0].focusedPane().history.Append(testRow("old2 ")) |
| 488 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 503 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 489 | 504 | ||||
| 490 | d.enterCopyMode(sess, ac) | 505 | d.enterCopyMode(sess, ac) | ||
| @@ -502,7 +517,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||||
| 502 | func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) { | 517 | func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) { | ||
| 503 | p, _ := newBlockingPTY(t) | 518 | p, _ := newBlockingPTY(t) | ||
| 504 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 519 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 505 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(1) | 520 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 1}) |
| 506 | longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1) | 521 | longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1) | ||
| 507 | sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1) | 522 | sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1) | ||
| 508 | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine)) | 523 | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine)) | ||
| @@ -527,7 +542,7 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) { | |||||
| 527 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 542 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 528 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | 543 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | ||
| 529 | d.clock = clk | 544 | d.clock = clk | ||
| 530 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 545 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 531 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 546 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 532 | 547 | ||||
| 533 | d.enterCopyMode(sess, ac) | 548 | d.enterCopyMode(sess, ac) | ||
| @@ -551,7 +566,7 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) { | |||||
| 551 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 566 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 552 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | 567 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | ||
| 553 | d.clock = clk | 568 | d.clock = clk | ||
| 554 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 569 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 555 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 570 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 556 | 571 | ||||
| 557 | d.enterCopyMode(sess, ac) | 572 | d.enterCopyMode(sess, ac) | ||
| @@ -594,7 +609,7 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | |||||
| 594 | t.Run(tc.name, func(t *testing.T) { | 609 | t.Run(tc.name, func(t *testing.T) { | ||
| 595 | p, _ := newBlockingPTY(t) | 610 | p, _ := newBlockingPTY(t) | ||
| 596 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 611 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 597 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 612 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 598 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 613 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 599 | 614 | ||||
| 600 | d.enterCopyMode(sess, ac) | 615 | d.enterCopyMode(sess, ac) | ||
| @@ -643,7 +658,7 @@ func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) { | |||||
| 643 | func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | 658 | func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | ||
| 644 | p, _ := newBlockingPTY(t) | 659 | p, _ := newBlockingPTY(t) | ||
| 645 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 660 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 646 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 661 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 647 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | 662 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | ||
| 648 | 663 | ||||
| 649 | done := make(chan struct{}) | 664 | done := make(chan struct{}) | ||
| @@ -665,7 +680,7 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | |||||
| 665 | d.handleInput(sess, ac, []byte("q")) | 680 | d.handleInput(sess, ac, []byte("q")) | ||
| 666 | }) | 681 | }) | ||
| 667 | wg.Go(func() { | 682 | wg.Go(func() { | ||
| 668 | - | d.paint(sess, ac, true) | 683 | + | d.paint(sess, ac, true, nil) |
| 669 | }) | 684 | }) | ||
| 670 | } | 685 | } | ||
| 671 | wg.Wait() | 686 | wg.Wait() | ||
| @@ -717,11 +732,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) { | |||||
| 717 | var want *pane | 732 | var want *pane | ||
| 718 | if tc.useFloating { | 733 | if tc.useFloating { | ||
| 719 | want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | 734 | want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | ||
| 720 | - | want.scrollback.Append(testRow("flt-old")) | 735 | + | want.history.Append(testRow("flt-old")) |
| 721 | want.screen.Write([]byte("flt-live")) | 736 | want.screen.Write([]byte("flt-live")) | ||
| 722 | } else { | 737 | } else { | ||
| 723 | want = main | 738 | want = main | ||
| 724 | - | want.scrollback.Append(testRow("one-old")) | 739 | + | want.history.Append(testRow("one-old")) |
| 725 | want.screen.Write([]byte("one-live")) | 740 | want.screen.Write([]byte("one-live")) | ||
| 726 | } | 741 | } | ||
| 727 | 742 | ||||
| @@ -758,11 +773,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||||
| 758 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | 773 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | ||
| 759 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | 774 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | ||
| 760 | for i := range 30 { | 775 | for i := range 30 { | ||
| 761 | - | fp.scrollback.Append(testRow(fmt.Sprintf("old-%02d", i))) | 776 | + | fp.history.Append(testRow(fmt.Sprintf("old-%02d", i))) |
| 762 | } | 777 | } | ||
| 763 | fp.screen.Write([]byte("live")) | 778 | fp.screen.Write([]byte("live")) | ||
| 764 | fp.mu.Lock() | 779 | fp.mu.Lock() | ||
| 765 | - | total := scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Len() | 780 | + | total := scopy.NewSnapshot(fp.history, fp.screen.Frame).Len() |
| 766 | fp.mu.Unlock() | 781 | fp.mu.Unlock() | ||
| 767 | 782 | ||||
| 768 | d.enterCopyMode(sess, ac) | 783 | d.enterCopyMode(sess, ac) | ||
| @@ -784,7 +799,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||||
| 784 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | 799 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | ||
| 785 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | 800 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | ||
| 786 | for i := range 5 { | 801 | for i := range 5 { | ||
| 787 | - | fp.scrollback.Append(testRow(fmt.Sprintf("old-%d", i))) | 802 | + | fp.history.Append(testRow(fmt.Sprintf("old-%d", i))) |
| 788 | } | 803 | } | ||
| 789 | fp.screen.Write([]byte("live")) | 804 | fp.screen.Write([]byte("live")) | ||
| 790 | 805 | ||||
M internal/usecase/daemon/daemon.go +93 -71
| @@ -1,29 +1,4 @@ | |||||
| 1 | - | // Package daemon holds vev's server-side session multiplexer use case: the | 1 | + | // Package daemon holds vev's server-side session multiplexer use case. |
| 2 | - | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |||
| 3 | - | // and VT screen, and the per-client debounced render scheduler. | |||
| 4 | - | // | |||
| 5 | - | // Concurrency model (sessions own one or more PTY-backed tabs): | |||
| 6 | - | // | |||
| 7 | - | // - Serve runs the accept loop. Each accepted connection is handled by its | |||
| 8 | - | // own goroutine (handleConn): it reads the first frame and routes it to a | |||
| 9 | - | // session create/attach, a list, or a kill. | |||
| 10 | - | // - Per session there are exactly two long-lived goroutines: the PTY reader | |||
| 11 | - | // (drains child output into the VT screen and pokes a cap-1 dirty channel) | |||
| 12 | - | // and the render scheduler (debounces dirties and paints the attached | |||
| 13 | - | // client). Both are tied to the session context and unwind when the | |||
| 14 | - | // session is killed (pty.Close unblocks the reader; ctx cancel stops the | |||
| 15 | - | // scheduler). | |||
| 16 | - | // - The daemon exits (Serve returns) when the last session is removed, or | |||
| 17 | - | // when the parent context is cancelled (graceful shutdown notifies any | |||
| 18 | - | // attached clients with ReasonServerShutdown). Client detach does not end | |||
| 19 | - | // a session: ephemeral sessions stay in memory until explicitly killed or | |||
| 20 | - | // until the daemon exits, and named sessions can also persist on disk. | |||
| 21 | - | // | |||
| 22 | - | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |||
| 23 | - | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |||
| 24 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | |||
| 25 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |||
| 26 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |||
| 27 | package daemon | 2 | package daemon | ||
| 28 | 3 | ||||
| 29 | import ( | 4 | import ( | ||
| @@ -41,14 +16,13 @@ import ( | |||||
| 41 | "github.com/bnema/vev/internal/persist" | 16 | "github.com/bnema/vev/internal/persist" | ||
| 42 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 43 | "github.com/bnema/vev/internal/usecase/keys" | 18 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 19 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 44 | ) | 20 | ) | ||
| 45 | 21 | ||||
| 46 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | 22 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | ||
| 47 | // sustained floods step toward the maximum to reduce frame/syscall pressure. | 23 | // sustained floods step toward the maximum to reduce frame/syscall pressure. | ||
| 48 | const ( | 24 | const ( | ||
| 49 | minDebounceInterval = 2 * time.Millisecond | 25 | minDebounceInterval = 2 * time.Millisecond | ||
| 50 | - | maxDebounceInterval = 16 * time.Millisecond | |||
| 51 | - | debounceStep = 2 * time.Millisecond | |||
| 52 | maxSyncUpdateDuration = 500 * time.Millisecond | 26 | maxSyncUpdateDuration = 500 * time.Millisecond | ||
| 53 | ) | 27 | ) | ||
| 54 | 28 | ||||
| @@ -117,17 +91,33 @@ type Daemon struct { | |||||
| 117 | // beforeRecentSessionHandoff is a deterministic test seam for the narrow | 91 | // beforeRecentSessionHandoff is a deterministic test seam for the narrow | ||
| 118 | // interval between JRS validation and its committed hand-off. | 92 | // interval between JRS validation and its committed hand-off. | ||
| 119 | beforeRecentSessionHandoff func() | 93 | beforeRecentSessionHandoff func() | ||
| 120 | - | 94 | + | ptys ports.PTYFactory | |
| 121 | - | ptys ports.PTYFactory | 95 | + | clock ports.Clock |
| 122 | - | clock ports.Clock | 96 | + | log *slog.Logger |
| 123 | - | log *slog.Logger | 97 | + | runtimeObserver ports.RuntimeObserver |
| 124 | - | baseEnv []string | 98 | + | baseEnv []string |
| 125 | - | shell string | 99 | + | shell string |
| 126 | - | shellArgs []string | 100 | + | shellArgs []string |
| 127 | - | persist *persist.Persister | 101 | + | persist *persist.Persister |
| 128 | - | persistEnabled bool | 102 | + | persistEnabled bool |
| 129 | - | snaps ports.SnapshotStore | 103 | + | snaps ports.SnapshotStore |
| 130 | - | snapsEnabled bool | 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 | restoreDone chan struct{} | 121 | restoreDone chan struct{} | ||
| 132 | restoreOnce sync.Once | 122 | restoreOnce sync.Once | ||
| 133 | procCwd func(int) (string, error) | 123 | procCwd func(int) (string, error) | ||
| @@ -160,8 +150,14 @@ type Daemon struct { | |||||
| 160 | done chan struct{} | 150 | done chan struct{} | ||
| 161 | doneOnce sync.Once | 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 | connWg sync.WaitGroup // per-connection handler goroutines | 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 | type parkedAttachment struct { | 163 | type parkedAttachment struct { | ||
| @@ -194,6 +190,13 @@ func (s stoppedSession) same(other stoppedSession) bool { | |||||
| 194 | 190 | ||||
| 195 | type Option func(*Daemon) | 191 | type Option func(*Daemon) | ||
| 196 | 192 | ||||
| 193 | + | // WithRuntimeObserver accepts only a composition-root serialized observer. | |||
| 194 | + | // The application owns its lifecycle; the daemon never creates or closes a | |||
| 195 | + | // second reporting worker around it. | |||
| 196 | + | func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option { | |||
| 197 | + | return func(d *Daemon) { d.runtimeObserver = observer } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 197 | // WithShell overrides the command (and its args) each session spawns. The | 200 | // WithShell overrides the command (and its args) each session spawns. The | ||
| 198 | // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a | 201 | // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a | ||
| 199 | // deterministic program. | 202 | // deterministic program. | ||
| @@ -312,6 +315,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 312 | done: make(chan struct{}), | 315 | done: make(chan struct{}), | ||
| 313 | restoreDone: make(chan struct{}), | 316 | restoreDone: make(chan struct{}), | ||
| 314 | animWake: make(chan struct{}, 1), | 317 | animWake: make(chan struct{}, 1), | ||
| 318 | + | snapshotMarshal: snapcodec.Marshal, | |||
| 319 | + | snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity), | |||
| 315 | resumeParkGrace: defaultResumeParkGrace, | 320 | resumeParkGrace: defaultResumeParkGrace, | ||
| 316 | barScripts: &barScriptState{ | 321 | barScripts: &barScriptState{ | ||
| 317 | cfg: barConfigFromDomain(domain.Defaults().Bar), | 322 | cfg: barConfigFromDomain(domain.Defaults().Bar), | ||
| @@ -382,6 +387,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||||
| 382 | }) | 387 | }) | ||
| 383 | } | 388 | } | ||
| 384 | if d.snapsEnabled { | 389 | if d.snapsEnabled { | ||
| 390 | + | d.startSnapshotEncodeWorker() | |||
| 385 | d.sessWg.Go(func() { | 391 | d.sessWg.Go(func() { | ||
| 386 | d.snapshotSaver(d.serveCtx) | 392 | d.snapshotSaver(d.serveCtx) | ||
| 387 | }) | 393 | }) | ||
| @@ -425,12 +431,17 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||||
| 425 | // remaining parked connections, so graceful notices are never raced by the | 431 | // remaining parked connections, so graceful notices are never raced by the | ||
| 426 | // force-close. Finally drain the conn handlers, run one defensive sweep in | 432 | // force-close. Finally drain the conn handlers, run one defensive sweep in | ||
| 427 | // case any ordering ever leaves a session behind, and join the session | 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 | d.shutdownAll(ports.ReasonServerShutdown) | 435 | d.shutdownAll(ports.ReasonServerShutdown) | ||
| 430 | d.waitNotifies() | 436 | d.waitNotifies() | ||
| 431 | d.hardCancel() | 437 | d.hardCancel() | ||
| 432 | d.serveCancel() | 438 | d.serveCancel() | ||
| 439 | + | // Wait for handlers before flushing snapshots: a handler that already | |||
| 440 | + | // entered killSession may still submit its terminal capture after the | |||
| 441 | + | // registry snapshot has been removed. | |||
| 433 | d.connWg.Wait() | 442 | d.connWg.Wait() | ||
| 443 | + | d.attachmentCleanupWg.Wait() | |||
| 444 | + | d.stopSnapshotEncodeWorker() | |||
| 434 | d.shutdownAll(ports.ReasonServerShutdown) | 445 | d.shutdownAll(ports.ReasonServerShutdown) | ||
| 435 | d.sessWg.Wait() | 446 | d.sessWg.Wait() | ||
| 436 | d.waitNotifies() | 447 | d.waitNotifies() | ||
| @@ -459,7 +470,9 @@ func (d *Daemon) shutdownAll(reason uint8) { | |||||
| 459 | return | 470 | return | ||
| 460 | } | 471 | } | ||
| 461 | for _, s := range snapshot { | 472 | for _, s := range snapshot { | ||
| 462 | - | _ = d.killSession(s, reason, false) | 473 | + | if err := d.killSession(s, reason, false); err != nil { |
| 474 | + | d.log.Error("closing session with unpersisted terminal state", "err", err) | |||
| 475 | + | } | |||
| 463 | } | 476 | } | ||
| 464 | } | 477 | } | ||
| 465 | 478 | ||||
| @@ -620,7 +633,23 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||||
| 620 | return | 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 | d.clientGone(sess, ac, tr, false) | 653 | d.clientGone(sess, ac, tr, false) | ||
| 625 | return | 654 | return | ||
| 626 | } | 655 | } | ||
| @@ -637,6 +666,23 @@ type protoErr struct { | |||||
| 637 | 666 | ||||
| 638 | func (e *protoErr) Error() string { return e.text } | 667 | func (e *protoErr) Error() string { return e.text } | ||
| 639 | 668 | ||||
| 669 | + | // finishAttach completes an attachment prepared while d.mu is held. It | |||
| 670 | + | // publishes the terminal state before releasing d.mu, then queues replacement | |||
| 671 | + | // teardown so obsolete worker joins never delay the new handshake. | |||
| 672 | + | func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient { | |||
| 673 | + | ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{ | |||
| 674 | + | clientID: h.ClientID, | |||
| 675 | + | resumeCapable: true, | |||
| 676 | + | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | |||
| 677 | + | }) | |||
| 678 | + | sess.mu.Lock() | |||
| 679 | + | sess.terminal = term | |||
| 680 | + | sess.mu.Unlock() | |||
| 681 | + | d.mu.Unlock() | |||
| 682 | + | d.retireReplacedClient(old, cleanup) | |||
| 683 | + | return ac | |||
| 684 | + | } | |||
| 685 | + | ||||
| 640 | // route resolves a Hello to a session and a freshly attached client, creating | 686 | // route resolves a Hello to a session and a freshly attached client, creating | ||
| 641 | // the session for ephemeral/new intents. | 687 | // the session for ephemeral/new intents. | ||
| 642 | func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) { | 688 | func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) { | ||
| @@ -687,13 +733,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 687 | } | 733 | } | ||
| 688 | } | 734 | } | ||
| 689 | d.purgeParkedForSessionLocked(sess) | 735 | d.purgeParkedForSessionLocked(sess) | ||
| 690 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)}) | 736 | + | return sess, d.finishAttach(sess, tr, sz, term, h), nil |
| 691 | - | sess.mu.Lock() | |||
| 692 | - | sess.terminal = term | |||
| 693 | - | sess.mu.Unlock() | |||
| 694 | - | d.mu.Unlock() | |||
| 695 | - | d.detachReplacedClient(old) | |||
| 696 | - | return sess, ac, nil | |||
| 697 | 737 | ||||
| 698 | case ports.IntentEphemeral: | 738 | case ports.IntentEphemeral: | ||
| 699 | name := d.allocEphemeralNameLocked() | 739 | name := d.allocEphemeralNameLocked() | ||
| @@ -703,13 +743,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 703 | return nil, nil, err | 743 | return nil, nil, err | ||
| 704 | } | 744 | } | ||
| 705 | d.purgeParkedForSessionLocked(sess) | 745 | d.purgeParkedForSessionLocked(sess) | ||
| 706 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)}) | 746 | + | return sess, d.finishAttach(sess, tr, sz, term, h), nil |
| 707 | - | sess.mu.Lock() | |||
| 708 | - | sess.terminal = term | |||
| 709 | - | sess.mu.Unlock() | |||
| 710 | - | d.mu.Unlock() | |||
| 711 | - | d.detachReplacedClient(old) | |||
| 712 | - | return sess, ac, nil | |||
| 713 | 747 | ||||
| 714 | case ports.IntentNew: | 748 | case ports.IntentNew: | ||
| 715 | if h.Name == "" { | 749 | if h.Name == "" { | ||
| @@ -730,13 +764,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 730 | return nil, nil, err | 764 | return nil, nil, err | ||
| 731 | } | 765 | } | ||
| 732 | d.purgeParkedForSessionLocked(sess) | 766 | d.purgeParkedForSessionLocked(sess) | ||
| 733 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)}) | 767 | + | return sess, d.finishAttach(sess, tr, sz, term, h), nil |
| 734 | - | sess.mu.Lock() | |||
| 735 | - | sess.terminal = term | |||
| 736 | - | sess.mu.Unlock() | |||
| 737 | - | d.mu.Unlock() | |||
| 738 | - | d.detachReplacedClient(old) | |||
| 739 | - | return sess, ac, nil | |||
| 740 | 768 | ||||
| 741 | case ports.IntentAttach: | 769 | case ports.IntentAttach: | ||
| 742 | sess := d.findByNameLocked(h.Name) | 770 | sess := d.findByNameLocked(h.Name) | ||
| @@ -755,13 +783,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 755 | } | 783 | } | ||
| 756 | } | 784 | } | ||
| 757 | d.purgeParkedForSessionLocked(sess) | 785 | d.purgeParkedForSessionLocked(sess) | ||
| 758 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)}) | 786 | + | return sess, d.finishAttach(sess, tr, sz, term, h), nil |
| 759 | - | sess.mu.Lock() | |||
| 760 | - | sess.terminal = term | |||
| 761 | - | sess.mu.Unlock() | |||
| 762 | - | d.mu.Unlock() | |||
| 763 | - | d.detachReplacedClient(old) | |||
| 764 | - | return sess, ac, nil | |||
| 765 | 787 | ||||
| 766 | default: | 788 | default: | ||
| 767 | d.mu.Unlock() | 789 | d.mu.Unlock() | ||
M internal/usecase/daemon/daemon_test.go +99 -5
| @@ -142,7 +142,7 @@ func newBlockingOpenFactory(t *testing.T, d *Daemon) *blockingOpenFactory { | |||||
| 142 | return f | 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 | <-f.release | 146 | <-f.release | ||
| 147 | return nil, io.ErrClosedPipe | 147 | return nil, io.ErrClosedPipe | ||
| 148 | } | 148 | } | ||
| @@ -154,8 +154,8 @@ func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory { | |||||
| 154 | t.Helper() | 154 | t.Helper() | ||
| 155 | f := portsmocks.NewMockPTYFactory(t) | 155 | f := portsmocks.NewMockPTYFactory(t) | ||
| 156 | var normal domain.Size | 156 | var normal domain.Size | ||
| 157 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 157 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( |
| 158 | - | func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) { | 158 | + | func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) { |
| 159 | if !normal.Valid() { | 159 | if !normal.Valid() { | ||
| 160 | normal = sz | 160 | normal = sz | ||
| 161 | } | 161 | } | ||
| @@ -174,8 +174,8 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory { | |||||
| 174 | var mu sync.Mutex | 174 | var mu sync.Mutex | ||
| 175 | var normal domain.Size | 175 | var normal domain.Size | ||
| 176 | next := 0 | 176 | next := 0 | ||
| 177 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 177 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( |
| 178 | - | func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) { | 178 | + | func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) { |
| 179 | mu.Lock() | 179 | mu.Lock() | ||
| 180 | defer mu.Unlock() | 180 | defer mu.Unlock() | ||
| 181 | if !normal.Valid() { | 181 | if !normal.Valid() { | ||
| @@ -286,6 +286,54 @@ func listSessions(t *testing.T, d *Daemon) ports.Sessions { | |||||
| 286 | return sessions | 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 | func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports.Frame) { | 337 | func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports.Frame) { | ||
| 290 | t.Helper() | 338 | t.Helper() | ||
| 291 | tr := portsmocks.NewMockTransport(t) | 339 | tr := portsmocks.NewMockTransport(t) | ||
| @@ -395,6 +443,52 @@ func (r realTimer) C() <-chan time.Time { return r.t.C } | |||||
| 395 | func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) } | 443 | func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) } | ||
| 396 | func (r realTimer) Stop() bool { return r.t.Stop() } | 444 | func (r realTimer) Stop() bool { return r.t.Stop() } | ||
| 397 | 445 | ||||
| 446 | + | func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) { | |||
| 447 | + | pty, releasePTY := newBlockingPTY(t) | |||
| 448 | + | defer releasePTY() | |||
| 449 | + | clock := newCoordinatorMockClock(t, 4) | |||
| 450 | + | d := newTestDaemon(t, newFactory(t, pty), clock.clock) | |||
| 451 | + | tr := newWelcomeBlockingTransport(t) | |||
| 452 | + | done := make(chan struct{}) | |||
| 453 | + | go func() { | |||
| 454 | + | d.handleHello(tr.tr, mustHello(ports.IntentNew, "welcome-gate", domain.Size{Cols: 80, Rows: 24})) | |||
| 455 | + | close(done) | |||
| 456 | + | }() | |||
| 457 | + | ||||
| 458 | + | awaitTestCompletion(t, tr.welcomeEntered, "timed out waiting for Welcome send") | |||
| 459 | + | welcome := awaitFrame(t, tr.sends, ports.MsgWelcome) | |||
| 460 | + | require.Equal(t, ports.MsgWelcome, welcome.Type) | |||
| 461 | + | sess := firstSession(d) | |||
| 462 | + | sess.mu.Lock() | |||
| 463 | + | ac := sess.client | |||
| 464 | + | sess.mu.Unlock() | |||
| 465 | + | require.NotNil(t, ac, "route must publish the attachment before Welcome returns") | |||
| 466 | + | ||||
| 467 | + | // Exercise the real producer fan-in after publication. The deadline worker | |||
| 468 | + | // has completed before asserting the gate, so this is not a timing race. | |||
| 469 | + | d.invalidateRender(sess, ac, true, "welcome-gate-test") | |||
| 470 | + | // Attach-time theme setup may have armed an older bulk timer. The fresh | |||
| 471 | + | // urgent producer replaces it, so advance the newest published deadline. | |||
| 472 | + | timer := awaitLatestCoordinatorTimer(t, clock) | |||
| 473 | + | rc := sess.renderCoordinator() | |||
| 474 | + | rc.mu.Lock() | |||
| 475 | + | workerDone := rc.normalLane.token.done | |||
| 476 | + | rc.mu.Unlock() | |||
| 477 | + | require.NotNil(t, workerDone) | |||
| 478 | + | timer.ch <- time.Time{} | |||
| 479 | + | awaitTestCompletion(t, workerDone, "coordinator deadline worker did not complete") | |||
| 480 | + | requireNoCoordinatorOutputFrame(t, tr.sends) | |||
| 481 | + | ||||
| 482 | + | tr.release() | |||
| 483 | + | output := awaitFrame(t, tr.sends, ports.MsgOutput) | |||
| 484 | + | first, err := ports.UnmarshalOutput(output.Payload) | |||
| 485 | + | require.NoError(t, err) | |||
| 486 | + | require.Zero(t, first.BaseStateNum, "the first post-Welcome frame is full") | |||
| 487 | + | tr.finish() | |||
| 488 | + | awaitTestCompletion(t, done, "timed out waiting for Welcome handler completion") | |||
| 489 | + | requireNoCoordinatorOutputFrame(t, tr.sends) | |||
| 490 | + | } | |||
| 491 | + | ||||
| 398 | // TestServeReturnsDespiteWedgedClientOnShutdown: killSession's teardown must | 492 | // TestServeReturnsDespiteWedgedClientOnShutdown: killSession's teardown must | ||
| 399 | // not be gated behind the Detached notice. The client transport wedges every | 493 | // not be gated behind the Detached notice. The client transport wedges every | ||
| 400 | // MsgDetached send until the transport is closed (mirroring a full kernel | 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 | // activateTab performs the work associated with making a tab the destination. | 154 | // activateTab performs the work associated with making a tab the destination. | ||
| 155 | // It is deliberately idempotent: every tab transition can use this single | 155 | // It is deliberately idempotent: every tab transition can use this single | ||
| 156 | // hook, while inactive restored tabs remain cold until actually selected. | 156 | // hook, while inactive restored tabs remain cold until actually selected. | ||
| 157 | - | func (d *Daemon) activateTab(sess *session, tb *tab) { | 157 | + | func (d *Daemon) activateTab(sess *session, tb *tab) bool { |
| 158 | + | return d.activateTabAfterResize(sess, tb, false) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // activateTabAfterResize retains tab activation's warmup work while avoiding a | |||
| 162 | + | // second resize when a synchronous outer resize request was already accepted. | |||
| 163 | + | func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccepted bool) bool { | |||
| 158 | if d == nil || sess == nil || tb == nil { | 164 | if d == nil || sess == nil || tb == nil { | ||
| 159 | - | return | 165 | + | return false |
| 160 | } | 166 | } | ||
| 161 | // A headless session has no actual terminal destination. Deferring warmup | 167 | // A headless session has no actual terminal destination. Deferring warmup | ||
| 162 | // until firstPaint keeps restored tabs cold and avoids launching children | 168 | // until firstPaint keeps restored tabs cold and avoids launching children | ||
| @@ -166,10 +172,20 @@ func (d *Daemon) activateTab(sess *session, tb *tab) { | |||||
| 166 | sess.mu.Unlock() | 172 | sess.mu.Unlock() | ||
| 167 | d.exitCopyMode(ac) | 173 | d.exitCopyMode(ac) | ||
| 168 | if ac == nil { | 174 | if ac == nil { | ||
| 169 | - | return | 175 | + | return false |
| 170 | } | 176 | } | ||
| 171 | d.ensureFloatingWarm(sess, tb) | 177 | d.ensureFloatingWarm(sess, tb) | ||
| 172 | - | d.resizeInstalledFloating(tb) | 178 | + | if outerResizeAccepted { |
| 179 | + | return false | |||
| 180 | + | } | |||
| 181 | + | tb.mu.Lock() | |||
| 182 | + | hasFloating := tb.floating.pane != nil | |||
| 183 | + | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |||
| 184 | + | tb.mu.Unlock() | |||
| 185 | + | if hasFloating { | |||
| 186 | + | return d.requestTransactionalResize(sess, ac, size, true) | |||
| 187 | + | } | |||
| 188 | + | return false | |||
| 173 | } | 189 | } | ||
| 174 | 190 | ||||
| 175 | // toggleFloating changes only this tab's slot. Opening an uninitialized slot | 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 | cfg := d.currentFloatingConfig() | 201 | cfg := d.currentFloatingConfig() | ||
| 186 | tb.mu.Lock() | 202 | tb.mu.Lock() | ||
| 203 | + | wasVisible := tb.floating.state == floatingVisible | |||
| 187 | start, generation := tb.toggleFloatingLocked() | 204 | start, generation := tb.toggleFloatingLocked() | ||
| 188 | // desiredVisible is meaningful only while a launch is warming. Installed | 205 | // desiredVisible is meaningful only while a launch is warming. Installed | ||
| 189 | // slots derive their resize and paint work from the resulting slot state. | 206 | // slots derive their resize and paint work from the resulting slot state. | ||
| 190 | visible := tb.floating.state == floatingVisible | 207 | visible := tb.floating.state == floatingVisible | ||
| 208 | + | hidden := wasVisible && tb.floating.state == floatingHidden | |||
| 191 | tb.mu.Unlock() | 209 | tb.mu.Unlock() | ||
| 192 | if start { | 210 | if start { | ||
| 193 | d.launchFloating(sess, tb, cfg, generation, true) | 211 | d.launchFloating(sess, tb, cfg, generation, true) | ||
| 194 | if ac != nil { | 212 | if ac != nil { | ||
| 195 | - | d.paint(sess, ac, true) | 213 | + | d.invalidateRender(sess, ac, true, "floating.go") |
| 196 | } | 214 | } | ||
| 197 | return nil | 215 | return nil | ||
| 198 | } | 216 | } | ||
| 199 | - | if visible { | 217 | + | if hidden { |
| 200 | - | d.resizeActiveFloating(tb) | 218 | + | d.invalidateRender(sess, ac, true, "floating.go") |
| 219 | + | return nil | |||
| 201 | } | 220 | } | ||
| 202 | - | if ac != nil { | 221 | + | if visible { |
| 203 | - | d.paint(sess, ac, true) | 222 | + | tb.mu.Lock() |
| 223 | + | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |||
| 224 | + | tb.mu.Unlock() | |||
| 225 | + | d.requestTransactionalResize(sess, ac, size, true) | |||
| 204 | } | 226 | } | ||
| 205 | return nil | 227 | return nil | ||
| 206 | } | 228 | } | ||
| @@ -218,6 +240,40 @@ func (d *Daemon) startFloating(sess *session, tb *tab, visible bool) { | |||||
| 218 | 240 | ||||
| 219 | // floatingLaunchSpec is an immutable snapshot of one launch. It is built | 241 | // floatingLaunchSpec is an immutable snapshot of one launch. It is built | ||
| 220 | // before the worker starts so config reloads cannot affect that launch. | 242 | // before the worker starts so config reloads cannot affect that launch. | ||
| 243 | + | // floatingLaunch owns one worker from queueing through installation or stale | |||
| 244 | + | // result cleanup. Its done channel lets session teardown reap an in-flight | |||
| 245 | + | // Open before returning. | |||
| 246 | + | type floatingLaunch struct { | |||
| 247 | + | done chan struct{} | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func (sess *session) registerFloatingLaunch() (*floatingLaunch, bool) { | |||
| 251 | + | launch := &floatingLaunch{done: make(chan struct{})} | |||
| 252 | + | sess.floatingLaunchMu.Lock() | |||
| 253 | + | defer sess.floatingLaunchMu.Unlock() | |||
| 254 | + | if sess.floatingLaunchStopping { | |||
| 255 | + | return nil, false | |||
| 256 | + | } | |||
| 257 | + | if sess.floatingLaunches == nil { | |||
| 258 | + | sess.floatingLaunches = make(map[*floatingLaunch]struct{}) | |||
| 259 | + | } | |||
| 260 | + | sess.floatingLaunches[launch] = struct{}{} | |||
| 261 | + | return launch, true | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func (sess *session) finishFloatingLaunch(launch *floatingLaunch) { | |||
| 265 | + | sess.floatingLaunchMu.Lock() | |||
| 266 | + | delete(sess.floatingLaunches, launch) | |||
| 267 | + | close(launch.done) | |||
| 268 | + | sess.floatingLaunchMu.Unlock() | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func (sess *session) stopFloatingLaunches() { | |||
| 272 | + | sess.floatingLaunchMu.Lock() | |||
| 273 | + | sess.floatingLaunchStopping = true | |||
| 274 | + | sess.floatingLaunchMu.Unlock() | |||
| 275 | + | } | |||
| 276 | + | ||||
| 221 | type floatingLaunchSpec struct { | 277 | type floatingLaunchSpec struct { | ||
| 222 | sessionName string | 278 | sessionName string | ||
| 223 | cwd string | 279 | cwd string | ||
| @@ -233,17 +289,25 @@ type floatingLaunchSpec struct { | |||||
| 233 | } | 289 | } | ||
| 234 | 290 | ||||
| 235 | // launchFloating snapshots launch inputs, then accounts for a worker through | 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 | func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) { | 293 | func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) { | ||
| 238 | spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen) | 294 | spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen) | ||
| 239 | if err != nil { | 295 | if err != nil { | ||
| 240 | d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err) | 296 | d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err) | ||
| 241 | return | 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 | // Count the launch before its goroutine starts. It remains counted through | 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 | // before a late Open completion has either been rejected or fully joined. | 306 | // before a late Open completion has either been rejected or fully joined. | ||
| 246 | - | d.sessWg.Go(func() { d.openAndInstallFloating(sess, tb, spec, generation) }) | 307 | + | d.sessWg.Go(func() { |
| 308 | + | defer sess.finishFloatingLaunch(launch) | |||
| 309 | + | d.openAndInstallFloating(sess, tb, spec, generation) | |||
| 310 | + | }) | |||
| 247 | } | 311 | } | ||
| 248 | 312 | ||||
| 249 | func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) { | 313 | func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) { | ||
| @@ -300,7 +364,22 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||||
| 300 | // openAndInstallFloating runs entirely in the launch worker. No PTY operation | 364 | // openAndInstallFloating runs entirely in the launch worker. No PTY operation | ||
| 301 | // occurs under tb.mu; the pane is fully initialized before publication. | 365 | // occurs under tb.mu; the pane is fully initialized before publication. | ||
| 302 | func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) { | 366 | func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) { | ||
| 303 | - | pty, err := d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size) | 367 | + | // Open is context-aware and is intentionally called without any daemon, |
| 368 | + | // session, tab, or launch-ownership lock. Cancellation makes teardown | |||
| 369 | + | // bounded even if the adapter is waiting to create the child. | |||
| 370 | + | if err := spec.parentCtx.Err(); err != nil { | |||
| 371 | + | return | |||
| 372 | + | } | |||
| 373 | + | openCtx, cancelOpen := context.WithCancel(spec.parentCtx) | |||
| 374 | + | stopSessionCancel := context.AfterFunc(sess.ctx, cancelOpen) | |||
| 375 | + | ownedByPane := false | |||
| 376 | + | defer func() { | |||
| 377 | + | if !ownedByPane { | |||
| 378 | + | stopSessionCancel() | |||
| 379 | + | cancelOpen() | |||
| 380 | + | } | |||
| 381 | + | }() | |||
| 382 | + | pty, err := d.ptys.Open(openCtx, spec.command, spec.args, spec.env, spec.cwd, spec.size) | |||
| 304 | if err != nil { | 383 | if err != nil { | ||
| 305 | d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err) | 384 | d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err) | ||
| 306 | return | 385 | return | ||
| @@ -309,7 +388,14 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||||
| 309 | p.rect = spec.geometry.Inner | 388 | p.rect = spec.geometry.Inner | ||
| 310 | p.popupGeometry = spec.geometry | 389 | p.popupGeometry = spec.geometry | ||
| 311 | p.title.displayFallback = spec.fallback | 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 | // The reader may run as soon as install returns; make its exit policy | 399 | // The reader may run as soon as install returns; make its exit policy | ||
| 314 | // immutable before publishing the pane to the slot. | 400 | // immutable before publishing the pane to the slot. | ||
| 315 | p.onExit = func() { d.reapFloating(sess, tb, p, generation) } | 401 | p.onExit = func() { d.reapFloating(sess, tb, p, generation) } | ||
| @@ -343,13 +429,13 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin | |||||
| 343 | d.reapplyThemeSession(sess) | 429 | d.reapplyThemeSession(sess) | ||
| 344 | d.startPaneGoroutines(sess, tb, p) | 430 | d.startPaneGoroutines(sess, tb, p) | ||
| 345 | if visible { | 431 | if visible { | ||
| 346 | - | d.resizeActiveFloating(tb) | |||
| 347 | sess.mu.Lock() | 432 | sess.mu.Lock() | ||
| 348 | ac := sess.client | 433 | ac := sess.client | ||
| 349 | sess.mu.Unlock() | 434 | sess.mu.Unlock() | ||
| 350 | - | if ac != nil { | 435 | + | tb.mu.Lock() |
| 351 | - | d.paint(sess, ac, true) | 436 | + | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} |
| 352 | - | } | 437 | + | tb.mu.Unlock() |
| 438 | + | d.requestTransactionalResize(sess, ac, size, true) | |||
| 353 | } | 439 | } | ||
| 354 | } | 440 | } | ||
| 355 | 441 | ||||
| @@ -367,9 +453,12 @@ func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64 | |||||
| 367 | sess.mu.Lock() | 453 | sess.mu.Lock() | ||
| 368 | ac := sess.client | 454 | ac := sess.client | ||
| 369 | sess.mu.Unlock() | 455 | sess.mu.Unlock() | ||
| 456 | + | if ac != nil { | |||
| 457 | + | ac.pruneCaptureFrames(p) | |||
| 458 | + | } | |||
| 370 | copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p) | 459 | copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p) | ||
| 371 | if ac != nil && (visible || copyCleared) { | 460 | if ac != nil && (visible || copyCleared) { | ||
| 372 | - | d.paint(sess, ac, true) | 461 | + | d.invalidateRender(sess, ac, true, "floating.go") |
| 373 | } | 462 | } | ||
| 374 | } | 463 | } | ||
| 375 | 464 | ||||
| @@ -383,6 +472,7 @@ func (d *Daemon) teardownFloating(tb *tab, ac *attachedClient) { | |||||
| 383 | tb.mu.Unlock() | 472 | tb.mu.Unlock() | ||
| 384 | if ac != nil { | 473 | if ac != nil { | ||
| 385 | ac.overlays.clearCopyModeForPane(p) | 474 | ac.overlays.clearCopyModeForPane(p) | ||
| 475 | + | ac.pruneCaptureFrames(p) | |||
| 386 | } | 476 | } | ||
| 387 | closeFloatingPane(p) | 477 | closeFloatingPane(p) | ||
| 388 | } | 478 | } | ||
A internal/usecase/daemon/floating_open_test.go +299 -0
| @@ -0,0 +1,299 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | type contextAwareFloatingPTY struct { | |||
| 18 | + | ctx context.Context | |||
| 19 | + | readerStarted chan struct{} | |||
| 20 | + | closed chan struct{} | |||
| 21 | + | readerOnce sync.Once | |||
| 22 | + | closeOnce sync.Once | |||
| 23 | + | closeCount int | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func newContextAwareFloatingPTY() *contextAwareFloatingPTY { | |||
| 27 | + | return &contextAwareFloatingPTY{ | |||
| 28 | + | readerStarted: make(chan struct{}), | |||
| 29 | + | closed: make(chan struct{}), | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (p *contextAwareFloatingPTY) Read([]byte) (int, error) { | |||
| 34 | + | p.readerOnce.Do(func() { close(p.readerStarted) }) | |||
| 35 | + | <-p.ctx.Done() | |||
| 36 | + | return 0, p.ctx.Err() | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (*contextAwareFloatingPTY) Write(b []byte) (int, error) { return len(b), nil } | |||
| 40 | + | func (*contextAwareFloatingPTY) Resize(domain.Size) error { return nil } | |||
| 41 | + | func (*contextAwareFloatingPTY) Pid() int { return 1 } | |||
| 42 | + | func (*contextAwareFloatingPTY) ForegroundPgid() (int, error) { | |||
| 43 | + | return 1, nil | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (p *contextAwareFloatingPTY) Close() error { | |||
| 47 | + | p.closeOnce.Do(func() { | |||
| 48 | + | p.closeCount++ | |||
| 49 | + | close(p.closed) | |||
| 50 | + | }) | |||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | type contextAwareFloatingFactory struct { | |||
| 55 | + | pty *contextAwareFloatingPTY | |||
| 56 | + | opened chan context.Context | |||
| 57 | + | err error | |||
| 58 | + | onOpen func() | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) { | |||
| 62 | + | if f.pty != nil { | |||
| 63 | + | f.pty.ctx = ctx | |||
| 64 | + | } | |||
| 65 | + | f.opened <- ctx | |||
| 66 | + | if f.onOpen != nil { | |||
| 67 | + | f.onOpen() | |||
| 68 | + | } | |||
| 69 | + | return f.pty, f.err | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | |||
| 73 | + | teardowns := []struct { | |||
| 74 | + | name string | |||
| 75 | + | teardown func(*Daemon, *session, *tab) | |||
| 76 | + | }{ | |||
| 77 | + | { | |||
| 78 | + | name: "floating pane", | |||
| 79 | + | teardown: func(d *Daemon, _ *session, tb *tab) { | |||
| 80 | + | d.teardownFloating(tb, nil) | |||
| 81 | + | }, | |||
| 82 | + | }, | |||
| 83 | + | { | |||
| 84 | + | name: "tab", | |||
| 85 | + | teardown: func(_ *Daemon, _ *session, tb *tab) { | |||
| 86 | + | tb.closeAllPanes() | |||
| 87 | + | }, | |||
| 88 | + | }, | |||
| 89 | + | { | |||
| 90 | + | name: "session", | |||
| 91 | + | teardown: func(d *Daemon, sess *session, _ *tab) { | |||
| 92 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |||
| 93 | + | }, | |||
| 94 | + | }, | |||
| 95 | + | } | |||
| 96 | + | for _, tt := range teardowns { | |||
| 97 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 98 | + | pty := newContextAwareFloatingPTY() | |||
| 99 | + | factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)} | |||
| 100 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 101 | + | tb := newFloatingTestTab(t) | |||
| 102 | + | tabCtx, cancelTab := context.WithCancel(t.Context()) | |||
| 103 | + | tb.ctx, tb.cancel = tabCtx, cancelTab | |||
| 104 | + | sessCtx, cancelSession := context.WithCancel(t.Context()) | |||
| 105 | + | sess := &session{id: "context-" + domain.SessionID(tt.name), name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession} | |||
| 106 | + | d.mu.Lock() | |||
| 107 | + | d.sessions[sess.id] = sess | |||
| 108 | + | d.mu.Unlock() | |||
| 109 | + | tb.mu.Lock() | |||
| 110 | + | generation := tb.beginFloatingWarmLocked(true) | |||
| 111 | + | tb.mu.Unlock() | |||
| 112 | + | ||||
| 113 | + | d.openAndInstallFloating(sess, tb, floatingLaunchSpec{ | |||
| 114 | + | sessionName: "work", | |||
| 115 | + | size: domain.Size{Cols: 20, Rows: 8}, | |||
| 116 | + | geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}}, | |||
| 117 | + | paneStableID: "p_floating", | |||
| 118 | + | parentCtx: tabCtx, | |||
| 119 | + | }, generation) | |||
| 120 | + | ||||
| 121 | + | openCtx := <-factory.opened | |||
| 122 | + | require.NoError(t, openCtx.Err(), "the PTY context must outlive the launch worker") | |||
| 123 | + | tb.mu.Lock() | |||
| 124 | + | floating := tb.floating.pane | |||
| 125 | + | tb.mu.Unlock() | |||
| 126 | + | require.NotNil(t, floating) | |||
| 127 | + | sess.mu.Lock() | |||
| 128 | + | sess.client = &attachedClient{} | |||
| 129 | + | sess.mu.Unlock() | |||
| 130 | + | require.True(t, d.paneRenderable(sess, tb, floating), "installed visible popup must remain renderable") | |||
| 131 | + | sess.mu.Lock() | |||
| 132 | + | sess.client = nil | |||
| 133 | + | sess.mu.Unlock() | |||
| 134 | + | select { | |||
| 135 | + | case <-pty.readerStarted: | |||
| 136 | + | case <-time.After(time.Second): | |||
| 137 | + | t.Fatal("floating reader did not start") | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | tt.teardown(d, sess, tb) | |||
| 141 | + | select { | |||
| 142 | + | case <-openCtx.Done(): | |||
| 143 | + | case <-time.After(time.Second): | |||
| 144 | + | t.Fatal("floating PTY context was not cancelled by teardown") | |||
| 145 | + | } | |||
| 146 | + | select { | |||
| 147 | + | case <-pty.closed: | |||
| 148 | + | case <-time.After(time.Second): | |||
| 149 | + | t.Fatal("floating PTY was not closed by teardown") | |||
| 150 | + | } | |||
| 151 | + | d.sessWg.Wait() | |||
| 152 | + | require.Equal(t, 1, pty.closeCount, "floating PTY must close exactly once") | |||
| 153 | + | }) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestFloatingFailedAndStaleOpenCancelContext(t *testing.T) { | |||
| 158 | + | tests := []struct { | |||
| 159 | + | name string | |||
| 160 | + | setup func(*tab, *contextAwareFloatingFactory) | |||
| 161 | + | }{ | |||
| 162 | + | { | |||
| 163 | + | name: "failed Open", | |||
| 164 | + | setup: func(_ *tab, factory *contextAwareFloatingFactory) { | |||
| 165 | + | factory.err = context.Canceled | |||
| 166 | + | }, | |||
| 167 | + | }, | |||
| 168 | + | { | |||
| 169 | + | name: "stale install", | |||
| 170 | + | setup: func(tb *tab, factory *contextAwareFloatingFactory) { | |||
| 171 | + | factory.onOpen = func() { | |||
| 172 | + | tb.mu.Lock() | |||
| 173 | + | tb.takeFloatingLocked() | |||
| 174 | + | tb.mu.Unlock() | |||
| 175 | + | } | |||
| 176 | + | }, | |||
| 177 | + | }, | |||
| 178 | + | } | |||
| 179 | + | for _, tt := range tests { | |||
| 180 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 181 | + | pty := newContextAwareFloatingPTY() | |||
| 182 | + | factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)} | |||
| 183 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 184 | + | tb := newFloatingTestTab(t) | |||
| 185 | + | sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()} | |||
| 186 | + | tb.mu.Lock() | |||
| 187 | + | generation := tb.beginFloatingWarmLocked(true) | |||
| 188 | + | tb.mu.Unlock() | |||
| 189 | + | tt.setup(tb, factory) | |||
| 190 | + | ||||
| 191 | + | d.openAndInstallFloating(sess, tb, floatingLaunchSpec{ | |||
| 192 | + | sessionName: "work", | |||
| 193 | + | size: domain.Size{Cols: 20, Rows: 8}, | |||
| 194 | + | geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}}, | |||
| 195 | + | paneStableID: "p_floating", | |||
| 196 | + | parentCtx: tb.ctx, | |||
| 197 | + | }, generation) | |||
| 198 | + | ||||
| 199 | + | openCtx := <-factory.opened | |||
| 200 | + | select { | |||
| 201 | + | case <-openCtx.Done(): | |||
| 202 | + | case <-time.After(time.Second): | |||
| 203 | + | t.Fatal("failed or stale floating Open retained its context") | |||
| 204 | + | } | |||
| 205 | + | tb.mu.Lock() | |||
| 206 | + | require.Nil(t, tb.floating.pane) | |||
| 207 | + | tb.mu.Unlock() | |||
| 208 | + | if tt.name == "stale install" { | |||
| 209 | + | select { | |||
| 210 | + | case <-pty.closed: | |||
| 211 | + | case <-time.After(time.Second): | |||
| 212 | + | t.Fatal("stale floating PTY was not closed") | |||
| 213 | + | } | |||
| 214 | + | require.Equal(t, 1, pty.closeCount) | |||
| 215 | + | } | |||
| 216 | + | }) | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testing.T) { | |||
| 221 | + | factory := portsmocks.NewMockPTYFactory(t) | |||
| 222 | + | opened := make(chan struct{}) | |||
| 223 | + | latePTY := portsmocks.NewMockPTY(t) | |||
| 224 | + | closed := make(chan struct{}) | |||
| 225 | + | latePTY.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once() | |||
| 226 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 227 | + | func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) { | |||
| 228 | + | close(opened) | |||
| 229 | + | <-ctx.Done() | |||
| 230 | + | return latePTY, nil | |||
| 231 | + | }).Once() | |||
| 232 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 233 | + | tb := newFloatingTestTab(t) | |||
| 234 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 235 | + | sess := &session{id: "blocked-open", name: "work", tabs: []*tab{tb}, ctx: ctx, cancel: cancel} | |||
| 236 | + | d.mu.Lock() | |||
| 237 | + | d.sessions[sess.id] = sess | |||
| 238 | + | d.mu.Unlock() | |||
| 239 | + | d.startFloating(sess, tb, true) | |||
| 240 | + | select { | |||
| 241 | + | case <-opened: | |||
| 242 | + | case <-time.After(time.Second): | |||
| 243 | + | t.Fatal("floating Open did not begin") | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | killed := make(chan error, 1) | |||
| 247 | + | go func() { killed <- d.killSession(sess, ports.ReasonSessionKilled, false) }() | |||
| 248 | + | select { | |||
| 249 | + | case err := <-killed: | |||
| 250 | + | require.NoError(t, err) | |||
| 251 | + | case <-time.After(time.Second): | |||
| 252 | + | t.Fatal("session teardown waited for blocked Open") | |||
| 253 | + | } | |||
| 254 | + | select { | |||
| 255 | + | case <-closed: | |||
| 256 | + | case <-time.After(time.Second): | |||
| 257 | + | t.Fatal("late Open PTY was not closed") | |||
| 258 | + | } | |||
| 259 | + | tb.mu.Lock() | |||
| 260 | + | require.Nil(t, tb.floating.pane, "late PTY must not be installed") | |||
| 261 | + | tb.mu.Unlock() | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func TestFloatingOpensFromUnrelatedSessionsAreConcurrent(t *testing.T) { | |||
| 265 | + | factory := portsmocks.NewMockPTYFactory(t) | |||
| 266 | + | entered := make(chan struct{}, 2) | |||
| 267 | + | release := make(chan struct{}) | |||
| 268 | + | var once sync.Once | |||
| 269 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 270 | + | func(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) { | |||
| 271 | + | entered <- struct{}{} | |||
| 272 | + | <-release | |||
| 273 | + | return newQuietPTY(), nil | |||
| 274 | + | }).Twice() | |||
| 275 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 276 | + | tabs := make([]*tab, 0, 2) | |||
| 277 | + | cancels := make([]context.CancelFunc, 0, 2) | |||
| 278 | + | for _, id := range []domain.SessionID{"one", "two"} { | |||
| 279 | + | tb := newFloatingTestTab(t) | |||
| 280 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 281 | + | sess := &session{id: id, name: string(id), tabs: []*tab{tb}, ctx: ctx, cancel: cancel} | |||
| 282 | + | tabs = append(tabs, tb) | |||
| 283 | + | cancels = append(cancels, cancel) | |||
| 284 | + | d.startFloating(sess, tb, false) | |||
| 285 | + | } | |||
| 286 | + | for range 2 { | |||
| 287 | + | select { | |||
| 288 | + | case <-entered: | |||
| 289 | + | case <-time.After(time.Second): | |||
| 290 | + | t.Fatal("unrelated floating launch was serialized") | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | once.Do(func() { close(release) }) | |||
| 294 | + | for i, cancel := range cancels { | |||
| 295 | + | cancel() | |||
| 296 | + | d.teardownFloating(tabs[i], nil) | |||
| 297 | + | } | |||
| 298 | + | d.sessWg.Wait() | |||
| 299 | + | } | |||
M internal/usecase/daemon/floating_render.go +12 -113
| @@ -85,60 +85,26 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo | |||||
| 85 | } | 85 | } | ||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | - | // composeFloatingFrame applies the popup to a copy of the normal composed | 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 | - | // destination. In particular it never writes the VT screen frame: that frame | 89 | + | frame := base.Clone() |
| 90 | - | // is shared with the PTY reader and is the source for future renders. | 90 | + | legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid} |
| 91 | - | func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) { | 91 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme) |
| 92 | - | var frame renderer.Frame | 92 | + | geometry := floating.geometry.translate(content.X, content.Y) |
| 93 | - | if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height { | 93 | + | blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, theme) |
| 94 | - | frame = cache.floatingFrame | |||
| 95 | - | for y := 0; y < base.Height; y++ { | |||
| 96 | - | copy(frame.Row(y), base.Row(y)) | |||
| 97 | - | } | |||
| 98 | - | } else { | |||
| 99 | - | frame = base.Clone() | |||
| 100 | - | if cache != nil { | |||
| 101 | - | cache.floatingFrame = frame | |||
| 102 | - | } | |||
| 103 | - | } | |||
| 104 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme) | |||
| 105 | - | // The PTY reader mutates both the frame and its damage under p.mu. Keep | |||
| 106 | - | // every read of them, including the blit, in the same critical section. | |||
| 107 | - | // In particular, Frame is not safe to retain as an alias after unlocking. | |||
| 108 | - | p.mu.Lock() | |||
| 109 | - | // Committed popup geometry is content-relative; translate it exactly once | |||
| 110 | - | // while holding the same lock that protects the committed state and frame. | |||
| 111 | - | geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y) | |||
| 112 | - | title := p.displayTitleLocked() | |||
| 113 | - | titleGeneration := p.title.generation | |||
| 114 | - | screenDamage := p.screen.Damage() | |||
| 115 | - | blitPaneFrame(frame, geometry.Inner, p.screen.Frame, false, theme) | |||
| 116 | damage := append([]renderer.Damage(nil), baseDamage...) | 94 | damage := append([]renderer.Damage(nil), baseDamage...) | ||
| 117 | - | for _, d := range screenDamage { | 95 | + | for _, d := range floating.pane.damage { |
| 118 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | 96 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | ||
| 119 | } | 97 | } | ||
| 120 | - | p.screen.ClearDamage() | 98 | + | popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry |
| 121 | - | p.mu.Unlock() | 99 | + | titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration |
| 122 | - | 100 | + | drawFloatingBorder(frame, geometry.Bounds, floating.title, newThemeStyles(theme).border) | |
| 123 | - | // The committed geometry, rather than the requested config geometry, is | |||
| 124 | - | // what was actually rendered. Track it so movement (including a same-size | |||
| 125 | - | // resize) cannot reuse a cached popup at its former position. | |||
| 126 | - | popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry | |||
| 127 | - | titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration | |||
| 128 | - | drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border) | |||
| 129 | - | if cache != nil { | |||
| 130 | - | cache.floating = p | |||
| 131 | - | cache.floatingGeneration = generation | |||
| 132 | - | cache.floatingGeometry = geometry | |||
| 133 | - | cache.floatingTitleGeneration = titleGeneration | |||
| 134 | - | } | |||
| 135 | if full || popupChanged { | 101 | if full || popupChanged { | ||
| 136 | - | return frame, []renderer.Damage{renderer.FullRedraw()}, geometry | 102 | + | return frame, []renderer.Damage{renderer.FullRedraw()} |
| 137 | } | 103 | } | ||
| 138 | if titleChanged && geometry.Bounds.Height >= 3 { | 104 | if titleChanged && geometry.Bounds.Height >= 3 { | ||
| 139 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1}) | 105 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1}) | ||
| 140 | } | 106 | } | ||
| 141 | - | return frame, damage, geometry | 107 | + | return frame, damage |
| 142 | } | 108 | } | ||
| 143 | 109 | ||||
| 144 | func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) { | 110 | func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) { | ||
| @@ -167,70 +133,3 @@ func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, | |||||
| 167 | ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style) | 133 | ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style) | ||
| 168 | } | 134 | } | ||
| 169 | } | 135 | } | ||
| 170 | - | ||||
| 171 | - | // resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize | |||
| 172 | - | // is intentionally outside pane.mu; failed resizes do not publish any state. | |||
| 173 | - | func (d *Daemon) resizeFloatingPane(p *pane, geometry floatingGeometry) bool { | |||
| 174 | - | inner := geometry.Inner | |||
| 175 | - | if p == nil || !geometry.valid() { | |||
| 176 | - | return false | |||
| 177 | - | } | |||
| 178 | - | p.resizeMu.Lock() | |||
| 179 | - | defer p.resizeMu.Unlock() | |||
| 180 | - | ||||
| 181 | - | p.mu.Lock() | |||
| 182 | - | old := p.rect | |||
| 183 | - | pty := p.pty | |||
| 184 | - | p.mu.Unlock() | |||
| 185 | - | if old.Width == inner.Width && old.Height == inner.Height { | |||
| 186 | - | // Position and outer bounds can change while the terminal dimensions do | |||
| 187 | - | // not. Commit that geometry without needlessly resetting the PTY/screen. | |||
| 188 | - | p.mu.Lock() | |||
| 189 | - | p.rect = inner | |||
| 190 | - | p.popupGeometry = geometry | |||
| 191 | - | p.mu.Unlock() | |||
| 192 | - | return true | |||
| 193 | - | } | |||
| 194 | - | if pty != nil { | |||
| 195 | - | if err := pty.Resize(rectSize(inner)); err != nil { | |||
| 196 | - | d.log.Warn("floating pty resize failed", "err", err) | |||
| 197 | - | return false | |||
| 198 | - | } | |||
| 199 | - | } | |||
| 200 | - | p.mu.Lock() | |||
| 201 | - | p.screen.Resize(inner.Width, inner.Height) | |||
| 202 | - | p.rect = inner | |||
| 203 | - | p.popupGeometry = geometry | |||
| 204 | - | p.mu.Unlock() | |||
| 205 | - | return true | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | // resizeInstalledFloating updates an installed floating pane, including a | |||
| 209 | - | // retained hidden pane when its tab becomes active. | |||
| 210 | - | func (d *Daemon) resizeInstalledFloating(tb *tab) bool { | |||
| 211 | - | if d == nil || tb == nil { | |||
| 212 | - | return false | |||
| 213 | - | } | |||
| 214 | - | tb.mu.Lock() | |||
| 215 | - | p := tb.floating.pane | |||
| 216 | - | content := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows} | |||
| 217 | - | tb.mu.Unlock() | |||
| 218 | - | if p == nil { | |||
| 219 | - | return false | |||
| 220 | - | } | |||
| 221 | - | return d.resizeFloatingPane(p, calculateContentFloatingGeometry(content, d.currentFloatingConfig())) | |||
| 222 | - | } | |||
| 223 | - | ||||
| 224 | - | // resizeActiveFloating updates a visible floating pane during a client resize. | |||
| 225 | - | func (d *Daemon) resizeActiveFloating(tb *tab) bool { | |||
| 226 | - | if d == nil || tb == nil { | |||
| 227 | - | return false | |||
| 228 | - | } | |||
| 229 | - | tb.mu.Lock() | |||
| 230 | - | visible := tb.floating.state == floatingVisible | |||
| 231 | - | tb.mu.Unlock() | |||
| 232 | - | if !visible { | |||
| 233 | - | return false | |||
| 234 | - | } | |||
| 235 | - | return d.resizeInstalledFloating(tb) | |||
| 236 | - | } | |||
A internal/usecase/daemon/floating_render_legacy_test.go +72 -0
| @@ -0,0 +1,72 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // composeFloatingFrame applies the popup to a copy of the normal composed | |||
| 10 | + | // destination. In particular it never writes the VT screen frame: that frame | |||
| 11 | + | // is shared with the PTY reader and is the source for future renders. | |||
| 12 | + | func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) { | |||
| 13 | + | var frame renderer.Frame | |||
| 14 | + | if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height { | |||
| 15 | + | frame = cache.floatingFrame | |||
| 16 | + | for y := 0; y < base.Height; y++ { | |||
| 17 | + | copy(frame.Row(y), base.Row(y)) | |||
| 18 | + | } | |||
| 19 | + | } else { | |||
| 20 | + | frame = base.Clone() | |||
| 21 | + | if cache != nil { | |||
| 22 | + | cache.floatingFrame = frame | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme) | |||
| 26 | + | // The PTY reader mutates both the frame and its damage under p.mu. Keep | |||
| 27 | + | // every read of them, including the blit, in the same critical section. | |||
| 28 | + | // In particular, Frame is not safe to retain as an alias after unlocking. | |||
| 29 | + | p.mu.Lock() | |||
| 30 | + | // Committed popup geometry is content-relative; translate it exactly once | |||
| 31 | + | // while holding the same lock that protects the committed state and frame. | |||
| 32 | + | geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y) | |||
| 33 | + | previousCapture := capturedPaneRenderState{} | |||
| 34 | + | if cache != nil { | |||
| 35 | + | previousCapture = cache.floatingCaptured | |||
| 36 | + | } | |||
| 37 | + | captured := capturePaneRenderStateLockedInto(p, geometry.Inner, previousCapture) | |||
| 38 | + | // This retained legacy test helper models its historical synchronous | |||
| 39 | + | // consume path; production commits receipts in emitFrame instead. | |||
| 40 | + | p.screen.AcknowledgeDamage(captured.damageGeneration) | |||
| 41 | + | if cache != nil { | |||
| 42 | + | cache.floatingCaptured = captured | |||
| 43 | + | } | |||
| 44 | + | p.mu.Unlock() | |||
| 45 | + | title := captured.title | |||
| 46 | + | titleGeneration := captured.titleGeneration | |||
| 47 | + | blitPaneFrame(frame, geometry.Inner, captured.frame, false, theme) | |||
| 48 | + | damage := append([]renderer.Damage(nil), baseDamage...) | |||
| 49 | + | for _, d := range captured.damage { | |||
| 50 | + | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // The committed geometry, rather than the requested config geometry, is | |||
| 54 | + | // what was actually rendered. Track it so movement (including a same-size | |||
| 55 | + | // resize) cannot reuse a cached popup at its former position. | |||
| 56 | + | popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry | |||
| 57 | + | titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration | |||
| 58 | + | drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border) | |||
| 59 | + | if cache != nil { | |||
| 60 | + | cache.floating = p | |||
| 61 | + | cache.floatingGeneration = generation | |||
| 62 | + | cache.floatingGeometry = geometry | |||
| 63 | + | cache.floatingTitleGeneration = titleGeneration | |||
| 64 | + | } | |||
| 65 | + | if full || popupChanged { | |||
| 66 | + | return frame, []renderer.Damage{renderer.FullRedraw()}, geometry | |||
| 67 | + | } | |||
| 68 | + | if titleChanged && geometry.Bounds.Height >= 3 { | |||
| 69 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1}) | |||
| 70 | + | } | |||
| 71 | + | return frame, damage, geometry | |||
| 72 | + | } | |||
M internal/usecase/daemon/floating_render_test.go +28 -44
| @@ -14,6 +14,25 @@ import ( | |||||
| 14 | "github.com/bnema/vev/pkg/renderer" | 14 | "github.com/bnema/vev/pkg/renderer" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | + | // applyFloatingResizePlanForTest exercises the transactional apply primitive. | |||
| 18 | + | func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometry) bool { | |||
| 19 | + | if p == nil || !geometry.valid() { | |||
| 20 | + | return false | |||
| 21 | + | } | |||
| 22 | + | plan := resizePlan{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}} | |||
| 23 | + | d.applyResize(&plan) | |||
| 24 | + | member := plan.members[0] | |||
| 25 | + | if !member.ok { | |||
| 26 | + | return false | |||
| 27 | + | } | |||
| 28 | + | p.mu.Lock() | |||
| 29 | + | p.rect = geometry.Inner | |||
| 30 | + | p.popupGeometry = geometry | |||
| 31 | + | p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height) | |||
| 32 | + | p.mu.Unlock() | |||
| 33 | + | return true | |||
| 34 | + | } | |||
| 35 | + | ||||
| 17 | func TestFloatingGeometryTranslate(t *testing.T) { | 36 | func TestFloatingGeometryTranslate(t *testing.T) { | ||
| 18 | geometry := floatingGeometry{ | 37 | geometry := floatingGeometry{ | ||
| 19 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | 38 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | ||
| @@ -200,7 +219,7 @@ func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T) | |||||
| 200 | p.rect, p.popupGeometry = initial.Inner, initial | 219 | p.rect, p.popupGeometry = initial.Inner, initial | ||
| 201 | d := newTestDaemon(t, nil, stubClock{}) | 220 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 202 | 221 | ||||
| 203 | - | require.True(t, d.resizeFloatingPane(p, tt.next)) | 222 | + | require.True(t, applyFloatingResizePlanForTest(d, p, tt.next)) |
| 204 | require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY") | 223 | require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY") | ||
| 205 | require.Equal(t, tt.next.Inner, p.rect) | 224 | require.Equal(t, tt.next.Inner, p.rect) | ||
| 206 | require.Equal(t, tt.next, p.popupGeometry) | 225 | require.Equal(t, tt.next, p.popupGeometry) | ||
| @@ -268,41 +287,6 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | |||||
| 268 | } | 287 | } | ||
| 269 | } | 288 | } | ||
| 270 | 289 | ||||
| 271 | - | func TestRenderHiddenFloatingSkipsPreviewAndActiveClientAndClearsDamage(t *testing.T) { | |||
| 272 | - | normalPTY, releaseNormal := newBlockingPTY(t) | |||
| 273 | - | defer releaseNormal() | |||
| 274 | - | d, sess, _, activeSends := newManualSessionWithPTYs(t, normalPTY) | |||
| 275 | - | previewTransport, previewSends := newCapturingTransport(t) | |||
| 276 | - | preview := &attachedClient{tr: previewTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 277 | - | preview.initOverlays() | |||
| 278 | - | preview.setSession(sess) | |||
| 279 | - | ||||
| 280 | - | tb := sess.activeTab() | |||
| 281 | - | floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 8}) | |||
| 282 | - | floating.screen.Write([]byte("hidden output")) | |||
| 283 | - | tb.mu.Lock() | |||
| 284 | - | generation := tb.beginFloatingWarmLocked(false) | |||
| 285 | - | require.True(t, tb.installFloatingLocked(floating, generation)) | |||
| 286 | - | tb.previewClient = preview | |||
| 287 | - | tb.mu.Unlock() | |||
| 288 | - | ||||
| 289 | - | d.render(sess, tb, floating) | |||
| 290 | - | ||||
| 291 | - | floating.mu.Lock() | |||
| 292 | - | require.Empty(t, floating.screen.Damage()) | |||
| 293 | - | floating.mu.Unlock() | |||
| 294 | - | select { | |||
| 295 | - | case frame := <-previewSends: | |||
| 296 | - | t.Fatalf("hidden floating output painted picker preview: %#v", frame) | |||
| 297 | - | default: | |||
| 298 | - | } | |||
| 299 | - | select { | |||
| 300 | - | case frame := <-activeSends: | |||
| 301 | - | t.Fatalf("hidden floating output painted active client: %#v", frame) | |||
| 302 | - | default: | |||
| 303 | - | } | |||
| 304 | - | } | |||
| 305 | - | ||||
| 306 | func BenchmarkComposeFloatingFrameCached(b *testing.B) { | 290 | func BenchmarkComposeFloatingFrameCached(b *testing.B) { | ||
| 307 | p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18}) | 291 | p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18}) | ||
| 308 | p.title.displayFallback = "float" | 292 | p.title.displayFallback = "float" | ||
| @@ -364,14 +348,14 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||||
| 364 | d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42}) | 348 | d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42}) | ||
| 365 | require.Empty(t, pty.sizes()) | 349 | require.Empty(t, pty.sizes()) | ||
| 366 | 350 | ||||
| 367 | - | // Showing attempts the current size before paint. A failed resize leaves the | 351 | + | // Showing attempts the current size before paint. A failed resize retains |
| 368 | - | // old screen and rect untouched, so a later hide/show can retry. | 352 | + | // the old screen, but commits the new popup rect so capture clips/pads it. |
| 369 | require.NoError(t, d.toggleFloating(sess, nil)) | 353 | require.NoError(t, d.toggleFloating(sess, nil)) | ||
| 370 | require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes()) | 354 | require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes()) | ||
| 371 | - | require.Equal(t, initial, floating.rect) | 355 | + | require.Equal(t, current, floating.rect) |
| 372 | require.Equal(t, initial.Width, floating.screen.Frame.Width) | 356 | require.Equal(t, initial.Width, floating.screen.Frame.Width) | ||
| 373 | require.Equal(t, initial.Height, floating.screen.Frame.Height) | 357 | require.Equal(t, initial.Height, floating.screen.Frame.Height) | ||
| 374 | - | require.Equal(t, initialGeometry, floating.popupGeometry) | 358 | + | require.Equal(t, currentGeometry, floating.popupGeometry) |
| 375 | 359 | ||||
| 376 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | 360 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | ||
| 377 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | 361 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | ||
| @@ -397,7 +381,7 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) | |||||
| 397 | d := newTestDaemon(t, nil, stubClock{}) | 381 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 398 | d.ApplyConfig(domain.Config{Floating: cfg}) | 382 | d.ApplyConfig(domain.Config{Floating: cfg}) | ||
| 399 | 383 | ||||
| 400 | - | require.False(t, d.resizeFloatingPane(p, newGeometry)) | 384 | + | require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry)) |
| 401 | tb.mu.Lock() | 385 | tb.mu.Lock() | ||
| 402 | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | 386 | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | ||
| 403 | copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y)) | 387 | copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y)) | ||
| @@ -423,7 +407,7 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||||
| 423 | p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4} | 407 | p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4} | ||
| 424 | d := newTestDaemon(t, nil, stubClock{}) | 408 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 425 | requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}} | 409 | requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}} | ||
| 426 | - | require.False(t, d.resizeFloatingPane(p, requested)) | 410 | + | require.False(t, applyFloatingResizePlanForTest(d, p, requested)) |
| 427 | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | 411 | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | ||
| 428 | require.Equal(t, floatingGeometry{}, p.popupGeometry) | 412 | require.Equal(t, floatingGeometry{}, p.popupGeometry) | ||
| 429 | require.Equal(t, 5, p.screen.Frame.Width) | 413 | require.Equal(t, 5, p.screen.Frame.Width) | ||
| @@ -439,11 +423,11 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||||
| 439 | done1 := make(chan bool, 1) | 423 | done1 := make(chan bool, 1) | ||
| 440 | done2 := make(chan bool, 1) | 424 | done2 := make(chan bool, 1) | ||
| 441 | secondStarted := make(chan struct{}) | 425 | secondStarted := make(chan struct{}) | ||
| 442 | - | go func() { done1 <- d.resizeFloatingPane(p, first) }() | 426 | + | go func() { done1 <- applyFloatingResizePlanForTest(d, p, first) }() |
| 443 | <-pty.entered | 427 | <-pty.entered | ||
| 444 | go func() { | 428 | go func() { | ||
| 445 | close(secondStarted) | 429 | close(secondStarted) | ||
| 446 | - | done2 <- d.resizeFloatingPane(p, second) | 430 | + | done2 <- applyFloatingResizePlanForTest(d, p, second) |
| 447 | }() | 431 | }() | ||
| 448 | <-secondStarted | 432 | <-secondStarted | ||
| 449 | close(pty.release) | 433 | close(pty.release) | ||
M internal/usecase/daemon/floating_test.go +5 -5
| @@ -36,8 +36,8 @@ func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsm | |||||
| 36 | var releaseOnce sync.Once | 36 | var releaseOnce sync.Once | ||
| 37 | releaseOpen := func() { releaseOnce.Do(func() { close(release) }) } | 37 | releaseOpen := func() { releaseOnce.Do(func() { close(release) }) } | ||
| 38 | t.Cleanup(releaseOpen) | 38 | t.Cleanup(releaseOpen) | ||
| 39 | - | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 39 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( |
| 40 | - | func(command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) { | 40 | + | func(_ context.Context, command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) { |
| 41 | opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size} | 41 | opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size} | ||
| 42 | <-release | 42 | <-release | ||
| 43 | return result, openErr | 43 | return result, openErr | ||
| @@ -364,7 +364,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) { | |||||
| 364 | 364 | ||||
| 365 | // Establish a renderer shadow first. The EOF repaint must reset it and | 365 | // Establish a renderer shadow first. The EOF repaint must reset it and | ||
| 366 | // redraw the underlying cell rather than depending on popup damage. | 366 | // redraw the underlying cell rather than depending on popup damage. | ||
| 367 | - | d.paint(sess, ac, true) | 367 | + | d.paint(sess, ac, true, nil) |
| 368 | baseline := awaitFrame(t, sends, ports.MsgOutput) | 368 | baseline := awaitFrame(t, sends, ports.MsgOutput) | ||
| 369 | baselineOutput, err := ports.UnmarshalOutput(baseline.Payload) | 369 | baselineOutput, err := ports.UnmarshalOutput(baseline.Payload) | ||
| 370 | require.NoError(t, err) | 370 | require.NoError(t, err) | ||
| @@ -467,8 +467,8 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) { | |||||
| 467 | t.Run(tt.name, func(t *testing.T) { | 467 | t.Run(tt.name, func(t *testing.T) { | ||
| 468 | factory := portsmocks.NewMockPTYFactory(t) | 468 | factory := portsmocks.NewMockPTYFactory(t) | ||
| 469 | opened := make(chan string, 1) | 469 | opened := make(chan string, 1) | ||
| 470 | - | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 470 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( |
| 471 | - | func(_ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) { | 471 | + | func(_ context.Context, _ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) { |
| 472 | opened <- cwd | 472 | opened <- cwd | ||
| 473 | return nil, io.ErrUnexpectedEOF | 473 | return nil, io.ErrUnexpectedEOF | ||
| 474 | }).Once() | 474 | }).Once() | ||
A internal/usecase/daemon/history_test_helper_test.go +10 -0
| @@ -0,0 +1,10 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/pkg/vt" | |||
| 4 | + | ||||
| 5 | + | func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) } | |||
| 6 | + | ||||
| 7 | + | func installTestHistory(p *pane, config vt.HistoryConfig) { | |||
| 8 | + | p.screen = vt.NewScreenWithHistory(p.screen.Frame.Width, p.screen.Frame.Height, config) | |||
| 9 | + | p.history = p.screen.History() | |||
| 10 | + | } | |||
M internal/usecase/daemon/input.go +9 -32
| @@ -1,27 +1,4 @@ | |||||
| 1 | - | // Package daemon holds vev's server-side session multiplexer use case: the | 1 | + | // Package daemon holds vev's server-side session multiplexer use case. |
| 2 | - | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |||
| 3 | - | // and VT screen, and the per-client debounced render scheduler. | |||
| 4 | - | // | |||
| 5 | - | // Concurrency model (sessions own one or more PTY-backed tabs): | |||
| 6 | - | // | |||
| 7 | - | // - Serve runs the accept loop. Each accepted connection is handled by its | |||
| 8 | - | // own goroutine (handleConn): it reads the first frame and routes it to a | |||
| 9 | - | // session create/attach, a list, or a kill. | |||
| 10 | - | // - Per session there are exactly two long-lived goroutines: the PTY reader | |||
| 11 | - | // (drains child output into the VT screen and pokes a cap-1 dirty channel) | |||
| 12 | - | // and the render scheduler (debounces dirties and paints the attached | |||
| 13 | - | // client). Both are tied to the session context and unwind when the | |||
| 14 | - | // session is killed (pty.Close unblocks the reader; ctx cancel stops the | |||
| 15 | - | // scheduler). | |||
| 16 | - | // - The daemon exits (Serve returns) when the last session is removed, or | |||
| 17 | - | // when the parent context is cancelled (graceful shutdown notifies any | |||
| 18 | - | // attached clients with ReasonServerShutdown). | |||
| 19 | - | // | |||
| 20 | - | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |||
| 21 | - | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |||
| 22 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | |||
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |||
| 24 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |||
| 25 | package daemon | 2 | package daemon | ||
| 26 | 3 | ||||
| 27 | import ( | 4 | import ( | ||
| @@ -118,7 +95,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 118 | if target.ID != oldFocus { | 95 | if target.ID != oldFocus { | ||
| 119 | d.refreshPaneTitleOnFocus(sess, target.ID) | 96 | d.refreshPaneTitleOnFocus(sess, target.ID) | ||
| 120 | } | 97 | } | ||
| 121 | - | d.paint(sess, ac, true) | 98 | + | d.invalidateRender(sess, ac, true, "input.go") |
| 122 | return | 99 | return | ||
| 123 | } | 100 | } | ||
| 124 | } | 101 | } | ||
| @@ -174,7 +151,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 174 | d.exitCopyMode(ac) | 151 | d.exitCopyMode(ac) | ||
| 175 | d.refreshPaneTitleOnFocus(sess, pl.ID) | 152 | d.refreshPaneTitleOnFocus(sess, pl.ID) | ||
| 176 | } | 153 | } | ||
| 177 | - | d.paint(sess, ac, true) | 154 | + | d.invalidateRender(sess, ac, true, "input.go") |
| 178 | return | 155 | return | ||
| 179 | } | 156 | } | ||
| 180 | var p *pane | 157 | var p *pane | ||
| @@ -195,7 +172,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 195 | if isMouseFocusPress(ev) && pl.ID != oldFocus { | 172 | if isMouseFocusPress(ev) && pl.ID != oldFocus { | ||
| 196 | d.exitCopyMode(ac) | 173 | d.exitCopyMode(ac) | ||
| 197 | d.refreshPaneTitleOnFocus(sess, pl.ID) | 174 | d.refreshPaneTitleOnFocus(sess, pl.ID) | ||
| 198 | - | d.paint(sess, ac, true) | 175 | + | d.invalidateRender(sess, ac, true, "input.go") |
| 199 | } | 176 | } | ||
| 200 | if multi { | 177 | if multi { | ||
| 201 | ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y) | 178 | ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y) | ||
| @@ -225,8 +202,8 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 225 | mouseMode, mouseSGR := p.screen.MouseMode() | 202 | mouseMode, mouseSGR := p.screen.MouseMode() | ||
| 226 | altScreen := p.screen.AltScreenActive() | 203 | altScreen := p.screen.AltScreenActive() | ||
| 227 | scrollbackRows := 0 | 204 | scrollbackRows := 0 | ||
| 228 | - | if p.scrollback != nil { | 205 | + | if p.history != nil { |
| 229 | - | scrollbackRows = p.scrollback.Len() | 206 | + | scrollbackRows = p.history.Len() |
| 230 | } | 207 | } | ||
| 231 | p.mu.Unlock() | 208 | p.mu.Unlock() | ||
| 232 | 209 | ||||
| @@ -272,7 +249,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 272 | 249 | ||||
| 273 | // The immutable snapshot is the only live-pane access needed for drag entry. | 250 | // The immutable snapshot is the only live-pane access needed for drag entry. | ||
| 274 | p.mu.Lock() | 251 | p.mu.Lock() | ||
| 275 | - | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | 252 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame) |
| 276 | p.mu.Unlock() | 253 | p.mu.Unlock() | ||
| 277 | tb := sess.activeTab() | 254 | tb := sess.activeTab() | ||
| 278 | if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) { | 255 | if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) { | ||
| @@ -290,7 +267,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 290 | rt.copyDragging = true | 267 | rt.copyDragging = true | ||
| 291 | } | 268 | } | ||
| 292 | rt.copyMu.Unlock() | 269 | rt.copyMu.Unlock() | ||
| 293 | - | d.paint(sess, ac, true) | 270 | + | d.invalidateRender(sess, ac, true, "input.go") |
| 294 | case mouse.Release: | 271 | case mouse.Release: | ||
| 295 | rt.copyMu.Lock() | 272 | rt.copyMu.Lock() | ||
| 296 | rt.normalMousePressValid = false | 273 | rt.normalMousePressValid = false | ||
| @@ -432,7 +409,7 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 432 | idx := int(action - keys.ActionSwitchTab1) | 409 | idx := int(action - keys.ActionSwitchTab1) | ||
| 433 | if sess.switchTab(idx) { | 410 | if sess.switchTab(idx) { | ||
| 434 | h.d.activateTab(sess, sess.activeTab()) | 411 | h.d.activateTab(sess, sess.activeTab()) | ||
| 435 | - | h.d.paint(sess, h.ac, true) | 412 | + | h.d.invalidateRender(sess, h.ac, true, "input.go") |
| 436 | } | 413 | } | ||
| 437 | } | 414 | } | ||
| 438 | } | 415 | } | ||
M internal/usecase/daemon/input_test.go +188 -13
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "encoding/base64" | 5 | "encoding/base64" | ||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "strings" | 7 | "strings" | ||
| @@ -14,10 +15,10 @@ import ( | |||||
| 14 | "github.com/bnema/vev/internal/domain" | 15 | "github.com/bnema/vev/internal/domain" | ||
| 15 | "github.com/bnema/vev/internal/ports" | 16 | "github.com/bnema/vev/internal/ports" | ||
| 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 17 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 18 | "github.com/bnema/vev/internal/usecase/keys" | 18 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 19 | "github.com/bnema/vev/internal/usecase/layout" | 19 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 20 | "github.com/bnema/vev/internal/usecase/picker" | 20 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 21 | + | "github.com/bnema/vev/pkg/vt" | |||
| 21 | ) | 22 | ) | ||
| 22 | 23 | ||||
| 23 | // --- test doubles ----------------------------------------------------------- | 24 | // --- test doubles ----------------------------------------------------------- | ||
| @@ -57,11 +58,11 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |||||
| 57 | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3 | 58 | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3 | ||
| 58 | }, 2*time.Second, 5*time.Millisecond) | 59 | }, 2*time.Second, 5*time.Millisecond) | ||
| 59 | require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond) | 60 | require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond) | ||
| 60 | - | require.Equal(t, []byte("A"), <-writes1) | 61 | + | requirePTYWrite(t, writes1, []byte("A")) |
| 61 | require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond) | 62 | require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond) | ||
| 62 | - | require.Equal(t, []byte("B"), <-writes2) | 63 | + | requirePTYWrite(t, writes2, []byte("B")) |
| 63 | require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond) | 64 | require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond) | ||
| 64 | - | require.Equal(t, []byte("C"), <-writes3) | 65 | + | requirePTYWrite(t, writes3, []byte("C")) |
| 65 | 66 | ||||
| 66 | d.mu.Lock() | 67 | d.mu.Lock() | ||
| 67 | var sess *session | 68 | var sess *session | ||
| @@ -87,6 +88,77 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |||||
| 87 | d.sessWg.Wait() | 88 | d.sessWg.Wait() | ||
| 88 | } | 89 | } | ||
| 89 | 90 | ||||
| 91 | + | func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||
| 92 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil) | |||
| 93 | + | source := sess.tabs[0].focusedPane() | |||
| 94 | + | target := sess.tabs[1].focusedPane() | |||
| 95 | + | require.Equal(t, source.id, target.id, "tabs deliberately reuse pane-1") | |||
| 96 | + | ||||
| 97 | + | source.mu.Lock() | |||
| 98 | + | source.screen.Write([]byte("source")) | |||
| 99 | + | source.mu.Unlock() | |||
| 100 | + | d.paint(sess, ac, true, nil) | |||
| 101 | + | first := awaitFrame(t, sends, ports.MsgOutput) | |||
| 102 | + | firstOutput, err := ports.UnmarshalOutput(first.Payload) | |||
| 103 | + | require.NoError(t, err) | |||
| 104 | + | terminal := vt.NewScreen(ac.size.Cols, ac.size.Rows) | |||
| 105 | + | terminal.Write(firstOutput.Data) | |||
| 106 | + | ||||
| 107 | + | // A clean pane relies on the attachment capture cache for its retained | |||
| 108 | + | // snapshot. Both tabs deliberately use the tab-local default pane ID. | |||
| 109 | + | target.mu.Lock() | |||
| 110 | + | target.screen.ClearDamage() | |||
| 111 | + | target.mu.Unlock() | |||
| 112 | + | // Use the real key action: it requests the mandatory complete repaint for | |||
| 113 | + | // the first target-tab frame. | |||
| 114 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2) | |||
| 115 | + | require.Equal(t, 1, activeTabIndex(sess)) | |||
| 116 | + | ||||
| 117 | + | second := awaitFrame(t, sends, ports.MsgOutput) | |||
| 118 | + | secondOutput, err := ports.UnmarshalOutput(second.Payload) | |||
| 119 | + | require.NoError(t, err) | |||
| 120 | + | require.Zero(t, secondOutput.BaseStateNum, "tab switch must emit the complete target frame first") | |||
| 121 | + | terminal.Write(secondOutput.Data) | |||
| 122 | + | require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells") | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) { | |||
| 126 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 127 | + | tb := sess.tabs[0] | |||
| 128 | + | survivor := tb.panes["pane-1"] | |||
| 129 | + | closed := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 23}) | |||
| 130 | + | tb.mu.Lock() | |||
| 131 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |||
| 132 | + | tb.panes[closed.id] = closed | |||
| 133 | + | tb.mu.Unlock() | |||
| 134 | + | ||||
| 135 | + | cachedSurvivor := capturedPaneRenderState{title: "survivor"} | |||
| 136 | + | ac.captureFrames = map[*pane]capturedPaneRenderState{ | |||
| 137 | + | survivor: cachedSurvivor, | |||
| 138 | + | closed: {title: "closed"}, | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | require.NoError(t, d.closePane(sess, tb, closed.id, ac, false)) | |||
| 142 | + | require.NotContains(t, ac.captureFrames, closed, "closed pane must not remain strongly retained by its attachment") | |||
| 143 | + | require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving pane keeps its incremental capture") | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) { | |||
| 147 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |||
| 148 | + | closedTab, survivorTab := sess.tabs[0], sess.tabs[1] | |||
| 149 | + | closed := closedTab.panes["pane-1"] | |||
| 150 | + | survivor := survivorTab.panes["pane-1"] | |||
| 151 | + | cachedSurvivor := capturedPaneRenderState{title: "survivor"} | |||
| 152 | + | ac.captureFrames = map[*pane]capturedPaneRenderState{ | |||
| 153 | + | closed: {title: "closed"}, | |||
| 154 | + | survivor: cachedSurvivor, | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | d.closeTab(sess, closedTab, false) | |||
| 158 | + | require.NotContains(t, ac.captureFrames, closed, "closed tab panes must not remain strongly retained by its attachment") | |||
| 159 | + | require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving tab pane keeps its incremental capture") | |||
| 160 | + | } | |||
| 161 | + | ||||
| 90 | func TestAltCForwardsToPTY(t *testing.T) { | 162 | func TestAltCForwardsToPTY(t *testing.T) { | ||
| 91 | writes := make(chan []byte, 2) | 163 | writes := make(chan []byte, 2) | ||
| 92 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | 164 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | ||
| @@ -94,10 +166,74 @@ func TestAltCForwardsToPTY(t *testing.T) { | |||||
| 94 | 166 | ||||
| 95 | d.handleInput(sess, ac, []byte("\x1bc")) | 167 | d.handleInput(sess, ac, []byte("\x1bc")) | ||
| 96 | 168 | ||||
| 97 | - | require.Equal(t, []byte("\x1bc"), <-writes) | 169 | + | requirePTYWrite(t, writes, []byte("\x1bc")) |
| 98 | releasePTY() | 170 | releasePTY() | ||
| 99 | } | 171 | } | ||
| 100 | 172 | ||||
| 173 | + | func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) { | |||
| 174 | + | normal, releaseNormal := newBlockingPTY(t) | |||
| 175 | + | floatingPTY, releaseFloating := newBlockingPTY(t) | |||
| 176 | + | defer releaseNormal() | |||
| 177 | + | defer releaseFloating() | |||
| 178 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |||
| 179 | + | client := vt.NewScreen(80, 25) | |||
| 180 | + | floatingCtx, cancelFloating := context.WithCancel(sess.ctx) | |||
| 181 | + | defer cancelFloating() | |||
| 182 | + | floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5}) | |||
| 183 | + | floating.ctx = floatingCtx | |||
| 184 | + | floating.screen.Write([]byte("popup-content")) | |||
| 185 | + | installTestFloating(sess.activeTab(), floating, false) | |||
| 186 | + | ||||
| 187 | + | // Establish the client shadow while the retained popup is hidden. | |||
| 188 | + | d.paint(sess, ac, true, nil) | |||
| 189 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 190 | + | ||||
| 191 | + | // Route the real Alt+F binding. Showing a retained pane must paint once. | |||
| 192 | + | d.handleInput(sess, ac, []byte("\x1bf")) | |||
| 193 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 194 | + | require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content") | |||
| 195 | + | require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "┌") | |||
| 196 | + | select { | |||
| 197 | + | case extra := <-sends: | |||
| 198 | + | t.Fatalf("show emitted duplicate output: %#v", extra) | |||
| 199 | + | default: | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // The second real key must repaint immediately, rather than waiting for | |||
| 203 | + | // unrelated output, while retaining the existing PTY and context. | |||
| 204 | + | d.handleInput(sess, ac, []byte("\x1bf")) | |||
| 205 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 206 | + | require.NotContains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content") | |||
| 207 | + | tb := sess.activeTab() | |||
| 208 | + | tb.mu.Lock() | |||
| 209 | + | require.Equal(t, floatingHidden, tb.floating.state) | |||
| 210 | + | require.Same(t, floating, tb.floating.pane) | |||
| 211 | + | generation := tb.floating.generation | |||
| 212 | + | tb.mu.Unlock() | |||
| 213 | + | require.Same(t, floatingPTY, floating.pty) | |||
| 214 | + | select { | |||
| 215 | + | case <-floating.ctx.Done(): | |||
| 216 | + | t.Fatal("hiding retained popup cancelled its context") | |||
| 217 | + | default: | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | // A third show uses the installed pane; it must not launch or emit a second | |||
| 221 | + | // resize/start frame. | |||
| 222 | + | d.handleInput(sess, ac, []byte("\x1bf")) | |||
| 223 | + | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 224 | + | require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content") | |||
| 225 | + | tb.mu.Lock() | |||
| 226 | + | require.Equal(t, floatingVisible, tb.floating.state) | |||
| 227 | + | require.Same(t, floating, tb.floating.pane) | |||
| 228 | + | require.Equal(t, generation, tb.floating.generation) | |||
| 229 | + | tb.mu.Unlock() | |||
| 230 | + | select { | |||
| 231 | + | case extra := <-sends: | |||
| 232 | + | t.Fatalf("retained show emitted duplicate output: %#v", extra) | |||
| 233 | + | default: | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 101 | func requireFloatingInitialized(t *testing.T, tb *tab) { | 237 | func requireFloatingInitialized(t *testing.T, tb *tab) { | ||
| 102 | t.Helper() | 238 | t.Helper() | ||
| 103 | tb.mu.Lock() | 239 | tb.mu.Lock() | ||
| @@ -260,7 +396,7 @@ func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | |||||
| 260 | paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~") | 396 | paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~") | ||
| 261 | d.handleInput(sess, ac, paste) | 397 | d.handleInput(sess, ac, paste) | ||
| 262 | 398 | ||||
| 263 | - | require.Equal(t, paste, <-writes) | 399 | + | requirePTYWrite(t, writes, paste) |
| 264 | releasePTY() | 400 | releasePTY() | ||
| 265 | } | 401 | } | ||
| 266 | 402 | ||||
| @@ -342,7 +478,7 @@ func TestOverlayInputPrecedence(t *testing.T) { | |||||
| 342 | input: []byte("Z"), | 478 | input: []byte("Z"), | ||
| 343 | check: func(t *testing.T, ac *attachedClient, writes chan []byte) { | 479 | check: func(t *testing.T, ac *attachedClient, writes chan []byte) { | ||
| 344 | t.Helper() | 480 | t.Helper() | ||
| 345 | - | require.Equal(t, []byte("Z"), <-writes) | 481 | + | requirePTYWrite(t, writes, []byte("Z")) |
| 346 | }, | 482 | }, | ||
| 347 | }, | 483 | }, | ||
| 348 | } | 484 | } | ||
| @@ -466,6 +602,45 @@ func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) { | |||||
| 466 | require.Same(t, updated, ac.previousSession.Get()) | 602 | require.Same(t, updated, ac.previousSession.Get()) | ||
| 467 | } | 603 | } | ||
| 468 | 604 | ||||
| 605 | + | // backSession's invalid-target fallback is a render producer: it must publish | |||
| 606 | + | // exactly one coordinator invalidation instead of composing directly, while | |||
| 607 | + | // the valid-target hand-off (TestPaletteBackSessionTogglesPreviousSession) and | |||
| 608 | + | // the guarded stale clear (TestStaleBackSessionClearPreservesConcurrentTarget) | |||
| 609 | + | // keep their PR #73 behavior unchanged. | |||
| 610 | + | func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) { | |||
| 611 | + | for _, tc := range []struct { | |||
| 612 | + | name string | |||
| 613 | + | prepare func(current *session, ac *attachedClient) | |||
| 614 | + | }{ | |||
| 615 | + | {name: "no previous target", prepare: func(*session, *attachedClient) {}}, | |||
| 616 | + | {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) { | |||
| 617 | + | ac.previousSession.Set(&session{id: "gone"}) | |||
| 618 | + | }}, | |||
| 619 | + | {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) { | |||
| 620 | + | ac.previousSession.Set(current) | |||
| 621 | + | }}, | |||
| 622 | + | } { | |||
| 623 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 624 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |||
| 625 | + | defer releaseAll(releases) | |||
| 626 | + | invs := make(chan renderInvalidation, 4) | |||
| 627 | + | current.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{ | |||
| 628 | + | clock: d.clock, | |||
| 629 | + | wake: func(renderWake) {}, | |||
| 630 | + | onInvalidate: func(inv renderInvalidation) { invs <- inv }, | |||
| 631 | + | })) | |||
| 632 | + | tc.prepare(current, ac) | |||
| 633 | + | ||||
| 634 | + | d.backSession(current, ac) | |||
| 635 | + | ||||
| 636 | + | require.Same(t, current, ac.currentSession(), "an invalid target must not move the attachment") | |||
| 637 | + | awaitInvalidation(t, invs) | |||
| 638 | + | requireNoInvalidation(t, invs) | |||
| 639 | + | requireNoOutputFrame(t, sends) | |||
| 640 | + | }) | |||
| 641 | + | } | |||
| 642 | + | } | |||
| 643 | + | ||||
| 469 | func TestSwitchSourcePreviousSessionContracts(t *testing.T) { | 644 | func TestSwitchSourcePreviousSessionContracts(t *testing.T) { | ||
| 470 | for _, tc := range []struct { | 645 | for _, tc := range []struct { | ||
| 471 | name string | 646 | name string | ||
| @@ -592,7 +767,7 @@ func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) { | |||||
| 592 | require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab") | 767 | require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab") | ||
| 593 | d.handleInput(sess, ac, []byte("Z")) | 768 | d.handleInput(sess, ac, []byte("Z")) | ||
| 594 | require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond) | 769 | require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond) | ||
| 595 | - | require.Equal(t, []byte("Z"), <-writes) | 770 | + | requirePTYWrite(t, writes, []byte("Z")) |
| 596 | 771 | ||||
| 597 | releasePTY1() | 772 | releasePTY1() | ||
| 598 | releasePTY2() | 773 | releasePTY2() | ||
| @@ -696,8 +871,8 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) { | |||||
| 696 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | 871 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | ||
| 697 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | 872 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | ||
| 698 | 873 | ||||
| 699 | - | require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes) | 874 | + | requirePTYWrite(t, writes, []byte("\x1b[A\x1b[A\x1b[A")) |
| 700 | - | require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes) | 875 | + | requirePTYWrite(t, writes, []byte("\x1b[B\x1b[B\x1b[B")) |
| 701 | require.Nil(t, ac.overlays.copyMode) | 876 | require.Nil(t, ac.overlays.copyMode) | ||
| 702 | } | 877 | } | ||
| 703 | 878 | ||||
| @@ -752,7 +927,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||||
| 752 | 927 | ||||
| 753 | sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h")) | 928 | sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h")) | ||
| 754 | d.handleInput(sess, ac, raw) | 929 | d.handleInput(sess, ac, raw) | ||
| 755 | - | require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes) | 930 | + | requirePTYWrite(t, writes, []byte("\x1b[<0;2;2M")) |
| 756 | 931 | ||||
| 757 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | 932 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | ||
| 758 | select { | 933 | select { | ||
| @@ -892,7 +1067,7 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |||||
| 892 | func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | 1067 | func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | ||
| 893 | p, _ := newBlockingPTY(t) | 1068 | p, _ := newBlockingPTY(t) | ||
| 894 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | 1069 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | ||
| 895 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(50) | 1070 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 50}) |
| 896 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | 1071 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | ||
| 897 | 1072 | ||||
| 898 | // Press on a content row while scrollback is empty: the anchor is | 1073 | // Press on a content row while scrollback is empty: the anchor is | ||
| @@ -902,7 +1077,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |||||
| 902 | // Simulate 5 lines evicted into scrollback between the Press and the | 1077 | // Simulate 5 lines evicted into scrollback between the Press and the | ||
| 903 | // first Motion (e.g. the child kept producing output). | 1078 | // first Motion (e.g. the child kept producing output). | ||
| 904 | for range 5 { | 1079 | for range 5 { | ||
| 905 | - | sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted")) | 1080 | + | sess.tabs[0].focusedPane().history.Append(testRow("evicted")) |
| 906 | } | 1081 | } | ||
| 907 | 1082 | ||||
| 908 | // Motion lands on wire row 3 (0-based row 2 of the *current* screen). | 1083 | // Motion lands on wire row 3 (0-based row 2 of the *current* screen). | ||
A internal/usecase/daemon/lease_callback_guard_test.go +67 -0
| @@ -0,0 +1,67 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "runtime" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestResizeAndRetryCallbacksRejectSameObjectLeaseReplacement(t *testing.T) { | |||
| 14 | + | for _, tc := range []struct { | |||
| 15 | + | name string | |||
| 16 | + | schedule func(*renderCoordinator, *attachedClient, *attachmentLease, func()) | |||
| 17 | + | done func(*testing.T, *renderCoordinator) <-chan struct{} | |||
| 18 | + | }{ | |||
| 19 | + | { | |||
| 20 | + | name: "resize", | |||
| 21 | + | schedule: func(rc *renderCoordinator, ac *attachedClient, lease *attachmentLease, run func()) { | |||
| 22 | + | rc.scheduleResizeForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease, func(uint64) { run() }) | |||
| 23 | + | }, | |||
| 24 | + | done: captureResizeCallbackDone, | |||
| 25 | + | }, | |||
| 26 | + | { | |||
| 27 | + | name: "retry", | |||
| 28 | + | schedule: func(rc *renderCoordinator, ac *attachedClient, lease *attachmentLease, run func()) { | |||
| 29 | + | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |||
| 30 | + | require.NotZero(t, epoch) | |||
| 31 | + | require.True(t, rc.resizeCurrentForLease(epoch, ac, lease, true)) | |||
| 32 | + | rc.scheduleResizeRetryForLease(epoch, ac, lease, run) | |||
| 33 | + | }, | |||
| 34 | + | done: func(t *testing.T, rc *renderCoordinator) <-chan struct{} { | |||
| 35 | + | t.Helper() | |||
| 36 | + | rc.mu.Lock() | |||
| 37 | + | defer rc.mu.Unlock() | |||
| 38 | + | if rc.retryLane.token == nil { | |||
| 39 | + | t.Fatal("coordinator did not publish a retry callback completion") | |||
| 40 | + | } | |||
| 41 | + | return rc.retryLane.token.done | |||
| 42 | + | }, | |||
| 43 | + | }, | |||
| 44 | + | } { | |||
| 45 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 46 | + | clock := newCoordinatorMockClock(t, 1) | |||
| 47 | + | rc := newRenderCoordinator(renderCoordinatorOptions{clock: clock.clock}) | |||
| 48 | + | ac := &attachedClient{} | |||
| 49 | + | rc.attach(ac) | |||
| 50 | + | lease := rc.attachmentLease(ac) | |||
| 51 | + | ran := false | |||
| 52 | + | tc.schedule(rc, ac, lease, func() { ran = true }) | |||
| 53 | + | timer := awaitCoordinatorScheduledTimer(t, clock) | |||
| 54 | + | done := tc.done(t, rc) | |||
| 55 | + | require.NotNil(t, done) | |||
| 56 | + | ||||
| 57 | + | rc.notePark(ac) | |||
| 58 | + | rc.attach(ac) // resume the exact same object under a new incarnation | |||
| 59 | + | timer.ch <- time.Time{} | |||
| 60 | + | awaitTestCompletion(t, done, "stale callback did not complete") | |||
| 61 | + | for range 32 { | |||
| 62 | + | runtime.Gosched() | |||
| 63 | + | } | |||
| 64 | + | require.False(t, ran, "stale %s callback ran after lease replacement", tc.name) | |||
| 65 | + | }) | |||
| 66 | + | } | |||
| 67 | + | } | |||
M internal/usecase/daemon/mouse_panes.go +0 -4
| @@ -64,10 +64,6 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot { | |||||
| 64 | return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok} | 64 | return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok} | ||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | - | func (snap tabLayoutSnapshot) matchesLocked(tb *tab) bool { | |||
| 68 | - | return snap.ok && tb != nil && tb.tree != nil && tb.tree.Root == snap.root && tb.tree.Focus == snap.focus && tb.size.Cols == snap.area.Width && tb.size.Rows == snap.area.Height && snap.fingerprint == layoutFingerprint(tb.tree.Root) | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | func layoutFingerprint(root *layout.Node) string { | 67 | func layoutFingerprint(root *layout.Node) string { | ||
| 72 | var b strings.Builder | 68 | var b strings.Builder | ||
| 73 | writeLayoutFingerprint(&b, root) | 69 | writeLayoutFingerprint(&b, root) | ||
M internal/usecase/daemon/nil_pty_test.go +1 -1
| @@ -24,7 +24,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||||
| 24 | sess.tabs = []*tab{first, second} | 24 | sess.tabs = []*tab{first, second} | ||
| 25 | 25 | ||||
| 26 | require.NotPanics(t, func() { | 26 | require.NotPanics(t, func() { | ||
| 27 | - | d.resizePane(first.focusedPane(), domain.Rect{Width: 20, Height: 5}) | 27 | + | d.applyPaneResize(first.focusedPane(), domain.Rect{Width: 20, Height: 5}) |
| 28 | }) | 28 | }) | ||
| 29 | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | 29 | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | ||
| 30 | require.Equal(t, 5, first.focusedPane().screen.Frame.Height) | 30 | require.Equal(t, 5, first.focusedPane().screen.Frame.Height) | ||
M internal/usecase/daemon/output_state_stream.go +0 -29
| @@ -17,8 +17,6 @@ type outputStateStream struct { | |||||
| 17 | renderer *renderer.Renderer | 17 | renderer *renderer.Renderer | ||
| 18 | next uint64 | 18 | next uint64 | ||
| 19 | acked uint64 | 19 | acked uint64 | ||
| 20 | - | deferred bool | |||
| 21 | - | deferredReset bool | |||
| 22 | maxOutstanding uint64 | 20 | maxOutstanding uint64 | ||
| 23 | } | 21 | } | ||
| 24 | 22 | ||||
| @@ -89,37 +87,10 @@ func (s *outputStateStream) ack(state uint64) { | |||||
| 89 | func (s *outputStateStream) rebase() { | 87 | func (s *outputStateStream) rebase() { | ||
| 90 | s.acked = s.next | 88 | s.acked = s.next | ||
| 91 | s.renderer.Reset() | 89 | s.renderer.Reset() | ||
| 92 | - | s.clearDeferred() | |||
| 93 | } | 90 | } | ||
| 94 | 91 | ||||
| 95 | func (s *outputStateStream) atCapacity() bool { | 92 | func (s *outputStateStream) atCapacity() bool { | ||
| 96 | return s.outstanding() >= s.maxOutstanding | 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 | func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked } | 96 | func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked } | ||
M internal/usecase/daemon/output_state_stream_test.go +0 -75
| @@ -68,53 +68,6 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T | |||||
| 68 | } | 68 | } | ||
| 69 | } | 69 | } | ||
| 70 | 70 | ||||
| 71 | - | func TestOutputStateStreamAckProgressionReleasesDeferredPaint(t *testing.T) { | |||
| 72 | - | stream := newOutputStateStream() | |||
| 73 | - | for range maxUnackedOutputStates { | |||
| 74 | - | stream.frame([]byte("output"), false, 0) | |||
| 75 | - | } | |||
| 76 | - | require.True(t, stream.deferIfAtCapacity(false)) | |||
| 77 | - | _, ok := stream.takeDeferred() | |||
| 78 | - | require.False(t, ok) | |||
| 79 | - | stream.ack(maxUnackedOutputStates - 1) | |||
| 80 | - | reset, ok := stream.takeDeferred() | |||
| 81 | - | require.True(t, ok) | |||
| 82 | - | require.False(t, reset) | |||
| 83 | - | require.Equal(t, uint64(1), stream.outstanding()) | |||
| 84 | - | ||||
| 85 | - | // Stale and future ACKs do not move the cumulative acknowledgement. | |||
| 86 | - | stream.ack(maxUnackedOutputStates - 2) | |||
| 87 | - | stream.ack(maxUnackedOutputStates + 1) | |||
| 88 | - | require.Equal(t, uint64(maxUnackedOutputStates-1), stream.acked) | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | func TestOutputStateStreamResetIsDependencyFreeAndClearsDeferral(t *testing.T) { | |||
| 92 | - | stream := newOutputStateStream() | |||
| 93 | - | for range maxUnackedOutputStates { | |||
| 94 | - | stream.frame([]byte("output"), false, 0) | |||
| 95 | - | } | |||
| 96 | - | require.True(t, stream.deferIfAtCapacity(false)) | |||
| 97 | - | for range 32 { | |||
| 98 | - | require.True(t, stream.deferIfAtCapacity(true), "reset paints must coalesce while the stream is at capacity") | |||
| 99 | - | } | |||
| 100 | - | require.Equal(t, uint64(maxUnackedOutputStates), stream.outstanding()) | |||
| 101 | - | stream.ack(maxUnackedOutputStates) | |||
| 102 | - | reset, ok := stream.takeDeferred() | |||
| 103 | - | require.True(t, ok) | |||
| 104 | - | require.True(t, reset, "a coalesced reset must flush as a dependency-free repaint") | |||
| 105 | - | ||||
| 106 | - | frame := renderer.NewFrame(2, 1) | |||
| 107 | - | fillOutputStateRows(frame, []string{"ok"}) | |||
| 108 | - | outFrame, ok, err := drawOutputState(t, stream, frame, nil, true, 0) | |||
| 109 | - | require.NoError(t, err) | |||
| 110 | - | require.True(t, ok) | |||
| 111 | - | out, err := ports.UnmarshalOutput(outFrame.Payload) | |||
| 112 | - | require.NoError(t, err) | |||
| 113 | - | require.Zero(t, out.BaseStateNum) | |||
| 114 | - | _, ok = stream.takeDeferred() | |||
| 115 | - | require.False(t, ok) | |||
| 116 | - | } | |||
| 117 | - | ||||
| 118 | func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) { | 71 | func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) { | ||
| 119 | t.Helper() | 72 | t.Helper() | ||
| 120 | data, err := stream.render(frame, damage, reset) | 73 | data, err := stream.render(frame, damage, reset) | ||
| @@ -124,34 +77,6 @@ func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Fra | |||||
| 124 | return stream.frame(data, reset, echoAck), true, nil | 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 | func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) { | 80 | func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) { | ||
| 156 | require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding) | 81 | require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding) | ||
| 157 | require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding) | 82 | require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding) | ||
M internal/usecase/daemon/output_state_sync_test.go +52 -25
| @@ -82,7 +82,7 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) { | |||||
| 82 | require.Same(t, sess, routed) | 82 | require.Same(t, sess, routed) | ||
| 83 | 83 | ||||
| 84 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | 84 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | ||
| 85 | - | d.paint(sess, ac, false) | 85 | + | d.paint(sess, ac, false, nil) |
| 86 | first := awaitFrame(t, tr.sends, ports.MsgOutput) | 86 | first := awaitFrame(t, tr.sends, ports.MsgOutput) | ||
| 87 | out, err := ports.UnmarshalOutput(first.Payload) | 87 | out, err := ports.UnmarshalOutput(first.Payload) | ||
| 88 | require.NoError(t, err) | 88 | require.NoError(t, err) | ||
| @@ -90,7 +90,7 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) { | |||||
| 90 | 90 | ||||
| 91 | // Before the MsgAck, the renderer has already advanced along the ordered | 91 | // Before the MsgAck, the renderer has already advanced along the ordered | ||
| 92 | // output dependency chain, so an unchanged repaint is a no-op. | 92 | // output dependency chain, so an unchanged repaint is a no-op. | ||
| 93 | - | d.paint(sess, ac, false) | 93 | + | d.paint(sess, ac, false, nil) |
| 94 | requireNoOutputFrame(t, tr.sends) | 94 | requireNoOutputFrame(t, tr.sends) | ||
| 95 | 95 | ||||
| 96 | tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})} | 96 | tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})} | ||
| @@ -99,7 +99,7 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) { | |||||
| 99 | 99 | ||||
| 100 | // The production MsgAck path retires retained states without moving the | 100 | // The production MsgAck path retires retained states without moving the | ||
| 101 | // renderer baseline backward. | 101 | // renderer baseline backward. | ||
| 102 | - | d.paint(sess, ac, false) | 102 | + | d.paint(sess, ac, false, nil) |
| 103 | requireNoOutputFrame(t, tr.sends) | 103 | requireNoOutputFrame(t, tr.sends) | ||
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| @@ -111,7 +111,7 @@ func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) { | |||||
| 111 | ac.replaceTransport(tr) | 111 | ac.replaceTransport(tr) | ||
| 112 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | 112 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | ||
| 113 | 113 | ||||
| 114 | - | d.paint(sess, ac, false) | 114 | + | d.paint(sess, ac, false, nil) |
| 115 | 115 | ||||
| 116 | awaitFrame(t, tr.asyncSends, ports.MsgOutput) | 116 | awaitFrame(t, tr.asyncSends, ports.MsgOutput) | ||
| 117 | select { | 117 | select { | ||
| @@ -144,7 +144,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) { | |||||
| 144 | } | 144 | } | ||
| 145 | pane.screen.ClearDamage() | 145 | pane.screen.ClearDamage() | ||
| 146 | client := vt.NewScreen(80, 25) | 146 | client := vt.NewScreen(80, 25) | ||
| 147 | - | d.paint(sess, ac, true) | 147 | + | d.paint(sess, ac, true, nil) |
| 148 | first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput)) | 148 | first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput)) | ||
| 149 | ac.ackOutputState(first.NewStateNum) | 149 | ac.ackOutputState(first.NewStateNum) | ||
| 150 | 150 | ||||
| @@ -164,7 +164,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) { | |||||
| 164 | pane.screen.Col = 0 | 164 | pane.screen.Col = 0 | ||
| 165 | pane.screen.Write([]byte("\nq")) | 165 | pane.screen.Write([]byte("\nq")) | ||
| 166 | pane.screen.Frame = desired.Clone() | 166 | pane.screen.Frame = desired.Clone() | ||
| 167 | - | d.paint(sess, ac, false) | 167 | + | d.paint(sess, ac, false, nil) |
| 168 | mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput)) | 168 | mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput)) | ||
| 169 | } | 169 | } | ||
| 170 | 170 | ||||
| @@ -202,11 +202,11 @@ func TestLocalAttachStillAdvancesRendererOnSend(t *testing.T) { | |||||
| 202 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 202 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 203 | 203 | ||||
| 204 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | 204 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | ||
| 205 | - | d.paint(sess, ac, false) | 205 | + | d.paint(sess, ac, false, nil) |
| 206 | awaitFrame(t, sends, ports.MsgOutput) | 206 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 207 | 207 | ||||
| 208 | // The first paint updates the renderer shadow without waiting for MsgAck. | 208 | // The first paint updates the renderer shadow without waiting for MsgAck. | ||
| 209 | - | d.paint(sess, ac, false) | 209 | + | d.paint(sess, ac, false, nil) |
| 210 | requireNoOutputFrame(t, sends) | 210 | requireNoOutputFrame(t, sends) | ||
| 211 | } | 211 | } | ||
| 212 | 212 | ||||
| @@ -217,17 +217,17 @@ func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) { | |||||
| 217 | pane := sess.tabs[0].focusedPane() | 217 | pane := sess.tabs[0].focusedPane() | ||
| 218 | 218 | ||||
| 219 | pane.screen.Write([]byte("A")) | 219 | pane.screen.Write([]byte("A")) | ||
| 220 | - | d.paint(sess, ac, false) | 220 | + | d.paint(sess, ac, false, nil) |
| 221 | first := awaitFrame(t, sends, ports.MsgOutput) | 221 | first := awaitFrame(t, sends, ports.MsgOutput) | ||
| 222 | out, err := ports.UnmarshalOutput(first.Payload) | 222 | out, err := ports.UnmarshalOutput(first.Payload) | ||
| 223 | require.NoError(t, err) | 223 | require.NoError(t, err) | ||
| 224 | 224 | ||||
| 225 | pane.screen.Write([]byte("\rB")) | 225 | pane.screen.Write([]byte("\rB")) | ||
| 226 | - | d.paint(sess, ac, false) | 226 | + | d.paint(sess, ac, false, nil) |
| 227 | awaitFrame(t, sends, ports.MsgOutput) | 227 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 228 | 228 | ||||
| 229 | ac.ackOutputState(out.NewStateNum) | 229 | ac.ackOutputState(out.NewStateNum) | ||
| 230 | - | d.paint(sess, ac, false) | 230 | + | d.paint(sess, ac, false, nil) |
| 231 | requireNoOutputFrame(t, sends) | 231 | requireNoOutputFrame(t, sends) | ||
| 232 | } | 232 | } | ||
| 233 | 233 | ||||
| @@ -264,7 +264,7 @@ func TestFailedPaintSendRollsBackOutputState(t *testing.T) { | |||||
| 264 | ac.replaceTransport(failingOutputTransport{}) | 264 | ac.replaceTransport(failingOutputTransport{}) | ||
| 265 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | 265 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | ||
| 266 | 266 | ||||
| 267 | - | d.paint(sess, ac, false) | 267 | + | d.paint(sess, ac, false, nil) |
| 268 | 268 | ||||
| 269 | require.Zero(t, ac.output.next) | 269 | require.Zero(t, ac.output.next) | ||
| 270 | require.Zero(t, ac.output.acked) | 270 | require.Zero(t, ac.output.acked) | ||
| @@ -279,30 +279,50 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) { | |||||
| 279 | {name: "stream window", window: maxUnackedOutputStates}, | 279 | {name: "stream window", window: maxUnackedOutputStates}, | ||
| 280 | } { | 280 | } { | ||
| 281 | t.Run(tc.name, func(t *testing.T) { | 281 | t.Run(tc.name, func(t *testing.T) { | ||
| 282 | - | p, releasePTY := newBlockingPTY(t) | 282 | + | // resizeReaderPTY delivers the child's SIGWINCH redraw through the |
| 283 | - | defer releasePTY() | 283 | + | // actual ptyReader. Its Resize callback does not return until Read has |
| 284 | + | // accepted the bytes, at which point resizeApplying is still true. | |||
| 285 | + | p := newResizeReaderPTY([]byte("\x1b[1;81H" + strings.Repeat("B", 40))) | |||
| 284 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 286 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 285 | ac.output.maxOutstanding = tc.window | 287 | ac.output.maxOutstanding = tc.window | ||
| 286 | pane := sess.activeTab().focusedPane() | 288 | pane := sess.activeTab().focusedPane() | ||
| 287 | - | 289 | + | p.applying = func() bool { | |
| 290 | + | pane.mu.Lock() | |||
| 291 | + | defer pane.mu.Unlock() | |||
| 292 | + | return pane.resizeApplying | |||
| 293 | + | } | |||
| 288 | pane.screen.Write([]byte(strings.Repeat("A", 79))) | 294 | pane.screen.Write([]byte(strings.Repeat("A", 79))) | ||
| 289 | - | d.paint(sess, ac, true) | 295 | + | d.paint(sess, ac, true, nil) |
| 290 | client := vt.NewScreen(80, 24) | 296 | client := vt.NewScreen(80, 24) | ||
| 291 | initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | 297 | initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | ||
| 292 | ac.ackOutputState(initial.NewStateNum) | 298 | ac.ackOutputState(initial.NewStateNum) | ||
| 293 | 299 | ||||
| 294 | - | // The child can redraw the newly exposed columns while the resize | 300 | + | d.sessWg.Add(1) |
| 295 | - | // output is being prepared. The first visible grown frame must contain | 301 | + | pane.onExit = func() {} |
| 296 | - | // that redraw, regardless of the negotiated output window. | 302 | + | go d.ptyReader(sess, sess.activeTab(), pane) |
| 303 | + | defer func() { p.close(); d.sessWg.Wait() }() | |||
| 304 | + | ||||
| 305 | + | // The resize deadline drives coordinator prepare/apply/commit. No test | |||
| 306 | + | // writes the grown columns directly. | |||
| 307 | + | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |||
| 308 | + | d.clock = clock | |||
| 297 | d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24}) | 309 | d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24}) | ||
| 298 | - | pane.screen.Write([]byte("\x1b[1;81H" + strings.Repeat("B", 40))) | 310 | + | timer := <-clock.timers |
| 299 | - | d.paint(sess, ac, false) | 311 | + | done := captureResizeCallbackDone(t, sess.renderCoordinator()) |
| 312 | + | timer.ch <- time.Time{} | |||
| 313 | + | awaitTestCompletion(t, done, "resize callback did not complete") | |||
| 314 | + | require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate") | |||
| 315 | + | require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit") | |||
| 300 | 316 | ||||
| 301 | client.Resize(120, 24) | 317 | client.Resize(120, 24) | ||
| 302 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | 318 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | ||
| 303 | require.Equal(t, 'B', client.Frame.At(100, 1).Rune, | 319 | require.Equal(t, 'B', client.Frame.At(100, 1).Rune, | ||
| 304 | "first grown frame exposed stale pre-SIGWINCH pane content") | 320 | "first grown frame exposed stale pre-SIGWINCH pane content") | ||
| 305 | - | requireNoOutputFrame(t, sends) | 321 | + | select { |
| 322 | + | case extra := <-sends: | |||
| 323 | + | t.Fatalf("resize emitted more than one frame: %#v", extra) | |||
| 324 | + | default: | |||
| 325 | + | } | |||
| 306 | }) | 326 | }) | ||
| 307 | } | 327 | } | ||
| 308 | } | 328 | } | ||
| @@ -323,7 +343,9 @@ func TestResizeWithoutPTYOutputFlushesOneFullFrameAtDeadline(t *testing.T) { | |||||
| 323 | } | 343 | } | ||
| 324 | requireNoOutputFrame(t, sends) | 344 | requireNoOutputFrame(t, sends) | ||
| 325 | 345 | ||||
| 346 | + | done := captureResizeCallbackDone(t, sess.renderCoordinator()) | |||
| 326 | timer.ch <- time.Now() | 347 | timer.ch <- time.Now() | ||
| 348 | + | awaitTestCompletion(t, done, "resize callback did not complete") | |||
| 327 | frame := awaitFrame(t, sends, ports.MsgOutput) | 349 | frame := awaitFrame(t, sends, ports.MsgOutput) | ||
| 328 | client := vt.NewScreen(120, 24) | 350 | client := vt.NewScreen(120, 24) | ||
| 329 | out := mustApplyOutput(t, client, frame) | 351 | out := mustApplyOutput(t, client, frame) | ||
| @@ -348,6 +370,7 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) { | |||||
| 348 | case <-time.After(time.Second): | 370 | case <-time.After(time.Second): | ||
| 349 | t.Fatal("first resize did not schedule a bounded paint") | 371 | t.Fatal("first resize did not schedule a bounded paint") | ||
| 350 | } | 372 | } | ||
| 373 | + | firstDone := captureResizeCallbackDone(t, sess.renderCoordinator()) | |||
| 351 | d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24}) | 374 | d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24}) | ||
| 352 | var latest *signalTimer | 375 | var latest *signalTimer | ||
| 353 | select { | 376 | select { | ||
| @@ -356,9 +379,12 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) { | |||||
| 356 | t.Fatal("latest resize did not replace the bounded paint") | 379 | t.Fatal("latest resize did not replace the bounded paint") | ||
| 357 | } | 380 | } | ||
| 358 | 381 | ||||
| 382 | + | latestDone := captureResizeCallbackDone(t, sess.renderCoordinator()) | |||
| 359 | first.ch <- time.Now() | 383 | first.ch <- time.Now() | ||
| 384 | + | awaitTestCompletion(t, firstDone, "obsolete resize callback did not complete") | |||
| 360 | requireNoOutputFrame(t, sends) | 385 | requireNoOutputFrame(t, sends) | ||
| 361 | latest.ch <- time.Now() | 386 | latest.ch <- time.Now() | ||
| 387 | + | awaitTestCompletion(t, latestDone, "latest resize callback did not complete") | |||
| 362 | awaitFrame(t, sends, ports.MsgOutput) | 388 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 363 | require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size) | 389 | require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size) | ||
| 364 | require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width) | 390 | require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width) | ||
| @@ -378,20 +404,21 @@ func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) { | |||||
| 378 | MaxOutputInFlight: 1, | 404 | MaxOutputInFlight: 1, | ||
| 379 | }, tr) | 405 | }, tr) | ||
| 380 | require.NoError(t, err) | 406 | require.NoError(t, err) | ||
| 407 | + | require.True(t, sess.renderCoordinator().markAttachmentReady(sess.renderCoordinator().attachmentLease(ac))) | |||
| 381 | 408 | ||||
| 382 | pane := sess.tabs[0].focusedPane() | 409 | pane := sess.tabs[0].focusedPane() | ||
| 383 | pane.screen.Write([]byte("A")) | 410 | pane.screen.Write([]byte("A")) | ||
| 384 | - | d.paint(sess, ac, false) | 411 | + | d.paint(sess, ac, false, nil) |
| 385 | first := awaitFrame(t, tr.sends, ports.MsgOutput) | 412 | first := awaitFrame(t, tr.sends, ports.MsgOutput) | ||
| 386 | firstOutput, err := ports.UnmarshalOutput(first.Payload) | 413 | firstOutput, err := ports.UnmarshalOutput(first.Payload) | ||
| 387 | require.NoError(t, err) | 414 | require.NoError(t, err) | ||
| 388 | require.Equal(t, uint64(1), firstOutput.NewStateNum) | 415 | require.Equal(t, uint64(1), firstOutput.NewStateNum) | ||
| 389 | for range 100 { | 416 | for range 100 { | ||
| 390 | pane.screen.Write([]byte("x")) | 417 | pane.screen.Write([]byte("x")) | ||
| 391 | - | d.paint(sess, ac, false) | 418 | + | d.invalidateRender(sess, ac, false, "output_state_sync_test.go") |
| 392 | } | 419 | } | ||
| 393 | pane.screen.Write([]byte("LATEST")) | 420 | pane.screen.Write([]byte("LATEST")) | ||
| 394 | - | d.paint(sess, ac, false) | 421 | + | d.invalidateRender(sess, ac, false, "output_state_sync_test.go") |
| 395 | requireNoOutputFrame(t, tr.sends) | 422 | requireNoOutputFrame(t, tr.sends) | ||
| 396 | require.Equal(t, uint64(1), ac.output.outstanding(), "only one state-bearing datagram output may be unacknowledged") | 423 | require.Equal(t, uint64(1), ac.output.outstanding(), "only one state-bearing datagram output may be unacknowledged") | ||
| 397 | 424 | ||||
M internal/usecase/daemon/overlay_runtime.go +7 -5
| @@ -13,11 +13,13 @@ import ( | |||||
| 13 | type overlayRuntime struct { | 13 | type overlayRuntime struct { | ||
| 14 | ac *attachedClient | 14 | ac *attachedClient | ||
| 15 | 15 | ||||
| 16 | - | pickerMu sync.Mutex | 16 | + | pickerMu sync.Mutex |
| 17 | - | picker *picker.Model | 17 | + | picker *picker.Model |
| 18 | - | pickerPreview *tab | 18 | + | pickerPreview *tab |
| 19 | - | pickerPending []byte | 19 | + | pickerPreviewSession *session |
| 20 | - | pickerESC pendingByteTimer | 20 | + | pickerPreviewGeneration uint64 |
| 21 | + | pickerPending []byte | |||
| 22 | + | pickerESC pendingByteTimer | |||
| 21 | 23 | ||||
| 22 | paletteMu sync.Mutex | 24 | paletteMu sync.Mutex | ||
| 23 | palette *palette.Model | 25 | palette *palette.Model | ||
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
| @@ -173,7 +173,7 @@ func TestOverlayRuntimeSnapshotCarriesCopyDocumentAndClearReleasesIt(t *testing. | |||||
| 173 | func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) { | 173 | func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) { | ||
| 174 | ac := &attachedClient{} | 174 | ac := &attachedClient{} | ||
| 175 | ac.initOverlays() | 175 | ac.initOverlays() | ||
| 176 | - | history := scopy.NewScrollback(1) | 176 | + | history := newTestHistory(1) |
| 177 | history.Append(testRow("before")) | 177 | history.Append(testRow("before")) | ||
| 178 | document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1)) | 178 | document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1)) | ||
| 179 | 179 | ||||
M internal/usecase/daemon/paint_gate_test.go +0 -112
| @@ -1,14 +1,10 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "io" | |||
| 5 | "testing" | 4 | "testing" | ||
| 6 | "time" | 5 | "time" | ||
| 7 | 6 | ||||
| 8 | "github.com/bnema/vev/internal/ports" | 7 | "github.com/bnema/vev/internal/ports" | ||
| 9 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 10 | - | "github.com/bnema/vev/pkg/vt" | |||
| 11 | - | "github.com/stretchr/testify/require" | |||
| 12 | ) | 8 | ) | ||
| 13 | 9 | ||||
| 14 | func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) { | 10 | func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) { | ||
| @@ -25,111 +21,3 @@ func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) { | |||||
| 25 | } | 21 | } | ||
| 26 | } | 22 | } | ||
| 27 | } | 23 | } | ||
| 28 | - | ||||
| 29 | - | func TestPaintDefersWhenOutputAckLagsAtCap(t *testing.T) { | |||
| 30 | - | p, _ := newBlockingPTY(t) | |||
| 31 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 32 | - | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | |||
| 33 | - | ||||
| 34 | - | // Client is maxUnackedOutputStates behind (outputAck stays 0). | |||
| 35 | - | ac.output.next = maxUnackedOutputStates | |||
| 36 | - | d.paint(sess, ac, false) | |||
| 37 | - | ||||
| 38 | - | requireNoOutputFrame(t, sends) | |||
| 39 | - | ac.sendMu.Lock() | |||
| 40 | - | deferred := ac.output.deferred | |||
| 41 | - | ac.sendMu.Unlock() | |||
| 42 | - | require.True(t, deferred, "diff paint at ack cap must defer") | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | func TestDeferredPaintFlushesOnceWhenAcksCatchUp(t *testing.T) { | |||
| 46 | - | p, _ := newBlockingPTY(t) | |||
| 47 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 48 | - | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | |||
| 49 | - | ||||
| 50 | - | ac.output.next = maxUnackedOutputStates | |||
| 51 | - | d.paint(sess, ac, false) | |||
| 52 | - | requireNoOutputFrame(t, sends) | |||
| 53 | - | ||||
| 54 | - | // Drive a real MsgAck through the connection loop so the flush is exercised | |||
| 55 | - | // end-to-end: UnmarshalAck → advanceOutputAck → takeDeferredPaint → paint. | |||
| 56 | - | // One ack frame, then Recv blocks until the test ends. | |||
| 57 | - | recvCh := make(chan ports.Frame, 1) | |||
| 58 | - | done := make(chan struct{}) | |||
| 59 | - | t.Cleanup(func() { close(done) }) | |||
| 60 | - | tr, ok := ac.transport().(*portsmocks.MockTransport) | |||
| 61 | - | require.True(t, ok, "manual session transport must be the capturing mock") | |||
| 62 | - | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 63 | - | select { | |||
| 64 | - | case f := <-recvCh: | |||
| 65 | - | return f, nil | |||
| 66 | - | case <-done: | |||
| 67 | - | return ports.Frame{}, io.EOF | |||
| 68 | - | } | |||
| 69 | - | }).Maybe() | |||
| 70 | - | recvCh <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: maxUnackedOutputStates})} | |||
| 71 | - | go d.runConnLoop(ac) | |||
| 72 | - | ||||
| 73 | - | // The cumulative flush paint must be triggered by the MsgAck handler itself | |||
| 74 | - | // (the renderer shadow only advanced on send, so the skip coalesces here). | |||
| 75 | - | awaitFrame(t, sends, ports.MsgOutput) | |||
| 76 | - | requireNoOutputFrame(t, sends) | |||
| 77 | - | ac.sendMu.Lock() | |||
| 78 | - | deferred := ac.output.deferred | |||
| 79 | - | ac.sendMu.Unlock() | |||
| 80 | - | require.False(t, deferred, "flag must clear after the flush paint") | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | func TestResetPaintCoalescesAtAckGate(t *testing.T) { | |||
| 84 | - | p, _ := newBlockingPTY(t) | |||
| 85 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 86 | - | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | |||
| 87 | - | ||||
| 88 | - | // At the cap, repeated reset paints coalesce instead of growing retained | |||
| 89 | - | // state and transport queues without bound. | |||
| 90 | - | ac.output.next = maxUnackedOutputStates | |||
| 91 | - | for range 32 { | |||
| 92 | - | d.paint(sess, ac, true) | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | requireNoOutputFrame(t, sends) | |||
| 96 | - | ac.sendMu.Lock() | |||
| 97 | - | deferred := ac.output.deferred | |||
| 98 | - | deferredReset := ac.output.deferredReset | |||
| 99 | - | retained := ac.output.outstanding() | |||
| 100 | - | ac.sendMu.Unlock() | |||
| 101 | - | require.True(t, deferred) | |||
| 102 | - | require.True(t, deferredReset) | |||
| 103 | - | require.LessOrEqual(t, retained, uint64(maxUnackedOutputStates)) | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | func TestCoalescedResetFlushesLatestFrameAfterAck(t *testing.T) { | |||
| 107 | - | p, _ := newBlockingPTY(t) | |||
| 108 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 109 | - | for range maxUnackedOutputStates { | |||
| 110 | - | ac.output.frame([]byte("in flight"), false, 0) | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | pane := sess.tabs[0].focusedPane() | |||
| 114 | - | var latest byte | |||
| 115 | - | for i := range 32 { | |||
| 116 | - | latest = byte('a' + i%26) | |||
| 117 | - | pane.screen.Write([]byte{'\r', latest}) | |||
| 118 | - | d.paint(sess, ac, true) | |||
| 119 | - | } | |||
| 120 | - | requireNoOutputFrame(t, sends) | |||
| 121 | - | require.Equal(t, uint64(maxUnackedOutputStates), ac.output.outstanding()) | |||
| 122 | - | ||||
| 123 | - | ac.ackOutputState(maxUnackedOutputStates) | |||
| 124 | - | reset, ok := ac.takeDeferredPaint() | |||
| 125 | - | require.True(t, ok) | |||
| 126 | - | require.True(t, reset) | |||
| 127 | - | d.paint(sess, ac, reset) | |||
| 128 | - | ||||
| 129 | - | client := vt.NewScreen(80, 25) | |||
| 130 | - | out := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 131 | - | require.Zero(t, out.BaseStateNum) | |||
| 132 | - | require.Equal(t, rune(latest), client.Frame.At(0, 1).Rune) | |||
| 133 | - | require.Equal(t, uint64(1), ac.output.outstanding()) | |||
| 134 | - | requireNoOutputFrame(t, sends) | |||
| 135 | - | } | |||
M internal/usecase/daemon/palette.go +9 -9
| @@ -42,7 +42,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||||
| 42 | ac.overlays.paletteHints = palette.ContextualHints{} | 42 | ac.overlays.paletteHints = palette.ContextualHints{} | ||
| 43 | ac.overlays.palettePending = nil | 43 | ac.overlays.palettePending = nil | ||
| 44 | ac.overlays.paletteMu.Unlock() | 44 | ac.overlays.paletteMu.Unlock() | ||
| 45 | - | d.paint(sess, ac, true) | 45 | + | d.invalidateRender(sess, ac, true, "palette.go") |
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| 48 | func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints { | 48 | func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints { | ||
| @@ -167,12 +167,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 167 | } | 167 | } | ||
| 168 | ac.overlays.paletteMu.Unlock() | 168 | ac.overlays.paletteMu.Unlock() | ||
| 169 | if cancel { | 169 | if cancel { | ||
| 170 | - | d.paint(sess, ac, true) | 170 | + | d.invalidateRender(sess, ac, true, "palette.go") |
| 171 | return | 171 | return | ||
| 172 | } | 172 | } | ||
| 173 | if !execute { | 173 | if !execute { | ||
| 174 | if changed { | 174 | if changed { | ||
| 175 | - | d.paint(sess, ac, true) | 175 | + | d.invalidateRender(sess, ac, true, "palette.go") |
| 176 | } | 176 | } | ||
| 177 | return | 177 | return | ||
| 178 | } | 178 | } | ||
| @@ -183,7 +183,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 183 | rank, err := command.ParsePositiveDecimal(args) | 183 | rank, err := command.ParsePositiveDecimal(args) | ||
| 184 | if err != nil { | 184 | if err != nil { | ||
| 185 | ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal") | 185 | ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal") | ||
| 186 | - | d.paint(sess, ac, true) | 186 | + | d.invalidateRender(sess, ac, true, "palette.go") |
| 187 | return | 187 | return | ||
| 188 | } | 188 | } | ||
| 189 | // The captured ID is handed off atomically. A target can disappear after | 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 | exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent} | 191 | exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent} | ||
| 192 | if err := exec.JumpRecentSession(rank); err != nil { | 192 | if err := exec.JumpRecentSession(rank); err != nil { | ||
| 193 | ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable") | 193 | ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable") | ||
| 194 | - | d.paint(sess, ac, true) | 194 | + | d.invalidateRender(sess, ac, true, "palette.go") |
| 195 | return | 195 | return | ||
| 196 | } | 196 | } | ||
| 197 | // Publication remains generation-safe; do not let stale work close a | 197 | // Publication remains generation-safe; do not let stale work close a | ||
| 198 | // newer palette interaction. | 198 | // newer palette interaction. | ||
| 199 | if ac.closeExecutedPalette(generation, rawQuery) { | 199 | if ac.closeExecutedPalette(generation, rawQuery) { | ||
| 200 | d.recordPaletteUse(cmd.Code) | 200 | d.recordPaletteUse(cmd.Code) | ||
| 201 | - | d.paint(ac.currentSession(), ac, true) | 201 | + | d.invalidateRender(ac.currentSession(), ac, true, "palette.go") |
| 202 | } | 202 | } | ||
| 203 | return | 203 | return | ||
| 204 | } | 204 | } | ||
| @@ -259,7 +259,7 @@ type paletteExec struct { | |||||
| 259 | } | 259 | } | ||
| 260 | 260 | ||||
| 261 | func (e paletteExec) CreateTab() error { | 261 | func (e paletteExec) CreateTab() error { | ||
| 262 | - | defer e.d.paint(e.sess, e.ac, true) | 262 | + | defer e.d.invalidateRender(e.sess, e.ac, true, "palette.go") |
| 263 | return e.d.createTab(e.sess, e.ac.size) | 263 | return e.d.createTab(e.sess, e.ac.size) | ||
| 264 | } | 264 | } | ||
| 265 | 265 | ||||
| @@ -325,7 +325,7 @@ func (e paletteExec) NextTab() error { | |||||
| 325 | if e.sess.switchRelative(1) { | 325 | if e.sess.switchRelative(1) { | ||
| 326 | e.d.activateTab(e.sess, e.sess.activeTab()) | 326 | e.d.activateTab(e.sess, e.sess.activeTab()) | ||
| 327 | } | 327 | } | ||
| 328 | - | e.d.paint(e.sess, e.ac, true) | 328 | + | e.d.invalidateRender(e.sess, e.ac, true, "palette.go") |
| 329 | return nil | 329 | return nil | ||
| 330 | } | 330 | } | ||
| 331 | 331 | ||||
| @@ -333,7 +333,7 @@ func (e paletteExec) PrevTab() error { | |||||
| 333 | if e.sess.switchRelative(-1) { | 333 | if e.sess.switchRelative(-1) { | ||
| 334 | e.d.activateTab(e.sess, e.sess.activeTab()) | 334 | e.d.activateTab(e.sess, e.sess.activeTab()) | ||
| 335 | } | 335 | } | ||
| 336 | - | e.d.paint(e.sess, e.ac, true) | 336 | + | e.d.invalidateRender(e.sess, e.ac, true, "palette.go") |
| 337 | return nil | 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 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | 371 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | ||
| 372 | defer releases[0]() | 372 | defer releases[0]() | ||
| 373 | ptys := portsmocks.NewMockPTYFactory(t) | 373 | ptys := portsmocks.NewMockPTYFactory(t) | ||
| 374 | - | ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")) | 374 | + | ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")) |
| 375 | d.ptys = ptys | 375 | d.ptys = ptys | ||
| 376 | 376 | ||||
| 377 | d.handleInput(sess, ac, []byte("\x1b ")) | 377 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| @@ -518,7 +518,7 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) { | |||||
| 518 | go func() { | 518 | go func() { | ||
| 519 | defer wg.Done() | 519 | defer wg.Done() | ||
| 520 | for range 200 { | 520 | for range 200 { | ||
| 521 | - | d.paint(sess, ac, true) | 521 | + | d.paint(sess, ac, true, nil) |
| 522 | } | 522 | } | ||
| 523 | }() | 523 | }() | ||
| 524 | go func() { | 524 | go func() { | ||
M internal/usecase/daemon/pane.go +26 -32
| @@ -6,27 +6,32 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 8 | "github.com/bnema/vev/internal/ports" | 8 | "github.com/bnema/vev/internal/ports" | ||
| 9 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 10 | "github.com/bnema/vev/internal/usecase/layout" | 9 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 11 | "github.com/bnema/vev/pkg/vt" | 10 | "github.com/bnema/vev/pkg/vt" | ||
| 12 | ) | 11 | ) | ||
| 13 | 12 | ||||
| 14 | - | // pane owns one PTY, its terminal screen state, and render scheduling channels. | 13 | + | // pane owns one PTY and its terminal screen state. |
| 15 | // Lock order when multiple locks are held is: | 14 | // Lock order when multiple locks are held is: | ||
| 16 | // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | 15 | // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | ||
| 17 | // The PTY reader takes only pane.mu, so child output never waits on client IO. | 16 | // The PTY reader takes only pane.mu, so child output never waits on client IO. | ||
| 18 | type pane struct { | 17 | type pane struct { | ||
| 19 | - | id layout.PaneID | 18 | + | id layout.PaneID |
| 20 | - | stableID string | 19 | + | stableID string |
| 21 | - | pty ports.PTY | 20 | + | pty ports.PTY |
| 22 | - | mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title | 21 | + | mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title |
| 23 | - | resizeMu sync.Mutex // serializes PTY resizes without holding mu | 22 | + | resizeMu sync.Mutex // serializes PTY resizes without holding mu |
| 24 | - | screen *vt.Screen | 23 | + | screen *vt.Screen |
| 25 | - | scrollback *scopy.Scrollback | 24 | + | history *vt.History |
| 26 | - | dirty chan struct{} | 25 | + | syncGen uint64 |
| 27 | - | flush chan struct{} | 26 | + | rect domain.Rect |
| 28 | - | syncGen uint64 | 27 | + | // resizeApplying gates VT parsing across PTY.Resize. The reader continues |
| 29 | - | rect domain.Rect | 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 | popupGeometry floatingGeometry // last geometry committed after a successful floating resize | 35 | popupGeometry floatingGeometry // last geometry committed after a successful floating resize | ||
| 31 | title paneTitleState | 36 | title paneTitleState | ||
| 32 | ctx context.Context | 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 | func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane { | 49 | func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane { | ||
| 45 | - | sb := scopy.NewScrollback(defaultScrollbackRows) | 50 | + | screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows}) |
| 46 | - | screen := vt.NewScreen(sz.Cols, sz.Rows) | |||
| 47 | - | screen.OnLineEvicted = sb.Append | |||
| 48 | return &pane{ | 51 | return &pane{ | ||
| 49 | - | id: id, | 52 | + | id: id, |
| 50 | - | stableID: stableID, | 53 | + | stableID: stableID, |
| 51 | - | pty: pty, | 54 | + | pty: pty, |
| 52 | - | screen: screen, | 55 | + | screen: screen, |
| 53 | - | scrollback: sb, | 56 | + | history: screen.History(), |
| 54 | - | dirty: make(chan struct{}, 1), | 57 | + | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, |
| 55 | - | flush: make(chan struct{}, 1), | 58 | + | title: paneTitleState{displayFallback: "sh"}, |
| 56 | - | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | |||
| 57 | - | title: paneTitleState{displayFallback: "sh"}, | |||
| 58 | } | 59 | } | ||
| 59 | } | 60 | } | ||
| 60 | - | ||||
| 61 | - | func paneDone(p *pane) <-chan struct{} { | |||
| 62 | - | if p.ctx == nil { | |||
| 63 | - | return nil | |||
| 64 | - | } | |||
| 65 | - | return p.ctx.Done() | |||
| 66 | - | } | |||
M internal/usecase/daemon/pane_actions.go +15 -11
| @@ -23,7 +23,7 @@ func (d *Daemon) spawnPaneOp( | |||||
| 23 | ) error { | 23 | ) error { | ||
| 24 | if d.ptys == nil { | 24 | if d.ptys == nil { | ||
| 25 | if ac != nil { | 25 | if ac != nil { | ||
| 26 | - | d.paint(sess, ac, true) | 26 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") |
| 27 | } | 27 | } | ||
| 28 | return nil | 28 | return nil | ||
| 29 | } | 29 | } | ||
| @@ -69,7 +69,7 @@ func (d *Daemon) spawnPaneOp( | |||||
| 69 | tb.mu.Unlock() | 69 | tb.mu.Unlock() | ||
| 70 | return fmt.Errorf("daemon: generating pane identity: %w", err) | 70 | return fmt.Errorf("daemon: generating pane identity: %w", err) | ||
| 71 | } | 71 | } | ||
| 72 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect)) | 72 | + | pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect)) |
| 73 | if err != nil { | 73 | if err != nil { | ||
| 74 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | 74 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | ||
| 75 | tb.mu.Lock() | 75 | tb.mu.Lock() | ||
| @@ -99,7 +99,7 @@ func (d *Daemon) spawnPaneOp( | |||||
| 99 | d.startPaneGoroutines(sess, tb, p) | 99 | d.startPaneGoroutines(sess, tb, p) | ||
| 100 | markSnapshotDirty(sess) | 100 | markSnapshotDirty(sess) | ||
| 101 | if ac != nil { | 101 | if ac != nil { | ||
| 102 | - | d.paint(sess, ac, true) | 102 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") |
| 103 | } | 103 | } | ||
| 104 | return nil | 104 | return nil | ||
| 105 | } | 105 | } | ||
| @@ -111,7 +111,7 @@ func (d *Daemon) applyLayoutLocked(tb *tab) { | |||||
| 111 | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | 111 | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | ||
| 112 | if !ok { | 112 | if !ok { | ||
| 113 | for _, p := range tb.panes { | 113 | for _, p := range tb.panes { | ||
| 114 | - | d.resizePane(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | 114 | + | d.applyPaneResize(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) |
| 115 | } | 115 | } | ||
| 116 | return | 116 | return | ||
| 117 | } | 117 | } | ||
| @@ -120,12 +120,12 @@ func (d *Daemon) applyLayoutLocked(tb *tab) { | |||||
| 120 | continue | 120 | continue | ||
| 121 | } | 121 | } | ||
| 122 | if p := tb.panes[pl.ID]; p != nil { | 122 | if p := tb.panes[pl.ID]; p != nil { | ||
| 123 | - | d.resizePane(p, pl.Content) | 123 | + | d.applyPaneResize(p, pl.Content) |
| 124 | } | 124 | } | ||
| 125 | } | 125 | } | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | - | func (d *Daemon) resizePane(p *pane, r domain.Rect) { | 128 | + | func (d *Daemon) applyPaneResize(p *pane, r domain.Rect) { |
| 129 | if p == nil { | 129 | if p == nil { | ||
| 130 | return | 130 | return | ||
| 131 | } | 131 | } | ||
| @@ -167,7 +167,7 @@ func (d *Daemon) stackPane(sess *session, ac *attachedClient) error { | |||||
| 167 | func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | 167 | func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | ||
| 168 | if d.ptys == nil { | 168 | if d.ptys == nil { | ||
| 169 | if ac != nil { | 169 | if ac != nil { | ||
| 170 | - | d.paint(sess, ac, true) | 170 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") |
| 171 | } | 171 | } | ||
| 172 | return nil | 172 | return nil | ||
| 173 | } | 173 | } | ||
| @@ -188,7 +188,7 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | |||||
| 188 | if err == nil { | 188 | if err == nil { | ||
| 189 | markSnapshotDirty(sess) | 189 | markSnapshotDirty(sess) | ||
| 190 | if ac != nil { | 190 | if ac != nil { | ||
| 191 | - | d.paint(sess, ac, true) | 191 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") |
| 192 | } | 192 | } | ||
| 193 | } | 193 | } | ||
| 194 | return err | 194 | return err | ||
| @@ -197,7 +197,7 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | |||||
| 197 | func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error { | 197 | func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error { | ||
| 198 | if d.ptys == nil { | 198 | if d.ptys == nil { | ||
| 199 | if ac != nil { | 199 | if ac != nil { | ||
| 200 | - | d.paint(sess, ac, true) | 200 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") |
| 201 | } | 201 | } | ||
| 202 | return nil | 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 | if ac != nil { | 256 | if ac != nil { | ||
| 257 | ac.overlays.clearCopyModeForPane(p) | 257 | ac.overlays.clearCopyModeForPane(p) | ||
| 258 | + | ac.pruneCaptureFrames(p) | |||
| 258 | } | 259 | } | ||
| 259 | 260 | ||||
| 261 | + | if rc := sess.renderCoordinator(); rc != nil { | |||
| 262 | + | rc.noteSyncPaneRemoved(p) | |||
| 263 | + | } | |||
| 260 | if p.cancel != nil { | 264 | if p.cancel != nil { | ||
| 261 | p.cancel() | 265 | p.cancel() | ||
| 262 | } | 266 | } | ||
| @@ -267,7 +271,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||||
| 267 | markSnapshotDirty(sess) | 271 | markSnapshotDirty(sess) | ||
| 268 | if repaint { | 272 | if repaint { | ||
| 269 | if ac != nil { | 273 | if ac != nil { | ||
| 270 | - | d.paint(sess, ac, true) | 274 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") |
| 271 | } | 275 | } | ||
| 272 | } | 276 | } | ||
| 273 | return nil | 277 | return nil | ||
| @@ -305,7 +309,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||||
| 305 | d.refreshPaneTitleOnFocus(sess, newFocus) | 309 | d.refreshPaneTitleOnFocus(sess, newFocus) | ||
| 306 | } | 310 | } | ||
| 307 | } | 311 | } | ||
| 308 | - | d.paint(sess, ac, true) | 312 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") |
| 309 | } | 313 | } | ||
| 310 | if errors.Is(err, layout.ErrNoPane) { | 314 | if errors.Is(err, layout.ErrNoPane) { | ||
| 311 | return nil | 315 | return nil | ||
M internal/usecase/daemon/pane_actions_test.go +9 -9
| @@ -39,7 +39,7 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |||||
| 39 | newPTY := portsmocks.NewMockPTY(t) | 39 | newPTY := portsmocks.NewMockPTY(t) | ||
| 40 | oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once() | 40 | oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once() | ||
| 41 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | 41 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | ||
| 42 | - | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once() | 42 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once() |
| 43 | 43 | ||||
| 44 | require.NoError(t, d.splitPane(sess, nil, tt.dir)) | 44 | require.NoError(t, d.splitPane(sess, nil, tt.dir)) | ||
| 45 | 45 | ||||
| @@ -68,7 +68,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | |||||
| 68 | newPTY := portsmocks.NewMockPTY(t) | 68 | newPTY := portsmocks.NewMockPTY(t) | ||
| 69 | stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once() | 69 | stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once() | ||
| 70 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | 70 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | ||
| 71 | - | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once() | 71 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once() |
| 72 | 72 | ||||
| 73 | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | 73 | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | ||
| 74 | 74 | ||||
| @@ -84,7 +84,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | |||||
| 84 | 84 | ||||
| 85 | func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) { | 85 | func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) { | ||
| 86 | d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | 86 | d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | ||
| 87 | - | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once() | 87 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once() |
| 88 | 88 | ||||
| 89 | tb := sess.activeTab() | 89 | tb := sess.activeTab() | ||
| 90 | tb.mu.Lock() | 90 | tb.mu.Lock() | ||
| @@ -231,7 +231,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) { | |||||
| 231 | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5}) | 231 | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5}) | ||
| 232 | newPTY := portsmocks.NewMockPTY(t) | 232 | newPTY := portsmocks.NewMockPTY(t) | ||
| 233 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | 233 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | ||
| 234 | - | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once() | 234 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once() |
| 235 | 235 | ||||
| 236 | require.NoError(t, d.stackPane(sess, nil)) | 236 | require.NoError(t, d.stackPane(sess, nil)) | ||
| 237 | tb := sess.activeTab() | 237 | tb := sess.activeTab() | ||
| @@ -251,7 +251,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) { | |||||
| 251 | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3}) | 251 | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3}) | ||
| 252 | newPTY := portsmocks.NewMockPTY(t) | 252 | newPTY := portsmocks.NewMockPTY(t) | ||
| 253 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | 253 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | ||
| 254 | - | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once() | 254 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once() |
| 255 | require.NoError(t, d.stackPane(sess, nil)) | 255 | require.NoError(t, d.stackPane(sess, nil)) | ||
| 256 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once() | 256 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once() | ||
| 257 | 257 | ||||
| @@ -261,7 +261,7 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) { | |||||
| 261 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus) | 261 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus) | ||
| 262 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded) | 262 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded) | ||
| 263 | thirdPTY := portsmocks.NewMockPTY(t) | 263 | thirdPTY := portsmocks.NewMockPTY(t) | ||
| 264 | - | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe() | 264 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe() |
| 265 | require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall) | 265 | require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall) | ||
| 266 | require.Len(t, tb.panes, 2) | 266 | require.Len(t, tb.panes, 2) | ||
| 267 | } | 267 | } | ||
| @@ -291,7 +291,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) { | |||||
| 291 | defer releaseNew() | 291 | defer releaseNew() | ||
| 292 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | 292 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | ||
| 293 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | 293 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | ||
| 294 | - | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once() | 294 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once() |
| 295 | 295 | ||||
| 296 | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | 296 | require.NoError(t, d.splitPane(sess, nil, layout.Right)) | ||
| 297 | tb := sess.activeTab() | 297 | tb := sess.activeTab() | ||
| @@ -350,8 +350,8 @@ func TestSplitPaneInheritsTerminalEnv(t *testing.T) { | |||||
| 350 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | 350 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | ||
| 351 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | 351 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | ||
| 352 | var gotEnv []string | 352 | var gotEnv []string | ||
| 353 | - | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn( | 353 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn( |
| 354 | - | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | 354 | + | func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { |
| 355 | gotEnv = append([]string(nil), env...) | 355 | gotEnv = append([]string(nil), env...) | ||
| 356 | return newPTY, nil | 356 | return newPTY, nil | ||
| 357 | }, | 357 | }, | ||
M internal/usecase/daemon/performance_benchmark_test.go +650 -54
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "io" | 5 | "io" | ||
| 6 | "sync" | 6 | "sync" | ||
| 7 | "testing" | 7 | "testing" | ||
| 8 | + | "time" | |||
| 8 | 9 | ||||
| 9 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 10 | 11 | ||||
| @@ -49,8 +50,15 @@ func TestPerformanceFixtureCounters(t *testing.T) { | |||||
| 49 | fixture.paintLive() | 50 | fixture.paintLive() | ||
| 50 | metrics := fixture.metrics() | 51 | metrics := fixture.metrics() | ||
| 51 | require.Equal(t, uint64(2), metrics.outputFrames) | 52 | require.Equal(t, uint64(2), metrics.outputFrames) | ||
| 53 | + | require.Equal(t, uint64(2), metrics.renderCaptures, "capture is one immutable state snapshot per live render request") | |||
| 54 | + | require.Equal(t, uint64(2), metrics.renderCompositions, "composition is one frame built from each captured state") | |||
| 55 | + | require.Equal(t, uint64(2), metrics.renderEmissions, "emission is one accepted output frame") | |||
| 52 | require.Positive(t, metrics.outputBytes) | 56 | require.Positive(t, metrics.outputBytes) | ||
| 53 | require.Positive(t, metrics.outputPayloadBytes) | 57 | require.Positive(t, metrics.outputPayloadBytes) | ||
| 58 | + | require.Equal(t, uint64(2), metrics.coordinatorInvalidations) | |||
| 59 | + | require.Equal(t, uint64(2), metrics.coordinatorWakes) | |||
| 60 | + | require.Equal(t, uint64(2), metrics.coordinatorCoalesced) | |||
| 61 | + | require.Positive(t, metrics.coordinatorWakes, "coordinator metric ratios require a nonzero denominator") | |||
| 54 | 62 | ||||
| 55 | fixture.captureSnapshot() | 63 | fixture.captureSnapshot() | ||
| 56 | metrics = fixture.metrics() | 64 | metrics = fixture.metrics() | ||
| @@ -64,14 +72,70 @@ func TestPerformanceFixtureCounters(t *testing.T) { | |||||
| 64 | require.True(t, fixture.resized()) | 72 | require.True(t, fixture.resized()) | ||
| 65 | } | 73 | } | ||
| 66 | 74 | ||||
| 67 | - | func TestPerformanceFixtureResizeAlternatesRealDimensions(t *testing.T) { | 75 | + | func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) { |
| 68 | - | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}}) | 76 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) |
| 77 | + | d.paint(sess, ac, true, nil) | |||
| 78 | + | _ = awaitFrame(t, sends, ports.MsgOutput) | |||
| 79 | + | var captures, compositions, emissions int | |||
| 80 | + | ac.renderStages = renderStageHooks{ | |||
| 81 | + | capture: func() { captures++ }, | |||
| 82 | + | compose: func() { compositions++ }, | |||
| 83 | + | emit: func() { emissions++ }, | |||
| 84 | + | } | |||
| 85 | + | ac.replaceTransport(cacheFailTransport{}) | |||
| 86 | + | p := sess.tabs[0].focusedPane() | |||
| 87 | + | p.mu.Lock() | |||
| 88 | + | p.screen.Write([]byte("hook")) | |||
| 89 | + | p.mu.Unlock() | |||
| 90 | + | d.paint(sess, ac, false, nil) | |||
| 91 | + | require.Equal(t, 1, captures, "a completed capture counts even when its emission fails") | |||
| 92 | + | require.Equal(t, 1, compositions, "a completed composition counts even when its emission fails") | |||
| 93 | + | require.Zero(t, emissions, "emit counts only prepare and transport success") | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestPerformanceFixtureResize(t *testing.T) { | |||
| 97 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}}) | |||
| 98 | + | sequence := []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}, {Cols: 160, Rows: 50}, {Cols: 80, Rows: 24}} | |||
| 69 | 99 | ||||
| 70 | - | for _, want := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}} { | 100 | + | for _, want := range sequence { |
| 71 | - | fixture.resize() | 101 | + | fixture.resizeTo(want) |
| 72 | require.Equal(t, want, fixture.ac.size) | 102 | require.Equal(t, want, fixture.ac.size) | ||
| 73 | require.True(t, fixture.resized()) | 103 | require.True(t, fixture.resized()) | ||
| 74 | } | 104 | } | ||
| 105 | + | metrics := fixture.metrics() | |||
| 106 | + | require.Equal(t, uint64(len(sequence)), metrics.resizeRequests) | |||
| 107 | + | require.Equal(t, uint64(len(sequence)), metrics.resizeCommits) | |||
| 108 | + | require.Equal(t, uint64(len(sequence)), metrics.outputFrames, "one full frame per accepted epoch") | |||
| 109 | + | require.Zero(t, metrics.skippedEpochs) | |||
| 110 | + | require.Zero(t, metrics.ptyFailures) | |||
| 111 | + | require.Zero(t, metrics.frameGapEpochs) | |||
| 112 | + | require.NotZero(t, metrics.resizeCommitNanos) | |||
| 113 | + | require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 60, Rows: 38}, {Cols: 80, Rows: 48}, {Cols: 40, Rows: 22}}, fixture.pty.requested()) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestTransactionalResizeMetricDefinitionsAreZeroSafe(t *testing.T) { | |||
| 117 | + | metrics := performanceMetrics{} | |||
| 118 | + | require.Zero(t, coordinatorCoalescingRatio(metrics)) | |||
| 119 | + | require.Zero(t, metrics.skippedEpochs, "no accepted request means no skipped epoch") | |||
| 120 | + | require.Zero(t, metrics.frameGapEpochs, "no committed epoch means no frame gap") | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestTransactionalResizeMetrics(t *testing.T) { | |||
| 124 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, ptyErrors: []error{fmt.Errorf("scripted failure"), nil}}) | |||
| 125 | + | fixture.resizeTo(domain.Size{Cols: 100, Rows: 30}) | |||
| 126 | + | // The fixture deliberately invokes the coordinator's committed retry path: | |||
| 127 | + | // a retry never republishes geometry and its reset produces one extra frame. | |||
| 128 | + | fixture.retryLatest() | |||
| 129 | + | ||||
| 130 | + | metrics := fixture.metrics() | |||
| 131 | + | require.Equal(t, uint64(1), metrics.resizeRequests) | |||
| 132 | + | require.Equal(t, uint64(1), metrics.resizeCommits) | |||
| 133 | + | require.Equal(t, uint64(1), metrics.ptyFailures) | |||
| 134 | + | require.Equal(t, uint64(1), metrics.ptyRetries) | |||
| 135 | + | require.Equal(t, uint64(2), metrics.outputFrames, "commit plus successful full-reset retry") | |||
| 136 | + | require.Zero(t, metrics.frameGapEpochs, "every committed epoch emitted its required frame") | |||
| 137 | + | require.Equal(t, []domain.Size{{Cols: 50, Rows: 28}, {Cols: 50, Rows: 28}}, fixture.pty.requested()) | |||
| 138 | + | require.Equal(t, domain.Size{Cols: 100, Rows: 30}, fixture.ac.size) | |||
| 75 | } | 139 | } | ||
| 76 | 140 | ||||
| 77 | func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) { | 141 | func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) { | ||
| @@ -81,45 +145,137 @@ func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) { | |||||
| 81 | for _, tb := range fixture.sess.tabs { | 145 | for _, tb := range fixture.sess.tabs { | ||
| 82 | require.Len(t, tb.panes, 4) | 146 | require.Len(t, tb.panes, 4) | ||
| 83 | for _, p := range tb.panes { | 147 | for _, p := range tb.panes { | ||
| 84 | - | require.Equal(t, 10_000, p.scrollback.Len()) | 148 | + | require.Equal(t, 10_000, p.history.Len()) |
| 85 | } | 149 | } | ||
| 86 | } | 150 | } | ||
| 87 | } | 151 | } | ||
| 88 | 152 | ||||
| 153 | + | func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) { | |||
| 154 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000}) | |||
| 155 | + | ||||
| 156 | + | first, ok := fixture.d.captureSnapshotState(fixture.sess, 1) | |||
| 157 | + | require.True(t, ok) | |||
| 158 | + | require.Len(t, first.tabs, 1) | |||
| 159 | + | require.Len(t, first.tabs[0].panes, 2) | |||
| 160 | + | require.Equal(t, 10_000, first.tabs[0].panes[0].history.Len()) | |||
| 161 | + | require.Equal(t, 40, first.tabs[0].panes[0].history.ChunkCount()) | |||
| 162 | + | ||||
| 163 | + | second, ok := fixture.d.captureSnapshotState(fixture.sess, 2) | |||
| 164 | + | require.True(t, ok) | |||
| 165 | + | secondPane := capturePaneByID(second, first.tabs[0].panes[0].id) | |||
| 166 | + | if secondPane == nil || first.tabs[0].panes[0].history.Chunk(0) != secondPane.history.Chunk(0) { | |||
| 167 | + | t.Fatal("unchanged sealed history chunk was not reused across captures") | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestPerformanceFixtureCoordinatorMetricsForCanonicalTopologies(t *testing.T) { | |||
| 172 | + | for _, topology := range daemonHistoryTopologies { | |||
| 173 | + | t.Run(topology.name, func(t *testing.T) { | |||
| 174 | + | fixture := newPerformanceFixture(t, performanceConfig{ | |||
| 175 | + | size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000, | |||
| 176 | + | }) | |||
| 177 | + | fixture.paintLive() | |||
| 178 | + | metrics := fixture.metrics() | |||
| 179 | + | require.Equal(t, uint64(1), metrics.coordinatorInvalidations) | |||
| 180 | + | require.Equal(t, uint64(1), metrics.coordinatorWakes) | |||
| 181 | + | require.Equal(t, uint64(1), metrics.coordinatorCoalesced) | |||
| 182 | + | require.Positive(t, metrics.coordinatorWakes, "coalescing ratio denominator") | |||
| 183 | + | }) | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | type daemonHistoryTopology struct { | |||
| 188 | + | name string | |||
| 189 | + | tabs, panes int | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | var daemonHistoryTopologies = []daemonHistoryTopology{ | |||
| 193 | + | {name: "1tab-1pane-control", tabs: 1, panes: 1}, | |||
| 194 | + | {name: "1tab-4panes", tabs: 1, panes: 4}, | |||
| 195 | + | {name: "4tabs-1pane", tabs: 4, panes: 1}, | |||
| 196 | + | {name: "4tabs-4panes", tabs: 4, panes: 4}, | |||
| 197 | + | {name: "8tabs-1pane", tabs: 8, panes: 1}, | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // v3 bounds a decoded snapshot to 256 MiB. One 120-column 10k-row tab is | |||
| 201 | + | // representative and valid; multi-tab fixtures exceed that durable budget, so | |||
| 202 | + | // snapshot capture/encode benchmark only the valid one-tab topologies. | |||
| 203 | + | var daemonSnapshotTopologies = []daemonHistoryTopology{ | |||
| 204 | + | {name: "1tab-1pane-control", tabs: 1, panes: 1}, | |||
| 205 | + | {name: "1tab-4panes", tabs: 1, panes: 4}, | |||
| 206 | + | } | |||
| 207 | + | ||||
| 89 | func BenchmarkDaemonHistoryLivePaint(b *testing.B) { | 208 | func BenchmarkDaemonHistoryLivePaint(b *testing.B) { | ||
| 90 | benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() }) | 209 | benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() }) | ||
| 91 | } | 210 | } | ||
| 211 | + | ||||
| 212 | + | // BenchmarkDaemonHistorySnapshotCapture measures the synchronous immutable | |||
| 213 | + | // capture boundary only. Encoding and the bounded worker queue are deliberately | |||
| 214 | + | // excluded so scheduler latency cannot affect this benchmark. | |||
| 92 | func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) { | 215 | func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) { | ||
| 93 | - | benchmarkDaemonLargeHistory(b, "snapshot-capture", func(f *performanceFixture) { f.captureSnapshot() }) | 216 | + | benchmarkDaemonSnapshotCapture(b) |
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // BenchmarkDaemonHistorySnapshotEncode measures v3 encoding from an already | |||
| 220 | + | // immutable capture, without queueing or persistence. | |||
| 221 | + | func BenchmarkDaemonHistorySnapshotEncode(b *testing.B) { | |||
| 222 | + | benchmarkDaemonSnapshotEncode(b) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // BenchmarkDaemonHistorySnapshotRestore measures terminal-state restoration | |||
| 226 | + | // from a validated v3 pane manifest without spawning PTY or session workers. | |||
| 227 | + | func BenchmarkDaemonHistorySnapshotRestore(b *testing.B) { | |||
| 228 | + | benchmarkDaemonSnapshotRestore(b) | |||
| 94 | } | 229 | } | ||
| 230 | + | ||||
| 231 | + | // BenchmarkDaemonHistoryCopyEnter uses the parent benchmark's repeated-entry | |||
| 232 | + | // semantics: each operation replaces the active copy document. Exit cleanup is | |||
| 233 | + | // intentionally outside this benchmark, so its allocations cannot be reported | |||
| 234 | + | // as copy-entry work. | |||
| 95 | func BenchmarkDaemonHistoryCopyEnter(b *testing.B) { | 235 | func BenchmarkDaemonHistoryCopyEnter(b *testing.B) { | ||
| 96 | benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) }) | 236 | benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) }) | ||
| 97 | } | 237 | } | ||
| 98 | func BenchmarkDaemonHistoryCopySearch(b *testing.B) { | 238 | func BenchmarkDaemonHistoryCopySearch(b *testing.B) { | ||
| 99 | benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) }) | 239 | benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) }) | ||
| 100 | } | 240 | } | ||
| 101 | - | func BenchmarkDaemonHistoryResize(b *testing.B) { | 241 | + | |
| 102 | - | benchmarkDaemonLargeHistory(b, "resize", func(f *performanceFixture) { f.resize() }) | 242 | + | // BenchmarkDaemonHistoryResizeSweep alternates deterministic grow/shrink |
| 243 | + | // geometry. Metrics are fixture counters; no benchmark result depends on wall | |||
| 244 | + | // time or scheduler delivery. | |||
| 245 | + | func BenchmarkDaemonHistoryResizeSweep(b *testing.B) { | |||
| 246 | + | sequence := []domain.Size{{Cols: 80, Rows: 24}, {Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}, {Cols: 160, Rows: 50}, {Cols: 120, Rows: 40}, {Cols: 100, Rows: 30}} | |||
| 247 | + | benchmarkDaemonLargeHistory(b, "resize-sweep", func(f *performanceFixture) { | |||
| 248 | + | f.resizeTo(sequence[f.resizes%len(sequence)]) | |||
| 249 | + | }) | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // BenchmarkDaemonHistoryResizeRetry measures one deterministic failed apply | |||
| 253 | + | // followed by its successful full-reset retry for each operation. | |||
| 254 | + | func BenchmarkDaemonHistoryResizeRetry(b *testing.B) { | |||
| 255 | + | benchmarkDaemonLargeHistory(b, "resize-retry", func(f *performanceFixture) { | |||
| 256 | + | f.pty.setErrors([]error{fmt.Errorf("scripted resize failure"), nil}) | |||
| 257 | + | size := domain.Size{Cols: 100, Rows: 30} | |||
| 258 | + | if f.resizes%2 != 0 { | |||
| 259 | + | size = domain.Size{Cols: 120, Rows: 40} | |||
| 260 | + | } | |||
| 261 | + | f.resizeTo(size) | |||
| 262 | + | f.retryLatest() | |||
| 263 | + | }) | |||
| 103 | } | 264 | } | ||
| 104 | 265 | ||||
| 105 | func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) { | 266 | func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) { | ||
| 106 | b.Helper() | 267 | b.Helper() | ||
| 107 | - | for _, topology := range []struct { | 268 | + | for _, topology := range daemonHistoryTopologies { |
| 108 | - | name string | |||
| 109 | - | tabs, panes int | |||
| 110 | - | }{ | |||
| 111 | - | {name: "1tab-1pane-control", tabs: 1, panes: 1}, | |||
| 112 | - | {name: "1tab-4panes", tabs: 1, panes: 4}, | |||
| 113 | - | {name: "4tabs-1pane", tabs: 4, panes: 1}, | |||
| 114 | - | {name: "4tabs-4panes", tabs: 4, panes: 4}, | |||
| 115 | - | {name: "8tabs-1pane", tabs: 8, panes: 1}, | |||
| 116 | - | } { | |||
| 117 | b.Run(topology.name, func(b *testing.B) { | 269 | b.Run(topology.name, func(b *testing.B) { | ||
| 118 | fixture := newPerformanceFixture(b, performanceConfig{ | 270 | fixture := newPerformanceFixture(b, performanceConfig{ | ||
| 119 | size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000, | 271 | size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000, | ||
| 120 | }) | 272 | }) | ||
| 273 | + | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | |||
| 274 | + | b.Fatal("invalid daemon history fixture") | |||
| 275 | + | } | |||
| 121 | if workload == "copy-search" { | 276 | if workload == "copy-search" { | ||
| 122 | - | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | 277 | + | benchmarkDaemonCopySearch(b, fixture, run) |
| 278 | + | return | |||
| 123 | } | 279 | } | ||
| 124 | b.ReportAllocs() | 280 | b.ReportAllocs() | ||
| 125 | fixture.resetMetrics() | 281 | fixture.resetMetrics() | ||
| @@ -133,26 +289,180 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor | |||||
| 133 | b.Fatalf("decode last output: %v", err) | 289 | b.Fatalf("decode last output: %v", err) | ||
| 134 | } | 290 | } | ||
| 135 | } | 291 | } | ||
| 136 | - | if workload == "snapshot-capture" { | |||
| 137 | - | payload := fixture.snaps.lastPayload() | |||
| 138 | - | if payload == nil { | |||
| 139 | - | b.Fatal("snapshot capture retained no snapshot payload") | |||
| 140 | - | } | |||
| 141 | - | if _, err := snapcodec.Unmarshal(payload); err != nil { | |||
| 142 | - | b.Fatalf("decode last snapshot: %v", err) | |||
| 143 | - | } | |||
| 144 | - | } | |||
| 145 | if workload == "live-paint" && metrics.outputFrames != uint64(b.N) { | 292 | if workload == "live-paint" && metrics.outputFrames != uint64(b.N) { | ||
| 146 | b.Fatalf("live paint emitted %d frames for %d operations", metrics.outputFrames, b.N) | 293 | b.Fatalf("live paint emitted %d frames for %d operations", metrics.outputFrames, b.N) | ||
| 147 | } | 294 | } | ||
| 148 | - | if workload == "snapshot-capture" && metrics.snapshotWrites != uint64(b.N) { | |||
| 149 | - | b.Fatalf("snapshot capture wrote %d snapshots for %d operations", metrics.snapshotWrites, b.N) | |||
| 150 | - | } | |||
| 151 | benchmarkReportMetrics(b, metrics, b.N, 10_000) | 295 | benchmarkReportMetrics(b, metrics, b.N, 10_000) | ||
| 152 | }) | 296 | }) | ||
| 153 | } | 297 | } | ||
| 154 | } | 298 | } | ||
| 155 | 299 | ||||
| 300 | + | func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) { | |||
| 301 | + | b.Helper() | |||
| 302 | + | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | |||
| 303 | + | fixture.ac.ackOutputState(fixture.ac.output.next) | |||
| 304 | + | b.ReportAllocs() | |||
| 305 | + | b.ResetTimer() | |||
| 306 | + | for range b.N { | |||
| 307 | + | run(fixture) | |||
| 308 | + | fixture.ac.ackOutputState(fixture.ac.output.next) | |||
| 309 | + | } | |||
| 310 | + | b.StopTimer() | |||
| 311 | + | if fixture.searchMatches() == 0 { | |||
| 312 | + | b.Fatal("copy search produced no deterministic matches") | |||
| 313 | + | } | |||
| 314 | + | b.ReportMetric(1, "copyoperations/op") | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | func benchmarkDaemonSnapshotCapture(b *testing.B) { | |||
| 318 | + | for _, topology := range daemonSnapshotTopologies { | |||
| 319 | + | b.Run(topology.name, func(b *testing.B) { | |||
| 320 | + | fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000}) | |||
| 321 | + | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | |||
| 322 | + | b.Fatal("invalid daemon snapshot capture fixture") | |||
| 323 | + | } | |||
| 324 | + | first, ok := fixture.d.captureSnapshotState(fixture.sess, 1) | |||
| 325 | + | if !ok || !validBenchmarkCapture(first) { | |||
| 326 | + | b.Fatal("invalid snapshot capture fixture") | |||
| 327 | + | } | |||
| 328 | + | firstPane := first.tabs[0].panes[0] | |||
| 329 | + | firstChunk := firstPane.history.Chunk(0) | |||
| 330 | + | captures := 0 | |||
| 331 | + | var last *snapshotCapture | |||
| 332 | + | b.ReportAllocs() | |||
| 333 | + | b.ResetTimer() | |||
| 334 | + | for range b.N { | |||
| 335 | + | last, ok = fixture.d.captureSnapshotState(fixture.sess, uint64(captures+2)) | |||
| 336 | + | if !ok { | |||
| 337 | + | b.Fatal("snapshot capture unexpectedly rejected fixture") | |||
| 338 | + | } | |||
| 339 | + | captures++ | |||
| 340 | + | } | |||
| 341 | + | b.StopTimer() | |||
| 342 | + | lastPane := capturePaneByID(last, firstPane.id) | |||
| 343 | + | if captures != b.N || !validBenchmarkCapture(last) || lastPane == nil || lastPane.history.Chunk(0) != firstChunk { | |||
| 344 | + | b.Fatal("snapshot capture did not preserve immutable sealed history") | |||
| 345 | + | } | |||
| 346 | + | b.ReportMetric(float64(capturePaneCount(last)), "capturepanes/op") | |||
| 347 | + | }) | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | func benchmarkDaemonSnapshotEncode(b *testing.B) { | |||
| 352 | + | for _, topology := range daemonSnapshotTopologies { | |||
| 353 | + | b.Run(topology.name, func(b *testing.B) { | |||
| 354 | + | fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000}) | |||
| 355 | + | if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) { | |||
| 356 | + | b.Fatal("invalid daemon snapshot encode fixture") | |||
| 357 | + | } | |||
| 358 | + | capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1) | |||
| 359 | + | if !ok || !validBenchmarkCapture(capture) { | |||
| 360 | + | b.Fatal("invalid snapshot encode fixture") | |||
| 361 | + | } | |||
| 362 | + | encoded, err := fixture.d.encodeSnapshotCapture(capture) | |||
| 363 | + | if err != nil { | |||
| 364 | + | b.Fatal(err) | |||
| 365 | + | } | |||
| 366 | + | if _, err := snapcodec.Unmarshal(encoded); err != nil { | |||
| 367 | + | b.Fatalf("invalid v3 snapshot encode fixture: %v", err) | |||
| 368 | + | } | |||
| 369 | + | var last []byte | |||
| 370 | + | b.ReportAllocs() | |||
| 371 | + | b.SetBytes(int64(len(encoded))) | |||
| 372 | + | b.ResetTimer() | |||
| 373 | + | for range b.N { | |||
| 374 | + | last, err = fixture.d.encodeSnapshotCapture(capture) | |||
| 375 | + | if err != nil { | |||
| 376 | + | b.Fatal(err) | |||
| 377 | + | } | |||
| 378 | + | } | |||
| 379 | + | b.StopTimer() | |||
| 380 | + | if _, err := snapcodec.Unmarshal(last); err != nil { | |||
| 381 | + | b.Fatalf("snapshot encoder produced invalid v3 data: %v", err) | |||
| 382 | + | } | |||
| 383 | + | b.ReportMetric(float64(len(last)), "snapshotbytes/op") | |||
| 384 | + | }) | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func benchmarkDaemonSnapshotRestore(b *testing.B) { | |||
| 389 | + | fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000}) | |||
| 390 | + | if !fixture.hasHistoryTopology(1, 1, 10_000) { | |||
| 391 | + | b.Fatal("invalid daemon snapshot restore fixture") | |||
| 392 | + | } | |||
| 393 | + | capture, ok := fixture.d.captureSnapshotState(fixture.sess, 1) | |||
| 394 | + | if !ok || !validBenchmarkCapture(capture) { | |||
| 395 | + | b.Fatal("invalid snapshot restore fixture") | |||
| 396 | + | } | |||
| 397 | + | encoded, err := fixture.d.encodeSnapshotCapture(capture) | |||
| 398 | + | if err != nil { | |||
| 399 | + | b.Fatal(err) | |||
| 400 | + | } | |||
| 401 | + | snapshot, err := snapcodec.Unmarshal(encoded) | |||
| 402 | + | if err != nil || len(snapshot.Tabs) != 1 || len(snapshot.Tabs[0].Panes) == 0 { | |||
| 403 | + | b.Fatalf("invalid v3 snapshot restore fixture: %v", err) | |||
| 404 | + | } | |||
| 405 | + | paneSnapshot := snapshot.Tabs[0].Panes[0] | |||
| 406 | + | b.ReportAllocs() | |||
| 407 | + | b.SetBytes(int64(len(encoded))) | |||
| 408 | + | b.ResetTimer() | |||
| 409 | + | for range b.N { | |||
| 410 | + | if err := restorePaneTerminal(fixture.activePane, paneSnapshot); err != nil { | |||
| 411 | + | b.Fatal(err) | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 414 | + | b.StopTimer() | |||
| 415 | + | fixture.activePane.mu.Lock() | |||
| 416 | + | restoredRows := fixture.activePane.history.Len() | |||
| 417 | + | fixture.activePane.mu.Unlock() | |||
| 418 | + | if restoredRows != 10_000 { | |||
| 419 | + | b.Fatalf("snapshot restore retained %d history rows, want 10000", restoredRows) | |||
| 420 | + | } | |||
| 421 | + | b.ReportMetric(float64(restoredRows), "historyrows/restore") | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | func validBenchmarkCapture(capture *snapshotCapture) bool { | |||
| 425 | + | return capture != nil && len(capture.tabs) > 0 && len(capture.tabs[0].panes) > 0 && capture.tabs[0].panes[0].history.Len() == 10_000 && capture.tabs[0].panes[0].history.ChunkCount() > 0 | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | func capturePaneCount(capture *snapshotCapture) int { | |||
| 429 | + | count := 0 | |||
| 430 | + | for _, tab := range capture.tabs { | |||
| 431 | + | count += len(tab.panes) | |||
| 432 | + | } | |||
| 433 | + | return count | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | func capturePaneByID(capture *snapshotCapture, id layout.PaneID) *snapshotCapturePane { | |||
| 437 | + | if capture == nil { | |||
| 438 | + | return nil | |||
| 439 | + | } | |||
| 440 | + | for tabIndex := range capture.tabs { | |||
| 441 | + | for paneIndex := range capture.tabs[tabIndex].panes { | |||
| 442 | + | pane := &capture.tabs[tabIndex].panes[paneIndex] | |||
| 443 | + | if pane.id == id { | |||
| 444 | + | return pane | |||
| 445 | + | } | |||
| 446 | + | } | |||
| 447 | + | } | |||
| 448 | + | return nil | |||
| 449 | + | } | |||
| 450 | + | ||||
| 451 | + | func TestCoordinatorCoalescingRatioReportsInvalidationsPerWake(t *testing.T) { | |||
| 452 | + | require.Equal(t, float64(3), coordinatorCoalescingRatio(performanceMetrics{ | |||
| 453 | + | coordinatorWakes: 1, | |||
| 454 | + | coordinatorCoalesced: 3, | |||
| 455 | + | })) | |||
| 456 | + | require.Zero(t, coordinatorCoalescingRatio(performanceMetrics{})) | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | func coordinatorCoalescingRatio(metrics performanceMetrics) float64 { | |||
| 460 | + | if metrics.coordinatorWakes == 0 { | |||
| 461 | + | return 0 | |||
| 462 | + | } | |||
| 463 | + | return float64(metrics.coordinatorCoalesced) / float64(metrics.coordinatorWakes) | |||
| 464 | + | } | |||
| 465 | + | ||||
| 156 | func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations, historyRows int) { | 466 | func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations, historyRows int) { | ||
| 157 | b.Helper() | 467 | b.Helper() | ||
| 158 | if operations == 0 { | 468 | if operations == 0 { | ||
| @@ -164,9 +474,89 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations | |||||
| 164 | b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op") | 474 | b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op") | ||
| 165 | b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op") | 475 | b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op") | ||
| 166 | b.ReportMetric(float64(metrics.snapshotBytes)/perOperation, "snapshotbytes/op") | 476 | b.ReportMetric(float64(metrics.snapshotBytes)/perOperation, "snapshotbytes/op") | ||
| 477 | + | b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op") | |||
| 478 | + | b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op") | |||
| 479 | + | b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op") | |||
| 480 | + | // Split counters have precise fixture definitions: capture is an immutable | |||
| 481 | + | // state snapshot requested for a live paint; composition builds the frame | |||
| 482 | + | // from that capture; emission accepts its output frame. Keep bytes/frames | |||
| 483 | + | // above as transport observables rather than folding them into these rates. | |||
| 484 | + | b.ReportMetric(float64(metrics.renderCaptures)/perOperation, "rendercaptures/op") | |||
| 485 | + | b.ReportMetric(float64(metrics.renderCompositions)/perOperation, "rendercompositions/op") | |||
| 486 | + | b.ReportMetric(float64(metrics.renderEmissions)/perOperation, "renderemissions/op") | |||
| 487 | + | // resizecommitns is the injected-clock elapsed time from accepted request | |||
| 488 | + | // to committed epoch. The remaining values are counter deltas: skipped is | |||
| 489 | + | // accepted minus committed (never negative), framegap is committed epochs | |||
| 490 | + | // lacking their required output frame (never negative), and retries/failures | |||
| 491 | + | // are PTY Resize outcomes. All divisions are guarded above. | |||
| 492 | + | b.ReportMetric(float64(metrics.resizeCommitNanos)/perOperation, "resizecommitns/op") | |||
| 493 | + | b.ReportMetric(float64(metrics.skippedEpochs)/perOperation, "skippedepochs/op") | |||
| 494 | + | b.ReportMetric(float64(metrics.ptyFailures)/perOperation, "ptyfailures/op") | |||
| 495 | + | b.ReportMetric(float64(metrics.ptyRetries)/perOperation, "ptyretries/op") | |||
| 496 | + | b.ReportMetric(float64(metrics.frameGapEpochs)/perOperation, "framegapepochs/op") | |||
| 497 | + | b.ReportMetric(coordinatorCoalescingRatio(metrics), "coordinatorcoalescingratio") | |||
| 167 | b.ReportMetric(float64(historyRows), "historyrows/pane") | 498 | b.ReportMetric(float64(historyRows), "historyrows/pane") | ||
| 168 | } | 499 | } | ||
| 169 | 500 | ||||
| 501 | + | // Root-cause hypothesis (verified by this guard before the fix): S2 allocates | |||
| 502 | + | // one visible VT-frame copy during capture plus a new composed frame and its | |||
| 503 | + | // base-frame clone for every live paint. Those client-sized copies, rather | |||
| 504 | + | // than history, account for the ~1 MiB/op regression in the pinned benchmark. | |||
| 505 | + | func TestLivePaintAllocationBudget(t *testing.T) { | |||
| 506 | + | for _, tt := range []struct { | |||
| 507 | + | name string | |||
| 508 | + | panes int | |||
| 509 | + | }{ | |||
| 510 | + | {name: "1tab-1pane", panes: 1}, | |||
| 511 | + | {name: "1tab-4panes", panes: 4}, | |||
| 512 | + | } { | |||
| 513 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 514 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000}) | |||
| 515 | + | allocs := testing.AllocsPerRun(20, func() { | |||
| 516 | + | fixture.paintLive() | |||
| 517 | + | fixture.ac.ackOutputState(fixture.ac.output.next) | |||
| 518 | + | }) | |||
| 519 | + | require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints") | |||
| 520 | + | }) | |||
| 521 | + | } | |||
| 522 | + | } | |||
| 523 | + | ||||
| 524 | + | // TestCopyEnterAllocationBudget protects the parent baseline plus 10%. Copy | |||
| 525 | + | // rendering borrows sealed VT history rows; allocating a copy per viewport row | |||
| 526 | + | // is a production regression even though the capture itself remains immutable. | |||
| 527 | + | func TestCopyEnterAllocationBudget(t *testing.T) { | |||
| 528 | + | for _, tt := range []struct { | |||
| 529 | + | name string | |||
| 530 | + | tabs, panes, max int | |||
| 531 | + | }{ | |||
| 532 | + | {name: "1tab-1pane", tabs: 1, panes: 1, max: 38}, | |||
| 533 | + | {name: "1tab-4panes", tabs: 1, panes: 4, max: 43}, | |||
| 534 | + | {name: "4tabs-1pane", tabs: 4, panes: 1, max: 42}, | |||
| 535 | + | {name: "4tabs-4panes", tabs: 4, panes: 4, max: 48}, | |||
| 536 | + | {name: "8tabs-1pane", tabs: 8, panes: 1, max: 50}, | |||
| 537 | + | } { | |||
| 538 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 539 | + | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000}) | |||
| 540 | + | run := func() { | |||
| 541 | + | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | |||
| 542 | + | fixture.ac.ackOutputState(fixture.ac.output.next) | |||
| 543 | + | } | |||
| 544 | + | ||||
| 545 | + | // Always exercise and validate copy entry. The race detector adds | |||
| 546 | + | // instrumentation allocations which are not reported by the parent | |||
| 547 | + | // benchmark, so its allocation count cannot represent this budget. | |||
| 548 | + | run() | |||
| 549 | + | require.True(t, fixture.copyModeActive(), "copy entry must install a history-backed mode") | |||
| 550 | + | if !copyEnterAllocationBudgetEnabled { | |||
| 551 | + | return | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | allocs := testing.AllocsPerRun(20, run) | |||
| 555 | + | require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10%% of the parent allocation baseline") | |||
| 556 | + | }) | |||
| 557 | + | } | |||
| 558 | + | } | |||
| 559 | + | ||||
| 170 | func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) { | 560 | func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) { | ||
| 171 | fixture := newPerformanceFixture(t, performanceConfig{}) | 561 | fixture := newPerformanceFixture(t, performanceConfig{}) | ||
| 172 | 562 | ||||
| @@ -189,29 +579,119 @@ type performanceConfig struct { | |||||
| 189 | tabs int | 579 | tabs int | ||
| 190 | panes int | 580 | panes int | ||
| 191 | historyRows int | 581 | historyRows int | ||
| 582 | + | ptyErrors []error | |||
| 192 | } | 583 | } | ||
| 193 | 584 | ||||
| 194 | type performanceMetrics struct { | 585 | type performanceMetrics struct { | ||
| 195 | - | outputFrames uint64 | 586 | + | outputFrames uint64 |
| 196 | - | outputBytes uint64 | 587 | + | outputBytes uint64 |
| 197 | - | outputPayloadBytes uint64 | 588 | + | outputPayloadBytes uint64 |
| 198 | - | snapshotWrites uint64 | 589 | + | snapshotWrites uint64 |
| 199 | - | snapshotBytes 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 | type performanceFixture struct { | 671 | type performanceFixture struct { | ||
| 203 | - | t testing.TB | 672 | + | t testing.TB |
| 204 | - | d *Daemon | 673 | + | d *Daemon |
| 205 | - | sess *session | 674 | + | sess *session |
| 206 | - | ac *attachedClient | 675 | + | ac *attachedClient |
| 207 | - | output *countingOutputTransport | 676 | + | output *countingOutputTransport |
| 208 | - | snaps *countingSnapshotStore | 677 | + | snaps *countingSnapshotStore |
| 209 | - | activePane *pane | 678 | + | activePane *pane |
| 210 | - | liveWrites [][]byte | 679 | + | liveWrites [][]byte |
| 211 | - | paints int | 680 | + | paints int |
| 212 | - | resizeSizes [2]domain.Size | 681 | + | resizeSizes [2]domain.Size |
| 213 | - | resizes int | 682 | + | resizes int |
| 214 | - | resizedSize domain.Size | 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 | func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture { | 697 | func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture { | ||
| @@ -228,8 +708,12 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||||
| 228 | if config.historyRows == 0 { | 708 | if config.historyRows == 0 { | ||
| 229 | config.historyRows = 1 | 709 | config.historyRows = 1 | ||
| 230 | } | 710 | } | ||
| 711 | + | pty := &scriptedPerformancePTY{} | |||
| 231 | ptys := make([]ports.PTY, config.tabs) | 712 | ptys := make([]ports.PTY, config.tabs) | ||
| 713 | + | ptys[0] = pty | |||
| 232 | d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...) | 714 | d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...) | ||
| 715 | + | clock := &performanceClock{} | |||
| 716 | + | d.clock = clock | |||
| 233 | output := &countingOutputTransport{} | 717 | output := &countingOutputTransport{} | ||
| 234 | ac.tr = output | 718 | ac.tr = output | ||
| 235 | ac.size = config.size | 719 | ac.size = config.size | ||
| @@ -244,18 +728,34 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||||
| 244 | ac: ac, | 728 | ac: ac, | ||
| 245 | output: output, | 729 | output: output, | ||
| 246 | snaps: &countingSnapshotStore{}, | 730 | snaps: &countingSnapshotStore{}, | ||
| 731 | + | pty: pty, | |||
| 732 | + | clock: clock, | |||
| 247 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | 733 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | ||
| 248 | resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size}, | 734 | resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size}, | ||
| 249 | } | 735 | } | ||
| 250 | WithSnapshotStore(fixture.snaps)(d) | 736 | WithSnapshotStore(fixture.snaps)(d) | ||
| 737 | + | d.startSnapshotEncodeWorker() | |||
| 738 | + | t.Cleanup(d.stopSnapshotEncodeWorker) | |||
| 251 | for tabIndex, tb := range sess.tabs { | 739 | for tabIndex, tb := range sess.tabs { | ||
| 252 | fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size)) | 740 | fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size)) | ||
| 253 | } | 741 | } | ||
| 254 | fixture.activePane = fixture.findActivePane() | 742 | fixture.activePane = fixture.findActivePane() | ||
| 743 | + | // Fixture construction may solve the initial layout; benchmark scripts begin | |||
| 744 | + | // after that setup work and therefore have an empty call history. | |||
| 745 | + | pty.setErrors(config.ptyErrors) | |||
| 746 | + | ||||
| 747 | + | // The fixture uses the production coordinator. performanceClock's nil timer | |||
| 748 | + | // channel completes the coordinator path synchronously and deterministically. | |||
| 749 | + | ac.renderStages = renderStageHooks{ | |||
| 750 | + | capture: func() { fixture.renderCaptures++ }, | |||
| 751 | + | compose: func() { fixture.renderCompositions++ }, | |||
| 752 | + | emit: func() { fixture.renderEmissions++ }, | |||
| 753 | + | } | |||
| 754 | + | d.attachCoordinator(sess, nil, ac, true) | |||
| 255 | 755 | ||||
| 256 | // Prime the real renderer shadow before measurements. Subsequent paints use | 756 | // Prime the real renderer shadow before measurements. Subsequent paints use | ||
| 257 | // actual production diffs rather than the initial full frame. | 757 | // actual production diffs rather than the initial full frame. | ||
| 258 | - | d.paint(sess, ac, true) | 758 | + | d.paint(sess, ac, true, nil) |
| 259 | fixture.resetMetrics() | 759 | fixture.resetMetrics() | ||
| 260 | return fixture | 760 | return fixture | ||
| 261 | } | 761 | } | ||
| @@ -278,7 +778,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR | |||||
| 278 | for _, p := range tb.panes { | 778 | for _, p := range tb.panes { | ||
| 279 | p.mu.Lock() | 779 | p.mu.Lock() | ||
| 280 | width := p.screen.Frame.Width | 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 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | 782 | p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n")) | ||
| 283 | } | 783 | } | ||
| 284 | p.mu.Unlock() | 784 | p.mu.Unlock() | ||
| @@ -311,13 +811,20 @@ func (f *performanceFixture) paintLive() { | |||||
| 311 | p.mu.Lock() | 811 | p.mu.Lock() | ||
| 312 | p.screen.Write(f.liveWrites[f.paints%len(f.liveWrites)]) | 812 | p.screen.Write(f.liveWrites[f.paints%len(f.liveWrites)]) | ||
| 313 | p.mu.Unlock() | 813 | p.mu.Unlock() | ||
| 314 | - | f.d.paint(f.sess, f.ac, false) | 814 | + | if rc := f.sess.renderCoordinator(); rc != nil { |
| 815 | + | rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "performance_benchmark_test.go"}) | |||
| 816 | + | } else { | |||
| 817 | + | f.d.paint(f.sess, f.ac, false, nil) | |||
| 818 | + | } | |||
| 315 | f.paints++ | 819 | f.paints++ | ||
| 316 | } | 820 | } | ||
| 317 | 821 | ||||
| 318 | func (f *performanceFixture) captureSnapshot() { | 822 | func (f *performanceFixture) captureSnapshot() { | ||
| 319 | f.t.Helper() | 823 | f.t.Helper() | ||
| 320 | require.True(f.t, f.d.captureSession(f.sess)) | 824 | require.True(f.t, f.d.captureSession(f.sess)) | ||
| 825 | + | // Worker finalization clears snapshotPending and signals snapshotChanged on | |||
| 826 | + | // every success and failure path after persistence has returned. | |||
| 827 | + | awaitSnapshotIdle(f.t, f.sess) | |||
| 321 | } | 828 | } | ||
| 322 | 829 | ||||
| 323 | func (f *performanceFixture) enterCopySearch() { | 830 | func (f *performanceFixture) enterCopySearch() { | ||
| @@ -326,6 +833,30 @@ func (f *performanceFixture) enterCopySearch() { | |||||
| 326 | f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) | 833 | f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) | ||
| 327 | } | 834 | } | ||
| 328 | 835 | ||||
| 836 | + | func (f *performanceFixture) hasHistoryTopology(tabs, panes, historyRows int) bool { | |||
| 837 | + | if len(f.sess.tabs) != tabs { | |||
| 838 | + | return false | |||
| 839 | + | } | |||
| 840 | + | for _, tb := range f.sess.tabs { | |||
| 841 | + | tb.mu.Lock() | |||
| 842 | + | if len(tb.panes) != panes { | |||
| 843 | + | tb.mu.Unlock() | |||
| 844 | + | return false | |||
| 845 | + | } | |||
| 846 | + | for _, p := range tb.panes { | |||
| 847 | + | p.mu.Lock() | |||
| 848 | + | rows := p.history.Len() | |||
| 849 | + | p.mu.Unlock() | |||
| 850 | + | if rows != historyRows { | |||
| 851 | + | tb.mu.Unlock() | |||
| 852 | + | return false | |||
| 853 | + | } | |||
| 854 | + | } | |||
| 855 | + | tb.mu.Unlock() | |||
| 856 | + | } | |||
| 857 | + | return true | |||
| 858 | + | } | |||
| 859 | + | ||||
| 329 | func (f *performanceFixture) searchMatches() int { | 860 | func (f *performanceFixture) searchMatches() int { | ||
| 330 | f.ac.overlays.copyMu.Lock() | 861 | f.ac.overlays.copyMu.Lock() | ||
| 331 | defer f.ac.overlays.copyMu.Unlock() | 862 | defer f.ac.overlays.copyMu.Unlock() | ||
| @@ -335,10 +866,48 @@ func (f *performanceFixture) searchMatches() int { | |||||
| 335 | return len(f.ac.overlays.copyMode.Searches) | 866 | return len(f.ac.overlays.copyMode.Searches) | ||
| 336 | } | 867 | } | ||
| 337 | 868 | ||||
| 869 | + | func (f *performanceFixture) copyModeActive() bool { | |||
| 870 | + | f.ac.overlays.copyMu.Lock() | |||
| 871 | + | defer f.ac.overlays.copyMu.Unlock() | |||
| 872 | + | return f.ac.overlays.copyMode != nil && f.ac.overlays.copySnapshot != nil && f.ac.overlays.copySnapshot.Len() >= 10_000 | |||
| 873 | + | } | |||
| 874 | + | ||||
| 338 | func (f *performanceFixture) resize() { | 875 | func (f *performanceFixture) resize() { | ||
| 339 | - | f.resizedSize = f.resizeSizes[f.resizes%len(f.resizeSizes)] | 876 | + | f.resizeTo(f.resizeSizes[f.resizes%len(f.resizeSizes)]) |
| 877 | + | } | |||
| 878 | + | ||||
| 879 | + | func (f *performanceFixture) resizeTo(size domain.Size) { | |||
| 880 | + | f.resizedSize = size | |||
| 340 | f.resizes++ | 881 | f.resizes++ | ||
| 341 | - | f.d.resize(f.sess, f.ac, f.resizedSize) | 882 | + | before := f.sess.renderCoordinator().resizeSnapshot() |
| 883 | + | start := f.clock.Now() | |||
| 884 | + | failuresBefore, callsBefore := f.pty.metrics() | |||
| 885 | + | f.d.resize(f.sess, f.ac, size) | |||
| 886 | + | after := f.sess.renderCoordinator().resizeSnapshot() | |||
| 887 | + | if after.epoch > before.epoch { | |||
| 888 | + | f.resizeRequests += after.epoch - before.epoch | |||
| 889 | + | } | |||
| 890 | + | if after.committed > before.committed { | |||
| 891 | + | f.resizeCommits += after.committed - before.committed | |||
| 892 | + | f.resizeCommitNanos += uint64(f.clock.Now().Sub(start).Nanoseconds()) | |||
| 893 | + | } | |||
| 894 | + | failuresAfter, callsAfter := f.pty.metrics() | |||
| 895 | + | f.ptyFailures += failuresAfter - failuresBefore | |||
| 896 | + | if callsAfter > callsBefore+1 { | |||
| 897 | + | f.ptyRetries += callsAfter - callsBefore - 1 | |||
| 898 | + | } | |||
| 899 | + | } | |||
| 900 | + | ||||
| 901 | + | func (f *performanceFixture) retryLatest() { | |||
| 902 | + | epoch := f.sess.renderCoordinator().resizeSnapshot().committed | |||
| 903 | + | plan := f.d.prepareResize(f.sess, f.ac.size) | |||
| 904 | + | failuresBefore, callsBefore := f.pty.metrics() | |||
| 905 | + | f.d.retryResizeMembers(f.sess, f.ac, f.sess.renderCoordinator().attachmentLease(f.ac), epoch, plan.members) | |||
| 906 | + | failuresAfter, callsAfter := f.pty.metrics() | |||
| 907 | + | f.ptyFailures += failuresAfter - failuresBefore | |||
| 908 | + | if callsAfter > callsBefore { | |||
| 909 | + | f.ptyRetries += callsAfter - callsBefore | |||
| 910 | + | } | |||
| 342 | } | 911 | } | ||
| 343 | 912 | ||||
| 344 | func (f *performanceFixture) resized() bool { | 913 | func (f *performanceFixture) resized() bool { | ||
| @@ -359,15 +928,42 @@ func (f *performanceFixture) resized() bool { | |||||
| 359 | func (f *performanceFixture) resetMetrics() { | 928 | func (f *performanceFixture) resetMetrics() { | ||
| 360 | f.output.reset() | 929 | f.output.reset() | ||
| 361 | f.snaps.reset() | 930 | f.snaps.reset() | ||
| 931 | + | f.renderCaptures = 0 | |||
| 932 | + | f.renderCompositions = 0 | |||
| 933 | + | f.renderEmissions = 0 | |||
| 934 | + | f.resizeRequests = 0 | |||
| 935 | + | f.resizeCommits = 0 | |||
| 936 | + | f.resizeCommitNanos = 0 | |||
| 937 | + | f.ptyFailures = 0 | |||
| 938 | + | f.ptyRetries = 0 | |||
| 939 | + | if rc := f.sess.renderCoordinator(); rc != nil { | |||
| 940 | + | f.coordinatorBaseline = rc.burstMetricsSnapshot() | |||
| 941 | + | } | |||
| 362 | } | 942 | } | ||
| 363 | 943 | ||||
| 364 | func (f *performanceFixture) metrics() performanceMetrics { | 944 | func (f *performanceFixture) metrics() performanceMetrics { | ||
| 365 | output := f.output.metrics() | 945 | output := f.output.metrics() | ||
| 366 | snapshots := f.snaps.metrics() | 946 | snapshots := f.snaps.metrics() | ||
| 367 | - | return performanceMetrics{ | 947 | + | metrics := performanceMetrics{ |
| 368 | outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes, | 948 | outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes, | ||
| 369 | snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes, | 949 | snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes, | ||
| 950 | + | renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions, | |||
| 951 | + | resizeRequests: f.resizeRequests, resizeCommits: f.resizeCommits, resizeCommitNanos: f.resizeCommitNanos, | |||
| 952 | + | ptyFailures: f.ptyFailures, ptyRetries: f.ptyRetries, | |||
| 953 | + | } | |||
| 954 | + | if metrics.resizeRequests > metrics.resizeCommits { | |||
| 955 | + | metrics.skippedEpochs = metrics.resizeRequests - metrics.resizeCommits | |||
| 956 | + | } | |||
| 957 | + | if metrics.resizeCommits > metrics.outputFrames { | |||
| 958 | + | metrics.frameGapEpochs = metrics.resizeCommits - metrics.outputFrames | |||
| 959 | + | } | |||
| 960 | + | if rc := f.sess.renderCoordinator(); rc != nil { | |||
| 961 | + | coordinator := rc.burstMetricsSnapshot() | |||
| 962 | + | metrics.coordinatorInvalidations = coordinator.invalidations - f.coordinatorBaseline.invalidations | |||
| 963 | + | metrics.coordinatorWakes = coordinator.wakes - f.coordinatorBaseline.wakes | |||
| 964 | + | metrics.coordinatorCoalesced = coordinator.coalesced - f.coordinatorBaseline.coalesced | |||
| 370 | } | 965 | } | ||
| 966 | + | return metrics | |||
| 371 | } | 967 | } | ||
| 372 | 968 | ||||
| 373 | const outputPayloadHeaderBytes = 24 | 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 | 1 | + | // Package daemon holds vev's server-side session multiplexer use case. |
| 2 | - | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |||
| 3 | - | // and VT screen, and the per-client debounced render scheduler. | |||
| 4 | - | // | |||
| 5 | - | // Concurrency model (sessions own one or more PTY-backed tabs): | |||
| 6 | - | // | |||
| 7 | - | // - Serve runs the accept loop. Each accepted connection is handled by its | |||
| 8 | - | // own goroutine (handleConn): it reads the first frame and routes it to a | |||
| 9 | - | // session create/attach, a list, or a kill. | |||
| 10 | - | // - Per session there are exactly two long-lived goroutines: the PTY reader | |||
| 11 | - | // (drains child output into the VT screen and pokes a cap-1 dirty channel) | |||
| 12 | - | // and the render scheduler (debounces dirties and paints the attached | |||
| 13 | - | // client). Both are tied to the session context and unwind when the | |||
| 14 | - | // session is killed (pty.Close unblocks the reader; ctx cancel stops the | |||
| 15 | - | // scheduler). | |||
| 16 | - | // - The daemon exits (Serve returns) when the last session is removed, or | |||
| 17 | - | // when the parent context is cancelled (graceful shutdown notifies any | |||
| 18 | - | // attached clients with ReasonServerShutdown). | |||
| 19 | - | // | |||
| 20 | - | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |||
| 21 | - | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |||
| 22 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | |||
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |||
| 24 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |||
| 25 | package daemon | 2 | package daemon | ||
| 26 | 3 | ||||
| 27 | import ( | 4 | import ( | ||
| @@ -32,7 +9,6 @@ import ( | |||||
| 32 | "github.com/bnema/vev/internal/usecase/keys" | 9 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 33 | "github.com/bnema/vev/internal/usecase/layout" | 10 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 34 | "github.com/bnema/vev/internal/usecase/picker" | 11 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 35 | - | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 36 | "github.com/bnema/vev/internal/usecase/ui" | 12 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 37 | "github.com/bnema/vev/pkg/renderer" | 13 | "github.com/bnema/vev/pkg/renderer" | ||
| 38 | ) | 14 | ) | ||
| @@ -47,7 +23,7 @@ func (d *Daemon) enterPicker(sess *session, ac *attachedClient) { | |||||
| 47 | ac.overlays.pickerPreview = nil | 23 | ac.overlays.pickerPreview = nil | ||
| 48 | ac.overlays.pickerMu.Unlock() | 24 | ac.overlays.pickerMu.Unlock() | ||
| 49 | d.registerPreviewForSelection(ac) | 25 | d.registerPreviewForSelection(ac) | ||
| 50 | - | d.paint(sess, ac, true) | 26 | + | d.invalidateRender(sess, ac, true, "picker.go") |
| 51 | } | 27 | } | ||
| 52 | 28 | ||||
| 53 | func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | 29 | func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | ||
| @@ -141,7 +117,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||||
| 141 | d.killPickerTarget(target) | 117 | d.killPickerTarget(target) | ||
| 142 | } | 118 | } | ||
| 143 | d.refreshPicker(ac) | 119 | d.refreshPicker(ac) | ||
| 144 | - | d.paint(sess, ac, true) | 120 | + | d.invalidateRender(sess, ac, true, "picker.go") |
| 145 | return | 121 | return | ||
| 146 | case 'j': | 122 | case 'j': | ||
| 147 | ac.overlays.picker.Down() | 123 | ac.overlays.picker.Down() | ||
| @@ -191,7 +167,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||||
| 191 | return | 167 | return | ||
| 192 | } | 168 | } | ||
| 193 | if exit || changed { | 169 | if exit || changed { | ||
| 194 | - | d.paint(sess, ac, true) | 170 | + | d.invalidateRender(sess, ac, true, "picker.go") |
| 195 | } | 171 | } | ||
| 196 | } | 172 | } | ||
| 197 | 173 | ||||
| @@ -207,11 +183,12 @@ func (d *Daemon) retainPickerESCLocked(ac *attachedClient) { | |||||
| 207 | ac.overlays.pickerESC.timer = nil | 183 | ac.overlays.pickerESC.timer = nil | ||
| 208 | ac.overlays.pickerESC.done = nil | 184 | ac.overlays.pickerESC.done = nil | ||
| 209 | ac.overlays.picker = nil | 185 | ac.overlays.picker = nil | ||
| 186 | + | generation := ac.overlays.pickerPreviewGeneration | |||
| 210 | ac.overlays.pickerMu.Unlock() | 187 | ac.overlays.pickerMu.Unlock() | ||
| 211 | 188 | ||||
| 212 | - | d.unregisterPreview(ac) | 189 | + | d.clearPreviewGeneration(ac, generation) |
| 213 | if sess := ac.currentSession(); sess != nil { | 190 | if sess := ac.currentSession(); sess != nil { | ||
| 214 | - | d.paint(sess, ac, true) | 191 | + | d.invalidateRender(sess, ac, true, "picker.go") |
| 215 | } | 192 | } | ||
| 216 | }) | 193 | }) | ||
| 217 | } | 194 | } | ||
| @@ -239,89 +216,138 @@ func isPickerEscapePrefix(data []byte) bool { | |||||
| 239 | } | 216 | } | ||
| 240 | 217 | ||||
| 241 | func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | 218 | func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | ||
| 219 | + | if ac == nil || ac.overlays == nil { | |||
| 220 | + | return | |||
| 221 | + | } | |||
| 242 | ac.overlays.pickerMu.Lock() | 222 | ac.overlays.pickerMu.Lock() | ||
| 243 | var target picker.Target | 223 | var target picker.Target | ||
| 244 | var ok bool | 224 | var ok bool | ||
| 245 | if ac.overlays.picker != nil { | 225 | if ac.overlays.picker != nil { | ||
| 246 | target, ok = ac.overlays.picker.Selected() | 226 | target, ok = ac.overlays.picker.Selected() | ||
| 247 | } | 227 | } | ||
| 228 | + | previous, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration | |||
| 229 | + | ac.overlays.pickerPreviewGeneration++ | |||
| 230 | + | generation := ac.overlays.pickerPreviewGeneration | |||
| 231 | + | ac.overlays.pickerPreviewSession = nil | |||
| 232 | + | ac.overlays.pickerPreview = nil | |||
| 248 | ac.overlays.pickerMu.Unlock() | 233 | ac.overlays.pickerMu.Unlock() | ||
| 249 | - | 234 | + | d.teardownPreviewSubscription(ac, previous, previousGeneration) | |
| 250 | - | var next *tab | 235 | + | if !ok { |
| 251 | - | if ok { | 236 | + | return |
| 252 | - | next = d.tabByTarget(target) | 237 | + | } |
| 238 | + | next := d.tabByTarget(target) | |||
| 239 | + | targetSess := d.sessionByID(target.Session) | |||
| 240 | + | if next == nil || targetSess == nil { | |||
| 241 | + | return | |||
| 253 | } | 242 | } | ||
| 254 | - | ||||
| 255 | ac.overlays.pickerMu.Lock() | 243 | ac.overlays.pickerMu.Lock() | ||
| 256 | - | old := ac.overlays.pickerPreview | 244 | + | // Selection may have changed while the target was resolved. |
| 257 | - | if ac.overlays.picker == nil { | 245 | + | selected, stillSelected := ac.overlays.picker.Selected() |
| 258 | - | next = nil | 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 | ac.overlays.pickerMu.Unlock() | 251 | ac.overlays.pickerMu.Unlock() | ||
| 262 | - | 252 | + | if !valid { | |
| 263 | - | if old != nil && old != next { | 253 | + | return |
| 264 | - | old.mu.Lock() | 254 | + | } |
| 265 | - | if old.previewClient == ac { | 255 | + | // A same-session preview is already invalidated by its own coordinator. |
| 266 | - | old.previewClient = nil | 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 { | 286 | + | } |
| 271 | - | next.mu.Lock() | |||
| 272 | - | next.previewClient = ac | |||
| 273 | - | next.mu.Unlock() | |||
| 274 | 287 | ||||
| 275 | - | ac.overlays.pickerMu.Lock() | 288 | + | func (d *Daemon) teardownPreviewSubscription(ac *attachedClient, previous *session, generation uint64) { |
| 276 | - | keep := ac.overlays.pickerPreview == next | 289 | + | if previous != nil { |
| 277 | - | ac.overlays.pickerMu.Unlock() | 290 | + | if rc := previous.renderCoordinator(); rc != nil { |
| 278 | - | if !keep { | 291 | + | rc.teardownPreviewFor(ac, generation) |
| 279 | - | next.mu.Lock() | |||
| 280 | - | if next.previewClient == ac { | |||
| 281 | - | next.previewClient = nil | |||
| 282 | - | } | |||
| 283 | - | next.mu.Unlock() | |||
| 284 | } | 292 | } | ||
| 285 | } | 293 | } | ||
| 286 | } | 294 | } | ||
| 287 | 295 | ||||
| 288 | func (d *Daemon) unregisterPreview(ac *attachedClient) { | 296 | func (d *Daemon) unregisterPreview(ac *attachedClient) { | ||
| 297 | + | if ac == nil || ac.overlays == nil { | |||
| 298 | + | return | |||
| 299 | + | } | |||
| 289 | ac.overlays.pickerMu.Lock() | 300 | ac.overlays.pickerMu.Lock() | ||
| 290 | - | old := ac.overlays.pickerPreview | 301 | + | generation := ac.overlays.pickerPreviewGeneration |
| 291 | - | ac.overlays.pickerPreview = nil | |||
| 292 | ac.overlays.pickerMu.Unlock() | 302 | ac.overlays.pickerMu.Unlock() | ||
| 303 | + | d.clearPreviewGeneration(ac, generation) | |||
| 304 | + | } | |||
| 293 | 305 | ||||
| 294 | - | if old != nil { | 306 | + | // clearPreviewGeneration removes the preview only if it is still the observed |
| 295 | - | old.mu.Lock() | 307 | + | // selection. Teardown paths may race a new picker selection. |
| 296 | - | if old.previewClient == ac { | 308 | + | func (d *Daemon) clearPreviewGeneration(ac *attachedClient, generation uint64) bool { |
| 297 | - | old.previewClient = nil | 309 | + | ac.overlays.pickerMu.Lock() |
| 298 | - | } | 310 | + | if ac.overlays.pickerPreviewGeneration != generation { |
| 299 | - | old.mu.Unlock() | 311 | + | ac.overlays.pickerMu.Unlock() |
| 312 | + | return false | |||
| 300 | } | 313 | } | ||
| 314 | + | previous := ac.overlays.pickerPreviewSession | |||
| 315 | + | ac.overlays.pickerPreviewGeneration++ | |||
| 316 | + | ac.overlays.pickerPreviewSession = nil | |||
| 317 | + | ac.overlays.pickerPreview = nil | |||
| 318 | + | ac.overlays.pickerMu.Unlock() | |||
| 319 | + | d.teardownPreviewSubscription(ac, previous, generation) | |||
| 320 | + | return true | |||
| 301 | } | 321 | } | ||
| 302 | 322 | ||||
| 323 | + | // clearDestroyedTabPreview clears coordinator-owned picker state for clients | |||
| 324 | + | // that were previewing a removed tab. Tabs do not route render ownership. | |||
| 303 | func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | 325 | func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | ||
| 304 | - | tb.mu.Lock() | 326 | + | if d == nil || d.sessions == nil { |
| 305 | - | previewer := tb.previewClient | |||
| 306 | - | tb.mu.Unlock() | |||
| 307 | - | if previewer == nil { | |||
| 308 | return | 327 | return | ||
| 309 | } | 328 | } | ||
| 310 | - | cleared := false | 329 | + | d.mu.Lock() |
| 311 | - | previewer.overlays.pickerMu.Lock() | 330 | + | var clients []*attachedClient |
| 312 | - | if previewer.overlays.pickerPreview == tb { | 331 | + | for _, sess := range d.sessions { |
| 313 | - | previewer.overlays.pickerPreview = nil | 332 | + | sess.mu.Lock() |
| 314 | - | cleared = true | 333 | + | if sess.client != nil { |
| 315 | - | } | 334 | + | clients = append(clients, sess.client) |
| 316 | - | previewer.overlays.pickerMu.Unlock() | 335 | + | } |
| 317 | - | tb.mu.Lock() | 336 | + | sess.mu.Unlock() |
| 318 | - | if tb.previewClient == previewer { | |||
| 319 | - | tb.previewClient = nil | |||
| 320 | } | 337 | } | ||
| 321 | - | tb.mu.Unlock() | 338 | + | d.mu.Unlock() |
| 322 | - | if cleared { | 339 | + | for _, ac := range clients { |
| 323 | - | if sess := previewer.currentSession(); sess != nil { | 340 | + | if ac == nil || ac.overlays == nil { |
| 324 | - | d.paint(sess, previewer, true) | 341 | + | continue |
| 342 | + | } | |||
| 343 | + | ac.overlays.pickerMu.Lock() | |||
| 344 | + | generation := ac.overlays.pickerPreviewGeneration | |||
| 345 | + | observed := ac.overlays.pickerPreview == tb | |||
| 346 | + | ac.overlays.pickerMu.Unlock() | |||
| 347 | + | if observed && d.clearPreviewGeneration(ac, generation) { | |||
| 348 | + | if sess := ac.currentSession(); sess != nil { | |||
| 349 | + | d.invalidateRender(sess, ac, true, "picker.go") | |||
| 350 | + | } | |||
| 325 | } | 351 | } | ||
| 326 | } | 352 | } | ||
| 327 | } | 353 | } | ||
| @@ -331,8 +357,9 @@ func (d *Daemon) closePicker(ac *attachedClient) { | |||||
| 331 | ac.overlays.picker = nil | 357 | ac.overlays.picker = nil | ||
| 332 | ac.overlays.pickerPending = nil | 358 | ac.overlays.pickerPending = nil | ||
| 333 | d.stopPickerPendingTimerLocked(ac) | 359 | d.stopPickerPendingTimerLocked(ac) | ||
| 360 | + | generation := ac.overlays.pickerPreviewGeneration | |||
| 334 | ac.overlays.pickerMu.Unlock() | 361 | ac.overlays.pickerMu.Unlock() | ||
| 335 | - | d.unregisterPreview(ac) | 362 | + | d.clearPreviewGeneration(ac, generation) |
| 336 | } | 363 | } | ||
| 337 | 364 | ||||
| 338 | func (d *Daemon) tabByTarget(target picker.Target) *tab { | 365 | func (d *Daemon) tabByTarget(target picker.Target) *tab { | ||
| @@ -362,19 +389,19 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker | |||||
| 362 | } | 389 | } | ||
| 363 | targetSess := d.sessionByID(target.Session) | 390 | targetSess := d.sessionByID(target.Session) | ||
| 364 | if targetSess == nil { | 391 | if targetSess == nil { | ||
| 365 | - | d.paint(sess, ac, true) | 392 | + | d.invalidateRender(sess, ac, true, "picker.go") |
| 366 | return false | 393 | return false | ||
| 367 | } | 394 | } | ||
| 368 | if targetSess == sess { | 395 | if targetSess == sess { | ||
| 369 | if sess.switchTab(target.TabIndex) { | 396 | if sess.switchTab(target.TabIndex) { | ||
| 370 | d.activateTab(sess, sess.activeTab()) | 397 | d.activateTab(sess, sess.activeTab()) | ||
| 371 | } | 398 | } | ||
| 372 | - | d.paint(sess, ac, true) | 399 | + | d.invalidateRender(sess, ac, true, "picker.go") |
| 373 | return true | 400 | return true | ||
| 374 | } | 401 | } | ||
| 375 | old := d.stealClientForTarget(sess, ac, targetSess, target) | 402 | old := d.stealClientForTarget(sess, ac, targetSess, target) | ||
| 376 | if ac.currentSession() != targetSess { | 403 | if ac.currentSession() != targetSess { | ||
| 377 | - | d.paint(sess, ac, true) | 404 | + | d.invalidateRender(sess, ac, true, "picker.go") |
| 378 | return false | 405 | return false | ||
| 379 | } | 406 | } | ||
| 380 | if old != nil && old != ac { | 407 | if old != nil && old != ac { | ||
| @@ -407,16 +434,22 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||||
| 407 | targetSess.mu.Lock() | 434 | targetSess.mu.Lock() | ||
| 408 | old := targetSess.client | 435 | old := targetSess.client | ||
| 409 | targetSess.terminal = term | 436 | targetSess.terminal = term | ||
| 410 | - | targetSess.client = ac | |||
| 411 | if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) { | 437 | if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) { | ||
| 412 | targetSess.active = target.TabIndex | 438 | targetSess.active = target.TabIndex | ||
| 413 | } | 439 | } | ||
| 414 | targetSess.mu.Unlock() | 440 | targetSess.mu.Unlock() | ||
| 415 | 441 | ||||
| 416 | - | d.touchMRU(targetSess) | 442 | + | // Do not expose targetSess.client until the target coordinator has claimed |
| 443 | + | // this attachment and the output dependency chain has been rebased. | |||
| 444 | + | cleanups := d.handoffCoordinator(from, targetSess, old, ac) | |||
| 417 | ac.setSession(targetSess) | 445 | ac.setSession(targetSess) | ||
| 446 | + | targetSess.mu.Lock() | |||
| 447 | + | targetSess.client = ac | |||
| 448 | + | targetSess.mu.Unlock() | |||
| 449 | + | d.touchMRU(targetSess) | |||
| 418 | ac.recordPreviousSession(from) | 450 | ac.recordPreviousSession(from) | ||
| 419 | d.mu.Unlock() | 451 | d.mu.Unlock() | ||
| 452 | + | finishRenderLifecycleCleanups(cleanups) | |||
| 420 | return old | 453 | return old | ||
| 421 | } | 454 | } | ||
| 422 | 455 | ||||
| @@ -425,7 +458,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 425 | stopped, ok := d.stopped[target.Name] | 458 | stopped, ok := d.stopped[target.Name] | ||
| 426 | if !ok || stopped.purging { | 459 | if !ok || stopped.purging { | ||
| 427 | d.mu.Unlock() | 460 | d.mu.Unlock() | ||
| 428 | - | d.paint(from, ac, true) | 461 | + | d.invalidateRender(from, ac, true, "picker.go") |
| 429 | return false | 462 | return false | ||
| 430 | } | 463 | } | ||
| 431 | from.mu.Lock() | 464 | from.mu.Lock() | ||
| @@ -441,20 +474,22 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 441 | from.mu.Unlock() | 474 | from.mu.Unlock() | ||
| 442 | d.mu.Unlock() | 475 | d.mu.Unlock() | ||
| 443 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | 476 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | ||
| 444 | - | d.paint(from, ac, true) | 477 | + | d.invalidateRender(from, ac, true, "picker.go") |
| 445 | return false | 478 | return false | ||
| 446 | } | 479 | } | ||
| 447 | from.client = nil | 480 | from.client = nil | ||
| 448 | ac.setSession(nil) | 481 | ac.setSession(nil) | ||
| 449 | from.mu.Unlock() | 482 | from.mu.Unlock() | ||
| 483 | + | ||||
| 484 | + | cleanups := d.handoffCoordinator(from, targetSess, nil, ac) | |||
| 485 | + | ac.setSession(targetSess) | |||
| 450 | targetSess.mu.Lock() | 486 | targetSess.mu.Lock() | ||
| 451 | targetSess.client = ac | 487 | targetSess.client = ac | ||
| 452 | targetSess.mu.Unlock() | 488 | targetSess.mu.Unlock() | ||
| 453 | - | ||||
| 454 | d.touchMRU(targetSess) | 489 | d.touchMRU(targetSess) | ||
| 455 | - | ac.setSession(targetSess) | |||
| 456 | ac.recordPreviousSession(from) | 490 | ac.recordPreviousSession(from) | ||
| 457 | d.mu.Unlock() | 491 | d.mu.Unlock() | ||
| 492 | + | finishRenderLifecycleCleanups(cleanups) | |||
| 458 | d.firstPaint(targetSess, ac, ac.size) | 493 | d.firstPaint(targetSess, ac, ac.size) | ||
| 459 | return true | 494 | return true | ||
| 460 | } | 495 | } | ||
| @@ -496,49 +531,59 @@ func (d *Daemon) refreshPicker(ac *attachedClient) { | |||||
| 496 | d.registerPreviewForSelection(ac) | 531 | d.registerPreviewForSelection(ac) | ||
| 497 | } | 532 | } | ||
| 498 | 533 | ||||
| 499 | - | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |||
| 500 | - | styleSet := resolveThemeStyles(styles) | |||
| 501 | - | renderStyles := picker.RenderStyles{ | |||
| 502 | - | Selection: styleSet.selection, | |||
| 503 | - | SelectionName: styleSet.pickerSelectionName, | |||
| 504 | - | SelectionMuted: styleSet.pickerSelectionMuted, | |||
| 505 | - | Name: styleSet.pickerName, | |||
| 506 | - | Detail: styleSet.paletteDesc, | |||
| 507 | - | Base: renderer.DefaultStyle(), | |||
| 508 | - | } | |||
| 509 | - | return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |||
| 510 | - | return model.Render(size, preview, renderStyles) | |||
| 511 | - | }) | |||
| 512 | - | } | |||
| 513 | - | ||||
| 514 | func snapshotPickerPreview(tb *tab) picker.Preview { | 534 | func snapshotPickerPreview(tb *tab) picker.Preview { | ||
| 515 | if tb == nil { | 535 | if tb == nil { | ||
| 516 | return picker.Preview{} | 536 | return picker.Preview{} | ||
| 517 | } | 537 | } | ||
| 518 | tb.mu.Lock() | 538 | tb.mu.Lock() | ||
| 519 | - | defer tb.mu.Unlock() | 539 | + | layoutSnap := solveTabLayoutLocked(tb) |
| 520 | - | return pickerPreviewFromLockedTab(tb) | 540 | + | if !layoutSnap.ok { |
| 521 | - | } | 541 | + | p := tb.focusedPane() |
| 522 | - | 542 | + | if p == nil { | |
| 523 | - | func pickerPreviewFromLockedTab(tb *tab) picker.Preview { | 543 | + | tb.mu.Unlock() |
| 524 | - | p := tb.focusedPane() | 544 | + | return picker.Preview{} |
| 525 | - | if p == nil { | 545 | + | } |
| 526 | - | return picker.Preview{} | |||
| 527 | - | } | |||
| 528 | - | if tb.tree == nil || tb.tree.Root == nil || tb.tree.Root.Kind == layout.Leaf { | |||
| 529 | p.mu.Lock() | 546 | p.mu.Lock() | ||
| 530 | - | defer p.mu.Unlock() | 547 | + | preview := pickerPreviewFromLockedPane(p) |
| 531 | - | return pickerPreviewFromLockedPane(p) | 548 | + | p.mu.Unlock() |
| 549 | + | tb.mu.Unlock() | |||
| 550 | + | return preview | |||
| 551 | + | } | |||
| 552 | + | state := capturedRenderState{layout: capturedTabLayout{ | |||
| 553 | + | root: tb.tree.Clone().Root, area: layoutSnap.area, focus: layoutSnap.focus, | |||
| 554 | + | placements: append([]layout.Placement(nil), layoutSnap.placements...), | |||
| 555 | + | fingerprint: layoutSnap.fingerprint, valid: true, | |||
| 556 | + | }} | |||
| 557 | + | for _, placement := range layoutSnap.placements { | |||
| 558 | + | p := tb.panes[placement.ID] | |||
| 559 | + | if p == nil { | |||
| 560 | + | continue | |||
| 561 | + | } | |||
| 562 | + | visible := placement.Content | |||
| 563 | + | if placement.Collapsed { | |||
| 564 | + | visible = domain.Rect{} | |||
| 565 | + | } | |||
| 566 | + | p.mu.Lock() | |||
| 567 | + | captured := capturePaneRenderStateLocked(p, visible) | |||
| 568 | + | p.mu.Unlock() | |||
| 569 | + | captured.placement = placement | |||
| 570 | + | captured.focused = placement.ID == layoutSnap.focus | |||
| 571 | + | state.panes = append(state.panes, captured) | |||
| 532 | } | 572 | } | ||
| 573 | + | tb.mu.Unlock() | |||
| 574 | + | return pickerPreviewFromCapturedRender(state) | |||
| 575 | + | } | |||
| 533 | 576 | ||||
| 534 | - | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | 577 | + | func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview { |
| 535 | - | if area.Width <= 0 || area.Height <= 0 { | 578 | + | composed := composeFrame(state, composeCacheInput{}, composeCacheInput{}).frame |
| 536 | - | p.mu.Lock() | 579 | + | if composed.Height < 2 { |
| 537 | - | defer p.mu.Unlock() | 580 | + | return pickerPreviewFromFrame(composed) |
| 538 | - | return pickerPreviewFromLockedPane(p) | 581 | + | } |
| 582 | + | rows := make([][]renderer.Cell, composed.Height-2) | |||
| 583 | + | for y := range rows { | |||
| 584 | + | rows[y] = append([]renderer.Cell(nil), composed.Row(y+1)...) | |||
| 539 | } | 585 | } | ||
| 540 | - | frame, _ := composeTabFrame(tb, area, themeui.Theme{}) | 586 | + | return picker.Preview{Rows: rows, Width: composed.Width, Height: len(rows)} |
| 541 | - | return pickerPreviewFromFrame(frame) | |||
| 542 | } | 587 | } | ||
| 543 | 588 | ||||
| 544 | func pickerPreviewFromLockedPane(p *pane) picker.Preview { | 589 | func pickerPreviewFromLockedPane(p *pane) picker.Preview { | ||
M internal/usecase/daemon/picker_test.go +151 -126
| @@ -2,7 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "sync" | 5 | + | "strings" |
| 6 | "testing" | 6 | "testing" | ||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | "github.com/bnema/vev/internal/usecase/keys" | 15 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 16 | "github.com/bnema/vev/internal/usecase/layout" | 16 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 17 | "github.com/bnema/vev/internal/usecase/picker" | 17 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 18 | + | "github.com/bnema/vev/pkg/vt" | |||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 20 | // --- test doubles ----------------------------------------------------------- | 21 | // --- test doubles ----------------------------------------------------------- | ||
| @@ -217,6 +218,47 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) { | |||||
| 217 | require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond) | 218 | require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond) | ||
| 218 | } | 219 | } | ||
| 219 | 220 | ||||
| 221 | + | func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||
| 222 | + | p1, releasePTY1 := newBlockingPTY(t) | |||
| 223 | + | p2, releasePTY2 := newBlockingPTY(t) | |||
| 224 | + | defer releasePTY1() | |||
| 225 | + | defer releasePTY2() | |||
| 226 | + | d, source, ac, sends := newManualSessionWithPTYs(t, p1) | |||
| 227 | + | source.id, source.name = "source", "source" | |||
| 228 | + | delete(d.sessions, domain.SessionID("manual")) | |||
| 229 | + | d.sessions[source.id] = source | |||
| 230 | + | target := &session{ | |||
| 231 | + | id: "target", name: "target", ctx: source.ctx, cancel: func() {}, | |||
| 232 | + | tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})}, | |||
| 233 | + | } | |||
| 234 | + | d.sessions[target.id] = target | |||
| 235 | + | ||||
| 236 | + | sourcePane := source.tabs[0].focusedPane() | |||
| 237 | + | targetPane := target.tabs[0].focusedPane() | |||
| 238 | + | require.Equal(t, layout.PaneID("pane-1"), sourcePane.id, "source uses reusable pane-1") | |||
| 239 | + | require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1") | |||
| 240 | + | sourcePane.screen.Write([]byte("SOURCE")) | |||
| 241 | + | client := vt.NewScreen(80, 25) | |||
| 242 | + | d.firstPaint(source, ac, ac.size) | |||
| 243 | + | sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 244 | + | ac.ackOutputState(sourceOutput.NewStateNum) | |||
| 245 | + | ||||
| 246 | + | targetPane.screen.Write([]byte("TARGET")) | |||
| 247 | + | targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage. | |||
| 248 | + | require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage") | |||
| 249 | + | ac.previousSession.Set(target) | |||
| 250 | + | ||||
| 251 | + | // Exercise the user-facing previous-session route, which delegates to the | |||
| 252 | + | // real switchToTarget hand-off and immediately emits its required reset. | |||
| 253 | + | d.backSession(source, ac) | |||
| 254 | + | firstReset := awaitFrame(t, sends, ports.MsgOutput) | |||
| 255 | + | out := mustApplyOutput(t, client, firstReset) | |||
| 256 | + | require.Zero(t, out.BaseStateNum, "the first target frame must be the reset, not an eventual repair") | |||
| 257 | + | frame := strings.Join(frameRows(client.Frame), "\n") | |||
| 258 | + | require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture") | |||
| 259 | + | require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state") | |||
| 260 | + | } | |||
| 261 | + | ||||
| 220 | func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | 262 | func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | ||
| 221 | p1, releasePTY1 := newBlockingPTY(t) | 263 | p1, releasePTY1 := newBlockingPTY(t) | ||
| 222 | p2, releasePTY2 := newBlockingPTY(t) | 264 | p2, releasePTY2 := newBlockingPTY(t) | ||
| @@ -259,6 +301,40 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||||
| 259 | awaitFrame(t, sends1, ports.MsgOutput) | 301 | awaitFrame(t, sends1, ports.MsgOutput) | ||
| 260 | } | 302 | } | ||
| 261 | 303 | ||||
| 304 | + | func TestPickerDisplacementCancelsSupersededResize(t *testing.T) { | |||
| 305 | + | p1, releasePTY1 := newBlockingPTY(t) | |||
| 306 | + | p2, releasePTY2 := newBlockingPTY(t) | |||
| 307 | + | defer releasePTY1() | |||
| 308 | + | defer releasePTY2() | |||
| 309 | + | clock := &signalClock{timers: make(chan *signalTimer, 2)} | |||
| 310 | + | d := newTestDaemon(t, nil, clock) | |||
| 311 | + | tr1, _ := newCapturingTransport(t) | |||
| 312 | + | tr2, _ := newCapturingTransport(t) | |||
| 313 | + | ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 314 | + | ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 315 | + | sctx1, cancel1 := context.WithCancel(d.serveCtx) | |||
| 316 | + | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |||
| 317 | + | defer cancel1() | |||
| 318 | + | defer cancel2() | |||
| 319 | + | sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1} | |||
| 320 | + | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2} | |||
| 321 | + | ac1.setSession(sess1) | |||
| 322 | + | ac2.setSession(sess2) | |||
| 323 | + | d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2 | |||
| 324 | + | ||||
| 325 | + | d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24}) | |||
| 326 | + | timer := <-clock.timers | |||
| 327 | + | done := captureResizeCallbackDone(t, sess2.renderCoordinator()) | |||
| 328 | + | before := sess2.renderCoordinator().resizeSnapshot().epoch | |||
| 329 | + | ||||
| 330 | + | require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})) | |||
| 331 | + | // The target coordinator invalidates its scheduled epoch during handoff; | |||
| 332 | + | // no attachment-local timer survives the transfer. | |||
| 333 | + | require.Equal(t, before, sess2.renderCoordinator().resizeSnapshot().epoch) | |||
| 334 | + | timer.ch <- time.Time{} | |||
| 335 | + | awaitTestCompletion(t, done, "superseded resize callback did not complete") | |||
| 336 | + | } | |||
| 337 | + | ||||
| 262 | func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | 338 | func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | ||
| 263 | p1, releasePTY1 := newBlockingPTY(t) | 339 | p1, releasePTY1 := newBlockingPTY(t) | ||
| 264 | p2, releasePTY2 := newBlockingPTY(t) | 340 | p2, releasePTY2 := newBlockingPTY(t) | ||
| @@ -295,7 +371,7 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |||||
| 295 | oldPane.screen.Write([]byte("stale-damage")) | 371 | oldPane.screen.Write([]byte("stale-damage")) | ||
| 296 | require.NotEmpty(t, oldPane.screen.Damage()) | 372 | require.NotEmpty(t, oldPane.screen.Damage()) | ||
| 297 | 373 | ||||
| 298 | - | d.paint(sess1, ac1, false) | 374 | + | d.paint(sess1, ac1, false, nil) |
| 299 | 375 | ||||
| 300 | require.Zero(t, len(sends1), "stale paint from old session sent a frame") | 376 | require.Zero(t, len(sends1), "stale paint from old session sent a frame") | ||
| 301 | require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage") | 377 | require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage") | ||
| @@ -329,7 +405,7 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||||
| 329 | sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching")) | 405 | sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching")) | ||
| 330 | paintDone := make(chan struct{}) | 406 | paintDone := make(chan struct{}) | ||
| 331 | go func() { | 407 | go func() { | ||
| 332 | - | d.paint(sess1, ac, false) | 408 | + | d.paint(sess1, ac, false, nil) |
| 333 | close(paintDone) | 409 | close(paintDone) | ||
| 334 | }() | 410 | }() | ||
| 335 | <-enteredSend | 411 | <-enteredSend | ||
| @@ -368,8 +444,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||||
| 368 | defer releasePTY3() | 444 | defer releasePTY3() | ||
| 369 | var openedEnv []string | 445 | var openedEnv []string | ||
| 370 | f := portsmocks.NewMockPTYFactory(t) | 446 | f := portsmocks.NewMockPTYFactory(t) | ||
| 371 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 447 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( |
| 372 | - | func(_ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) { | 448 | + | func(_ context.Context, _ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) { |
| 373 | if sz != (domain.Size{Cols: 80, Rows: 22}) { | 449 | if sz != (domain.Size{Cols: 80, Rows: 22}) { | ||
| 374 | return newQuietPTY(), nil | 450 | return newQuietPTY(), nil | ||
| 375 | } | 451 | } | ||
| @@ -403,6 +479,72 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||||
| 403 | require.NotContains(t, openedEnv, "TERM=xterm-direct") | 479 | require.NotContains(t, openedEnv, "TERM=xterm-direct") | ||
| 404 | } | 480 | } | ||
| 405 | 481 | ||||
| 482 | + | func TestPickerPreviewGenerationRejectsStaleSubscriptionReplacement(t *testing.T) { | |||
| 483 | + | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |||
| 484 | + | viewer := &attachedClient{} | |||
| 485 | + | newer := func(renderWake) {} | |||
| 486 | + | ||||
| 487 | + | require.True(t, rc.subscribePreviewFor(viewer, 2, newer)) | |||
| 488 | + | require.False(t, rc.subscribePreviewFor(viewer, 1, func(renderWake) {})) | |||
| 489 | + | rc.teardownPreviewFor(viewer, 1) | |||
| 490 | + | ||||
| 491 | + | rc.mu.Lock() | |||
| 492 | + | subscription, ok := rc.previewWakes[viewer] | |||
| 493 | + | rc.mu.Unlock() | |||
| 494 | + | require.True(t, ok) | |||
| 495 | + | require.Equal(t, uint64(2), subscription.generation) | |||
| 496 | + | require.NotNil(t, subscription.fn) | |||
| 497 | + | } | |||
| 498 | + | ||||
| 499 | + | func TestPickerPreviewPostSubscribeRevalidationKeepsNewerSubscription(t *testing.T) { | |||
| 500 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 501 | + | viewer := &attachedClient{} | |||
| 502 | + | viewer.initOverlays() | |||
| 503 | + | target := &session{} | |||
| 504 | + | tab := &tab{} | |||
| 505 | + | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |||
| 506 | + | ||||
| 507 | + | viewer.overlays.pickerMu.Lock() | |||
| 508 | + | viewer.overlays.pickerPreviewGeneration = 2 | |||
| 509 | + | viewer.overlays.pickerPreviewSession = target | |||
| 510 | + | viewer.overlays.pickerPreview = tab | |||
| 511 | + | viewer.overlays.pickerMu.Unlock() | |||
| 512 | + | require.True(t, rc.subscribePreviewFor(viewer, 2, func(renderWake) {})) | |||
| 513 | + | ||||
| 514 | + | // This is the exact post-subscribe check from registerPreviewForSelection. | |||
| 515 | + | // It represents a generation-1 subscriber resuming after generation 2 won. | |||
| 516 | + | d.revalidatePreviewSubscription(viewer, rc, target, tab, 1) | |||
| 517 | + | ||||
| 518 | + | rc.mu.Lock() | |||
| 519 | + | subscription, ok := rc.previewWakes[viewer] | |||
| 520 | + | rc.mu.Unlock() | |||
| 521 | + | require.True(t, ok) | |||
| 522 | + | require.Equal(t, uint64(2), subscription.generation) | |||
| 523 | + | } | |||
| 524 | + | ||||
| 525 | + | func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) { | |||
| 526 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 527 | + | viewer := &attachedClient{} | |||
| 528 | + | viewer.initOverlays() | |||
| 529 | + | newerSession := &session{} | |||
| 530 | + | newerTab := &tab{} | |||
| 531 | + | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |||
| 532 | + | ||||
| 533 | + | viewer.overlays.pickerMu.Lock() | |||
| 534 | + | viewer.overlays.pickerPreviewGeneration = 2 | |||
| 535 | + | viewer.overlays.pickerPreviewSession = newerSession | |||
| 536 | + | viewer.overlays.pickerPreview = newerTab | |||
| 537 | + | viewer.overlays.pickerMu.Unlock() | |||
| 538 | + | require.True(t, rc.subscribePreviewFor(viewer, 2, func(renderWake) {})) | |||
| 539 | + | ||||
| 540 | + | require.False(t, d.clearPreviewGeneration(viewer, 1)) | |||
| 541 | + | require.True(t, pickerPreviewCurrent(viewer, newerSession, newerTab, 2)) | |||
| 542 | + | rc.mu.Lock() | |||
| 543 | + | _, subscribed := rc.previewWakes[viewer] | |||
| 544 | + | rc.mu.Unlock() | |||
| 545 | + | require.True(t, subscribed) | |||
| 546 | + | } | |||
| 547 | + | ||||
| 406 | func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) { | 548 | func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) { | ||
| 407 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | 549 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | ||
| 408 | p := tb.focusedPane() | 550 | p := tb.focusedPane() | ||
| @@ -448,123 +590,6 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) { | |||||
| 448 | require.Equal(t, 'R', preview.Rows[2][21].Rune, "expanded stacked pane content should be included") | 590 | require.Equal(t, 'R', preview.Rows[2][21].Rune, "expanded stacked pane content should be included") | ||
| 449 | } | 591 | } | ||
| 450 | 592 | ||||
| 451 | - | func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) { | |||
| 452 | - | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |||
| 453 | - | defer func() { | |||
| 454 | - | for _, release := range releases { | |||
| 455 | - | release() | |||
| 456 | - | } | |||
| 457 | - | }() | |||
| 458 | - | ||||
| 459 | - | d.enterPicker(sess, ac) | |||
| 460 | - | awaitFrame(t, sends, ports.MsgOutput) | |||
| 461 | - | d.handleInput(sess, ac, []byte("j")) | |||
| 462 | - | awaitFrame(t, sends, ports.MsgOutput) | |||
| 463 | - | ||||
| 464 | - | previewTab := sess.tabs[1] | |||
| 465 | - | previewTab.mu.Lock() | |||
| 466 | - | previewTab.focusedPane().screen.Write([]byte("inactive-preview-live")) | |||
| 467 | - | previewTab.mu.Unlock() | |||
| 468 | - | d.render(sess, previewTab, previewTab.focusedPane()) | |||
| 469 | - | ||||
| 470 | - | previewOut := awaitFrame(t, sends, ports.MsgOutput) | |||
| 471 | - | previewMsg, err := ports.UnmarshalOutput(previewOut.Payload) | |||
| 472 | - | require.NoError(t, err) | |||
| 473 | - | require.Contains(t, string(previewMsg.Data), "inactive-preview-live") | |||
| 474 | - | } | |||
| 475 | - | ||||
| 476 | - | func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) { | |||
| 477 | - | p1, releasePTY1 := newBlockingPTY(t) | |||
| 478 | - | p2, releasePTY2 := newBlockingPTY(t) | |||
| 479 | - | defer releasePTY1() | |||
| 480 | - | defer releasePTY2() | |||
| 481 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 482 | - | tr1, sends1 := newCapturingTransport(t) | |||
| 483 | - | tr2, _ := newCapturingTransport(t) | |||
| 484 | - | ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 485 | - | ac1.initOverlays() | |||
| 486 | - | ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 487 | - | ac2.initOverlays() | |||
| 488 | - | sctx1, cancel1 := context.WithCancel(d.serveCtx) | |||
| 489 | - | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |||
| 490 | - | defer cancel1() | |||
| 491 | - | defer cancel2() | |||
| 492 | - | sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1} | |||
| 493 | - | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2} | |||
| 494 | - | ac1.setSession(sess1) | |||
| 495 | - | ac2.setSession(sess2) | |||
| 496 | - | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil) | |||
| 497 | - | ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}, nil) | |||
| 498 | - | d.sessions[sess1.id] = sess1 | |||
| 499 | - | d.sessions[sess2.id] = sess2 | |||
| 500 | - | ||||
| 501 | - | d.enterPicker(sess1, ac1) | |||
| 502 | - | awaitFrame(t, sends1, ports.MsgOutput) | |||
| 503 | - | d.handleInput(sess1, ac1, []byte("j")) | |||
| 504 | - | awaitFrame(t, sends1, ports.MsgOutput) | |||
| 505 | - | ||||
| 506 | - | previewTab := sess2.tabs[0] | |||
| 507 | - | previewTab.mu.Lock() | |||
| 508 | - | previewTab.focusedPane().screen.Write([]byte("cross-session-preview-live")) | |||
| 509 | - | previewTab.mu.Unlock() | |||
| 510 | - | d.render(sess2, previewTab, previewTab.focusedPane()) | |||
| 511 | - | ||||
| 512 | - | previewOut := awaitFrame(t, sends1, ports.MsgOutput) | |||
| 513 | - | previewMsg, err := ports.UnmarshalOutput(previewOut.Payload) | |||
| 514 | - | require.NoError(t, err) | |||
| 515 | - | require.Contains(t, string(previewMsg.Data), "cross-session-preview-live") | |||
| 516 | - | } | |||
| 517 | - | ||||
| 518 | - | func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) { | |||
| 519 | - | d, sess, ac, sends, releases := newManualTabSession(t, 3) | |||
| 520 | - | defer func() { | |||
| 521 | - | for _, release := range releases { | |||
| 522 | - | release() | |||
| 523 | - | } | |||
| 524 | - | }() | |||
| 525 | - | ||||
| 526 | - | done := make(chan struct{}) | |||
| 527 | - | var drain sync.WaitGroup | |||
| 528 | - | drain.Go(func() { | |||
| 529 | - | for { | |||
| 530 | - | select { | |||
| 531 | - | case <-sends: | |||
| 532 | - | case <-done: | |||
| 533 | - | return | |||
| 534 | - | } | |||
| 535 | - | } | |||
| 536 | - | }) | |||
| 537 | - | ||||
| 538 | - | var wg sync.WaitGroup | |||
| 539 | - | for i := range 100 { | |||
| 540 | - | wg.Go(func() { | |||
| 541 | - | d.enterPicker(sess, ac) | |||
| 542 | - | }) | |||
| 543 | - | wg.Go(func() { | |||
| 544 | - | d.handlePickerInput(ac, []byte("j")) | |||
| 545 | - | d.handlePickerInput(ac, []byte("k")) | |||
| 546 | - | }) | |||
| 547 | - | wg.Go(func() { | |||
| 548 | - | d.closePicker(ac) | |||
| 549 | - | }) | |||
| 550 | - | tb := sess.tabs[i%len(sess.tabs)] | |||
| 551 | - | wg.Go(func() { | |||
| 552 | - | tb.mu.Lock() | |||
| 553 | - | p := tb.focusedPane() | |||
| 554 | - | tb.mu.Unlock() | |||
| 555 | - | if p != nil { | |||
| 556 | - | p.mu.Lock() | |||
| 557 | - | p.screen.Write([]byte("render-race")) | |||
| 558 | - | p.mu.Unlock() | |||
| 559 | - | } | |||
| 560 | - | d.render(sess, tb, p) | |||
| 561 | - | }) | |||
| 562 | - | } | |||
| 563 | - | wg.Wait() | |||
| 564 | - | close(done) | |||
| 565 | - | drain.Wait() | |||
| 566 | - | } | |||
| 567 | - | ||||
| 568 | func TestPickerModalGeometry(t *testing.T) { | 593 | func TestPickerModalGeometry(t *testing.T) { | ||
| 569 | base := domain.Size{Cols: 100, Rows: 40} | 594 | base := domain.Size{Cols: 100, Rows: 40} | ||
| 570 | 595 | ||||
| @@ -582,7 +607,7 @@ func TestPickerResizeRecomposesModal(t *testing.T) { | |||||
| 582 | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) | 607 | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) | ||
| 583 | // Picker recomposition is the relevant event here; do not depend on a real | 608 | // Picker recomposition is the relevant event here; do not depend on a real | ||
| 584 | // resize-idle timer in this synchronous rendering test. | 609 | // resize-idle timer in this synchronous rendering test. | ||
| 585 | - | d.paint(sess, ac, false) | 610 | + | d.paint(sess, ac, false, nil) |
| 586 | 611 | ||||
| 587 | out := awaitFrame(t, sends, ports.MsgOutput) | 612 | out := awaitFrame(t, sends, ports.MsgOutput) | ||
| 588 | msg, err := ports.UnmarshalOutput(out.Payload) | 613 | msg, err := ports.UnmarshalOutput(out.Payload) | ||
| @@ -600,8 +625,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 600 | var opens [][]string | 625 | var opens [][]string | ||
| 601 | f := portsmocks.NewMockPTYFactory(t) | 626 | f := portsmocks.NewMockPTYFactory(t) | ||
| 602 | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} | 627 | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} | ||
| 603 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | 628 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( |
| 604 | - | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | 629 | + | func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { |
| 605 | opens = append(opens, append([]string(nil), env...)) | 630 | opens = append(opens, append([]string(nil), env...)) | ||
| 606 | if len(opens) == 1 { | 631 | if len(opens) == 1 { | ||
| 607 | return p1, nil | 632 | return p1, nil | ||
| @@ -610,7 +635,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 610 | }, | 635 | }, | ||
| 611 | ).Twice() | 636 | ).Twice() | ||
| 612 | floating := newQuietPTY() | 637 | floating := newQuietPTY() | ||
| 613 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | 638 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { |
| 614 | return got != normalSize && got.Valid() | 639 | return got != normalSize && got.Valid() | ||
| 615 | })).Return(floating, nil).Once() | 640 | })).Return(floating, nil).Once() | ||
| 616 | d := newTestDaemon(t, f, stubClock{}) | 641 | d := newTestDaemon(t, f, stubClock{}) | ||
M internal/usecase/daemon/prompt.go +5 -11
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 7 | promptui "github.com/bnema/vev/internal/usecase/prompt" | 7 | promptui "github.com/bnema/vev/internal/usecase/prompt" | ||
| 8 | "github.com/bnema/vev/internal/usecase/ui" | 8 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 10 | ) | 9 | ) | ||
| 11 | 10 | ||||
| 12 | func promptModalFor(title string) ui.Modal { | 11 | func promptModalFor(title string) ui.Modal { | ||
| @@ -20,7 +19,7 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s | |||||
| 20 | ac.overlays.promptSubmit = submit | 19 | ac.overlays.promptSubmit = submit | ||
| 21 | ac.overlays.promptPending = nil | 20 | ac.overlays.promptPending = nil | ||
| 22 | ac.overlays.promptMu.Unlock() | 21 | ac.overlays.promptMu.Unlock() | ||
| 23 | - | d.paint(sess, ac, true) | 22 | + | d.invalidateRender(sess, ac, true, "prompt.go") |
| 24 | } | 23 | } | ||
| 25 | 24 | ||||
| 26 | func (d *Daemon) closePrompt(ac *attachedClient) { | 25 | func (d *Daemon) closePrompt(ac *attachedClient) { | ||
| @@ -77,26 +76,21 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |||||
| 77 | ac.overlays.prompt.SetError(err.Error()) | 76 | ac.overlays.prompt.SetError(err.Error()) | ||
| 78 | } | 77 | } | ||
| 79 | ac.overlays.promptMu.Unlock() | 78 | ac.overlays.promptMu.Unlock() | ||
| 80 | - | d.paint(sess, ac, true) | 79 | + | d.invalidateRender(sess, ac, true, "prompt.go") |
| 81 | return | 80 | return | ||
| 82 | } | 81 | } | ||
| 83 | d.closePrompt(ac) | 82 | d.closePrompt(ac) | ||
| 84 | if current := ac.currentSession(); current != nil { | 83 | if current := ac.currentSession(); current != nil { | ||
| 85 | - | d.paint(current, ac, true) | 84 | + | d.invalidateRender(current, ac, true, "prompt.go") |
| 86 | } | 85 | } | ||
| 87 | return | 86 | return | ||
| 88 | } | 87 | } | ||
| 89 | if exit { | 88 | if exit { | ||
| 90 | d.closePrompt(ac) | 89 | d.closePrompt(ac) | ||
| 91 | - | d.paint(sess, ac, true) | 90 | + | d.invalidateRender(sess, ac, true, "prompt.go") |
| 92 | return | 91 | return | ||
| 93 | } | 92 | } | ||
| 94 | if changed { | 93 | if changed { | ||
| 95 | - | d.paint(sess, ac, true) | 94 | + | d.invalidateRender(sess, ac, true, "prompt.go") |
| 96 | } | 95 | } | ||
| 97 | } | 96 | } | ||
| 98 | - | ||||
| 99 | - | func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |||
| 100 | - | styleSet := resolveThemeStyles(styles) | |||
| 101 | - | return composeModalClientFrame(base, promptModalFor(model.Title()), styleSet, styleSet.accent, model.Render) | |||
| 102 | - | } | |||
M internal/usecase/daemon/render.go +230 -688
| @@ -1,49 +1,85 @@ | |||||
| 1 | - | // Package daemon holds vev's server-side session multiplexer use case: the | 1 | + | // Package daemon holds vev's server-side session multiplexer use case. |
| 2 | - | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |||
| 3 | - | // and VT screen, and the per-client debounced render scheduler. | |||
| 4 | - | // | |||
| 5 | - | // Concurrency model (sessions own one or more PTY-backed tabs): | |||
| 6 | - | // | |||
| 7 | - | // - Serve runs the accept loop. Each accepted connection is handled by its | |||
| 8 | - | // own goroutine (handleConn): it reads the first frame and routes it to a | |||
| 9 | - | // session create/attach, a list, or a kill. | |||
| 10 | - | // - Per session there are exactly two long-lived goroutines: the PTY reader | |||
| 11 | - | // (drains child output into the VT screen and pokes a cap-1 dirty channel) | |||
| 12 | - | // and the render scheduler (debounces dirties and paints the attached | |||
| 13 | - | // client). Both are tied to the session context and unwind when the | |||
| 14 | - | // session is killed (pty.Close unblocks the reader; ctx cancel stops the | |||
| 15 | - | // scheduler). | |||
| 16 | - | // - The daemon exits (Serve returns) when the last session is removed, or | |||
| 17 | - | // when the parent context is cancelled (graceful shutdown notifies any | |||
| 18 | - | // attached clients with ReasonServerShutdown). | |||
| 19 | - | // | |||
| 20 | - | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |||
| 21 | - | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |||
| 22 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | |||
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |||
| 24 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |||
| 25 | package daemon | 2 | package daemon | ||
| 26 | 3 | ||||
| 27 | import ( | 4 | import ( | ||
| 28 | "bytes" | 5 | "bytes" | ||
| 29 | - | "errors" | |||
| 30 | - | "strconv" | |||
| 31 | - | "time" | |||
| 32 | 6 | ||||
| 33 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 34 | "github.com/bnema/vev/internal/ports" | 8 | "github.com/bnema/vev/internal/ports" | ||
| 35 | "github.com/bnema/vev/internal/usecase/layout" | 9 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 36 | themeui "github.com/bnema/vev/internal/usecase/theme" | 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 37 | - | "github.com/bnema/vev/internal/usecase/ui" | |||
| 38 | "github.com/bnema/vev/pkg/renderer" | 11 | "github.com/bnema/vev/pkg/renderer" | ||
| 39 | - | "github.com/bnema/vev/pkg/vt" | |||
| 40 | ) | 12 | ) | ||
| 41 | 13 | ||||
| 42 | - | func signal(ch chan struct{}) { | 14 | + | // completedSynchronizedUpdate reports a full DEC 2026 batch contained in one |
| 43 | - | select { | 15 | + | // PTY read. The VT's final state is inactive in this case, so it cannot be |
| 44 | - | case ch <- struct{}{}: | 16 | + | // inferred from before/after SyncUpdateActive alone. |
| 45 | - | default: | 17 | + | func completedSynchronizedUpdate(data []byte) bool { |
| 18 | + | _, tail, found := bytes.Cut(data, []byte(renderer.SyncStartCSI)) | |||
| 19 | + | return found && bytes.Contains(tail, []byte(renderer.SyncEndCSI)) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // paneRenderable reports dynamic composition visibility. Callers must not | |||
| 23 | + | // hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks. | |||
| 24 | + | func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool { | |||
| 25 | + | if sess == nil || tb == nil || p == nil { | |||
| 26 | + | return false | |||
| 27 | + | } | |||
| 28 | + | sess.mu.Lock() | |||
| 29 | + | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | |||
| 30 | + | attached := sess.client != nil | |||
| 31 | + | sess.mu.Unlock() | |||
| 32 | + | ||||
| 33 | + | // The normal attached render path needs no cross-session picker lookup. | |||
| 34 | + | // Only inactive or headless tabs can be renderable as a picker preview. | |||
| 35 | + | if (!active || !attached) && !d.tabIsPickerPreview(tb) { | |||
| 36 | + | return false | |||
| 37 | + | } | |||
| 38 | + | tb.mu.Lock() | |||
| 39 | + | defer tb.mu.Unlock() | |||
| 40 | + | if tb.floating.pane == p { | |||
| 41 | + | return active && attached && tb.floating.state == floatingVisible | |||
| 42 | + | } | |||
| 43 | + | placements, ok := solvedPlacementsLocked(tb) | |||
| 44 | + | if !ok { | |||
| 45 | + | return false | |||
| 46 | + | } | |||
| 47 | + | for _, placement := range placements { | |||
| 48 | + | if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 { | |||
| 49 | + | return true | |||
| 50 | + | } | |||
| 46 | } | 51 | } | ||
| 52 | + | return false | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // tabIsPickerPreview reports whether any attached client currently composes | |||
| 56 | + | // tb as a picker preview. It snapshots daemon ownership before taking overlay | |||
| 57 | + | // locks, preserving the Daemon -> session -> tab -> pane lock order. | |||
| 58 | + | func (d *Daemon) tabIsPickerPreview(tb *tab) bool { | |||
| 59 | + | if d == nil || tb == nil { | |||
| 60 | + | return false | |||
| 61 | + | } | |||
| 62 | + | d.mu.Lock() | |||
| 63 | + | sessions := make([]*session, 0, len(d.sessions)) | |||
| 64 | + | for _, sess := range d.sessions { | |||
| 65 | + | sessions = append(sessions, sess) | |||
| 66 | + | } | |||
| 67 | + | d.mu.Unlock() | |||
| 68 | + | for _, sess := range sessions { | |||
| 69 | + | sess.mu.Lock() | |||
| 70 | + | ac := sess.client | |||
| 71 | + | sess.mu.Unlock() | |||
| 72 | + | if ac == nil || ac.overlays == nil { | |||
| 73 | + | continue | |||
| 74 | + | } | |||
| 75 | + | ac.overlays.pickerMu.Lock() | |||
| 76 | + | preview := ac.overlays.pickerPreview == tb | |||
| 77 | + | ac.overlays.pickerMu.Unlock() | |||
| 78 | + | if preview { | |||
| 79 | + | return true | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | return false | |||
| 47 | } | 83 | } | ||
| 48 | 84 | ||||
| 49 | func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | 85 | func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | ||
| @@ -52,61 +88,17 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||||
| 52 | return | 88 | return | ||
| 53 | } | 89 | } | ||
| 54 | buf := make([]byte, ptyReadBufSize) | 90 | buf := make([]byte, ptyReadBufSize) | ||
| 55 | - | var resp []byte | |||
| 56 | - | var clipboards []string | |||
| 57 | - | attentionCh := make(chan struct{}, 1) | |||
| 58 | p.mu.Lock() | 91 | p.mu.Lock() | ||
| 59 | - | p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | 92 | + | p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) } |
| 60 | - | p.screen.OnBell = func() { signal(attentionCh) } | 93 | + | p.screen.OnBell = func() { p.ptyAttention = true } |
| 61 | - | p.screen.OnNotify = func(string, string) { signal(attentionCh) } | 94 | + | p.screen.OnNotify = func(string, string) { p.ptyAttention = true } |
| 62 | - | p.screen.OnProgress = func(bool) { signal(attentionCh) } | 95 | + | p.screen.OnProgress = func(bool) { p.ptyAttention = true } |
| 63 | - | p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) } | 96 | + | p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) } |
| 64 | p.mu.Unlock() | 97 | p.mu.Unlock() | ||
| 65 | for { | 98 | for { | ||
| 66 | n, err := p.pty.Read(buf) | 99 | n, err := p.pty.Read(buf) | ||
| 67 | if n > 0 { | 100 | if n > 0 { | ||
| 68 | - | data := buf[:n] | 101 | + | d.processPTYData(sess, tb, p, buf[:n], true) |
| 69 | - | p.mu.Lock() | |||
| 70 | - | wasSyncing := p.screen.SyncUpdateActive() | |||
| 71 | - | p.screen.Write(data) | |||
| 72 | - | p.refreshTerminalTitleLocked() | |||
| 73 | - | isSyncing := p.screen.SyncUpdateActive() | |||
| 74 | - | p.mu.Unlock() | |||
| 75 | - | markSnapshotDirty(sess) | |||
| 76 | - | select { | |||
| 77 | - | case <-attentionCh: | |||
| 78 | - | d.noteAttention(sess, tb) | |||
| 79 | - | default: | |||
| 80 | - | } | |||
| 81 | - | if len(clipboards) > 0 { | |||
| 82 | - | for _, b64 := range clipboards { | |||
| 83 | - | d.forwardClipboardAsync(sess, b64) | |||
| 84 | - | } | |||
| 85 | - | clipboards = clipboards[:0] | |||
| 86 | - | } | |||
| 87 | - | if len(resp) > 0 { | |||
| 88 | - | if _, writeErr := p.pty.Write(resp); writeErr != nil { | |||
| 89 | - | d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name) | |||
| 90 | - | } | |||
| 91 | - | resp = resp[:0] | |||
| 92 | - | } | |||
| 93 | - | if wasSyncing != isSyncing { | |||
| 94 | - | p.mu.Lock() | |||
| 95 | - | p.syncGen++ | |||
| 96 | - | gen := p.syncGen | |||
| 97 | - | p.mu.Unlock() | |||
| 98 | - | if isSyncing { | |||
| 99 | - | go d.syncWatchdog(p, gen) | |||
| 100 | - | } | |||
| 101 | - | } | |||
| 102 | - | if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) { | |||
| 103 | - | signal(p.flush) | |||
| 104 | - | continue | |||
| 105 | - | } | |||
| 106 | - | if isSyncing { | |||
| 107 | - | continue | |||
| 108 | - | } | |||
| 109 | - | signal(p.dirty) | |||
| 110 | } | 102 | } | ||
| 111 | if err != nil { | 103 | if err != nil { | ||
| 112 | if p.onExit != nil { | 104 | if p.onExit != nil { | ||
| @@ -119,250 +111,182 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||||
| 119 | } | 111 | } | ||
| 120 | } | 112 | } | ||
| 121 | 113 | ||||
| 122 | - | // scheduler debounces dirty signals. The first dirty opens a short tab; | 114 | + | // processPTYData is the sole VT parsing path. While a resize apply owns the |
| 123 | - | // sustained floods progressively widen that tab, while isolated updates | 115 | + | // pane, reader calls append exact bytes and returns immediately; it never waits |
| 124 | - | // return to the minimum delay for interactive latency. | 116 | + | // for PTY.Resize or loses a short read. |
| 125 | - | func (d *Daemon) scheduler(sess *session, tb *tab, p *pane) { | 117 | + | func (d *Daemon) processPTYData(sess *session, tb *tab, p *pane, data []byte, bufferDuringApply bool) { |
| 126 | - | defer d.sessWg.Done() | 118 | + | rc := sess.renderCoordinator() |
| 127 | - | if p == nil { | 119 | + | p.mu.Lock() |
| 120 | + | if bufferDuringApply && p.resizeApplying { | |||
| 121 | + | p.resizePending = append(p.resizePending, data...) | |||
| 122 | + | p.mu.Unlock() | |||
| 128 | return | 123 | return | ||
| 129 | } | 124 | } | ||
| 130 | - | delay := minDebounceInterval | 125 | + | wasSyncing := p.screen.SyncUpdateActive() |
| 131 | - | lastRender := d.clock.Now() | 126 | + | p.screen.Write(data) |
| 132 | - | outer: | 127 | + | p.refreshTerminalTitleLocked() |
| 133 | - | for { | 128 | + | isSyncing := p.screen.SyncUpdateActive() |
| 134 | - | select { | 129 | + | completeSyncRead := !wasSyncing && !isSyncing && completedSynchronizedUpdate(data) |
| 135 | - | case <-sess.ctx.Done(): | 130 | + | var syncGen uint64 |
| 136 | - | return | 131 | + | syncEnded := false |
| 137 | - | case <-paneDone(p): | 132 | + | if wasSyncing != isSyncing { |
| 138 | - | return | 133 | + | if isSyncing { |
| 139 | - | case <-p.flush: | 134 | + | syncGen = sess.syncGen.Add(1) |
| 140 | - | d.render(sess, tb, p) | 135 | + | p.syncGen = syncGen |
| 141 | - | lastRender = d.clock.Now() | 136 | + | if rc != nil { |
| 142 | - | continue | 137 | + | rc.noteSyncBeginWithRenderability(p, syncGen, func() bool { return d.paneRenderable(sess, tb, p) }, func() { |
| 143 | - | case <-p.dirty: | 138 | + | p.mu.Lock() |
| 144 | - | if d.clock.Now().Sub(lastRender) >= maxDebounceInterval { | 139 | + | if p.syncGen == syncGen && p.screen.SyncUpdateActive() { |
| 145 | - | delay = minDebounceInterval | 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 | + | } | |
| 149 | - | coalesced := 0 | 150 | + | p.mu.Unlock() |
| 150 | - | timer := d.clock.NewTimer(delay) | 151 | + | renderable := d.paneRenderable(sess, tb, p) |
| 151 | - | absorb: | 152 | + | markSnapshotDirty(sess) |
| 152 | - | for { | 153 | + | d.flushPTYEffects(sess, tb, p) |
| 153 | - | select { | 154 | + | if rc != nil { |
| 154 | - | case <-sess.ctx.Done(): | 155 | + | if wasSyncing != isSyncing { |
| 155 | - | timer.Stop() | 156 | + | if isSyncing { |
| 156 | - | return | 157 | + | if renderable { |
| 157 | - | case <-paneDone(p): | 158 | + | rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) |
| 158 | - | timer.Stop() | |||
| 159 | - | return | |||
| 160 | - | case <-p.flush: | |||
| 161 | - | if !timer.Stop() { | |||
| 162 | - | select { | |||
| 163 | - | case <-timer.C(): | |||
| 164 | - | default: | |||
| 165 | - | } | |||
| 166 | } | 159 | } | ||
| 167 | - | d.render(sess, tb, p) | 160 | + | } else if syncEnded && renderable { |
| 168 | - | lastRender = d.clock.Now() | 161 | + | rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"}) |
| 169 | - | continue outer | |||
| 170 | - | case <-p.dirty: | |||
| 171 | - | coalesced++ | |||
| 172 | - | case <-timer.C(): | |||
| 173 | - | break absorb | |||
| 174 | } | 162 | } | ||
| 175 | } | 163 | } | ||
| 176 | - | delay = nextDebounceDelay(delay, coalesced) | 164 | + | if completeSyncRead && renderable { |
| 177 | - | d.render(sess, tb, p) | 165 | + | rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"}) |
| 178 | - | lastRender = d.clock.Now() | 166 | + | } else if renderable && !isSyncing && wasSyncing == isSyncing { |
| 179 | - | } | 167 | + | rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) |
| 180 | - | } | 168 | + | } |
| 181 | - | ||||
| 182 | - | func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration { | |||
| 183 | - | if coalesced == 0 { | |||
| 184 | - | return minDebounceInterval | |||
| 185 | - | } | |||
| 186 | - | if delay >= maxDebounceInterval { | |||
| 187 | - | return maxDebounceInterval | |||
| 188 | - | } | |||
| 189 | - | delay += debounceStep | |||
| 190 | - | if delay > maxDebounceInterval { | |||
| 191 | - | return maxDebounceInterval | |||
| 192 | } | 169 | } | ||
| 193 | - | return delay | |||
| 194 | } | 170 | } | ||
| 195 | 171 | ||||
| 196 | - | func (d *Daemon) syncWatchdog(p *pane, gen uint64) { | 172 | + | // flushPTYEffects preserves the normal response/title/attention/clipboard |
| 197 | - | timer := d.clock.NewTimer(maxSyncUpdateDuration) | 173 | + | // behavior for both direct reads and resize-buffer replay. |
| 198 | - | select { | 174 | + | func (d *Daemon) flushPTYEffects(sess *session, tb *tab, p *pane) { |
| 199 | - | case <-paneDone(p): | |||
| 200 | - | timer.Stop() | |||
| 201 | - | return | |||
| 202 | - | case <-timer.C(): | |||
| 203 | - | } | |||
| 204 | - | ||||
| 205 | p.mu.Lock() | 175 | p.mu.Lock() | ||
| 206 | - | if p.syncGen != gen || !p.screen.SyncUpdateActive() { | 176 | + | attention := p.ptyAttention |
| 207 | - | p.mu.Unlock() | 177 | + | p.ptyAttention = false |
| 208 | - | return | 178 | + | responses := append([]byte(nil), p.ptyResponses...) |
| 209 | - | } | 179 | + | p.ptyResponses = p.ptyResponses[:0] |
| 210 | - | p.screen.ForceSyncEnd() | 180 | + | clipboards := append([]string(nil), p.ptyClipboards...) |
| 181 | + | p.ptyClipboards = p.ptyClipboards[:0] | |||
| 211 | p.mu.Unlock() | 182 | p.mu.Unlock() | ||
| 212 | - | signal(p.flush) | 183 | + | if attention { |
| 213 | - | } | 184 | + | d.noteAttention(sess, tb) |
| 214 | - | ||||
| 215 | - | func syncUpdateEndIn(data []byte) bool { | |||
| 216 | - | return bytes.Contains(data, []byte("\x1b[?2026l")) | |||
| 217 | - | } | |||
| 218 | - | ||||
| 219 | - | // render paints the current client, or (when detached) just clears accumulated | |||
| 220 | - | // damage so it never grows unbounded while headless. | |||
| 221 | - | func (d *Daemon) render(sess *session, tb *tab, p *pane) { | |||
| 222 | - | if p == nil { | |||
| 223 | - | return | |||
| 224 | } | 185 | } | ||
| 225 | - | p.mu.Lock() | 186 | + | for _, b64 := range clipboards { |
| 226 | - | if p.screen.SyncUpdateActive() { | 187 | + | d.forwardClipboardAsync(sess, b64) |
| 227 | - | p.mu.Unlock() | |||
| 228 | - | return | |||
| 229 | } | 188 | } | ||
| 230 | - | p.mu.Unlock() | 189 | + | if len(responses) > 0 { |
| 231 | - | 190 | + | if _, err := p.pty.Write(responses); err != nil { | |
| 232 | - | // A retained floating pane continues to drain its PTY while hidden. Its | 191 | + | d.log.Warn("pty response write failed", "err", err, "session", sess.name) |
| 233 | - | // scheduler must not route through picker preview (or the active client), | |||
| 234 | - | // but it must consume damage so a long-hidden command cannot retain it. | |||
| 235 | - | tb.mu.Lock() | |||
| 236 | - | hiddenFloating := tb.floating.pane == p && tb.floating.state != floatingVisible | |||
| 237 | - | previewer := tb.previewClient | |||
| 238 | - | tb.mu.Unlock() | |||
| 239 | - | if hiddenFloating { | |||
| 240 | - | p.mu.Lock() | |||
| 241 | - | p.screen.ClearDamage() | |||
| 242 | - | p.mu.Unlock() | |||
| 243 | - | return | |||
| 244 | - | } | |||
| 245 | - | ||||
| 246 | - | sess.mu.Lock() | |||
| 247 | - | ac := sess.client | |||
| 248 | - | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | |||
| 249 | - | sess.mu.Unlock() | |||
| 250 | - | ||||
| 251 | - | if previewer != nil { | |||
| 252 | - | if previewSess := previewer.currentSession(); previewSess != nil { | |||
| 253 | - | d.paint(previewSess, previewer, false) | |||
| 254 | } | 192 | } | ||
| 255 | - | if ac != nil && active && ac != previewer { | |||
| 256 | - | d.paint(sess, ac, false) | |||
| 257 | - | return | |||
| 258 | - | } | |||
| 259 | - | p.mu.Lock() | |||
| 260 | - | p.screen.ClearDamage() | |||
| 261 | - | p.mu.Unlock() | |||
| 262 | - | return | |||
| 263 | - | } | |||
| 264 | - | ||||
| 265 | - | if ac == nil || !active { | |||
| 266 | - | p.mu.Lock() | |||
| 267 | - | p.screen.ClearDamage() | |||
| 268 | - | p.mu.Unlock() | |||
| 269 | - | return | |||
| 270 | } | 193 | } | ||
| 271 | - | d.refreshBarScriptsIfDue(sess, d.clock.Now(), false) | |||
| 272 | - | d.paint(sess, ac, false) | |||
| 273 | } | 194 | } | ||
| 274 | 195 | ||||
| 275 | // paint draws the composed client frame (active tab plus status bar) and | 196 | // paint draws the composed client frame (active tab plus status bar) and | ||
| 276 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | 197 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | ||
| 277 | // such as switch/create/close/rename/resize so the repaint is complete. | 198 | // such as switch/create/close/rename/resize so the repaint is complete. | ||
| 278 | - | // copyTargetRectLocked maps a captured copy source into the already-composed | 199 | + | func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid bool) { |
| 279 | - | // client frame. The caller holds tb.mu, preserving the layout/floating | 200 | + | if d != nil && d.runtimeObserver != nil { |
| 280 | - | // snapshot while the rectangle is chosen. | 201 | + | d.runtimeObserver.ObserveRuntime(ports.NewRuntimeMark("daemon", kind, bytes, valid)) |
| 281 | - | func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, floatingFrameGeometry floatingGeometry) domain.Rect { | |||
| 282 | - | if p == nil { | |||
| 283 | - | return domain.Rect{} | |||
| 284 | - | } | |||
| 285 | - | if hasFloating && p == floating { | |||
| 286 | - | return floatingFrameGeometry.Inner | |||
| 287 | - | } | |||
| 288 | - | for _, placement := range layoutSnap.placements { | |||
| 289 | - | if placement.ID == p.id && !placement.Collapsed { | |||
| 290 | - | r := placement.Content | |||
| 291 | - | r.X += contentArea.X | |||
| 292 | - | r.Y += contentArea.Y | |||
| 293 | - | return r | |||
| 294 | - | } | |||
| 295 | } | 202 | } | ||
| 296 | - | p.mu.Lock() | |||
| 297 | - | width, height := p.screen.Frame.Width, p.screen.Frame.Height | |||
| 298 | - | p.mu.Unlock() | |||
| 299 | - | return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)} | |||
| 300 | } | 203 | } | ||
| 301 | 204 | ||||
| 302 | - | func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID { | 205 | + | // runtimeMarkBatch captures immutable runtime marks during a render transaction. |
| 303 | - | if !ok { | 206 | + | // paint owns attachment sendMu while it captures, composes, and emits; JSONL |
| 304 | - | return nil | 207 | + | // observer I/O must therefore happen only after that ownership is released. |
| 305 | - | } | 208 | + | type runtimeMarkBatch struct { |
| 306 | - | ids := make([]layout.PaneID, 0, len(placements)) | 209 | + | observer ports.RuntimeObserver |
| 307 | - | for _, pl := range placements { | 210 | + | marks []ports.RuntimeMark |
| 308 | - | if pl.TitleBar.Height > 0 { | 211 | + | } |
| 309 | - | ids = append(ids, pl.ID) | 212 | + | |
| 310 | - | } | 213 | + | func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch { |
| 214 | + | if d == nil { | |||
| 215 | + | return runtimeMarkBatch{} | |||
| 311 | } | 216 | } | ||
| 312 | - | return ids | 217 | + | return runtimeMarkBatch{observer: d.runtimeObserver} |
| 313 | } | 218 | } | ||
| 314 | 219 | ||||
| 315 | - | func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) { | 220 | + | func (b *runtimeMarkBatch) span(start, end ports.RuntimeMarkKind, bytes uint64) func(uint64, bool) { |
| 316 | - | ac.resizePaint.stop() | 221 | + | if b == nil || b.observer == nil { |
| 317 | - | ac.resizePaintGeneration++ | 222 | + | return func(uint64, bool) {} |
| 318 | - | generation := ac.resizePaintGeneration | 223 | + | } |
| 319 | - | ac.resizePaintPending = true | 224 | + | correlation := ports.NewRuntimeCorrelation() |
| 320 | - | ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) { | 225 | + | b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, start, bytes, true)) |
| 321 | - | d.paintForResizeGeneration(sess, ac, true, generation) | 226 | + | return func(endBytes uint64, valid bool) { |
| 322 | - | }) | 227 | + | b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, end, endBytes, valid)) |
| 228 | + | } | |||
| 323 | } | 229 | } | ||
| 324 | 230 | ||||
| 325 | - | func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | 231 | + | func (b *runtimeMarkBatch) flush() { |
| 326 | - | d.paintForResizeGeneration(sess, ac, reset, 0) | 232 | + | if b == nil || b.observer == nil { |
| 233 | + | return | |||
| 234 | + | } | |||
| 235 | + | for _, mark := range b.marks { | |||
| 236 | + | b.observer.ObserveRuntime(mark) | |||
| 237 | + | } | |||
| 327 | } | 238 | } | ||
| 328 | 239 | ||||
| 329 | - | func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration uint64) { | 240 | + | func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *attachmentLease) { |
| 241 | + | marks := d.newRuntimeMarkBatch() | |||
| 330 | tb := sess.activeTab() | 242 | tb := sess.activeTab() | ||
| 331 | if tb == nil { | 243 | if tb == nil { | ||
| 332 | return | 244 | return | ||
| 333 | } | 245 | } | ||
| 334 | 246 | ||||
| 335 | - | ac.initOverlays() | |||
| 336 | ac.sendMu.Lock() | 247 | ac.sendMu.Lock() | ||
| 337 | - | if ac.currentSession() != sess { | 248 | + | // sendMu is the attachment ownership boundary. Check the session's |
| 338 | - | ac.sendMu.Unlock() | 249 | + | // published identity while holding it so a deadline captured before an |
| 339 | - | return | 250 | + | // attach/replace cannot emit on either the old or new output chain. |
| 340 | - | } | 251 | + | sess.mu.Lock() |
| 341 | - | if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) { | 252 | + | owned := sess.client == ac |
| 253 | + | sess.mu.Unlock() | |||
| 254 | + | if !owned || ac.currentSession() != sess { | |||
| 342 | ac.sendMu.Unlock() | 255 | ac.sendMu.Unlock() | ||
| 343 | return | 256 | return | ||
| 344 | } | 257 | } | ||
| 345 | - | if ac.resizePaintPending { | 258 | + | if lease != nil { |
| 346 | - | ac.resizePaint.stop() | 259 | + | rc := sess.renderCoordinator() |
| 347 | - | ac.resizePaintPending = false | 260 | + | if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) { |
| 348 | - | reset = true | 261 | + | ac.sendMu.Unlock() |
| 262 | + | return | |||
| 263 | + | } | |||
| 349 | } | 264 | } | ||
| 350 | - | // Ack-gated coalescing: while the client is at least maxUnackedOutputStates | 265 | + | // Capacity is checked before any destructive capture. The coordinator is |
| 351 | - | // behind, skip composing another diff and mark the paint deferred. The | 266 | + | // the normal gate, but this ownership check also protects direct resize and |
| 352 | - | // MsgOutputAck handler flushes one cumulative paint once acks catch up. A | 267 | + | // test paint paths from consuming damage that cannot be emitted. |
| 353 | - | // deferred reset stays sticky so the cumulative paint is dependency-free. | 268 | + | if ac.output != nil && ac.output.atCapacity() { |
| 354 | - | if ac.output.deferIfAtCapacity(reset) { | 269 | + | // The coordinator owns the blocked interval; this guard only protects |
| 270 | + | // direct test/resize paint calls from destructive capture. | |||
| 355 | ac.sendMu.Unlock() | 271 | ac.sendMu.Unlock() | ||
| 356 | return | 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 | overlays := ac.overlays.SnapshotForRender() | 278 | overlays := ac.overlays.SnapshotForRender() | ||
| 359 | repaintAttachedClients := false | 279 | repaintAttachedClients := false | ||
| 360 | defer func() { | 280 | defer func() { | ||
| 281 | + | overlays.Unlock() | |||
| 282 | + | // Every return below has released sendMu before reaching this defer. | |||
| 283 | + | // Emit the captured sequence before any follow-up repaint can introduce | |||
| 284 | + | // another boundary, while no attachment, session, tab, or pane lock is held. | |||
| 285 | + | marks.flush() | |||
| 361 | if repaintAttachedClients { | 286 | if repaintAttachedClients { | ||
| 362 | d.repaintAllAttachedClients() | 287 | d.repaintAllAttachedClients() | ||
| 363 | } | 288 | } | ||
| 364 | }() | 289 | }() | ||
| 365 | - | defer overlays.Unlock() | |||
| 366 | preview := snapshotPickerPreview(nil) | 290 | preview := snapshotPickerPreview(nil) | ||
| 367 | if overlays.previewTab != tb { | 291 | if overlays.previewTab != tb { | ||
| 368 | preview = snapshotPickerPreview(overlays.previewTab) | 292 | preview = snapshotPickerPreview(overlays.previewTab) | ||
| @@ -375,11 +299,16 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||||
| 375 | attentionVisible := pulseVisible(bars.attentionFrame) | 299 | attentionVisible := pulseVisible(bars.attentionFrame) | ||
| 376 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | 300 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | ||
| 377 | 301 | ||||
| 378 | - | styles := newThemeStyles(ac.getTheme()) | |||
| 379 | paletteCfg := d.currentPaletteConfig() | 302 | paletteCfg := d.currentPaletteConfig() | ||
| 303 | + | floatingCfg := d.currentFloatingConfig() | |||
| 304 | + | // Title refresh may inspect process state, so it remains before the capture | |||
| 305 | + | // boundary and outside tab/pane ownership locks. | |||
| 380 | tb.mu.Lock() | 306 | tb.mu.Lock() | ||
| 381 | - | layoutSnap := solveTabLayoutLocked(tb) | 307 | + | titleIDs := ac.renderScratch.titleIDs[:0] |
| 382 | - | titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok) | 308 | + | if tb.tree != nil { |
| 309 | + | titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root) | |||
| 310 | + | } | |||
| 311 | + | ac.renderScratch.titleIDs = titleIDs | |||
| 383 | floating := tb.floating.pane | 312 | floating := tb.floating.pane | ||
| 384 | hasFloating := tb.floating.state == floatingVisible && floating != nil | 313 | hasFloating := tb.floating.state == floatingVisible && floating != nil | ||
| 385 | tb.mu.Unlock() | 314 | tb.mu.Unlock() | ||
| @@ -390,189 +319,35 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||||
| 390 | d.refreshPaneDisplayTitle(sess, floating, false) | 319 | d.refreshPaneDisplayTitle(sess, floating, false) | ||
| 391 | } | 320 | } | ||
| 392 | 321 | ||||
| 393 | - | tb.mu.Lock() | 322 | + | capturedOverlays := capturedOverlayRenderState{ |
| 394 | - | if !layoutSnap.matchesLocked(tb) { | 323 | + | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, |
| 395 | - | layoutSnap = solveTabLayoutLocked(tb) | 324 | + | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, |
| 325 | + | copyFeedback: overlays.copyFeedback, | |||
| 396 | } | 326 | } | ||
| 397 | - | p := tb.focusedPane() | 327 | + | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) |
| 398 | - | if p == nil { | 328 | + | state, ok := capturePrimaryRenderState(sess, ac, bars, capturedOverlays, preview, floatingCfg, reset, lease) |
| 399 | - | tb.mu.Unlock() | 329 | + | endCapture(0, ok) |
| 400 | - | ac.sendMu.Unlock() | 330 | + | if !ok { |
| 401 | - | return | |||
| 402 | - | } | |||
| 403 | - | // Recheck the slot after refreshing its title outside the tab lock. One | |||
| 404 | - | // paint uses one immutable floating config snapshot across composition, | |||
| 405 | - | // copy targeting, and cursor placement. | |||
| 406 | - | floating = tb.floating.pane | |||
| 407 | - | hasFloating = tb.floating.state == floatingVisible && floating != nil | |||
| 408 | - | floatingCfg := d.currentFloatingConfig() | |||
| 409 | - | overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | |||
| 410 | - | if reset || overlayActive { | |||
| 411 | - | ac.bars.Reset() | |||
| 412 | - | ac.composed.invalidate() | |||
| 413 | - | } | |||
| 414 | - | if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive { | |||
| 415 | - | ac.lastCursor.valid = false | |||
| 416 | - | } | |||
| 417 | - | var frame renderer.Frame | |||
| 418 | - | var damage []renderer.Damage | |||
| 419 | - | if overlayActive { | |||
| 420 | - | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, nil) | |||
| 421 | - | } else { | |||
| 422 | - | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed) | |||
| 423 | - | } | |||
| 424 | - | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | |||
| 425 | - | floatingFrameGeometry := floatingGeometry{} | |||
| 426 | - | if hasFloating { | |||
| 427 | - | desiredFloatingGeometry := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, floatingCfg) | |||
| 428 | - | frame, damage, floatingFrameGeometry = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, desiredFloatingGeometry, layoutSnap, bars.theme, &ac.composed, reset || overlayActive) | |||
| 429 | - | } | |||
| 430 | - | if overlays.copyActive { | |||
| 431 | - | copyPane := overlays.copyPane | |||
| 432 | - | if copyPane == nil { | |||
| 433 | - | copyPane = p | |||
| 434 | - | } | |||
| 435 | - | copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry) | |||
| 436 | - | frame, damage = composeCopyClientFrame(overlays.copyMode, overlays.copySnapshot, copyTarget, frame, bars) | |||
| 437 | - | } | |||
| 438 | - | // A palette above normal/copy content dims that composed content. When a | |||
| 439 | - | // floating pane is present its own backdrop already dims normal pane | |||
| 440 | - | // contents; applying the palette backdrop here would also dim the popup. | |||
| 441 | - | if overlays.paletteActive && !hasFloating { | |||
| 442 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme) | |||
| 443 | - | } | |||
| 444 | - | if overlays.copySearchModel != nil { | |||
| 445 | - | frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles) | |||
| 446 | - | } | |||
| 447 | - | if overlays.pickerActive { | |||
| 448 | - | if overlays.previewTab == tb { | |||
| 449 | - | if layoutSnap.ok && tb.tree != nil && tb.tree.Root != nil && tb.tree.Root.Kind != layout.Leaf { | |||
| 450 | - | previewFrame, _ := composeTabFrameWithLayout(tb, layoutSnap.area, themeui.Theme{}, layoutSnap) | |||
| 451 | - | preview = pickerPreviewFromFrame(previewFrame) | |||
| 452 | - | } else { | |||
| 453 | - | preview = pickerPreviewFromLockedTab(tb) | |||
| 454 | - | } | |||
| 455 | - | } | |||
| 456 | - | frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles) | |||
| 457 | - | } | |||
| 458 | - | if overlays.paletteActive { | |||
| 459 | - | guidance := "" | |||
| 460 | - | if overlays.paletteHints != nil { | |||
| 461 | - | guidance = overlays.paletteHints.Feedback | |||
| 462 | - | } | |||
| 463 | - | frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, guidance, styles) | |||
| 464 | - | } | |||
| 465 | - | if overlays.promptActive { | |||
| 466 | - | frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles) | |||
| 467 | - | } | |||
| 468 | - | overlays.Unlock() | |||
| 469 | - | cursorPane := p | |||
| 470 | - | cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id) | |||
| 471 | - | cursorContent.X += contentArea.X | |||
| 472 | - | cursorContent.Y += contentArea.Y | |||
| 473 | - | if hasFloating { | |||
| 474 | - | cursorPane = floating | |||
| 475 | - | cursorContent, cursorVisible = floatingFrameGeometry.Inner, floatingFrameGeometry.Inner.Width > 0 && floatingFrameGeometry.Inner.Height > 0 | |||
| 476 | - | } | |||
| 477 | - | cursorPane.mu.Lock() | |||
| 478 | - | desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive) | |||
| 479 | - | cursorPane.mu.Unlock() | |||
| 480 | - | ac.sess.mu.Lock() | |||
| 481 | - | if ac.sess.v != sess { | |||
| 482 | - | ac.sess.mu.Unlock() | |||
| 483 | - | tb.mu.Unlock() | |||
| 484 | ac.sendMu.Unlock() | 331 | ac.sendMu.Unlock() | ||
| 485 | return | 332 | return | ||
| 486 | } | 333 | } | ||
| 487 | - | prepared, err := ac.output.prepare(frame, damage, reset || overlayActive) | 334 | + | if ac.renderStages.capture != nil { |
| 488 | - | var data []byte | 335 | + | ac.renderStages.capture() |
| 489 | - | if err == nil { | |||
| 490 | - | data = prepared.data | |||
| 491 | - | } | |||
| 492 | - | var cursorTail []byte | |||
| 493 | - | if err == nil { | |||
| 494 | - | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | |||
| 495 | - | } | |||
| 496 | - | tb.mu.Unlock() | |||
| 497 | - | ||||
| 498 | - | var serr error | |||
| 499 | - | var sendTr ports.Transport | |||
| 500 | - | if err == nil { | |||
| 501 | - | data = append(data, cursorTail...) | |||
| 502 | - | if len(data) > 0 { | |||
| 503 | - | sendTr = ac.transport() | |||
| 504 | - | if sendTr == nil { | |||
| 505 | - | serr = errors.New("client transport is nil") | |||
| 506 | - | } else { | |||
| 507 | - | send := sendTr.Send | |||
| 508 | - | if async, ok := sendTr.(ports.AsyncTransport); ok { | |||
| 509 | - | send = async.SendAsync | |||
| 510 | - | } | |||
| 511 | - | serr = prepared.send(data, ac.echoAck.Load(), send) | |||
| 512 | - | } | |||
| 513 | - | } | |||
| 514 | - | } | |||
| 515 | - | ac.sess.mu.Unlock() | |||
| 516 | - | ac.sendMu.Unlock() | |||
| 517 | - | ||||
| 518 | - | if err != nil { | |||
| 519 | - | d.log.Error("render draw failed", "err", err, "session", sess.name) | |||
| 520 | - | return | |||
| 521 | - | } | |||
| 522 | - | if serr != nil { | |||
| 523 | - | d.detachOnSendError(sess, ac, sendTr) | |||
| 524 | } | 336 | } | ||
| 525 | - | } | 337 | + | if overlays.pickerActive && overlays.previewTab == tb { |
| 526 | - | 338 | + | previewState := *state | |
| 527 | - | func focusedPaneContentRect(layoutSnap tabLayoutSnapshot, id layout.PaneID) (domain.Rect, bool) { | 339 | + | previewState.overlays = capturedOverlayRenderState{} |
| 528 | - | if !layoutSnap.ok { | 340 | + | previewState.reset = true |
| 529 | - | return layoutSnap.area, layoutSnap.area.Width > 0 && layoutSnap.area.Height > 0 | 341 | + | state.preview = pickerPreviewFromCapturedRender(previewState) |
| 530 | } | 342 | } | ||
| 531 | - | for _, pl := range layoutSnap.placements { | 343 | + | captureOverlayLayers(state, overlays, paletteCfg) |
| 532 | - | if pl.ID == id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | 344 | + | endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0) |
| 533 | - | return pl.Content, true | 345 | + | composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch) |
| 534 | - | } | 346 | + | endCompose(0, true) |
| 347 | + | if ac.renderStages.compose != nil { | |||
| 348 | + | ac.renderStages.compose() | |||
| 535 | } | 349 | } | ||
| 536 | - | return domain.Rect{}, false | 350 | + | d.emitFrame(sess, ac, state, composed, &marks) |
| 537 | - | } | |||
| 538 | - | ||||
| 539 | - | // desiredCursorOut computes the terminal cursor state that should be shown to | |||
| 540 | - | // the client for the current pane placement and overlay mode. | |||
| 541 | - | func desiredCursorOut(s *vt.Screen, content domain.Rect, hide bool) cursorOut { | |||
| 542 | - | if hide || !s.CursorVisible() { | |||
| 543 | - | return cursorOut{hidden: true} | |||
| 544 | - | } | |||
| 545 | - | style, ok := s.CursorStyle() | |||
| 546 | - | if !ok { | |||
| 547 | - | style = 1 | |||
| 548 | - | } | |||
| 549 | - | return cursorOut{row: content.Y + s.CursorRow(), col: content.X + s.CursorCol(), style: style, hasStyle: true} | |||
| 550 | - | } | |||
| 551 | - | ||||
| 552 | - | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | |||
| 553 | - | changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle | |||
| 554 | - | if !changed { | |||
| 555 | - | return nil | |||
| 556 | - | } | |||
| 557 | - | prev := ac.lastCursor | |||
| 558 | - | ac.lastCursor = desired | |||
| 559 | - | ac.lastCursor.valid = true | |||
| 560 | - | if desired.hidden { | |||
| 561 | - | return []byte("\x1b[?25l") | |||
| 562 | - | } | |||
| 563 | - | var b []byte | |||
| 564 | - | b = append(b, "\x1b["...) | |||
| 565 | - | b = strconv.AppendInt(b, int64(desired.row+1), 10) | |||
| 566 | - | b = append(b, ';') | |||
| 567 | - | b = strconv.AppendInt(b, int64(desired.col+1), 10) | |||
| 568 | - | b = append(b, 'H') | |||
| 569 | - | if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle { | |||
| 570 | - | b = append(b, "\x1b["...) | |||
| 571 | - | b = strconv.AppendInt(b, int64(desired.style), 10) | |||
| 572 | - | b = append(b, " q"...) | |||
| 573 | - | } | |||
| 574 | - | b = append(b, "\x1b[?25h"...) | |||
| 575 | - | return b | |||
| 576 | } | 351 | } | ||
| 577 | 352 | ||||
| 578 | type themeStyles struct { | 353 | type themeStyles struct { | ||
| @@ -638,211 +413,6 @@ func (c *barCache) Reset() { | |||||
| 638 | c.bottom = nil | 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 | func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement { | 416 | func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement { | ||
| 847 | pl.Content.X += dx | 417 | pl.Content.X += dx | ||
| 848 | pl.Content.Y += dy | 418 | pl.Content.Y += dy | ||
| @@ -851,15 +421,6 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement { | |||||
| 851 | return pl | 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 | func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) { | 424 | func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) { | ||
| 864 | rows := min(r.Height, src.Height) | 425 | rows := min(r.Height, src.Height) | ||
| 865 | cols := min(r.Width, src.Width) | 426 | cols := min(r.Width, src.Width) | ||
| @@ -874,25 +435,6 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo | |||||
| 874 | } | 435 | } | ||
| 875 | } | 436 | } | ||
| 876 | 437 | ||||
| 877 | - | func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 { | |||
| 878 | - | styles := newThemeStyles(theme) | |||
| 879 | - | style := styles.border | |||
| 880 | - | if focused { | |||
| 881 | - | style = styles.statusBar | |||
| 882 | - | } else { | |||
| 883 | - | style = themeui.DimStyle(style, theme) | |||
| 884 | - | } | |||
| 885 | - | for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ { | |||
| 886 | - | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | |||
| 887 | - | } | |||
| 888 | - | p.mu.Lock() | |||
| 889 | - | title := p.displayTitleLocked() | |||
| 890 | - | generation := p.title.generation | |||
| 891 | - | p.mu.Unlock() | |||
| 892 | - | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | |||
| 893 | - | return generation | |||
| 894 | - | } | |||
| 895 | - | ||||
| 896 | func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) { | 438 | func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) { | ||
| 897 | if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 { | 439 | if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 { | ||
| 898 | return | 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 | require.True(t, paletteVisible, "palette must remain composed above copy mode") | 55 | require.True(t, paletteVisible, "palette must remain composed above copy mode") | ||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | + | func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) { | |||
| 59 | + | for _, tc := range []struct { | |||
| 60 | + | name string | |||
| 61 | + | clientSize domain.Size | |||
| 62 | + | }{ | |||
| 63 | + | {name: "matching outer size", clientSize: domain.Size{Cols: 80, Rows: 25}}, | |||
| 64 | + | {name: "different outer size", clientSize: domain.Size{Cols: 100, Rows: 40}}, | |||
| 65 | + | } { | |||
| 66 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 67 | + | pty, releasePTY := newBlockingPTY(t) | |||
| 68 | + | defer releasePTY() | |||
| 69 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |||
| 70 | + | ac.sendMu.Lock() | |||
| 71 | + | ac.size = tc.clientSize | |||
| 72 | + | ac.sendMu.Unlock() | |||
| 73 | + | ||||
| 74 | + | // A retained visible popup still needs activation warmup. When the | |||
| 75 | + | // outer terminal also changes, its completed transaction must cover | |||
| 76 | + | // that popup rather than emitting a second reset-producing frame. | |||
| 77 | + | floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8}) | |||
| 78 | + | installTestFloating(sess.activeTab(), floating, true) | |||
| 79 | + | ||||
| 80 | + | d.firstPaint(sess, ac, tc.clientSize) | |||
| 81 | + | ||||
| 82 | + | frame := awaitFrame(t, sends, ports.MsgOutput) | |||
| 83 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |||
| 84 | + | require.NoError(t, err) | |||
| 85 | + | require.Zero(t, output.BaseStateNum, "first paint must be a mandatory reset") | |||
| 86 | + | select { | |||
| 87 | + | case extra := <-sends: | |||
| 88 | + | t.Fatalf("first paint emitted duplicate frame after floating activation resize: %#v", extra) | |||
| 89 | + | default: | |||
| 90 | + | } | |||
| 91 | + | }) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 58 | func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | 95 | func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | ||
| 59 | p, release := newBlockingPTY(t) | 96 | p, release := newBlockingPTY(t) | ||
| 60 | defer release() | 97 | defer release() | ||
| @@ -84,7 +121,7 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||||
| 84 | client := vt.NewScreen(80, 25) | 121 | client := vt.NewScreen(80, 25) | ||
| 85 | pane := sess.tabs[0].focusedPane() | 122 | pane := sess.tabs[0].focusedPane() | ||
| 86 | pane.screen.Write([]byte("X")) | 123 | pane.screen.Write([]byte("X")) | ||
| 87 | - | d.paint(sess, ac, true) | 124 | + | d.paint(sess, ac, true, nil) |
| 88 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | 125 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | ||
| 89 | undimmed := client.Frame.At(0, 1) | 126 | undimmed := client.Frame.At(0, 1) | ||
| 90 | topBar := client.Frame.At(0, 0) | 127 | topBar := client.Frame.At(0, 0) | ||
| @@ -110,6 +147,451 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||||
| 110 | // stubClock returns timers whose channel never fires, so a scheduler under it | 147 | // stubClock returns timers whose channel never fires, so a scheduler under it | ||
| 111 | // blocks in its debounce loop until the session context is cancelled. Used by | 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 | func TestPTYWriteErrorIsLogged(t *testing.T) { | 595 | func TestPTYWriteErrorIsLogged(t *testing.T) { | ||
| 114 | var logs bytes.Buffer | 596 | var logs bytes.Buffer | ||
| 115 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil))) | 597 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil))) | ||
| @@ -130,56 +612,6 @@ func TestPTYWriteErrorIsLogged(t *testing.T) { | |||||
| 130 | } | 612 | } | ||
| 131 | } | 613 | } | ||
| 132 | 614 | ||||
| 133 | - | func TestAltXClosesNonFinalTabScheduler(t *testing.T) { | |||
| 134 | - | p1, releasePTY1 := newBlockingPTY(t) | |||
| 135 | - | p2, releasePTY2 := newBlockingPTY(t) | |||
| 136 | - | clk := &signalClock{called: make(chan struct{})} | |||
| 137 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2) | |||
| 138 | - | d.clock = clk | |||
| 139 | - | defer releasePTY1() | |||
| 140 | - | defer releasePTY2() | |||
| 141 | - | ||||
| 142 | - | d.sessWg.Add(1) | |||
| 143 | - | go d.scheduler(sess, sess.tabs[1], sess.tabs[1].focusedPane()) | |||
| 144 | - | sess.tabs[1].focusedPane().dirty <- struct{}{} | |||
| 145 | - | <-clk.called | |||
| 146 | - | ||||
| 147 | - | d.handleInput(sess, ac, []byte("\x1b2")) | |||
| 148 | - | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 149 | - | d.handleInput(sess, ac, []byte("CLT\r")) | |||
| 150 | - | ||||
| 151 | - | select { | |||
| 152 | - | case <-waitGroupDone(&d.sessWg): | |||
| 153 | - | case <-time.After(2 * time.Second): | |||
| 154 | - | t.Fatal("scheduler for removed tab did not exit") | |||
| 155 | - | } | |||
| 156 | - | require.Equal(t, 1, sessionCount(d)) | |||
| 157 | - | } | |||
| 158 | - | ||||
| 159 | - | func TestPTYEOFClosesNonFinalTabScheduler(t *testing.T) { | |||
| 160 | - | p1, releasePTY1 := newBlockingPTY(t) | |||
| 161 | - | p2, releasePTY2 := newBlockingPTY(t) | |||
| 162 | - | clk := &signalClock{called: make(chan struct{})} | |||
| 163 | - | d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2) | |||
| 164 | - | d.clock = clk | |||
| 165 | - | defer releasePTY1() | |||
| 166 | - | defer releasePTY2() | |||
| 167 | - | ||||
| 168 | - | d.sessWg.Add(2) | |||
| 169 | - | go d.scheduler(sess, sess.tabs[1], sess.tabs[1].focusedPane()) | |||
| 170 | - | go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane()) | |||
| 171 | - | sess.tabs[1].focusedPane().dirty <- struct{}{} | |||
| 172 | - | <-clk.called | |||
| 173 | - | releasePTY2() | |||
| 174 | - | ||||
| 175 | - | select { | |||
| 176 | - | case <-waitGroupDone(&d.sessWg): | |||
| 177 | - | case <-time.After(2 * time.Second): | |||
| 178 | - | t.Fatal("scheduler for EOF-removed tab did not exit") | |||
| 179 | - | } | |||
| 180 | - | require.Equal(t, 1, sessionCount(d)) | |||
| 181 | - | } | |||
| 182 | - | ||||
| 183 | func TestAltXClosesFinalTabAndDetaches(t *testing.T) { | 615 | func TestAltXClosesFinalTabAndDetaches(t *testing.T) { | ||
| 184 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | 616 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | ||
| 185 | defer releases[0]() | 617 | defer releases[0]() | ||
| @@ -262,87 +694,6 @@ func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) { | |||||
| 262 | d.sessWg.Wait() | 694 | d.sessWg.Wait() | ||
| 263 | } | 695 | } | ||
| 264 | 696 | ||||
| 265 | - | func TestSchedulerDebounceCoalesces(t *testing.T) { | |||
| 266 | - | mc := portsmocks.NewMockClock(t) | |||
| 267 | - | mc.EXPECT().Now().Return(time.Now()).Maybe() | |||
| 268 | - | mt := portsmocks.NewMockTimer(t) | |||
| 269 | - | timerCh := make(chan time.Time, 1) | |||
| 270 | - | newTimerCalled := make(chan struct{}, 4) | |||
| 271 | - | ||||
| 272 | - | mc.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer { | |||
| 273 | - | select { | |||
| 274 | - | case newTimerCalled <- struct{}{}: | |||
| 275 | - | default: | |||
| 276 | - | } | |||
| 277 | - | return mt | |||
| 278 | - | }).Maybe() | |||
| 279 | - | mt.EXPECT().C().Return(timerCh).Maybe() | |||
| 280 | - | mt.EXPECT().Stop().Return(true).Maybe() | |||
| 281 | - | ||||
| 282 | - | var outputs atomic.Int32 | |||
| 283 | - | gotOutput := make(chan struct{}, 1) | |||
| 284 | - | tr := portsmocks.NewMockTransport(t) | |||
| 285 | - | tr.EXPECT().Close().Return(nil).Maybe() | |||
| 286 | - | tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error { | |||
| 287 | - | if f.Type == ports.MsgOutput { | |||
| 288 | - | outputs.Add(1) | |||
| 289 | - | select { | |||
| 290 | - | case gotOutput <- struct{}{}: | |||
| 291 | - | default: | |||
| 292 | - | } | |||
| 293 | - | } | |||
| 294 | - | return nil | |||
| 295 | - | }).Maybe() | |||
| 296 | - | ||||
| 297 | - | d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil))) | |||
| 298 | - | sctx, cancel := context.WithCancel(context.Background()) | |||
| 299 | - | win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 5}) | |||
| 300 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |||
| 301 | - | ac.initOverlays() | |||
| 302 | - | sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |||
| 303 | - | ac.setSession(sess) | |||
| 304 | - | ||||
| 305 | - | win.mu.Lock() | |||
| 306 | - | win.focusedPane().screen.Write([]byte("hi")) | |||
| 307 | - | win.mu.Unlock() | |||
| 308 | - | ||||
| 309 | - | d.sessWg.Add(1) | |||
| 310 | - | go d.scheduler(sess, win, win.focusedPane()) | |||
| 311 | - | ||||
| 312 | - | // First dirty opens the debounce window. | |||
| 313 | - | win.focusedPane().dirty <- struct{}{} | |||
| 314 | - | <-newTimerCalled | |||
| 315 | - | ||||
| 316 | - | // A burst of further dirties inside the window must be absorbed. | |||
| 317 | - | for range 5 { | |||
| 318 | - | select { | |||
| 319 | - | case win.focusedPane().dirty <- struct{}{}: | |||
| 320 | - | default: | |||
| 321 | - | } | |||
| 322 | - | } | |||
| 323 | - | ||||
| 324 | - | // Fire the timer once: exactly one render. | |||
| 325 | - | timerCh <- time.Now() | |||
| 326 | - | <-gotOutput | |||
| 327 | - | ||||
| 328 | - | cancel() | |||
| 329 | - | d.sessWg.Wait() | |||
| 330 | - | require.Equal(t, int32(1), outputs.Load(), "N dirties in one tab must render exactly once") | |||
| 331 | - | } | |||
| 332 | - | ||||
| 333 | - | func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) { | |||
| 334 | - | delay := nextDebounceDelay(minDebounceInterval, 3) | |||
| 335 | - | require.Greater(t, delay, minDebounceInterval, "sustained flood should widen debounce") | |||
| 336 | - | delay = nextDebounceDelay(delay, 3) | |||
| 337 | - | require.Greater(t, delay, minDebounceInterval+debounceStep, "continued flood should keep adapting") | |||
| 338 | - | } | |||
| 339 | - | ||||
| 340 | - | func TestSchedulerAdaptiveDebounceResetsAfterQuietPeriod(t *testing.T) { | |||
| 341 | - | delay := nextDebounceDelay(minDebounceInterval, 2) | |||
| 342 | - | require.Greater(t, delay, minDebounceInterval) | |||
| 343 | - | require.Equal(t, minDebounceInterval, nextDebounceDelay(delay, 0), "isolated update after quiet window should restore idle latency") | |||
| 344 | - | } | |||
| 345 | - | ||||
| 346 | // --- resize ordering -------------------------------------------------------- | 697 | // --- resize ordering -------------------------------------------------------- | ||
| 347 | 698 | ||||
| 348 | func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | 699 | func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | ||
| @@ -372,14 +723,14 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||||
| 372 | d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4}) | 723 | d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4}) | ||
| 373 | require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0)) | 724 | require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0)) | ||
| 374 | require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1)) | 725 | require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1)) | ||
| 375 | - | require.Equal(t, 2, win.focusedPane().scrollback.Len()) | 726 | + | require.Equal(t, 2, win.focusedPane().history.Len()) |
| 376 | - | require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0))) | 727 | + | require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0))) |
| 377 | - | require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1))) | 728 | + | require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1))) |
| 378 | 729 | ||||
| 379 | d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6}) | 730 | d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6}) | ||
| 380 | require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0)) | 731 | require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0)) | ||
| 381 | require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1)) | 732 | require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1)) | ||
| 382 | - | require.Equal(t, 2, win.focusedPane().scrollback.Len()) | 733 | + | require.Equal(t, 2, win.focusedPane().history.Len()) |
| 383 | } | 734 | } | ||
| 384 | 735 | ||||
| 385 | func frameRowString(f renderer.Frame, y int) string { | 736 | func frameRowString(f renderer.Frame, y int) string { | ||
| @@ -451,39 +802,6 @@ func TestTranslatePaneDamagePreservesFullWidthScrollFastPathOnly(t *testing.T) { | |||||
| 451 | } | 802 | } | ||
| 452 | } | 803 | } | ||
| 453 | 804 | ||||
| 454 | - | func TestRenderClearsDamageOnEmittingPaneWhenDetached(t *testing.T) { | |||
| 455 | - | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |||
| 456 | - | focused := tb.focusedPane() | |||
| 457 | - | emitting := newPane("pane-2", nil, domain.Size{Cols: 10, Rows: 3}) | |||
| 458 | - | tb.panes[emitting.id] = emitting | |||
| 459 | - | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(focused.id), layout.NewLeaf(emitting.id)}} | |||
| 460 | - | tb.tree.Focus = focused.id | |||
| 461 | - | sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0} | |||
| 462 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 463 | - | ||||
| 464 | - | focused.screen.Write([]byte("a")) | |||
| 465 | - | emitting.screen.Write([]byte("b")) | |||
| 466 | - | d.render(sess, tb, emitting) | |||
| 467 | - | ||||
| 468 | - | require.NotEmpty(t, focused.screen.Damage(), "focused pane damage should be untouched by detached render for another pane") | |||
| 469 | - | require.Empty(t, emitting.screen.Damage(), "emitting pane damage should be drained") | |||
| 470 | - | } | |||
| 471 | - | ||||
| 472 | - | func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) { | |||
| 473 | - | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |||
| 474 | - | sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0} | |||
| 475 | - | ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 10, Rows: 4}} | |||
| 476 | - | ac.initOverlays() | |||
| 477 | - | sess.client = ac | |||
| 478 | - | ac.setSession(sess) | |||
| 479 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 480 | - | ||||
| 481 | - | tb.focusedPane().screen.Write([]byte("x")) | |||
| 482 | - | d.render(sess, tb, tb.focusedPane()) | |||
| 483 | - | ||||
| 484 | - | require.Zero(t, ac.output.next) | |||
| 485 | - | } | |||
| 486 | - | ||||
| 487 | func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) { | 805 | func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) { | ||
| 488 | win := newTab(nil, domain.Size{Cols: 41, Rows: 4}) | 806 | win := newTab(nil, domain.Size{Cols: 41, Rows: 4}) | ||
| 489 | left := win.focusedPane() | 807 | left := win.focusedPane() | ||
| @@ -735,7 +1053,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | |||||
| 735 | { | 1053 | { | ||
| 736 | name: "picker", | 1054 | name: "picker", | ||
| 737 | open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterPicker(sess, ac) }, | 1055 | open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterPicker(sess, ac) }, | ||
| 738 | - | close: func(d *Daemon, sess *session, ac *attachedClient) { d.closePicker(ac); d.paint(sess, ac, true) }, | 1056 | + | close: func(d *Daemon, sess *session, ac *attachedClient) { d.closePicker(ac); d.paint(sess, ac, true, nil) }, |
| 739 | visible: "Sessions", | 1057 | visible: "Sessions", | ||
| 740 | notVisible: "Sessions", | 1058 | notVisible: "Sessions", | ||
| 741 | }, | 1059 | }, | ||
| @@ -744,7 +1062,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | |||||
| 744 | open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) }, | 1062 | open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) }, | ||
| 745 | close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) }, | 1063 | close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) }, | ||
| 746 | prepareScreen: func(sess *session) { | 1064 | prepareScreen: func(sess *session) { | ||
| 747 | - | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | 1065 | + | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4}) |
| 748 | }, | 1066 | }, | ||
| 749 | visible: "[SCROLL]", | 1067 | visible: "[SCROLL]", | ||
| 750 | notVisible: "[SCROLL]", | 1068 | notVisible: "[SCROLL]", | ||
| @@ -756,10 +1074,10 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | |||||
| 756 | p, release := newBlockingPTY(t) | 1074 | p, release := newBlockingPTY(t) | ||
| 757 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 1075 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 758 | defer release() | 1076 | defer release() | ||
| 759 | - | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |||
| 760 | if tt.prepareScreen != nil { | 1077 | if tt.prepareScreen != nil { | ||
| 761 | tt.prepareScreen(sess) | 1078 | tt.prepareScreen(sess) | ||
| 762 | } | 1079 | } | ||
| 1080 | + | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |||
| 763 | if tt.wantRestored == "" { | 1081 | if tt.wantRestored == "" { | ||
| 764 | tt.wantRestored = "live" | 1082 | tt.wantRestored = "live" | ||
| 765 | } | 1083 | } | ||
| @@ -783,23 +1101,23 @@ func TestOverlayPaintBypassesComposedCache(t *testing.T) { | |||||
| 783 | pane := sess.tabs[0].focusedPane() | 1101 | pane := sess.tabs[0].focusedPane() | ||
| 784 | pane.screen.Write([]byte("live")) | 1102 | pane.screen.Write([]byte("live")) | ||
| 785 | 1103 | ||||
| 786 | - | d.paint(sess, ac, true) | 1104 | + | d.paint(sess, ac, true, nil) |
| 787 | _ = mustOutputData(t, sends) | 1105 | _ = mustOutputData(t, sends) | ||
| 788 | - | require.True(t, ac.composed.valid) | 1106 | + | require.True(t, ac.pipelineCache.valid) |
| 789 | - | require.NotContains(t, frameText(ac.composed.frame), "Rename session") | 1107 | + | require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session") |
| 790 | 1108 | ||||
| 791 | d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil }) | 1109 | d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil }) | ||
| 792 | shown := string(mustOutputData(t, sends)) | 1110 | shown := string(mustOutputData(t, sends)) | ||
| 793 | require.Contains(t, shown, "Rename session") | 1111 | require.Contains(t, shown, "Rename session") | ||
| 794 | - | require.False(t, ac.composed.valid, "overlay paint must not store the modal-mutated frame in the composed cache") | 1112 | + | require.False(t, ac.pipelineCache.valid, "overlay paint must not store the modal-mutated frame in the composed cache") |
| 795 | - | require.NotContains(t, frameText(ac.composed.frame), "Rename session") | 1113 | + | require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session") |
| 796 | 1114 | ||||
| 797 | d.closePrompt(ac) | 1115 | d.closePrompt(ac) | ||
| 798 | - | d.paint(sess, ac, false) | 1116 | + | d.paint(sess, ac, false, nil) |
| 799 | restored := string(mustOutputData(t, sends)) | 1117 | restored := string(mustOutputData(t, sends)) | ||
| 800 | require.NotContains(t, restored, "Rename session") | 1118 | require.NotContains(t, restored, "Rename session") | ||
| 801 | - | require.True(t, ac.composed.valid) | 1119 | + | require.True(t, ac.pipelineCache.valid) |
| 802 | - | cached := frameText(ac.composed.frame) | 1120 | + | cached := frameText(ac.pipelineCache.frame) |
| 803 | require.Contains(t, cached, "live") | 1121 | require.Contains(t, cached, "live") | ||
| 804 | require.NotContains(t, cached, "Rename session") | 1122 | require.NotContains(t, cached, "Rename session") | ||
| 805 | } | 1123 | } | ||
| @@ -934,7 +1252,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||||
| 934 | d.resize(sess, ac, newSize) | 1252 | d.resize(sess, ac, newSize) | ||
| 935 | // This test verifies resize ordering, not the idle fallback. Consume the | 1253 | // This test verifies resize ordering, not the idle fallback. Consume the | ||
| 936 | // pending resize paint deterministically under the non-firing stub clock. | 1254 | // pending resize paint deterministically under the non-firing stub clock. | ||
| 937 | - | d.paint(sess, ac, false) | 1255 | + | d.paint(sess, ac, false, nil) |
| 938 | 1256 | ||||
| 939 | require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize") | 1257 | require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize") | ||
| 940 | require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty") | 1258 | require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty") | ||
| @@ -965,7 +1283,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |||||
| 965 | win.focusedPane().screen.Write([]byte("x")) | 1283 | win.focusedPane().screen.Write([]byte("x")) | ||
| 966 | win.mu.Unlock() | 1284 | win.mu.Unlock() | ||
| 967 | 1285 | ||||
| 968 | - | d.paint(sess, ac, true) | 1286 | + | d.paint(sess, ac, true, nil) |
| 969 | 1287 | ||||
| 970 | require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send") | 1288 | require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send") | ||
| 971 | sess.mu.Lock() | 1289 | sess.mu.Lock() | ||
| @@ -977,75 +1295,6 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |||||
| 977 | d.waitNotifies() | 1295 | d.waitNotifies() | ||
| 978 | } | 1296 | } | ||
| 979 | 1297 | ||||
| 980 | - | func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) { | |||
| 981 | - | mc := portsmocks.NewMockClock(t) | |||
| 982 | - | mc.EXPECT().Now().Return(time.Now()).Maybe() | |||
| 983 | - | mt := portsmocks.NewMockTimer(t) | |||
| 984 | - | timerCh := make(chan time.Time, 1) | |||
| 985 | - | mc.EXPECT().NewTimer(mock.Anything).Return(mt).Maybe() | |||
| 986 | - | mt.EXPECT().C().Return(timerCh).Maybe() | |||
| 987 | - | mt.EXPECT().Stop().Return(true).Maybe() | |||
| 988 | - | ||||
| 989 | - | var outputs atomic.Int32 | |||
| 990 | - | gotOutput := make(chan struct{}, 1) | |||
| 991 | - | tr := portsmocks.NewMockTransport(t) | |||
| 992 | - | tr.EXPECT().Close().Return(nil).Maybe() | |||
| 993 | - | tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error { | |||
| 994 | - | if f.Type == ports.MsgOutput { | |||
| 995 | - | outputs.Add(1) | |||
| 996 | - | select { | |||
| 997 | - | case gotOutput <- struct{}{}: | |||
| 998 | - | default: | |||
| 999 | - | } | |||
| 1000 | - | } | |||
| 1001 | - | return nil | |||
| 1002 | - | }).Maybe() | |||
| 1003 | - | ||||
| 1004 | - | d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil))) | |||
| 1005 | - | sctx, cancel := context.WithCancel(context.Background()) | |||
| 1006 | - | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |||
| 1007 | - | win.ctx, win.cancel = sctx, cancel | |||
| 1008 | - | p := win.focusedPane() | |||
| 1009 | - | p.ctx, p.cancel = sctx, cancel | |||
| 1010 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |||
| 1011 | - | ac.initOverlays() | |||
| 1012 | - | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |||
| 1013 | - | ac.setSession(sess) | |||
| 1014 | - | ||||
| 1015 | - | p.mu.Lock() | |||
| 1016 | - | p.screen.Write([]byte("before")) | |||
| 1017 | - | p.mu.Unlock() | |||
| 1018 | - | ||||
| 1019 | - | d.sessWg.Add(1) | |||
| 1020 | - | go d.scheduler(sess, win, p) | |||
| 1021 | - | p.dirty <- struct{}{} | |||
| 1022 | - | ||||
| 1023 | - | p.mu.Lock() | |||
| 1024 | - | p.screen.Write([]byte("\x1b[?2026hafter")) | |||
| 1025 | - | p.mu.Unlock() | |||
| 1026 | - | timerCh <- time.Now() | |||
| 1027 | - | ||||
| 1028 | - | select { | |||
| 1029 | - | case <-gotOutput: | |||
| 1030 | - | t.Fatal("scheduler rendered while synchronized update was active") | |||
| 1031 | - | case <-time.After(20 * time.Millisecond): | |||
| 1032 | - | } | |||
| 1033 | - | require.Equal(t, int32(0), outputs.Load()) | |||
| 1034 | - | ||||
| 1035 | - | p.mu.Lock() | |||
| 1036 | - | p.screen.Write([]byte(" done\x1b[?2026l")) | |||
| 1037 | - | p.mu.Unlock() | |||
| 1038 | - | p.flush <- struct{}{} | |||
| 1039 | - | select { | |||
| 1040 | - | case <-gotOutput: | |||
| 1041 | - | case <-time.After(time.Second): | |||
| 1042 | - | t.Fatal("sync end did not flush deferred damage") | |||
| 1043 | - | } | |||
| 1044 | - | ||||
| 1045 | - | cancel() | |||
| 1046 | - | d.sessWg.Wait() | |||
| 1047 | - | } | |||
| 1048 | - | ||||
| 1049 | func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | 1298 | func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | ||
| 1050 | d := newTestDaemon(t, nil, stubClock{}) | 1299 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 1051 | p := portsmocks.NewMockPTY(t) | 1300 | p := portsmocks.NewMockPTY(t) | ||
| @@ -1089,185 +1338,25 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |||||
| 1089 | } | 1338 | } | ||
| 1090 | } | 1339 | } | ||
| 1091 | 1340 | ||||
| 1092 | - | func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testing.T) { | |||
| 1093 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 1094 | - | p := portsmocks.NewMockPTY(t) | |||
| 1095 | - | chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")} | |||
| 1096 | - | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |||
| 1097 | - | if len(chunks) == 0 { | |||
| 1098 | - | return 0, io.EOF | |||
| 1099 | - | } | |||
| 1100 | - | n := copy(buf, chunks[0]) | |||
| 1101 | - | chunks = chunks[1:] | |||
| 1102 | - | return n, nil | |||
| 1103 | - | }) | |||
| 1104 | - | p.EXPECT().Close().Return(nil).Maybe() | |||
| 1105 | - | ||||
| 1106 | - | sctx, cancel := context.WithCancel(context.Background()) | |||
| 1107 | - | win := newTestTabWithContext(p, sctx, cancel) | |||
| 1108 | - | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |||
| 1109 | - | d.sessions[sess.id] = sess | |||
| 1110 | - | d.sessWg.Add(1) | |||
| 1111 | - | d.ptyReader(sess, win, win.focusedPane()) | |||
| 1112 | - | ||||
| 1113 | - | select { | |||
| 1114 | - | case <-win.focusedPane().dirty: | |||
| 1115 | - | t.Fatal("same-read synchronized update end should request flush, not dirty") | |||
| 1116 | - | default: | |||
| 1117 | - | } | |||
| 1118 | - | select { | |||
| 1119 | - | case <-win.focusedPane().flush: | |||
| 1120 | - | case <-time.After(time.Second): | |||
| 1121 | - | t.Fatal("same-read synchronized update end did not request immediate flush") | |||
| 1122 | - | } | |||
| 1123 | - | } | |||
| 1124 | - | ||||
| 1125 | - | func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing.T) { | |||
| 1126 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 1127 | - | p := portsmocks.NewMockPTY(t) | |||
| 1128 | - | chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")} | |||
| 1129 | - | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |||
| 1130 | - | if len(chunks) == 0 { | |||
| 1131 | - | return 0, io.EOF | |||
| 1132 | - | } | |||
| 1133 | - | n := copy(buf, chunks[0]) | |||
| 1134 | - | chunks = chunks[1:] | |||
| 1135 | - | return n, nil | |||
| 1136 | - | }) | |||
| 1137 | - | p.EXPECT().Close().Return(nil).Maybe() | |||
| 1138 | - | ||||
| 1139 | - | sctx, cancel := context.WithCancel(context.Background()) | |||
| 1140 | - | win := newTestTabWithContext(p, sctx, cancel) | |||
| 1141 | - | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |||
| 1142 | - | d.sessions[sess.id] = sess | |||
| 1143 | - | d.sessWg.Add(1) | |||
| 1144 | - | d.ptyReader(sess, win, win.focusedPane()) | |||
| 1145 | - | ||||
| 1146 | - | select { | |||
| 1147 | - | case <-win.focusedPane().dirty: | |||
| 1148 | - | t.Fatal("dirty signaled while synchronized update was active") | |||
| 1149 | - | default: | |||
| 1150 | - | } | |||
| 1151 | - | select { | |||
| 1152 | - | case <-win.focusedPane().flush: | |||
| 1153 | - | case <-time.After(time.Second): | |||
| 1154 | - | t.Fatal("sync end did not request immediate flush") | |||
| 1155 | - | } | |||
| 1156 | - | } | |||
| 1157 | - | ||||
| 1158 | - | func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) { | |||
| 1159 | - | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 1160 | - | d := newTestDaemon(t, nil, clk) | |||
| 1161 | - | p := portsmocks.NewMockPTY(t) | |||
| 1162 | - | release := make(chan struct{}) | |||
| 1163 | - | read := 0 | |||
| 1164 | - | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |||
| 1165 | - | if read == 0 { | |||
| 1166 | - | read++ | |||
| 1167 | - | return copy(buf, []byte("\x1b[?2026habandoned")), nil | |||
| 1168 | - | } | |||
| 1169 | - | <-release | |||
| 1170 | - | return 0, io.EOF | |||
| 1171 | - | }) | |||
| 1172 | - | p.EXPECT().Close().Return(nil).Maybe() | |||
| 1173 | - | ||||
| 1174 | - | tr, sends := newCapturingTransport(t) | |||
| 1175 | - | sctx, cancel := context.WithCancel(context.Background()) | |||
| 1176 | - | win := newTestTabWithContext(p, sctx, cancel) | |||
| 1177 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |||
| 1178 | - | ac.initOverlays() | |||
| 1179 | - | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |||
| 1180 | - | ac.setSession(sess) | |||
| 1181 | - | d.sessions[sess.id] = sess | |||
| 1182 | - | d.sessWg.Add(2) | |||
| 1183 | - | go d.scheduler(sess, win, win.focusedPane()) | |||
| 1184 | - | go d.ptyReader(sess, win, win.focusedPane()) | |||
| 1185 | - | ||||
| 1186 | - | timer := <-clk.timers | |||
| 1187 | - | timer.ch <- time.Now() | |||
| 1188 | - | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1189 | - | ||||
| 1190 | - | win.mu.Lock() | |||
| 1191 | - | active := win.focusedPane().screen.SyncUpdateActive() | |||
| 1192 | - | got := screenLineText(win.focusedPane().screen, 0) | |||
| 1193 | - | win.mu.Unlock() | |||
| 1194 | - | require.False(t, active) | |||
| 1195 | - | require.Contains(t, got, "abandoned") | |||
| 1196 | - | ||||
| 1197 | - | close(release) | |||
| 1198 | - | cancel() | |||
| 1199 | - | d.sessWg.Wait() | |||
| 1200 | - | } | |||
| 1201 | - | ||||
| 1202 | - | func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) { | |||
| 1203 | - | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 1204 | - | d := newTestDaemon(t, nil, clk) | |||
| 1205 | - | p := portsmocks.NewMockPTY(t) | |||
| 1206 | - | release := make(chan struct{}) | |||
| 1207 | - | chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")} | |||
| 1208 | - | p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |||
| 1209 | - | if len(chunks) > 0 { | |||
| 1210 | - | n := copy(buf, chunks[0]) | |||
| 1211 | - | chunks = chunks[1:] | |||
| 1212 | - | return n, nil | |||
| 1213 | - | } | |||
| 1214 | - | <-release | |||
| 1215 | - | return 0, io.EOF | |||
| 1216 | - | }) | |||
| 1217 | - | p.EXPECT().Close().Return(nil).Maybe() | |||
| 1218 | - | ||||
| 1219 | - | tr, sends := newCapturingTransport(t) | |||
| 1220 | - | sctx, cancel := context.WithCancel(context.Background()) | |||
| 1221 | - | win := newTestTabWithContext(p, sctx, cancel) | |||
| 1222 | - | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |||
| 1223 | - | ac.initOverlays() | |||
| 1224 | - | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |||
| 1225 | - | ac.setSession(sess) | |||
| 1226 | - | d.sessions[sess.id] = sess | |||
| 1227 | - | d.sessWg.Add(2) | |||
| 1228 | - | go d.scheduler(sess, win, win.focusedPane()) | |||
| 1229 | - | go d.ptyReader(sess, win, win.focusedPane()) | |||
| 1230 | - | ||||
| 1231 | - | timer := <-clk.timers | |||
| 1232 | - | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1233 | - | timer.ch <- time.Now() | |||
| 1234 | - | ||||
| 1235 | - | select { | |||
| 1236 | - | case f := <-sends: | |||
| 1237 | - | if f.Type == ports.MsgOutput { | |||
| 1238 | - | t.Fatal("stale synchronized update watchdog produced an extra render") | |||
| 1239 | - | } | |||
| 1240 | - | case <-time.After(50 * time.Millisecond): | |||
| 1241 | - | } | |||
| 1242 | - | win.mu.Lock() | |||
| 1243 | - | active := win.focusedPane().screen.SyncUpdateActive() | |||
| 1244 | - | win.mu.Unlock() | |||
| 1245 | - | require.False(t, active) | |||
| 1246 | - | ||||
| 1247 | - | close(release) | |||
| 1248 | - | cancel() | |||
| 1249 | - | d.sessWg.Wait() | |||
| 1250 | - | } | |||
| 1251 | - | ||||
| 1252 | func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | 1341 | func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | ||
| 1253 | p, _ := newBlockingPTY(t) | 1342 | p, _ := newBlockingPTY(t) | ||
| 1254 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 1343 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 1255 | win := sess.tabs[0] | 1344 | win := sess.tabs[0] | ||
| 1256 | win.focusedPane().screen.Write([]byte("A")) | 1345 | win.focusedPane().screen.Write([]byte("A")) | ||
| 1257 | 1346 | ||||
| 1258 | - | d.paint(sess, ac, true) | 1347 | + | d.paint(sess, ac, true, nil) |
| 1259 | data := mustOutputData(t, sends) | 1348 | data := mustOutputData(t, sends) | ||
| 1260 | require.Contains(t, string(data), "\x1b[1 q") | 1349 | require.Contains(t, string(data), "\x1b[1 q") | ||
| 1261 | require.Contains(t, string(data), "\x1b[?25h") | 1350 | require.Contains(t, string(data), "\x1b[?25h") | ||
| 1262 | 1351 | ||||
| 1263 | win.focusedPane().screen.Write([]byte("\x1b[2;3H")) | 1352 | win.focusedPane().screen.Write([]byte("\x1b[2;3H")) | ||
| 1264 | - | d.paint(sess, ac, false) | 1353 | + | d.paint(sess, ac, false, nil) |
| 1265 | data = mustOutputData(t, sends) | 1354 | data = mustOutputData(t, sends) | ||
| 1266 | require.Contains(t, string(data), "\x1b[3;3H") | 1355 | require.Contains(t, string(data), "\x1b[3;3H") | ||
| 1267 | require.Contains(t, string(data), "\x1b[?25h") | 1356 | require.Contains(t, string(data), "\x1b[?25h") | ||
| 1268 | 1357 | ||||
| 1269 | win.focusedPane().screen.Write([]byte("\x1b[?25l")) | 1358 | win.focusedPane().screen.Write([]byte("\x1b[?25l")) | ||
| 1270 | - | d.paint(sess, ac, false) | 1359 | + | d.paint(sess, ac, false, nil) |
| 1271 | data = mustOutputData(t, sends) | 1360 | data = mustOutputData(t, sends) | ||
| 1272 | require.Contains(t, string(data), "\x1b[?25l") | 1361 | require.Contains(t, string(data), "\x1b[?25l") | ||
| 1273 | } | 1362 | } | ||
| @@ -1284,7 +1373,7 @@ func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) { | |||||
| 1284 | floating.popupGeometry = committed | 1373 | floating.popupGeometry = committed | ||
| 1285 | installTestFloating(sess.activeTab(), floating, true) | 1374 | installTestFloating(sess.activeTab(), floating, true) | ||
| 1286 | 1375 | ||||
| 1287 | - | d.paint(sess, ac, true) | 1376 | + | d.paint(sess, ac, true, nil) |
| 1288 | data := mustOutputData(t, sends) | 1377 | data := mustOutputData(t, sends) | ||
| 1289 | want := committed.translate(contentArea.X, contentArea.Y) | 1378 | want := committed.translate(contentArea.X, contentArea.Y) | ||
| 1290 | require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, 1)) | 1379 | require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, 1)) | ||
| @@ -1309,7 +1398,7 @@ func TestCursorTailUsesFocusedPanePlacement(t *testing.T) { | |||||
| 1309 | require.True(t, ok) | 1398 | require.True(t, ok) | ||
| 1310 | rightContent := placementContent(placements, right.id) | 1399 | rightContent := placementContent(placements, right.id) | ||
| 1311 | 1400 | ||||
| 1312 | - | d.paint(sess, ac, true) | 1401 | + | d.paint(sess, ac, true, nil) |
| 1313 | data := mustOutputData(t, sends) | 1402 | data := mustOutputData(t, sends) | ||
| 1314 | want := cursorCSI(rightContent.Y+right.screen.CursorRow()+2, rightContent.X+right.screen.CursorCol()+1) | 1403 | want := cursorCSI(rightContent.Y+right.screen.CursorRow()+2, rightContent.X+right.screen.CursorCol()+1) | ||
| 1315 | require.Contains(t, string(data), want) | 1404 | require.Contains(t, string(data), want) | ||
| @@ -1338,7 +1427,7 @@ func TestCursorTailUsesExpandedStackContentPlacement(t *testing.T) { | |||||
| 1338 | twoContent := placementContent(placements, two.id) | 1427 | twoContent := placementContent(placements, two.id) | ||
| 1339 | require.Greater(t, twoContent.Y, 0, "stack title bars should offset content") | 1428 | require.Greater(t, twoContent.Y, 0, "stack title bars should offset content") | ||
| 1340 | 1429 | ||||
| 1341 | - | d.paint(sess, ac, true) | 1430 | + | d.paint(sess, ac, true, nil) |
| 1342 | data := mustOutputData(t, sends) | 1431 | data := mustOutputData(t, sends) | ||
| 1343 | want := cursorCSI(twoContent.Y+two.screen.CursorRow()+2, twoContent.X+two.screen.CursorCol()+1) | 1432 | want := cursorCSI(twoContent.Y+two.screen.CursorRow()+2, twoContent.X+two.screen.CursorCol()+1) | ||
| 1344 | require.Contains(t, string(data), want) | 1433 | require.Contains(t, string(data), want) | ||
| @@ -1357,7 +1446,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||||
| 1357 | } | 1446 | } | ||
| 1358 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | 1447 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | ||
| 1359 | p.screen.Write([]byte("ab\r\ncd")) | 1448 | p.screen.Write([]byte("ab\r\ncd")) | ||
| 1360 | - | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | 1449 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame) |
| 1361 | mode := scopy.NewMode(document) | 1450 | mode := scopy.NewMode(document) | ||
| 1362 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | 1451 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | ||
| 1363 | 1452 | ||||
| @@ -1417,7 +1506,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) { | |||||
| 1417 | defer releaseFloating() | 1506 | defer releaseFloating() | ||
| 1418 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | 1507 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | ||
| 1419 | fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3}) | 1508 | fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3}) | ||
| 1420 | - | fp.scrollback.Append(testRow("flt-old")) | 1509 | + | fp.history.Append(testRow("flt-old")) |
| 1421 | fp.screen.Write([]byte("flt-live")) | 1510 | fp.screen.Write([]byte("flt-live")) | ||
| 1422 | installTestFloating(sess.activeTab(), fp, true) | 1511 | installTestFloating(sess.activeTab(), fp, true) | ||
| 1423 | 1512 | ||||
A internal/usecase/daemon/render_timer.go +90 -0
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // timerToken is the complete, immutable identity of one timer worker. A lane | |||
| 11 | + | // owns either this token or nothing; cancellation and completion are never | |||
| 12 | + | // retained separately where a stale callback could reacquire them. | |||
| 13 | + | type timerToken struct { | |||
| 14 | + | generation uint64 | |||
| 15 | + | timer ports.Timer | |||
| 16 | + | cancel chan struct{} | |||
| 17 | + | done chan struct{} | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // timerLane is coordinator-owned and must be accessed while renderCoordinator.mu | |||
| 21 | + | // is held. Detach removes the complete token before any external Timer call. | |||
| 22 | + | type timerLane struct { | |||
| 23 | + | generation uint64 | |||
| 24 | + | token *timerToken | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (l *timerLane) replaceLocked() (uint64, *timerToken) { | |||
| 28 | + | l.generation++ | |||
| 29 | + | return l.generation, l.detachLocked() | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (l *timerLane) detachLocked() *timerToken { | |||
| 33 | + | token := l.token | |||
| 34 | + | l.token = nil | |||
| 35 | + | if token != nil { | |||
| 36 | + | close(token.cancel) | |||
| 37 | + | } | |||
| 38 | + | return token | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (l *timerLane) publishLocked(generation uint64, timer ports.Timer) *timerToken { | |||
| 42 | + | if l.generation != generation || l.token != nil { | |||
| 43 | + | return nil | |||
| 44 | + | } | |||
| 45 | + | token := &timerToken{ | |||
| 46 | + | generation: generation, | |||
| 47 | + | timer: timer, | |||
| 48 | + | cancel: make(chan struct{}), | |||
| 49 | + | done: make(chan struct{}), | |||
| 50 | + | } | |||
| 51 | + | l.token = token | |||
| 52 | + | return token | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (l *timerLane) clearLocked(token *timerToken) bool { | |||
| 56 | + | if token == nil || l.generation != token.generation || l.token != token { | |||
| 57 | + | return false | |||
| 58 | + | } | |||
| 59 | + | l.token = nil | |||
| 60 | + | return true | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // timerSupervisor owns worker accounting. Workers are registered only while | |||
| 64 | + | // coordinator ownership is held and terminal teardown rejects registrations | |||
| 65 | + | // before Wait begins, so Add cannot race the terminal Wait. | |||
| 66 | + | type timerSupervisor struct{ workers sync.WaitGroup } | |||
| 67 | + | ||||
| 68 | + | func (s *timerSupervisor) startLocked(token *timerToken, timerC <-chan time.Time, fire func()) { | |||
| 69 | + | s.workers.Add(1) | |||
| 70 | + | go func() { | |||
| 71 | + | defer s.workers.Done() | |||
| 72 | + | defer close(token.done) | |||
| 73 | + | select { | |||
| 74 | + | case <-timerC: | |||
| 75 | + | fire() | |||
| 76 | + | case <-token.cancel: | |||
| 77 | + | } | |||
| 78 | + | }() | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (s *timerSupervisor) wait() { s.workers.Wait() } | |||
| 82 | + | ||||
| 83 | + | // stopDetachedTimer is intentionally non-blocking. Ordinary lifecycle and | |||
| 84 | + | // replacement transitions may cancel/stop stale workers but callback stacks | |||
| 85 | + | // never wait for a worker to finish. | |||
| 86 | + | func stopDetachedTimer(token *timerToken) { | |||
| 87 | + | if token != nil && token.timer != nil { | |||
| 88 | + | token.timer.Stop() | |||
| 89 | + | } | |||
| 90 | + | } | |||
A internal/usecase/daemon/render_timer_test.go +144 -0
| @@ -0,0 +1,144 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestTimerLaneDetachRemovesCompleteToken(t *testing.T) { | |||
| 14 | + | lane := timerLane{generation: 1} | |||
| 15 | + | timer := portsmocks.NewMockTimer(t) | |||
| 16 | + | token := lane.publishLocked(1, timer) | |||
| 17 | + | require.NotNil(t, token) | |||
| 18 | + | detached := lane.detachLocked() | |||
| 19 | + | require.Same(t, token, detached) | |||
| 20 | + | require.Nil(t, lane.token) | |||
| 21 | + | select { | |||
| 22 | + | case <-token.cancel: | |||
| 23 | + | default: | |||
| 24 | + | t.Fatal("detach did not cancel token") | |||
| 25 | + | } | |||
| 26 | + | require.Nil(t, lane.publishLocked(0, timer), "stale generation reacquired detached lane") | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestRenderDeadlineCallbackCanInvalidateAgainWithoutJoiningItself(t *testing.T) { | |||
| 30 | + | clock := newCoordinatorMockClock(t, 2) | |||
| 31 | + | ac := &attachedClient{} | |||
| 32 | + | wakeReturned := make(chan struct{}) | |||
| 33 | + | var rc *renderCoordinator | |||
| 34 | + | rc = newRenderCoordinator(renderCoordinatorOptions{ | |||
| 35 | + | clock: clock.clock, | |||
| 36 | + | wake: func(renderWake) { | |||
| 37 | + | require.True(t, rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "reentrant-wake-test"})) | |||
| 38 | + | close(wakeReturned) | |||
| 39 | + | }, | |||
| 40 | + | }) | |||
| 41 | + | rc.attach(ac) | |||
| 42 | + | t.Cleanup(func() { | |||
| 43 | + | rc.beginSessionTeardown().finish() | |||
| 44 | + | rc.waitForTimerWorkers() | |||
| 45 | + | }) | |||
| 46 | + | require.True(t, rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "initial-wake-test"})) | |||
| 47 | + | awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{} | |||
| 48 | + | awaitTestCompletion(t, wakeReturned, "timer callback deadlocked while publishing its next invalidation") | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestRenderTimerCallbacksCanReplaceTheirOwnLane(t *testing.T) { | |||
| 52 | + | cases := []struct { | |||
| 53 | + | name string | |||
| 54 | + | run func(*testing.T, *renderCoordinator, *coordinatorMockClock, *attachedClient) | |||
| 55 | + | }{ | |||
| 56 | + | { | |||
| 57 | + | name: "normal wake invalidates", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, ac *attachedClient) { | |||
| 58 | + | woke := make(chan struct{}) | |||
| 59 | + | rc.opts.wake = func(renderWake) { | |||
| 60 | + | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "table-normal"}) | |||
| 61 | + | close(woke) | |||
| 62 | + | } | |||
| 63 | + | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "table-normal"}) | |||
| 64 | + | awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{} | |||
| 65 | + | awaitTestCompletion(t, woke, "normal callback did not reenter invalidation") | |||
| 66 | + | }, | |||
| 67 | + | }, | |||
| 68 | + | { | |||
| 69 | + | name: "resize callback schedules resize", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, ac *attachedClient) { | |||
| 70 | + | lease := rc.attachmentLease(ac) | |||
| 71 | + | rc.scheduleResizeForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease, func(uint64) { | |||
| 72 | + | rc.scheduleResizeForLease(domain.Size{Cols: 81, Rows: 24}, ac, lease, func(uint64) {}) | |||
| 73 | + | }) | |||
| 74 | + | timer := awaitCoordinatorScheduledTimer(t, clock) | |||
| 75 | + | done := captureResizeCallbackDone(t, rc) | |||
| 76 | + | timer.ch <- time.Time{} | |||
| 77 | + | _ = awaitTestValue(t, clock.timers, "resize callback did not schedule replacement") | |||
| 78 | + | awaitTestCompletion(t, done, "resize callback did not complete") | |||
| 79 | + | }, | |||
| 80 | + | }, | |||
| 81 | + | { | |||
| 82 | + | name: "retry callback schedules retry", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, ac *attachedClient) { | |||
| 83 | + | lease := rc.attachmentLease(ac) | |||
| 84 | + | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | |||
| 85 | + | require.True(t, rc.resizeCurrentForLease(epoch, ac, lease, true)) | |||
| 86 | + | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { rc.scheduleResizeRetryForLease(epoch, ac, lease, func() {}) }) | |||
| 87 | + | awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{} | |||
| 88 | + | _ = awaitTestValue(t, clock.timers, "retry callback did not schedule replacement") | |||
| 89 | + | }, | |||
| 90 | + | }, | |||
| 91 | + | { | |||
| 92 | + | name: "sync force transitions sync generation", run: func(t *testing.T, rc *renderCoordinator, clock *coordinatorMockClock, _ *attachedClient) { | |||
| 93 | + | p := &pane{} | |||
| 94 | + | rc.noteSyncBegin(p, 1, func() { rc.noteSyncBegin(p, 2) }) | |||
| 95 | + | awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{} | |||
| 96 | + | _ = awaitTestValue(t, clock.timers, "sync force did not schedule replacement") | |||
| 97 | + | }, | |||
| 98 | + | }, | |||
| 99 | + | } | |||
| 100 | + | for _, tc := range cases { | |||
| 101 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 102 | + | clock := newCoordinatorMockClock(t, 4) | |||
| 103 | + | rc, ac := newRenderCoordinator(renderCoordinatorOptions{clock: clock.clock}), &attachedClient{} | |||
| 104 | + | rc.attach(ac) | |||
| 105 | + | tc.run(t, rc, clock, ac) | |||
| 106 | + | rc.beginSessionTeardown().finish() | |||
| 107 | + | rc.waitForTimerWorkers() | |||
| 108 | + | }) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestRenderTimerScheduleRacesTerminalTeardown(t *testing.T) { | |||
| 113 | + | for range 32 { | |||
| 114 | + | clock := newCoordinatorMockClock(t, 2) | |||
| 115 | + | rc, ac := newRenderCoordinator(renderCoordinatorOptions{clock: clock.clock}), &attachedClient{} | |||
| 116 | + | rc.attach(ac) | |||
| 117 | + | start := make(chan struct{}) | |||
| 118 | + | scheduled := make(chan struct{}) | |||
| 119 | + | go func() { | |||
| 120 | + | <-start | |||
| 121 | + | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, producer: "race"}) | |||
| 122 | + | close(scheduled) | |||
| 123 | + | }() | |||
| 124 | + | close(start) | |||
| 125 | + | cleanup := rc.beginSessionTeardown() | |||
| 126 | + | awaitTestCompletion(t, scheduled, "schedule raced terminal teardown") | |||
| 127 | + | cleanup.finish() | |||
| 128 | + | rc.waitForTimerWorkers() | |||
| 129 | + | rc.mu.Lock() | |||
| 130 | + | require.True(t, rc.torndown) | |||
| 131 | + | require.Nil(t, rc.normalLane.token) | |||
| 132 | + | rc.mu.Unlock() | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestTimerSupervisorCancelsNeverFiringWorker(t *testing.T) { | |||
| 137 | + | var lane timerLane | |||
| 138 | + | generation, _ := lane.replaceLocked() | |||
| 139 | + | token := lane.publishLocked(generation, portsmocks.NewMockTimer(t)) | |||
| 140 | + | var supervisor timerSupervisor | |||
| 141 | + | supervisor.startLocked(token, nil, func() { t.Fatal("cancelled worker fired") }) | |||
| 142 | + | lane.detachLocked() | |||
| 143 | + | supervisor.wait() | |||
| 144 | + | } | |||
M internal/usecase/daemon/resume.go +6 -5
| @@ -48,11 +48,11 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |||||
| 48 | if !d.prepareParkAttachment(sess, ac) { | 48 | if !d.prepareParkAttachment(sess, ac) { | ||
| 49 | return false | 49 | return false | ||
| 50 | } | 50 | } | ||
| 51 | - | // A parked attachment has no valid visible destination. Retire both the | 51 | + | // A parked attachment has no session owner. Release pane snapshots before |
| 52 | - | // deferred diff and any resize-idle callback before registering its grace | 52 | + | // publishing it in the daemon registry so headless pane/session teardown |
| 53 | - | // timer; resume performs a fresh full paint. | 53 | + | // cannot leave closed panes retained through its capture cache. This must |
| 54 | - | ac.cancelResizePaint() | 54 | + | // remain outside d.mu: sendMu is ordered before the daemon lock. |
| 55 | - | ac.clearPaintDeferred() | 55 | + | ac.clearCaptureFrames() |
| 56 | d.mu.Lock() | 56 | d.mu.Lock() | ||
| 57 | if d.closing { | 57 | if d.closing { | ||
| 58 | d.mu.Unlock() | 58 | d.mu.Unlock() | ||
| @@ -188,6 +188,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||||
| 188 | sess.client = ac | 188 | sess.client = ac | ||
| 189 | sess.terminal = terminalEnv{TrueColor: h.TrueColor} | 189 | sess.terminal = terminalEnv{TrueColor: h.TrueColor} | ||
| 190 | sess.mu.Unlock() | 190 | sess.mu.Unlock() | ||
| 191 | + | d.attachCoordinator(sess, nil, ac, false) | |||
| 191 | d.touchMRU(sess) | 192 | d.touchMRU(sess) | ||
| 192 | d.log.Info("client resumed", "session", sess.name) | 193 | d.log.Info("client resumed", "session", sess.name) | ||
| 193 | return sess, ac, true, nil | 194 | return sess, ac, true, nil | ||
M internal/usecase/daemon/resume_test.go +163 -1
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | + | "strings" | |||
| 5 | "sync" | 6 | "sync" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | "time" | 8 | "time" | ||
| @@ -10,6 +11,8 @@ import ( | |||||
| 10 | 11 | ||||
| 11 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 12 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 14 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 15 | + | "github.com/bnema/vev/pkg/vt" | |||
| 13 | ) | 16 | ) | ||
| 14 | 17 | ||||
| 15 | type closeTrackingTransport struct { | 18 | type closeTrackingTransport struct { | ||
| @@ -77,6 +80,110 @@ func TestNamedLinkLossParks(t *testing.T) { | |||||
| 77 | require.True(t, parked, "named resume-capable link loss is parked") | 80 | require.True(t, parked, "named resume-capable link loss is parked") | ||
| 78 | } | 81 | } | ||
| 79 | 82 | ||||
| 83 | + | func TestBoundedSendTimeoutCannotTargetResumedTransport(t *testing.T) { | |||
| 84 | + | pty, release := newBlockingPTY(t) | |||
| 85 | + | defer release() | |||
| 86 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 87 | + | oldTransport := &closeTrackingTransport{} | |||
| 88 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "bounded-send-resume", 0), oldTransport) | |||
| 89 | + | require.NoError(t, err) | |||
| 90 | + | token := ac.resumeToken | |||
| 91 | + | d.clientGone(sess, ac, oldTransport, false) | |||
| 92 | + | ||||
| 93 | + | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 94 | + | d.clock = clock | |||
| 95 | + | ac.sendMu.Lock() | |||
| 96 | + | expected := ac.transportSnapshot() | |||
| 97 | + | orphanDone := make(chan struct{}) | |||
| 98 | + | type boundedResult struct { | |||
| 99 | + | transport ports.Transport | |||
| 100 | + | err error | |||
| 101 | + | } | |||
| 102 | + | result := make(chan boundedResult, 1) | |||
| 103 | + | go func() { | |||
| 104 | + | transport, sendErr := d.boundedSendWithTimeout(time.Second, expected.transport, func() error { | |||
| 105 | + | defer close(orphanDone) | |||
| 106 | + | return ac.sendExpectedTransport(expected, frameDetached(ports.ReasonDetach)) | |||
| 107 | + | }) | |||
| 108 | + | result <- boundedResult{transport: transport, err: sendErr} | |||
| 109 | + | }() | |||
| 110 | + | ||||
| 111 | + | // The worker is parked behind sendMu, so it has not observed a transport. | |||
| 112 | + | // Expire its deadline before resuming this same attachment with a new link. | |||
| 113 | + | timer := <-clock.timers | |||
| 114 | + | timer.ch <- time.Time{} | |||
| 115 | + | got := <-result | |||
| 116 | + | require.ErrorIs(t, got.err, errSendTimedOut) | |||
| 117 | + | require.Same(t, oldTransport, got.transport) | |||
| 118 | + | require.NoError(t, ac.closeCapturedTransport(got.transport)) | |||
| 119 | + | ||||
| 120 | + | newTransport := &closeTrackingTransport{} | |||
| 121 | + | d.mu.Lock() | |||
| 122 | + | resumedSess, resumedAC, ok, err := d.resumeParkedLocked( | |||
| 123 | + | helloResumeCapable(ports.IntentResume, sess.name, token), | |||
| 124 | + | newTransport, | |||
| 125 | + | domain.Size{Cols: 80, Rows: 24}, | |||
| 126 | + | ) | |||
| 127 | + | d.mu.Unlock() | |||
| 128 | + | require.NoError(t, err) | |||
| 129 | + | require.True(t, ok) | |||
| 130 | + | require.Same(t, sess, resumedSess) | |||
| 131 | + | require.Same(t, ac, resumedAC) | |||
| 132 | + | ||||
| 133 | + | ac.sendMu.Unlock() | |||
| 134 | + | <-orphanDone | |||
| 135 | + | require.Empty(t, newTransport.Sends(), "orphaned send must not write to the resumed transport") | |||
| 136 | + | require.False(t, newTransport.Closed(), "orphaned send must not close the resumed transport") | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) { | |||
| 140 | + | pty, releasePTY := newBlockingPTY(t) | |||
| 141 | + | defer releasePTY() | |||
| 142 | + | clock := newCoordinatorMockClock(t, 4) | |||
| 143 | + | d := newTestDaemon(t, newFactorySeq(t, pty), clock.clock) | |||
| 144 | + | oldTransport := &closeTrackingTransport{} | |||
| 145 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "resume-welcome-gate", 0), oldTransport) | |||
| 146 | + | require.NoError(t, err) | |||
| 147 | + | token := ac.resumeToken | |||
| 148 | + | d.clientGone(sess, ac, oldTransport, false) | |||
| 149 | + | ||||
| 150 | + | tr := newWelcomeBlockingTransport(t) | |||
| 151 | + | done := make(chan struct{}) | |||
| 152 | + | resumeHello := helloResumeCapable(ports.IntentResume, sess.name, token) | |||
| 153 | + | go func() { | |||
| 154 | + | d.handleHello(tr.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(resumeHello)}) | |||
| 155 | + | close(done) | |||
| 156 | + | }() | |||
| 157 | + | ||||
| 158 | + | <-tr.welcomeEntered | |||
| 159 | + | welcome := <-tr.sends | |||
| 160 | + | require.Equal(t, ports.MsgWelcome, welcome.Type) | |||
| 161 | + | sess.mu.Lock() | |||
| 162 | + | resumed := sess.client | |||
| 163 | + | sess.mu.Unlock() | |||
| 164 | + | require.Same(t, ac, resumed) | |||
| 165 | + | ||||
| 166 | + | d.invalidateRender(sess, resumed, true, "resume-welcome-gate-test") | |||
| 167 | + | timer := awaitLatestCoordinatorTimer(t, clock) | |||
| 168 | + | rc := sess.renderCoordinator() | |||
| 169 | + | rc.mu.Lock() | |||
| 170 | + | workerDone := rc.normalLane.token.done | |||
| 171 | + | rc.mu.Unlock() | |||
| 172 | + | require.NotNil(t, workerDone) | |||
| 173 | + | timer.ch <- time.Time{} | |||
| 174 | + | awaitTestCompletion(t, workerDone, "coordinator deadline worker did not complete") | |||
| 175 | + | requireNoCoordinatorOutputFrame(t, tr.sends) | |||
| 176 | + | ||||
| 177 | + | tr.release() | |||
| 178 | + | output := awaitFrame(t, tr.sends, ports.MsgOutput) | |||
| 179 | + | first, err := ports.UnmarshalOutput(output.Payload) | |||
| 180 | + | require.NoError(t, err) | |||
| 181 | + | require.Zero(t, first.BaseStateNum) | |||
| 182 | + | tr.finish() | |||
| 183 | + | <-done | |||
| 184 | + | requireNoCoordinatorOutputFrame(t, tr.sends) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 80 | func TestEphemeralLinkLossParksAndResumes(t *testing.T) { | 187 | func TestEphemeralLinkLossParksAndResumes(t *testing.T) { | ||
| 81 | pty, release := newBlockingPTY(t) | 188 | pty, release := newBlockingPTY(t) | ||
| 82 | defer release() | 189 | defer release() | ||
| @@ -228,6 +335,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||||
| 228 | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr) | 335 | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr) | ||
| 229 | require.NoError(t, err) | 336 | require.NoError(t, err) | ||
| 230 | require.Same(t, ac, resumedAC) | 337 | require.Same(t, ac, resumedAC) | ||
| 338 | + | require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC))) | |||
| 231 | d.firstPaint(resumedSess, resumedAC, resumedAC.size) | 339 | d.firstPaint(resumedSess, resumedAC, resumedAC.size) | ||
| 232 | 340 | ||||
| 233 | sends := newTr.Sends() | 341 | sends := newTr.Sends() | ||
| @@ -239,7 +347,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||||
| 239 | resumedAC.ackOutputState(first.NewStateNum) | 347 | resumedAC.ackOutputState(first.NewStateNum) | ||
| 240 | 348 | ||||
| 241 | resumedSess.tabs[0].focusedPane().screen.Write([]byte("A")) | 349 | resumedSess.tabs[0].focusedPane().screen.Write([]byte("A")) | ||
| 242 | - | d.paint(resumedSess, resumedAC, false) | 350 | + | d.paint(resumedSess, resumedAC, false, nil) |
| 243 | sends = newTr.Sends() | 351 | sends = newTr.Sends() | ||
| 244 | require.Len(t, sends, 2) | 352 | require.Len(t, sends, 2) | ||
| 245 | second, err := ports.UnmarshalOutput(sends[1].Payload) | 353 | second, err := ports.UnmarshalOutput(sends[1].Payload) | ||
| @@ -247,6 +355,60 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||||
| 247 | require.Equal(t, first.NewStateNum, second.BaseStateNum) | 355 | require.Equal(t, first.NewStateNum, second.BaseStateNum) | ||
| 248 | } | 356 | } | ||
| 249 | 357 | ||||
| 358 | + | func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||
| 359 | + | pty, release := newBlockingPTY(t) | |||
| 360 | + | defer release() | |||
| 361 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |||
| 362 | + | oldTransport := &closeTrackingTransport{} | |||
| 363 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |||
| 364 | + | require.NoError(t, err) | |||
| 365 | + | ||||
| 366 | + | tb := sess.tabs[0] | |||
| 367 | + | survivor := tb.panes["pane-1"] | |||
| 368 | + | closed := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 23}) | |||
| 369 | + | tb.mu.Lock() | |||
| 370 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |||
| 371 | + | tb.panes[closed.id] = closed | |||
| 372 | + | d.applyLayoutLocked(tb) | |||
| 373 | + | tb.mu.Unlock() | |||
| 374 | + | survivor.screen.Write([]byte("survivor")) | |||
| 375 | + | closed.screen.Write([]byte("closed")) | |||
| 376 | + | d.paint(sess, ac, true, nil) | |||
| 377 | + | ||||
| 378 | + | ac.sendMu.Lock() | |||
| 379 | + | require.Contains(t, ac.captureFrames, closed, "fixture must render and capture the pane before parking") | |||
| 380 | + | ac.sendMu.Unlock() | |||
| 381 | + | token := ac.resumeToken | |||
| 382 | + | require.True(t, sess.detachIfCurrent(ac)) | |||
| 383 | + | require.True(t, d.parkAttachment(sess, ac)) | |||
| 384 | + | ||||
| 385 | + | // Headless close cannot find the parked attachment through sess.client. | |||
| 386 | + | // Its capture must already have been released before the attachment parked. | |||
| 387 | + | require.NoError(t, d.closePane(sess, tb, closed.id, nil, false)) | |||
| 388 | + | ac.sendMu.Lock() | |||
| 389 | + | require.NotContains(t, ac.captureFrames, closed, "parked attachment must not retain a pane closed while headless") | |||
| 390 | + | ac.sendMu.Unlock() | |||
| 391 | + | ||||
| 392 | + | newTransport := &closeTrackingTransport{} | |||
| 393 | + | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, sess.name, token), newTransport) | |||
| 394 | + | require.NoError(t, err) | |||
| 395 | + | require.Same(t, sess, resumedSess) | |||
| 396 | + | require.Same(t, ac, resumedAC) | |||
| 397 | + | require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC))) | |||
| 398 | + | d.firstPaint(resumedSess, resumedAC, resumedAC.size) | |||
| 399 | + | ||||
| 400 | + | sends := newTransport.Sends() | |||
| 401 | + | require.Len(t, sends, 1) | |||
| 402 | + | output, err := ports.UnmarshalOutput(sends[0].Payload) | |||
| 403 | + | require.NoError(t, err) | |||
| 404 | + | require.Zero(t, output.BaseStateNum, "resume must start with a complete frame") | |||
| 405 | + | terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows) | |||
| 406 | + | terminal.Write(output.Data) | |||
| 407 | + | contents := strings.Join(frameRows(terminal.Frame), "\n") | |||
| 408 | + | require.Contains(t, contents, "survivor", "resume first paint must contain current headless content") | |||
| 409 | + | require.NotContains(t, contents, "closed", "resume first paint must not contain closed pane content") | |||
| 410 | + | } | |||
| 411 | + | ||||
| 250 | func TestExplicitDetachDoesNotPark(t *testing.T) { | 412 | func TestExplicitDetachDoesNotPark(t *testing.T) { | ||
| 251 | pty, release := newBlockingPTY(t) | 413 | pty, release := newBlockingPTY(t) | ||
| 252 | defer release() | 414 | defer release() | ||
A internal/usecase/daemon/resume_wake_incarnation_test.go +68 -0
| @@ -0,0 +1,68 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // A wake can have passed coordinator scheduling before a link loss parks its | |||
| 13 | + | // attachment. Resume deliberately reuses that attachment object, so pointer | |||
| 14 | + | // identity alone must not let the old wake paint the new transport before its | |||
| 15 | + | // Welcome frame. | |||
| 16 | + | func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) { | |||
| 17 | + | pty, releasePTY := newBlockingPTY(t) | |||
| 18 | + | defer releasePTY() | |||
| 19 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |||
| 20 | + | ||||
| 21 | + | oldTransport := &closeTrackingTransport{} | |||
| 22 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | token := ac.resumeToken | |||
| 25 | + | rc := sess.renderCoordinator() | |||
| 26 | + | require.NotNil(t, rc) | |||
| 27 | + | require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac))) | |||
| 28 | + | ||||
| 29 | + | // Pause after dispatch but before the coordinator's normal wake callback | |||
| 30 | + | // reaches paint/sendMu. This is the precise stale-wake window. | |||
| 31 | + | originalWake := rc.opts.wake | |||
| 32 | + | wakeDispatched := make(chan struct{}) | |||
| 33 | + | releaseWake := make(chan struct{}) | |||
| 34 | + | rc.opts.wake = func(w renderWake) { | |||
| 35 | + | close(wakeDispatched) | |||
| 36 | + | <-releaseWake | |||
| 37 | + | originalWake(w) | |||
| 38 | + | } | |||
| 39 | + | wakeDone := make(chan struct{}) | |||
| 40 | + | go func() { | |||
| 41 | + | d.invalidateRenderNow(sess, ac, true, "resume-wake-test") | |||
| 42 | + | close(wakeDone) | |||
| 43 | + | }() | |||
| 44 | + | <-wakeDispatched | |||
| 45 | + | ||||
| 46 | + | d.clientGone(sess, ac, oldTransport, false) | |||
| 47 | + | newTransport := &closeTrackingTransport{} | |||
| 48 | + | resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, sess.name, token), newTransport, domain.Size{Cols: 80, Rows: 24}) | |||
| 49 | + | require.NoError(t, err) | |||
| 50 | + | require.True(t, ok) | |||
| 51 | + | require.Same(t, sess, resumedSess) | |||
| 52 | + | require.Same(t, ac, resumedAC) | |||
| 53 | + | ||||
| 54 | + | close(releaseWake) | |||
| 55 | + | <-wakeDone | |||
| 56 | + | require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome") | |||
| 57 | + | ac.sendMu.Lock() | |||
| 58 | + | staleWakeComposed := ac.pipelineCache.valid | |||
| 59 | + | ac.sendMu.Unlock() | |||
| 60 | + | require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow") | |||
| 61 | + | ||||
| 62 | + | // The new attachment incarnation accepts the required paint only after its | |||
| 63 | + | // replacement transport has completed Welcome. | |||
| 64 | + | rc.opts.wake = originalWake | |||
| 65 | + | require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac))) | |||
| 66 | + | d.firstPaint(sess, ac, ac.size) | |||
| 67 | + | require.NotEmpty(t, newTransport.Sends()) | |||
| 68 | + | } | |||
M internal/usecase/daemon/scrollback_test.go +15 -6
| @@ -14,29 +14,38 @@ func TestNewTabInitializesScrollback(t *testing.T) { | |||||
| 14 | wantRow string | 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 | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | 18 | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | ||
| 19 | wantLen: 1, | 19 | wantLen: 1, | ||
| 20 | wantRow: "AAAA", | 20 | wantRow: "AAAA", | ||
| 21 | }, | 21 | }, | ||
| 22 | + | { | |||
| 23 | + | name: "alternate screen evictions do not enter pane history", | |||
| 24 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"), | |||
| 25 | + | wantLen: 1, | |||
| 26 | + | wantRow: "AAAA", | |||
| 27 | + | }, | |||
| 22 | } | 28 | } | ||
| 23 | 29 | ||||
| 24 | for _, tt := range tests { | 30 | for _, tt := range tests { | ||
| 25 | t.Run(tt.name, func(t *testing.T) { | 31 | t.Run(tt.name, func(t *testing.T) { | ||
| 26 | win := newTab(nil, domain.Size{Cols: 4, Rows: 3}) | 32 | win := newTab(nil, domain.Size{Cols: 4, Rows: 3}) | ||
| 27 | - | if win.focusedPane().scrollback == nil { | 33 | + | if win.focusedPane().history == nil { |
| 28 | t.Fatal("scrollback is nil") | 34 | t.Fatal("scrollback is nil") | ||
| 29 | } | 35 | } | ||
| 30 | - | if win.focusedPane().scrollback.Cap() != defaultScrollbackRows { | 36 | + | if win.focusedPane().history != win.focusedPane().screen.History() { |
| 31 | - | t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows) | 37 | + | t.Fatal("pane history is not owned by its screen") |
| 38 | + | } | |||
| 39 | + | if win.focusedPane().history.Cap() != defaultScrollbackRows { | |||
| 40 | + | t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().history.Cap(), defaultScrollbackRows) | |||
| 32 | } | 41 | } | ||
| 33 | 42 | ||||
| 34 | win.focusedPane().screen.Write(tt.write) | 43 | win.focusedPane().screen.Write(tt.write) | ||
| 35 | 44 | ||||
| 36 | - | if got := win.focusedPane().scrollback.Len(); got != tt.wantLen { | 45 | + | if got := win.focusedPane().history.Len(); got != tt.wantLen { |
| 37 | t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen) | 46 | t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen) | ||
| 38 | } | 47 | } | ||
| 39 | - | row := win.focusedPane().scrollback.View().Row(0) | 48 | + | row := win.focusedPane().history.View().Row(0) |
| 40 | runes := make([]rune, len(row)) | 49 | runes := make([]rune, len(row)) | ||
| 41 | for i, c := range row { | 50 | for i, c := range row { | ||
| 42 | runes[i] = c.Rune | 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 | 1 | + | // Package daemon holds vev's server-side session multiplexer use case. |
| 2 | - | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |||
| 3 | - | // and VT screen, and the per-client debounced render scheduler. | |||
| 4 | - | // | |||
| 5 | - | // Concurrency model (sessions own one or more PTY-backed tabs): | |||
| 6 | - | // | |||
| 7 | - | // - Serve runs the accept loop. Each accepted connection is handled by its | |||
| 8 | - | // own goroutine (handleConn): it reads the first frame and routes it to a | |||
| 9 | - | // session create/attach, a list, or a kill. | |||
| 10 | - | // - Per session there are exactly two long-lived goroutines: the PTY reader | |||
| 11 | - | // (drains child output into the VT screen and pokes a cap-1 dirty channel) | |||
| 12 | - | // and the render scheduler (debounces dirties and paints the attached | |||
| 13 | - | // client). Both are tied to the session context and unwind when the | |||
| 14 | - | // session is killed (pty.Close unblocks the reader; ctx cancel stops the | |||
| 15 | - | // scheduler). | |||
| 16 | - | // - The daemon exits (Serve returns) when the last session is removed, or | |||
| 17 | - | // when the parent context is cancelled (graceful shutdown notifies any | |||
| 18 | - | // attached clients with ReasonServerShutdown). | |||
| 19 | - | // | |||
| 20 | - | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |||
| 21 | - | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |||
| 22 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | |||
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |||
| 24 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |||
| 25 | package daemon | 2 | package daemon | ||
| 26 | 3 | ||||
| 27 | import ( | 4 | import ( | ||
| @@ -67,15 +44,35 @@ type session struct { | |||||
| 67 | mruAt atomic.Uint64 | 44 | mruAt atomic.Uint64 | ||
| 68 | snapDirty atomic.Bool | 45 | snapDirty atomic.Bool | ||
| 69 | snapEligible atomic.Bool | 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 | // clipFiles records clipboard-image-transfer temp file paths (see | 60 | // clipFiles records clipboard-image-transfer temp file paths (see | ||
| 71 | // clipboard.go) written for this session, removed best-effort in | 61 | // clipboard.go) written for this session, removed best-effort in | ||
| 72 | // killSession. | 62 | // killSession. | ||
| 73 | clipFiles []string | 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 | // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. | 73 | // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. | ||
| 77 | type tab struct { | 74 | type tab struct { | ||
| 78 | - | mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, previewClient, and pane map membership | 75 | + | mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership |
| 79 | 76 | ||||
| 80 | stableID string | 77 | stableID string | ||
| 81 | name string | 78 | name string | ||
| @@ -88,9 +85,6 @@ type tab struct { | |||||
| 88 | // floating is independent from the normal layout tree and pane map. | 85 | // floating is independent from the normal layout tree and pane map. | ||
| 89 | floating floatingSlot | 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 | // attention fields are guarded by the owning session.mu. | 88 | // attention fields are guarded by the owning session.mu. | ||
| 95 | attention bool | 89 | attention bool | ||
| 96 | attentionAt time.Time | 90 | attentionAt time.Time | ||
| @@ -100,7 +94,7 @@ type tab struct { | |||||
| 100 | // attachedClient is a client currently attached to a session's tab. rend is | 94 | // attachedClient is a client currently attached to a session's tab. rend is | ||
| 101 | // its private renderer shadow (so each client gets output minimised against | 95 | // its private renderer shadow (so each client gets output minimised against | ||
| 102 | // what it has actually seen). sendMu serialises the two senders — the render | 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 | func (d *Daemon) touchMRU(sess *session) { | 99 | func (d *Daemon) touchMRU(sess *session) { | ||
| 106 | if d == nil || sess == nil { | 100 | if d == nil || sess == nil { | ||
| @@ -145,7 +139,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||||
| 145 | closeTabs(tabs) | 139 | closeTabs(tabs) | ||
| 146 | return nil, err | 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 | if err != nil { | 143 | if err != nil { | ||
| 150 | closeTabs(tabs) | 144 | closeTabs(tabs) | ||
| 151 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session") | 145 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session") | ||
| @@ -258,16 +252,19 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||||
| 258 | ac.setSession(nil) | 252 | ac.setSession(nil) | ||
| 259 | from.mu.Unlock() | 253 | from.mu.Unlock() | ||
| 260 | 254 | ||||
| 255 | + | // Prepare source invalidation, output rebase, and destination coordinator | |||
| 256 | + | // identity before publishing the destination attachment. | |||
| 257 | + | cleanups := d.handoffCoordinator(from, newSess, nil, ac) | |||
| 258 | + | ac.setSession(newSess) | |||
| 261 | newSess.mu.Lock() | 259 | newSess.mu.Lock() | ||
| 262 | newSess.client = ac | 260 | newSess.client = ac | ||
| 263 | newSess.mu.Unlock() | 261 | newSess.mu.Unlock() | ||
| 264 | 262 | ||||
| 265 | d.touchMRU(newSess) | 263 | d.touchMRU(newSess) | ||
| 266 | - | ac.setSession(newSess) | |||
| 267 | ac.recordPreviousSession(from) | 264 | ac.recordPreviousSession(from) | ||
| 268 | d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable) | 265 | d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable) | ||
| 269 | d.mu.Unlock() | 266 | d.mu.Unlock() | ||
| 270 | - | 267 | + | finishRenderLifecycleCleanups(cleanups) | |
| 271 | d.firstPaint(newSess, ac, sz) | 268 | d.firstPaint(newSess, ac, sz) | ||
| 272 | return nil | 269 | return nil | ||
| 273 | } | 270 | } | ||
| @@ -284,7 +281,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 284 | if err != nil { | 281 | if err != nil { | ||
| 285 | return err | 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 | if err != nil { | 285 | if err != nil { | ||
| 289 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | 286 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | ||
| 290 | return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err) | 287 | return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err) | ||
| @@ -418,9 +415,9 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | |||||
| 418 | sess.mu.Unlock() | 415 | sess.mu.Unlock() | ||
| 419 | d.log.Info("pane created", "session", name, "pane", p.id) | 416 | d.log.Info("pane created", "session", name, "pane", p.id) | ||
| 420 | } | 417 | } | ||
| 421 | - | d.sessWg.Add(2) | 418 | + | // Scheduler ownership was removed; this launch creates exactly one reader. |
| 419 | + | d.sessWg.Add(1) | |||
| 422 | go d.ptyReader(sess, tb, p) | 420 | go d.ptyReader(sess, tb, p) | ||
| 423 | - | go d.scheduler(sess, tb, p) | |||
| 424 | } | 421 | } | ||
| 425 | 422 | ||||
| 426 | // attachClient makes ac the session's current client, displacing any prior one | 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 | markSnapshotDirty(sess) | 634 | markSnapshotDirty(sess) | ||
| 638 | 635 | ||||
| 639 | d.clearDestroyedTabPreview(tb) | 636 | d.clearDestroyedTabPreview(tb) | ||
| 637 | + | tb.mu.Lock() | |||
| 638 | + | panes := tb.panesSnapshot() | |||
| 639 | + | tb.mu.Unlock() | |||
| 640 | + | if ac != nil { | |||
| 641 | + | ac.pruneCaptureFrames(panes...) | |||
| 642 | + | } | |||
| 640 | if tb.cancel != nil { | 643 | if tb.cancel != nil { | ||
| 641 | tb.cancel() | 644 | tb.cancel() | ||
| 642 | } | 645 | } | ||
| 643 | d.teardownFloating(tb, ac) | 646 | d.teardownFloating(tb, ac) | ||
| 647 | + | if rc := sess.renderCoordinator(); rc != nil { | |||
| 648 | + | for _, p := range panes { | |||
| 649 | + | rc.noteSyncPaneRemoved(p) | |||
| 650 | + | } | |||
| 651 | + | } | |||
| 644 | tb.closeAllPanes() | 652 | tb.closeAllPanes() | ||
| 645 | if wasActive { | 653 | if wasActive { | ||
| 646 | d.activateTab(sess, destination) | 654 | d.activateTab(sess, destination) | ||
| 647 | } | 655 | } | ||
| 648 | if repaint && ac != nil { | 656 | if repaint && ac != nil { | ||
| 649 | - | d.paint(sess, ac, true) | 657 | + | d.invalidateRender(sess, ac, true, "session.go") |
| 650 | } | 658 | } | ||
| 651 | if ringing { | 659 | if ringing { | ||
| 652 | d.repaintAllAttachedClients() | 660 | d.repaintAllAttachedClients() | ||
| @@ -664,7 +672,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 664 | if !purge && !isEphemeral && d.persistEnabled { | 672 | if !purge && !isEphemeral && d.persistEnabled { | ||
| 665 | d.refreshSessionCwd(sess) | 673 | d.refreshSessionCwd(sess) | ||
| 666 | } | 674 | } | ||
| 667 | - | var snapshotDeleteErr error | 675 | + | var snapshotDeleteErr, terminalSnapshotErr error |
| 668 | if d.snapsEnabled && !isEphemeral { | 676 | if d.snapsEnabled && !isEphemeral { | ||
| 669 | if purge { | 677 | if purge { | ||
| 670 | sess.mu.Lock() | 678 | sess.mu.Lock() | ||
| @@ -675,7 +683,16 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 675 | d.log.Warn("deleting session snapshot failed", "err", err, "session", name) | 683 | d.log.Warn("deleting session snapshot failed", "err", err, "session", name) | ||
| 676 | } | 684 | } | ||
| 677 | } else { | 685 | } else { | ||
| 678 | - | d.captureSession(sess) | 686 | + | markSnapshotDirty(sess) |
| 687 | + | if !d.scheduleFinalSnapshot(sess) { | |||
| 688 | + | sess.mu.Lock() | |||
| 689 | + | name := sess.name | |||
| 690 | + | sess.mu.Unlock() | |||
| 691 | + | terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable or saturated", name) | |||
| 692 | + | if reason != ports.ReasonServerShutdown { | |||
| 693 | + | return terminalSnapshotErr | |||
| 694 | + | } | |||
| 695 | + | } | |||
| 679 | } | 696 | } | ||
| 680 | } | 697 | } | ||
| 681 | d.mu.Lock() | 698 | d.mu.Lock() | ||
| @@ -731,13 +748,24 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 731 | ac := sess.client | 748 | ac := sess.client | ||
| 732 | sess.client = nil | 749 | sess.client = nil | ||
| 733 | sess.mu.Unlock() | 750 | sess.mu.Unlock() | ||
| 751 | + | if rc := sess.renderCoordinator(); rc != nil { | |||
| 752 | + | // Terminal teardown has two phases: this session owner first prevents any | |||
| 753 | + | // new worker registration and detaches all tokens, then stops and waits | |||
| 754 | + | // outside coordinator callbacks and session locks. | |||
| 755 | + | rc.beginSessionTeardown().finish() | |||
| 756 | + | rc.waitForTimerWorkers() | |||
| 757 | + | } | |||
| 734 | if ac != nil { | 758 | if ac != nil { | ||
| 735 | - | ac.cancelResizePaint() | |||
| 736 | d.unregisterPreview(ac) | 759 | d.unregisterPreview(ac) | ||
| 737 | ac.clearPreviousSession() | 760 | ac.clearPreviousSession() | ||
| 738 | ac.setSession(nil) | 761 | ac.setSession(nil) | ||
| 762 | + | ac.clearCaptureFrames() | |||
| 739 | } | 763 | } | ||
| 740 | 764 | ||||
| 765 | + | // Prevent queued launches from entering Open, then cancel the parent | |||
| 766 | + | // context. The launch worker owns any late PTY result and closes it rather | |||
| 767 | + | // than publishing it into the torn-down tab. | |||
| 768 | + | sess.stopFloatingLaunches() | |||
| 741 | sess.cancel() | 769 | sess.cancel() | ||
| 742 | sess.mu.Lock() | 770 | sess.mu.Lock() | ||
| 743 | tabs := append([]*tab(nil), sess.tabs...) | 771 | tabs := append([]*tab(nil), sess.tabs...) | ||
| @@ -763,7 +791,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 763 | if ac != nil { | 791 | if ac != nil { | ||
| 764 | d.notifyDetachedAsync(ac, reason) | 792 | d.notifyDetachedAsync(ac, reason) | ||
| 765 | } | 793 | } | ||
| 766 | - | return purgeErr | 794 | + | return errors.Join(purgeErr, terminalSnapshotErr) |
| 767 | } | 795 | } | ||
| 768 | 796 | ||||
| 769 | // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds | 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 | target := ac.previousSession.Get() | 11 | target := ac.previousSession.Get() | ||
| 12 | if target == nil || target == current || d.sessionByID(target.id) != target { | 12 | if target == nil || target == current || d.sessionByID(target.id) != target { | ||
| 13 | ac.clearPreviousSessionIf(target) | 13 | ac.clearPreviousSessionIf(target) | ||
| 14 | - | d.paint(current, ac, true) | 14 | + | d.invalidateRender(current, ac, true, "session_back.go") |
| 15 | return | 15 | return | ||
| 16 | } | 16 | } | ||
| 17 | d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1}) | 17 | d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1}) | ||
M internal/usecase/daemon/session_test.go +104 -51
| @@ -21,6 +21,11 @@ import ( | |||||
| 21 | ) | 21 | ) | ||
| 22 | 22 | ||||
| 23 | // --- test doubles ----------------------------------------------------------- | 23 | // --- test doubles ----------------------------------------------------------- | ||
| 24 | + | func expectFloatingPrewarmOpen(factory *portsmocks.MockPTYFactory, normalSize domain.Size, floating ports.PTY) { | |||
| 25 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | |||
| 26 | + | return got != normalSize && got.Valid() | |||
| 27 | + | })).Return(floating, nil).Maybe() | |||
| 28 | + | } | |||
| 24 | 29 | ||||
| 25 | // stubClock returns timers whose channel never fires, so a scheduler under it | 30 | // stubClock returns timers whose channel never fires, so a scheduler under it | ||
| 26 | // blocks in its debounce loop until the session context is cancelled. Used by | 31 | // blocks in its debounce loop until the session context is cancelled. Used by | ||
| @@ -34,8 +39,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) { | |||||
| 34 | f := portsmocks.NewMockPTYFactory(t) | 39 | f := portsmocks.NewMockPTYFactory(t) | ||
| 35 | var dirs []string | 40 | var dirs []string | ||
| 36 | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} | 41 | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} | ||
| 37 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | 42 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( |
| 38 | - | func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) { | 43 | + | func(_ context.Context, _ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) { |
| 39 | dirs = append(dirs, dir) | 44 | dirs = append(dirs, dir) | ||
| 40 | if len(dirs) == 1 { | 45 | if len(dirs) == 1 { | ||
| 41 | return first, nil | 46 | return first, nil | ||
| @@ -44,9 +49,7 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) { | |||||
| 44 | }, | 49 | }, | ||
| 45 | ).Twice() | 50 | ).Twice() | ||
| 46 | floating := newQuietPTY() | 51 | floating := newQuietPTY() | ||
| 47 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | 52 | + | expectFloatingPrewarmOpen(f, normalSize, floating) |
| 48 | - | return got != normalSize && got.Valid() | |||
| 49 | - | })).Return(floating, nil).Once() | |||
| 50 | 53 | ||||
| 51 | d := newTestDaemon(t, f, stubClock{}) | 54 | d := newTestDaemon(t, f, stubClock{}) | ||
| 52 | tr := portsmocks.NewMockTransport(t) | 55 | tr := portsmocks.NewMockTransport(t) | ||
| @@ -72,11 +75,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) { | |||||
| 72 | releaseFirst() | 75 | releaseFirst() | ||
| 73 | releaseSecond() | 76 | releaseSecond() | ||
| 74 | d.sessWg.Wait() | 77 | d.sessWg.Wait() | ||
| 75 | - | select { | 78 | + | // Teardown may cancel a queued prewarm before it enters Open; an opened |
| 76 | - | case <-floating.done: | 79 | + | // prewarm is closed by teardownFloating. |
| 77 | - | default: | |||
| 78 | - | t.Fatal("floating prewarm PTY was not closed") | |||
| 79 | - | } | |||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | func TestHandshakeEphemeralHappy(t *testing.T) { | 82 | func TestHandshakeEphemeralHappy(t *testing.T) { | ||
| @@ -202,7 +202,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||||
| 202 | p1, release1 := newBlockingPTY(t) | 202 | p1, release1 := newBlockingPTY(t) | ||
| 203 | defer release1() | 203 | defer release1() | ||
| 204 | p2 := portsmocks.NewMockPTY(t) | 204 | p2 := portsmocks.NewMockPTY(t) | ||
| 205 | - | opened := make(chan struct{}) | 205 | + | openCtx := make(chan context.Context, 1) |
| 206 | releaseOpen := make(chan struct{}) | 206 | releaseOpen := make(chan struct{}) | ||
| 207 | closed := make(chan struct{}) | 207 | closed := make(chan struct{}) | ||
| 208 | p2.EXPECT().Close().RunAndReturn(func() error { | 208 | p2.EXPECT().Close().RunAndReturn(func() error { | ||
| @@ -213,15 +213,16 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||||
| 213 | 213 | ||||
| 214 | f := portsmocks.NewMockPTYFactory(t) | 214 | f := portsmocks.NewMockPTYFactory(t) | ||
| 215 | normalSize := domain.Size{Cols: 80, Rows: 22} | 215 | normalSize := domain.Size{Cols: 80, Rows: 22} | ||
| 216 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once() | 216 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once() |
| 217 | floating := newQuietPTY() | 217 | floating := newQuietPTY() | ||
| 218 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | 218 | + | expectFloatingPrewarmOpen(f, normalSize, floating) |
| 219 | - | return got != normalSize && got.Valid() | 219 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( |
| 220 | - | })).Return(floating, nil).Once() | 220 | + | func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) { |
| 221 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | 221 | + | openCtx <- ctx |
| 222 | - | func(string, []string, []string, string, domain.Size) (ports.PTY, error) { | 222 | + | select { |
| 223 | - | close(opened) | 223 | + | case <-ctx.Done(): |
| 224 | - | <-releaseOpen | 224 | + | case <-releaseOpen: |
| 225 | + | } | |||
| 225 | return p2, nil | 226 | return p2, nil | ||
| 226 | }, | 227 | }, | ||
| 227 | ).Once() | 228 | ).Once() | ||
| @@ -237,10 +238,20 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||||
| 237 | 238 | ||||
| 238 | errCh := make(chan error, 1) | 239 | errCh := make(chan error, 1) | ||
| 239 | go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }() | 240 | go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }() | ||
| 240 | - | <-opened | 241 | + | ctx := <-openCtx |
| 241 | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | 242 | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | ||
| 243 | + | ||||
| 244 | + | cancelled := true | |||
| 245 | + | select { | |||
| 246 | + | case <-ctx.Done(): | |||
| 247 | + | case <-time.After(time.Second): | |||
| 248 | + | cancelled = false | |||
| 249 | + | } | |||
| 242 | close(releaseOpen) | 250 | close(releaseOpen) | ||
| 243 | 251 | ||||
| 252 | + | if !cancelled { | |||
| 253 | + | t.Error("killSession did not cancel the context passed to PTYFactory.Open") | |||
| 254 | + | } | |||
| 244 | require.Error(t, <-errCh) | 255 | require.Error(t, <-errCh) | ||
| 245 | select { | 256 | select { | ||
| 246 | case <-closed: | 257 | case <-closed: | ||
| @@ -251,11 +262,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||||
| 251 | releaseConn() | 262 | releaseConn() | ||
| 252 | hg.Wait() | 263 | hg.Wait() | ||
| 253 | d.sessWg.Wait() | 264 | d.sessWg.Wait() | ||
| 254 | - | select { | 265 | + | // A queued prewarm may be cancelled before Open during teardown. |
| 255 | - | case <-floating.done: | |||
| 256 | - | default: | |||
| 257 | - | t.Fatal("floating prewarm PTY was not closed") | |||
| 258 | - | } | |||
| 259 | } | 266 | } | ||
| 260 | 267 | ||||
| 261 | // --- ephemeral numbering ---------------------------------------------------- | 268 | // --- ephemeral numbering ---------------------------------------------------- | ||
| @@ -508,24 +515,52 @@ func TestServeGracefulShutdownOnContextCancel(t *testing.T) { | |||||
| 508 | // --- shutdown/create interlock ------------------------------------------------ | 515 | // --- shutdown/create interlock ------------------------------------------------ | ||
| 509 | 516 | ||||
| 510 | // TestHelloRacingShutdownIsRejected covers the interlock between the | 517 | // TestHelloRacingShutdownIsRejected covers the interlock between the | ||
| 511 | - | // registry-empty shutdown decision and session creation: once killSession has | 518 | + | // registry-empty shutdown decision and session creation. In particular, the |
| 512 | - | // removed the last session (making shutdown irreversible via doneOnce), a | 519 | + | // old `.Once()` factory expectation was wrong: firstPaint legitimately queues |
| 513 | - | // Hello landing at any later instant must be rejected cleanly — never spawn a | 520 | + | // a floating prewarm, so an Open that starts before killSession is valid work, |
| 514 | - | // child that no teardown pass would reap. The interleaving is deterministic: | 521 | + | // not a leaked post-shutdown child. The factory markers classify timing at the |
| 515 | - | // killSession runs to completion before the racing Hellos are handled, which | 522 | + | // Open boundary: this test first observes that valid prewarm, then verifies |
| 516 | - | // models the worst case (insertion attempt after the shutdown snapshot). | 523 | + | // that d.done (closed synchronously by killSession) is never followed by an |
| 524 | + | // Open start. | |||
| 517 | func TestHelloRacingShutdownIsRejected(t *testing.T) { | 525 | func TestHelloRacingShutdownIsRejected(t *testing.T) { | ||
| 518 | p, releasePTY := newBlockingPTY(t) | 526 | p, releasePTY := newBlockingPTY(t) | ||
| 527 | + | floating := newQuietPTY() | |||
| 519 | f := portsmocks.NewMockPTYFactory(t) | 528 | f := portsmocks.NewMockPTYFactory(t) | ||
| 520 | - | // Exactly one child may ever be spawned; a second Open call (a leaked | 529 | + | normalSize := domain.Size{Cols: 80, Rows: 22} |
| 521 | - | // child) fails the test via mock expectations. | 530 | + | preShutdownFloatingOpen := make(chan struct{}, 1) |
| 522 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once() | 531 | + | var opensAfterShutdown atomic.Int32 |
| 523 | d := newTestDaemon(t, f, stubClock{}) | 532 | d := newTestDaemon(t, f, stubClock{}) | ||
| 533 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 534 | + | func(_ context.Context, _ string, _ []string, _ []string, _ string, size domain.Size) (ports.PTY, error) { | |||
| 535 | + | select { | |||
| 536 | + | case <-d.done: | |||
| 537 | + | opensAfterShutdown.Add(1) | |||
| 538 | + | default: | |||
| 539 | + | } | |||
| 540 | + | if size == normalSize { | |||
| 541 | + | return p, nil | |||
| 542 | + | } | |||
| 543 | + | select { | |||
| 544 | + | case preShutdownFloatingOpen <- struct{}{}: | |||
| 545 | + | default: | |||
| 546 | + | } | |||
| 547 | + | return floating, nil | |||
| 548 | + | }, | |||
| 549 | + | ).Maybe() | |||
| 524 | 550 | ||||
| 525 | tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24})) | 551 | tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24})) | ||
| 526 | var hg sync.WaitGroup | 552 | var hg sync.WaitGroup | ||
| 527 | hg.Go(func() { d.handleConn(tr1) }) | 553 | hg.Go(func() { d.handleConn(tr1) }) | ||
| 528 | awaitFrame(t, sends1, ports.MsgWelcome) | 554 | awaitFrame(t, sends1, ports.MsgWelcome) | ||
| 555 | + | // firstPaint starts the background prewarm asynchronously. It is valid for | |||
| 556 | + | // this Open to occur before shutdown, so do not mistake it for the racing | |||
| 557 | + | // Hello's forbidden launch. | |||
| 558 | + | awaitFrame(t, sends1, ports.MsgOutput) | |||
| 559 | + | select { | |||
| 560 | + | case <-preShutdownFloatingOpen: | |||
| 561 | + | case <-time.After(time.Second): | |||
| 562 | + | t.Fatal("legitimate pre-shutdown floating prewarm did not start") | |||
| 563 | + | } | |||
| 529 | sess := firstSession(d) | 564 | sess := firstSession(d) | ||
| 530 | require.NotNil(t, sess) | 565 | require.NotNil(t, sess) | ||
| 531 | 566 | ||||
| @@ -555,12 +590,14 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) { | |||||
| 555 | require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name) | 590 | require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name) | ||
| 556 | } | 591 | } | ||
| 557 | require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began") | 592 | require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began") | ||
| 593 | + | require.Zero(t, opensAfterShutdown.Load(), "no PTYFactory.Open may start after d.done/shutdown completion") | |||
| 558 | 594 | ||||
| 559 | release1() | 595 | release1() | ||
| 560 | releasePTY() | 596 | releasePTY() | ||
| 561 | hg.Wait() | 597 | hg.Wait() | ||
| 562 | d.sessWg.Wait() | 598 | d.sessWg.Wait() | ||
| 563 | d.waitNotifies() | 599 | d.waitNotifies() | ||
| 600 | + | require.Zero(t, opensAfterShutdown.Load(), "racing Hellos must not start a PTY after shutdown") | |||
| 564 | } | 601 | } | ||
| 565 | 602 | ||||
| 566 | // --- wedged-client teardown --------------------------------------------------- | 603 | // --- wedged-client teardown --------------------------------------------------- | ||
| @@ -1090,8 +1127,8 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) { | |||||
| 1090 | 1127 | ||||
| 1091 | var gotEnv []string | 1128 | var gotEnv []string | ||
| 1092 | f := portsmocks.NewMockPTYFactory(t) | 1129 | f := portsmocks.NewMockPTYFactory(t) | ||
| 1093 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 1130 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( |
| 1094 | - | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | 1131 | + | func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { |
| 1095 | gotEnv = append([]string(nil), env...) | 1132 | gotEnv = append([]string(nil), env...) | ||
| 1096 | return p, nil | 1133 | return p, nil | ||
| 1097 | }, | 1134 | }, | ||
| @@ -1243,8 +1280,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||||
| 1243 | var opens [][]string | 1280 | var opens [][]string | ||
| 1244 | f := portsmocks.NewMockPTYFactory(t) | 1281 | f := portsmocks.NewMockPTYFactory(t) | ||
| 1245 | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} | 1282 | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} | ||
| 1246 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | 1283 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( |
| 1247 | - | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | 1284 | + | func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { |
| 1248 | opens = append(opens, append([]string(nil), env...)) | 1285 | opens = append(opens, append([]string(nil), env...)) | ||
| 1249 | if len(opens) == 1 { | 1286 | if len(opens) == 1 { | ||
| 1250 | return p1, nil | 1287 | return p1, nil | ||
| @@ -1253,9 +1290,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||||
| 1253 | }, | 1290 | }, | ||
| 1254 | ).Twice() | 1291 | ).Twice() | ||
| 1255 | floating := newQuietPTY() | 1292 | floating := newQuietPTY() | ||
| 1256 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | 1293 | + | expectFloatingPrewarmOpen(f, normalSize, floating) |
| 1257 | - | return got != normalSize && got.Valid() | |||
| 1258 | - | })).Return(floating, nil).Once() | |||
| 1259 | 1294 | ||||
| 1260 | d := newTestDaemon(t, f, stubClock{}) | 1295 | d := newTestDaemon(t, f, stubClock{}) | ||
| 1261 | d.baseEnv = []string{"KEEP=1", "COLORTERM=old"} | 1296 | d.baseEnv = []string{"KEEP=1", "COLORTERM=old"} | ||
| @@ -1288,8 +1323,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||||
| 1288 | var opens [][]string | 1323 | var opens [][]string | ||
| 1289 | f := portsmocks.NewMockPTYFactory(t) | 1324 | f := portsmocks.NewMockPTYFactory(t) | ||
| 1290 | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} | 1325 | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} | ||
| 1291 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | 1326 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( |
| 1292 | - | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | 1327 | + | func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { |
| 1293 | opens = append(opens, append([]string(nil), env...)) | 1328 | opens = append(opens, append([]string(nil), env...)) | ||
| 1294 | if len(opens) == 1 { | 1329 | if len(opens) == 1 { | ||
| 1295 | return p1, nil | 1330 | return p1, nil | ||
| @@ -1298,9 +1333,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||||
| 1298 | }, | 1333 | }, | ||
| 1299 | ).Twice() | 1334 | ).Twice() | ||
| 1300 | floating := newQuietPTY() | 1335 | floating := newQuietPTY() | ||
| 1301 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | 1336 | + | expectFloatingPrewarmOpen(f, normalSize, floating) |
| 1302 | - | return got != normalSize && got.Valid() | |||
| 1303 | - | })).Return(floating, nil).Once() | |||
| 1304 | 1337 | ||||
| 1305 | d := newTestDaemon(t, f, stubClock{}) | 1338 | d := newTestDaemon(t, f, stubClock{}) | ||
| 1306 | tr1 := portsmocks.NewMockTransport(t) | 1339 | tr1 := portsmocks.NewMockTransport(t) | ||
| @@ -1335,8 +1368,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 1335 | var opens [][]string | 1368 | var opens [][]string | ||
| 1336 | f := portsmocks.NewMockPTYFactory(t) | 1369 | f := portsmocks.NewMockPTYFactory(t) | ||
| 1337 | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} | 1370 | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} | ||
| 1338 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | 1371 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( |
| 1339 | - | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | 1372 | + | func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { |
| 1340 | opens = append(opens, append([]string(nil), env...)) | 1373 | opens = append(opens, append([]string(nil), env...)) | ||
| 1341 | if len(opens) == 1 { | 1374 | if len(opens) == 1 { | ||
| 1342 | return p1, nil | 1375 | return p1, nil | ||
| @@ -1345,18 +1378,38 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 1345 | }, | 1378 | }, | ||
| 1346 | ).Twice() | 1379 | ).Twice() | ||
| 1347 | floating := newQuietPTY() | 1380 | floating := newQuietPTY() | ||
| 1348 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | 1381 | + | expectFloatingPrewarmOpen(f, normalSize, floating) |
| 1349 | - | return got != normalSize && got.Valid() | |||
| 1350 | - | })).Return(floating, nil).Once() | |||
| 1351 | d := newTestDaemon(t, f, stubClock{}) | 1382 | d := newTestDaemon(t, f, stubClock{}) | ||
| 1352 | tr := portsmocks.NewMockTransport(t) | 1383 | tr := portsmocks.NewMockTransport(t) | ||
| 1353 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | 1384 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | ||
| 1354 | tr.EXPECT().Close().Return(nil).Maybe() | 1385 | tr.EXPECT().Close().Return(nil).Maybe() | ||
| 1355 | sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr) | 1386 | sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr) | ||
| 1356 | require.NoError(t, err) | 1387 | require.NoError(t, err) | ||
| 1388 | + | // The source coordinator is deliberately made pending so the switch must | |||
| 1389 | + | // invalidate it rather than letting its stale callback render for ac. | |||
| 1390 | + | sourceCoordinator := sess.renderCoordinator() | |||
| 1391 | + | require.NotNil(t, sourceCoordinator) | |||
| 1392 | + | sourceCoordinator.invalidate(renderInvalidation{class: invalidateOutput}) | |||
| 1393 | + | ac.sendMu.Lock() | |||
| 1394 | + | ac.output.next = 3 | |||
| 1395 | + | ac.output.acked = 1 | |||
| 1396 | + | ac.sendMu.Unlock() | |||
| 1397 | + | ||||
| 1357 | require.NoError(t, d.createSessionAndSwitch(sess, ac, "next")) | 1398 | require.NoError(t, d.createSessionAndSwitch(sess, ac, "next")) | ||
| 1358 | got := ac.sess.Get() | 1399 | got := ac.sess.Get() | ||
| 1359 | require.NotNil(t, got) | 1400 | require.NotNil(t, got) | ||
| 1401 | + | sourceCoordinator.mu.Lock() | |||
| 1402 | + | require.NotNil(t, sourceCoordinator.lease) | |||
| 1403 | + | require.False(t, sourceCoordinator.lease.active, "source callbacks must be stale after handoff") | |||
| 1404 | + | sourceCoordinator.mu.Unlock() | |||
| 1405 | + | destinationCoordinator := got.renderCoordinator() | |||
| 1406 | + | require.NotNil(t, destinationCoordinator, "destination coordinator precedes first paint") | |||
| 1407 | + | destinationCoordinator.mu.Lock() | |||
| 1408 | + | require.Same(t, ac, destinationCoordinator.lease.attachment) | |||
| 1409 | + | destinationCoordinator.mu.Unlock() | |||
| 1410 | + | ac.sendMu.Lock() | |||
| 1411 | + | require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase") | |||
| 1412 | + | ac.sendMu.Unlock() | |||
| 1360 | got.mu.Lock() | 1413 | got.mu.Lock() | ||
| 1361 | require.True(t, got.terminal.TrueColor) | 1414 | require.True(t, got.terminal.TrueColor) | ||
| 1362 | got.mu.Unlock() | 1415 | got.mu.Unlock() | ||
M internal/usecase/daemon/snapshot.go +455 -79
| @@ -4,17 +4,58 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "math" | 6 | "math" | ||
| 7 | + | "sync" | |||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| 9 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 10 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 11 | "github.com/bnema/vev/internal/usecase/layout" | 11 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 12 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 12 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 13 | "github.com/bnema/vev/pkg/renderer" | 13 | "github.com/bnema/vev/pkg/renderer" | ||
| 14 | "github.com/bnema/vev/pkg/vt" | 14 | "github.com/bnema/vev/pkg/vt" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | - | const snapshotInterval = 30 * time.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 | func (d *Daemon) closeRestoreDone() { | 60 | func (d *Daemon) closeRestoreDone() { | ||
| 20 | d.restoreOnce.Do(func() { close(d.restoreDone) }) | 61 | d.restoreOnce.Do(func() { close(d.restoreDone) }) | ||
| @@ -51,6 +92,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 51 | if err := ctx.Err(); err != nil { | 92 | if err := ctx.Err(); err != nil { | ||
| 52 | return err | 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 | if snap.Name == "" { | 102 | if snap.Name == "" { | ||
| 55 | return fmt.Errorf("snapshot: empty session name") | 103 | return fmt.Errorf("snapshot: empty session name") | ||
| 56 | } | 104 | } | ||
| @@ -141,7 +189,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 141 | return fmt.Errorf("snapshot: generating pane identity: %w", err) | 189 | return fmt.Errorf("snapshot: generating pane identity: %w", err) | ||
| 142 | } | 190 | } | ||
| 143 | } | 191 | } | ||
| 144 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize) | 192 | + | pty, err := d.ptys.Open(ctx, d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize) |
| 145 | if err != nil { | 193 | if err != nil { | ||
| 146 | closeOpened() | 194 | closeOpened() | ||
| 147 | return err | 195 | return err | ||
| @@ -149,7 +197,13 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 149 | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize) | 197 | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize) | ||
| 150 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | 198 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | ||
| 151 | p.rect = contentRect | 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 | if restoreCommand != "" { | 207 | if restoreCommand != "" { | ||
| 154 | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | 208 | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | ||
| 155 | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | 209 | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | ||
| @@ -164,9 +218,6 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 164 | } | 218 | } | ||
| 165 | createdAt := int64(snap.CreatedAt) | 219 | createdAt := int64(snap.CreatedAt) | ||
| 166 | sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt} | 220 | sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt} | ||
| 167 | - | if sess.active < 0 || sess.active >= len(sess.tabs) { | |||
| 168 | - | sess.active = 0 | |||
| 169 | - | } | |||
| 170 | sess.snapEligible.Store(true) | 221 | sess.snapEligible.Store(true) | ||
| 171 | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | 222 | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | ||
| 172 | sess.cwd = snap.Tabs[0].Panes[0].Cwd | 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 | func markSnapshotDirty(sess *session) { | 254 | func markSnapshotDirty(sess *session) { | ||
| 204 | - | if sess == nil { | 255 | + | if sess == nil || !sess.snapEligible.Load() { |
| 205 | return | 256 | return | ||
| 206 | } | 257 | } | ||
| 207 | - | if sess.snapEligible.Load() { | 258 | + | sess.snapshotMu.Lock() |
| 208 | - | sess.snapDirty.Store(true) | 259 | + | sess.snapshotGeneration++ |
| 260 | + | sess.snapDirty.Store(true) | |||
| 261 | + | sess.signalSnapshotChangedLocked() | |||
| 262 | + | sess.snapshotMu.Unlock() | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | // snapshotChangeLocked returns a channel closed by the next snapshot state | |||
| 266 | + | // transition. snapshotMu must be held. | |||
| 267 | + | func (sess *session) snapshotChangeLocked() chan struct{} { | |||
| 268 | + | if sess.snapshotChanged == nil { | |||
| 269 | + | sess.snapshotChanged = make(chan struct{}) | |||
| 209 | } | 270 | } | ||
| 271 | + | return sess.snapshotChanged | |||
| 210 | } | 272 | } | ||
| 211 | 273 | ||||
| 212 | - | func seedPaneRows(p *pane, scrollback, visible [][]renderer.Cell) { | 274 | + | // signalSnapshotChangedLocked wakes waiters after a snapshot state transition. |
| 275 | + | // snapshotMu must be held. | |||
| 276 | + | func (sess *session) signalSnapshotChangedLocked() { | |||
| 277 | + | close(sess.snapshotChangeLocked()) | |||
| 278 | + | sess.snapshotChanged = make(chan struct{}) | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | func restorePaneTerminal(p *pane, snap snapcodec.Pane) error { | |||
| 282 | + | if len(snap.Tail) == 0 { | |||
| 283 | + | return fmt.Errorf("snapshot history: missing tail blob") | |||
| 284 | + | } | |||
| 285 | + | if len(snap.Visible) == 0 { | |||
| 286 | + | return fmt.Errorf("snapshot visible: missing visible blob") | |||
| 287 | + | } | |||
| 213 | p.mu.Lock() | 288 | p.mu.Lock() | ||
| 214 | defer p.mu.Unlock() | 289 | defer p.mu.Unlock() | ||
| 215 | - | p.scrollback = scopy.NewScrollback(defaultScrollbackRows) | 290 | + | screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail) |
| 216 | - | for _, row := range scrollback { | 291 | + | if err != nil { |
| 217 | - | p.scrollback.Append(row) | 292 | + | return fmt.Errorf("snapshot history: %w", err) |
| 218 | - | } | 293 | + | } |
| 219 | - | if len(visible) > 0 { | 294 | + | if err := screen.RestorePrimaryVisible(snap.Visible); err != nil { |
| 220 | - | p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height) | 295 | + | return fmt.Errorf("snapshot visible: %w", err) |
| 221 | - | for y, row := range visible { | |||
| 222 | - | if y >= p.screen.Frame.Height { | |||
| 223 | - | break | |||
| 224 | - | } | |||
| 225 | - | for x, cell := range row { | |||
| 226 | - | if x >= p.screen.Frame.Width { | |||
| 227 | - | break | |||
| 228 | - | } | |||
| 229 | - | p.screen.Frame.Set(x, y, cell) | |||
| 230 | - | } | |||
| 231 | - | } | |||
| 232 | } | 296 | } | ||
| 233 | - | p.screen.OnLineEvicted = p.scrollback.Append | 297 | + | p.screen = screen |
| 298 | + | p.history = screen.History() | |||
| 299 | + | return nil | |||
| 234 | } | 300 | } | ||
| 235 | 301 | ||||
| 236 | func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size { | 302 | func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size { | ||
| @@ -251,35 +317,135 @@ func restorePTYSize(contentRect domain.Rect, tabSize domain.Size) domain.Size { | |||||
| 251 | return domain.Size{Cols: cols, Rows: rows} | 317 | return domain.Size{Cols: cols, Rows: rows} | ||
| 252 | } | 318 | } | ||
| 253 | 319 | ||||
| 254 | - | func (d *Daemon) captureSession(sess *session) bool { | 320 | + | // scheduleSnapshot captures a session only once while an immutable capture is |
| 255 | - | if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil { | 321 | + | // queued or being written. It never waits for the bounded worker queue. |
| 322 | + | func (d *Daemon) scheduleSnapshot(sess *session) bool { | |||
| 323 | + | return d.scheduleSnapshotWithFinalFallback(sess, false) | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | // scheduleFinalSnapshot captures the terminal state even when an older capture | |||
| 327 | + | // is pending. A removed session cannot be retried by snapshotSaver, so the | |||
| 328 | + | // worker drains its retained terminal state before it stops. | |||
| 329 | + | func (d *Daemon) scheduleFinalSnapshot(sess *session) bool { | |||
| 330 | + | return d.scheduleSnapshotWithFinalFallback(sess, true) | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool { | |||
| 334 | + | if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil || !sess.snapEligible.Load() { | |||
| 335 | + | return true | |||
| 336 | + | } | |||
| 337 | + | sess.snapshotMu.Lock() | |||
| 338 | + | if !sess.snapDirty.Load() || (!final && sess.snapshotPending) { | |||
| 339 | + | sess.snapshotMu.Unlock() | |||
| 256 | return true | 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 | sess.mu.Lock() | 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 | ephemeral := sess.ephemeral | 429 | ephemeral := sess.ephemeral | ||
| 262 | - | createdAt := sess.createdAt | |||
| 263 | - | active := sess.active | |||
| 264 | fallbackCwd := sess.cwd | 430 | fallbackCwd := sess.cwd | ||
| 265 | tabs := append([]*tab(nil), sess.tabs...) | 431 | tabs := append([]*tab(nil), sess.tabs...) | ||
| 266 | sess.mu.Unlock() | 432 | sess.mu.Unlock() | ||
| 267 | - | if ephemeral || name == "" { | 433 | + | if ephemeral || capture.name == "" { |
| 268 | - | return true | 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 | for _, tb := range tabs { | 438 | for _, tb := range tabs { | ||
| 273 | tb.mu.Lock() | 439 | tb.mu.Lock() | ||
| 274 | - | tabSnap := snapcodec.Tab{ | 440 | + | tabCapture := snapshotCaptureTab{ |
| 275 | - | StableID: tb.stableID, | 441 | + | stableID: tb.stableID, |
| 276 | - | Cols: uint16(max(tb.size.Cols, 0)), | 442 | + | cols: uint16(max(tb.size.Cols, 0)), |
| 277 | - | Rows: uint16(max(tb.size.Rows, 0)), | 443 | + | rows: uint16(max(tb.size.Rows, 0)), |
| 278 | - | NextPaneID: uint64(max(tb.nextPaneID, 0)), | 444 | + | nextPaneID: uint64(max(tb.nextPaneID, 0)), |
| 279 | - | Tree: tb.tree.Clone(), | 445 | + | tree: tb.tree.Clone(), |
| 280 | } | 446 | } | ||
| 281 | if tb.tree != nil { | 447 | if tb.tree != nil { | ||
| 282 | - | tabSnap.Focus = tb.tree.Focus | 448 | + | tabCapture.focus = tb.tree.Focus |
| 283 | } | 449 | } | ||
| 284 | panes := make([]*pane, 0, len(tb.panes)) | 450 | panes := make([]*pane, 0, len(tb.panes)) | ||
| 285 | for _, p := range tb.panes { | 451 | for _, p := range tb.panes { | ||
| @@ -287,51 +453,274 @@ func (d *Daemon) captureSession(sess *session) bool { | |||||
| 287 | } | 453 | } | ||
| 288 | tb.mu.Unlock() | 454 | tb.mu.Unlock() | ||
| 289 | 455 | ||||
| 290 | - | paneIDs := make(map[layout.PaneID]struct{}, len(panes)) | 456 | + | tabCapture.panes = make([]snapshotCapturePane, 0, len(panes)) |
| 291 | - | paneSnaps := make([]snapcodec.Pane, 0, len(panes)) | |||
| 292 | for _, p := range panes { | 457 | for _, p := range panes { | ||
| 293 | p.mu.Lock() | 458 | p.mu.Lock() | ||
| 294 | - | pid := 0 | |||
| 295 | pty := p.pty | 459 | pty := p.pty | ||
| 460 | + | pid := 0 | |||
| 296 | if pty != nil { | 461 | if pty != nil { | ||
| 297 | pid = pty.Pid() | 462 | pid = pty.Pid() | ||
| 298 | } | 463 | } | ||
| 299 | - | paneSnap := snapcodec.Pane{ | 464 | + | paneCapture := snapshotCapturePane{ |
| 300 | - | ID: p.id, | 465 | + | id: p.id, |
| 301 | - | StableID: p.stableID, | 466 | + | stableID: p.stableID, |
| 302 | - | Scrollback: cloneRows(p.scrollback.Snapshot()), | 467 | + | history: p.history.SealAndView(), |
| 303 | - | Visible: cloneRows(p.screen.PrimaryVisibleRows()), | 468 | + | visible: p.screen.PrimaryVisibleFrame(), |
| 304 | } | 469 | } | ||
| 305 | p.mu.Unlock() | 470 | p.mu.Unlock() | ||
| 306 | - | paneSnap.Cwd = fallbackCwd | 471 | + | paneCapture.cwd = fallbackCwd |
| 307 | if d.procCwd != nil && pid > 0 { | 472 | if d.procCwd != nil && pid > 0 { | ||
| 308 | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | 473 | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | ||
| 309 | - | paneSnap.Cwd = cwd | 474 | + | paneCapture.cwd = cwd |
| 310 | } | 475 | } | ||
| 311 | } | 476 | } | ||
| 312 | - | paneSnap.Process = d.capturePaneProcess(pty, pid) | 477 | + | paneCapture.process = d.capturePaneProcess(pty, pid) |
| 313 | - | paneIDs[paneSnap.ID] = struct{}{} | 478 | + | tabCapture.panes = append(tabCapture.panes, paneCapture) |
| 314 | - | paneSnaps = append(paneSnaps, paneSnap) | 479 | + | } |
| 480 | + | capture.tabs = append(capture.tabs, tabCapture) | |||
| 481 | + | } | |||
| 482 | + | return capture, true | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | // captureSession remains the synchronous producer-facing trigger for callers | |||
| 486 | + | // such as teardown and benchmarks; the actual encoding and Write stay async. | |||
| 487 | + | func (d *Daemon) captureSession(sess *session) bool { | |||
| 488 | + | markSnapshotDirty(sess) | |||
| 489 | + | return d.scheduleSnapshot(sess) | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error) { | |||
| 493 | + | snap := snapcodec.Session{Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.Tab, 0, len(capture.tabs))} | |||
| 494 | + | for _, tabCapture := range capture.tabs { | |||
| 495 | + | tabSnap := snapcodec.Tab{ | |||
| 496 | + | StableID: tabCapture.stableID, Cols: tabCapture.cols, Rows: tabCapture.rows, | |||
| 497 | + | NextPaneID: tabCapture.nextPaneID, Focus: tabCapture.focus, Tree: tabCapture.tree, | |||
| 498 | + | Panes: make([]snapcodec.Pane, 0, len(tabCapture.panes)), | |||
| 499 | + | } | |||
| 500 | + | paneIDs := make(map[layout.PaneID]struct{}, len(tabCapture.panes)) | |||
| 501 | + | for _, paneCapture := range tabCapture.panes { | |||
| 502 | + | sealed, tail, historyErr := vt.MarshalSealedHistory(paneCapture.history) | |||
| 503 | + | visible, visibleErr := vt.MarshalVisible(paneCapture.visible) | |||
| 504 | + | if historyErr != nil || visibleErr != nil { | |||
| 505 | + | return nil, fmt.Errorf("terminal snapshot pane %s: history=%w visible=%w", paneCapture.id, historyErr, visibleErr) | |||
| 506 | + | } | |||
| 507 | + | tabSnap.Panes = append(tabSnap.Panes, snapcodec.Pane{ID: paneCapture.id, StableID: paneCapture.stableID, Cwd: paneCapture.cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: paneCapture.process}) | |||
| 508 | + | paneIDs[paneCapture.id] = struct{}{} | |||
| 315 | } | 509 | } | ||
| 316 | if tabSnap.Tree != nil { | 510 | if tabSnap.Tree != nil { | ||
| 317 | pruneTreeToPanes(tabSnap.Tree.Root, paneIDs) | 511 | pruneTreeToPanes(tabSnap.Tree.Root, paneIDs) | ||
| 318 | } | 512 | } | ||
| 319 | - | tabSnap.Panes = paneSnaps | |||
| 320 | snap.Tabs = append(snap.Tabs, tabSnap) | 513 | snap.Tabs = append(snap.Tabs, tabSnap) | ||
| 321 | } | 514 | } | ||
| 515 | + | return d.snapshotMarshal(snap) | |||
| 516 | + | } | |||
| 322 | 517 | ||||
| 323 | - | data, err := snapcodec.Marshal(snap) | 518 | + | func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) { |
| 324 | - | if err != nil { | 519 | + | if capture == nil { |
| 325 | - | d.log.Warn("marshaling session snapshot failed", "err", err, "session", name) | 520 | + | return |
| 326 | - | return false | 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 { | 640 | + | return nil |
| 329 | - | d.log.Warn("writing session snapshot failed", "err", err, "session", name) | 641 | + | } |
| 642 | + | ||||
| 643 | + | func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool { | |||
| 644 | + | d.snapshotWorkerMu.Lock() | |||
| 645 | + | defer d.snapshotWorkerMu.Unlock() | |||
| 646 | + | if d.snapshotWorkerID != workerID || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil { | |||
| 330 | return false | 647 | return false | ||
| 331 | } | 648 | } | ||
| 649 | + | d.snapshotWorkerInFlight = capture | |||
| 332 | return true | 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 | func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process { | 724 | func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process { | ||
| 336 | if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil { | 725 | if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil { | ||
| 337 | return nil | 726 | return nil | ||
| @@ -368,27 +757,14 @@ func (d *Daemon) snapshotSaver(ctx context.Context) { | |||||
| 368 | sessions := d.sessionsSnapshotLocked() | 757 | sessions := d.sessionsSnapshotLocked() | ||
| 369 | d.mu.Unlock() | 758 | d.mu.Unlock() | ||
| 370 | for _, sess := range sessions { | 759 | for _, sess := range sessions { | ||
| 371 | - | if sess.snapEligible.Load() && sess.snapDirty.Swap(false) { | 760 | + | if sess.snapEligible.Load() && sess.snapDirty.Load() { |
| 372 | - | if !d.captureSession(sess) { | 761 | + | d.scheduleSnapshot(sess) |
| 373 | - | sess.snapDirty.Store(true) | |||
| 374 | - | } | |||
| 375 | } | 762 | } | ||
| 376 | } | 763 | } | ||
| 377 | t.Reset(snapshotInterval) | 764 | t.Reset(snapshotInterval) | ||
| 378 | } | 765 | } | ||
| 379 | } | 766 | } | ||
| 380 | 767 | ||||
| 381 | - | func cloneRows(rows [][]renderer.Cell) [][]renderer.Cell { | |||
| 382 | - | if len(rows) == 0 { | |||
| 383 | - | return nil | |||
| 384 | - | } | |||
| 385 | - | out := make([][]renderer.Cell, len(rows)) | |||
| 386 | - | for i, row := range rows { | |||
| 387 | - | out[i] = append([]renderer.Cell(nil), row...) | |||
| 388 | - | } | |||
| 389 | - | return out | |||
| 390 | - | } | |||
| 391 | - | ||||
| 392 | func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool { | 768 | func pruneTreeToPanes(n *layout.Node, panes map[layout.PaneID]struct{}) bool { | ||
| 393 | if n == nil { | 769 | if n == nil { | ||
| 394 | return false | 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 | "github.com/bnema/vev/internal/usecase/layout" | 18 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 20 | "github.com/bnema/vev/pkg/renderer" | 20 | "github.com/bnema/vev/pkg/renderer" | ||
| 21 | + | "github.com/bnema/vev/pkg/vt" | |||
| 21 | ) | 22 | ) | ||
| 22 | 23 | ||||
| 24 | + | func terminalPane(t *testing.T, id, stableID, cwd string, historyRows, visibleRows [][]renderer.Cell) snapcodec.Pane { | |||
| 25 | + | t.Helper() | |||
| 26 | + | h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows}) | |||
| 27 | + | for _, row := range historyRows { | |||
| 28 | + | h.Append(row) | |||
| 29 | + | } | |||
| 30 | + | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | frame := renderer.NewFrame(40, 24) | |||
| 33 | + | for y, row := range visibleRows { | |||
| 34 | + | copy(frame.Row(y), row) | |||
| 35 | + | } | |||
| 36 | + | visible, err := vt.MarshalVisible(frame) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | return snapcodec.Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T) { | |||
| 42 | + | valid := terminalPane(t, "pane", "p", "/work", nil, nil) | |||
| 43 | + | tests := []struct { | |||
| 44 | + | name string | |||
| 45 | + | mutate func(*snapcodec.Pane) | |||
| 46 | + | }{ | |||
| 47 | + | {name: "missing tail", mutate: func(p *snapcodec.Pane) { p.Tail = nil }}, | |||
| 48 | + | {name: "missing visible", mutate: func(p *snapcodec.Pane) { p.Visible = nil }}, | |||
| 49 | + | {name: "malformed tail", mutate: func(p *snapcodec.Pane) { p.Tail = []byte("bad") }}, | |||
| 50 | + | {name: "malformed visible", mutate: func(p *snapcodec.Pane) { p.Visible = []byte("bad") }}, | |||
| 51 | + | } | |||
| 52 | + | for _, tt := range tests { | |||
| 53 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 54 | + | snap := valid | |||
| 55 | + | tt.mutate(&snap) | |||
| 56 | + | p := newPane("pane", newRestorePTY(), domain.Size{Cols: 40, Rows: 24}) | |||
| 57 | + | require.Error(t, restorePaneTerminal(p, snap)) | |||
| 58 | + | }) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestRestoreSessionRejectsInvalidActiveTab(t *testing.T) { | |||
| 63 | + | factory := &restorePTYFactory{} | |||
| 64 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 65 | + | ||||
| 66 | + | err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad-active", Active: 1, Tabs: []snapcodec.Tab{{ | |||
| 67 | + | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")}, | |||
| 68 | + | }}}) | |||
| 69 | + | ||||
| 70 | + | require.Error(t, err) | |||
| 71 | + | require.Empty(t, factory.opens) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) { | |||
| 75 | + | factory := &restorePTYFactory{} | |||
| 76 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 77 | + | pane := emptyTerminalPane(t, "pane-1", "/tmp") | |||
| 78 | + | pane.Tail = []byte("malformed") | |||
| 79 | + | ||||
| 80 | + | err := d.restoreSession(context.Background(), snapcodec.Session{Name: "bad", Tabs: []snapcodec.Tab{{ | |||
| 81 | + | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}, | |||
| 82 | + | }}}) | |||
| 83 | + | ||||
| 84 | + | require.Error(t, err) | |||
| 85 | + | require.Len(t, factory.opens, 1) | |||
| 86 | + | require.Equal(t, 1, factory.opens[0].pty.closeCount()) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 23 | func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | 89 | func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | ||
| 24 | store := &restoreSnapshotStore{} | 90 | store := &restoreSnapshotStore{} | ||
| 25 | store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | 91 | store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | ||
| @@ -37,8 +103,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 37 | layout.NewLeaf("pane-2"), | 103 | layout.NewLeaf("pane-2"), | ||
| 38 | }}}, | 104 | }}}, | ||
| 39 | Panes: []snapcodec.Pane{ | 105 | Panes: []snapcodec.Pane{ | ||
| 40 | - | {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}}, | 106 | + | terminalPane(t, "pane-1", "p_saved_1", "/one", [][]renderer.Cell{cells("old1")}, [][]renderer.Cell{cells("vis1")}), |
| 41 | - | {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}}, | 107 | + | terminalPane(t, "pane-2", "p_saved_2", "/two", [][]renderer.Cell{cells("old2")}, [][]renderer.Cell{cells("vis2")}), |
| 42 | }, | 108 | }, | ||
| 43 | }}, | 109 | }}, | ||
| 44 | })}} | 110 | })}} | ||
| @@ -77,10 +143,11 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 77 | p := tb.panes["pane-2"] | 143 | p := tb.panes["pane-2"] | ||
| 78 | tb.mu.Unlock() | 144 | tb.mu.Unlock() | ||
| 79 | p.mu.Lock() | 145 | p.mu.Lock() | ||
| 80 | - | require.Equal(t, "old2", rowText(p.scrollback.View().Row(0))) | 146 | + | require.Same(t, p.screen.History(), p.history) |
| 81 | - | require.Equal(t, 1, p.scrollback.Len()) | 147 | + | require.Equal(t, "old2", rowText(p.history.View().Row(0))) |
| 148 | + | require.Equal(t, 1, p.history.Len()) | |||
| 82 | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | 149 | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | ||
| 83 | - | copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | 150 | + | copySnap := scopy.NewSnapshot(p.history, p.screen.Frame) |
| 84 | require.Equal(t, "old2", rowText(copySnap.Row(0)[:4])) | 151 | require.Equal(t, "old2", rowText(copySnap.Row(0)[:4])) | ||
| 85 | require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4])) | 152 | require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4])) | ||
| 86 | p.mu.Unlock() | 153 | p.mu.Unlock() | ||
| @@ -108,8 +175,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 108 | func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) { | 175 | func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) { | ||
| 109 | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | 176 | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | ||
| 110 | Name: "work", Active: 0, Tabs: []snapcodec.Tab{ | 177 | Name: "work", Active: 0, Tabs: []snapcodec.Tab{ | ||
| 111 | - | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/one"}}}, | 178 | + | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/one")}}, |
| 112 | - | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/two"}}}, | 179 | + | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}}, |
| 113 | }, | 180 | }, | ||
| 114 | })}}} | 181 | })}}} | ||
| 115 | factory := &restorePTYFactory{} | 182 | factory := &restorePTYFactory{} | ||
| @@ -138,17 +205,15 @@ func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testi | |||||
| 138 | 205 | ||||
| 139 | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | 206 | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | ||
| 140 | t.Helper() | 207 | t.Helper() | ||
| 208 | + | pane := emptyTerminalPane(t, "pane-1", "/tmp") | |||
| 209 | + | pane.Process = proc | |||
| 141 | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | 210 | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | ||
| 142 | Name: name, | 211 | Name: name, | ||
| 143 | Tabs: []snapcodec.Tab{{ | 212 | Tabs: []snapcodec.Tab{{ | ||
| 144 | - | Cols: 80, | 213 | + | Cols: 80, |
| 145 | - | Rows: 24, | 214 | + | Rows: 24, |
| 146 | - | Tree: layout.NewTree("pane-1"), | 215 | + | Tree: layout.NewTree("pane-1"), |
| 147 | - | Panes: []snapcodec.Pane{{ | 216 | + | Panes: []snapcodec.Pane{pane}, |
| 148 | - | ID: "pane-1", | |||
| 149 | - | Cwd: "/tmp", | |||
| 150 | - | Process: proc, | |||
| 151 | - | }}, | |||
| 152 | }}, | 217 | }}, | ||
| 153 | })}}} | 218 | })}}} | ||
| 154 | } | 219 | } | ||
| @@ -185,20 +250,17 @@ func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) { | |||||
| 185 | Cols: 80, | 250 | Cols: 80, | ||
| 186 | Rows: 24, | 251 | Rows: 24, | ||
| 187 | Tree: layout.NewTree("pane-1"), | 252 | Tree: layout.NewTree("pane-1"), | ||
| 188 | - | Panes: []snapcodec.Pane{{ | 253 | + | Panes: func() []snapcodec.Pane { |
| 189 | - | ID: "pane-1", | 254 | + | pane := emptyTerminalPane(t, "pane-1", "/one") |
| 190 | - | Cwd: "/one", | 255 | + | pane.Process = lessProc |
| 191 | - | Process: lessProc, | 256 | + | return []snapcodec.Pane{pane} |
| 192 | - | }}, | 257 | + | }(), |
| 193 | }, | 258 | }, | ||
| 194 | { | 259 | { | ||
| 195 | - | Cols: 80, | 260 | + | Cols: 80, |
| 196 | - | Rows: 24, | 261 | + | Rows: 24, |
| 197 | - | Tree: layout.NewTree("pane-1"), | 262 | + | Tree: layout.NewTree("pane-1"), |
| 198 | - | Panes: []snapcodec.Pane{{ | 263 | + | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}, |
| 199 | - | ID: "pane-1", | |||
| 200 | - | Cwd: "/two", | |||
| 201 | - | }}, | |||
| 202 | }, | 264 | }, | ||
| 203 | }, | 265 | }, | ||
| 204 | })}}}, | 266 | })}}}, | ||
| @@ -246,11 +308,26 @@ func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) { | |||||
| 246 | d := newTestDaemon(t, factory, stubClock{}) | 308 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 247 | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | 309 | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | ||
| 248 | 310 | ||||
| 249 | - | require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp", Process: proc}}}}})) | 311 | + | pane := emptyTerminalPane(t, "pane-1", "/tmp") |
| 312 | + | pane.Process = proc | |||
| 313 | + | require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}}}})) | |||
| 250 | require.Len(t, factory.opens, 1) | 314 | require.Len(t, factory.opens, 1) | ||
| 251 | require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes) | 315 | require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes) | ||
| 252 | } | 316 | } | ||
| 253 | 317 | ||||
| 318 | + | func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) { | |||
| 319 | + | factory := &restorePTYFactory{} | |||
| 320 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 321 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 322 | + | defer cancel() | |||
| 323 | + | ||||
| 324 | + | require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{ | |||
| 325 | + | Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")}, | |||
| 326 | + | }}})) | |||
| 327 | + | require.Len(t, factory.opens, 1) | |||
| 328 | + | require.Same(t, ctx, factory.opens[0].ctx) | |||
| 329 | + | } | |||
| 330 | + | ||||
| 254 | func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) { | 331 | func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) { | ||
| 255 | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{ | 332 | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{ | ||
| 256 | Name: "stacked", | 333 | Name: "stacked", | ||
| @@ -260,7 +337,11 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) | |||||
| 260 | Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{ | 337 | Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{ | ||
| 261 | layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"), | 338 | layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"), | ||
| 262 | }}}, | 339 | }}}, | ||
| 263 | - | Panes: []snapcodec.Pane{{ID: "a", Cwd: "/a"}, {ID: "b", Cwd: "/b"}, {ID: "c", Cwd: "/c"}}, | 340 | + | Panes: []snapcodec.Pane{ |
| 341 | + | emptyTerminalPane(t, "a", "/a"), | |||
| 342 | + | emptyTerminalPane(t, "b", "/b"), | |||
| 343 | + | emptyTerminalPane(t, "c", "/c"), | |||
| 344 | + | }, | |||
| 264 | }}, | 345 | }}, | ||
| 265 | })}}} | 346 | })}}} | ||
| 266 | factory := &restorePTYFactory{} | 347 | factory := &restorePTYFactory{} | ||
| @@ -290,7 +371,16 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) | |||||
| 290 | } | 371 | } | ||
| 291 | 372 | ||||
| 292 | func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) { | 373 | func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) { | ||
| 293 | - | valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}}) | 374 | + | valid := mustSnapshotBytes(t, snapcodec.Session{ |
| 375 | + | Name: "live", | |||
| 376 | + | CreatedAt: 1, | |||
| 377 | + | Tabs: []snapcodec.Tab{{ | |||
| 378 | + | Cols: 80, | |||
| 379 | + | Rows: 24, | |||
| 380 | + | Tree: layout.NewTree("pane-1"), | |||
| 381 | + | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/ok")}, | |||
| 382 | + | }}, | |||
| 383 | + | }) | |||
| 294 | badVersion := append([]byte(nil), valid...) | 384 | badVersion := append([]byte(nil), valid...) | ||
| 295 | binary.BigEndian.PutUint16(badVersion[4:6], 1) | 385 | binary.BigEndian.PutUint16(badVersion[4:6], 1) | ||
| 296 | tests := []struct { | 386 | tests := []struct { | ||
| @@ -327,7 +417,7 @@ func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) { | |||||
| 327 | Cols: 80, | 417 | Cols: 80, | ||
| 328 | Rows: 24, | 418 | Rows: 24, | ||
| 329 | Tree: layout.NewTree("pane-1"), | 419 | Tree: layout.NewTree("pane-1"), | ||
| 330 | - | Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/bad"}}, | 420 | + | Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/bad")}, |
| 331 | }}, | 421 | }}, | ||
| 332 | })}}} | 422 | })}}} | ||
| 333 | factory := &restorePTYFactory{} | 423 | factory := &restorePTYFactory{} | ||
| @@ -353,7 +443,15 @@ func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) { | |||||
| 353 | loadFn: func() ([]ports.SnapshotBlob, error) { | 443 | loadFn: func() ([]ports.SnapshotBlob, error) { | ||
| 354 | close(loaded) | 444 | close(loaded) | ||
| 355 | <-releaseLoad | 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 | factory := &restorePTYFactory{} | 457 | factory := &restorePTYFactory{} | ||
| @@ -402,6 +500,11 @@ func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) { | |||||
| 402 | require.Equal(t, ports.ErrServerShutdown, perr.code) | 500 | require.Equal(t, ports.ErrServerShutdown, perr.code) | ||
| 403 | } | 501 | } | ||
| 404 | 502 | ||||
| 503 | + | func emptyTerminalPane(t *testing.T, id, cwd string) snapcodec.Pane { | |||
| 504 | + | t.Helper() | |||
| 505 | + | return terminalPane(t, id, "", cwd, nil, nil) | |||
| 506 | + | } | |||
| 507 | + | ||||
| 405 | func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte { | 508 | func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte { | ||
| 406 | t.Helper() | 509 | t.Helper() | ||
| 407 | b, err := snapcodec.Marshal(s) | 510 | b, err := snapcodec.Marshal(s) | ||
| @@ -438,24 +541,26 @@ type restorePTYFactory struct { | |||||
| 438 | } | 541 | } | ||
| 439 | 542 | ||||
| 440 | type restorePTYOpen struct { | 543 | type restorePTYOpen struct { | ||
| 544 | + | ctx context.Context | |||
| 441 | dir string | 545 | dir string | ||
| 442 | size domain.Size | 546 | size domain.Size | ||
| 443 | env []string | 547 | env []string | ||
| 444 | pty *restorePTY | 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 | f.mu.Lock() | 552 | f.mu.Lock() | ||
| 449 | defer f.mu.Unlock() | 553 | defer f.mu.Unlock() | ||
| 450 | pty := newRestorePTY() | 554 | pty := newRestorePTY() | ||
| 451 | pty.writeErr = f.writeErr | 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 | return pty, nil | 557 | return pty, nil | ||
| 454 | } | 558 | } | ||
| 455 | 559 | ||||
| 456 | type restorePTY struct { | 560 | type restorePTY struct { | ||
| 457 | mu sync.Mutex | 561 | mu sync.Mutex | ||
| 458 | writes []string | 562 | writes []string | ||
| 563 | + | closes int | |||
| 459 | writeErr error | 564 | writeErr error | ||
| 460 | done chan struct{} | 565 | done chan struct{} | ||
| 461 | once sync.Once | 566 | once sync.Once | ||
| @@ -475,7 +580,18 @@ func (p *restorePTY) Write(b []byte) (int, error) { | |||||
| 475 | } | 580 | } | ||
| 476 | return len(b), nil | 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 | func (p *restorePTY) Resize(domain.Size) error { return nil } | 595 | func (p *restorePTY) Resize(domain.Size) error { return nil } | ||
| 480 | func (p *restorePTY) Pid() int { return 0 } | 596 | func (p *restorePTY) Pid() int { return 0 } | ||
| 481 | func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil } | 597 | func (p *restorePTY) ForegroundPgid() (int, error) { return 0, nil } | ||
M internal/usecase/daemon/snapshot_test.go +730 -8 collapsed to keep the page light
A internal/usecase/daemon/stalled_attachment_test.go +78 -0 collapsed to keep the page light
M internal/usecase/daemon/status.go +1 -24 collapsed to keep the page light
M internal/usecase/daemon/status_test.go +72 -20 collapsed to keep the page light
A internal/usecase/daemon/test_wait_helpers_test.go +55 -0 collapsed to keep the page light
M internal/usecase/daemon/theme_resolve.go +0 -1
| @@ -28,7 +28,6 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme | |||||
| 28 | 28 | ||||
| 29 | if ac != nil { | 29 | if ac != nil { | ||
| 30 | ac.setTheme(t) | 30 | ac.setTheme(t) | ||
| 31 | - | ac.composed.invalidate() | |||
| 32 | } | 31 | } | ||
| 33 | for _, tb := range tabs { | 32 | for _, tb := range tabs { | ||
| 34 | tb.mu.Lock() | 33 | tb.mu.Lock() | ||
A internal/usecase/daemon/transactional_resize.go +292 -0 collapsed to keep the page light
A internal/usecase/daemon/transactional_resize_test.go +403 -0 collapsed to keep the page light
A internal/usecase/daemon/transport_observability_test.go +390 -0 collapsed to keep the page light
M internal/usecase/daemon/wire.go +1 -24 collapsed to keep the page light
A internal/usecase/snapshot/benchmark_test.go +122 -0 collapsed to keep the page light
M internal/usecase/snapshot/codec.go +84 -313 collapsed to keep the page light
M internal/usecase/snapshot/codec_test.go +390 -312 collapsed to keep the page light
A internal/usecase/snapshot/preflight.go +449 -0 collapsed to keep the page light
A internal/usecase/snapshot/preflight_acceptance_test.go +45 -0 collapsed to keep the page light
M internal/usecase/snapshot/snapshot.go +10 -12 collapsed to keep the page light
M internal/usecase/visualsearch/model_test.go +2 -2
| @@ -61,14 +61,14 @@ func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) { | |||||
| 61 | require.Equal(t, "alpha", matches[0].Text) | 61 | require.Equal(t, "alpha", matches[0].Text) | ||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | - | func TestVisualSearchCloneSharesDocumentAndKeepsUIStateIndependent(t *testing.T) { | 64 | + | func TestVisualSearchCloneRetainsDocumentAndKeepsUIStateIndependent(t *testing.T) { |
| 65 | m := New(testSnapshot("alpha", "beta alpha")) | 65 | m := New(testSnapshot("alpha", "beta alpha")) | ||
| 66 | for _, r := range "alpha" { | 66 | for _, r := range "alpha" { | ||
| 67 | m.Insert(r) | 67 | m.Insert(r) | ||
| 68 | } | 68 | } | ||
| 69 | clone := m.Clone() | 69 | clone := m.Clone() | ||
| 70 | 70 | ||||
| 71 | - | require.Same(t, &m.snapshot.Row(0)[0], &clone.snapshot.Row(0)[0]) | 71 | + | require.Equal(t, m.snapshot.Row(0), clone.snapshot.Row(0)) |
| 72 | clone.Down() | 72 | clone.Down() | ||
| 73 | clone.Insert('z') | 73 | clone.Insert('z') | ||
| 74 | 74 | ||||